fix(rectification): keep collection denials from stalling the interview
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:
@@ -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
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
|
||||
@@ -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_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,
|
||||
}),
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user