/** * These helpers recover old leaked Turns on hydrate. They are not the live * spoken-answer classifier; `runV9AgentTurn` must not call them. */ import assert from "node:assert/strict"; import test from "node:test"; import { finalizeRectificationSpokenAndThinking, isRectificationProcessNarration, nextStableChannelDelta, settleRectificationSpokenAndThinking, splitRectificationSpokenAndThinking, } from "../src/lib/rectification-agentic/v9/spoken-answer.ts"; test("a plain spoken follow-up stays on the answer channel", () => { const spoken = [ "记下了,大约六岁入学小学。", "接下来你大概哪一年上的初中?说个大概年份或范围就行。", ].join("\n\n"); assert.deepEqual(splitRectificationSpokenAndThinking(spoken), { thinking: "", spoken, }); }); test("process self-talk leaves the spoken conclusion on the answer channel", () => { const processTalk = [ "用户提到先给了一个很晚的年份,后又改口说六岁入学。这里有个明显的内部矛盾。", "但按 skill 规则,日期精度真实保留,不得猜补。datePrecision 用 year,occurredFrom 只能按用户原话来。", "我决定先按六岁入学记下来,再在正文里确认那句晚年份是不是口误。", "让我调用 batch 写入 education_start。", ].join("\n\n"); const spoken = [ "入学小学这条先按大约六岁记下。你第一句提到的那个很晚的年份,我理解是口误对吗?", "接下来你大概哪一年上的初中?说个大概年份或范围就行。", ].join("\n\n"); assert.deepEqual(splitRectificationSpokenAndThinking(`${processTalk}\n\n${spoken}`), { thinking: processTalk, spoken, }); }); test("English process talk is classified as thinking, not the spoken answer", () => { const split = splitRectificationSpokenAndThinking([ "The proposedKind value was rejected. Retrying with education_start.", "记下了,那年九月上大学。", ].join("\n\n")); assert.match(split.thinking, /proposedKind/); assert.equal(split.spoken, "记下了,那年九月上大学。"); }); test("stable channel deltas only emit the newly classified suffix", () => { assert.equal(nextStableChannelDelta("", "先核对升学年份。"), "先核对升学年份。"); assert.equal( nextStableChannelDelta("先核对升学年份。", "先核对升学年份。\n\n再问初中。"), "\n\n再问初中。", ); assert.equal(nextStableChannelDelta("先核对升学年份。", "另一段"), ""); }); test("a lone process paragraph stays in thinking until a spoken conclusion arrives", () => { const processTalk = "用户提到先给了一个很晚的年份。这里有个明显的内部矛盾。"; assert.deepEqual(splitRectificationSpokenAndThinking(`${processTalk}\n\n`), { thinking: processTalk, spoken: "", }); }); test("truncated third-person batch-tool narration stays in thinking", () => { const truncated = "用户在上一轮里提供了两件带日期的经历。我需要用批量工具写入这些证据。用户"; assert.equal(isRectificationProcessNarration(truncated), true); assert.deepEqual(splitRectificationSpokenAndThinking(truncated), { thinking: truncated, spoken: "", }); assert.deepEqual(finalizeRectificationSpokenAndThinking(truncated), { thinking: truncated, spoken: "", }); }); test("finalizing does not promote process-only self-talk into the spoken answer", () => { const first = "用户提到先给了一个很晚的年份。这里有个明显的内部矛盾。"; const last = "但按 skill 规则,日期精度真实保留,不得猜补。"; const processTalk = `${first}\n\n${last}`; assert.deepEqual(finalizeRectificationSpokenAndThinking(processTalk), { thinking: processTalk, spoken: "", }); }); 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("planning about candidate_contrast_packet stays out of the spoken answer", () => { const processTalk = [ "这意味着:方法资料已齐,还不能出牌。", "服务器给了 candidate_contrast_packet,choice_frame 已写好。", "第 7 条边界:id=ask_candidate_discriminator 时不得 offer。", ].join("\n\n"); const spoken = "那次高考或重要考试,发挥有没有明显失常、压力很大?说有或没有就行。"; const split = splitRectificationSpokenAndThinking(`${processTalk}\n\n${spoken}`); assert.equal(split.spoken, spoken); assert.match(split.thinking, /这意味着/); assert.match(split.thinking, /candidate_contrast_packet/); assert.match(split.thinking, /不得 offer/); assert.match(split.thinking, /第 7 条边界/); assert.doesNotMatch(split.thinking, /发挥有没有明显失常/); assert.equal(isRectificationProcessNarration("这意味着:方法资料已齐"), true); assert.equal(isRectificationProcessNarration("服务器给了 candidate_contrast_packet"), true); assert.equal(isRectificationProcessNarration(spoken), false); const settled = settleRectificationSpokenAndThinking(`${processTalk}\n\n${spoken}`, ""); assert.equal(settled.spoken, spoken); assert.doesNotMatch(settled.spoken, /candidate_contrast_packet/); }); test("draft-ledger planning stays out of the spoken answer", () => { const processTalk = [ "第二件离职事件已记录为草稿。现在账本有两件已确认加一件草稿。", "既然实习开始和实习离职都属于同一段职业经历并且相关联,用户后面的消息会继续补充,我先向用户确认这件离职信息,同时继续收集。", "由于离职是事务性的独立事件且 quote 路径有障碍,我继续自然访谈。", "先确认草稿再继续收集下一件。", "这里草稿是服务器接受但待确认状态。", "我应该继续访谈而不是纠缠草稿确认。", ].join("\n\n"); const spoken = [ "好的,已经记下你这段职业经历:那年春天开始实习、秋天离职。加上之前上学,目前有了比较清晰的两大块时间线。", "不用急着回忆全部。毕业后正式入职的第一份工作,大概哪一年?有没有恋爱、结婚这样的关系节点?哪一件印象深就先说哪件,大概年份就好。", ].join("\n\n"); const split = splitRectificationSpokenAndThinking(`${processTalk}\n\n${spoken}`); assert.equal(split.spoken, spoken); assert.match(split.thinking, /账本/); assert.match(split.thinking, /我应该继续访谈/); assert.match(split.thinking, /quote 路径/); assert.doesNotMatch(split.spoken, /账本/); assert.doesNotMatch(split.spoken, /我应该/); assert.doesNotMatch(split.spoken, /草稿是服务器/); assert.equal(isRectificationProcessNarration("我应该继续访谈而不是纠缠草稿确认。"), true); assert.equal(isRectificationProcessNarration(spoken.split("\n\n")[0]!), false); }); test("probe-deliberation with no user-facing close stays off the spoken answer", () => { const processTalk = [ "这回应的是当前探针(感情前事,用于对照 D9 差异)。", "当前探针是请核对一段还没用进评分的感情前事。用户给出的是一个主观感受,不是具体带日期的事件。", "让我看看是否应该把它作为一条证据记录。", "严格来说,用户没有给出有确定日期的新事件。根据规则,没有具体带日期事件就按 focus 处理。", "current_question 为 null,但 current_probe 有一条。", "也许我应该直接自然回应,不调用工具。规则要求承接。", "我认为这里不需要写证据。写入需要日期精度。", "我直接自然问。", ].join("\n\n"); const split = splitRectificationSpokenAndThinking(processTalk); assert.equal(split.spoken, ""); assert.match(split.thinking, /当前探针/); assert.match(split.thinking, /current_question/); assert.match(split.thinking, /我直接自然问/); assert.equal(finalizeRectificationSpokenAndThinking(processTalk).spoken, ""); const settled = settleRectificationSpokenAndThinking(processTalk, ""); assert.equal(settled.spoken, ""); assert.doesNotMatch(settled.spoken, /探针/); assert.doesNotMatch(settled.spoken, /根据规则/); }); test("leaked process text on the answer channel is not mixed into native thinking", () => { const processTalk = "用户在上一轮里提供了两件带日期的经历。我需要用批量工具写入这些证据。用户"; const spoken = "记下了升学这两件。接下来有没有一件带大概年份的工作变化?"; assert.deepEqual(settleRectificationSpokenAndThinking(processTalk, ""), { thinking: processTalk, spoken: "", }); assert.deepEqual(settleRectificationSpokenAndThinking(spoken, processTalk), { thinking: processTalk, spoken, }); assert.deepEqual(settleRectificationSpokenAndThinking(processTalk, processTalk), { thinking: processTalk, spoken: "", }); });