fix(polish): 对话感知上下文按风格包隔离 + 排除翻译会话

对话感知 polish 回看最近历史作为 LLM 上下文时,之前复用所有成功会话,导致两类串味:

1. 切换风格包后,旧风格包的历史仍被带进新提示词,把当前风格带偏;
2. 翻译会话的译文/指令污染下一段普通润色(如中文输入被带成英文)。

给 DictationSession 增加 style_pack_id / translation_active 两字段(serde default 向后兼容旧历史),抽出 eligible_polish_context_turns 过滤:仅复用同一 active 风格包、且非翻译的成功会话;旧历史无 style_pack_id 时保守排除。前后端类型同步,补两条单测。
This commit is contained in:
吕柏青 committed 2026-06-04 09:12:33 +08:00
1 parent 08ad6b7979
commit 8bc1583c98
5 files changed
+93 -11

No files matched your search

@@ -3566,6 +3566,8 @@ 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,
app_bundle_id: None,
app_name: front_app.clone(),
insert_status: InsertStatus::CopiedFallback,
@@ -1473,6 +1473,8 @@ 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,
app_bundle_id: None,
app_name: None,
insert_status: InsertStatus::Failed,
@@ -1565,8 +1567,8 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> 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<Inner>) -> 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<Inner>) -> 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<DictationSession>,
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")];
+7
View File
@@ -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<String>,
/// 本次是否走翻译路径。翻译结果不应再作为下一次普通润色的上下文。
#[serde(default)]
pub translation_active: bool,
pub app_bundle_id: Option<String>,
pub app_name: Option<String>,
pub insert_status: InsertStatus,
+2
View File
@@ -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",
+2
View File
@@ -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;