Merge pull request #583 from Open-Less/fix/polish-context-isolation

fix(polish): 对话感知上下文——按风格包隔离 + 翻译会话参与润色(不串味)
This commit is contained in:
TRIP authored and GitHub committed 2026-06-04 13:08:09 +08:00
commit 85d6ed851f
5 files changed
+384 -52

No files matched your search

+188 -35
View File
@@ -1761,7 +1761,10 @@ fn sync_custom_dictation_to_plugin(inner: &Arc<Inner>) {
return;
}
match crate::linux_fcitx::set_custom_dictation_trigger(&key_string) {
Ok(()) => log::info!("[fcitx] Synced custom dictation trigger '{}' to plugin", key_string),
Ok(()) => log::info!(
"[fcitx] Synced custom dictation trigger '{}' to plugin",
key_string
),
Err(e) => log::warn!("[fcitx] Failed to sync custom dictation trigger: {e}"),
}
}
@@ -2877,36 +2880,8 @@ async fn polish_text(
.await?)
}
/// 翻译路径——和 polish 一样失败时返回原文 + 失败原因,避免"不丢字"约定被违反(CLAUDE.md)。
async fn translate_or_passthrough(
raw: &RawTranscript,
target_language: &str,
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
output_language_preference: OutputLanguagePreference,
llm_thinking_enabled: bool,
front_app: Option<&str>,
) -> (String, Option<String>) {
match translate_text(
&raw.text,
target_language,
working_languages,
chinese_script_preference,
output_language_preference,
llm_thinking_enabled,
front_app,
)
.await
{
Ok(s) => (s, None),
Err(e) => {
let reason = e.to_string();
log::error!("[coord] translate failed, falling back to raw: {reason}");
(raw.text.clone(), Some(reason))
}
}
}
/// 专用翻译(仅翻译、不润色、单轮)。现作为"润色+翻译"合成调用解析失败时的兜底——
/// 模型没按两段格式输出时,退回这里拿一段干净译文,而不是把畸形输出当译文插入。
async fn translate_text(
raw: &str,
target_language: &str,
@@ -2948,6 +2923,124 @@ async fn translate_text(
.await?)
}
/// "润色+翻译"单次调用的两段哨兵。模型按 `SRC\n源文\nTGT\n译文` 输出,解析器据此切分。
/// 这两个串必须与 build_polish_translate_system_prompt 写给模型的完全一致。
const POLISH_TRANSLATE_SRC_MARKER: &str = "[[OPENLESS_POLISHED_SOURCE]]";
const POLISH_TRANSLATE_TGT_MARKER: &str = "[[OPENLESS_TRANSLATION]]";
/// 合成"先润色源文、再翻译"的系统提示词:在原翻译 prompt 之上追加"额外输出润色后源文"
/// 与严格两段格式(覆盖原 prompt 末尾的"只输出译文")。译文仍是要插入用户光标的主产物,
/// 故完整保留原翻译规则;润色后的源文只作对话上下文用,轻量清理即可。
fn build_polish_translate_system_prompt(target_language: &str) -> String {
let base = crate::polish::prompts::translate_system_prompt(target_language);
format!(
"{base}\n\n\
# 额外输出:润色后的源文(仅用于对话上下文,不展示给用户)\n\
在译文之前,先把上面的原始转写**按它本来的语言**润色一遍:去掉口癖(嗯 / 那个 / um)、\
补必要标点、纠正明显的识别错误,但**不翻译、不改写风格、不增删意思**。\n\n\
# 输出格式(覆盖上面\u{201C}只输出译文\u{201D}的说明,严格遵守)\n\
严格按下面两段输出,两个标记必须原样出现、各占一行,标记之外不要有任何多余文字:\n\
{src}\n\
(这里放润色后的源文,保持原语言)\n\
{tgt}\n\
(这里放翻译成\u{300C}{lang}\u{300D}的译文)",
base = base,
src = POLISH_TRANSLATE_SRC_MARKER,
tgt = POLISH_TRANSLATE_TGT_MARKER,
lang = target_language,
)
}
/// 解析"润色+翻译"单次调用输出 → Some((润色后源文, 译文))。
/// 找到译文标记且译文非空 → Some((源文, 译文)):源文标记缺失 / 源文段为空时源文为 None,
/// 译文取标记之后的干净正文。**没有译文标记、或译文段为空(模型截断 / 只吐了标记)→ None**,
/// 表示没拿到可信译文,交由调用方退回专用翻译——避免把空串当"成功译文"插进光标而丢字。
fn split_polish_translate_output(raw: &str) -> Option<(Option<String>, String)> {
let tgt_idx = raw.find(POLISH_TRANSLATE_TGT_MARKER)?;
let translation = raw[tgt_idx + POLISH_TRANSLATE_TGT_MARKER.len()..]
.trim()
.to_string();
if translation.is_empty() {
return None;
}
let before_tgt = &raw[..tgt_idx];
let source = before_tgt
.find(POLISH_TRANSLATE_SRC_MARKER)
.map(|i| {
before_tgt[i + POLISH_TRANSLATE_SRC_MARKER.len()..]
.trim()
.to_string()
})
.filter(|s| !s.is_empty());
Some((source, translation))
}
/// 翻译路径——单次 LLM 调用同时润色源文 + 翻译。和 polish 一样失败时返回原文 + 失败原因,
/// 避免"不丢字"约定被违反(CLAUDE.md)。返回 (要插入的译文, 润色后源文供上下文用, 失败原因)。
#[allow(clippy::too_many_arguments)]
async fn polish_and_translate_or_passthrough(
raw: &RawTranscript,
target_language: &str,
mode: PolishMode,
hotwords: &[String],
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
output_language_preference: OutputLanguagePreference,
llm_thinking_enabled: bool,
front_app: Option<&str>,
prior_turns: &[(String, String)],
) -> (String, Option<String>, Option<String>) {
let system_prompt = build_polish_translate_system_prompt(target_language);
match polish_text(
&raw.text,
mode,
hotwords,
&system_prompt,
working_languages,
chinese_script_preference,
output_language_preference,
llm_thinking_enabled,
front_app,
prior_turns,
)
.await
{
Ok(out) => match split_polish_translate_output(&out) {
Some((source, translation)) => (translation, source, None),
None => {
// 模型没按两段格式输出:退回专用翻译拿一段干净译文,避免把畸形输出插进光标。
// 此时无可信源文,这条翻译历史不参与后续普通润色上下文。
log::warn!(
"[coord] polish+translate output missing markers; falling back to plain translate"
);
match translate_text(
&raw.text,
target_language,
working_languages,
chinese_script_preference,
output_language_preference,
llm_thinking_enabled,
front_app,
)
.await
{
Ok(translation) => (translation, None, None),
Err(e) => {
let reason = e.to_string();
log::error!("[coord] fallback translate failed, using raw: {reason}");
(raw.text.clone(), None, Some(reason))
}
}
}
},
Err(e) => {
let reason = e.to_string();
log::error!("[coord] polish+translate failed, falling back to raw: {reason}");
(raw.text.clone(), None, Some(reason))
}
}
}
fn read_whisper_credentials() -> (String, String, String) {
let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey)
.ok()
@@ -3567,6 +3660,9 @@ async fn end_qa_session(inner: &Arc<Inner>) -> Result<(), String> {
raw_transcript: question.clone(),
final_text: answer.clone(),
mode: PolishMode::Raw,
style_pack_id: None,
translation_active: false,
polish_source: None,
app_bundle_id: None,
app_name: front_app.clone(),
insert_status: InsertStatus::CopiedFallback,
@@ -3783,6 +3879,54 @@ mod tests {
Uuid::from_u128(n)
}
#[test]
fn split_polish_translate_parses_both_sections() {
let out = format!(
"{POLISH_TRANSLATE_SRC_MARKER}\n你好,世界。\n{POLISH_TRANSLATE_TGT_MARKER}\nHello, world."
);
let (source, translation) = split_polish_translate_output(&out).expect("both markers");
assert_eq!(source.as_deref(), Some("你好,世界。"));
assert_eq!(translation, "Hello, world.");
}
#[test]
fn split_polish_translate_no_translation_marker_returns_none_for_fallback() {
// 完全没有译文标记 → None,调用方据此退回专用翻译拿干净译文。
assert_eq!(split_polish_translate_output(" Hello, world. "), None);
}
#[test]
fn split_polish_translate_empty_translation_returns_none_for_fallback() {
// 有译文标记但内容为空(截断 / 只吐标记)→ None,避免空串当成功译文插入光标。
let out =
format!("{POLISH_TRANSLATE_SRC_MARKER}\n你好。\n{POLISH_TRANSLATE_TGT_MARKER}\n ");
assert_eq!(split_polish_translate_output(&out), None);
}
#[test]
fn split_polish_translate_only_translation_marker_keeps_clean_translation() {
let out = format!("noise{POLISH_TRANSLATE_TGT_MARKER}\nHola");
let (source, translation) = split_polish_translate_output(&out).expect("tgt marker");
assert_eq!(source, None);
assert_eq!(translation, "Hola");
}
#[test]
fn split_polish_translate_empty_source_section_is_none() {
let out = format!("{POLISH_TRANSLATE_SRC_MARKER}\n \n{POLISH_TRANSLATE_TGT_MARKER}\nHi");
let (source, translation) = split_polish_translate_output(&out).expect("tgt marker");
assert_eq!(source, None);
assert_eq!(translation, "Hi");
}
#[test]
fn build_polish_translate_prompt_contains_markers_and_target() {
let p = build_polish_translate_system_prompt("日本語");
assert!(p.contains(POLISH_TRANSLATE_SRC_MARKER));
assert!(p.contains(POLISH_TRANSLATE_TGT_MARKER));
assert!(p.contains("日本語"));
}
#[tokio::test]
async fn hotkey_injection_gate_logs_pressed_and_cancels() {
let _ = env_logger::builder()
@@ -5013,7 +5157,9 @@ fn emit_capsule(
std::thread::spawn(move || {
let current = LAST_AUX.lock().unwrap().clone();
if current.as_deref() != Some(&text) {
log::info!("[capsule] set_aux_down skipped: state changed to {current:?}");
log::info!(
"[capsule] set_aux_down skipped: state changed to {current:?}"
);
return;
}
if let Err(e) = crate::linux_fcitx::set_aux_down(&text) {
@@ -5021,7 +5167,10 @@ fn emit_capsule(
}
});
// 终态(Done/Cancelled/Error)3 秒后自动清除,避免一直跟随焦点。
if matches!(state, CapsuleState::Done | CapsuleState::Cancelled | CapsuleState::Error) {
if matches!(
state,
CapsuleState::Done | CapsuleState::Cancelled | CapsuleState::Error
) {
let text = t.to_string();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_secs(3));
@@ -5044,12 +5193,16 @@ fn emit_capsule(
std::thread::spawn(move || {
let latest_gen = RETRY_GEN.load(std::sync::atomic::Ordering::SeqCst);
if latest_gen > gen + 1 {
log::info!("[capsule] clear_aux_down skipped: gen {gen}, latest {latest_gen}");
log::info!(
"[capsule] clear_aux_down skipped: gen {gen}, latest {latest_gen}"
);
return;
}
let current = LAST_AUX.lock().unwrap().clone();
if current.is_some() {
log::info!("[capsule] clear_aux_down skipped: state changed to {current:?}");
log::info!(
"[capsule] clear_aux_down skipped: state changed to {current:?}"
);
return;
}
if let Err(e) = crate::linux_fcitx::clear_aux_down() {
@@ -1473,6 +1473,9 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
raw_transcript: raw.text.clone(),
final_text: String::new(),
mode: inner.prefs.get().default_mode,
style_pack_id: None,
translation_active: false,
polish_source: None,
app_bundle_id: None,
app_name: None,
insert_status: InsertStatus::Failed,
@@ -1564,25 +1567,21 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
hotword_strs.len(),
working_languages
);
// 对话感知 polish:拉最近 N 分钟的会话作为 LLM 上下文。仅在非翻译路径且非 Raw mode
// 才有意义(Raw 不走 LLM、翻译走单轮独立 prompt)。窗口=0 时 prior_turns 是空 Vec,
// polish 路径自动退化成单轮单消息——跟历史行为一致。
// 对话感知 polish:拉最近 N 分钟的会话作为 LLM 上下文。翻译现在也走"润色+翻译"单次
// LLM 调用,所以翻译路径同样需要上下文;只有 Raw 且不走 LLM 才没意义。窗口=0 时为空 Vec。
// 只复用同一 active style pack 的历史;翻译历史按当前是否翻译决定喂译文还是润色后源文
// (见 eligible_polish_context_turns)。
let polish_context_window_minutes = prefs.polish_context_window_minutes;
let prior_turns: Vec<(String, String)> = if !translation_active
&& (mode != PolishMode::Raw || raw_uses_llm)
let prior_turns: Vec<(String, String)> = if (translation_active
|| mode != PolishMode::Raw
|| raw_uses_llm)
&& polish_context_window_minutes > 0
{
match inner
.history
.recent_within_minutes(polish_context_window_minutes)
{
Ok(sessions) => sessions
.into_iter()
// 只取实际成功润色过的会话作为上下文:失败的会话 final_text 是 raw 兜底,
// 喂回 LLM 会让模型以为"上一轮我什么都没做"——没意义且占 token。
.filter(|s| s.error_code.is_none() && !s.final_text.trim().is_empty())
.map(|s| (s.raw_transcript, s.final_text))
.collect(),
Ok(sessions) => eligible_polish_context_turns(sessions, &pack.id, translation_active),
Err(e) => {
log::warn!("[coord] fetch polish context failed: {e}; fall back to single-turn");
Vec::new()
@@ -1606,6 +1605,9 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
// Linux: emit_capsule(Polishing) 已通过 fcitx5 auxDown 显示 "✨ 润色中...",
// 无需在此重复调用。
// 翻译会话润色后的源语言文本(译文前的中间产物),仅翻译路径解析成功时有值,
// 写进 history 供后续普通润色轮复用(剔除译文、避免外语污染)。
let mut polish_source: Option<String> = None;
let (polished, polish_error, already_streamed) = if translation_active {
log::info!(
"[coord] translation mode → target=\u{300C}{}\u{300D} working={:?} front_app={:?}",
@@ -1613,16 +1615,20 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
working_languages,
front_app
);
let (p, e) = translate_or_passthrough(
let (p, src, e) = polish_and_translate_or_passthrough(
&raw,
&translation_target,
mode,
&hotword_strs,
&working_languages,
chinese_script_preference,
output_language_preference,
llm_thinking_enabled,
front_app.as_deref(),
&prior_turns,
)
.await;
polish_source = src;
(p, e, false)
} else if streaming_eligible {
run_streaming_polish(
@@ -1790,6 +1796,9 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
raw_transcript: raw.text.clone(),
final_text: polished.clone(),
mode,
style_pack_id: Some(pack.id.clone()),
translation_active,
polish_source,
app_bundle_id: None,
app_name: None,
insert_status: status,
@@ -1904,14 +1913,47 @@ fn append_typed_prefix(target: &mut String, delta: &str, typed_chars: usize) ->
appended
}
fn eligible_polish_context_turns(
sessions: Vec<DictationSession>,
active_style_pack_id: &str,
current_translation_active: bool,
) -> Vec<(String, String)> {
sessions
.into_iter()
// 只取实际成功润色过的会话作为上下文:失败的会话 final_text 是 raw 兜底,
// 喂回 LLM 会让模型以为"上一轮我什么都没做"——没意义且占 token。
// 这条同时保证下面 filter_map 里翻译历史的 final_text 一定是真译文(而非 passthrough
// 原文)——失败 / 兜底的翻译会话 error_code 非空,已在此被滤掉。
.filter(|s| s.error_code.is_none() && !s.final_text.trim().is_empty())
// 风格包切换 = 上下文边界。旧历史没有 style_pack_id,无法证明同源,保守排除。
.filter(|s| s.style_pack_id.as_deref() == Some(active_style_pack_id))
// 翻译历史按"下一轮是否也翻译"决定喂哪一段,既保留对话连续性又不让译文串味:
// - 当前是翻译轮 → 喂译文(final_text),保持目标语言一致;
// - 当前是普通轮 → 喂润色后的源文(polish_source),把译文剔除掉;源文缺失(解析
// 失败 / 旧历史)则整条跳过——宁可少一条上下文,也不让外语译文混进普通润色。
// - 普通历史无论当前轮是什么,都喂 final_text(本就是源语言润色结果)。
.filter_map(|s| {
if s.translation_active && !current_translation_active {
s.polish_source
.filter(|src| !src.trim().is_empty())
.map(|src| (s.raw_transcript, src))
} else {
Some((s.raw_transcript, s.final_text))
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::{
append_typed_prefix, batch_asr_chunk_limit_ms, default_done_message,
drain_streaming_insert_deltas_with, finalize_polished_text,
drain_streaming_insert_deltas_with, eligible_polish_context_turns, finalize_polished_text,
flush_streaming_insert_buffer_with, streaming_insert_eligible,
};
use crate::types::{ChineseScriptPreference, CorrectionRule, InsertStatus, PolishMode};
use crate::types::{
ChineseScriptPreference, CorrectionRule, DictationSession, InsertStatus, PolishMode,
};
fn correction_rule(pattern: &str, replacement: &str) -> CorrectionRule {
CorrectionRule {
@@ -1923,6 +1965,122 @@ mod tests {
}
}
#[allow(clippy::too_many_arguments)]
fn history_session(
id: &str,
raw: &str,
final_text: &str,
style_pack_id: Option<&str>,
translation_active: bool,
polish_source: Option<&str>,
) -> DictationSession {
DictationSession {
id: id.into(),
created_at: "2026-06-03T00:00:00Z".into(),
raw_transcript: raw.into(),
final_text: final_text.into(),
mode: PolishMode::Structured,
app_bundle_id: None,
app_name: None,
insert_status: InsertStatus::Inserted,
error_code: None,
duration_ms: Some(1000),
dictionary_entry_count: None,
has_audio_recording: None,
style_pack_id: style_pack_id.map(str::to_string),
translation_active,
polish_source: polish_source.map(str::to_string),
}
}
#[test]
fn polish_context_resets_when_active_style_pack_changes() {
let sessions = vec![
history_session("new", "raw new", "final new", Some("pack.new"), false, None),
history_session("old", "raw old", "final old", Some("pack.old"), false, None),
];
let turns = eligible_polish_context_turns(sessions, "pack.new", false);
assert_eq!(
turns,
vec![("raw new".to_string(), "final new".to_string())]
);
}
#[test]
fn normal_turn_uses_polished_source_of_translation_history_not_the_translation() {
// 当前是普通润色轮:翻译历史喂"润色后的源文",把译文剔除,避免外语污染。
let sessions = vec![
history_session(
"translation",
"你好",
"Hello",
Some("pack.new"),
true,
Some("你好。"),
),
history_session("dictation", "继续", "继续。", Some("pack.new"), false, None),
];
let turns = eligible_polish_context_turns(sessions, "pack.new", false);
assert_eq!(
turns,
vec![
("你好".to_string(), "你好。".to_string()),
("继续".to_string(), "继续。".to_string()),
]
);
}
#[test]
fn normal_turn_skips_translation_history_without_polished_source() {
// 译文历史没有 polish_source(解析失败 / 旧历史)→ 普通轮整条跳过,宁缺毋滥。
let sessions = vec![
history_session("translation", "你好", "Hello", Some("pack.new"), true, None),
history_session("dictation", "继续", "继续。", Some("pack.new"), false, None),
];
let turns = eligible_polish_context_turns(sessions, "pack.new", false);
assert_eq!(turns, vec![("继续".to_string(), "继续。".to_string())]);
}
#[test]
fn translation_turn_keeps_translation_text_of_translation_history() {
// 当前还是翻译轮:翻译历史喂译文(final_text),保持目标语言一致。
let sessions = vec![history_session(
"translation",
"你好",
"Hello",
Some("pack.new"),
true,
Some("你好。"),
)];
let turns = eligible_polish_context_turns(sessions, "pack.new", true);
assert_eq!(turns, vec![("你好".to_string(), "Hello".to_string())]);
}
#[test]
fn translation_turn_uses_normal_history_final_text() {
// 当前是翻译轮,普通历史照常喂 final_text(本就是源语言润色结果,不需要剔除)。
let sessions = vec![history_session(
"dictation",
"继续",
"继续。",
Some("pack.new"),
false,
None,
)];
let turns = eligible_polish_context_turns(sessions, "pack.new", true);
assert_eq!(turns, vec![("继续".to_string(), "继续。".to_string())]);
}
#[test]
fn streamed_output_skips_postprocessing_mutations() {
let rules = vec![correction_rule("Open AI", "OpenAI")];
+17 -2
View File
@@ -93,6 +93,19 @@ pub struct DictationSession {
pub raw_transcript: String,
pub final_text: String,
pub mode: PolishMode,
/// 本次 dictation 使用的风格包。旧历史没有此字段时为 None;对话感知 polish
/// 只复用同一风格包的历史,避免切换风格包后旧上下文污染新提示词。
#[serde(default)]
pub style_pack_id: Option<String>,
/// 本次是否走翻译路径。决定对话感知上下文怎么复用这条历史:下一轮也是翻译时喂
/// `final_text`(译文)保持一致;下一轮是普通润色时改喂 `polish_source`(润色后的源文)
/// 以剔除译文、避免外语污染。
#[serde(default)]
pub translation_active: bool,
/// 翻译会话润色后的**源语言**文本(译文前的润色中间产物)。普通会话、解析失败或旧
/// 历史为 None。仅用于对话感知上下文:普通润色轮复用翻译历史时喂这一段而非译文。
#[serde(default)]
pub polish_source: Option<String>,
pub app_bundle_id: Option<String>,
pub app_name: Option<String>,
pub insert_status: InsertStatus,
@@ -1563,14 +1576,16 @@ pub fn default_style_system_prompt_for_mode(mode: PolishMode) -> String {
// 到这里只剩 Raw 一种模式(Light / Structured / Formal 都在上面 early-return 了)。
// 仍用 match 把 _ 兜底为 unreachable!(),让编译期挡住未来加新 mode 时忘了在上面分流。
let task_and_example = match mode {
PolishMode::Raw => "# 任务(原文)\n\
PolishMode::Raw => {
"# 任务(原文)\n\
仅做最小化整理:补全标点、必要分句。\n\
保留原话顺序、用词、语气;\u{4E0D}改写、\u{4E0D}扩写、\u{4E0D}重排。\n\
可去除明显口癖(\u{55EF}、\u{554A}、那个、就是、you know),但\u{4E0D}改变信息密度。\n\
\n\
# 示例\n\
原:\u{55EF}那个我刚刚跟客户聊完然后他说下周三可以给反馈\n\
出:我刚刚跟客户聊完,他说下周三可以给反馈。",
出:我刚刚跟客户聊完,他说下周三可以给反馈。"
}
PolishMode::Light | PolishMode::Structured | PolishMode::Formal => {
unreachable!("light/structured/formal handled by early return above")
+3
View File
@@ -464,6 +464,9 @@ const mockHistory: DictationSession[] = OL_DATA.history.map((h, i) => ({
rawTranscript: h.preview,
finalText: h.preview,
mode: "structured",
stylePackId: "builtin.structured",
translationActive: false,
polishSource: null,
appBundleId: null,
appName: "VS Code",
insertStatus: "inserted",
+3
View File
@@ -12,6 +12,9 @@ export interface DictationSession {
rawTranscript: string;
finalText: string;
mode: PolishMode;
stylePackId: string | null;
translationActive: boolean;
polishSource: string | null;
appBundleId: string | null;
appName: string | null;
insertStatus: InsertStatus;