import assert from "node:assert/strict"; import test from "node:test"; import { deriveRectificationOpenWindow } from "../src/lib/rectification-agentic/v9/case-service.ts"; import { decideAfterInferenceChange, decideFromDossier } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts"; import { evidenceLedgerFingerprint, parseV9CaseDossier } from "../src/lib/rectification-agentic/v9/tool-service.ts"; import { applyRectificationChoice } from "../src/lib/rectification-agentic/v9/answer-choice.ts"; import { CHOICE_ACTION } from "../src/lib/rectification-agentic/v9/choice-action.ts"; import { projectRectificationStepState } from "../src/lib/rectification-agentic/v9/step-state.ts"; import { BLOCK_CHOICE_INTENT, BLOCK_PERIOD_LABELS, buildBlockChoiceFrame, parseBlockScanPayload, serializeBlockScanPayload, windowFromBlockChoice, } from "../src/lib/rectification-agentic/v9/block-scan.ts"; import { splitWindowIntoThirds } from "../src/lib/rectification-agentic/v9/search-window.ts"; import { CASE_ID, FOCUS_ID, SESSION_ID, TURN_ID, USER_ID, activeFocusFixture, computeFixture, dossierFixture, fakeAccounting, receiptHandlers, } from "./rectification-v9-test-support.ts"; const BLOCKS = [ { period: "early_morning", start_time: "04:00", end_time: "07:59", relative_support: 12 }, { period: "morning", start_time: "08:00", end_time: "11:59", relative_support: 41 }, { period: "afternoon", start_time: "12:00", end_time: "17:59", relative_support: 28 }, { period: "evening", start_time: "18:00", end_time: "22:59", relative_support: 11 }, { period: "late_night", start_time: "23:00", end_time: "03:59", relative_support: 8 }, ] as const; const EVIDENCE = [ { id: "e-edu", source_turn_id: TURN_ID, subject: "self", event_kind: "education_start", domain: "education", occurred_from: "2016-09-01", occurred_to: "2016-09-30", date_precision: "month", summary: "education start", status: "confirmed", supersedes_evidence_id: null, created_at: "2026-09-06T00:00:00.000Z", }, { id: "e-rel", source_turn_id: TURN_ID, subject: "self", event_kind: "relationship_end", domain: "relationship", occurred_from: "2024-08-08", occurred_to: "2024-08-08", date_precision: "day", summary: "relationship end", status: "confirmed", supersedes_evidence_id: null, created_at: "2026-09-06T00:00:01.000Z", }, { id: "e-move", source_turn_id: TURN_ID, subject: "self", event_kind: "relocation", domain: "relocation", occurred_from: "2023-07-01", occurred_to: "2023-07-31", date_precision: "month", summary: "relocation", status: "confirmed", supersedes_evidence_id: null, created_at: "2026-09-06T00:00:02.000Z", }, ]; function blockPayload(extra: Record = {}) { return { evidence_ledger_fingerprint: "f".repeat(64), algorithm_version: "test", minute_step: 10, blocks: [...BLOCKS], ...extra, }; } function blockSchema() { const payload = parseBlockScanPayload(blockPayload()); assert.ok(payload); const frame = buildBlockChoiceFrame(payload); assert.ok(frame); return { choice: { prompt: frame.prompt, option_a: frame.option_a_hint, option_b: frame.option_b_hint, option_c: frame.neither_label, option_d: frame.unsure_label, options: [ { key: "A", label: frame.option_a_hint, answer_class: frame.option_a_answer_class, role: "primary" }, { key: "B", label: frame.option_b_hint, answer_class: frame.option_b_answer_class, role: "primary" }, { key: "C", label: frame.neither_label, answer_class: frame.option_c_answer_class, role: "primary" }, { key: "D", label: frame.unsure_label, answer_class: frame.option_d_answer_class, role: "primary" }, ], }, choice_kind: "block_choice", scoring: false, block_periods: { A: BLOCKS[1], B: BLOCKS[2], C: BLOCKS[0], }, }; } function blockDossier(extra: { declined?: boolean; stage?: string; range?: { start_time: string; end_time: string }; blockScan?: unknown; } = {}) { const schema = blockSchema(); return dossierFixture({ candidateRange: extra.range ?? { start_time: "00:00", end_time: "23:59" }, stage: extra.stage ?? "block_scan", blockScan: extra.blockScan ?? (extra.declined ? blockPayload({ declined_at_fingerprint: "f".repeat(64), evidence_ledger_fingerprint: "f".repeat(64) }) : blockPayload()), evidence: EVIDENCE, conversationSummary: { confirmed_evidence_summary: [], pending_revisions: [], active_focus: extra.stage === "minute" ? null : activeFocusFixture({ intent: BLOCK_CHOICE_INTENT, targetDomain: null, targetKind: null, questionId: "block_scan:choose_birth_block:holdout", expectedAnswerSchema: schema, }), declined_skipped_topics: [], candidate_divergence_summary: null, missing_evidence_categories: [], last_result_policy: null, summary_version: 1, updated_at: "2026-09-06T00:00:00.000Z", }, }); } test("unknown source opens a full-day block_scan window", () => { assert.deepEqual( deriveRectificationOpenWindow({ reportedTime: null, source: "unknown", period: null, windowStart: null, windowEnd: null, }), { candidateRange: { start_time: "00:00", end_time: "23:59" }, stage: "block_scan", }, ); assert.deepEqual( deriveRectificationOpenWindow({ reportedTime: "05:00", source: "unknown", period: null, windowStart: null, windowEnd: null, }).stage, "minute", ); assert.equal( deriveRectificationOpenWindow({ reportedTime: "05:00", source: "family_exact", period: null, windowStart: null, windowEnd: null, }).stage, "minute", ); }); test("three dated events in two domains ask a block choice and never adopt", () => { const parsed = parseV9CaseDossier(blockDossier()); assert.ok(parsed); const decision = decideFromDossier(parsed); assert.equal(decision.nextAction, "ask_block_choice"); assert.equal(decision.canAdopt, false); assert.equal(decision.selectionAllowed, false); assert.equal(decision.representativeTime, null); assert.equal(decision.separation.ranked.length, 0); const step = projectRectificationStepState({ nextAction: decision.nextAction }); assert.equal(step.index, 1); assert.equal(step.stage, "collect"); assert.match(step.headline, /比较时段/); }); test("block choice copy names periods, not a winning minute", () => { const payload = parseBlockScanPayload(blockPayload()); assert.ok(payload); const frame = buildBlockChoiceFrame(payload); assert.ok(frame); assert.match(frame.prompt, /哪一段更像出生时段/); assert.doesNotMatch(frame.prompt, /更像\s*\d/); assert.match(frame.option_a_hint ?? "", new RegExp(BLOCK_PERIOD_LABELS.morning)); assert.equal(frame.scoring, false); assert.equal(frame.choice_kind, "block_choice"); const schema = blockSchema(); assert.deepEqual(windowFromBlockChoice({ schema, optionId: "B" }), { start_time: "12:00", end_time: "17:59", }); assert.equal(windowFromBlockChoice({ schema, optionId: "D" }), null); }); test("declined block_scan fingerprint returns collect instead of another card", () => { const parsed = parseV9CaseDossier(blockDossier()); assert.ok(parsed); const fingerprint = evidenceLedgerFingerprint(parsed.evidence); const declined = parseV9CaseDossier(blockDossier({ blockScan: { ...blockPayload(), declined_at_fingerprint: fingerprint, }, })); assert.ok(declined); assert.equal(declined.case.blockScan?.declinedAtFingerprint, fingerprint); const decision = decideAfterInferenceChange({ dossier: declined, state: null, userStopped: false, }); assert.equal(decision.nextAction, "ask_fact_collection"); assert.equal(decision.canAdopt, false); }); test("choosing afternoon from a full-day scan stays in block_scan for a window wider than 120 minutes", async () => { let current = blockDossier(); const accounting = fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => current, get_agentic_rectification_case_compute: () => computeFixture({ baselineBirthSnapshot: { birth_date: "1998-03-15", latitude: 39.9042, longitude: 116.4074, timezone_id: "Asia/Shanghai", timezone_offset: 8, birth_time_source: "unknown", }, }), 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, revision: 1, narration: args.p_narration, focus_status: args.p_focus_status, }), 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, expected_answer_schema: args.p_expected_answer_schema, status: "active", asked_at: "2026-09-06T00:00:00.000Z", resolved_at: null, }, idempotent: false, }), write_agentic_rectification_block_scan: (_fn, args) => { current = blockDossier({ stage: "block_scan", range: current.case.candidate_range as { start_time: string; end_time: string }, blockScan: args.p_block_scan, }); return { case_id: CASE_ID, stage: "block_scan", block_scan: args.p_block_scan }; }, advance_agentic_rectification_case_from_block_scan: (_fn, args) => { current = blockDossier({ stage: "block_scan", range: { start_time: String(args.p_start_time), end_time: String(args.p_end_time) }, blockScan: { rounds: 0, blocks: [] }, }); return { case_id: CASE_ID, stage: "block_scan", candidate_range: { start_time: args.p_start_time, end_time: args.p_end_time }, }; }, persist_agentic_rectification_candidate_v2: () => { throw new Error("must not persist the 24h minute snapshot"); }, }); const receipt = await applyRectificationChoice(accounting.client, { userId: USER_ID, caseId: CASE_ID, sessionId: SESSION_ID, actionId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1", action: CHOICE_ACTION, focusId: FOCUS_ID, optionId: "B", expectedRevision: 0, }); assert.equal(receipt.snapshotCurrent, false); assert.equal(receipt.nextAction.can_adopt, false); const parsed = parseV9CaseDossier(current); assert.equal(parsed?.case.stage, "block_scan"); assert.deepEqual(parsed?.case.candidateRange, { start_time: "12:00", end_time: "17:59" }); assert.ok(accounting.calls.some((item) => item.fn === "advance_agentic_rectification_case_from_block_scan")); }); test("choosing D stays in block_scan and collects again", async () => { let current = blockDossier(); const accounting = fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => current, 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, revision: 1, narration: args.p_narration, focus_status: args.p_focus_status, }), 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, expected_answer_schema: args.p_expected_answer_schema, status: "active", asked_at: "2026-09-06T00:00:00.000Z", resolved_at: null, }, idempotent: false, }), write_agentic_rectification_block_scan: (_fn, args) => { current = blockDossier({ blockScan: args.p_block_scan }); return { case_id: CASE_ID, stage: "block_scan", block_scan: args.p_block_scan }; }, advance_agentic_rectification_case_from_block_scan: () => { throw new Error("D must not advance the window"); }, }); const receipt = await applyRectificationChoice(accounting.client, { userId: USER_ID, caseId: CASE_ID, sessionId: SESSION_ID, actionId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2", action: CHOICE_ACTION, focusId: FOCUS_ID, optionId: "D", expectedRevision: 0, }); const parsedAfter = parseV9CaseDossier(current); assert.equal(parsedAfter?.case.stage, "block_scan"); assert.equal( parsedAfter?.case.blockScan?.declinedAtFingerprint, evidenceLedgerFingerprint(parsedAfter!.evidence), ); assert.equal(receipt.nextAction.type, "ask_fact_collection"); assert.equal(receipt.nextAction.can_adopt, false); assert.ok(accounting.calls.some((item) => item.fn === "write_agentic_rectification_block_scan")); const payload = serializeBlockScanPayload(parseBlockScanPayload(blockPayload({ declined_at_fingerprint: "f".repeat(64), }))!); assert.equal(payload.declined_at_fingerprint, "f".repeat(64)); }); test("existing minute-stage cases keep the minute interview", () => { const parsed = parseV9CaseDossier(dossierFixture({ evidence: EVIDENCE, stage: "minute", candidateRange: { start_time: "04:50", end_time: "05:10" }, })); assert.ok(parsed); const decision = decideFromDossier(parsed); assert.notEqual(decision.nextAction, "ask_block_choice"); assert.notEqual(decision.sessionOutcome, "compare_blocks"); }); test("period_only evening and approximate ±120 open as block_scan, custom 30 minutes stay minute", () => { assert.deepEqual( deriveRectificationOpenWindow({ reportedTime: null, source: "period_only", period: "evening", windowStart: null, windowEnd: null, }), { candidateRange: { start_time: "18:00", end_time: "22:59" }, stage: "block_scan", }, ); assert.deepEqual( deriveRectificationOpenWindow({ reportedTime: "05:00", source: "approximate", period: null, windowStart: null, windowEnd: null, uncertaintyBefore: 120, uncertaintyAfter: 120, }), { candidateRange: { start_time: "03:00", end_time: "07:00" }, stage: "block_scan", }, ); assert.equal( deriveRectificationOpenWindow({ reportedTime: null, source: "period_only", period: null, windowStart: "14:30", windowEnd: "15:00", }).stage, "minute", ); }); test("advance stubs with empty blocks still parse rounds", () => { const parsed = parseBlockScanPayload({ rounds: 0, blocks: [] }); assert.equal(parsed?.rounds, 0); assert.deepEqual(parsed?.blocks, []); assert.equal(parseBlockScanPayload({ blocks: [] }), null); }); test("evening thirds become a sub-block card with clock labels", () => { const thirds = splitWindowIntoThirds({ start_time: "18:00", end_time: "22:59" }); const payload = parseBlockScanPayload({ rounds: 1, blocks: thirds.map((block, index) => ({ period: block.period, start_time: block.start_time, end_time: block.end_time, relative_support: [40, 35, 25][index], })), }); assert.ok(payload); const frame = buildBlockChoiceFrame(payload); assert.ok(frame); assert.match(frame.prompt, /更像出生时间/); assert.match(frame.option_a_hint ?? "", /18:00/); assert.doesNotMatch(frame.option_a_hint ?? "", /傍晚到晚上/); });