import assert from "node:assert/strict"; 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 { INFERENCE_ALGORITHM_VERSION } from "../src/lib/rectification-agentic/core/types.ts"; import type { ConflictProbe } from "../src/lib/rectification-agentic/core/types.ts"; import { decideFromDossier, rectificationFollowupCatalog, type DecisionDossier, } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts"; import { persistNextInterviewAfterChoice, persistNextInterviewIfIdle, } from "../src/lib/rectification-agentic/v9/answer-choice.ts"; import { resetDeliveryTurnGuardForTests } from "../src/lib/rectification-agentic/v9/delivery-turn-guard.ts"; import { publicNextAction } from "../src/lib/rectification-agentic/core/rectification-decision.ts"; import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts"; import { COLLECT_FLOW_BANNED_PHRASES, targetedCollectPool, } from "../src/lib/rectification-agentic/v9/collection-question-pool.ts"; import { rangeDeliveryForSnapshot } from "../src/lib/rectification-agentic/v9/divergence-panel.ts"; import { buildMethodFollowupPlan, } from "../src/lib/rectification-agentic/v9/method-followup.ts"; import { alignedProbeId, refreshDatedDiscriminatorPoolIfNeeded, resetRefreshDiscriminatorProbesForTests, setRefreshDiscriminatorProbesForTests, } from "../src/lib/rectification-agentic/v9/refresh-discriminator-probes.ts"; import { RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts"; import { persistServerOwnedFocus, FOCUS_TARGET_KIND_CHECK } from "../src/lib/rectification-agentic/v9/server-focus.ts"; import type { DiscriminatingEventProbe } from "../src/lib/rectification-agentic/v9/refinement-packet.ts"; import { rectificationQuestionGapState } from "../src/lib/rectification-surface-state.ts"; import { CASE_ID, FOCUS_ID, TURN_ID, USER_ID, activeFocusFixture, candidateSnapshotFixture, computeFixture, conversationSummaryFixture, 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 VARGA_OPTIONS = [ { label: "相处里更在意照顾对方的感受", answer_class: "yes" as const, sign: "巨蟹座" }, { label: "习惯带头,也不排斥站到台前", answer_class: "weak_yes" as const, sign: "狮子座" }, ]; const TIMES = ["04:48", "04:53", "04:54", "04:59", "05:06", "05:07"] as const; const SCORES: Record = { "04:48": 10, "04:53": 15, "04:54": 14, "04:59": 13, "05:06": 13, "05:07": 12, }; const educationStart = { id: "e-edu-start", status: "confirmed" as const, domain: "education", datePrecision: "month" as const, occurredFrom: "2016-09-01", occurredTo: "2016-09-30", eventKind: "education_start", summary: "2016年9月上大学", }; const educationEnd = { id: "e-edu-end", status: "confirmed" as const, domain: "education", datePrecision: "month" as const, occurredFrom: "2020-06-01", occurredTo: "2020-06-30", eventKind: "education_completion", summary: "2020年6月毕业", }; const careerIntern = { id: "e-career-intern", status: "confirmed" as const, domain: "career", datePrecision: "month" as const, occurredFrom: "2020-04-01", occurredTo: null, eventKind: "career_entry", summary: "2020年4月入职实习", }; const careerLeave = { id: "e-career-leave", status: "confirmed" as const, domain: "career", datePrecision: "month" as const, occurredFrom: "2020-10-01", occurredTo: null, eventKind: "career_exit", summary: "2020年10月离职", }; const EVIDENCE = [educationStart, educationEnd, careerIntern, careerLeave]; function uuidAt(index: number) { return `00000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`; } function existenceProbe(input: { key: string; domain: string; year: number; month?: number; question: string; source?: string; choiceKind?: ConflictProbe["choice_kind"]; }): ConflictProbe { return { id: `probe:${input.key}`, semantic_key: input.key, candidate_split_hash: input.key, domain: input.domain, year: input.year, question: input.question, candidate_ids: [...TIMES], expected_outcomes: [ { answer_class: "yes", supports: ["04:54"], conflicts: ["05:06"] }, { answer_class: "weak_yes", supports: [], conflicts: [] }, { answer_class: "no", supports: ["05:06"], conflicts: ["04:54"] }, { answer_class: "unsure", supports: [], conflicts: [] }, ], information_gain: 0.4, source: input.source ?? "dasha_boundary", choice_kind: input.choiceKind ?? "existence", style_options: EXISTENCE_OPTIONS, }; } const ASKED_PROBES = [ existenceProbe({ key: "career.2023.05.dasha_boundary", domain: "career", year: 2023, month: 5, question: "2023 年 5 月前后有没有入职或换工作", }), existenceProbe({ key: "relationship.2023.05.dasha_boundary", domain: "relationship", year: 2023, month: 5, question: "2023 年 5 月前后感情有没有明显变化", }), existenceProbe({ key: "relocation.2015.05.dasha_boundary", domain: "relocation", year: 2015, month: 5, question: "2015 年 5 月前后有没有搬家", }), existenceProbe({ key: "education.2016.quality", domain: "education", year: 2016, question: "2016 年那次学业发挥怎么样", source: "known_event_quality", choiceKind: "event_quality", }), existenceProbe({ key: "career.2024.04.dasha_boundary", domain: "career", year: 2024, month: 4, question: "2024 年 4 月前后有没有入职或换工作", }), existenceProbe({ key: "relationship.2024.04.dasha_boundary", domain: "relationship", year: 2024, month: 4, question: "2024 年 4 月前后感情有没有明显变化", }), ]; const SIXTH = ASKED_PROBES[5]!; const LEFTOVER_SAME_YEAR = existenceProbe({ key: "career.2023.dasha_activation", domain: "career", year: 2023, question: "2023 年前后有没有职责加重", source: "dasha_activation", }); const D10_STYLE: ConflictProbe = { id: "probe:varga.d10.狮子座/处女座", semantic_key: "varga.d10.狮子座/处女座", candidate_split_hash: "04:48-05:07:04:49,04:53:varga.d10.狮子座/处女座", domain: "career", year: 0, question: "平时做事,你更接近下面哪一种?", candidate_ids: [...TIMES], expected_outcomes: [ { answer_class: "yes", supports: ["04:54"], conflicts: ["05:06"] }, { answer_class: "weak_yes", supports: ["05:06"], conflicts: ["04:54"] }, { answer_class: "no", supports: [], conflicts: [] }, { answer_class: "unsure", supports: [], conflicts: [] }, ], information_gain: 0.9, source: "varga_contrast", choice_kind: "varga_style", style_options: VARGA_OPTIONS, }; const NAKSHATRA: ConflictProbe = { id: "probe:nakshatra.boundary", semantic_key: "nakshatra.boundary.a/b", candidate_split_hash: "nakshatra.boundary.a/b", domain: "other", year: 0, question: "两组月宿性格里更接近哪一种?", candidate_ids: ["04:54", "05:06"], expected_outcomes: [ { answer_class: "yes", supports: ["04:54"], conflicts: ["05:06"] }, { answer_class: "weak_yes", supports: ["05:06"], conflicts: ["04:54"] }, { answer_class: "no", supports: [], conflicts: [] }, { answer_class: "unsure", supports: [], conflicts: [] }, ], information_gain: 0.01, source: "nakshatra_boundary", choice_kind: "varga_style", style_options: VARGA_OPTIONS, }; function liveState(answeredCount: number) { const answered = ASKED_PROBES.slice(0, answeredCount); const leftoverYearless = [1, 2, 3, 4, 5].map((index) => existenceProbe({ key: `yearless.existence.${index}`, domain: "career", year: 0, question: "有没有过一次说不清年份的工作变化", source: "varga_contrast", })); const probes = [ ...answered, LEFTOVER_SAME_YEAR, ...leftoverYearless, D10_STYLE, NAKSHATRA, ]; const candidates = TIMES.map((time, index) => ({ id: time, time, cluster_range: [time, time] as const, prior_score: SCORES[time] ?? 0, posterior_score: SCORES[time] ?? 0, probability: (SCORES[time] ?? 0) / 76, status: "active" as const, rank: index + 1, strong_conflict_count: 0, })); const raw = { algorithm_version: INFERENCE_ALGORITHM_VERSION, candidate_set_id: candidateSetId("04:48", "05:07", TIMES), revision: answeredCount, phase: "discrimination" as const, result_status: "discriminating" as const, range_start: "04:48", range_end: "05:07", candidates, events: [ { id: educationStart.id, domain: "education", year: 2016, precision: "month" as const, usage: "training" as const }, { id: educationEnd.id, domain: "education", year: 2020, precision: "month" as const, usage: "training" as const }, { id: careerIntern.id, domain: "career", year: 2020, precision: "month" as const, usage: "training" as const }, { id: careerLeave.id, domain: "career", year: 2020, precision: "month" as const, usage: "holdout" as const }, ], probes, answered_probes: answered.map((probe) => ({ probe_id: probe.id, semantic_key: probe.semantic_key, candidate_split_hash: probe.candidate_split_hash, answer_class: probe.semantic_key.includes("2024.04") && probe.domain === "career" ? "yes" as const : "no" as const, classified_from: "choice" as const, })), rounds: answered.map((probe, index) => ({ round: index + 1, phase: "discrimination" as const, probe_id: probe.id, scores_before: { "04:53": 15 }, scores_after: { "04:53": 15 }, entropy_before: 1.4, entropy_after: 1.4, eliminated_ids: [] as string[], winner_id: null, kind: "informative" as const, })), last_inference_round: null, entropy: 1.4, representative_time: "04:53", credible_range: ["04:48", "05:07"] as const, holdout_passed: null, refresh_count: 0, transitions: [ { layer: "d9", at: "04:52", from_sign: "Cancer", to_sign: "Leo" }, { layer: "d10", at: "05:00", from_sign: "Cancer", to_sign: "Leo" }, { layer: "d4", at: "05:00", from_sign: "Aries", to_sign: "Taurus" }, { layer: "d12", at: "05:00", from_sign: "Aries", to_sign: "Taurus" }, { layer: "d24", at: "05:00", from_sign: "Aries", to_sign: "Taurus" }, { layer: "d2", at: "05:00", from_sign: "Aries", to_sign: "Taurus" }, { layer: "d24", at: "05:06", from_sign: "Taurus", to_sign: "Gemini" }, { layer: "d11", at: "05:07", from_sign: "Aries", to_sign: "Taurus" }, ], }; const loaded = asInferenceState(raw); assert.ok(loaded); return loaded; } function eventProbeRow(probe: ConflictProbe): DiscriminatingEventProbe { return { year: probe.year, year_label: probe.year > 0 ? `${probe.year} 年前后` : "", domain: probe.domain as DiscriminatingEventProbe["domain"], event_family: probe.domain === "family" ? "家人结婚、添丁或住院" : probe.domain, source: probe.source === "dasha_activation" || probe.source === "dasha_boundary" || probe.source === "known_event_quality" ? probe.source : "dasha_boundary", 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, ...(probe.choice_kind ? { choice_kind: probe.choice_kind } : {}), ...(probe.style_options?.length ? { style_options: probe.style_options } : {}), }; } const FAMILY_REFRESH = existenceProbe({ key: "family.2018.05.dasha_boundary", domain: "family", year: 2018, month: 5, question: "2018 年 5 月前后家里有没有添丁或长辈住院", }); const TARGETED_DECLINED = { target_domain: "family", status: "declined", intent: "collect_method_evidence", questionId: "collect:targeted:family", target_kind: "targeted:family", } as const; const TARGETED_ALL_DECLINED = [ "relationship", "relocation", "family", "finance", "health_pressure", ].map((domain) => ({ target_domain: domain, status: "declined", intent: "collect_method_evidence", questionId: `collect:targeted:${domain}`, target_kind: `targeted:${domain}`, })); function accidentDossier(answeredCount: number, extra: { activeFocus?: ReturnType | null; refreshCount?: number; declinedTopics?: readonly Readonly>[]; } = {}): DecisionDossier { const loaded = liveState(answeredCount); const state = extra.refreshCount != null ? { ...loaded, refresh_count: extra.refreshCount, refresh_attempts: extra.refreshCount >= 1 ? [{ candidate_set_id: loaded.candidate_set_id, answer_count: loaded.answered_probes.length, result: "no_new_probes" as const, at: "2026-09-11T00:00:00.000Z", }] : loaded.refresh_attempts, } : loaded; const fingerprint = evidenceLedgerFingerprint(EVIDENCE as never); return { evidence: EVIDENCE, conversationSummary: { activeFocus: extra.activeFocus ? { id: extra.activeFocus.id, intent: extra.activeFocus.intent, targetDomain: extra.activeFocus.target_domain, targetKind: extra.activeFocus.target_kind, expectedAnswerSchema: extra.activeFocus.expected_answer_schema, } : null, declinedSkippedTopics: [{ target_domain: "other", status: "declined", intent: "collect_method_evidence", questionId: "collect:invite:more", target_kind: "invite_more", }, ...(extra.declinedTopics ?? [])], }, 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: SCORES[time] ?? 0, })), representativeTime: "04:53", decisionReceipt: { accept_allowed: true, acceptance_allowed: true, propose_allowed: true, selection_allowed: true, confirmation_allowed: false, acceptance_reasons: [], inference_state: state, discriminating_event_probes: [ ...ASKED_PROBES.map(eventProbeRow), eventProbeRow(LEFTOVER_SAME_YEAR), eventProbeRow(D10_STYLE), ], oos_blind_prompts: [], }, }, case: { acceptedTime: null, status: "collecting_evidence" }, }; } function rpcDossier(decision: DecisionDossier, extra: { activeFocus?: ReturnType | null; } = {}) { const evidence = decision.evidence.map((item) => ({ id: item.id ?? "e-unknown", source_turn_id: TURN_ID, subject: "self", event_kind: item.eventKind ?? item.domain, domain: item.domain, occurred_from: item.occurredFrom, occurred_to: item.occurredTo, date_precision: item.datePrecision, summary: item.summary ?? item.domain, status: item.status, supersedes_evidence_id: null, created_at: "2026-09-11T00:00:00.000Z", })); return dossierFixture({ evidence, evidenceCount: evidence.length, latestResult: candidateSnapshotFixture({ selectionAllowed: true, confirmationAllowed: false, representativeTime: "04:53", evidenceLedgerFingerprint: evidenceLedgerFingerprint(decision.evidence as never), candidates: decision.latestResult?.candidates?.map((item, index) => ({ candidate_id: item.candidateId ?? uuidAt(index), time: item.time, rank: item.rank ?? index + 1, relative_support: Math.max(0, Math.min(100, item.relativeSupport ?? 0)), tied_minute_count: 1, })) ?? [], decisionReceipt: { ...(decision.latestResult?.decisionReceipt ?? {}) }, }), conversationSummary: conversationSummaryFixture({ activeFocus: extra.activeFocus ?? null, declinedSkippedTopics: [...decision.conversationSummary.declinedSkippedTopics], }), }); } function idleHandlers(decision: DecisionDossier, extra: { activeFocus?: ReturnType | null; throwOnFocus?: boolean; transitions?: Record[]; } = {}) { return fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => { const base = rpcDossier(decision, extra); const last = extra.transitions?.at(-1); const inference = last?.p_inference_state; const latest = base.latest_result as { decision_receipt?: Record; } | null | undefined; if (!inference || typeof inference !== "object" || !latest) return base; return { ...base, latest_result: { ...latest, decision_receipt: { ...(latest.decision_receipt ?? {}), inference_state: inference, refresh_attempts: (inference as { refresh_attempts?: unknown }).refresh_attempts, }, }, }; }, get_agentic_rectification_case_compute: () => computeFixture(), append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }), apply_agentic_rectification_choice_action: (_fn, args) => ({ action_id: args.p_action_id, status: "applied", idempotent: false, question_id: args.p_question_id, option_id: args.p_option_id, probe_id: SIXTH.id, revision: Number(args.p_expected_revision) + 1, source_quote: args.p_source_quote, derived_context: args.p_derived_context, narration: args.p_narration, focus_status: args.p_focus_status, }), set_agentic_rectification_conversation_focus: extra.throwOnFocus ? () => { throw new Error("persist skipped"); } : (_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-11T00:00:00.000Z", resolved_at: null, asked_turn_id: args.p_asked_turn_id ?? null, }, idempotent: false, }), finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }), get_agentic_rectification_turn_receipt: () => null, append_agentic_rectification_inference_transition: (_fn, args) => { extra.transitions?.push(args as Record); const inference = args.p_inference_state as { candidates?: unknown[]; candidate_set_id?: string; refresh_count?: number; } | undefined; return { result_id: "55555555-5555-4555-8555-555555555555", revision: Number(args.p_expected_revision ?? 0) + 1, idempotent: false, decision_receipt: { inference_state: args.p_inference_state, }, decision_state_fingerprint: args.p_decision_state_fingerprint, reason: args.p_reason, candidates: inference?.candidates?.length ?? 0, candidate_set_id: args.p_candidate_set_id ?? inference?.candidate_set_id, refresh_count: inference?.refresh_count ?? 0, }; }, }); } function warnLines(run: () => Promise | unknown) { const lines: string[] = []; const original = console.warn; console.warn = (...args: unknown[]) => { lines.push(args.map((item) => String(item)).join(" ")); original.apply(console, args); }; return Promise.resolve(run()).finally(() => { console.warn = original; }).then((result) => ({ result, lines })); } function followupPlan(dossier: DecisionDossier, sessionOutcome: string) { const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence); return buildMethodFollowupPlan({ ...catalog, evidence: dossier.evidence, declinedTopics: dossier.conversationSummary.declinedSkippedTopics, sessionOutcome: sessionOutcome as never, candidatesSeparated: false, birthDate: "1997-08-08", }); } test("T0: sixth dated answer must refresh or targeted-collect, not deliver a card", async () => { resetDeliveryTurnGuardForTests(); resetRefreshDiscriminatorProbesForTests(); setRefreshDiscriminatorProbesForTests(async ({ state }) => ({ state: { ...state, refresh_count: (state.refresh_count ?? 0) + 1 }, eventProbes: [], candidateSetId: state.candidate_set_id, refreshCount: (state.refresh_count ?? 0) + 1, })); const dossier = accidentDossier(6); const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); const plan = followupPlan(dossier, decision.sessionOutcome); // 原值: sixth answer → offer_provisional_range / complete_with_range // 新值: ask_fact_collection until refresh + targeted collect are exhausted // 原因: BUG-654 带年月池空不等于结束 assert.equal(decision.nextAction, "ask_fact_collection", decision.nextAction); assert.equal(decision.canOfferRange, false); assert.equal(plan.next_followup?.choice_kind, "existence"); assert.notEqual(plan.next_followup?.choice_kind, "varga_style"); assert.notEqual(plan.next_followup?.source, "nakshatra_boundary"); assert.match(plan.next_followup?.collection_key ?? "", /collect:targeted:relationship/); // 原值: 题干「结过婚或订过婚吗?」 // 新值: 领域全称问法「哪一年都算」 // 原因: D5 存在性题问整个领域 assert.equal( plan.next_followup?.choice_frame?.prompt, "感情上有没有过开始一段认真关系、分手、订婚或结婚,哪一年都算?", ); assert.match(plan.next_followup?.spoken_prompt ?? "", /现在还剩 04:48–05:07 里 6 个候选/); assert.doesNotMatch(plan.next_followup?.spoken_prompt ?? "", /能把 04:48 和 05:07 分开/); const idleAccounting = idleHandlers(dossier); const { result: idle } = await warnLines(() => persistNextInterviewIfIdle({ accounting: idleAccounting.client, userId: USER_ID, caseId: CASE_ID, })); const persisted = idle as Awaited>; const host = persisted.hostNarration ?? ""; assert.ok(host.trim(), "answer/idle transaction must leave a carrier"); assert.match(host, /家里|收入|搬家|感情|还能再收窄|添丁|住院|结过婚|现在还剩/); assert.doesNotMatch(host, /这次给出|最终|做不了|才会变|没有拿到下一个问题/); assert.doesNotMatch(host, /能把 04:48 和 05:07 分开/); assert.equal(persisted.choiceReady, true); const focusWrite = idleAccounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus"); assert.ok(focusWrite, "targeted collect must become the active focus"); assert.match(String(focusWrite.args.p_question_id ?? ""), /collect:targeted:/); assert.ok( (FOCUS_TARGET_KIND_CHECK as readonly string[]).includes(String(focusWrite.args.p_target_kind ?? "")), String(focusWrite.args.p_target_kind), ); const schema = focusWrite.args.p_expected_answer_schema as Record | undefined; assert.equal(schema?.targeted_collect, true); assert.equal( (schema?.choice as { prompt?: string } | undefined)?.prompt, "感情上有没有过开始一段认真关系、分手、订婚或结婚,哪一年都算?", ); for (const phrase of COLLECT_FLOW_BANNED_PHRASES) { if (phrase === "领域") continue; assert.equal(host.includes(phrase), false, phrase); } resetRefreshDiscriminatorProbesForTests(); }); test("T1: sixth-answer persist refreshes a dated family probe without changing the candidate set", async () => { resetDeliveryTurnGuardForTests(); resetRefreshDiscriminatorProbesForTests(); setRefreshDiscriminatorProbesForTests(async ({ state }) => { const nextCount = (state.refresh_count ?? 0) + 1; return { state: { ...state, refresh_count: nextCount, probes: [...state.probes, FAMILY_REFRESH], }, eventProbes: [eventProbeRow(FAMILY_REFRESH)], candidateSetId: state.candidate_set_id, refreshCount: nextCount, }; }); const dossier = accidentDossier(6); const beforeSet = liveState(6).candidate_set_id; const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); const accounting = idleHandlers(dossier); const next = await persistNextInterviewAfterChoice({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID, dossier, decisionState: liveState(6), nextAction: publicNextAction(decision), decision, birthDate: "1997-08-08", }); assert.equal(next.choiceReady, true, next.hostNarration); assert.match(next.hostNarration, /2018|家里|添丁|住院/); assert.doesNotMatch(next.hostNarration, /平时做事|月宿性格/); const live = asInferenceState( (next as { followup?: { semantic_key?: string } }).followup ? dossier.latestResult?.decisionReceipt?.inference_state : liveState(6), ); assert.equal(beforeSet, candidateSetId("04:48", "05:07", TIMES)); assert.equal(live?.candidate_set_id, beforeSet); assert.equal(liveState(6).rounds.every((item) => item.kind === "informative"), true); assert.match(next.followup?.semantic_key ?? "", /family\.2018|finance\.|relocation\./); assert.ok((next.followup?.probe_year ?? 0) >= 2015); assert.ok((next.followup?.probe_year ?? 0) <= 2026); resetRefreshDiscriminatorProbesForTests(); }); test("T3: skipped persist still leaves a non-empty carrier; 没有了 delivers the range card", async () => { resetDeliveryTurnGuardForTests(); resetRefreshDiscriminatorProbesForTests(); setRefreshDiscriminatorProbesForTests(async ({ state }) => ({ state: { ...state, refresh_count: (state.refresh_count ?? 0) + 1 }, eventProbes: [], candidateSetId: state.candidate_set_id, refreshCount: (state.refresh_count ?? 0) + 1, })); const dossier = accidentDossier(6); const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); const accounting = idleHandlers(dossier, { throwOnFocus: true }); const { result, lines } = await warnLines(() => persistNextInterviewAfterChoice({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID, dossier, decisionState: liveState(6), nextAction: publicNextAction(decision), decision, birthDate: "1997-08-08", })); const next = result as Awaited>; assert.ok((next.hostNarration ?? "").trim(), "BUG-652: never silent empty carrier"); // 原值: /目前范围|范围已经收到|能问的都问完了/ // 新值: 门槛未达时写引导收窄句 // 原因: D1 20 分钟窗不出卡,改问引导题 assert.match(next.hostNarration, /目前范围|范围已经收到|能问的都问完了|再对照几件经历会更准/); assert.doesNotMatch(next.hostNarration, /没有拿到下一个问题/); const skippedDirect = await persistServerOwnedFocus({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID, activeFocus: null, decisionReceipt: dossier.latestResult?.decisionReceipt ?? null, followup: { method_id: "d10_career", intent: "distinguish_candidates", ask_theme: "career_style", domain: "career", kind_hint: null, user_prompt_hint: "ask", must_not_label: false, choice_frame: null, source: "event_probe", semantic_key: D10_STYLE.semantic_key, choice_kind: "varga_style", }, }); assert.equal(skippedDirect.status, "skipped"); const declined = accidentDossier(6, { refreshCount: 1, declinedTopics: [TARGETED_DECLINED], }); const stillAsking = decideFromDossier(declined, { birthDate: "1997-08-08" }); // 原值: 拒答任意一条定向补事即出范围卡 // 新值: 只关掉 family 这条线后仍问剩余线 // 原因: BUG-661 逐条点选 assert.equal(stillAsking.nextAction, "ask_fact_collection", stillAsking.nextAction); const stillCatalog = rectificationFollowupCatalog(declined.latestResult, declined.evidence); assert.ok( targetedCollectPool( stillCatalog.remainingLayers, declined.evidence, declined.conversationSummary.declinedSkippedTopics, stillCatalog.remainingSplitTimes, stillCatalog.remainingCandidateCount, ).length > 0, ); const allDeclined = accidentDossier(6, { refreshCount: 1, declinedTopics: TARGETED_ALL_DECLINED, }); const delivered = decideFromDossier(allDeclined, { birthDate: "1997-08-08" }); // 原值: 风格题前置把 closed-ceiling 也 hold 成 ask_candidate_discriminator // 新值: 耗尽/收口路径直接交付 // 原因: BUG-688 D2 assert.ok( delivered.nextAction === "offer_provisional_range" || delivered.nextAction === "ready_to_adopt" || delivered.nextAction === "complete_with_range", delivered.nextAction, ); assert.equal(delivered.canOfferRange, true); const catalog = rectificationFollowupCatalog(allDeclined.latestResult, allDeclined.evidence); assert.equal( targetedCollectPool( catalog.remainingLayers, allDeclined.evidence, allDeclined.conversationSummary.declinedSkippedTopics, catalog.remainingSplitTimes, catalog.remainingCandidateCount, ).length, 0, ); assert.ok(skippedDirect.status === "skipped" || lines.length >= 0); resetRefreshDiscriminatorProbesForTests(); }); test("T4: exhausted refresh and declined targeted collect titles the card 目前范围", async () => { resetDeliveryTurnGuardForTests(); resetRefreshDiscriminatorProbesForTests(); const dossier = accidentDossier(6, { refreshCount: 1, declinedTopics: TARGETED_ALL_DECLINED, }); const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); // 原值: heldForTieBreak 把 closed-ceiling 压成 ask_candidate_discriminator // 新值: 定向补事关完后交付范围卡 // 原因: BUG-688 D2 耗尽/收口不得 hold assert.ok( decision.nextAction === "offer_provisional_range" || decision.nextAction === "ready_to_adopt" || decision.nextAction === "complete_with_range", decision.nextAction, ); const delivery = rangeDeliveryForSnapshot({ decisionReceipt: dossier.latestResult?.decisionReceipt, candidates: dossier.latestResult?.candidates, representativeTime: decision.representativeTime, credibleRange: decision.credibleRange, evidence: dossier.evidence, declinedTopics: dossier.conversationSummary.declinedSkippedTopics, }); assert.ok((delivery.columns?.length ?? 0) >= 3); // 原值: 这次给出的范围 04:48–05:07 · 对照了 4 件经历 // 新值: 目前范围 04:48–05:07(对照了 4 件经历) // 原因: BUG-654 卡片不得把当前范围写成结束 assert.equal(RECTIFICATION_USER_COPY.rangeDeliveryTitle, "目前范围"); const title = `${RECTIFICATION_USER_COPY.rangeDeliveryTitle} ${delivery.range?.[0]}–${delivery.range?.[1]}(对照了 ${delivery.event_count} 件经历)`; assert.match(title, /^目前范围 04:48–05:07(对照了 4 件经历)$/); assert.doesNotMatch(title, /这次给出|最终|结束| · /); assert.match(delivery.narrow_hint ?? "", /现在还剩 04:48–05:07 里 6 个候选/); // 原值: 收口改为不限领域的补充邀请 // 新值: 交付卡只写「按现有信息分不开」,不再邀请自由打字 // 原因: D3 删除自由文本邀请 assert.match(delivery.narrow_hint ?? "", /按现有信息分不开/); assert.doesNotMatch(delivery.narrow_hint ?? "", /不限领域|确切哪一天|问完了/); assert.doesNotMatch(delivery.narrow_hint ?? "", /还能再收窄:如果记得/); assert.doesNotMatch(delivery.narrow_hint ?? "", /能把 04:48 和 05:07 分开/); assert.doesNotMatch(delivery.narrow_hint ?? "", /这次给出|最终/); const publicAction = publicNextAction(decision); assert.equal(publicAction.can_offer_range, true); assert.equal(rectificationQuestionGapState({ liveQuestionVisible: false, questionMissing: true, questionLoadFailed: false, collectWaiting: false, busy: false, readonly: false, regenerating: false, snapshotLoaded: true, resumableCase: true, retryAttempts: 0, offerAwaitingReader: publicAction.can_offer_range, }), "idle"); const idle = await persistNextInterviewIfIdle({ accounting: idleHandlers(dossier).client, userId: USER_ID, caseId: CASE_ID, }); // 原值: /目前范围|范围已经收到|能问的都问完了/ // 新值: 追加 /现在还剩 .+ 里 \d+ 个候选/ // 原因: persistNextInterviewIfIdle 自己重算决策,没有 options 里的门槛覆盖, // 所以它写的是采集侧的区间旁白;禁词断言不变(BUG-751) assert.match( idle.hostNarration ?? "", /目前范围|范围已经收到|能问的都问完了|现在还剩 .+ 里 \d+ 个候选/, ); assert.doesNotMatch(idle.hostNarration ?? "", /这次给出|最终/); assert.doesNotMatch(idle.hostNarration ?? "", /平时做事|月宿性格/); }); test("T0: GET-selected receipt probe is rejected until refresh merges it into inference_state", async () => { resetDeliveryTurnGuardForTests(); resetRefreshDiscriminatorProbesForTests(); const base = accidentDossier(6); const mismatched = { ...base, latestResult: { ...base.latestResult!, decisionReceipt: { ...(base.latestResult?.decisionReceipt ?? {}), discriminating_event_probes: [ ...((base.latestResult?.decisionReceipt?.discriminating_event_probes as DiscriminatingEventProbe[] | undefined) ?? []), eventProbeRow(FAMILY_REFRESH), ], }, }, }; const getDecision = decideFromDossier(mismatched, { birthDate: "1997-08-08" }); const getPlan = followupPlan(mismatched, getDecision.sessionOutcome); const getKey = getPlan.next_followup?.semantic_key ?? null; const persist = await persistServerOwnedFocus({ accounting: idleHandlers(mismatched).client, userId: USER_ID, caseId: CASE_ID, activeFocus: null, decisionReceipt: mismatched.latestResult?.decisionReceipt ?? null, followup: getPlan.next_followup, }); console.log("T0 GET probe vs persist rejection", { getKey, persistStatus: persist.status }); assert.equal(getKey, FAMILY_REFRESH.semantic_key, String(getKey)); assert.equal(persist.status, "invalid_choice_schema", persist.status); }); test("T0: last inference row and GET probe key vs persist status after a real refresh", async () => { resetDeliveryTurnGuardForTests(); resetRefreshDiscriminatorProbesForTests(); setRefreshDiscriminatorProbesForTests(async ({ state }) => { const nextCount = (state.refresh_count ?? 0) + 1; return { state: { ...state, refresh_count: nextCount }, eventProbes: [eventProbeRow(FAMILY_REFRESH)], candidateSetId: state.candidate_set_id, refreshCount: nextCount, }; }); const dossier = accidentDossier(6); const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); const transitions: Record[] = []; const accounting = idleHandlers(dossier, { transitions }); const next = await persistNextInterviewAfterChoice({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID, dossier, decisionState: liveState(6), nextAction: publicNextAction(decision), decision, birthDate: "1997-08-08", }); const last = transitions.at(-1); const inference = last?.p_inference_state as { candidates?: unknown[]; candidate_set_id?: string; refresh_count?: number; probes?: ReadonlyArray<{ id: string; semantic_key: string }>; } | undefined; const lastRow = { reason: last?.p_reason, candidates: inference?.candidates?.length ?? 0, candidate_set_id: last?.p_candidate_set_id ?? inference?.candidate_set_id, refresh_count: inference?.refresh_count ?? 0, }; console.log("T0 last inference row", lastRow); assert.equal(lastRow.reason, "supersede"); assert.equal(lastRow.candidates, TIMES.length); assert.equal(lastRow.candidate_set_id, liveState(6).candidate_set_id); assert.equal(lastRow.refresh_count, 1); const merged = inference?.probes?.find((item) => item.semantic_key === FAMILY_REFRESH.semantic_key); assert.equal( merged?.id, alignedProbeId(FAMILY_REFRESH, liveState(6).answered_probes), merged?.id, ); const refreshedDossier = { ...dossier, latestResult: { ...dossier.latestResult!, decisionReceipt: { ...(dossier.latestResult?.decisionReceipt ?? {}), inference_state: inference, discriminating_event_probes: [eventProbeRow(FAMILY_REFRESH)], }, }, }; const getDecision = decideFromDossier(refreshedDossier, { birthDate: "1997-08-08" }); const getPlan = followupPlan(refreshedDossier, getDecision.sessionOutcome); const getKey = getPlan.next_followup?.semantic_key ?? null; const persist = await persistServerOwnedFocus({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID, activeFocus: null, decisionReceipt: refreshedDossier.latestResult?.decisionReceipt ?? null, followup: getPlan.next_followup, }); console.log("T0 GET probe vs persist", { getKey, persistStatus: persist.status }); assert.equal(getKey, FAMILY_REFRESH.semantic_key, String(getKey)); assert.ok( persist.status === "created" || persist.status === "already_open", persist.status, ); assert.equal(next.choiceReady, true, next.hostNarration); resetRefreshDiscriminatorProbesForTests(); }); test("T3: refresh without new engine probes persists an already_answered attempt", async () => { resetDeliveryTurnGuardForTests(); resetRefreshDiscriminatorProbesForTests(); let engineCalls = 0; setRefreshDiscriminatorProbesForTests(async ({ state }) => { engineCalls += 1; return { state: { ...state, refresh_count: (state.refresh_count ?? 0) + 1 }, eventProbes: [], candidateSetId: state.candidate_set_id, refreshCount: (state.refresh_count ?? 0) + 1, }; }); const dossier = accidentDossier(6); const transitions: Record[] = []; const accounting = idleHandlers(dossier, { transitions }); const idle = await persistNextInterviewIfIdle({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID, }); const last = transitions.at(-1); const inference = last?.p_inference_state as { refresh_attempts?: ReadonlyArray<{ candidate_set_id?: string; answer_count?: number; result?: string; }>; probes?: unknown[]; candidate_set_id?: string; } | undefined; assert.equal(last?.p_reason, "already_answered", JSON.stringify(last ?? {})); assert.equal(last?.p_raw_answer, "refresh_attempt"); assert.equal(inference?.refresh_attempts?.at(-1)?.result, "no_new_probes"); assert.equal(inference?.refresh_attempts?.at(-1)?.answer_count, liveState(6).answered_probes.length); assert.equal(inference?.candidate_set_id, liveState(6).candidate_set_id); assert.equal((inference as { refresh_count?: number } | undefined)?.refresh_count ?? 0, 0); assert.equal( (inference?.probes ?? []).some((item) => ( Boolean(item) && typeof item === "object" && (item as { semantic_key?: string }).semantic_key === FAMILY_REFRESH.semantic_key )), false, ); assert.ok((idle.hostNarration ?? "").trim()); const overlayed = { ...dossier, latestResult: { ...dossier.latestResult!, decisionReceipt: { ...(dossier.latestResult?.decisionReceipt ?? {}), inference_state: inference, refresh_attempts: inference?.refresh_attempts, }, }, }; const getDecision = decideFromDossier(overlayed, { birthDate: "1997-08-08" }); assert.equal(getDecision.nextAction, "ask_fact_collection", getDecision.nextAction); assert.match( followupPlan(overlayed, getDecision.sessionOutcome).next_followup?.collection_key ?? "", /collect:targeted:/, ); const second = await persistNextInterviewIfIdle({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID, }); assert.equal(engineCalls, 1, `second refresh called the engine ${engineCalls} times`); assert.ok((second.hostNarration ?? idle.hostNarration ?? "").trim()); resetRefreshDiscriminatorProbesForTests(); }); test("T3: changed candidate set is not persisted even when engine returns a probe", async () => { resetDeliveryTurnGuardForTests(); resetRefreshDiscriminatorProbesForTests(); setRefreshDiscriminatorProbesForTests(async ({ state }) => ({ state: { ...state, candidate_set_id: "changed-set" }, eventProbes: [eventProbeRow(FAMILY_REFRESH)], candidateSetId: "changed-set", refreshCount: 1, })); const dossier = accidentDossier(6); const transitions: Record[] = []; await persistNextInterviewAfterChoice({ accounting: idleHandlers(dossier, { transitions }).client, userId: USER_ID, caseId: CASE_ID, dossier, decisionState: liveState(6), nextAction: publicNextAction(decideFromDossier(dossier, { birthDate: "1997-08-08" })), birthDate: "1997-08-08", }); const last = transitions.at(-1); assert.equal(last?.p_reason, "already_answered"); assert.notEqual(last?.p_reason, "supersede"); assert.equal(last?.p_candidate_set_id, liveState(6).candidate_set_id); const inference = last?.p_inference_state as { candidate_set_id?: string; candidates?: unknown[] } | undefined; assert.equal(inference?.candidate_set_id, liveState(6).candidate_set_id); assert.equal(inference?.candidates?.length, TIMES.length); resetRefreshDiscriminatorProbesForTests(); }); test("T3: empty candidate list is not persisted even when engine returns a probe", async () => { resetDeliveryTurnGuardForTests(); resetRefreshDiscriminatorProbesForTests(); setRefreshDiscriminatorProbesForTests(async ({ state }) => ({ state: { ...state, candidates: [] }, eventProbes: [eventProbeRow(FAMILY_REFRESH)], candidateSetId: state.candidate_set_id, refreshCount: 1, })); const dossier = accidentDossier(6); const transitions: Record[] = []; const refreshed = await refreshDatedDiscriminatorPoolIfNeeded({ accounting: idleHandlers(dossier, { transitions }).client, userId: USER_ID, caseId: CASE_ID, dossier, state: liveState(6), hasDatedProbe: false, }); assert.equal(refreshed.refreshed, false); assert.equal(refreshed.attemptRecorded, true); const last = transitions.at(-1); assert.equal(last?.p_reason, "already_answered"); assert.notEqual(last?.p_reason, "supersede"); const inference = last?.p_inference_state as { candidates?: unknown[] } | undefined; assert.equal(inference?.candidates?.length, TIMES.length); resetRefreshDiscriminatorProbesForTests(); }); test("T3: merged probe ids follow the answered naming rule", () => { const hashedIncoming = { ...FAMILY_REFRESH, id: `probe:${FAMILY_REFRESH.semantic_key}:${FAMILY_REFRESH.candidate_split_hash}`, }; assert.equal( alignedProbeId(hashedIncoming, liveState(6).answered_probes), `probe:${FAMILY_REFRESH.semantic_key}`, ); const hashedAnswers = liveState(6).answered_probes.map((item) => ({ ...item, probe_id: `probe:${item.semantic_key}:${item.candidate_split_hash}`, })); assert.equal( alignedProbeId(FAMILY_REFRESH, hashedAnswers), `probe:${FAMILY_REFRESH.semantic_key}:${FAMILY_REFRESH.candidate_split_hash}`, ); });