fix(rectification): prevent silent collect focus stalls
Independent Staging Quality Gate / validate (push) Successful in 20m38s
Independent Staging Quality Gate / publish (push) Successful in 24m14s

This commit is contained in:
Jesse_Chen
2026-08-30 23:07:17 +08:00
parent 0ae3e2d796
commit 79bfa73da3
5 changed files with 468 additions and 20 deletions
+16
View File
@@ -6926,3 +6926,19 @@
- 相关记录:BUG-352
- 复发自:无
- 修复版本:待 staging 部署;本地提交待生成
## BUG-452 | 采集焦点落库失败后静默结束
- 状态:resolved
- 首次发现:2026-08-30
- 最近更新:2026-08-30
- 影响面:`persistNextInterviewAfterChoice``persistFocusAfterChoice``persistExhaustionCollect`、POST `/api/rectification/agent` 非终态轮末出口
- 用户现象:答完家人采集题后助手只说「记下了,这方面先跳过。」,没有下一问也没有时间卡。
- 触发条件:上一采集焦点刚被关闭,下一条 `collect_method_evidence` 焦点落库返回 `duplicate_focus``skipped`;该轮同时没有可采用的候选承载。
- 根因:采集焦点落库失败时 `persistNextInterviewAfterChoice` 返回 `hostNarration=null`,随后被 denial 兜底文案「记下了,这方面先跳过。」吞掉;轮末也没有结构化不变量检查 `current_question` 或真实可采用承载,最终形成非终态但无可继续交互入口的静默死路。
- 修复:采集焦点首次落库失败后重读 dossier 并重试一次;若已存在同 `questionId` 的 active focus,则按 `already_open` 复用。重试仍失败时复用 `persistExhaustionCollect` 口径,正文写出可信区间、代表分钟和口述下一问,并尝试落一个可回答的采集焦点。自由文本轮末新增结构化非终态出口检查:若未 accepted、未 confirmed、未 user-stopped,且既无 `current_question` 也无真实可采用承载,则确定性补口述采集焦点、把题干写入正文,并记录不含用户内容的 `rectification_nonterminal_exit_repaired` 日志。删除会成为终局的 denial 单句兜底。
- 验证:修复前新增 fixture 证明 `duplicate_focus``skipped` 均会得到 `hostNarration=null``current_question=null`;修复后两条路径均有问题正文并持久化或复用非空焦点。新增非终态出口不变量回归,锁定服务端补出 `collect_spoken` 焦点和题干。复刻五证据、五条已答探针、活跃候选 `05:00/05:07/04:53`、family declined 的 case,锁定 `career.2023.dasha_activation``no_split_among_active` drop、`nextAction=offer_provisional_range``canAdopt=false`,轮末 `current_question.question_id=collect:occupation:collect_method_evidence` 且正文含「你长期做什么工作?」。`assert.doesNotMatch(afterRun, /answerText\.(?:includes|match|search)\(/)` 保持通过 本地 `npx tsc --noEmit`、改动文件 ESLint、目标四组回归 `122/122` 通过;focused 全套 `730/731`,唯一 Docker/PostgreSQL migration fixture 失败单独重跑 `1/1` 通过;全量基线 `2342/2347`,五项失败均为并行数据库容器/migration 环境波动或本机缺少 PyYAML,未扩大到产品逻辑。
- 防复发:非终态轮次结束时必须有 `current_question` 或真实可采用承载;任何采集焦点落库失败都不得静默降级为一句确认话。该不变量只看焦点与 decision/承载字段,不得用正文文本判断助手是否问出问题。不得为无选项题新造视觉容器或复用选择卡样式;不得引入语义正则、关键词表或 A/B/C/D 位置推断;`resolved``declined` 语义不得互换。
- 相关记录:BUG-441、BUG-449、BUG-450
- 复发自:BUG-450
- 修复版本:`fix(rectification): prevent silent collect focus stalls`Skill 保持 `10.0.13`
@@ -5,6 +5,7 @@ import {
evidenceLedgerFingerprint,
loadV9CaseCompute,
loadV9CaseDossier,
loadV9TurnReceipt,
persistV9DeterministicTurn,
RectificationToolServiceError,
transitionV9CaseStatus,
@@ -13,6 +14,7 @@ import { decideFromDossier, rectificationFollowupCatalog } from "@/lib/rectifica
import {
applyRectificationChoice,
applyCollectFocusDenial,
ensureNonTerminalTurnExit,
persistNextInterviewIfIdle,
persistCollectSpokenAssistantIfNew,
} from "@/lib/rectification-agentic/v9/answer-choice";
@@ -683,6 +685,22 @@ export async function POST(request: Request) {
`[rectification-v9] persist next interview after turn failed case=${caseId} reason=${error instanceof Error ? error.name : "Unknown"}`,
);
}
try {
const receipt = await loadV9TurnReceipt(accounting as never, userId, caseId, result.turnId);
const exit = await ensureNonTerminalTurnExit({
accounting: accounting as never,
userId,
caseId,
adoptCarrierReady: Boolean(receipt?.toolActivities.some((activity) => (
activity.tool === "rectification-offer-candidates" && activity.status === "completed"
))),
});
idleHostNarration = exit.hostNarration ?? idleHostNarration;
} catch (error) {
console.warn(
`[rectification-v9] nonterminal turn exit repair failed case=${caseId} reason=${error instanceof Error ? error.name : "Unknown"}`,
);
}
try {
const fallback = await persistCollectSpokenAssistantIfNew({
accounting: accounting as never,
@@ -52,6 +52,7 @@ import { isSafeCollectSpokenPrompt } from "./spoken-answer";
import {
collectSpokenPromptForNewFocus,
composeCollectSpokenAssistantText,
projectCurrentQuestion,
} from "./turn-decision";
export type ApplyChoiceCommand = Readonly<{
@@ -287,7 +288,7 @@ export async function persistNextInterviewAfterChoice(input: {
decisionState: InferenceState | null;
nextAction: ReturnType<typeof publicNextAction>;
birthDate?: string | null;
}): Promise<{ hostNarration: string | null; choiceReady: boolean }> {
}): Promise<{ hostNarration: string; choiceReady: boolean; persisted?: boolean }> {
const latest = input.dossier.latestResult
? {
...input.dossier.latestResult,
@@ -351,7 +352,17 @@ export async function persistNextInterviewAfterChoice(input: {
) {
return { hostNarration: spoken, choiceReady: false };
}
return { hostNarration: null, choiceReady: false };
return persistExhaustionCollect({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
dossier: input.dossier,
decision: {
credibleRange: input.nextAction.credible_range,
representativeTime: input.nextAction.representative_time,
},
decisionReceipt: latest.decisionReceipt,
});
}
if (!followup) {
if (isNonConvergingRangeOffer({
@@ -393,8 +404,9 @@ async function persistFocusAfterChoice(input: {
decisionReceipt: Readonly<Record<string, unknown>> | null | undefined;
followup: ReturnType<typeof buildMethodFollowupPlan>["next_followup"];
}) {
let persisted;
try {
return await persistServerOwnedFocus({
persisted = await persistServerOwnedFocus({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
@@ -406,13 +418,32 @@ async function persistFocusAfterChoice(input: {
console.warn(
`[rectification-v9] persist next focus failed case=${input.caseId} reason=${safeToolErrorCode(error)}`,
);
return {
persisted = {
status: "skipped" as const,
focus: null,
questionId: null,
prompt: null,
};
}
if (persisted.status === "created" || persisted.status === "already_open" || !input.followup) {
return persisted;
}
try {
const dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
return await persistServerOwnedFocus({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
activeFocus: dossier.conversationSummary.activeFocus,
decisionReceipt: input.decisionReceipt,
followup: input.followup,
});
} catch (error) {
console.warn(
`[rectification-v9] retry next focus failed case=${input.caseId} reason=${safeToolErrorCode(error)}`,
);
return persisted;
}
}
export async function applyCollectFocusDenial(
@@ -470,7 +501,7 @@ export async function applyCollectFocusDenial(
birthDate,
});
return {
narration: nextInterview.hostNarration ?? "记下了,这方面先跳过。",
narration: nextInterview.hostNarration,
nextInterviewPersisted: Boolean(nextInterview.hostNarration) || nextInterview.choiceReady,
nextChoiceReady: nextInterview.choiceReady,
};
@@ -567,7 +598,7 @@ async function persistExhaustionCollect(input: {
return {
persisted,
choiceReady: false,
hostNarration: persisted && spoken
hostNarration: spoken
? composeCollectSpokenAssistantText(range, spoken).composed
: range,
};
@@ -690,7 +721,11 @@ async function persistApplied(
});
nextChoiceReady = nextInterview.choiceReady;
if (nextInterview.hostNarration) {
hostNarration = nextInterview.hostNarration;
hostNarration = nextInterview.persisted === false
? `${input.narration}
${nextInterview.hostNarration}`
: nextInterview.hostNarration;
nextInterviewPersisted = true;
}
}
@@ -744,6 +779,47 @@ async function persistApplied(
};
}
export async function ensureNonTerminalTurnExit(input: {
accounting: AccountingClient;
userId: string;
caseId: string;
adoptCarrierReady: boolean;
}): Promise<{ persisted: boolean; choiceReady: boolean; hostNarration: string | null }> {
const dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
if (projectCurrentQuestion(dossier.conversationSummary.activeFocus)) {
return { persisted: false, choiceReady: false, hostNarration: null };
}
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 decision = decideFromDossier(dossier, { birthDate });
if (
dossier.case.acceptedTime
|| dossier.case.confirmedTime
|| decision.completionStatus === "provisional_range_user_stopped"
|| (decision.canAdopt && input.adoptCarrierReady)
) {
return { persisted: false, choiceReady: false, hostNarration: null };
}
console.warn(JSON.stringify({
event: "rectification_nonterminal_exit_repaired",
case_id: input.caseId,
reason: "missing_question_and_adopt_carrier",
}));
return persistExhaustionCollect({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
dossier,
decision,
decisionReceipt: dossier.latestResult?.decisionReceipt,
});
}
function optionQuoteFromSchema(schema: Readonly<Record<string, unknown>>, optionId: ChoiceKey): string | null {
const choice = schema.choice && typeof schema.choice === "object" && !Array.isArray(schema.choice)
? schema.choice as Record<string, unknown>
@@ -14,7 +14,7 @@ import {
userVisibleChoiceLine,
} from "../src/lib/rectification-agentic/v9/choice-action.ts";
import { isNearBottom, shouldFollowLatest, shouldShowJumpToLatest } from "../src/lib/rectification-sticky-scroll.ts";
import { persistServerOwnedFocus, stableFollowupQuestionId } from "../src/lib/rectification-agentic/v9/server-focus.ts";
import { stableFollowupQuestionId } from "../src/lib/rectification-agentic/v9/server-focus.ts";
import {
spokenCollectFallbackFollowup,
spokenFollowupForUser,
@@ -538,14 +538,12 @@ test("clicking A applies the choice without invoking a language model", async ()
start: "2015-01-01",
end: "2015-12-31",
});
assert.equal(applied.narration, composeChoiceNarration({
optionId: "A",
scoring: true,
appliedInference: true,
}));
assert.match(applied.narration, /已记录你的选择/);
assert.match(applied.narration, /当前可信区间/);
assert.match(applied.narration, /家里有没有结婚、添丁或住院/);
const fns = accounting.calls.map((call) => call.fn);
assert.ok(fns.includes("apply_agentic_rectification_choice_action"));
assert.equal(fns.includes("append_agentic_rectification_turn"), false);
assert.equal(fns.includes("append_agentic_rectification_turn"), true);
assert.equal(fns.includes("record_agentic_rectification_evidence_batch"), false);
assert.equal(fns.some((fn) => fn === "create_agentic_rectification_run_attempt" || fn.includes("stream")), false);
const persist = accounting.calls.find((call) => call.fn === "apply_agentic_rectification_choice_action");
@@ -2,14 +2,15 @@ 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 type { ConflictProbe } from "../src/lib/rectification-agentic/core/types.ts";
import { applyAnswerToState, buildInferenceState, candidateSetId } from "../src/lib/rectification-agentic/core/build-state.ts";
import { publicNextAction } from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import type { ConflictProbe, InferenceState } from "../src/lib/rectification-agentic/core/types.ts";
import {
decideFromDossier,
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 { buildMethodFollowupPlan, spokenFollowupForUser } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { focusStatusForAnswer } from "../src/lib/rectification-agentic/v9/choice-action.ts";
import { persistServerOwnedFocus } from "../src/lib/rectification-agentic/v9/server-focus.ts";
import { projectCurrentQuestion } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
@@ -19,6 +20,7 @@ import {
} from "../src/lib/rectification-agentic/v9/turn-intent-classifier.ts";
import {
applyCollectFocusDenial,
persistNextInterviewAfterChoice,
persistNextInterviewIfIdle,
} from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
@@ -93,6 +95,19 @@ const EVIDENCE = [
},
] as const;
const LIVE_CASE_EVIDENCE = [
...EVIDENCE,
{
id: "e-education",
status: "confirmed",
domain: "education",
datePrecision: "year",
occurredFrom: "2016-01-01",
occurredTo: null,
eventKind: "education_start",
},
] as const;
const FAMILY_2021_COLLECT = {
year: 2021,
year_label: "2021 年前后",
@@ -200,6 +215,34 @@ const CAREER_2023: ConflictProbe = {
style_options: EXISTENCE_OPTIONS,
};
const CAREER_2023_ACTIVATION: ConflictProbe = {
id: "probe:career.2023.dasha_activation",
semantic_key: "career.2023.dasha_activation",
candidate_split_hash: "career.2023.activation",
domain: "career",
year: 2023,
question: "2023 年前后大运有没有启动?",
candidate_ids: CANDIDATES.map((candidate) => candidate.time),
expected_outcomes: [
{
answer_class: "yes",
supports: ["05:15"],
conflicts: ["04:47", "04:51", "04:53", "04:59", "05:00", "05:07", "05:12", "05:14"],
},
{ answer_class: "weak_yes", supports: [], conflicts: [] },
{
answer_class: "no",
supports: ["04:47", "04:51", "04:53", "04:59", "05:00", "05:07", "05:12", "05:14"],
conflicts: ["05:15"],
},
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 0.56,
source: "dasha_activation",
choice_kind: "existence",
style_options: EXISTENCE_OPTIONS,
};
const RELOCATION_2015: ConflictProbe = {
id: "probe:relocation.2015.dasha_boundary",
semantic_key: "relocation.2015.dasha_boundary",
@@ -369,21 +412,30 @@ function planFrom(
function rpcDossier(decision: DecisionDossier, activeFocus?: Record<string, unknown> | null) {
return dossierFixture({
evidence: EVIDENCE.map((item) => ({
evidence: decision.evidence.map((item) => ({
id: item.id,
source_turn_id: TURN_ID,
subject: "self",
event_kind: item.eventKind,
event_kind: item.eventKind ?? "event",
domain: item.domain,
occurred_from: item.occurredFrom,
occurred_to: item.occurredTo,
date_precision: item.datePrecision,
summary: `${item.occurredFrom} ${item.eventKind}`,
summary: item.summary ?? `${item.occurredFrom} ${item.eventKind ?? "event"}`,
status: item.status,
supersedes_evidence_id: null,
created_at: "2026-08-29T00:00:00.000Z",
})),
latestResult: candidateSnapshotFixture({
candidates: decision.latestResult?.candidates?.map((item, index) => ({
candidate_id: `00000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`,
time: item.time,
rank: item.rank ?? index + 1,
relative_support: Math.max(0, Math.min(100, item.relativeSupport ?? 0)),
tied_minute_count: item.tiedMinuteCount ?? 1,
})),
representativeTime: decision.latestResult?.representativeTime ?? null,
evidenceLedgerFingerprint: decision.latestResult?.evidenceLedgerFingerprint,
decisionReceipt: { ...(decision.latestResult?.decisionReceipt ?? {}) },
}),
conversationSummary: {
@@ -638,6 +690,294 @@ test("free-text turns persist the next followup so current_question is not null"
assert.ok(question?.kind === "collect_spoken" || question?.kind === "choice");
});
function occupationCollectFocus(id = FOCUS_ID) {
return {
id,
case_id: CASE_ID,
question_id: "collect:occupation:collect_method_evidence",
intent: "collect_method_evidence",
target_evidence_id: null,
target_domain: "occupation",
target_kind: null,
expected_answer_schema: {
collect: true,
prompt: "你长期做什么工作?",
},
status: "active",
asked_at: "2026-08-30T00:00:00.000Z",
resolved_at: null,
};
}
function createdFocusFromArgs(args: Record<string, unknown>, id = FOCUS_ID) {
return {
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-30T00:00:00.000Z",
resolved_at: null,
};
}
function occupationDossier() {
return revision5Dossier(revision5State(), {
declinedTopics: [{ target_domain: "family", status: "declined" }],
});
}
function liveCaseState(): InferenceState {
const times = CANDIDATES.map((candidate) => candidate.time);
const answered = [D9, D10, RELOCATION_2015, D24, D7];
const active = [
{ time: "05:00", score: 18, probability: 0.4 },
{ time: "05:07", score: 16, probability: 0.33 },
{ time: "04:53", score: 14, probability: 0.27 },
] as const;
const eliminated = times.filter((time) => !active.some((candidate) => candidate.time === time));
const orderedTimes = [...active.map((candidate) => candidate.time), ...eliminated];
return {
algorithm_version: revision5State().algorithm_version,
candidate_set_id: candidateSetId("04:47", "05:15", orderedTimes),
revision: 6,
phase: "discrimination",
result_status: "discriminating",
range_start: "04:47",
range_end: "05:15",
candidates: [
...active.map((candidate, index) => ({
id: candidate.time,
time: candidate.time,
cluster_range: [candidate.time, candidate.time] as const,
prior_score: candidate.score,
posterior_score: candidate.score,
probability: candidate.probability,
status: "active",
rank: index + 1,
strong_conflict_count: 0,
} as const)),
...eliminated.map((time, index) => ({
id: time,
time,
cluster_range: [time, time] as const,
prior_score: 4 - index,
posterior_score: 4 - index,
probability: 0,
status: "eliminated" as const,
rank: active.length + index + 1,
strong_conflict_count: 3,
})),
],
events: [
{ id: "e-career-entry", domain: "career", year: 2020, precision: "month", usage: "training" },
{ id: "e-career-exit", domain: "career", year: 2020, precision: "month", usage: "training" },
{ id: "e-rel-end", domain: "relationship", year: 2024, precision: "day", usage: "training" },
{ id: "e-rel-start", domain: "relationship", year: 2024, precision: "month", usage: "training" },
{ id: "e-education", domain: "education", year: 2016, precision: "year", usage: "training" },
],
probes: [...answered, CAREER_2023_ACTIVATION],
answered_probes: answered.map((probe) => ({
probe_id: probe.id,
semantic_key: probe.semantic_key,
candidate_split_hash: probe.candidate_split_hash,
answer_class: probe === D24 ? "unsure" : "no",
classified_from: "choice",
})),
rounds: [],
last_inference_round: null,
entropy: 1.08,
representative_time: "05:00",
credible_range: ["04:53", "05:07"],
holdout_passed: null,
};
}
function liveCaseDossier(): DecisionDossier {
const state = liveCaseState();
return {
evidence: LIVE_CASE_EVIDENCE,
conversationSummary: {
activeFocus: null,
declinedSkippedTopics: [{ target_domain: "family", status: "declined" }],
},
latestResult: {
resultId: "55555555-5555-4555-8555-555555555555",
selectionAllowed: true,
confirmationAllowed: false,
evidenceLedgerFingerprint: evidenceLedgerFingerprint(LIVE_CASE_EVIDENCE as never),
candidates: state.candidates.map((candidate, index) => ({
candidateId: `77777777-7777-4777-8777-${String(index + 1).padStart(12, "0")}`,
time: candidate.time,
rank: candidate.rank,
relativeSupport: Math.round(candidate.posterior_score),
})),
representativeTime: state.representative_time,
decisionReceipt: {
accept_allowed: true,
propose_allowed: true,
selection_allowed: true,
inference_state: state,
},
},
case: { acceptedTime: null },
};
}
async function persistOccupationAfterChoice(accounting: ReturnType<typeof fakeAccounting>["client"]) {
const dossier = occupationDossier();
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
return persistNextInterviewAfterChoice({
accounting,
userId: USER_ID,
caseId: CASE_ID,
dossier,
decisionState: revision5State(),
nextAction: publicNextAction(decision),
birthDate: "1997-08-08",
});
}
test("duplicate collect focus reloads the active question instead of returning null narration", async () => {
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => rpcDossier(occupationDossier(), occupationCollectFocus()),
set_agentic_rectification_conversation_focus: () => {
throw new Error("focus_idempotency_conflict");
},
});
const persisted = await persistOccupationAfterChoice(accounting.client);
assert.equal(persisted.hostNarration, "你长期做什么工作?");
const currentQuestion = projectCurrentQuestion({
id: FOCUS_ID,
questionId: "collect:occupation:collect_method_evidence",
intent: "collect_method_evidence",
targetDomain: "occupation",
expectedAnswerSchema: { collect: true, prompt: "你长期做什么工作?" },
});
assert.equal(currentQuestion?.question_id, "collect:occupation:collect_method_evidence");
assert.equal(accounting.calls.filter((item) => item.fn === "set_agentic_rectification_conversation_focus").length, 1);
});
test("skipped collect focus reloads once and retries persistence", async () => {
let writes = 0;
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => rpcDossier(occupationDossier()),
set_agentic_rectification_conversation_focus: (_fn, args) => {
writes += 1;
if (writes === 1) throw new Error("temporary focus write failure");
return { focus: createdFocusFromArgs(args), idempotent: false };
},
});
const persisted = await persistOccupationAfterChoice(accounting.client);
assert.equal(persisted.hostNarration, "你长期做什么工作?");
assert.equal(writes, 2);
const write = accounting.calls.filter((item) => item.fn === "set_agentic_rectification_conversation_focus").at(-1);
const currentQuestion = projectCurrentQuestion({
id: FOCUS_ID,
questionId: String(write?.args.p_question_id ?? ""),
intent: String(write?.args.p_intent ?? ""),
targetDomain: typeof write?.args.p_target_domain === "string" ? write.args.p_target_domain : null,
expectedAnswerSchema: write?.args.p_expected_answer_schema as Record<string, unknown>,
});
assert.equal(currentQuestion?.question_id, "collect:occupation:collect_method_evidence");
});
test("nonterminal turn exit deterministically restores a spoken question", async () => {
const answerChoiceModule = await import("../src/lib/rectification-agentic/v9/answer-choice.ts") as Record<string, unknown>;
const ensureExit = answerChoiceModule.ensureNonTerminalTurnExit as undefined | ((input: {
accounting: ReturnType<typeof fakeAccounting>["client"];
userId: string;
caseId: string;
adoptCarrierReady: boolean;
}) => Promise<{ hostNarration: string | null; persisted: boolean }>);
assert.equal(typeof ensureExit, "function");
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => rpcDossier(occupationDossier()),
set_agentic_rectification_conversation_focus: (_fn, args) => {
return { focus: createdFocusFromArgs(args), idempotent: false };
},
});
const repaired = await ensureExit!({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
adoptCarrierReady: false,
});
assert.equal(repaired.persisted, true);
assert.match(repaired.hostNarration ?? "", /当前可信区间/);
assert.match(repaired.hostNarration ?? "", /05:07/);
assert.match(repaired.hostNarration ?? "", /代表分钟/);
const write = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus");
const currentQuestion = projectCurrentQuestion({
id: FOCUS_ID,
questionId: String(write?.args.p_question_id ?? ""),
intent: String(write?.args.p_intent ?? ""),
targetDomain: typeof write?.args.p_target_domain === "string" ? write.args.p_target_domain : null,
expectedAnswerSchema: write?.args.p_expected_answer_schema as Record<string, unknown>,
});
assert.equal(currentQuestion?.kind, "collect_spoken");
assert.ok(currentQuestion?.prompt);
});
test("live five-evidence case ends on the occupation spoken collect", async () => {
const dossier = liveCaseDossier();
const state = liveCaseState();
assert.equal(dossier.evidence.length, 5);
assert.equal(state.answered_probes.length, 5);
assert.deepEqual(state.candidates.filter((candidate) => candidate.status === "active").map((candidate) => candidate.time), [
"05:00", "05:07", "04:53",
]);
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
assert.equal(decision.probe, null);
assert.equal(decision.nextAction, "offer_provisional_range");
assert.equal(decision.canAdopt, false);
assert.ok(decision.droppedProbes.some((probe) => (
probe.semantic_key === CAREER_2023_ACTIVATION.semantic_key
&& probe.reason === "no_split_among_active"
)), JSON.stringify(decision.droppedProbes));
const plan = planFrom(dossier);
assert.equal(plan.next_followup?.method_id, "occupation");
assert.equal(plan.next_followup?.intent, "collect_method_evidence");
assert.equal(spokenFollowupForUser(plan.next_followup), "你长期做什么工作?");
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => rpcDossier(dossier),
set_agentic_rectification_conversation_focus: (_fn, args) => ({
focus: createdFocusFromArgs(args),
idempotent: false,
}),
});
const persisted = await persistNextInterviewAfterChoice({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
dossier,
decisionState: state,
nextAction: publicNextAction(decision),
birthDate: "1997-08-08",
});
assert.match(persisted.hostNarration, /你长期做什么工作?/);
const write = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus");
const currentQuestion = projectCurrentQuestion({
id: FOCUS_ID,
questionId: String(write?.args.p_question_id ?? ""),
intent: String(write?.args.p_intent ?? ""),
targetDomain: typeof write?.args.p_target_domain === "string" ? write.args.p_target_domain : null,
expectedAnswerSchema: write?.args.p_expected_answer_schema as Record<string, unknown>,
});
assert.equal(currentQuestion?.question_id, "collect:occupation:collect_method_evidence");
});
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));