Merge pull request #583 from Open-Less/fix/polish-context-isolation
fix(polish): 对话感知上下文——按风格包隔离 + 翻译会话参与润色(不串味)
This commit is contained in:
5 files changed
+384
-52
No files matched your search
@@ -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")];
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user