From 8bc1583c989b9063263404144ed36e86e10ce39b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=95=E6=9F=8F=E9=9D=92?= Date: Thu, 4 Jun 2026 09:12:33 +0800 Subject: [PATCH] =?UTF-8?q?fix(polish):=20=E5=AF=B9=E8=AF=9D=E6=84=9F?= =?UTF-8?q?=E7=9F=A5=E4=B8=8A=E4=B8=8B=E6=96=87=E6=8C=89=E9=A3=8E=E6=A0=BC?= =?UTF-8?q?=E5=8C=85=E9=9A=94=E7=A6=BB=20+=20=E6=8E=92=E9=99=A4=E7=BF=BB?= =?UTF-8?q?=E8=AF=91=E4=BC=9A=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对话感知 polish 回看最近历史作为 LLM 上下文时,之前复用所有成功会话,导致两类串味: 1. 切换风格包后,旧风格包的历史仍被带进新提示词,把当前风格带偏; 2. 翻译会话的译文/指令污染下一段普通润色(如中文输入被带成英文)。 给 DictationSession 增加 style_pack_id / translation_active 两字段(serde default 向后兼容旧历史),抽出 eligible_polish_context_turns 过滤:仅复用同一 active 风格包、且非翻译的成功会话;旧历史无 style_pack_id 时保守排除。前后端类型同步,补两条单测。 --- openless-all/app/src-tauri/src/coordinator.rs | 2 + .../src-tauri/src/coordinator/dictation.rs | 91 ++++++++++++++++--- openless-all/app/src-tauri/src/types.rs | 7 ++ openless-all/app/src/lib/ipc.ts | 2 + openless-all/app/src/lib/types.ts | 2 + 5 files changed, 93 insertions(+), 11 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index b31cd1cf..135a3d3f 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -3566,6 +3566,8 @@ async fn end_qa_session(inner: &Arc) -> Result<(), String> { raw_transcript: question.clone(), final_text: answer.clone(), mode: PolishMode::Raw, + style_pack_id: None, + translation_active: false, app_bundle_id: None, app_name: front_app.clone(), insert_status: InsertStatus::CopiedFallback, diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 5251ad76..5723f0ca 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -1473,6 +1473,8 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { raw_transcript: raw.text.clone(), final_text: String::new(), mode: inner.prefs.get().default_mode, + style_pack_id: None, + translation_active: false, app_bundle_id: None, app_name: None, insert_status: InsertStatus::Failed, @@ -1565,8 +1567,8 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { working_languages ); // 对话感知 polish:拉最近 N 分钟的会话作为 LLM 上下文。仅在非翻译路径且非 Raw mode - // 才有意义(Raw 不走 LLM、翻译走单轮独立 prompt)。窗口=0 时 prior_turns 是空 Vec, - // polish 路径自动退化成单轮单消息——跟历史行为一致。 + // 才有意义(Raw 不走 LLM、翻译走单轮独立 prompt)。窗口=0 时 prior_turns 是空 Vec。 + // 只复用同一 active style pack 的历史;翻译会话永远不进入后续普通 polish 上下文。 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) @@ -1576,13 +1578,7 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { .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), Err(e) => { log::warn!("[coord] fetch polish context failed: {e}; fall back to single-turn"); Vec::new() @@ -1790,6 +1786,8 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { raw_transcript: raw.text.clone(), final_text: polished.clone(), mode, + style_pack_id: Some(pack.id.clone()), + translation_active, app_bundle_id: None, app_name: None, insert_status: status, @@ -1904,14 +1902,33 @@ fn append_typed_prefix(target: &mut String, delta: &str, typed_chars: usize) -> appended } +fn eligible_polish_context_turns( + sessions: Vec, + active_style_pack_id: &str, +) -> Vec<(String, String)> { + sessions + .into_iter() + // 只取实际成功润色过的会话作为上下文:失败的会话 final_text 是 raw 兜底, + // 喂回 LLM 会让模型以为"上一轮我什么都没做"——没意义且占 token。 + .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)) + // 翻译输出不参与后续普通润色上下文,避免英文翻译指令 / 译文污染下一次中文输入。 + .filter(|s| !s.translation_active) + .map(|s| (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 +1940,58 @@ mod tests { } } + fn history_session( + id: &str, + raw: &str, + final_text: &str, + style_pack_id: Option<&str>, + translation_active: bool, + ) -> 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, + } + } + + #[test] + fn polish_context_resets_when_active_style_pack_changes() { + let sessions = vec![ + history_session("new", "raw new", "final new", Some("pack.new"), false), + history_session("old", "raw old", "final old", Some("pack.old"), false), + ]; + + let turns = eligible_polish_context_turns(sessions, "pack.new"); + + assert_eq!( + turns, + vec![("raw new".to_string(), "final new".to_string())] + ); + } + + #[test] + fn polish_context_excludes_translation_sessions() { + let sessions = vec![ + history_session("translation", "你好", "Hello", Some("pack.new"), true), + history_session("dictation", "继续", "继续。", Some("pack.new"), false), + ]; + + let turns = eligible_polish_context_turns(sessions, "pack.new"); + + assert_eq!(turns, vec![("继续".to_string(), "继续。".to_string())]); + } + #[test] fn streamed_output_skips_postprocessing_mutations() { let rules = vec![correction_rule("Open AI", "OpenAI")]; diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index f64f054e..4b5277e3 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -92,6 +92,13 @@ pub struct DictationSession { pub raw_transcript: String, pub final_text: String, pub mode: PolishMode, + /// 本次 dictation 使用的风格包。旧历史没有此字段时为 None;对话感知 polish + /// 只复用同一风格包的历史,避免切换风格包后旧上下文污染新提示词。 + #[serde(default)] + pub style_pack_id: Option, + /// 本次是否走翻译路径。翻译结果不应再作为下一次普通润色的上下文。 + #[serde(default)] + pub translation_active: bool, pub app_bundle_id: Option, pub app_name: Option, pub insert_status: InsertStatus, diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index a146238c..a9d7abfc 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -464,6 +464,8 @@ const mockHistory: DictationSession[] = OL_DATA.history.map((h, i) => ({ rawTranscript: h.preview, finalText: h.preview, mode: "structured", + stylePackId: "builtin.structured", + translationActive: false, appBundleId: null, appName: "VS Code", insertStatus: "inserted", diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 4b8d08f1..7ca9d310 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -12,6 +12,8 @@ export interface DictationSession { rawTranscript: string; finalText: string; mode: PolishMode; + stylePackId: string | null; + translationActive: boolean; appBundleId: string | null; appName: string | null; insertStatus: InsertStatus;