import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; import { 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 { adoptDeliveryFacts, templatePostAdoptExplain, templateRangeReadingExplain, validateAdoptNarration, RANGE_READING_COPY, type AdoptDeliveryFacts, } from "../src/lib/rectification-agentic/v9/adopt-narration.ts"; import { ADOPT_NARRATION_INSTRUCTIONS, createAdoptNarrationWriter, deliverAdoptNarration, } from "../src/lib/rectification-agentic/v9/adopt-narration-agent.ts"; import { applyCollectFocusDenial, persistNextInterviewAfterChoice, persistNextInterviewIfIdle, } from "../src/lib/rectification-agentic/v9/answer-choice.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 { RECTIFICATION_USER_COPY, USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.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 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 TIMES = [ "04:45", "04:47", "04:51", "04:53", "04:59", "05:00", "05:06", "05:08", "05:12", "05:15", ] as const; const WINDOW_ENDS = ["04:45", "04:47", "04:51", "05:08", "05:12", "05:15"] as const; const ACTIVE = ["05:00", "05:06", "04:53"] as const; const EVIDENCE = [ { id: "e-education", status: "confirmed", domain: "education", datePrecision: "year" as const, occurredFrom: "2016-01-01", occurredTo: null, eventKind: "education_start", }, { id: "e-rel-start", status: "confirmed", domain: "relationship", datePrecision: "month" as const, occurredFrom: "2024-05-01", occurredTo: null, eventKind: "relationship_start", }, { id: "e-rel-end", status: "confirmed", domain: "relationship", datePrecision: "day" as const, occurredFrom: "2024-08-08", occurredTo: null, eventKind: "relationship_end", }, { id: "e-career-entry", status: "confirmed", domain: "career", datePrecision: "month" as const, occurredFrom: "2020-04-01", occurredTo: null, eventKind: "career_entry", }, { id: "e-career-exit", status: "confirmed", domain: "career", datePrecision: "month" as const, occurredFrom: "2020-10-01", occurredTo: null, eventKind: "career_exit", }, ] as const; const EXHAUSTED_COLLECT_TOPICS = [ { target_domain: "family", intent: "collect_method_evidence", status: "declined" }, { target_domain: "finance", intent: "collect_method_evidence", status: "declined" }, { target_domain: "relocation", intent: "collect_method_evidence", status: "declined" }, { target_domain: "health_pressure", intent: "collect_method_evidence", status: "declined" }, { target_domain: "occupation", intent: "collect_method_evidence", status: "declined" }, ] as const; const OOS_BLIND = [ { domain: "family", user_meaning: "家里有没有结婚、添丁或住院", used_for_scoring: false }, { domain: "finance", user_meaning: "钱的方面有没有明显变化", used_for_scoring: false }, { domain: "health_pressure", user_meaning: "身体或压力这边有没有难熬的一段", used_for_scoring: false }, ] as const; function vargaExistence(input: { layer: string; gain: number; domain: string; question: string; }): ConflictProbe { const key = `varga.${input.layer}.yearless`; return { id: `contrast:${key}`, semantic_key: key, candidate_split_hash: key, domain: input.domain, year: 0, question: input.question, candidate_ids: [...ACTIVE], expected_outcomes: [ { answer_class: "yes", supports: ["05:00", "05:06"], conflicts: ["04:53"] }, { answer_class: "weak_yes", supports: ["05:00", "05:06"], conflicts: [] }, { answer_class: "no", supports: ["04:53"], conflicts: ["05:00", "05:06"] }, { 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:06"], expected_outcomes: [ { answer_class: "yes", supports: ["05:00"], conflicts: ["05:06"] }, { answer_class: "weak_yes", supports: ["05:06"], 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:06"], expected_outcomes: [ { answer_class: "yes", supports: ["05:00"], conflicts: ["05:06"] }, { answer_class: "weak_yes", supports: ["05:06"], 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:06"], expected_outcomes: [ { answer_class: "yes", supports: ["05:00"], conflicts: ["05:06"] }, { answer_class: "no", supports: ["05:06"], conflicts: ["05:00"] }, { answer_class: "unsure", supports: [], conflicts: [] }, ], information_gain: 0.72, source: "dasha_boundary", choice_kind: "existence", style_options: EXISTENCE_OPTIONS, }; const CAREER_2024: ConflictProbe = { id: "probe:career.2024.dasha_boundary", semantic_key: "career.2024.dasha_boundary", candidate_split_hash: "career.2024", domain: "career", year: 2024, question: "2024 年前后有没有职责加重?", candidate_ids: ["05:00", "05:06"], expected_outcomes: [ { answer_class: "yes", supports: ["05:00"], conflicts: ["05:06"] }, { answer_class: "no", supports: ["05:06"], conflicts: ["05:00"] }, { answer_class: "unsure", supports: [], conflicts: [] }, ], information_gain: 0.68, 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:06"], expected_outcomes: [ { answer_class: "yes", supports: ["05:00"], conflicts: ["05:06"] }, { answer_class: "no", supports: ["05:06"], conflicts: ["05:00"] }, { answer_class: "unsure", supports: [], conflicts: [] }, ], information_gain: 0.61, source: "dasha_boundary", choice_kind: "existence", style_options: EXISTENCE_OPTIONS, }; const NAKSHATRA: ConflictProbe = { id: "probe:nakshatra.ashlesha", semantic_key: "nakshatra.ashlesha/magha", candidate_split_hash: "nakshatra.ashlesha", domain: "appearance", year: 0, question: "外表或体质更接近哪一种?", candidate_ids: [...ACTIVE], expected_outcomes: [ { answer_class: "yes", supports: [...ACTIVE], conflicts: [] }, { answer_class: "weak_yes", supports: [...ACTIVE], conflicts: [] }, { answer_class: "no", supports: [], conflicts: [] }, { answer_class: "unsure", supports: [], conflicts: [] }, ], information_gain: 0.4, source: "nakshatra_boundary", choice_kind: "varga_style", }; const DASHA_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: [...TIMES], expected_outcomes: [ { answer_class: "yes", supports: [...ACTIVE, ...WINDOW_ENDS], conflicts: [] }, { answer_class: "weak_yes", supports: [], conflicts: [] }, { answer_class: "no", supports: [...ACTIVE, ...WINDOW_ENDS], conflicts: [] }, { answer_class: "unsure", supports: [], conflicts: [] }, ], information_gain: 0.56, source: "dasha_activation", choice_kind: "existence", style_options: EXISTENCE_OPTIONS, }; function windowEndQuality(id: string, semantic: string): ConflictProbe { return { id, semantic_key: semantic, candidate_split_hash: semantic, domain: "relationship", year: 2024, question: "2024 年这段感情的质量更接近哪一种?", candidate_ids: [...WINDOW_ENDS], expected_outcomes: [ { answer_class: "yes", supports: ["04:45", "04:47"], conflicts: ["05:08", "05:12", "05:15"] }, { answer_class: "no", supports: ["05:08", "05:12", "05:15"], conflicts: ["04:45", "04:47"] }, { answer_class: "unsure", supports: [], conflicts: [] }, ], information_gain: 0.44, source: "known_event_quality", choice_kind: "event_quality", }; } const QUALITY_A = windowEndQuality( "probe:relationship.2024.known_event_quality.a", "relationship.2024.known_event_quality.a", ); const QUALITY_B = windowEndQuality( "probe:relationship.2024.known_event_quality.b", "relationship.2024.known_event_quality.b", ); const D24 = vargaExistence({ layer: "d24", gain: 2.5, domain: "education", question: "有没有学业或考试发挥明显失常、压力特别大的时候?", }); const D12 = vargaExistence({ layer: "d12", gain: 1.89, domain: "family", question: "家里有没有结婚、添丁或住院这类事?", }); const D7 = vargaExistence({ layer: "d7", gain: 1.35, domain: "family", question: "有没有子女或子嗣相关的家里变化?", }); const D4 = vargaExistence({ layer: "d4", gain: 0.99, domain: "relocation", question: "有没有搬家或长期住到外地?", }); const D5 = vargaExistence({ layer: "d5", gain: 0.5, domain: "education", question: "有没有记得住年份的升学或考试?", }); const ANSWERED = [D9, D10, CAREER_2023, CAREER_2024, RELOCATION_2015] as const; function windowScan() { return { scanned: true, confirmation_allowed: false, unique_minute_claim: false, d9_lagna_count: 2, d10_lagna_count: 2, d4_lagna_count: 2, d5_lagna_count: 2, d7_lagna_count: 2, d12_lagna_count: 2, d24_lagna_count: 2, d9_candidates_differ: true, d10_candidates_differ: true, d4_candidates_differ: true, d5_candidates_differ: true, d7_candidates_differ: true, d12_candidates_differ: true, d24_candidates_differ: true, }; } function fourteenProbeState(): InferenceState { const probes: ConflictProbe[] = [ ...ANSWERED, NAKSHATRA, D24, D12, D7, D4, D5, DASHA_ACTIVATION, QUALITY_A, QUALITY_B, ]; const active = [ { time: "05:00", score: 21, probability: 0.44 }, { time: "05:06", score: 18, probability: 0.38 }, { time: "04:53", score: 9, probability: 0.18 }, ] as const; const eliminated = TIMES.filter((time) => !active.some((item) => item.time === time)); const orderedTimes = [...active.map((item) => item.time), ...eliminated]; return { algorithm_version: "rectification-inference-v1", candidate_set_id: candidateSetId("04:45", "05:15", orderedTimes), revision: 6, phase: "discrimination", result_status: "discriminating", range_start: "04:45", range_end: "05:15", candidates: [ ...active.map((item, index) => ({ id: item.time, time: item.time, cluster_range: [item.time, item.time] as const, prior_score: item.score, posterior_score: item.score, probability: item.probability, status: "active" as const, rank: index + 1, strong_conflict_count: 0, })), ...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-education", domain: "education", year: 2016, precision: "year", usage: "holdout" }, { id: "e-rel-start", domain: "relationship", year: 2024, precision: "month", usage: "training" }, { id: "e-rel-end", domain: "relationship", year: 2024, precision: "day", usage: "training" }, { id: "e-career-entry", domain: "career", year: 2020, precision: "month", usage: "training" }, { id: "e-career-exit", domain: "career", year: 2020, precision: "month", usage: "training" }, ], probes, answered_probes: ANSWERED.map((probe) => ({ probe_id: probe.id, semantic_key: probe.semantic_key, candidate_split_hash: probe.candidate_split_hash, answer_class: "no" as const, classified_from: "choice" as const, })), rounds: [], last_inference_round: null, entropy: 1.08, representative_time: "05:00", credible_range: ["05:00", "05:06"], holdout_passed: true, }; } function preClickFourteenProbeState(): InferenceState { const last = ANSWERED[ANSWERED.length - 1]!; const state = fourteenProbeState(); return { ...state, revision: 5, answered_probes: state.answered_probes.filter((item) => item.probe_id !== last.id), candidates: state.candidates.map((item) => { if (item.time === "05:00") { return { ...item, prior_score: 23, posterior_score: 23 }; } if (item.time === "05:06") { return { ...item, prior_score: 16, posterior_score: 16 }; } if (item.time === "04:53") { return { ...item, prior_score: 7, posterior_score: 7 }; } return item; }), }; } function caseDossier(extra?: { declinedTopics?: ReadonlyArray>; acceptedTime?: string | null; state?: InferenceState; }): DecisionDossier { const state = extra?.state ?? fourteenProbeState(); return { evidence: EVIDENCE, conversationSummary: { activeFocus: null, declinedSkippedTopics: extra?.declinedTopics ?? [ { target_domain: "family", intent: "collect_method_evidence", status: "declined" }, ], }, latestResult: { resultId: "55555555-5555-4555-8555-555555555555", selectionAllowed: true, confirmationAllowed: false, evidenceLedgerFingerprint: evidenceLedgerFingerprint(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: { acceptance_allowed: true, accept_allowed: true, propose_allowed: true, selection_allowed: true, confirmation_allowed: false, inference_state: state, window_scan: windowScan(), oos_blind_prompts: OOS_BLIND, }, }, case: { acceptedTime: extra?.acceptedTime ?? null, candidateRange: { start_time: "04:45", end_time: "05:15" }, }, }; } function planFrom( dossier: DecisionDossier, extra: Partial[0]> = {}, ) { const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence); const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); return buildMethodFollowupPlan({ evidence: dossier.evidence, declinedTopics: dossier.conversationSummary.declinedSkippedTopics, closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics, sessionOutcome: decision.sessionOutcome, ...catalog, candidatesSeparated: false, ...extra, }); } function rpcDossier(decision: DecisionDossier, activeFocus?: Record | 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-04T00:00:00.000Z", })); const fingerprint = evidenceLedgerFingerprint(evidence.map((item) => ({ id: item.id, sourceTurnId: item.source_turn_id, subject: item.subject, 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, supersedesEvidenceId: item.supersedes_evidence_id, createdAt: item.created_at, dateSource: null, dateReliability: null, dateCorroboration: null, dateConflictStatus: null, })) as never); return dossierFixture({ candidateRange: { start_time: "04:45", end_time: "05:15" }, 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: fingerprint, 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-09-04T00:00:00.000Z", }, }); } function familyCollectFocus() { return { id: FOCUS_ID, case_id: CASE_ID, question_id: "collect:family:collect_method_evidence", intent: "collect_method_evidence", target_evidence_id: null, target_domain: "family", target_kind: null, expected_answer_schema: { collect: true, prompt: "2021 年前后,家里如果有结婚、添丁或住院这类事,记得大概哪年就行。", }, status: "active", asked_at: "2026-09-04T00:00:00.000Z", resolved_at: null, }; } function adoptAccounting(dossier: DecisionDossier, activeFocus?: Record | null) { return fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => rpcDossier(dossier, activeFocus), 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: () => { throw new Error("adoptable offer must not persist another question"); }, }); } function collectAccounting(dossier: DecisionDossier, activeFocus?: Record | null) { return fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => rpcDossier(dossier, activeFocus), 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: 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-04T00:00:00.000Z", resolved_at: null, }, idempotent: false, }), }); } function assertAdoptTemplate(text: string) { // 原值: 「分不开 05:00 和 05:06」+ adoptCue // 新值: 三句交付,不含八法报告 // 原因: BUG-595 决策 4 assert.match(text, /这次给出的范围 05:00–05:06/); assert.doesNotMatch(text, /排盘用/); assert.match(text, /对照了 5 件经历/); assert.match(text, /这只是代表性候选,不是已确认的唯一出生分钟/); assert.doesNotMatch(text, /继续往下收/); assert.doesNotMatch(text, /方法1|Technique Audit/); } function assertNoFocusWrite(accounting: ReturnType) { assert.equal( accounting.calls.some((item) => item.fn === "set_agentic_rectification_conversation_focus"), false, ); } test("fourteen-probe case decides offer_provisional_range and skips leftover probes", () => { const dossier = caseDossier(); const state = fourteenProbeState(); assert.equal(dossier.evidence.length, 5); assert.equal(state.probes.length, 14); assert.equal(state.answered_probes.length, 5); assert.deepEqual( state.candidates.filter((item) => item.status === "active").map((item) => [item.time, item.posterior_score]), [["05:00", 21], ["05:06", 18], ["04:53", 9]], ); const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); // 旧:十四探针分不开就 offer_provisional_range / adopt_representative,财务采集只挂计划。 // 新:财务口述未问完,决策保持采集;出牌轮才写报告。 assert.equal(decision.nextAction, "ask_fact_collection"); assert.equal(decision.sessionOutcome, "collect_evidence"); assert.equal(decision.canAdopt, true); assert.equal(decision.probe, null); assert.equal(decision.precisionStage, "collect_events"); const plan = planFrom(dossier); assert.equal(plan.next_followup?.domain, "finance"); assert.equal(plan.next_followup?.intent, "collect_method_evidence"); // Task text said "null or choice_frame". The lock is: no frameless distinguish // in deferred_followup. Adopt may still stash a later collect (eight-method). if (plan.deferred_followup?.intent === "distinguish_candidates") { assert.ok(plan.deferred_followup.choice_frame); } }); test("persistNextInterviewAfterChoice after family denial collects remaining dated events", async () => { const dossier = caseDossier(); const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); const accounting = collectAccounting(dossier); const persisted = await persistNextInterviewAfterChoice({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID, dossier, decisionState: fourteenProbeState(), nextAction: publicNextAction(decision), birthDate: "1997-08-08", }); assert.equal(persisted.persisted, true); assert.equal(persisted.followup?.domain, "finance"); assert.equal(persisted.hostNarration, USER_COLLECT_QUESTION.finance); }); test("persistNextInterviewAfterChoice narrates the stop reason once dated collect is exhausted", async () => { const dossier = caseDossier({ declinedTopics: EXHAUSTED_COLLECT_TOPICS }); const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); const accounting = adoptAccounting(dossier); const persisted = await persistNextInterviewAfterChoice({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID, dossier, decisionState: fourteenProbeState(), nextAction: publicNextAction(decision), birthDate: "1997-08-08", }); assert.equal(persisted.persisted, false); assert.equal(persisted.choiceReady, false); assertAdoptTemplate(persisted.hostNarration); assertNoFocusWrite(accounting); }); test("adoptDeliveryFacts names the representative, range, stop class, and post-adopt checks", () => { const dossier = caseDossier(); const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); const facts = adoptDeliveryFacts(decision, dossier); assert.equal(facts.representative_minute, "05:00"); assert.deepEqual(facts.credible_range, ["05:00", "05:06"]); const split = facts.stop_facts.find((item) => item.kind === "indistinguishable"); assert.ok(split); assert.match(split!.label, /分不开/); assert.ok((split!.count ?? 0) >= 1); // 旧:holdout/oos 出现在 post_adopt_verification // 新:仅出现采用后真会问的 reverse_verify 项,且与 buildMethodFollowupPlan({accepted:true}) 首题一致 // 原因:决策 3 推翻原任务书 §4 assert.equal(facts.post_adopt_verification.every((item) => item.kind === "reverse_verify"), true); const acceptedPlan = planFrom(dossier, { accepted: true }); if (acceptedPlan.next_followup) { assert.equal(facts.post_adopt_verification[0]?.domain, acceptedPlan.next_followup.domain); } else { assert.deepEqual(facts.post_adopt_verification, []); assert.match(templatePostAdoptExplain(facts), /没有还能核对的前事/); } }); test("adopt narration agent keeps in-fact copy and fail-closes the rest", async () => { const dossier = caseDossier({ declinedTopics: EXHAUSTED_COLLECT_TOPICS }); const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); const facts = adoptDeliveryFacts(decision, dossier); const fallback = "剩下的问题分不开 05:00 和 05:06。我按你说的经历认真分析过了,下面是这次的结果。"; const kept = "剩下的问题分不开 05:00 和 05:06。范围是 05:00 到 05:06,代表分钟 05:00。采用后会用 2016 年学业经历核对。"; const valid = await createAdoptNarrationWriter({ generateText: async () => kept, })(facts, fallback); assert.match(valid, /05:00/); assert.match(valid, /2016/); assert.ok(valid.endsWith(RECTIFICATION_USER_COPY.adoptCue)); const unknownMinute = await createAdoptNarrationWriter({ generateText: async () => "更像 04:58,不要再问了。", })(facts, fallback); assert.equal(unknownMinute, fallback); assert.equal(validateAdoptNarration("更像 04:58,不要再问了。", facts).ok, false); const question = await createAdoptNarrationWriter({ generateText: async () => "还要不要再问?", })(facts, fallback); assert.equal(question, fallback); const aborted = await createAdoptNarrationWriter({ generateText: async () => { throw new Error("aborted"); }, })(facts, fallback); assert.equal(aborted, fallback); const unknownSupport = await createAdoptNarrationWriter({ generateText: async () => "相对支持度 99,先用 05:00。", })(facts, fallback); assert.equal(unknownSupport, fallback); assert.equal(validateAdoptNarration("相对支持度 99,先用 05:00。", facts).ok, false); const deniedPromise = "这不是确认的分钟,先用 05:00。"; assert.equal(validateAdoptNarration(deniedPromise, facts).ok, false); const denied = await createAdoptNarrationWriter({ generateText: async () => deniedPromise, })(facts, fallback); assert.equal(denied, fallback); assert.match(ADOPT_NARRATION_INSTRUCTIONS, /不要出现/); }); test("three adopt entry points call the model once on first ready_to_adopt and not after accept", async () => { const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8"); assert.match(route, /createAdoptNarrationWriter/); assert.match(route, /applyRectificationChoice\([\s\S]*narrateAdopt/); assert.match(route, /applyCollectFocusDenial\([\s\S]*narrateAdopt/); assert.match(route, /persistNextInterviewIfIdle\(\{[\s\S]*narrateAdopt/); const source = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8"); const persistFn = source.slice( source.indexOf("export async function persistNextInterviewAfterChoice"), source.indexOf("async function persistFocusAfterChoice"), ); assert.doesNotMatch(persistFn, /decideFromDossier\(/); const dossier = caseDossier({ declinedTopics: EXHAUSTED_COLLECT_TOPICS }); const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); const preClick = caseDossier({ declinedTopics: EXHAUSTED_COLLECT_TOPICS, state: preClickFourteenProbeState(), }); const preClickDecision = decideFromDossier(preClick, { birthDate: "1997-08-08" }); assert.notEqual(preClickDecision.precisionStage, "ready_to_adopt"); assert.ok(preClickDecision.probe); const kept = "剩下的问题分不开 05:00 和 05:06。范围是 05:00 到 05:06。采用后会用 2016 年学业核对。"; function withCounter() { let calls = 0; const seen: AdoptDeliveryFacts[] = []; const narrateAdopt = createAdoptNarrationWriter({ generateText: async (facts) => { calls += 1; seen.push(facts); return kept; }, }); return { narrateAdopt, count: () => calls, seen }; } const afterChoice = withCounter(); const accountingA = adoptAccounting(preClick); await persistNextInterviewAfterChoice({ accounting: accountingA.client, userId: USER_ID, caseId: CASE_ID, dossier: preClick, decisionState: fourteenProbeState(), nextAction: publicNextAction(decision), decision, birthDate: "1997-08-08", narrateAdopt: afterChoice.narrateAdopt, }); assert.equal(afterChoice.count(), 1); assert.equal(afterChoice.seen[0]?.representative_minute, "05:00"); assert.equal(afterChoice.seen[0]?.precision_stage, "ready_to_adopt"); assert.deepEqual( afterChoice.seen[0]?.active_candidates.slice(0, 3).map((item) => [item.time, item.relative_support]), [["05:00", 21], ["05:06", 18], ["04:53", 9]], ); assertNoFocusWrite(accountingA); const invalidChoice = withCounter(); const invalidWriter = createAdoptNarrationWriter({ generateText: async (facts) => { invalidChoice.seen.push(facts); return "更像 04:58,不要再问了。"; }, }); const invalidAccounting = adoptAccounting(preClick); const invalid = await persistNextInterviewAfterChoice({ accounting: invalidAccounting.client, userId: USER_ID, caseId: CASE_ID, dossier: preClick, decisionState: fourteenProbeState(), nextAction: publicNextAction(decision), decision, birthDate: "1997-08-08", narrateAdopt: invalidWriter, }); assertAdoptTemplate(invalid.hostNarration); assert.match(invalid.hostNarration, /05:00/); assert.doesNotMatch(invalid.hostNarration, /04:58/); const afterDenial = withCounter(); let loads = 0; const accountingB = fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => { loads += 1; if (loads === 1) return rpcDossier(dossier, familyCollectFocus()); return rpcDossier(caseDossier({ declinedTopics: EXHAUSTED_COLLECT_TOPICS, })); }, 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: () => { throw new Error("adoptable offer must not persist another question"); }, }); const denied = await applyCollectFocusDenial(accountingB.client, { userId: USER_ID, caseId: CASE_ID, focusId: FOCUS_ID, narrateAdopt: afterDenial.narrateAdopt, }); assert.equal(afterDenial.count(), 1); assert.match(denied.narration, /05:00/); assert.ok(denied.narration.includes(RECTIFICATION_USER_COPY.adoptCue)); assert.equal(denied.nextInterviewPersisted, false); assertNoFocusWrite(accountingB); const idle = withCounter(); const accountingC = adoptAccounting(dossier); await persistNextInterviewIfIdle({ accounting: accountingC.client, userId: USER_ID, caseId: CASE_ID, narrateAdopt: idle.narrateAdopt, }); assert.equal(idle.count(), 1); const accepted = withCounter(); const acceptedDossier = caseDossier({ acceptedTime: "05:00" }); const acceptedRpc = rpcDossier(acceptedDossier); (acceptedRpc.case as { accepted_time: string | null }).accepted_time = "05:00"; const accountingD = fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => acceptedRpc, get_agentic_rectification_case_compute: () => computeFixture(), set_agentic_rectification_conversation_focus: () => ({ focus: familyCollectFocus(), idempotent: false, }), }); await persistNextInterviewIfIdle({ accounting: accountingD.client, userId: USER_ID, caseId: CASE_ID, narrateAdopt: accepted.narrateAdopt, }); assert.equal(accepted.count(), 0); }); test("adopt narration diagnostics distinguish agent, validation, error, and not-ready", async () => { const dossier = caseDossier({ declinedTopics: EXHAUSTED_COLLECT_TOPICS }); const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); const facts = adoptDeliveryFacts(decision, dossier); const fallback = "剩下的问题分不开 05:00 和 05:06。我按你说的经历认真分析过了,下面是这次的结果。"; const kept = "剩下的问题分不开 05:00 和 05:06。范围是 05:00 到 05:06。采用后会用 2016 年学业核对。"; const agent = await deliverAdoptNarration({ facts, fallback, generateText: async () => kept, }); assert.equal(agent.adopt_narration, "agent"); assert.match(agent.text, /05:00/); const invalid = await deliverAdoptNarration({ facts, fallback, generateText: async () => "更像 04:58,不要再问了。", }); assert.equal(invalid.adopt_narration, "template:unknown_minute"); assert.equal(invalid.text, fallback); const errored = await deliverAdoptNarration({ facts, fallback, generateText: async () => { throw new Error("model down"); }, }); assert.equal(errored.adopt_narration, "template:model_error"); assert.equal(errored.text, fallback); const notReady = await deliverAdoptNarration({ facts: { ...facts, already_accepted: true }, fallback, generateText: async () => kept, }); assert.equal(notReady.adopt_narration, "template:not_ready"); assert.equal(notReady.text, fallback); }); test("adopt narration times out to the template without throwing", async () => { const dossier = caseDossier({ declinedTopics: EXHAUSTED_COLLECT_TOPICS }); const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); const facts = adoptDeliveryFacts(decision, dossier); const fallback = "剩下的问题分不开 05:00 和 05:06。我按你说的经历认真分析过了,下面是这次的结果。"; const timed = await deliverAdoptNarration({ facts, fallback, timeoutMs: 30, generateText: async () => new Promise(() => {}), }); assert.equal(timed.adopt_narration, "template:model_error"); assert.equal(timed.text, fallback); }); test("adopt narration does not leave an active timeout after the model returns", async () => { const dossier = caseDossier({ declinedTopics: EXHAUSTED_COLLECT_TOPICS }); const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); const facts = adoptDeliveryFacts(decision, dossier); const fallback = "剩下的问题分不开 05:00 和 05:06。我按你说的经历认真分析过了,下面是这次的结果。"; const kept = "剩下的问题分不开 05:00 和 05:06。范围是 05:00 到 05:06。采用后会用 2016 年学业核对。"; const source = readFileSync( new URL("../src/lib/rectification-agentic/v9/adopt-narration-agent.ts", import.meta.url), "utf8", ); assert.doesNotMatch(source, /AbortSignal\.timeout/); assert.match(source, /clearTimeout/); const pending = new Set(); let created = 0; const realSetTimeout = globalThis.setTimeout; const realClearTimeout = globalThis.clearTimeout; globalThis.setTimeout = ((handler: TimerHandler, delay?: number, ...args: unknown[]) => { created += 1; const id = realSetTimeout(handler, delay, ...args); pending.add(id); return id; }) as typeof setTimeout; globalThis.clearTimeout = ((id?: ReturnType) => { pending.delete(id); realClearTimeout(id); }) as typeof clearTimeout; try { const delivered = await deliverAdoptNarration({ facts, fallback, timeoutMs: 8_000, generateText: async () => kept, }); assert.equal(delivered.adopt_narration, "agent"); assert.ok(created >= 1); assert.equal(pending.size, 0); } finally { globalThis.setTimeout = realSetTimeout; globalThis.clearTimeout = realClearTimeout; } }); test("applyCollectFocusDenial on the family collect keeps dated collect instead of adopting", async () => { const dossier = caseDossier(); let loads = 0; const accounting = fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => { loads += 1; if (loads === 1) return rpcDossier(dossier, familyCollectFocus()); return rpcDossier(dossier); }, 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: 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-04T00:00:00.000Z", resolved_at: null, }, idempotent: false, }), }); const applied = await applyCollectFocusDenial(accounting.client, { userId: USER_ID, caseId: CASE_ID, focusId: FOCUS_ID, }); assert.equal(applied.nextInterviewPersisted, true); assert.equal(applied.narration, USER_COLLECT_QUESTION.finance); const setFocus = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus"); assert.equal(setFocus?.args.p_target_domain, "finance"); }); test("distinguish declined does not cover d10_career or drop dated career probes", () => { const evidence = [ { id: "e-rel", status: "confirmed", domain: "relationship", datePrecision: "year" as const, occurredFrom: "2024-01-01", occurredTo: null, eventKind: "relationship_end", }, { id: "e-family", status: "confirmed", domain: "family", datePrecision: "year" as const, occurredFrom: "2021-01-01", occurredTo: null, eventKind: "family_event", }, { id: "e-edu", status: "confirmed", domain: "education", datePrecision: "year" as const, occurredFrom: "2016-01-01", occurredTo: null, eventKind: "education_start", }, { id: "e-rel-2", status: "confirmed", domain: "relationship", datePrecision: "month" as const, occurredFrom: "2024-05-01", occurredTo: null, eventKind: "relationship_start", }, ]; const careerProbe = { year: 2023, year_label: "2023 年前后", domain: "career" as const, event_family: "入职或换工作", source: "dasha_boundary" as const, tracks: ["vimshottari" as const, "narayana" as const], tracks_agree: true, unique_minute_claim: false as const, user_meaning: "2023 年前后有没有入职或换工作?", role: "distinguish" as const, phase: "candidate_discriminator" as const, information_gain: 0.8, semantic_key: "career.2023.dasha_boundary", candidate_split_hash: "career.2023", candidate_ids: ["05:00", "05:06"], expected_outcomes: [ { answer_class: "yes", supports: ["05:00"], conflicts: ["05:06"] }, { answer_class: "no", supports: ["05:06"], conflicts: ["05:00"] }, { answer_class: "unsure", supports: [], conflicts: [] }, ], choice_kind: "existence" as const, style_options: EXISTENCE_OPTIONS, }; const plan = buildMethodFollowupPlan({ evidence, declinedTopics: [ { target_domain: "career", intent: "distinguish_candidates", status: "declined" }, ], sessionOutcome: "discriminate_candidates", eventProbes: [careerProbe], topCandidateTimes: ["05:00", "05:06"], candidatesSeparated: false, }); assert.notEqual(plan.methods.find((item) => item.method_id === "d10_career")?.status, "covered"); assert.equal(plan.next_followup?.domain, "career"); assert.equal(plan.next_followup?.semantic_key, "career.2023.dasha_boundary"); assert.ok(plan.next_followup?.choice_frame); }); test("family collect declined vs extra distinguish declined leaves the same adopt decision", async () => { const familyOnly = caseDossier({ declinedTopics: [ { target_domain: "family", intent: "collect_method_evidence", status: "declined" }, ], }); const withDistinguish = caseDossier({ declinedTopics: [ { target_domain: "family", intent: "collect_method_evidence", status: "declined" }, { target_domain: "career", intent: "distinguish_candidates", status: "declined" }, { target_domain: "relocation", intent: "distinguish_candidates", status: "declined" }, ], }); const left = decideFromDossier(familyOnly, { birthDate: "1997-08-08" }); const right = decideFromDossier(withDistinguish, { birthDate: "1997-08-08" }); assert.equal(left.nextAction, right.nextAction); assert.equal(left.sessionOutcome, right.sessionOutcome); assert.equal(left.canAdopt, right.canAdopt); assert.equal(left.precisionStage, right.precisionStage); const accountingLeft = collectAccounting(familyOnly); const accountingRight = collectAccounting(withDistinguish); const narratedLeft = await persistNextInterviewAfterChoice({ accounting: accountingLeft.client, userId: USER_ID, caseId: CASE_ID, dossier: familyOnly, decisionState: fourteenProbeState(), nextAction: publicNextAction(left), birthDate: "1997-08-08", }); const narratedRight = await persistNextInterviewAfterChoice({ accounting: accountingRight.client, userId: USER_ID, caseId: CASE_ID, dossier: withDistinguish, decisionState: fourteenProbeState(), nextAction: publicNextAction(right), birthDate: "1997-08-08", }); assert.equal(narratedLeft.hostNarration, narratedRight.hostNarration); assert.equal(narratedLeft.hostNarration, USER_COLLECT_QUESTION.finance); assert.equal(narratedLeft.persisted, true); }); test("range-reading explain uses theme_sensitivity labels and the unique-minute boundary", () => { const text = templateRangeReadingExplain({ widthMinutes: 27, stableThemes: ["career", "general"], sensitiveThemes: ["marriage"], }); assert.match(text ?? "", /这 27 分钟里/); assert.match(text ?? "", /事业方向/); assert.match(text ?? "", /性格底色/); assert.match(text ?? "", /稳定/); assert.match(text ?? "", /婚恋(D9)/); assert.match(text ?? "", /随分钟变/); assert.match(text ?? "", /按范围读/); assert.ok(text?.includes(RANGE_READING_COPY.boundary)); assert.doesNotMatch(text ?? "", /这只是粗看/); }); test("adopt skip-followup appends range-reading sentences when the engine answers", async () => { const originalFetch = globalThis.fetch; globalThis.fetch = (async (input: RequestInfo | URL) => { const url = String(input); if (url.includes("/api/rectification/v5/range_reading")) { return new Response(JSON.stringify({ stable_themes: ["career", "general"], sensitive_themes: ["marriage"], claim_boundary: RANGE_READING_COPY.boundary, }), { status: 200, headers: { "content-type": "application/json" } }); } throw new Error(`unexpected fetch ${url}`); }) as typeof fetch; try { const dossier = caseDossier({ declinedTopics: EXHAUSTED_COLLECT_TOPICS }); const persisted = await persistNextInterviewIfIdle({ accounting: adoptAccounting(dossier).client, userId: USER_ID, caseId: CASE_ID, }); // 原值: 交付旁白拼接 range_reading 事业方向 / 婚恋(D9) // 新值: 只写三句交付;八法/主题对照不进气泡 // 原因: BUG-595 决策 4 assertAdoptTemplate(persisted.hostNarration ?? ""); assert.doesNotMatch(persisted.hostNarration ?? "", /事业方向/); assert.doesNotMatch(persisted.hostNarration ?? "", /性格底色/); assert.doesNotMatch(persisted.hostNarration ?? "", /婚恋(D9)/); } finally { globalThis.fetch = originalFetch; } }); test("adopt skip-followup omits range-reading sentences when the engine is down", async () => { const originalFetch = globalThis.fetch; globalThis.fetch = (async () => { throw new Error("engine down"); }) as typeof fetch; try { const dossier = caseDossier({ declinedTopics: EXHAUSTED_COLLECT_TOPICS }); const persisted = await persistNextInterviewIfIdle({ accounting: adoptAccounting(dossier).client, userId: USER_ID, caseId: CASE_ID, }); assert.ok(persisted.hostNarration); assert.doesNotMatch(persisted.hostNarration ?? "", /随分钟变/); assert.doesNotMatch(persisted.hostNarration ?? "", /这只是粗看/); } finally { globalThis.fetch = originalFetch; } });