import assert from "node:assert/strict"; import { readFileSync, readdirSync } from "node:fs"; import test from "node:test"; import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts"; import { publicCanAdopt } from "../src/lib/rectification-agentic/core/rectification-decision.ts"; import { decideFromDossier, type DecisionDossier, } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts"; import { buildMethodFollowupPlan, exhaustionSpokenCollectFollowup, } from "../src/lib/rectification-agentic/v9/method-followup.ts"; import { ensureNonTerminalTurnExit } from "../src/lib/rectification-agentic/v9/answer-choice.ts"; import { projectCurrentQuestion } from "../src/lib/rectification-agentic/v9/turn-decision.ts"; import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts"; import { CASE_ID, FOCUS_ID, TURN_ID, USER_ID, candidateSnapshotFixture, computeFixture, dossierFixture, fakeAccounting, receiptHandlers, } from "./rectification-v9-test-support.ts"; const STYLE_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 COLLECT_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 OPENING_SKIPPED = { // 原值: collect:other:collect_method_evidence // 新值: collect:other:collect_method_evidence // 原因: 开场跳过记录仍按 other 身份,避免把 unknown 写入账本 questionId: "collect:other:collect_method_evidence", target_domain: "other", status: "skipped", }; function collectDecisionDossier(declinedTopics: ReadonlyArray> = []): DecisionDossier { const state = buildInferenceState({ range_start: "04:53", range_end: "05:06", candidates: [ { id: "05:06", time: "05:06", relative_support: 14 }, { id: "04:53", time: "04:53", relative_support: 13 }, { id: "05:03", time: "05:03", relative_support: 13 }, ], 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: [], }); return { evidence: COLLECT_EVIDENCE, conversationSummary: { activeFocus: null, declinedSkippedTopics: declinedTopics, }, latestResult: { resultId: "55555555-5555-4555-8555-555555555555", selectionAllowed: true, confirmationAllowed: false, evidenceLedgerFingerprint: evidenceLedgerFingerprint(COLLECT_EVIDENCE as never), candidates: state.candidates.map((candidate) => ({ candidateId: candidate.id, time: candidate.time, rank: candidate.rank, relativeSupport: Math.round(candidate.posterior_score), })), representativeTime: state.representative_time, decisionReceipt: { acceptance_allowed: true, accept_allowed: true, propose_allowed: true, selection_allowed: true, confirmation_allowed: false, inference_state: state, }, }, case: { acceptedTime: null }, }; } function rpcDossier(decision: DecisionDossier, activeFocus: Record | null = null) { const evidence = decision.evidence.map((item) => ({ id: item.id, source_turn_id: TURN_ID, subject: "self", event_kind: item.eventKind ?? "event", domain: item.domain, occurred_from: item.occurredFrom, occurred_to: item.occurredTo, date_precision: item.datePrecision, summary: item.summary ?? `${item.occurredFrom} ${item.eventKind ?? "event"}`, status: item.status, supersedes_evidence_id: null, created_at: "2026-09-03T00:00:00.000Z", })); return dossierFixture({ evidence, 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: evidenceLedgerFingerprint(evidence.map((item) => ({ id: item.id, eventKind: item.event_kind, domain: item.domain, occurredFrom: item.occurred_from, occurredTo: item.occurred_to, datePrecision: item.date_precision, summary: item.summary, status: item.status, })) as never), decisionReceipt: { ...(decision.latestResult?.decisionReceipt ?? {}) }, }), conversationSummary: { confirmed_evidence_summary: [], pending_revisions: [], active_focus: activeFocus, declined_skipped_topics: decision.conversationSummary.declinedSkippedTopics, candidate_divergence_summary: null, missing_evidence_categories: [], last_result_policy: null, summary_version: 1, updated_at: "2026-09-03T00:00:00.000Z", }, }); } function createdFocusFromArgs(args: Record) { return { 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-09-03T00:00:00.000Z", resolved_at: null, }; } test("collect-phase nonterminal exit persists a spoken collect when public can_adopt is closed", async () => { const decisionDossier = collectDecisionDossier(); const decision = decideFromDossier(decisionDossier, { birthDate: "1997-08-08" }); assert.equal(decision.sessionOutcome, "collect_evidence"); assert.equal(decision.canAdopt, true); assert.equal(publicCanAdopt(decision), false); const store: { focus: ReturnType | null } = { focus: null }; const warnings: string[] = []; const originalWarn = console.warn; console.warn = (...args: unknown[]) => { warnings.push(args.map((item) => String(item)).join(" ")); }; const accounting = fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => rpcDossier(decisionDossier, store.focus), get_agentic_rectification_case_compute: () => computeFixture(), set_agentic_rectification_conversation_focus: (_fn, args) => { store.focus = createdFocusFromArgs(args); return { focus: store.focus, idempotent: false }; }, }); try { const repaired = await ensureNonTerminalTurnExit({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID, }); assert.equal(repaired.persisted, true); } finally { console.warn = originalWarn; } assert.ok(warnings.some((item) => item.includes("rectification_nonterminal_exit_repaired"))); assert.equal(store.focus?.intent, "collect_method_evidence"); const question = projectCurrentQuestion({ id: FOCUS_ID, questionId: String(store.focus?.question_id ?? ""), intent: String(store.focus?.intent ?? ""), targetDomain: typeof store.focus?.target_domain === "string" ? store.focus.target_domain : null, expectedAnswerSchema: store.focus?.expected_answer_schema as Record, }); assert.equal(question?.kind, "collect_spoken"); }); test("adopt_representative nonterminal exit stays satisfied without a new question", async () => { const decisionDossier = collectDecisionDossier([ { target_domain: "family", status: "declined", intent: "collect_method_evidence" }, { target_domain: "education", status: "declined", intent: "collect_method_evidence" }, { target_domain: "finance", status: "declined", intent: "collect_method_evidence" }, { target_domain: "relocation", status: "declined", intent: "collect_method_evidence" }, { target_domain: "health_pressure", status: "declined", intent: "collect_method_evidence" }, { target_domain: "occupation", status: "declined", intent: "collect_method_evidence" }, ]); const decision = decideFromDossier(decisionDossier, { birthDate: "1997-08-08" }); // 旧:家人拒答即可 adopt_representative。新:带年份采集未完不得出牌;本夹具把剩余域拒答后才满足。 assert.equal(decision.sessionOutcome, "adopt_representative"); assert.equal(publicCanAdopt(decision), true); const accounting = fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => rpcDossier(decisionDossier), get_agentic_rectification_case_compute: () => computeFixture(), set_agentic_rectification_conversation_focus: () => { throw new Error("adoptable offer must not persist another question"); }, }); const repaired = await ensureNonTerminalTurnExit({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID, }); assert.equal(repaired.persisted, false); assert.equal(repaired.hostNarration, null); assert.equal( accounting.calls.some((item) => item.fn === "set_agentic_rectification_conversation_focus"), false, ); }); test("adopted time is shown on the card row, not a composer status bar", () => { const chat = readFileSync( new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8", ); const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); // 原值: 「改选」是状态条按钮,滚动到出卡消息 // 新值: 点其他候选行即改选;无 rectification-adopt-status // 原因: BUG-595 决策 3 assert.doesNotMatch(chat, /rectification-adopt-status__link/); assert.doesNotMatch(chat, /offerSectionRef/); assert.match(chat, /showSelectionCards && candidateResult && message\.renderKey === selectionCardMessageKey/); assert.doesNotMatch(styles, /\.rectification-adopt-status__link:focus-visible/); }); test("skipped opening collect other still yields education without collect_retry", () => { const family = { id: "e-family", status: "confirmed", domain: "family", datePrecision: "year", occurredFrom: "2021-01-01", occurredTo: null, }; const exhausted = exhaustionSpokenCollectFollowup({ evidence: [family], declinedTopics: [OPENING_SKIPPED], }); assert.equal(exhausted?.domain, "education"); assert.notEqual(exhausted?.collect_retry, true); const plan = buildMethodFollowupPlan({ evidence: COLLECT_EVIDENCE, closedCollectFocuses: [OPENING_SKIPPED], sessionOutcome: "collect_evidence", }); assert.equal(plan.next_followup?.intent, "collect_method_evidence"); assert.equal(plan.next_followup?.domain, "family"); assert.notEqual(plan.next_followup?.collect_retry, true); const educationPlan = buildMethodFollowupPlan({ evidence: [...COLLECT_EVIDENCE, family], closedCollectFocuses: [ OPENING_SKIPPED, { questionId: "collect:occupation:collect_method_evidence", target_domain: "occupation", status: "skipped", }, ], sessionOutcome: "collect_evidence", precisionStage: "d5_refine", }); const educationCollect = educationPlan.next_followup?.intent === "collect_method_evidence" && educationPlan.next_followup.domain === "education" ? educationPlan.next_followup : exhausted; assert.equal(educationCollect?.domain, "education"); assert.notEqual(educationCollect?.collect_retry, true); }); test("consult handoff button and startConsultationAfterRectification are gone", () => { const srcRoot = new URL("../src/", import.meta.url); const files = readdirSync(srcRoot, { recursive: true, encoding: "utf8" }) .filter((entry) => /\.(ts|tsx|css)$/.test(entry)); const banned = [ "startConsultationAfterRectification", "onStartConsultation", "请用刚才采用的代表性出生时间看盘", "rectification-consult-handoff", ]; for (const file of files) { const source = readFileSync(new URL(file.split("\\").join("/"), srcRoot), "utf8"); for (const token of banned) { assert.equal(source.includes(token), false, `${file} still contains ${token}`); } } const chat = readFileSync( new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8", ); // 原值: 状态条「之后新建对话即按此时间排盘」 // 新值: 无状态条;采用后旁白走 postAdoptVerifyDoneCopy;开场仍可预告结束后新建对话 // 原因: BUG-595 决策 3 assert.match(chat, /postAdoptVerifyDoneCopy/); assert.doesNotMatch(chat, /rectification-adopt-status/); assert.match(chat, /结束后新建对话,按采用的时间再问/); }); test("keepAcceptedFocus omits probe_year when schema and probes have none", () => { const followup = readFileSync( new URL("../src/lib/rectification-agentic/v9/method-followup.ts", import.meta.url), "utf8", ); assert.doesNotMatch(followup, /\/19\\d\{2\}\|20\\d\{2\}\//); const plan = buildMethodFollowupPlan({ evidence: COLLECT_EVIDENCE, accepted: true, sessionOutcome: "adopt_representative", eventProbes: [], activeFocus: { id: FOCUS_ID, questionId: "reverse_verify:education_style:score", intent: "reverse_verify", targetDomain: "education", targetKind: "education_milestone", expectedAnswerSchema: { prompt: "2016 年前后,升学结果或学习环境有没有明显变化?", choice: { prompt: "2016 年前后,升学结果或学习环境有没有明显变化?", option_a: STYLE_OPTIONS[0].label, option_b: STYLE_OPTIONS[1].label, option_c: STYLE_OPTIONS[2].label, option_d: STYLE_OPTIONS[3].label, options: STYLE_OPTIONS.map((option, index) => ({ key: (["A", "B", "C", "D"] as const)[index]!, label: option.label, answer_class: option.answer_class, })), }, }, }, }); assert.equal(plan.next_followup?.intent, "reverse_verify"); assert.equal(plan.next_followup?.probe_year, undefined); });