diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index be202061..b9d12e79 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -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`) diff --git a/frontend/src/app/api/rectification/agent/route.ts b/frontend/src/app/api/rectification/agent/route.ts index 22fd45eb..09a286ab 100644 --- a/frontend/src/app/api/rectification/agent/route.ts +++ b/frontend/src/app/api/rectification/agent/route.ts @@ -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, diff --git a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts index 3a75d27b..9233f29b 100644 --- a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts +++ b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts @@ -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; 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> | null | undefined; followup: ReturnType["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>, optionId: ChoiceKey): string | null { const choice = schema.choice && typeof schema.choice === "object" && !Array.isArray(schema.choice) ? schema.choice as Record diff --git a/frontend/tests/rectification-answer-choice.test.ts b/frontend/tests/rectification-answer-choice.test.ts index 2cda35ef..e1a70bcf 100644 --- a/frontend/tests/rectification-answer-choice.test.ts +++ b/frontend/tests/rectification-answer-choice.test.ts @@ -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"); diff --git a/frontend/tests/rectification-collect-stall.test.ts b/frontend/tests/rectification-collect-stall.test.ts index 6fa2efe8..87e238fd 100644 --- a/frontend/tests/rectification-collect-stall.test.ts +++ b/frontend/tests/rectification-collect-stall.test.ts @@ -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 | 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, 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["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, + }); + 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; + const ensureExit = answerChoiceModule.ensureNonTerminalTurnExit as undefined | ((input: { + accounting: ReturnType["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, + }); + 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, + }); + 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));