From 3a0319694e8eb836d3e90611ae447f25e201ad0c Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sat, 29 Aug 2026 23:18:07 +0800 Subject: [PATCH] fix(rectification): keep same-sentence dated events after structured answers The choice and collect fast paths applied the answer then returned, so a dated event in the same utterance never reached the evidence ledger. Idle persist also prechecked follow-up with a hardcoded collect_evidence outcome instead of the dossier decision. Co-authored-by: Cursor --- docs/BUG_HISTORY.md | 6 +- .../src/app/api/rectification/agent/route.ts | 23 ++- .../rectification-agentic/v9/answer-choice.ts | 26 ++- .../v9/turn-intent-classifier.ts | 13 +- frontend/src/mastra/rectification-v9-tools.ts | 2 +- .../tests/rectification-answer-choice.test.ts | 32 +++ .../tests/rectification-collect-stall.test.ts | 189 ++++++++++++++++++ ...ctification-turn-intent-classifier.test.ts | 32 +++ 8 files changed, 306 insertions(+), 17 deletions(-) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 850d976c..27ae7c26 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -6744,9 +6744,9 @@ - 用户现象:点完若干区分卡后,助手口述问家里有没有结婚、添丁或住院这类记得住时间的事。用户回「没有」。助手只确认「记下了」,随后 `current_question` 与 `choice_card` 均为 null,`interview.type = ask_fact_collection`,对话停住,无法继续。 - 触发条件:方法覆盖未齐(尤其 relatives),剩余可渲染探针都是无年份分盘题;用户对家人口述采集题给出明确否定,且该轮走自由文本而不是点选。 - 根因:三段同时成立才会空转。(1) 「没有」既不是证据也不是计分答案,`answered_probes` 与 revision 不变;若焦点以 `resolved` 关闭,`declined_skipped_topics` 只聚合 `status in ('declined','skipped')`,`buildMethodFollowupPlan` 又只认 `target_domain`,覆盖度不前进。(2) 无年份分盘题被 `coverageComplete` 挡在 dated 排序之后,计划回落到同领域口述采集题;家人覆盖要求「有家人证据或有 declined 记录」,家里确实没发生过事的用户两条都不满足,而 `varga.d12` 本就是这道家人题、`no` 分支可以计分淘汰候选,却被当成不计分采集题问出去并丢失。(3) 点选路径有 `persistNextInterviewAfterChoice` 保证下一问落库;自由文本路径跑完 `runV9AgentTurn` 后没有同等兜底。Agent 不重复刚被否掉的问题(这点是对的),于是只确认不提问,`current_question` 变成 null。 -- 修复:P0:即将返回某领域 `intent=collect_method_evidence` + `source=method_coverage` 口述题时,若 `rankRenderableDiscriminators` 的 yearless 桶里有同领域可渲染探针,改出该 distinguish 卡(`followupFromRanked`,按既有 `rankDiscriminatorScore` 取最高)。未放宽 coverageComplete 对 yearless 的总排序,避免回归 BUG-405/407。无年份 varga 探针允许铸出 choice_frame,不得向题干借用账本年份。P1:采集焦点自由文本接入同一套轻量分类器;明确否定时服务端把焦点 resolve 成 `declined`(不是 `resolved`),并按点选路径持久化下一问。同步修正 `rectification-resolve-focus` 工具描述。未引入语义正则、关键词表或 A/B/C/D 位置推断。`focusStatusForAnswer` 的 `no → declined` 语义未改。P2:`runV9AgentTurn` 成功后若无 open focus 且 plan 仍有 followup,按点选路径补落下一问。Skill 版本保持 10.0.13,未放宽 confirmation gate。 -- 验证:`frontend/tests/rectification-collect-stall.test.ts` 9 项全过。revision 5 fixture 锁:plan 出 D12 distinguish 卡而不是家人口述题;答 C 焦点 declined 且按 d12 `no` 分支计分;relatives 覆盖后推进 occupation;occupation 口述明确否定 → declined 并推进 horary;自由文本轮结束后只要 plan 有 followup 则 `current_question` 非 null;覆盖未齐时仍先出可渲染有年份区分题,不同领域无年份分盘题仍不出。既有 eight-method / discriminator-followup 里「无年份家人对比在没有有年份区分题时改出口述题」的旧断言,去掉 P0 即变红,现改为期望 D12 卡。`npx tsc --noEmit` 通过;eslint 改动文件 0 error(8 条既有 warning)。指定套件 `tests/rectification-*.test.ts tests/birth-time-rectification-contract.test.ts tests/agentic-rectification-*.test.ts` 667 项、666 通过,1 项 `rectification-ingest-p0-database` 在并行 Docker 下 migration spawn 失败,单独重跑通过。全量 `tests/*.test.ts` 2261 项、2257 通过;4 项失败均为既有环境类(`database-billing-adjustments` / `database-redeem-security` Docker slot 超时、`database-personal-report-jobs` migration、`onboarding-route` 5ms/50ms 竞速),后一项单独重跑通过。失败集合未扩大到产品逻辑。 -- 防复发:同一领域的问题若存在可渲染计分卡,不得改用不计分口述题。采集题的明确否定必须产生 declined 记录,不得写成 resolved。出题层还有 followup 时 `current_question` 不得为 null。不得为了救这条空转而取消 coverageComplete 对 yearless 的总限制。 +- 修复:P0:即将返回某领域 `intent=collect_method_evidence` + `source=method_coverage` 口述题时,若 `rankRenderableDiscriminators` 的 yearless 桶里有同领域可渲染探针,改出该 distinguish 卡(`followupFromRanked`,按既有 `rankDiscriminatorScore` 取最高)。未放宽 coverageComplete 对 yearless 的总排序,避免回归 BUG-405/407。无年份 varga 探针允许铸出 choice_frame,不得向题干借用账本年份。P1:采集焦点自由文本接入同一套轻量分类器;明确否定时服务端把焦点 resolve 成 `declined`(不是 `resolved`),并按点选路径持久化下一问。同步修正 `rectification-resolve-focus` 工具描述。未引入语义正则、关键词表或 A/B/C/D 位置推断。`focusStatusForAnswer` 的 `no → declined` 语义未改。P2:`runV9AgentTurn` 成功后若无 open focus 且 plan 仍有 followup,按点选路径补落下一问。收尾:(a) 分类器新增可选 `has_new_dated_event`,缺失按 false;选择题与采集题快路径在该字段为 true 时先确定性地应用答案(计分 / declined),不落确定性正文、不 return,继续 `runV9AgentTurn` 记同一句里的新带时间事件;为 false 时行为与原先逐字一致。应用答案时 `deferFollowup`,让 Agent 看到没有 active focus,不得再 resolve。(b) `persistNextInterviewIfIdle` 预检改为先 `decideFromDossier`,用同一个 `decision.sessionOutcome` 建 plan,并复用该 decision 做 `publicNextAction`,不再写死 `collect_evidence`。Skill 版本保持 10.0.13,未放宽 confirmation gate。 +- 验证:`frontend/tests/rectification-collect-stall.test.ts` 锁 D12 卡、答 C declined 计分、occupation 否定推进 horary、自由文本后 `current_question` 非 null、有年份区分题仍优先。新增:缺失 `has_new_dated_event` 按 false;采集/选择题快路径在该字段为 true 时应用答案后进入 Agent 且不落确定性正文;采集否定 + deferFollowup 不抢先落下一问;选择题 deferFollowup 仍计分且不落下一问/正文;`persistNextInterviewIfIdle` 同一 dossier 只调用一次 `decideFromDossier`,预检 sessionOutcome 与决策层相同。`npx tsc --noEmit` 通过;eslint 改动文件 0 error。指定套件 `tests/rectification-*.test.ts tests/birth-time-rectification-contract.test.ts tests/agentic-rectification-*.test.ts` 672 项、0 失败。全量 `tests/*.test.ts` 2266 项、0 失败。失败集合未扩大到产品逻辑。 +- 防复发:同一领域的问题若存在可渲染计分卡,不得改用不计分口述题。采集题的明确否定必须产生 declined 记录,不得写成 resolved。出题层还有 followup 时 `current_question` 不得为 null。不得为了救这条空转而取消 coverageComplete 对 yearless 的总限制。确定性快路径不得吞掉同一句话里的新带时间事件。同一次调用里出题层与决策层必须用同一个 sessionOutcome。 - 相关记录:BUG-404、BUG-405、BUG-407、BUG-411、BUG-432、BUG-437 - 复发自:无 - 修复版本:待发布 diff --git a/frontend/src/app/api/rectification/agent/route.ts b/frontend/src/app/api/rectification/agent/route.ts index 5c31330e..27979a49 100644 --- a/frontend/src/app/api/rectification/agent/route.ts +++ b/frontend/src/app/api/rectification/agent/route.ts @@ -30,6 +30,7 @@ import { classifyRectificationTurnIntent, optionIdForAnswerClass, shouldDeclineCollectFocus, + shouldContinueAgentForDatedEvent, } from "@/lib/rectification-agentic/v9/turn-intent-classifier"; import { persistServerOwnedFocus, openQuestionFromPersistedFocus, isCollectFocusSchema } from "@/lib/rectification-agentic/v9/server-focus"; import { buildMethodFollowupPlan } from "@/lib/rectification-agentic/v9/method-followup"; @@ -348,6 +349,7 @@ export async function POST(request: Request) { if (!optionId) { return completedMessageResponse("当前问题已更新,请刷新后重新作答。", requestId, caseId); } + const continueToAgent = shouldContinueAgentForDatedEvent(classified); const previous = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null); const applied = await applyRectificationChoice(accounting, { userId, @@ -363,8 +365,11 @@ export async function POST(request: Request) { optionId, expectedRevision: previous?.revision ?? 0, userDisplay: parsed.data.message ?? null, + deferFollowup: continueToAgent, }); - return completedMessageResponse(applied.narration, requestId, caseId); + if (!continueToAgent) { + return completedMessageResponse(applied.narration, requestId, caseId); + } } if (classified.intent === "stop_rectification") { const previous = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null); @@ -399,17 +404,21 @@ export async function POST(request: Request) { classified = null; } if (shouldDeclineCollectFocus(classified)) { + const continueToAgent = shouldContinueAgentForDatedEvent(classified); const applied = await applyCollectFocusDenial(accounting, { userId, caseId, focusId: focus.id, + deferFollowup: continueToAgent, }); - await persistV9DeterministicTurn(accounting, userId, caseId, { - requestId, - userMessage: parsed.data.message ?? null, - assistantMessage: applied.narration, - }); - return completedMessageResponse(applied.narration, requestId, caseId); + if (!continueToAgent) { + await persistV9DeterministicTurn(accounting, userId, caseId, { + requestId, + userMessage: parsed.data.message ?? null, + assistantMessage: applied.narration, + }); + return completedMessageResponse(applied.narration, requestId, caseId); + } } } else { let birthDate: string | null = null; diff --git a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts index 63ef5a00..d1cace5e 100644 --- a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts +++ b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts @@ -60,6 +60,7 @@ export type ApplyChoiceCommand = Readonly<{ optionId: ChoiceOptionId; expectedRevision: number; userDisplay?: string | null; + deferFollowup?: boolean; }>; export type AppliedChoiceReceipt = Readonly<{ @@ -388,7 +389,7 @@ async function persistFocusAfterChoice(input: { export async function applyCollectFocusDenial( accounting: AccountingClient, - input: { userId: string; caseId: string; focusId: string }, + input: { userId: string; caseId: string; focusId: string; deferFollowup?: boolean }, ): Promise<{ narration: string; nextInterviewPersisted: boolean; nextChoiceReady: boolean }> { const dossier = await loadV9CaseDossier(accounting, input.userId, input.caseId); const focus = dossier.conversationSummary.activeFocus; @@ -400,6 +401,13 @@ export async function applyCollectFocusDenial( status: "declined", evidenceId: null, }); + if (input.deferFollowup === true) { + return { + narration: "记下了,这方面先跳过。", + nextInterviewPersisted: false, + nextChoiceReady: false, + }; + } let birthDate: string | null = null; try { const compute = await loadV9CaseCompute(accounting, input.userId, input.caseId); @@ -456,12 +464,13 @@ export async function persistNextInterviewIfIdle(input: { } catch { birthDate = null; } + const decision = decideFromDossier(dossier, { birthDate }); const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence); const plan = buildMethodFollowupPlan({ evidence: dossier.evidence, activeFocus: null, declinedTopics: dossier.conversationSummary.declinedSkippedTopics, - sessionOutcome: "collect_evidence", + sessionOutcome: decision.sessionOutcome, ...catalog, birthDate, accepted: Boolean(dossier.case.acceptedTime), @@ -469,7 +478,7 @@ export async function persistNextInterviewIfIdle(input: { if (!plan.next_followup) { return { persisted: false, choiceReady: false }; } - const nextAction = publicNextAction(decideFromDossier(dossier, { birthDate })); + const nextAction = publicNextAction(decision); const nextInterview = await persistNextInterviewAfterChoice({ accounting: input.accounting, userId: input.userId, @@ -543,7 +552,11 @@ async function persistApplied( let nextInterviewPersisted = false; let nextChoiceReady = false; let hostNarration = input.narration; - if (input.userStopped !== true && shouldContinueAfterStructuredChoice(nextAction)) { + if ( + command.deferFollowup !== true + && input.userStopped !== true + && shouldContinueAfterStructuredChoice(nextAction) + ) { const nextInterview = await persistNextInterviewAfterChoice({ accounting, userId: command.userId, @@ -559,7 +572,10 @@ async function persistApplied( nextInterviewPersisted = true; } } - if (nextInterviewPersisted || !shouldContinueAfterStructuredChoice(nextAction, { nextInterviewPersisted })) { + if ( + command.deferFollowup !== true + && (nextInterviewPersisted || !shouldContinueAfterStructuredChoice(nextAction, { nextInterviewPersisted })) + ) { try { await persistV9DeterministicTurn(accounting, command.userId, command.caseId, { requestId: command.actionId, diff --git a/frontend/src/lib/rectification-agentic/v9/turn-intent-classifier.ts b/frontend/src/lib/rectification-agentic/v9/turn-intent-classifier.ts index 5aa73db6..9963fce5 100644 --- a/frontend/src/lib/rectification-agentic/v9/turn-intent-classifier.ts +++ b/frontend/src/lib/rectification-agentic/v9/turn-intent-classifier.ts @@ -16,6 +16,7 @@ const turnIntentSchema = z.object({ "unclear", ]), answer_class: z.enum(["yes", "weak_yes", "no", "unsure"]).nullable(), + has_new_dated_event: z.boolean().optional(), }).strict(); export type RectificationTurnIntent = z.infer; @@ -35,6 +36,12 @@ export function shouldDeclineCollectFocus( return classified?.intent === "answer_current_focus" && classified.answer_class === "no"; } +export function shouldContinueAgentForDatedEvent( + classified: RectificationTurnIntent | null, +): boolean { + return classified?.has_new_dated_event === true; +} + export function optionIdForAnswerClass( focus: ConversationFocus, answerClass: AnswerClass, @@ -66,12 +73,16 @@ export async function classifyRectificationTurnIntent( ? `你只做当前生时校正问题的意图分类,不回答用户,也不修改任何状态。 结合当前问题和动态选项判断用户是在回答当前问题、提供新的带时间经历、要求停止整个校正、询问结果,还是语义不清。 若是在回答当前问题,answer_class 必须使用某个选项提供的 answer_class;否则 answer_class 必须为 null。 +has_new_dated_event 仅在用户同一句里除了回答当前问题之外,还提供了新的、带大概时间的经历时为 true;单纯的否定或单纯的选项回答必须为 false。 +若同一句话既回答了当前问题又补充了新的带时间经历,intent 仍为 answer_current_focus,has_new_dated_event 为 true。 “当前方面没有、那段时间没有变化”通常是回答当前问题,不是停止整个流程;只有用户明确要求停止整个校正时才分类为 stop_rectification。 不要按 A/B/C/D 的位置猜语义,只按选项 label 与 answer_class 判断。` : `你只做当前生时校正采集题的意图分类,不回答用户,也不修改任何状态。 当前问题没有点选选项。判断用户是在回答当前采集题、提供新的带时间经历、要求停止整个校正、询问结果,还是语义不清。 若用户明确表示这个方面没有发生过、没有这类事,intent 为 answer_current_focus,answer_class 为 no。 -若用户在补充带时间的经历,intent 为 provide_new_evidence,answer_class 必须为 null。 +若用户只在补充带时间的经历、并没有回答当前采集题,intent 为 provide_new_evidence,answer_class 必须为 null。 +has_new_dated_event 仅在用户同一句里除了回答当前采集题之外,还提供了新的、带大概时间的经历时为 true;单纯的否定必须为 false。 +若同一句话既明确否定当前采集题又补充了新的带时间经历,intent 仍为 answer_current_focus 且 answer_class 为 no,不要改成 provide_new_evidence。 “当前方面没有”通常是回答当前采集题,不是停止整个流程;只有用户明确要求停止整个校正时才分类为 stop_rectification。 不要按关键词表或正则猜测,只根据当前问题与用户这句话的语义分类。`, }); diff --git a/frontend/src/mastra/rectification-v9-tools.ts b/frontend/src/mastra/rectification-v9-tools.ts index e0268079..ea40db1e 100644 --- a/frontend/src/mastra/rectification-v9-tools.ts +++ b/frontend/src/mastra/rectification-v9-tools.ts @@ -1195,7 +1195,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { const resolveFocusTool = createTool({ id: "rectification-resolve-focus", description: - "处理用户对当前问题的明确拒答、跳过或无证据式解决。必须引用服务器返回的 active focusId;若目标是既有证据,可同时引用 evidenceId。点选卡由服务器 answer_choice 处理,不要为 A/B/C/D 或「先这样」调用本工具。自由文本拒答/跳过才带 choiceKey,服务器会立刻更新候选后验,不要等下一次 compare。采集题得到「确实没有」必须用 declined;resolved 只用于已落证据的情形。不得从中文措辞或上一条助手消息猜测目标。", + "处理用户对当前问题的明确拒答、跳过或无证据式解决。必须引用服务器返回的 active focusId;若当前没有 active focus,不要调用本工具。若目标是既有证据,可同时引用 evidenceId。点选卡由服务器 answer_choice 处理,不要为 A/B/C/D 或「先这样」调用本工具。自由文本拒答/跳过才带 choiceKey,服务器会立刻更新候选后验,不要等下一次 compare。采集题得到「确实没有」必须用 declined;resolved 只用于已落证据的情形。不得从中文措辞或上一条助手消息猜测目标。", inputSchema: z.object({ caseId: z.string().uuid(), focusId: z.string().uuid(), diff --git a/frontend/tests/rectification-answer-choice.test.ts b/frontend/tests/rectification-answer-choice.test.ts index 1701ed99..a0f6cdec 100644 --- a/frontend/tests/rectification-answer-choice.test.ts +++ b/frontend/tests/rectification-answer-choice.test.ts @@ -663,6 +663,38 @@ test("answering a discriminator persists the next dated card so GET still has a assert.doesNotMatch(card.prompt, /2015/); }); +test("deferFollowup scores the choice without persisting the next interview or turn", async () => { + const accounting = persistChoiceAccounting(twoProbeDossier()); + const applied = await applyRectificationChoice(accounting.client, { + userId: USER_ID, + caseId: CASE_ID, + sessionId: SESSION_ID, + actionId: ACTION_ID, + action: CHOICE_ACTION, + focusId: FOCUS_ID, + questionId: QUESTION_ID, + probeId: RELOCATION_2015_PROBE.id, + optionId: "C", + expectedRevision: twoProbeInference().revision, + deferFollowup: true, + }); + assert.equal(applied.applied, true); + assert.equal(applied.answerClass, "no"); + const persist = accounting.calls.find((call) => call.fn === "apply_agentic_rectification_choice_action"); + assert.equal(persist?.args.p_option_id, "C"); + assert.ok(persist?.args.p_inference); + assert.equal( + accounting.calls.some((call) => call.fn === "set_agentic_rectification_conversation_focus"), + false, + ); + assert.equal( + accounting.calls.some((call) => call.fn === "append_agentic_rectification_turn"), + false, + ); + assert.equal(applied.nextInterviewPersisted, false); + assert.equal(applied.narrationPersisted, false); +}); + test("answering the last discriminator persists a year-locked family collect focus, not a yearless D24 card", async () => { const accounting = persistChoiceAccounting(familyCollectDossier(), { set_agentic_rectification_conversation_focus: (_fn, args) => ({ diff --git a/frontend/tests/rectification-collect-stall.test.ts b/frontend/tests/rectification-collect-stall.test.ts index 7e50e120..6c1be8a7 100644 --- a/frontend/tests/rectification-collect-stall.test.ts +++ b/frontend/tests/rectification-collect-stall.test.ts @@ -6,6 +6,7 @@ import { applyAnswerToState, buildInferenceState } from "../src/lib/rectificatio import { applyChoiceWithoutEvidence } from "../src/lib/rectification-agentic/v9/inference-adapter.ts"; import type { ConflictProbe } from "../src/lib/rectification-agentic/core/types.ts"; import { + decideFromDossier, rectificationFollowupCatalog, type DecisionDossier, } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts"; @@ -15,6 +16,7 @@ import { serverOwnedChoiceCopy } from "../src/lib/rectification-agentic/v9/choic import { persistServerOwnedFocus } from "../src/lib/rectification-agentic/v9/server-focus.ts"; import { projectCurrentQuestion } from "../src/lib/rectification-agentic/v9/turn-decision.ts"; import { + shouldContinueAgentForDatedEvent, shouldDeclineCollectFocus, } from "../src/lib/rectification-agentic/v9/turn-intent-classifier.ts"; import { @@ -745,4 +747,191 @@ test("collect-focus classifier and resolve-focus copy do not treat explicit no a assert.match(resolveTool, /declined/); assert.match(resolveTool, /采集题|确实没有/); assert.match(resolveTool, /resolved 只用于已落证据|已落证据/); + assert.match(resolveTool, /没有 active focus|没有当前.*focus/); +}); + +test("has_new_dated_event continues into the agent after applying the answer", () => { + const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8"); + const classifier = readFileSync(new URL("../src/lib/rectification-agentic/v9/turn-intent-classifier.ts", import.meta.url), "utf8"); + const fastPath = route.slice( + route.indexOf('if (action === "message")'), + route.indexOf("const requestTime"), + ); + assert.match(classifier, /has_new_dated_event/); + assert.match(classifier, /带大概时间的经历/); + assert.match(classifier, /intent 仍为 answer_current_focus/); + assert.match(classifier, /不要改成 provide_new_evidence/); + assert.doesNotMatch(classifier + fastPath, /USER_STOP_PATTERN|parseChoiceKeyFromUserMessage/); + assert.doesNotMatch(classifier + fastPath, /(?:userMessage|user_message|message)\.(?:match|search|includes|startsWith|endsWith)\(/); + assert.match(fastPath, /shouldContinueAgentForDatedEvent/); + assert.match(fastPath, /deferFollowup:\s*continueToAgent/); + const choiceApply = fastPath.slice( + fastPath.indexOf("if (classified.intent === \"answer_current_focus\")"), + fastPath.indexOf("if (classified.intent === \"stop_rectification\")"), + ); + assert.ok(choiceApply.indexOf("applyRectificationChoice") < choiceApply.indexOf("if (!continueToAgent)")); + assert.match(choiceApply, /return completedMessageResponse\(applied\.narration/); + assert.ok(choiceApply.indexOf("if (!continueToAgent)") < choiceApply.indexOf("return completedMessageResponse(applied.narration")); + const collectApply = fastPath.slice( + fastPath.indexOf("if (shouldDeclineCollectFocus(classified))"), + fastPath.indexOf("} else {"), + ); + assert.ok(collectApply.indexOf("applyCollectFocusDenial") < collectApply.indexOf("if (!continueToAgent)")); + assert.match(collectApply, /persistV9DeterministicTurn/); + assert.ok(collectApply.indexOf("if (!continueToAgent)") < collectApply.indexOf("persistV9DeterministicTurn")); + assert.ok(route.indexOf("if (action === \"message\")") < route.indexOf("runV9AgentTurn({")); + const answerChoice = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8"); + const persistApplied = answerChoice.slice(answerChoice.indexOf("async function persistApplied")); + assert.match(persistApplied, /command\.deferFollowup !== true/); + assert.equal(shouldContinueAgentForDatedEvent({ + intent: "answer_current_focus", + answer_class: "no", + has_new_dated_event: true, + }), true); + assert.equal(shouldContinueAgentForDatedEvent({ + intent: "answer_current_focus", + answer_class: "no", + }), false); +}); + +test("collect denial with a new dated event does not persist the next interview before the agent", async () => { + const occupationFocus = { + id: FOCUS_ID, + case_id: CASE_ID, + question_id: "occupation:occupation", + intent: "collect_method_evidence", + target_evidence_id: null, + target_domain: "occupation", + target_kind: null, + expected_answer_schema: { + prompt: "你长期做什么工作?", + collect: true, + }, + status: "active", + asked_at: "2026-08-29T00:00:00.000Z", + resolved_at: null, + }; + let loads = 0; + const accounting = fakeAccounting({ + ...receiptHandlers, + get_agentic_rectification_case_dossier: () => { + loads += 1; + if (loads === 1) { + return rpcDossier(revision5Dossier(revision5State(), { + declinedTopics: [{ target_domain: "family", status: "declined" }], + }), occupationFocus); + } + return rpcDossier(revision5Dossier(revision5State(), { + declinedTopics: [ + { target_domain: "family", status: "declined" }, + { target_domain: "occupation", status: "declined" }, + ], + })); + }, + get_agentic_rectification_case_compute: () => computeFixture(), + resolve_agentic_rectification_conversation_focus: (_fn, args) => ({ + focus_id: args.p_focus_id, + status: args.p_status, + evidence_id: null, + idempotent: false, + }), + set_agentic_rectification_conversation_focus: (_fn, args) => ({ + focus: { + id: "acacacac-acac-4cac-8cac-acacacacacac", + case_id: CASE_ID, + question_id: args.p_question_id, + intent: args.p_intent, + target_evidence_id: args.p_target_evidence_id, + target_domain: args.p_target_domain, + target_kind: args.p_target_kind, + expected_answer_schema: args.p_expected_answer_schema, + status: "active", + asked_at: "2026-08-29T00:00:00.000Z", + resolved_at: null, + }, + idempotent: false, + }), + }); + const applied = await applyCollectFocusDenial(accounting.client, { + userId: USER_ID, + caseId: CASE_ID, + focusId: FOCUS_ID, + deferFollowup: true, + }); + const resolve = accounting.calls.find((item) => item.fn === "resolve_agentic_rectification_conversation_focus"); + assert.equal(resolve?.args.p_status, "declined"); + assert.equal( + accounting.calls.some((item) => item.fn === "set_agentic_rectification_conversation_focus"), + false, + ); + assert.equal(applied.nextInterviewPersisted, false); +}); + +test("persistNextInterviewIfIdle uses the dossier decision sessionOutcome once", async () => { + const source = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8"); + const idle = source.slice( + source.indexOf("export async function persistNextInterviewIfIdle"), + source.indexOf("async function persistApplied"), + ); + assert.equal(idle.split("decideFromDossier").length - 1, 1); + assert.match(idle, /sessionOutcome:\s*decision\.sessionOutcome/); + assert.doesNotMatch(idle, /sessionOutcome:\s*"collect_evidence"/); + assert.match(idle, /publicNextAction\(decision\)/); + + const covered = revision5Dossier(revision5State([DATED_RELOCATION_2016]), { + declinedTopics: [ + { target_domain: "family", status: "declined" }, + { target_domain: "occupation", status: "declined" }, + { target_domain: "horary", status: "declined" }, + ], + }); + const decision = decideFromDossier(covered); + assert.notEqual(decision.sessionOutcome, "collect_evidence"); + const catalog = rectificationFollowupCatalog(covered.latestResult, covered.evidence); + const collectPlan = buildMethodFollowupPlan({ + evidence: covered.evidence, + declinedTopics: covered.conversationSummary.declinedSkippedTopics, + sessionOutcome: "collect_evidence", + ...catalog, + candidatesSeparated: false, + }); + const decisionPlan = buildMethodFollowupPlan({ + evidence: covered.evidence, + declinedTopics: covered.conversationSummary.declinedSkippedTopics, + sessionOutcome: decision.sessionOutcome, + ...catalog, + candidatesSeparated: false, + }); + assert.ok(decisionPlan.next_followup); + assert.notEqual(collectPlan.next_followup?.intent, decisionPlan.next_followup?.intent); + const accounting = fakeAccounting({ + ...receiptHandlers, + get_agentic_rectification_case_dossier: () => rpcDossier(covered), + get_agentic_rectification_case_compute: () => computeFixture(), + set_agentic_rectification_conversation_focus: (_fn, args) => ({ + focus: { + id: FOCUS_ID, + case_id: CASE_ID, + question_id: args.p_question_id, + intent: args.p_intent, + target_evidence_id: args.p_target_evidence_id, + target_domain: args.p_target_domain, + target_kind: args.p_target_kind, + expected_answer_schema: args.p_expected_answer_schema, + status: "active", + asked_at: "2026-08-29T00:00:00.000Z", + resolved_at: null, + }, + idempotent: false, + }), + }); + const persisted = await persistNextInterviewIfIdle({ + accounting: accounting.client, + userId: USER_ID, + caseId: CASE_ID, + }); + assert.equal(persisted.persisted, true); + const setFocus = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus"); + assert.ok(setFocus); + assert.notEqual(setFocus?.args.p_intent, "collect_method_evidence"); }); diff --git a/frontend/tests/rectification-turn-intent-classifier.test.ts b/frontend/tests/rectification-turn-intent-classifier.test.ts index 9895de78..d7f8cb17 100644 --- a/frontend/tests/rectification-turn-intent-classifier.test.ts +++ b/frontend/tests/rectification-turn-intent-classifier.test.ts @@ -5,6 +5,8 @@ import test from "node:test"; import { optionIdForAnswerClass, parseRectificationTurnIntent, + shouldContinueAgentForDatedEvent, + shouldDeclineCollectFocus, } from "../src/lib/rectification-agentic/v9/turn-intent-classifier.ts"; import type { ConversationFocus } from "../src/lib/rectification-agentic/v9/tool-service.ts"; import { CASE_ID, FOCUS_ID } from "./rectification-v9-test-support.ts"; @@ -71,6 +73,36 @@ test("turn intent parser enforces answer_class only for current-focus answers", }), null); }); +test("missing has_new_dated_event is treated as false and true is fail-closed optional", () => { + const legacy = parseRectificationTurnIntent({ + intent: "answer_current_focus", + answer_class: "no", + }); + assert.equal(legacy?.intent, "answer_current_focus"); + assert.equal(legacy?.answer_class, "no"); + assert.equal(legacy?.has_new_dated_event, undefined); + assert.equal(shouldContinueAgentForDatedEvent(legacy), false); + assert.equal(shouldContinueAgentForDatedEvent(null), false); + assert.equal(shouldContinueAgentForDatedEvent({ + intent: "answer_current_focus", + answer_class: "no", + has_new_dated_event: false, + }), false); + const withEvent = parseRectificationTurnIntent({ + intent: "answer_current_focus", + answer_class: "no", + has_new_dated_event: true, + }); + assert.equal(withEvent?.has_new_dated_event, true); + assert.equal(shouldContinueAgentForDatedEvent(withEvent), true); + assert.equal(shouldDeclineCollectFocus(withEvent), true); + assert.equal(parseRectificationTurnIntent({ + intent: "answer_current_focus", + answer_class: "no", + has_new_dated_event: "yes", + }), null); +}); + test("answer classes resolve through each dynamic option instead of A/B/C/D position", () => { const focus = focusWithOptions(SHUFFLED_CHOICE); assert.equal(optionIdForAnswerClass(focus, "no"), "A");