diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index bead7195..850d976c 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -6735,3 +6735,19 @@ - 复发自:无 - 修复版本:待发布 +## BUG-440 | 生时校正采集阶段否定后无下一问,对话空转 + +- 状态:resolved +- 首次发现:2026-08-29 +- 最近更新:2026-08-29 +- 影响面:`buildMethodFollowupPlan` 方法覆盖口述题、`classifyRectificationTurnIntent` 采集焦点、`applyCollectFocusDenial` / `persistNextInterviewIfIdle`、POST `/api/rectification/agent` 自由文本路径、GET `current_question` +- 用户现象:点完若干区分卡后,助手口述问家里有没有结婚、添丁或住院这类记得住时间的事。用户回「没有」。助手只确认「记下了」,随后 `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 的总限制。 +- 相关记录: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 8f785265..5c31330e 100644 --- a/frontend/src/app/api/rectification/agent/route.ts +++ b/frontend/src/app/api/rectification/agent/route.ts @@ -10,7 +10,7 @@ import { transitionV9CaseStatus, } from "@/lib/rectification-agentic/v9/tool-service"; import { decideFromDossier, rectificationFollowupCatalog } from "@/lib/rectification-agentic/v9/decision-from-dossier"; -import { applyRectificationChoice } from "@/lib/rectification-agentic/v9/answer-choice"; +import { applyRectificationChoice, applyCollectFocusDenial, persistNextInterviewIfIdle } from "@/lib/rectification-agentic/v9/answer-choice"; import { mapRectificationRpcError } from "@/lib/rectification-agentic/v9/case-service"; import { CHOICE_ACTION, STOP_ACTION } from "@/lib/rectification-agentic/v9/choice-action"; import { runV9AgentTurn, type V9RunBilling } from "@/lib/rectification-agentic/v9/agent-run"; @@ -29,8 +29,9 @@ import { parseAgentChoiceCopy } from "@/lib/rectification-agentic/v9/choice-card import { classifyRectificationTurnIntent, optionIdForAnswerClass, + shouldDeclineCollectFocus, } from "@/lib/rectification-agentic/v9/turn-intent-classifier"; -import { persistServerOwnedFocus, openQuestionFromPersistedFocus } from "@/lib/rectification-agentic/v9/server-focus"; +import { persistServerOwnedFocus, openQuestionFromPersistedFocus, isCollectFocusSchema } from "@/lib/rectification-agentic/v9/server-focus"; import { buildMethodFollowupPlan } from "@/lib/rectification-agentic/v9/method-followup"; export const runtime = "nodejs"; @@ -385,6 +386,31 @@ export async function POST(request: Request) { await transitionV9CaseStatus(accounting, userId, caseId, "paused"); return completedMessageResponse(applied.narration, requestId, caseId); } + } else if (focus && isCollectFocusSchema(focus.expectedAnswerSchema)) { + let classified = null; + try { + classified = await classifyRectificationTurnIntent(selectedModel, { + focus, + userMessage: parsed.data.message ?? "", + caseStatus, + signal: request.signal, + }); + } catch { + classified = null; + } + if (shouldDeclineCollectFocus(classified)) { + const applied = await applyCollectFocusDenial(accounting, { + userId, + caseId, + focusId: focus.id, + }); + 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; try { @@ -617,6 +643,19 @@ export async function POST(request: Request) { if (!result.ok) { send({ type: "error", message: "生时校正暂时不可用,请稍后重试。" }); } else { + if (action === "message") { + try { + await persistNextInterviewIfIdle({ + accounting: accounting as never, + userId, + caseId, + }); + } catch (error) { + console.warn( + `[rectification-v9] persist next interview after turn failed case=${caseId} reason=${error instanceof Error ? error.name : "Unknown"}`, + ); + } + } send({ type: "done", emitted: true }); } } catch (error) { diff --git a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts index ef1ff0a4..63ef5a00 100644 --- a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts +++ b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts @@ -13,7 +13,7 @@ import { applyChoiceWithoutEvidence, previousInferenceFromReceipt, } from "./inference-adapter"; -import { decideAfterInferenceChange, rectificationFollowupCatalog } from "./decision-from-dossier"; +import { decideAfterInferenceChange, decideFromDossier, rectificationFollowupCatalog } from "./decision-from-dossier"; import type { InferenceState } from "../core/types.ts"; import { CHOICE_ACTION, @@ -32,10 +32,12 @@ import { loadV9CaseDossier, persistV9ChoiceAction, persistV9DeterministicTurn, + resolveV10ConversationFocus, RectificationToolServiceError, safeToolErrorCode, type AccountingClient, type PersistChoiceActionInput, + type V9CaseDossier, } from "./tool-service"; import type { ChoiceKey } from "./choice-card"; import { persistServerOwnedFocus, openQuestionFromPersistedFocus } from "./server-focus"; @@ -84,6 +86,31 @@ function asText(value: unknown): string | null { return typeof value === "string" && value.trim() ? value.trim() : null; } +function dossierWithClosedFocus>[]; + }; +}>(dossier: T, status: "resolved" | "declined" | "skipped"): T { + const domain = dossier.conversationSummary.activeFocus?.targetDomain ?? null; + const declinedSkippedTopics = ( + (status === "declined" || status === "skipped") && domain + ? [ + ...dossier.conversationSummary.declinedSkippedTopics, + { target_domain: domain, status }, + ] + : dossier.conversationSummary.declinedSkippedTopics + ); + return { + ...dossier, + conversationSummary: { + ...dossier.conversationSummary, + activeFocus: null, + declinedSkippedTopics, + }, + }; +} + export async function applyRectificationChoice( accounting: AccountingClient, command: ApplyChoiceCommand, @@ -245,7 +272,7 @@ export async function applyRectificationChoice( }); } -async function persistNextInterviewAfterChoice(input: { +export async function persistNextInterviewAfterChoice(input: { accounting: AccountingClient; userId: string; caseId: string; @@ -359,6 +386,105 @@ async function persistFocusAfterChoice(input: { } } +export async function applyCollectFocusDenial( + accounting: AccountingClient, + input: { userId: string; caseId: string; focusId: string }, +): Promise<{ narration: string; nextInterviewPersisted: boolean; nextChoiceReady: boolean }> { + const dossier = await loadV9CaseDossier(accounting, input.userId, input.caseId); + const focus = dossier.conversationSummary.activeFocus; + if (!focus || focus.id !== input.focusId) { + throw new RectificationToolServiceError("agentic_rectification_focus_not_active"); + } + await resolveV10ConversationFocus(accounting, input.userId, input.caseId, { + focusId: input.focusId, + status: "declined", + evidenceId: null, + }); + let birthDate: string | null = null; + try { + const compute = await loadV9CaseCompute(accounting, input.userId, input.caseId); + birthDate = String(compute.baselineBirthSnapshot.birth_date ?? "") || null; + } catch { + birthDate = null; + } + let latest: V9CaseDossier = dossier; + try { + latest = await loadV9CaseDossier(accounting, input.userId, input.caseId); + } catch { + latest = dossier; + } + const withDeclined = dossierWithClosedFocus({ + ...latest, + conversationSummary: { + ...latest.conversationSummary, + activeFocus: { + ...focus, + targetDomain: focus.targetDomain, + }, + }, + }, "declined"); + const nextAction = publicNextAction(decideFromDossier(withDeclined, { birthDate })); + const nextInterview = await persistNextInterviewAfterChoice({ + accounting, + userId: input.userId, + caseId: input.caseId, + dossier: withDeclined, + decisionState: previousInferenceFromReceipt(withDeclined.latestResult?.decisionReceipt ?? null), + nextAction, + birthDate, + }); + return { + narration: nextInterview.hostNarration ?? "记下了,这方面先跳过。", + nextInterviewPersisted: Boolean(nextInterview.hostNarration) || nextInterview.choiceReady, + nextChoiceReady: nextInterview.choiceReady, + }; +} + +export async function persistNextInterviewIfIdle(input: { + accounting: AccountingClient; + userId: string; + caseId: string; +}): Promise<{ persisted: boolean; choiceReady: boolean }> { + const dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId); + if (dossier.conversationSummary.activeFocus) { + return { persisted: false, choiceReady: false }; + } + let birthDate: string | null = null; + try { + const compute = await loadV9CaseCompute(input.accounting, input.userId, input.caseId); + birthDate = String(compute.baselineBirthSnapshot.birth_date ?? "") || null; + } catch { + birthDate = null; + } + const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence); + const plan = buildMethodFollowupPlan({ + evidence: dossier.evidence, + activeFocus: null, + declinedTopics: dossier.conversationSummary.declinedSkippedTopics, + sessionOutcome: "collect_evidence", + ...catalog, + birthDate, + accepted: Boolean(dossier.case.acceptedTime), + }); + if (!plan.next_followup) { + return { persisted: false, choiceReady: false }; + } + const nextAction = publicNextAction(decideFromDossier(dossier, { birthDate })); + const nextInterview = await persistNextInterviewAfterChoice({ + accounting: input.accounting, + userId: input.userId, + caseId: input.caseId, + dossier, + decisionState: previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null), + nextAction, + birthDate, + }); + return { + persisted: Boolean(nextInterview.hostNarration) || nextInterview.choiceReady, + choiceReady: nextInterview.choiceReady, + }; +} + async function persistApplied( accounting: AccountingClient, command: ApplyChoiceCommand, @@ -422,7 +548,7 @@ async function persistApplied( accounting, userId: command.userId, caseId: command.caseId, - dossier: input.dossier, + dossier: dossierWithClosedFocus(input.dossier, input.focusStatus), decisionState: input.decisionState ?? null, nextAction, birthDate, diff --git a/frontend/src/lib/rectification-agentic/v9/choice-card.ts b/frontend/src/lib/rectification-agentic/v9/choice-card.ts index ced91e8d..9941f35d 100644 --- a/frontend/src/lib/rectification-agentic/v9/choice-card.ts +++ b/frontend/src/lib/rectification-agentic/v9/choice-card.ts @@ -278,11 +278,14 @@ function eventQuestionPrompt( const time = period.trim(); const topic = family.replace(/[??。]+$/g, "").trim(); if (!topic) return time; - if (kind === "event_quality") return `${time},有没有${topic}的时候?`; - if (kind === "varga_style") { - return isConcreteChoicePeriod(period) ? `${time},${topic}?` : `${topic}?`; + const dated = isConcreteChoicePeriod(period); + if (kind === "event_quality") { + return dated ? `${time},有没有${topic}的时候?` : `有没有${topic}的时候?`; } - return `${time},有没有${topic}?`; + if (kind === "varga_style") { + return dated ? `${time},${topic}?` : `${topic}?`; + } + return dated ? `${time},有没有${topic}?` : `有没有${topic}?`; } function withStyleOptionLabels( @@ -331,7 +334,8 @@ function hypothesisFor( if (!styleOptions.ok) return null; const period = periodFor(evidence, domain, probes, birthDate, followup); const kind = followup.choice_kind ?? probe.choice_kind ?? "existence"; - if (kind !== "varga_style" && !isConcreteChoicePeriod(period)) return null; + const yearlessVarga = (followup.semantic_key ?? probe.semantic_key ?? "").startsWith("varga."); + if (kind !== "varga_style" && !isConcreteChoicePeriod(period) && !yearlessVarga) return null; const prompt = eventQuestionPrompt(period, probe.event_family, kind); const why = probe.user_meaning?.trim() || followup.user_prompt_hint.trim(); if (!why) return null; diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index a57c7de1..01f416ef 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -44,9 +44,10 @@ * do not stamp a ledger year onto a yearless scoring card. An open varga * discriminator must resolve from the contrast packet when Python event * probes have no row in that domain. Scoring reverse-inference cards need - * the engine year or month. A yearless D12/family contrast does not attach - * A/B/C/D; skip it and either ask the next dated discriminator or collect a - * dated family event in natural language. + * the engine year or month. Yearless varga cards stay out of the dated + * ranking until coverage is complete. If the next ask would be a spoken + * collect in the same domain, prefer a renderable yearless scoring card + * instead of an unscoreable collect question. */ import { @@ -1477,9 +1478,17 @@ export function buildMethodFollowupPlan(input: { } } } + const sameDomainYearlessCard = (domain: string): MethodFollowup | null => { + const ranked = yearlessDiscriminators.find((row) => ( + (row.eventProbe?.domain ?? row.contrastProbe?.domain ?? null) === domain + )); + if (!ranked) return null; + const candidate = followupFromRanked(ranked); + return candidate.choice_frame ? candidate : null; + }; if (!next) { if (!relationshipCovered && !declined.has("relationship")) { - next = makeFollowup({ + next = sameDomainYearlessCard("relationship") ?? makeFollowup({ method_id: "d9_relationship", intent: "collect_method_evidence", ask_theme: "relationship_style", @@ -1493,7 +1502,7 @@ export function buildMethodFollowupPlan(input: { source: "method_coverage", }); } else if (!careerCovered && !declined.has("career")) { - next = makeFollowup({ + next = sameDomainYearlessCard("career") ?? makeFollowup({ method_id: "d10_career", intent: "collect_method_evidence", ask_theme: "career_style", @@ -1508,7 +1517,7 @@ export function buildMethodFollowupPlan(input: { }); } else if (!familyCovered && !declined.has("family")) { const familyCollect = datedCollectionProbe(input.evidenceCollectionProbes, "family"); - next = makeFollowup({ + next = sameDomainYearlessCard("family") ?? makeFollowup({ method_id: "relatives", intent: "collect_method_evidence", ask_theme: "family_event", @@ -1523,7 +1532,7 @@ export function buildMethodFollowupPlan(input: { ...collectionYearFields(familyCollect), }); } else if (!occupationCovered) { - next = makeFollowup({ + next = sameDomainYearlessCard("occupation") ?? makeFollowup({ method_id: "occupation", intent: "collect_method_evidence", ask_theme: "occupation", @@ -1745,7 +1754,7 @@ export function buildMethodFollowupPlan(input: { ); if (fields) next = makeFollowup(fields, false, true); } else if (horaryStatus === "uncovered") { - next = makeFollowup({ + next = sameDomainYearlessCard("horary") ?? makeFollowup({ method_id: "horary", intent: "collect_method_evidence", ask_theme: "horary", 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 1b41ee0c..5aa73db6 100644 --- a/frontend/src/lib/rectification-agentic/v9/turn-intent-classifier.ts +++ b/frontend/src/lib/rectification-agentic/v9/turn-intent-classifier.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import type { ResolvedLanguageModel } from "@/mastra/model"; import type { AnswerClass } from "../core/types"; import { parseAgentChoiceCopy, type ChoiceKey } from "./choice-card"; +import { isCollectFocusSchema } from "./server-focus"; import type { ConversationFocus } from "./tool-service"; const turnIntentSchema = z.object({ @@ -28,6 +29,12 @@ export function parseRectificationTurnIntent(value: unknown): RectificationTurnI return parsed.data.answer_class === null ? parsed.data : null; } +export function shouldDeclineCollectFocus( + classified: RectificationTurnIntent | null, +): boolean { + return classified?.intent === "answer_current_focus" && classified.answer_class === "no"; +} + export function optionIdForAnswerClass( focus: ConversationFocus, answerClass: AnswerClass, @@ -46,23 +53,33 @@ export async function classifyRectificationTurnIntent( }, ): Promise { const choice = parseAgentChoiceCopy(input.focus.expectedAnswerSchema); - if (!choice) return null; + const collectPrompt = isCollectFocusSchema(input.focus.expectedAnswerSchema) + ? String(input.focus.expectedAnswerSchema.prompt ?? "").trim() + : ""; + if (!choice && !collectPrompt) return null; const agent = new Agent({ id: `rectification-focus-intent-${model.id}`, name: "Rectification Focus Intent Classifier", model: model.model, - instructions: `你只做当前生时校正问题的意图分类,不回答用户,也不修改任何状态。 + instructions: choice + ? `你只做当前生时校正问题的意图分类,不回答用户,也不修改任何状态。 结合当前问题和动态选项判断用户是在回答当前问题、提供新的带时间经历、要求停止整个校正、询问结果,还是语义不清。 若是在回答当前问题,answer_class 必须使用某个选项提供的 answer_class;否则 answer_class 必须为 null。 “当前方面没有、那段时间没有变化”通常是回答当前问题,不是停止整个流程;只有用户明确要求停止整个校正时才分类为 stop_rectification。 -不要按 A/B/C/D 的位置猜语义,只按选项 label 与 answer_class 判断。`, +不要按 A/B/C/D 的位置猜语义,只按选项 label 与 answer_class 判断。` + : `你只做当前生时校正采集题的意图分类,不回答用户,也不修改任何状态。 +当前问题没有点选选项。判断用户是在回答当前采集题、提供新的带时间经历、要求停止整个校正、询问结果,还是语义不清。 +若用户明确表示这个方面没有发生过、没有这类事,intent 为 answer_current_focus,answer_class 为 no。 +若用户在补充带时间的经历,intent 为 provide_new_evidence,answer_class 必须为 null。 +“当前方面没有”通常是回答当前采集题,不是停止整个流程;只有用户明确要求停止整个校正时才分类为 stop_rectification。 +不要按关键词表或正则猜测,只根据当前问题与用户这句话的语义分类。`, }); const result = await agent.generate([{ role: "user", content: JSON.stringify({ - current_question: choice.prompt, - options: choice.options, + current_question: choice?.prompt ?? collectPrompt, + options: choice?.options ?? [], user_message: input.userMessage, case_status: input.caseStatus, }), diff --git a/frontend/src/mastra/rectification-v9-tools.ts b/frontend/src/mastra/rectification-v9-tools.ts index b8119f4a..e0268079 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。不得从中文措辞或上一条助手消息猜测目标。", + "处理用户对当前问题的明确拒答、跳过或无证据式解决。必须引用服务器返回的 active focusId;若目标是既有证据,可同时引用 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-choice-card.test.ts b/frontend/tests/rectification-choice-card.test.ts index f45f034d..da0760f6 100644 --- a/frontend/tests/rectification-choice-card.test.ts +++ b/frontend/tests/rectification-choice-card.test.ts @@ -342,16 +342,19 @@ test("yearless D24 quality cards do not borrow a recorded education year", () => style_options: QUALITY_STYLE_OPTIONS, }], }); - assert.equal(frame, null); + assert.ok(frame); + assert.doesNotMatch(frame.period, /2016/); + assert.doesNotMatch(frame.prompt, /2016/); }); -test("yearless family cards do not attach a scoring frame", () => { +test("yearless family cards attach a scoring frame without borrowing another domain's year", () => { const frame = buildChoiceFrame({ method_id: "relatives", ask_theme: "family_event", domain: "family", user_prompt_hint: "unused", choice_kind: "existence", + semantic_key: "varga.d12.05:00/05:07", }, { evidence: [{ status: "confirmed", @@ -370,7 +373,10 @@ test("yearless family cards do not attach a scoring frame", () => { semantic_key: "varga.d12.05:00/05:07", }], }); - assert.equal(frame, null); + assert.ok(frame); + assert.doesNotMatch(frame.period, /2016/); + assert.doesNotMatch(frame.prompt, /2016/); + assert.match(frame.neither_label + frame.option_a_hint + frame.option_b_hint, /明确没有发生|家人/); }); test("yearless family cards do not borrow a recorded family year", () => { @@ -380,6 +386,7 @@ test("yearless family cards do not borrow a recorded family year", () => { domain: "family", user_prompt_hint: "unused", choice_kind: "existence", + semantic_key: "varga.d12.05:00/05:07", }, { evidence: [{ status: "confirmed", @@ -398,7 +405,9 @@ test("yearless family cards do not borrow a recorded family year", () => { semantic_key: "varga.d12.05:00/05:07", }], }); - assert.equal(frame, null); + assert.ok(frame); + assert.doesNotMatch(frame.period, /2018/); + assert.doesNotMatch(frame.prompt, /2018/); }); test("GET upgrades a persisted placeholder stem to the recorded year lock", () => { diff --git a/frontend/tests/rectification-collect-stall.test.ts b/frontend/tests/rectification-collect-stall.test.ts new file mode 100644 index 00000000..7e50e120 --- /dev/null +++ b/frontend/tests/rectification-collect-stall.test.ts @@ -0,0 +1,748 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { applyAnswerToState, buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts"; +import { applyChoiceWithoutEvidence } from "../src/lib/rectification-agentic/v9/inference-adapter.ts"; +import type { ConflictProbe } from "../src/lib/rectification-agentic/core/types.ts"; +import { + rectificationFollowupCatalog, + type DecisionDossier, +} from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts"; +import { buildMethodFollowupPlan } from "../src/lib/rectification-agentic/v9/method-followup.ts"; +import { focusStatusForAnswer } from "../src/lib/rectification-agentic/v9/choice-action.ts"; +import { serverOwnedChoiceCopy } from "../src/lib/rectification-agentic/v9/choice-card.ts"; +import { persistServerOwnedFocus } from "../src/lib/rectification-agentic/v9/server-focus.ts"; +import { projectCurrentQuestion } from "../src/lib/rectification-agentic/v9/turn-decision.ts"; +import { + shouldDeclineCollectFocus, +} from "../src/lib/rectification-agentic/v9/turn-intent-classifier.ts"; +import { + applyCollectFocusDenial, + persistNextInterviewIfIdle, +} from "../src/lib/rectification-agentic/v9/answer-choice.ts"; +import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts"; +import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts"; +import { + CASE_ID, + FOCUS_ID, + TURN_ID, + USER_ID, + candidateSnapshotFixture, + computeFixture, + dossierFixture, + fakeAccounting, + receiptHandlers, +} from "./rectification-v9-test-support.ts"; + +const EXISTENCE_OPTIONS = [ + { label: "明确发生且时间吻合", answer_class: "yes" as const }, + { label: "发生过但程度较弱", answer_class: "weak_yes" as const }, + { label: "明确没有发生", answer_class: "no" as const }, + { label: "这段记不清楚", answer_class: "unsure" as const }, +]; + +const CANDIDATES = [ + { id: "04:47", time: "04:47", relative_support: 8 }, + { id: "04:51", time: "04:51", relative_support: 9 }, + { id: "04:53", time: "04:53", relative_support: 9 }, + { id: "04:59", time: "04:59", relative_support: 10 }, + { id: "05:00", time: "05:00", relative_support: 18 }, + { id: "05:07", time: "05:07", relative_support: 16 }, + { id: "05:12", time: "05:12", relative_support: 11 }, + { id: "05:14", time: "05:14", relative_support: 7 }, + { id: "05:15", time: "05:15", relative_support: 6 }, +] as const; + +const EVIDENCE = [ + { + id: "e-career-entry", + status: "confirmed", + domain: "career", + datePrecision: "month", + occurredFrom: "2020-04-01", + occurredTo: null, + eventKind: "career_entry", + }, + { + id: "e-career-exit", + status: "confirmed", + domain: "career", + datePrecision: "month", + occurredFrom: "2020-10-01", + occurredTo: null, + eventKind: "career_exit", + }, + { + id: "e-rel-end", + status: "confirmed", + domain: "relationship", + datePrecision: "day", + occurredFrom: "2024-08-08", + occurredTo: null, + eventKind: "relationship_end", + }, + { + id: "e-rel-start", + status: "confirmed", + domain: "relationship", + datePrecision: "month", + occurredFrom: "2024-05-01", + occurredTo: null, + eventKind: "relationship_start", + }, +] as const; + +const FAMILY_2021_COLLECT = { + year: 2021, + year_label: "2021 年前后", + domain: "family" as const, + event_family: "家人结婚、添丁或住院", + source: "age_band" as const, + tracks: ["vimshottari", "narayana"] as const, + tracks_agree: false, + unique_minute_claim: false as const, + user_meaning: "时间范围锁定 2021 年前后;领域锁定 family。", + role: "collect" as const, + phase: "evidence_collection" as const, + information_gain: 0, + semantic_key: "family.2021", + candidate_split_hash: "family:2021", + candidate_ids: [] as const, + expected_outcomes: [] as const, + choice_kind: "existence" as const, +}; + +function vargaExistence(input: { + layer: string; + gain: number; + domain: string; + question: string; + supports: readonly string[]; + conflicts: readonly string[]; +}): ConflictProbe { + const key = `varga.${input.layer}.${input.supports.join("/")}`; + return { + id: `contrast:${key}`, + semantic_key: key, + candidate_split_hash: key, + domain: input.domain, + year: 0, + question: input.question, + candidate_ids: [...new Set([...input.supports, ...input.conflicts])], + expected_outcomes: [ + { answer_class: "yes", supports: input.conflicts, conflicts: input.supports }, + { answer_class: "weak_yes", supports: input.conflicts, conflicts: [] }, + { answer_class: "no", supports: input.supports, conflicts: input.conflicts }, + { answer_class: "unsure", supports: [], conflicts: [] }, + ], + information_gain: input.gain, + source: "varga_contrast", + choice_kind: "existence", + style_options: EXISTENCE_OPTIONS, + }; +} + +const D9: ConflictProbe = { + id: "contrast:varga.d9.巨蟹座/狮子座", + semantic_key: "varga.d9.巨蟹座/狮子座", + candidate_split_hash: "varga.d9.巨蟹座/狮子座", + domain: "relationship", + year: 0, + question: "亲密关系里更接近下面哪一种相处方式?", + candidate_ids: ["05:00", "05:07"], + expected_outcomes: [ + { answer_class: "yes", supports: ["05:00"], conflicts: ["05:07"] }, + { answer_class: "weak_yes", supports: ["05:07"], conflicts: ["05:00"] }, + { answer_class: "no", supports: [], conflicts: [] }, + { answer_class: "unsure", supports: [], conflicts: [] }, + ], + information_gain: 1.1, + source: "varga_contrast", + choice_kind: "varga_style", +}; + +const D10: ConflictProbe = { + id: "contrast:varga.d10.天秤座/天蝎座", + semantic_key: "varga.d10.天秤座/天蝎座", + candidate_split_hash: "varga.d10.天秤座/天蝎座", + domain: "career", + year: 0, + question: "平时做事更接近下面哪一种职责风格?", + candidate_ids: ["05:00", "05:07"], + expected_outcomes: [ + { answer_class: "yes", supports: ["05:00"], conflicts: ["05:07"] }, + { answer_class: "weak_yes", supports: ["05:07"], conflicts: ["05:00"] }, + { answer_class: "no", supports: [], conflicts: [] }, + { answer_class: "unsure", supports: [], conflicts: [] }, + ], + information_gain: 1.05, + source: "varga_contrast", + choice_kind: "varga_style", +}; + +const CAREER_2023: ConflictProbe = { + id: "probe:career.2023.dasha_boundary", + semantic_key: "career.2023.dasha_boundary", + candidate_split_hash: "career.2023", + domain: "career", + year: 2023, + question: "2023 年前后有没有入职或换工作?", + candidate_ids: ["05:00", "05:07"], + expected_outcomes: [ + { answer_class: "yes", supports: ["05:00"], conflicts: ["05:07"] }, + { answer_class: "no", supports: ["05:07"], conflicts: ["05:00"] }, + { answer_class: "unsure", supports: [], conflicts: [] }, + ], + information_gain: 0.72, + source: "dasha_boundary", + choice_kind: "existence", + style_options: EXISTENCE_OPTIONS, +}; + +const RELOCATION_2015: ConflictProbe = { + id: "probe:relocation.2015.dasha_boundary", + semantic_key: "relocation.2015.dasha_boundary", + candidate_split_hash: "relocation.2015", + domain: "relocation", + year: 2015, + question: "2015 年前后有没有搬家或长期住到外地?", + candidate_ids: ["05:00", "05:07"], + expected_outcomes: [ + { answer_class: "yes", supports: ["05:00"], conflicts: ["05:07"] }, + { answer_class: "no", supports: ["05:07"], conflicts: ["05:00"] }, + { answer_class: "unsure", supports: [], conflicts: [] }, + ], + information_gain: 0.61, + source: "dasha_boundary", + choice_kind: "existence", + style_options: EXISTENCE_OPTIONS, +}; + +const D12 = vargaExistence({ + layer: "d12", + gain: 1.89, + domain: "family", + question: "家里有没有结婚、添丁或住院这类事?", + supports: ["05:00", "05:07", "05:12"], + conflicts: ["04:47", "04:51", "04:53", "04:59"], +}); + +const D24 = vargaExistence({ + layer: "d24", + gain: 2.5, + domain: "education", + question: "有没有学业或考试发挥明显失常、压力特别大的时候?", + supports: ["05:00"], + conflicts: ["05:07"], +}); + +const D7 = vargaExistence({ + layer: "d7", + gain: 1.35, + domain: "family", + question: "有没有子女或子嗣相关的家里变化?", + supports: ["05:00", "05:07"], + conflicts: ["04:47"], +}); + +const D4 = vargaExistence({ + layer: "d4", + gain: 0.99, + domain: "relocation", + question: "有没有搬家或长期住到外地?", + supports: ["05:00"], + conflicts: ["05:07"], +}); + +const D5 = vargaExistence({ + layer: "d5", + gain: 0.5, + domain: "education", + question: "有没有记得住年份的升学或考试?", + supports: ["05:00"], + conflicts: ["05:07"], +}); + +const DATED_RELOCATION_2016: ConflictProbe = { + id: "probe:relocation.2016.dasha_boundary", + semantic_key: "relocation.2016.dasha_boundary", + candidate_split_hash: "relocation.2016", + domain: "relocation", + year: 2016, + question: "2016 年前后有没有搬家或长期住到外地?", + candidate_ids: ["05:00", "05:07"], + expected_outcomes: [ + { answer_class: "yes", supports: ["05:00"], conflicts: ["05:07"] }, + { answer_class: "no", supports: ["05:07"], conflicts: ["05:00"] }, + { answer_class: "unsure", supports: [], conflicts: [] }, + ], + information_gain: 0.8, + source: "dasha_boundary", + choice_kind: "existence", + style_options: EXISTENCE_OPTIONS, +}; + +function revision5State(extraProbes: readonly ConflictProbe[] = []) { + const probes = [ + D9, + D10, + CAREER_2023, + RELOCATION_2015, + D24, + D12, + D7, + D4, + D5, + ...extraProbes, + ]; + let state = buildInferenceState({ + range_start: "04:47", + range_end: "05:15", + candidates: CANDIDATES, + events: [ + { id: "e-career-entry", domain: "career", year: 2020, precision: "month" }, + { id: "e-career-exit", domain: "career", year: 2020, precision: "month" }, + { id: "e-rel-end", domain: "relationship", year: 2024, precision: "day" }, + { id: "e-rel-start", domain: "relationship", year: 2024, precision: "month" }, + ], + probes, + }); + const answers: ReadonlyArray<{ id: string; answer: "yes" | "weak_yes" | "no" }> = [ + { id: D9.id, answer: "weak_yes" }, + { id: D10.id, answer: "weak_yes" }, + { id: CAREER_2023.id, answer: "no" }, + { id: RELOCATION_2015.id, answer: "no" }, + ]; + for (const item of answers) { + state = applyAnswerToState(state, item.id, item.answer); + } + return state; +} + +function revision5Dossier( + state = revision5State(), + extra?: { declinedTopics?: ReadonlyArray>; activeFocus?: DecisionDossier["conversationSummary"]["activeFocus"] }, +): DecisionDossier { + return { + evidence: EVIDENCE, + conversationSummary: { + activeFocus: extra?.activeFocus ?? null, + declinedSkippedTopics: extra?.declinedTopics ?? [], + }, + latestResult: { + resultId: "55555555-5555-4555-8555-555555555555", + candidates: state.candidates.map((item) => ({ + candidateId: item.id, + time: item.time, + rank: item.rank, + relativeSupport: Math.round(item.posterior_score), + })), + representativeTime: state.representative_time, + evidenceLedgerFingerprint: evidenceLedgerFingerprint(EVIDENCE as never), + decisionReceipt: { + inference_state: state, + evidence_collection_probes: [FAMILY_2021_COLLECT], + }, + }, + case: { acceptedTime: null }, + }; +} + +function planFrom(dossier: DecisionDossier) { + const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence); + return buildMethodFollowupPlan({ + evidence: dossier.evidence, + declinedTopics: dossier.conversationSummary.declinedSkippedTopics, + sessionOutcome: "discriminate_candidates", + ...catalog, + candidatesSeparated: false, + }); +} + +function rpcDossier(decision: DecisionDossier, activeFocus?: Record | null) { + return dossierFixture({ + evidence: EVIDENCE.map((item) => ({ + id: item.id, + source_turn_id: TURN_ID, + subject: "self", + event_kind: item.eventKind, + domain: item.domain, + occurred_from: item.occurredFrom, + occurred_to: item.occurredTo, + date_precision: item.datePrecision, + summary: `${item.occurredFrom} ${item.eventKind}`, + status: item.status, + supersedes_evidence_id: null, + created_at: "2026-08-29T00:00:00.000Z", + })), + latestResult: candidateSnapshotFixture({ + decisionReceipt: { ...(decision.latestResult?.decisionReceipt ?? {}) }, + }), + conversationSummary: { + confirmed_evidence_summary: [], + pending_revisions: [], + active_focus: activeFocus ?? null, + declined_skipped_topics: decision.conversationSummary.declinedSkippedTopics, + candidate_divergence_summary: null, + missing_evidence_categories: [], + last_result_policy: null, + summary_version: 1, + updated_at: "2026-08-29T00:00:00.000Z", + }, + }); +} + +test("skill version stays 10.0.13 for this collect-stall fix", () => { + assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.13"); +}); + +test("revision 5 with uncovered relatives asks the yearless D12 distinguish card, not a family spoken collect", () => { + const state = revision5State(); + assert.equal(state.revision, 5); + assert.equal(state.answered_probes.length, 4); + assert.equal(state.events.find((item) => item.id === "e-rel-start")?.usage, "holdout"); + const unanswered = state.probes.filter((probe) => ( + !state.answered_probes.some((item) => item.probe_id === probe.id) + )); + assert.equal(unanswered.some((item) => item.semantic_key.startsWith("varga.d12")), true); + assert.equal(unanswered.find((item) => item.semantic_key.startsWith("varga.d12"))?.information_gain, 1.89); + + const plan = planFrom(revision5Dossier(state)); + assert.equal(plan.methods.find((item) => item.method_id === "relatives")?.status, "uncovered"); + assert.equal(plan.next_followup?.intent, "distinguish_candidates"); + assert.match(plan.next_followup?.semantic_key ?? "", /^varga\.d12/); + assert.ok(plan.next_followup?.choice_frame); + assert.notEqual(plan.next_followup?.source, "method_coverage"); + const copy = serverOwnedChoiceCopy(plan.next_followup!.choice_frame!); + assert.ok(copy?.options.some((item) => item.label === "明确没有发生" && item.answer_class === "no")); + assert.equal(copy?.options.find((item) => item.key === "C")?.answer_class, "no"); +}); + +test("answering C on the D12 card declines the family focus and scores the no branch", () => { + const state = revision5State(); + const plan = planFrom(revision5Dossier(state)); + assert.match(plan.next_followup?.semantic_key ?? "", /^varga\.d12/); + assert.equal(plan.next_followup?.domain, "family"); + const copy = serverOwnedChoiceCopy(plan.next_followup!.choice_frame!); + assert.equal(copy?.options.find((item) => item.key === "C")?.answer_class, "no"); + assert.equal(focusStatusForAnswer("no", "C"), "declined"); + + const applied = applyChoiceWithoutEvidence(state, { + choiceKey: "C", + schema: { + choice: copy, + probe_id: D12.id, + semantic_key: D12.semantic_key, + candidate_split_hash: D12.candidate_split_hash, + }, + }); + assert.equal(applied.applied, true); + assert.equal(applied.answerClass, "no"); + assert.equal(applied.probeId, D12.id); + const recorded = applied.state.answered_probes.find((item) => item.probe_id === D12.id); + assert.equal(recorded?.answer_class, "no"); + const noBranch = D12.expected_outcomes.find((item) => item.answer_class === "no"); + assert.deepEqual(noBranch?.supports, ["05:00", "05:07", "05:12"]); + assert.deepEqual(noBranch?.conflicts, ["04:47", "04:51", "04:53", "04:59"]); + + const next = planFrom(revision5Dossier(applied.state, { + declinedTopics: [{ target_domain: "family", status: "declined" }], + })); + assert.equal(next.methods.find((item) => item.method_id === "relatives")?.status, "covered"); + assert.equal(next.next_followup?.domain, "occupation"); + assert.equal(next.next_followup?.intent, "collect_method_evidence"); + assert.notEqual(next.next_followup?.domain, "family"); +}); + +test("persistServerOwnedFocus writes family as target_domain so a no answer becomes declined coverage", async () => { + const plan = planFrom(revision5Dossier()); + const accounting = fakeAccounting({ + ...receiptHandlers, + 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 persistServerOwnedFocus({ + accounting: accounting.client, + userId: USER_ID, + caseId: CASE_ID, + activeFocus: null, + decisionReceipt: revision5Dossier().latestResult?.decisionReceipt, + followup: plan.next_followup, + }); + assert.equal(persisted.status, "created"); + const call = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus"); + assert.equal(call?.args.p_target_domain, "family"); + assert.equal(call?.args.p_intent, "distinguish_candidates"); +}); + +test("occupation collect denial declines the focus and advances coverage to horary", async () => { + assert.equal(shouldDeclineCollectFocus({ + intent: "answer_current_focus", + answer_class: "no", + }), true); + assert.equal(shouldDeclineCollectFocus({ + intent: "provide_new_evidence", + answer_class: null, + }), false); + assert.equal(shouldDeclineCollectFocus({ + intent: "answer_current_focus", + answer_class: "unsure", + }), false); + + const occupationPlan = planFrom(revision5Dossier(revision5State(), { + declinedTopics: [{ target_domain: "family", status: "declined" }], + })); + assert.equal(occupationPlan.next_followup?.domain, "occupation"); + assert.equal(occupationPlan.next_followup?.intent, "collect_method_evidence"); + assert.equal(occupationPlan.next_followup?.choice_frame, null); + + 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, + }); + const resolve = accounting.calls.find((item) => item.fn === "resolve_agentic_rectification_conversation_focus"); + assert.equal(resolve?.args.p_status, "declined"); + assert.notEqual(resolve?.args.p_status, "resolved"); + const setFocus = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus"); + assert.equal(setFocus?.args.p_target_domain, "horary"); + assert.equal(setFocus?.args.p_intent, "collect_method_evidence"); + assert.ok(applied.narration); + + const horary = planFrom(revision5Dossier(revision5State(), { + declinedTopics: [ + { target_domain: "family", status: "declined" }, + { target_domain: "occupation", status: "declined" }, + ], + })); + assert.equal(horary.next_followup?.domain, "horary"); +}); + +test("free-text turns persist the next followup so current_question is not null", async () => { + const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8"); + const afterRun = route.slice(route.indexOf("const result = await runV9AgentTurn")); + assert.match(afterRun, /persistNextInterviewIfIdle/); + assert.ok(afterRun.indexOf("result.ok") < afterRun.indexOf("persistNextInterviewIfIdle")); + + const occupationPlan = planFrom(revision5Dossier(revision5State(), { + declinedTopics: [{ target_domain: "family", status: "declined" }], + })); + assert.ok(occupationPlan.next_followup); + + const accounting = fakeAccounting({ + ...receiptHandlers, + get_agentic_rectification_case_dossier: () => rpcDossier(revision5Dossier(revision5State(), { + declinedTopics: [{ target_domain: "family", status: "declined" }], + })), + 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); + const question = projectCurrentQuestion({ + id: FOCUS_ID, + questionId: String(setFocus?.args.p_question_id ?? ""), + intent: String(setFocus?.args.p_intent ?? ""), + targetDomain: typeof setFocus?.args.p_target_domain === "string" ? setFocus.args.p_target_domain : null, + expectedAnswerSchema: setFocus?.args.p_expected_answer_schema as Record, + }); + assert.notEqual(question, null); + assert.equal(question?.kind, "collect_spoken"); +}); + +test("coverage incomplete still prefers a dated discriminator over a same-turn yearless varga card", () => { + const state = revision5State([DATED_RELOCATION_2016]); + const plan = planFrom(revision5Dossier(state)); + assert.equal(plan.next_followup?.semantic_key, DATED_RELOCATION_2016.semantic_key); + assert.ok(plan.next_followup?.choice_frame); + assert.notEqual(plan.next_followup?.source, "method_coverage"); + assert.doesNotMatch(plan.next_followup?.semantic_key ?? "", /varga\.d12/); +}); + +test("yearless varga in another domain stays behind uncovered-method spoken collect", () => { + const evidence = [ + { + id: "e-career", + status: "confirmed", + domain: "career", + datePrecision: "year" as const, + occurredFrom: "2020-01-01", + occurredTo: null, + eventKind: "career_entry", + }, + { + id: "e-rel", + status: "confirmed", + domain: "relationship", + datePrecision: "year" as const, + occurredFrom: "2024-01-01", + occurredTo: null, + eventKind: "relationship_end", + }, + { + id: "e-edu", + status: "confirmed", + domain: "education", + datePrecision: "year" as const, + occurredFrom: "2016-01-01", + occurredTo: null, + eventKind: "education_start", + }, + { + id: "e-job", + status: "confirmed", + domain: "occupation", + datePrecision: "unknown" as const, + occurredFrom: null, + occurredTo: null, + eventKind: "occupation_note", + }, + ]; + const plan = buildMethodFollowupPlan({ + evidence, + contrastPacket: { + candidateSetVersion: "05:00-05:07", + vargaDifferences: [], + probes: [{ + probeId: D24.id, + candidateSetVersion: "05:00-05:07", + question: D24.question, + expectedOutcomes: D24.expected_outcomes.map((row) => ({ + outcomeId: row.answer_class, + supportsCandidateIds: [...row.supports], + conflictsCandidateIds: [...row.conflicts], + })), + candidateSplitHash: D24.candidate_split_hash, + informationGain: D24.information_gain, + sourceFeatures: [{ technique: "D24", calculationResultId: null }], + domain: "education", + year: null, + semanticKey: D24.semantic_key, + choiceKind: "existence", + styleOptions: EXISTENCE_OPTIONS.map((item) => ({ + label: item.label, + answerClass: item.answer_class, + })), + }], + }, + candidatesSeparated: false, + }); + assert.equal(plan.next_followup?.domain, "family"); + assert.doesNotMatch(plan.next_followup?.semantic_key ?? "", /varga\.d24/); +}); + +test("collect-focus classifier and resolve-focus copy do not treat explicit no as resolved", () => { + 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 tools = readFileSync(new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url), "utf8"); + const fastPath = route.slice( + route.indexOf('if (action === "message")'), + route.indexOf("const requestTime"), + ); + assert.match(fastPath, /isCollectFocusSchema/); + assert.match(fastPath, /shouldDeclineCollectFocus/); + assert.match(fastPath, /applyCollectFocusDenial/); + assert.match(classifier, /collect === true|isCollectFocusSchema/); + assert.doesNotMatch(classifier + fastPath, /USER_STOP_PATTERN|parseChoiceKeyFromUserMessage/); + const resolveTool = tools.slice( + tools.indexOf('id: "rectification-resolve-focus"'), + tools.indexOf("inputSchema: z.object({", tools.indexOf('id: "rectification-resolve-focus"')), + ); + assert.match(resolveTool, /declined/); + assert.match(resolveTool, /采集题|确实没有/); + assert.match(resolveTool, /resolved 只用于已落证据|已落证据/); +}); diff --git a/frontend/tests/rectification-discriminator-followup-consistency.test.ts b/frontend/tests/rectification-discriminator-followup-consistency.test.ts index 2486c851..86b44503 100644 --- a/frontend/tests/rectification-discriminator-followup-consistency.test.ts +++ b/frontend/tests/rectification-discriminator-followup-consistency.test.ts @@ -345,7 +345,7 @@ test("method coverage incomplete still asks a renderable D9 style card", () => { assert.equal(decision.probe?.semanticKey, plan.next_followup?.semantic_key); }); -test("clock-key varga_style without signs fail-closes to collect instead of a dead-end discriminator", () => { +test("clock-key varga_style without signs fail-closes to a same-domain D12 card instead of a dead-end discriminator", () => { const d9Clock: ConflictProbe = { ...D9_STYLE_NO_OPTIONS, id: "contrast:varga.d9.05:00/05:10", @@ -415,14 +415,16 @@ test("clock-key varga_style without signs fail-closes to collect instead of a de ...catalog, candidatesSeparated: false, }); - assert.equal(followupAsksRenderableDiscriminator(plan.next_followup), false); - assert.equal(plan.next_followup?.intent, "collect_method_evidence"); - assert.equal(plan.next_followup?.choice_frame, null); - assert.equal(plan.next_followup?.source, "method_coverage"); + assert.equal(followupAsksRenderableDiscriminator(plan.next_followup), true); + assert.equal(plan.next_followup?.intent, "distinguish_candidates"); + assert.match(plan.next_followup?.semantic_key ?? "", /^varga\.d12/); + assert.ok(plan.next_followup?.choice_frame); + assert.notEqual(plan.next_followup?.semantic_key, d9Clock.semantic_key); + assert.notEqual(plan.next_followup?.semantic_key, d10Clock.semantic_key); const decision = decideFromDossier(clockDossier); - assert.notEqual(decision.nextAction, "ask_candidate_discriminator"); - assert.equal(decision.nextAction, "ask_fact_collection"); + assert.equal(decision.nextAction, "ask_candidate_discriminator"); + assert.equal(decision.probe?.semanticKey, plan.next_followup?.semantic_key); assert.equal(decision.canConfirmExactMinute, false); }); diff --git a/frontend/tests/rectification-eight-method.test.ts b/frontend/tests/rectification-eight-method.test.ts index a46dca96..25b16559 100644 --- a/frontend/tests/rectification-eight-method.test.ts +++ b/frontend/tests/rectification-eight-method.test.ts @@ -2000,7 +2000,7 @@ test("yearless family contrast yields to a dated career discriminator", () => { assert.doesNotMatch(plan.next_followup?.choice_frame?.prompt ?? "", /那段时间/); }); -test("yearless family contrast without a dated discriminator collects a dated family event", () => { +test("yearless family contrast without a dated discriminator asks the D12 distinguish card", () => { const plan = buildMethodFollowupPlan({ evidence: [ ...CLASSIC_COVERAGE.filter((item) => item.domain !== "horary" && item.domain !== "family"), @@ -2013,11 +2013,11 @@ test("yearless family contrast without a dated discriminator collects a dated fa }, candidatesSeparated: false, }); - assert.equal(plan.next_followup?.intent, "collect_method_evidence"); + assert.equal(plan.next_followup?.intent, "distinguish_candidates"); assert.equal(plan.next_followup?.domain, "family"); - assert.equal(plan.next_followup?.choice_frame, null); - assert.match(plan.next_followup?.user_prompt_hint ?? "", /记得住时间/); - assert.doesNotMatch(plan.next_followup?.user_prompt_hint ?? "", /A\/B\/C\/D/); + assert.match(plan.next_followup?.semantic_key ?? "", /^varga\.d12/); + assert.ok(plan.next_followup?.choice_frame); + assert.notEqual(plan.next_followup?.source, "method_coverage"); }); test("family collect attaches the collection-probe year instead of a yearless D24 card", () => { @@ -2076,7 +2076,7 @@ test("family collect attaches the collection-probe year instead of a yearless D2 assert.match(spokenFollowupForUser(plan.next_followup) ?? "", /2021 年前后,家里有没有/); }); -test("already-open yearless family card does not keep a scoring frame", () => { +test("already-open yearless family card keeps the D12 scoring frame", () => { const plan = buildMethodFollowupPlan({ evidence: [ ...CLASSIC_COVERAGE.filter((item) => item.domain !== "horary" && item.domain !== "family"), @@ -2109,10 +2109,9 @@ test("already-open yearless family card does not keep a scoring frame", () => { }, }, }); - assert.notEqual(plan.next_followup?.choice_frame?.prompt, "那段时间,有没有家人相关的明显变化?"); - assert.equal(plan.next_followup?.choice_frame, null); - assert.equal(plan.next_followup?.intent, "collect_method_evidence"); - assert.equal(plan.next_followup?.domain, "family"); + assert.equal(plan.next_followup?.intent, "distinguish_candidates"); + assert.ok(plan.next_followup?.choice_frame); + assert.match(plan.next_followup?.semantic_key ?? "", /varga\.d12/); }); test("already-open dated career card stays ahead of a yearless D24 catalog row", () => {