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 { asInferenceState } from "../src/lib/rectification-agentic/core/compose-receipt.ts"; import { inspectDiscriminatorProbes, buildCandidateContrastPacket, } from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts"; import { decideRectification, isNonConvergingRangeOffer, nonConvergingRangeNarration, REPRESENTATIVE_MINUTE_DISCLAIMER, } from "../src/lib/rectification-agentic/core/rectification-decision.ts"; import { INFERENCE_ALGORITHM_VERSION } from "../src/lib/rectification-agentic/core/types.ts"; import type { ConflictProbe, InferenceState } from "../src/lib/rectification-agentic/core/types.ts"; import { decideAfterInferenceChange, decideFromDossier, type DecisionDossier, } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts"; import { exhaustionSpokenCollectFollowup, projectRectificationChoiceCard, spokenFollowupForUser, } from "../src/lib/rectification-agentic/v9/method-followup.ts"; import { persistNextInterviewIfIdle } from "../src/lib/rectification-agentic/v9/answer-choice.ts"; import { informationGainAmongActive } from "../src/lib/rectification-agentic/v9/probe-question-contract.ts"; import { projectCurrentQuestion } from "../src/lib/rectification-agentic/v9/turn-decision.ts"; import { stableFollowupQuestionId } from "../src/lib/rectification-agentic/v9/server-focus.ts"; import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts"; import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts"; import { OPEN_ENGINE_CAPABILITY_CEILING, 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:47", "04:51", "04:53", "04:59", "05:00", "05:07", "05:12", "05:14", "05:15", ] as const; const ELIMINATED = new Set(["05:00", "05:07", "05:12", "05:14", "05:15"]); const ACTIVE = ["04:47", "04:51", "04:53", "04:59"] as const; const SCORES: Record = { "04:47": 16, "04:51": 20, "04:53": 16, "04:59": 10, "05:00": 4, "05:07": 3, "05:12": 2, "05:14": 1, "05:15": 1, }; const PROBABILITY: Record = { "04:47": 0.25, "04:51": 0.4, "04:53": 0.25, "04:59": 0.1, }; const EVIDENCE = [ { id: "e-career-entry", status: "confirmed" as const, domain: "career", datePrecision: "month" as const, occurredFrom: "2020-04-01", occurredTo: null, eventKind: "career_entry", summary: "2020-04-01 career_entry", }, { id: "e-career-exit", status: "confirmed" as const, domain: "career", datePrecision: "month" as const, occurredFrom: "2020-10-01", occurredTo: null, eventKind: "career_exit", summary: "2020-10-01 career_exit", }, { id: "e-rel-start", status: "confirmed" as const, domain: "relationship", datePrecision: "month" as const, occurredFrom: "2024-05-01", occurredTo: null, eventKind: "relationship_start", summary: "2024-05-01 relationship_start", }, { id: "e-rel-end", status: "confirmed" as const, domain: "relationship", datePrecision: "day" as const, occurredFrom: "2024-08-08", occurredTo: null, eventKind: "relationship_end", summary: "2024-08-08 relationship_end", }, ] as const; const DUAL_EXIT = "可以先按当前区间看盘,也可以再补一件记得时间的经历"; function existenceProbe(input: { key: string; year: number; question: string; gain: number; source: string; yes: readonly string[]; no: readonly string[]; }): ConflictProbe { return { id: `probe:${input.key}`, semantic_key: input.key, candidate_split_hash: input.key, domain: "career", year: input.year, question: input.question, candidate_ids: [...new Set([...input.yes, ...input.no])], expected_outcomes: [ { answer_class: "yes", supports: input.yes, conflicts: input.no }, { answer_class: "weak_yes", supports: [], conflicts: [] }, { answer_class: "no", supports: input.no, conflicts: input.yes }, { answer_class: "unsure", supports: [], conflicts: [] }, ], information_gain: input.gain, source: input.source, choice_kind: "existence", style_options: EXISTENCE_OPTIONS, }; } const D9: ConflictProbe = { id: "contrast:varga.d9.巨蟹座/狮子座", semantic_key: "varga.d9.巨蟹座/狮子座", candidate_split_hash: "varga.d9.巨蟹座/狮子座", domain: "relationship", year: 0, question: "亲密关系里更接近下面哪一种相处方式?", candidate_ids: ["05:00", "05:07"], expected_outcomes: [ { answer_class: "yes", supports: ["05:00"], conflicts: ["05:07"] }, { answer_class: "weak_yes", supports: ["05:07"], conflicts: ["05:00"] }, { answer_class: "no", supports: [], conflicts: [] }, { answer_class: "unsure", supports: [], conflicts: [] }, ], information_gain: 1.1, source: "varga_contrast", choice_kind: "varga_style", }; const D10: ConflictProbe = { id: "contrast:varga.d10.天秤座/天蝎座", semantic_key: "varga.d10.天秤座/天蝎座", candidate_split_hash: "varga.d10.天秤座/天蝎座", domain: "career", year: 0, question: "平时做事更接近下面哪一种职责风格?", candidate_ids: ["05:00", "05:07"], expected_outcomes: [ { answer_class: "yes", supports: ["05:00"], conflicts: ["05:07"] }, { answer_class: "weak_yes", supports: ["05:07"], conflicts: ["05:00"] }, { answer_class: "no", supports: [], conflicts: [] }, { answer_class: "unsure", supports: [], conflicts: [] }, ], information_gain: 1.05, source: "varga_contrast", choice_kind: "varga_style", }; const CAREER_2023_05 = existenceProbe({ key: "career.2023.05.dasha_boundary", year: 2023, question: "2023 年 5 月前后有没有入职或换工作?", gain: 0.9, source: "dasha_boundary", yes: ["05:00"], no: ["05:07"], }); const RELOCATION_2015_05: ConflictProbe = { ...existenceProbe({ key: "relocation.2015.05.dasha_boundary", year: 2015, question: "2015 年 5 月前后有没有搬家或长期住到外地?", gain: 0.7, source: "dasha_boundary", yes: ["05:00"], no: ["05:07"], }), domain: "relocation", }; /** Live remaining probe: splits after/before 05:00. Zero split on 04:47–04:59. */ const CAREER_2024_04 = existenceProbe({ key: "career.2024.04.dasha_boundary", year: 2024, question: "2024 年 4 月前后有没有入职或换工作?", gain: 1.1712, source: "dasha_boundary", yes: ["05:00", "05:07", "05:12", "05:14", "05:15"], no: ["04:47", "04:51", "04:53", "04:59"], }); /** Live remaining probe: isolates 05:15. Zero split on 04:47–04:59. */ const CAREER_2023_ACTIVATION = existenceProbe({ key: "career.2023.dasha_activation", year: 2023, question: "2023 年前后大运有没有启动?", gain: 0.56, source: "dasha_activation", yes: ["05:15"], no: ["04:47", "04:51", "04:53", "04:59", "05:00", "05:07", "05:12", "05:14"], }); /** P1 fixture: dated career probe that still splits the active four minutes. */ const CAREER_ACTIVE_SPLIT = existenceProbe({ key: "career.2022.dasha_boundary", year: 2022, question: "2022 年前后有没有入职或换工作?", gain: 1.4, source: "dasha_boundary", yes: ["04:47", "04:51"], no: ["04:53", "04:59"], }); function uuidAt(index: number): string { return `88888888-8888-4888-8888-8888888888${(10 + index).toString(16).padStart(2, "0")}`; } function liveState(remaining: readonly ConflictProbe[]): InferenceState { const probes = [D9, D10, CAREER_2023_05, RELOCATION_2015_05, ...remaining]; const rankedActive = [...ACTIVE].sort((left, right) => ( (PROBABILITY[right] ?? 0) - (PROBABILITY[left] ?? 0) || (SCORES[right] ?? 0) - (SCORES[left] ?? 0) || left.localeCompare(right) )); const candidates = TIMES.map((time, index) => { const eliminated = ELIMINATED.has(time); const activeRank = (rankedActive as readonly string[]).indexOf(time); return { id: time, time, cluster_range: [time, time] as const, prior_score: SCORES[time] ?? 0, posterior_score: SCORES[time] ?? 0, probability: eliminated ? 0 : (PROBABILITY[time] ?? 0), status: eliminated ? "eliminated" as const : "active" as const, rank: eliminated ? ACTIVE.length + index : activeRank + 1, strong_conflict_count: eliminated ? 3 : 0, }; }); const answered = [D9, D10, CAREER_2023_05, RELOCATION_2015_05].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, })); const raw = { algorithm_version: INFERENCE_ALGORITHM_VERSION, candidate_set_id: candidateSetId("04:47", "05:15", TIMES), revision: 5, phase: "discrimination" as const, result_status: "discriminating" as const, range_start: "04:47", range_end: "05:15", candidates, events: [ { id: "e-career-entry", domain: "career", year: 2020, precision: "month" as const, usage: "training" as const }, { id: "e-career-exit", domain: "career", year: 2020, precision: "month" as const, usage: "training" as const }, { id: "e-rel-start", domain: "relationship", year: 2024, precision: "month" as const, usage: "training" as const }, { id: "e-rel-end", domain: "relationship", year: 2024, precision: "day" as const, usage: "training" as const }, ], probes, answered_probes: answered, rounds: [], last_inference_round: null, entropy: 1.2, representative_time: "04:51", credible_range: ["04:47", "04:53"] as const, holdout_passed: null, }; const loaded = asInferenceState(raw); assert.ok(loaded, "live inference fixture must pass asInferenceState"); return loaded; } function liveDossier( remaining: readonly ConflictProbe[], extra: { declinedTopics?: ReadonlyArray>; status?: string; eventProbes?: boolean; } = {}, ): DecisionDossier { const state = liveState(remaining); const fingerprint = evidenceLedgerFingerprint(EVIDENCE as never); return { evidence: EVIDENCE, conversationSummary: { activeFocus: null, declinedSkippedTopics: extra.declinedTopics ?? [{ target_domain: "family", status: "declined" }], }, latestResult: { resultId: "55555555-5555-4555-8555-555555555555", selectionAllowed: true, confirmationAllowed: false, evidenceLedgerFingerprint: fingerprint, candidates: TIMES.map((time, index) => ({ candidateId: uuidAt(index), time, rank: index + 1, relativeSupport: Math.round(SCORES[time] ?? 0), })), representativeTime: "04:51", decisionReceipt: { accept_allowed: true, acceptance_allowed: true, propose_allowed: true, selection_allowed: true, confirmation_allowed: false, inference_state: state, ...(extra.eventProbes ? { discriminating_event_probes: remaining.map((probe) => ({ year: probe.year, year_label: `${probe.year} 年前后`, domain: probe.domain, event_family: "入职、换工作或职责加重", source: probe.source, tracks: ["vimshottari", "narayana"], tracks_agree: true, unique_minute_claim: false, user_meaning: probe.question, role: "distinguish", information_gain: probe.information_gain, semantic_key: probe.semantic_key, candidate_split_hash: probe.candidate_split_hash, candidate_ids: probe.candidate_ids, expected_outcomes: probe.expected_outcomes, choice_kind: probe.choice_kind, style_options: probe.style_options, })), } : {}), }, }, case: { acceptedTime: null, status: extra.status }, }; } function snapshotCandidates() { return TIMES.map((time, index) => ({ candidate_id: uuidAt(index), rank: index + 1, time, relative_support: Math.round(SCORES[time] ?? 0), tied_minute_count: 1, })); } function rpcDossier(decision: DecisionDossier) { const evidence = EVIDENCE.map((item) => ({ id: item.id, source_turn_id: TURN_ID, subject: "self", event_kind: item.eventKind, domain: item.domain, occurred_from: item.occurredFrom, occurred_to: item.occurredTo, date_precision: item.datePrecision, summary: `${item.occurredFrom} ${item.eventKind}`, status: item.status, supersedes_evidence_id: null, created_at: "2026-08-29T00:00:00.000Z", })); return dossierFixture({ evidence, latestResult: candidateSnapshotFixture({ selectionAllowed: true, confirmationAllowed: false, representativeTime: "04:51", evidenceLedgerFingerprint: evidenceLedgerFingerprint(EVIDENCE as never), candidates: snapshotCandidates(), decisionReceipt: { accept_allowed: true, acceptance_allowed: true, propose_allowed: true, selection_allowed: true, confirmation_allowed: false, ...(decision.latestResult?.decisionReceipt ?? {}), }, }), conversationSummary: { confirmed_evidence_summary: [], pending_revisions: [], active_focus: null, declined_skipped_topics: decision.conversationSummary.declinedSkippedTopics, candidate_divergence_summary: null, missing_evidence_categories: [], last_result_policy: null, summary_version: 1, updated_at: "2026-08-30T00:00:00.000Z", }, }); } test("skill version stays 10.0.14 for the range-offer dead-end fix", () => { assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.14"); }); test("pre-fix dual-exit constant is gone; range narration carries numbers and the disclaimer", () => { const decision = "../src/lib/rectification-agentic/core/rectification-decision.ts"; const answer = "../src/lib/rectification-agentic/v9/answer-choice.ts"; const route = "../src/app/api/rectification/agent/route.ts"; for (const relative of [decision, answer, route]) { const source = readFileSync(new URL(relative, import.meta.url), "utf8"); assert.doesNotMatch(source, new RegExp(DUAL_EXIT)); assert.doesNotMatch(source, /NON_CONVERGING_RANGE_NARRATION|DISCRIMINATOR_EXHAUSTED_NARRATION/); assert.match(source, /nonConvergingRangeNarration/); } const text = nonConvergingRangeNarration({ credibleRange: ["04:47", "04:53"], representativeTime: "04:51", }); assert.match(text, /04:47–04:53/); assert.match(text, /04:51/); assert.match(text, new RegExp(REPRESENTATIVE_MINUTE_DISCLAIMER)); assert.doesNotMatch(text, /可以先按当前区间看盘/); }); test("zero-split among active is dropped with an explicit reason, not kept silent", () => { const after = informationGainAmongActive(CAREER_2024_04.expected_outcomes, ACTIVE); const activation = informationGainAmongActive(CAREER_2023_ACTIVATION.expected_outcomes, ACTIVE); const split = informationGainAmongActive(CAREER_ACTIVE_SPLIT.expected_outcomes, ACTIVE); assert.equal(after.splits, false); assert.equal(activation.splits, false); assert.equal(split.splits, true); const packet = buildCandidateContrastPacket({ candidateSetVersion: candidateSetId("04:47", "05:15", TIMES), engineProbes: [CAREER_2024_04, CAREER_2023_ACTIVATION].map((probe) => ({ semantic_key: probe.semantic_key, candidate_split_hash: probe.candidate_split_hash, domain: probe.domain, year: probe.year, user_meaning: probe.question, information_gain: probe.information_gain, expected_outcomes: probe.expected_outcomes, choice_kind: probe.choice_kind, style_options: probe.style_options, })), providedDomains: ["career", "relationship"], candidateTimes: [...TIMES], }); assert.equal(packet.probes.some((item) => item.semanticKey === CAREER_2024_04.semantic_key), true); const inspected = inspectDiscriminatorProbes(packet, { topCandidateTimes: ACTIVE }); assert.equal(inspected.selected, null); assert.equal(inspected.dropped.some((item) => ( item.semantic_key === CAREER_2024_04.semantic_key && item.reason === "no_split_among_active" )), true); assert.equal(inspected.dropped.some((item) => ( item.semantic_key === CAREER_2023_ACTIVATION.semantic_key && item.reason === "no_split_among_active" )), true); }); test("P1: dated career probes live only in inference_state still ask when they split active minutes", () => { const dossier = liveDossier([CAREER_ACTIVE_SPLIT]); const fromDossier = decideFromDossier(dossier, { birthDate: "1997-08-08" }); assert.equal(fromDossier.nextAction, "ask_candidate_discriminator"); assert.equal(fromDossier.probe?.semanticKey, CAREER_ACTIVE_SPLIT.semantic_key); assert.equal(isNonConvergingRangeOffer(fromDossier), false); const after = decideAfterInferenceChange({ dossier, state: liveState([CAREER_ACTIVE_SPLIT]), userStopped: false, birthDate: "1997-08-08", }); assert.equal(after.nextAction, "ask_candidate_discriminator"); assert.equal(after.probe?.semanticKey, CAREER_ACTIVE_SPLIT.semantic_key); }); test("live remaining probes with zero active split enter dropped_probes and the range-offer branch", () => { const remaining = [CAREER_2024_04, CAREER_2023_ACTIVATION]; const dossier = liveDossier(remaining); const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); assert.equal(decision.probe, null); assert.equal(decision.nextAction, "offer_provisional_range"); assert.equal(isNonConvergingRangeOffer(decision), true); assert.equal(decision.canAdopt, false); assert.equal(decision.selectionAllowed, false); assert.deepEqual(decision.credibleRange, ["04:47", "04:53"]); assert.equal(decision.representativeTime, "04:51"); assert.equal(decision.droppedProbes.some((item) => ( item.semantic_key === CAREER_2024_04.semantic_key && item.reason === "no_split_among_active" )), true); assert.equal(decision.droppedProbes.some((item) => ( item.semantic_key === CAREER_2023_ACTIVATION.semantic_key && item.reason === "no_split_among_active" )), true); }); test("offerRangeWithoutAdopt persists a spoken collect and narrates the numeric range", async () => { const dossier = liveDossier([CAREER_2024_04, CAREER_2023_ACTIVATION]); const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); assert.equal(isNonConvergingRangeOffer(decision), true); const card = projectRectificationChoiceCard({ evidence: dossier.evidence, declinedTopics: dossier.conversationSummary.declinedSkippedTopics, sessionOutcome: decision.sessionOutcome, selectionAllowed: decision.selectionAllowed, }); assert.equal(card, null); const collect = exhaustionSpokenCollectFollowup({ evidence: dossier.evidence, declinedTopics: dossier.conversationSummary.declinedSkippedTopics, }); assert.equal(collect?.domain, "education"); assert.equal(collect?.intent, "collect_method_evidence"); assert.equal(collect?.choice_frame, null); const spoken = spokenFollowupForUser(collect); assert.ok(spoken); const accounting = fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => rpcDossier(dossier), get_agentic_rectification_case_compute: () => computeFixture(), set_agentic_rectification_conversation_focus: (_fn, args) => ({ focus: { id: FOCUS_ID, case_id: CASE_ID, question_id: args.p_question_id, intent: args.p_intent, target_evidence_id: args.p_target_evidence_id, target_domain: args.p_target_domain, target_kind: args.p_target_kind, expected_answer_schema: args.p_expected_answer_schema, status: "active", asked_at: "2026-08-30T00:00:00.000Z", resolved_at: null, }, idempotent: false, }), }); const idle = await persistNextInterviewIfIdle({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID, }); assert.equal(idle.persisted, true); assert.equal(idle.choiceReady, false); assert.match(idle.hostNarration ?? "", /04:47–04:53/); assert.match(idle.hostNarration ?? "", /04:51/); assert.match(idle.hostNarration ?? "", new RegExp(REPRESENTATIVE_MINUTE_DISCLAIMER)); assert.doesNotMatch(idle.hostNarration ?? "", /升学|转学|考试/); assert.doesNotMatch(idle.hostNarration ?? "", new RegExp(DUAL_EXIT)); const setFocus = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus"); assert.equal(setFocus?.args.p_intent, "collect_method_evidence"); assert.equal(setFocus?.args.p_target_domain, "education"); const schema = setFocus?.args.p_expected_answer_schema as Record | undefined; const question = projectCurrentQuestion({ id: FOCUS_ID, questionId: String(setFocus?.args.p_question_id ?? ""), intent: "collect_method_evidence", targetDomain: "education", expectedAnswerSchema: schema ?? null, }); assert.equal(question?.kind, "collect_spoken"); assert.ok(question?.prompt); assert.equal(Boolean(idle.hostNarration) && Boolean(question), true); }); test("userStopped completes with a review-only provisional range", () => { const stopped = decideRectification({ engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING, methodCoverageAll: false, trainingGateOpen: true, userStopped: true, candidateScores: ACTIVE.map((time) => ({ time, score: SCORES[time] ?? 0 })), }); assert.equal(stopped.sessionOutcome, "provisional_range_user_stopped"); // 原值是 true;用户停止只结束追问,不能绕过 coverage 和 holdout 门槛。 assert.equal(stopped.canAdopt, false); assert.equal(stopped.canOfferRange, true); assert.equal(isNonConvergingRangeOffer(stopped), false); const dossier = liveDossier([CAREER_2024_04, CAREER_2023_ACTIVATION], { status: "paused" }); const fromDossier = decideFromDossier(dossier, { birthDate: "1997-08-08" }); assert.equal(fromDossier.sessionOutcome, "provisional_range_user_stopped"); // 原值是 true;该 dossier 只有两个同域日期事件,停止不等于证据达标。 assert.equal(fromDossier.canAdopt, false); }); test("idle persist still decides from the dossier once and does not invent collect_evidence", () => { const source = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8"); const idle = source.slice( source.indexOf("export async function persistNextInterviewIfIdle"), source.indexOf("async function persistApplied"), ); assert.equal(idle.split("decideFromDossier").length - 1, 1); assert.match(idle, /sessionOutcome:\s*decision\.sessionOutcome/); assert.doesNotMatch(idle, /sessionOutcome:\s*"collect_evidence"/); assert.match(idle, /persistExhaustionCollect/); }); function datedCollectEvidence(domain: string, year: string, extra: { eventKind?: string } = {}) { return { status: "confirmed" as const, domain, datePrecision: extra.eventKind ? "unknown" as const : "year" as const, occurredFrom: extra.eventKind ? null : `${year}-01-01`, occurredTo: null, ...(extra.eventKind ? { eventKind: extra.eventKind } : {}), }; } test("exhaustion after family declined and dated domains confirmed asks occupation", () => { const next = exhaustionSpokenCollectFollowup({ evidence: [ datedCollectEvidence("education", "2016"), datedCollectEvidence("career", "2020"), datedCollectEvidence("relationship", "2018"), datedCollectEvidence("finance", "2024"), ], declinedTopics: [{ target_domain: "family", status: "declined" }], }); assert.equal(next?.domain, "occupation"); assert.equal(next?.choice_frame, null); assert.equal(spokenFollowupForUser(next), "你长期做什么工作?"); }); test("exhaustion generic fallback uses domain other, not unknown", () => { const next = exhaustionSpokenCollectFollowup({ evidence: [ datedCollectEvidence("education", "2016"), datedCollectEvidence("career", "2020"), datedCollectEvidence("relationship", "2018"), datedCollectEvidence("finance", "2024"), datedCollectEvidence("relocation", "2022"), datedCollectEvidence("health_pressure", "2021"), datedCollectEvidence("occupation", "2020", { eventKind: "occupation_note" }), ], declinedTopics: [{ target_domain: "family", status: "declined" }], }); assert.equal(next?.domain, "other"); assert.equal(next?.intent, "collect_method_evidence"); assert.equal(stableFollowupQuestionId(next), "collect:other:collect_method_evidence"); assert.equal(spokenFollowupForUser(next), "可以再说一件记得大概时间的经历。"); });