fix(rectification): keep collection denials from stalling the interview
Independent Staging Quality Gate / validate (push) Successful in 23m37s
Independent Staging Quality Gate / publish (push) Has been cancelled

A spoken no neither scored nor declined coverage, so relatives never closed and free-text turns left current_question null. Prefer a same-domain yearless scoring card before an unscoreable collect, resolve explicit collect denials as declined, and persist the next followup after an idle agent turn.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-29 22:37:32 +08:00
parent 187ef6ae24
commit 078c1cdc0d
11 changed files with 1014 additions and 45 deletions
+16
View File
@@ -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 覆盖后推进 occupationoccupation 口述明确否定 → declined 并推进 horary;自由文本轮结束后只要 plan 有 followup 则 `current_question` 非 null;覆盖未齐时仍先出可渲染有年份区分题,不同领域无年份分盘题仍不出。既有 eight-method / discriminator-followup 里「无年份家人对比在没有有年份区分题时改出口述题」的旧断言,去掉 P0 即变红,现改为期望 D12 卡。`npx tsc --noEmit` 通过;eslint 改动文件 0 error8 条既有 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
- 复发自:无
- 修复版本:待发布
@@ -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) {
@@ -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<T extends {
conversationSummary: {
activeFocus: { targetDomain?: string | null } | null;
declinedSkippedTopics: readonly Readonly<Record<string, unknown>>[];
};
}>(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,
@@ -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;
@@ -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",
@@ -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<RectificationTurnIntent | null> {
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_focusanswer_class 为 no。
若用户在补充带时间的经历,intent 为 provide_new_evidenceanswer_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,
}),
@@ -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(),
@@ -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", () => {
@@ -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<Record<string, unknown>>; 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<string, unknown> | 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<string, unknown>,
});
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 只用于已落证据|已落证据/);
});
@@ -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);
});
@@ -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", () => {