diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 72d74c5f..30cc2dc5 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -5415,7 +5415,7 @@ - 修复:保持 `thinking: disabled` 与 16384 正文预算。对已加载 Case 的 `text-delta` 按段落拆成过程自述与口语结论:自述进 `thinking.delta`,结论进 `answer.delta` 并作为落盘正文。界面与咨询对齐:折叠「思考」(13px 次要色,有正文后默认收起)和完整「回复」。 - 验证:`frontend/tests/rectification-spoken-answer.test.ts`、`frontend/tests/rectification-v9-stream.test.ts`、`frontend/tests/rectification-agentic-entry.test.ts`、`frontend/tests/chat-stream-layout.test.ts`;`tsc --noEmit` 通过。口语/思考拆分的 `emit` 类型必须与 `RunV9AgentTurnInput.emit` 同为 `Promise | void`,否则 Docker `next build` 会在 publish 阶段失败。 - 防复发:纠正组答不得在不拆开正文预算的情况下重开 provider thinking 来充当思考 UI。`answer.delta` 不得承载 skill/schema/batch 过程自述。思考与回复必须分层渲染,不得共用同一段正文样式。 -- 相关记录:BUG-345、BUG-354、BUG-356 +- 相关记录:BUG-345、BUG-354、BUG-356、BUG-373 - 复发自:BUG-345(关 thinking 后自述改走正文)、BUG-354(纠正与咨询一并关闭 provider thinking) - 修复版本:`f03a2706` @@ -5531,6 +5531,22 @@ - 复发自:无 - 修复版本:未修复 +## BUG-373 | 生时纠正把中文过程自述当成回答,工具完成标签和 occupation_note 漏进正文 + +- 状态:resolved +- 首次发现:2026-08-25 +- 最近更新:2026-08-25 +- 影响面:`POST /api/rectification/agent`、`splitRectificationSpokenAndThinking`、已落盘 Turn 水合 +- 用户现象:工具进度(读取校正记录、整理证据、比较候选)之后,回复气泡里先出现大段自我规划:方法覆盖、occupation_note、open_question、还不能出牌、本轮对照了哪些分盘,最后才是对用户的一句追问。看起来像思考过程被当成推理结果。 +- 触发条件:provider thinking 关闭;模型在工具完成后的最终 `text-delta` 里先写中文过程自述,再写对用户的问题。 +- 根因:BUG-368 维持 `thinking: disabled`。漏检网只认旧的 `datePrecision` / `用户提到` / `让我调用 batch` 句式。新的规划句复制了公开工具完成标签和服务器字段名,整段被当成口语并落盘。末尾「本轮对照了…」与界面已有的技法句重复。 +- 修复:漏检网补上工具完成标签回声、occupation_note / method_followup_plan / open_question / not_separated 等内部字段,以及方法覆盖、不可分宽度、让我继续访谈等规划句。混有过程句和对用户提问时只保留口语。不改已哈希 Skill `10.0.11`。 +- 验证:`frontend/tests/rectification-spoken-answer.test.ts` 锁定规划段进思考、入职追问留在口语;`frontend/tests/rectification-v9-stream.test.ts` 锁定最终 `text-delta` 只把口语发成 `answer.delta`。 +- 防复发:禁止把工具完成标签、内部字段名或「本轮对照了」写进 `answer.delta`。thinking 关闭时漏检网必须覆盖中文规划句,不能只认英文 / batch / datePrecision。对用户说话的追问不得被一起丢掉。 +- 相关记录:BUG-357、BUG-359、BUG-368、BUG-360 +- 复发自:BUG-357(关 thinking 后用正则从正文切思考;句式换了就漏) +- 修复版本:待发布 + ## BUG-372 | 生时纠正同一轮重复工具调用把已成功写入打成失败 - 状态:resolved diff --git a/frontend/src/lib/rectification-agentic/v9/spoken-answer.ts b/frontend/src/lib/rectification-agentic/v9/spoken-answer.ts index fc178957..96c5cbd1 100644 --- a/frontend/src/lib/rectification-agentic/v9/spoken-answer.ts +++ b/frontend/src/lib/rectification-agentic/v9/spoken-answer.ts @@ -6,20 +6,40 @@ * - drops process-only `text-delta` leaks so they cannot complete as the reply */ +import { + RECTIFICATION_ACTIVITY_PROGRESS_LABELS, + RECTIFICATION_TOOL_DONE_LABELS, +} from "../../rectification-activity-labels.ts"; + const CJK_RE = /[\u4e00-\u9fff]/; -const INTERNAL_TOKEN_RE = /\b(?:datePrecision|occurredFrom|occurredTo|proposedKind|education_start|missing_evidence|SKILL\.md|rectification-[a-z0-9-]+|focusId|evidenceId|display_date_label)\b/; -const PROCESS_ZH_RE = /skill\s*规则|不得猜补|让我(?:调用|记录|batch|提交)|我(?:决定|倾向|batch|需要用)|权衡:|内部矛盾|思维链|调用 batch|批量工具|写入(?:这些)?证据|datePrecision|occurredFrom|occurredTo/; -const THIRD_PERSON_USER_RE = /用户(?:在上|提到|先(?:说|提到)|说|自己|的核心|想表达|原话|的最终|对年份|提供了)/; +const INTERNAL_TOKEN_RE = /\b(?:datePrecision|occurredFrom|occurredTo|proposedKind|education_start|missing_evidence|SKILL\.md|rectification-[a-z0-9-]+|focusId|evidenceId|display_date_label|occupation_note|method_followup_plan|open_question|next_action|next_user_action|not_separated|propose_allowed|selection_allowed|information_gain|event_probe|session_outcome|unique_minute_path|confirmation_allowed|collect_method_evidence|candidate_contrast|deferred_followup)\b/; +const PROCESS_ZH_RE = /skill\s*规则|不得猜补|让我(?:调用|记录|batch|提交|继续|用)|我(?:决定|倾向|batch|需要用|需要继续|继续收集|继续访谈|自然地|用自然语言)|权衡:|内部矛盾|思维链|调用 batch|批量工具|写入(?:这些)?证据|datePrecision|occurredFrom|occurredTo|方法覆盖|还不能出牌|不得出牌|本轮对照了|不可分宽度|重新计算了候选|带评分日期|当前还应继续收集|根据 method_followup/; +const THIRD_PERSON_USER_RE = /^用户|用户(?:在上|提到|先(?:说|提到)|说|自己|的核心|想表达|原话|的最终|对年份|提供了)/; + +const ACTIVITY_ECHO_LABELS = [ + ...Object.values(RECTIFICATION_TOOL_DONE_LABELS), + ...Object.values(RECTIFICATION_ACTIVITY_PROGRESS_LABELS), +]; export type SplitSpokenAndThinking = Readonly<{ thinking: string; spoken: string; }>; +function isActivityEcho(text: string): boolean { + const trimmed = text.trim().replace(/[。.…]+$/u, ""); + if (!trimmed) return false; + return ACTIVITY_ECHO_LABELS.some((label) => { + const bare = label.replace(/[。.…]+$/u, ""); + return trimmed === bare || trimmed === `正在${bare}`; + }); +} + export function isRectificationProcessNarration(text: string): boolean { const trimmed = text.trim(); if (!trimmed) return false; if (/[A-Za-z]{4,}/.test(trimmed) && !CJK_RE.test(trimmed)) return true; + if (isActivityEcho(trimmed)) return true; if (INTERNAL_TOKEN_RE.test(trimmed)) return true; if (PROCESS_ZH_RE.test(trimmed)) return true; if (THIRD_PERSON_USER_RE.test(trimmed)) return true; @@ -31,17 +51,31 @@ export function nextStableChannelDelta(published: string, next: string): string return next.slice(published.length); } +function splitSentences(text: string): string[] { + const parts = text.split(/(?<=[。!?])\s*/u).map((part) => part.trim()).filter(Boolean); + return parts.length > 0 ? parts : [text]; +} + +function splitUnits(text: string): string[] { + const lines = text.split(/\n/).map((line) => line.trim()).filter(Boolean); + if (lines.length > 1) return lines.flatMap(splitSentences); + return splitSentences(text); +} + +function isMixed(units: readonly string[]): boolean { + return units.length > 1 + && units.some(isRectificationProcessNarration) + && units.some((unit) => !isRectificationProcessNarration(unit)); +} + function splitParagraphs(text: string): string[] { return text .split(/\n{2,}/) .flatMap((block) => { const trimmed = block.trim(); if (!trimmed) return []; - const lines = trimmed.split(/\n/).map((line) => line.trim()).filter(Boolean); - if (lines.length <= 1) return [trimmed]; - const hasProcess = lines.some(isRectificationProcessNarration); - const hasSpoken = lines.some((line) => !isRectificationProcessNarration(line)); - return hasProcess && hasSpoken ? lines : [trimmed]; + const units = splitUnits(trimmed); + return isMixed(units) ? units : [trimmed]; }); } diff --git a/frontend/tests/rectification-spoken-answer.test.ts b/frontend/tests/rectification-spoken-answer.test.ts index ed5cdff9..762deaf3 100644 --- a/frontend/tests/rectification-spoken-answer.test.ts +++ b/frontend/tests/rectification-spoken-answer.test.ts @@ -89,6 +89,30 @@ test("finalizing does not promote process-only self-talk into the spoken answer" }); }); +test("Chinese interview planning after tools stays out of the spoken answer", () => { + const processTalk = [ + "读取校正记录", + "整理多条事件证据", + "比较候选时间", + "职业类型已经记录,并重新计算了候选。虽然职业领域现在有信息了,但账户里仍然只有 3 件带评分日期的事件,还不足以拉开候选范围区间(当前不可分宽度 8 分钟)。", + "方法覆盖上,职业这一层已经通过 occupation_note 补齐了(不计分),但还需要更多带日期的经历来区分候选。当前还应继续收集事件。", + "我用自然语言再问一件带大概年份的经历。根据 method_followup_plan,职业已经覆盖。当前 open_question 为空,next_action 为空。", + "候选区分尚未充分(not_separated),应继续收集。", + "让我继续访谈,问一件能帮助区分候选的职业前事。", + "本轮对照了Gochara、D1 本命盘、D10 事业分盘。", + ].join("\n\n"); + const spoken = "职业类型已经记下。接下来想请你回想一下这份工作的时间段——**你大概是在哪一年入职的?**又是什么时候离开的?只要个大概年份就行。"; + + const split = splitRectificationSpokenAndThinking(`${processTalk}\n\n${spoken}`); + assert.equal(split.spoken, spoken); + assert.match(split.thinking, /occupation_note/); + assert.match(split.thinking, /读取校正记录/); + assert.match(split.thinking, /本轮对照了/); + assert.doesNotMatch(split.thinking, /你大概是在哪一年入职/); + assert.equal(isRectificationProcessNarration("读取校正记录"), true); + assert.equal(isRectificationProcessNarration(spoken), false); +}); + test("leaked process text on the answer channel is not mixed into native thinking", () => { const processTalk = "用户在上一轮里提供了两件带日期的经历。我需要用批量工具写入这些证据。用户"; const spoken = "记下了升学这两件。接下来有没有一件带大概年份的工作变化?"; diff --git a/frontend/tests/rectification-v9-stream.test.ts b/frontend/tests/rectification-v9-stream.test.ts index bfd58688..aadca64d 100644 --- a/frontend/tests/rectification-v9-stream.test.ts +++ b/frontend/tests/rectification-v9-stream.test.ts @@ -1301,6 +1301,43 @@ test("does not publish intermediate tool-step text as answer.delta", async () => assert.equal(emitted.some((event) => event.type === "thinking.delta"), false); }); +test("Chinese interview planning in the terminal text-delta is not the spoken answer", async () => { + const spoken = "职业类型已经记下。接下来想请你回想一下这份工作的时间段——**你大概是在哪一年入职的?**只要个大概年份就行。"; + const { options, emitted } = runOptions({ + buildAgent: async () => fakeAgentStream([ + chunk("start"), + chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }), + chunk("tool-result", { toolName: "skill" }), + chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }), + chunk("tool-result", { toolName: "rectification-read-case" }), + chunk("tool-call", { toolName: "rectification-compare-candidates", args: { caseId: CASE_ID } }), + chunk("tool-result", { toolName: "rectification-compare-candidates" }), + chunk("text-delta", { + text: [ + "读取校正记录", + "比较候选时间", + "方法覆盖上,职业这一层已经通过 occupation_note 补齐了(不计分)。", + "让我继续访谈,问一件能帮助区分候选的职业前事。", + "本轮对照了Gochara、D1 本命盘、D10 事业分盘。", + spoken, + ].join("\n\n"), + }), + chunk("finish"), + ]) as never, + }); + const result = await runV9AgentTurn(options); + assert.equal(result.ok, true); + assert.equal(result.answerText, spoken); + assert.deepEqual( + emitted.filter((event) => event.type === "answer.delta"), + [{ type: "answer.delta", text: spoken }], + ); + const publicText = JSON.stringify(emitted); + assert.doesNotMatch(publicText, /occupation_note/); + assert.doesNotMatch(publicText, /让我继续访谈/); + assert.doesNotMatch(publicText, /本轮对照了/); +}); + test("does not reset the whole attempt after duplicate_focus", async () => { let buildCount = 0; const accounting = fakeAccounting({