import assert from "node:assert/strict"; import test from "node:test"; import { applyRectificationChoice, persistNextInterviewAfterChoice } from "../src/lib/rectification-agentic/v9/answer-choice.ts"; import { CHOICE_ACTION } from "../src/lib/rectification-agentic/v9/choice-action.ts"; import { buildChoiceFrame } from "../src/lib/rectification-agentic/v9/choice-card.ts"; import { expectedAnswerSchemaFor, persistServerOwnedFocus, stableFollowupQuestionId, } from "../src/lib/rectification-agentic/v9/server-focus.ts"; import { attachQuestionsToTurns } from "../src/lib/rectification-agentic/v9/turn-question.ts"; import { persistedQuestionSurface } from "../src/lib/rectification-surface-state.ts"; import { inspectRepresentativeTime, warnRepresentativeTimeInconsistency, } from "../src/lib/rectification-agentic/core/representative-time-guard.ts"; import { publicNextAction } from "../src/lib/rectification-agentic/core/rectification-decision.ts"; import { decideFromDossier } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts"; import { INFERENCE_ALGORITHM_VERSION } from "../src/lib/rectification-agentic/core/types.ts"; import type { MethodFollowup } from "../src/lib/rectification-agentic/v9/method-followup.ts"; import { CASE_ID, FOCUS_ID, SESSION_ID, TURN_ID, USER_ID, activeFocusFixture, candidateSnapshotFixture, computeFixture, conversationSummaryFixture, dossierFixture, fakeAccounting, receiptHandlers, } from "./rectification-v9-test-support.ts"; const ACTION_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; const STEM = "2023 年 5 月前后,有没有开始一段认真关系、分手或结婚?"; const 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 }, ]; function emptyInference() { return { algorithm_version: INFERENCE_ALGORITHM_VERSION, candidate_set_id: "04:48-05:07", revision: 6, phase: "discrimination", result_status: "discriminating", range_start: "04:48", range_end: "05:07", candidates: [ { id: "04:53", time: "04:53", cluster_range: ["04:48", "05:07"] as const, prior_score: 16, posterior_score: 16, probability: 0.4, status: "active" as const, rank: 1, strong_conflict_count: 0, }, { id: "05:06", time: "05:06", cluster_range: ["04:48", "05:07"] as const, prior_score: 15, posterior_score: 15, probability: 0.35, status: "active" as const, rank: 2, strong_conflict_count: 0, }, ], events: [], probes: [], answered_probes: [], rounds: [], entropy: 1, representative_time: "04:53", credible_range: ["04:48", "05:07"] as const, }; } const WINDOW_SCAN = { scanned: true, d9_lagna_count: 2, d10_lagna_count: 2, d4_lagna_count: 2, d9_candidates_differ: true, d10_candidates_differ: true, d4_candidates_differ: true, }; function distinguishFollowup(): MethodFollowup { const frame = buildChoiceFrame({ method_id: "d9_relationship", ask_theme: "dated_event", domain: "relationship", user_prompt_hint: "ask", }, { probes: [{ year: 2023, year_label: "2023 年 5 月前后", domain: "relationship", event_family: "开始一段认真关系、分手或结婚", source: "dasha_boundary", tracks: ["vimshottari", "narayana"], tracks_agree: true, unique_minute_claim: false, user_meaning: STEM, role: "distinguish", information_gain: 0.4, semantic_key: "relationship.2023.05.dasha_boundary", style_options: OPTIONS, }], }); assert.ok(frame); return { method_id: "d9_relationship", intent: "distinguish_candidates", ask_theme: "dated_event", domain: "relationship", kind_hint: "relationship_change", user_prompt_hint: "ask", must_not_label: false, choice_frame: frame, source: "precision_stage", information_gain: 0.4, semantic_key: "relationship.2023.05.dasha_boundary", probe_year: 2023, year_label: "2023 年 5 月前后", candidate_ids: ["04:48", "05:07"], expected_outcomes: [ { answer_class: "yes", supports: ["04:48"], conflicts: ["05:07"] }, { answer_class: "no", supports: ["05:07"], conflicts: ["04:48"] }, ], }; } const DATED_EVIDENCE = [ { id: "e-edu", status: "confirmed" as const, domain: "education", datePrecision: "month" as const, occurredFrom: "2016-09-01", occurredTo: "2016-09-30", eventKind: "education_start", summary: "2016年9月上大学", }, { id: "e-career", status: "confirmed" as const, domain: "career", datePrecision: "month" as const, occurredFrom: "2020-04-01", occurredTo: null, eventKind: "career_entry", summary: "2020年4月入职", }, { id: "e-rel", status: "confirmed" as const, domain: "relationship", datePrecision: "year" as const, occurredFrom: "2018-01-01", occurredTo: "2018-12-31", eventKind: "relationship_start", summary: "2018年恋爱", }, { id: "e-move", status: "confirmed" as const, domain: "relocation", datePrecision: "year" as const, occurredFrom: "2015-01-01", occurredTo: "2015-12-31", eventKind: "relocation", summary: "2015年搬家", }, ]; const PERSIST_EVIDENCE = DATED_EVIDENCE.filter((row) => row.domain !== "relationship"); function focusAccounting() { return fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_compute: () => computeFixture(), get_agentic_rectification_case_dossier: () => snakeDossier(), 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 ?? null, target_domain: args.p_target_domain ?? null, target_kind: args.p_target_kind ?? null, expected_answer_schema: args.p_expected_answer_schema, status: "active", asked_at: "2026-09-14T00:00:00.000Z", resolved_at: null, asked_turn_id: args.p_asked_turn_id ?? null, }, idempotent: false, }), resolve_agentic_rectification_conversation_focus: () => ({ focus: { id: FOCUS_ID, status: "resolved" }, idempotent: false, }), 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: "p-cd", revision: Number(args.p_expected_revision ?? 6) + 1, source_quote: args.p_source_quote, derived_context: args.p_derived_context, narration: args.p_narration, focus_status: args.p_focus_status, }), }); } function snakeEvidence(rows: typeof DATED_EVIDENCE = DATED_EVIDENCE) { return rows.map((row) => ({ id: row.id, status: row.status, domain: row.domain, date_precision: row.datePrecision, occurred_from: row.occurredFrom, occurred_to: row.occurredTo, event_kind: row.eventKind, summary: row.summary, })); } function snakeDossier(overrides: Parameters[0] = {}) { return dossierFixture({ evidence: snakeEvidence(PERSIST_EVIDENCE), evidenceCount: PERSIST_EVIDENCE.length, stage: "minute", candidateRange: { start_time: "04:48", end_time: "05:07" }, latestResult: candidateSnapshotFixture({ selectionAllowed: true, representativeTime: "04:53", candidates: [ { candidate_id: "04:53", rank: 1, time: "04:53", relative_support: 16 }, { candidate_id: "05:06", rank: 2, time: "05:06", relative_support: 15 }, ], decisionReceipt: { inference_state: emptyInference(), prospective_probes: [], precision_stage: { current: "d9_refine" }, discriminating_event_probes: [], window_scan: WINDOW_SCAN, }, }), conversationSummary: conversationSummaryFixture({ activeFocus: activeFocusFixture({ expectedAnswerSchema: { prompt: "2016 年前后,有没有明显高考或重要考试发挥失常?", probe_id: "p-cd", semantic_key: "career.2015", scoring: true, choice: { prompt: "2016 年前后,有没有明显高考或重要考试发挥失常?", option_a: "是,大概就在那段时间", option_b: "有类似,但年份不对或不够重大", option_c: "没有明显发生", option_d: "不记得 / 不确定", options: [ { key: "A", label: "是,大概就在那段时间", answer_class: "yes" }, { key: "B", label: "有类似,但年份不对或不够重大", answer_class: "weak_yes" }, { key: "C", label: "没有明显发生", answer_class: "no" }, { key: "D", label: "不记得 / 不确定", answer_class: "unsure" }, ], }, }, }), }), turns: [ { id: TURN_ID, role: "assistant", text: "已记录,范围收到 04:48–05:07。", status: "completed", }, ], ...overrides, }); } function camelDossier() { return { evidence: PERSIST_EVIDENCE, conversationSummary: { activeFocus: null, declinedSkippedTopics: [], }, latestResult: { resultId: "55555555-5555-4555-8555-555555555555", decisionReceipt: { inference_state: emptyInference(), prospective_probes: [], precision_stage: { current: "d9_refine" }, discriminating_event_probes: [], window_scan: WINDOW_SCAN, }, selectionAllowed: true, candidates: [ { time: "04:53", rank: 1, relativeSupport: 16 }, { time: "05:06", rank: 2, relativeSupport: 15 }, ], }, case: { acceptedTime: null, status: "collecting_evidence", candidateRange: { start_time: "04:48", end_time: "05:07" }, stage: "minute" as const, }, turns: [ { role: "assistant" as const, text: "已记录,范围收到 04:48–05:07。" }, ], }; } test("empty probe pool cannot stamp a scoring distinguish schema", () => { const followup = distinguishFollowup(); const schema = expectedAnswerSchemaFor( followup.choice_frame!, stableFollowupQuestionId(followup), { inference_state: emptyInference() }, followup, ); assert.equal(schema, null); }); test("unstampable distinguish persist is invalid_choice_schema", async () => { const accounting = focusAccounting(); const result = await persistServerOwnedFocus({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID, activeFocus: null, decisionReceipt: { inference_state: emptyInference() }, followup: distinguishFollowup(), }); assert.equal(result.status, "invalid_choice_schema"); }); test("persistNextInterviewAfterChoice does not speak an unstampable distinguish stem", async () => { const followup = distinguishFollowup(); const stem = followup.choice_frame?.prompt ?? STEM; const accounting = focusAccounting(); const dossier = camelDossier(); const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); const first = await persistNextInterviewAfterChoice({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID, dossier, decisionState: emptyInference() as never, nextAction: publicNextAction(decision), decision, birthDate: "1997-08-08", skipRefresh: true, followup, }); assert.equal(first.hostNarration.includes(stem), false, first.hostNarration); assert.notEqual(first.followup?.intent, "distinguish_candidates"); assert.ok( first.choiceReady === true || first.followup?.intent === "collect_method_evidence", JSON.stringify({ choiceReady: first.choiceReady, intent: first.followup?.intent, host: first.hostNarration }), ); const second = await persistNextInterviewAfterChoice({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID, dossier: { ...dossier, turns: [{ role: "assistant" as const, text: stem }], }, decisionState: emptyInference() as never, nextAction: publicNextAction(decision), decision, birthDate: "1997-08-08", skipRefresh: true, followup, }); assert.equal(second.hostNarration.includes(stem), false, second.hostNarration); }); test("collect stem already in the last assistant turn is not spoken again", async () => { const stem = "请再补充一件带年月的搬家或长期住到外地的经历。"; const followup: MethodFollowup = { method_id: "dasha_events", intent: "collect_method_evidence", ask_theme: "dated_event", domain: "relocation", kind_hint: "home_change", user_prompt_hint: "ask", must_not_label: false, choice_frame: null, spoken_prompt: stem, source: "method_coverage", }; const accounting = focusAccounting(); const dossier = { ...camelDossier(), turns: [{ role: "assistant" as const, text: `记下了。\n\n${stem}` }], }; const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); const result = await persistNextInterviewAfterChoice({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID, dossier, decisionState: emptyInference() as never, nextAction: publicNextAction(decision), decision, birthDate: "1997-08-08", skipRefresh: true, followup, }); assert.equal(result.hostNarration.includes(stem), false, result.hostNarration); }); test("applyRectificationChoice narration does not re-ask an unstampable distinguish stem", async () => { const followup = distinguishFollowup(); const stem = followup.choice_frame?.prompt ?? STEM; const accounting = focusAccounting(); const applied = await applyRectificationChoice(accounting.client, { userId: USER_ID, caseId: CASE_ID, sessionId: SESSION_ID, actionId: ACTION_ID, action: CHOICE_ACTION, focusId: FOCUS_ID, questionId: "question-1", probeId: "p-cd", optionId: "C", expectedRevision: 6, }); assert.equal(applied.applied, true); assert.equal(applied.narration.includes(stem), false, applied.narration); const nextMeaning = JSON.stringify(applied.nextUserAction ?? {}); assert.equal(nextMeaning.includes(stem), false, nextMeaning); }); test("active choice focus without asked_turn_id hangs on the last assistant turn", () => { const prompt = "结过婚或订过婚吗?"; const attached = attachQuestionsToTurns( [ { id: TURN_ID, role: "assistant" as const, text: "已记录,范围收到 04:48–05:07。" }, ], [{ id: FOCUS_ID, caseId: CASE_ID, questionId: "collect:targeted:relationship", intent: "collect_method_evidence", targetEvidenceId: null, targetDomain: "relationship", targetKind: "targeted:relationship", expectedAnswerSchema: { targeted_collect: true, prompt, choice: { prompt, option_a: "有过这件事", option_b: "没有发生过", option_c: "记不太清楚", option_d: "这条先跳过", options: [ { key: "A", label: "有过这件事", answer_class: "yes" }, { key: "B", label: "没有发生过", answer_class: "no" }, { key: "C", label: "记不太清楚", answer_class: "unsure" }, { key: "D", label: "这条先跳过", answer_class: "weak_yes" }, ], }, }, status: "active", askedAt: "2026-09-14T00:00:00.000Z", resolvedAt: null, askedTurnId: null, answerOption: null, }], ); assert.equal(attached[0]?.question?.kind, "choice"); assert.equal(attached[0]?.question?.focus_id, FOCUS_ID); assert.equal(attached[0]?.question?.options?.length, 4); assert.equal(attached[0]?.question?.prompt, prompt); }); test("persisted question with a live choice card is not the spoken-only surface", () => { assert.equal( persistedQuestionSurface({ questionKind: "choice", hasChoiceCard: true, questionPersisted: true, }), "choice_card", ); assert.equal( persistedQuestionSurface({ questionKind: "collect_spoken", hasChoiceCard: false, questionPersisted: true, }), "spoken", ); assert.equal( persistedQuestionSurface({ questionKind: "choice", hasChoiceCard: true, questionPersisted: false, }), "none", ); }); test("representative time in eliminated ids or outside the range warns once each", () => { const lines: string[] = []; const original = console.warn; console.warn = (value: unknown) => { lines.push(String(value)); }; try { const eliminated = warnRepresentativeTimeInconsistency({ representativeTime: "05:14", eliminatedIds: ["05:14", "05:08"], credibleRange: ["04:48", "05:07"], winnerId: "04:53", scoresAfter: { "04:53": 16, "05:14": 7 }, }); assert.equal(eliminated.ok, false); assert.equal(eliminated.inEliminated, true); const outside = warnRepresentativeTimeInconsistency({ representativeTime: "05:14", eliminatedIds: [], credibleRange: ["04:48", "05:07"], winnerId: "04:53", scoresAfter: { "04:53": 16, "05:14": 7 }, }); assert.equal(outside.ok, false); assert.equal(outside.outsideRange, true); const healthy = warnRepresentativeTimeInconsistency({ representativeTime: "04:53", eliminatedIds: ["05:14"], credibleRange: ["04:48", "05:07"], winnerId: "04:53", scoresAfter: { "04:53": 16, "05:14": 7 }, }); assert.equal(healthy.ok, true); assert.equal(inspectRepresentativeTime({ representativeTime: "04:53", eliminatedIds: ["05:14"], credibleRange: ["04:48", "05:07"], }).ok, true); } finally { console.warn = original; } assert.equal(lines.filter((line) => line.includes("rectification_representative_time_inconsistent")).length, 2); assert.match(lines[0] ?? "", /"representative_time":"05:14"/); assert.doesNotMatch(lines.join("\n"), /birth|email|jwt/i); });