import assert from "node:assert/strict"; import test from "node:test"; import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { candidateSetId } from "../src/lib/rectification-agentic/core/build-state.ts"; import { INFERENCE_ALGORITHM_VERSION, type InferenceCandidate, type InferenceState } from "../src/lib/rectification-agentic/core/types.ts"; import { RANGE_DELIVERY_TIE_PERCENT, } from "../src/lib/rectification-agentic/user-copy.ts"; import { COLLECT_KIND_ORDER, GUIDED_NARROW_HINT, rangeNarrowHint, } from "../src/lib/rectification-agentic/v9/collection-question-pool.ts"; import { decideFromDossier, scoreableSnapshotCurrentFromDossier, type DecisionDossier, } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts"; import { previousInferenceFromReceipt } from "../src/lib/rectification-agentic/v9/inference-adapter.ts"; import { buildRangeDelivery } from "../src/lib/rectification-agentic/v9/divergence-panel.ts"; import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts"; import { RectificationRangeDelivery } from "../src/components/rectification-range-delivery.tsx"; import type { RectificationCandidateResult } from "../src/lib/rectification-candidate-result.ts"; import { rectificationQuestionGapState } from "../src/lib/rectification-surface-state.ts"; const ID_A = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1"; const ID_B = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb2"; const ID_C = "cccccccc-cccc-4ccc-8ccc-ccccccccccc3"; const CLOSED_LAYERS = ["d9", "d10", "d4", "d5", "d7", "d2", "d30"] as const; const DATED_EVIDENCE = [ { id: "evidence-education", status: "confirmed", domain: "education", datePrecision: "month", occurredFrom: "2016-09-01", occurredTo: null, eventKind: "education_start", summary: "上大学", }, { id: "evidence-career", status: "confirmed", domain: "career", datePrecision: "month", occurredFrom: "2020-04-01", occurredTo: null, eventKind: "career_entry", summary: "入职", }, { id: "evidence-relocation", status: "confirmed", domain: "relocation", datePrecision: "month", occurredFrom: "2018-07-01", occurredTo: null, eventKind: "relocation", summary: "搬家", }, ] as const; function declineAllTargeted() { return COLLECT_KIND_ORDER.map((domain) => ({ questionId: `collect:targeted:${domain}`, status: "declined", target_kind: `targeted:${domain}`, intent: "collect_method_evidence", target_domain: domain, })); } function cand( time: string, probability: number, range: readonly [string, string] = ["04:48", "05:07"], ): InferenceCandidate { return { id: time, time, cluster_range: range, prior_score: probability * 100, posterior_score: probability * 100, probability, status: "active", rank: 1, strong_conflict_count: 0, }; } function inference(overrides: Partial = {}): InferenceState { return { algorithm_version: INFERENCE_ALGORITHM_VERSION, candidate_set_id: "04:48-05:07:04:50,04:52,04:56", revision: 1, phase: "discrimination", result_status: "credible_range", range_start: "04:48", range_end: "05:07", candidates: [ cand("04:50", 0.26), cand("04:52", 0.26), cand("04:56", 0.20), ], events: [ { id: "e1", domain: "career", year: 2020, precision: "month", usage: "training" }, { id: "e2", domain: "education", year: 2016, precision: "month", usage: "training" }, { id: "e3", domain: "relocation", year: 2018, precision: "month", usage: "training" }, ], probes: [], answered_probes: [], rounds: [], entropy: 1, representative_time: "04:50", credible_range: ["04:48", "05:07"], refresh_count: 1, ...overrides, }; } function deliveryResult( delivery: ReturnType, ): RectificationCandidateResult { return { resultId: "result-open-collect", candidates: delivery.columns.map((column, index) => ({ candidateId: column.candidate_id, rank: index + 1, time: column.time, relativeSupport: column.probability_percent, tiedMinuteCount: 1, })), overallConfidence: "medium", selectionAllowed: true, canAdopt: true, confirmationAllowed: false, decisionReceipt: null, representativeTime: delivery.representative_time, selectedTime: null, selectionKind: null, houseTable: null, houseTablesByTime: {}, natalRecast: null, techniqueAudit: [], windowTransitions: [], eventDashaLedger: [], dashaAgreement: null, lagnaContrast: null, nakshatraBoundary: null, precisionStage: null, oosBlindPrompts: [], confirmationGate: { confirmation_allowed: false } as RectificationCandidateResult["confirmationGate"], validated: false, completionStatus: null, sessionOutcome: "adopt_representative", credibleRange: delivery.range, rangeDelivery: delivery, verificationReportMarkdown: delivery.verification_markdown, }; } function renderCard(delivery: ReturnType): string { return renderToStaticMarkup(createElement(RectificationRangeDelivery, { result: deliveryResult(delivery), acceptingCandidateId: null, readonly: false, onAccept: () => undefined, })); } function gapFor(decision: { sessionOutcome: string; stopReason?: string | null }): string { return rectificationQuestionGapState({ liveQuestionVisible: false, questionMissing: true, questionLoadFailed: false, collectWaiting: false, busy: false, readonly: false, regenerating: false, snapshotLoaded: true, resumableCase: true, retryAttempts: 0, sessionOutcome: decision.sessionOutcome, stopReason: decision.stopReason ?? null, }); } test("closed collect copy no longer invites free-text after the seven lines close", () => { const copy = rangeNarrowHint( CLOSED_LAYERS, DATED_EVIDENCE, declineAllTargeted(), ["04:48", "05:07"], 5, ["04:48", "05:07"], ); // 原值: 空池文案含「不限领域」「确切哪一天」自由文本邀请 // 新值: 门槛未达写「再对照几件经历会更准」,删除自由文本邀请 // 原因: D3 引导式补经历,系统点名逐题问 assert.match(copy, /现在还剩 04:48–05:07 里 5 个候选/); assert.match(copy, new RegExp(GUIDED_NARROW_HINT)); assert.doesNotMatch(copy, /不限领域|确切哪一天|有年月也行|接着算/); assert.doesNotMatch(copy, /哪年结婚或订婚|哪年收入明显变过|家里哪年添丁/); assert.doesNotMatch(copy, /问完了|都问完|定到分钟|精确到分钟/); }); test("closed-pool copy keeps the candidate count and drops the free-text invite", () => { const two = rangeNarrowHint( CLOSED_LAYERS, DATED_EVIDENCE, declineAllTargeted(), ["04:48", "05:07"], 2, ["04:48", "05:07"], ); // 原值: 「这两分钟按现有信息分不开」+ 自由文本邀请 // 新值: 「再对照几件经历会更准」,无邀请 // 原因: D3 删除自由文本邀请 assert.match(two, /现在还剩 04:48–05:07 里 2 个候选,再对照几件经历会更准/); assert.doesNotMatch(two, /不限领域|确切哪一天/); const four = rangeNarrowHint( CLOSED_LAYERS, DATED_EVIDENCE, declineAllTargeted(), ["04:47", "04:53"], 4, ["04:47", "04:53"], ); assert.match(four, /现在还剩 04:47–04:53 里 4 个候选,再对照几件经历会更准/); assert.doesNotMatch(four, /不限领域|登记结婚那天/); const unknown = rangeNarrowHint( CLOSED_LAYERS, DATED_EVIDENCE, declineAllTargeted(), ); assert.equal(unknown, GUIDED_NARROW_HINT); assert.doesNotMatch(unknown, /不限领域/); }); test("the range card no longer shows a free-text invite", () => { const publicCandidates = [ { candidateId: ID_A, time: "04:50" }, { candidateId: ID_B, time: "04:52" }, { candidateId: ID_C, time: "04:56" }, ]; const tied = buildRangeDelivery({ inference: inference(), publicCandidates, credibleRange: ["04:48", "05:07"], representativeTime: "04:50", evidence: DATED_EVIDENCE, declinedTopics: declineAllTargeted(), }); assert.equal( Math.abs((tied.columns[0]?.probability_percent ?? 0) - (tied.columns[1]?.probability_percent ?? 0)) <= RANGE_DELIVERY_TIE_PERCENT, true, ); const tiedHtml = renderCard(tied); // 原值: 并列时卡上出现自由文本邀请 // 新值: 交付卡不再邀请打字 // 原因: D3 删除 RANGE_DELIVERY_OPEN_COLLECT 邀请 assert.doesNotMatch(tiedHtml, /rectification-range-delivery__invite/); assert.doesNotMatch(tiedHtml, /不限领域|确切哪一天|换专业|创业/); assert.match(tiedHtml, /更像这个/); assert.doesNotMatch(tiedHtml, /问完了/); const stillOpen = buildRangeDelivery({ inference: inference({ transitions: [{ layer: "d9", at: "04:52" }], }), publicCandidates, credibleRange: ["04:48", "05:07"], representativeTime: "04:50", evidence: DATED_EVIDENCE, declinedTopics: [], }); assert.match(stillOpen.narrow_hint ?? "", /还能再收窄/); const openHtml = renderCard(stillOpen); assert.doesNotMatch(openHtml, /rectification-range-delivery__invite/); assert.doesNotMatch(openHtml, /不限领域/); }); test("a new dated event after delivery stales the snapshot and leaves the delivered gap", () => { const rows = DATED_EVIDENCE.map((item) => ({ ...item })); const fingerprint = evidenceLedgerFingerprint(rows as never); const times = ["04:50", "04:52", "04:56"] as const; const state = inference({ candidate_set_id: candidateSetId("04:48", "05:07", [...times]), refresh_count: 1, refresh_attempts: [{ candidate_set_id: candidateSetId("04:48", "05:07", [...times]), answer_count: 0, result: "no_new_probes", at: "2026-09-14T00:00:00.000Z", }], }); const dossier: DecisionDossier = { evidence: rows, conversationSummary: { activeFocus: null, declinedSkippedTopics: declineAllTargeted(), }, latestResult: { resultId: "55555555-5555-4555-8555-555555555555", selectionAllowed: true, confirmationAllowed: false, evidenceLedgerFingerprint: fingerprint, candidates: times.map((time, index) => ({ candidateId: [ID_A, ID_B, ID_C][index], time, rank: index + 1, relativeSupport: time === "04:56" ? 20 : 26, posterior_score: time === "04:56" ? 20 : 26, })), representativeTime: "04:50", decisionReceipt: { accept_allowed: true, acceptance_allowed: true, propose_allowed: true, selection_allowed: true, confirmation_allowed: false, inference_state: state, }, }, case: { acceptedTime: null, status: "candidate_ready", candidateRange: { start_time: "04:45", end_time: "05:15" }, }, }; const delivered = decideFromDossier(dossier, { birthDate: "1997-08-08" }); assert.ok( delivered.nextAction === "offer_provisional_range" || delivered.nextAction === "ready_to_adopt" || delivered.nextAction === "complete_with_range", delivered.nextAction, ); assert.equal(gapFor(delivered), "delivered"); const nextEvidence = [ ...rows, { id: "evidence-lawsuit", status: "confirmed", domain: "other", datePrecision: "month", occurredFrom: "2019-03-01", occurredTo: null, eventKind: "legal_case", summary: "打官司", }, ]; const nextDossier: DecisionDossier = { ...dossier, evidence: nextEvidence }; const inferenceAfter = previousInferenceFromReceipt(nextDossier.latestResult?.decisionReceipt ?? null); assert.equal(scoreableSnapshotCurrentFromDossier(dossier, undefined, inferenceAfter), true); assert.equal(scoreableSnapshotCurrentFromDossier(nextDossier, undefined, inferenceAfter), false); const after = decideFromDossier(nextDossier, { birthDate: "1997-08-08" }); assert.equal( after.nextAction === "complete_with_range" || after.nextAction === "offer_provisional_range" || after.nextAction === "ready_to_adopt", false, after.nextAction, ); assert.notEqual(after.stopReason, "tied_first"); assert.notEqual(gapFor(after), "delivered"); });