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 { INFERENCE_ALGORITHM_VERSION } from "../src/lib/rectification-agentic/core/types.ts"; import type { ConflictProbe, InferenceState } from "../src/lib/rectification-agentic/core/types.ts"; import { decideFromDossier, type DecisionDossier, } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts"; import { RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts"; import { applyRectificationChoice, ensureNonTerminalTurnExit, persistNextInterviewIfIdle, } from "../src/lib/rectification-agentic/v9/answer-choice.ts"; import { resetDeliveryTurnGuardForTests } from "../src/lib/rectification-agentic/v9/delivery-turn-guard.ts"; import { RECTIFICATION_SKILL_NAME, RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts"; import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts"; import { CHOICE_STOP_LABEL } from "../src/lib/rectification-agentic/v9/choice-card.ts"; import { CHOICE_ACTION } from "../src/lib/rectification-agentic/v9/choice-action.ts"; import { runV9AgentTurn } from "../src/lib/rectification-agentic/v9/agent-run.ts"; import { finalizeSuccessfulTurnExit } from "../src/lib/rectification-agentic/v9/turn-exit.ts"; import { CASE_ID, FOCUS_ID, SESSION_ID, TURN_ID, USER_ID, activeFocusFixture, 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-edu-start", status: "confirmed" as const, domain: "education", datePrecision: "month" as const, occurredFrom: "2016-09-01", occurredTo: "2016-09-30", eventKind: "education_start", summary: "education start", }, { 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: "education completion", }, { id: "e-rel-start", status: "confirmed" as const, domain: "relationship", datePrecision: "month" as const, occurredFrom: "2024-05-01", occurredTo: null, eventKind: "relationship_start", summary: "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: "relationship end", }, { id: "e-finance", status: "confirmed" as const, domain: "finance", datePrecision: "month" as const, occurredFrom: "2026-01-01", occurredTo: "2026-01-31", eventKind: "finance_loss", summary: "finance change", }, { id: "e-reloc", status: "confirmed" as const, domain: "relocation", datePrecision: "month" as const, occurredFrom: "2023-07-01", occurredTo: "2023-07-31", eventKind: "relocation", summary: "relocation", }, { id: "e-health", status: "confirmed" as const, domain: "health_pressure", datePrecision: "month" as const, occurredFrom: "2026-01-01", occurredTo: "2026-08-31", eventKind: "pressure_period", summary: "health pressure", }, { id: "e-occupation", status: "confirmed" as const, domain: "occupation", datePrecision: "unknown" as const, occurredFrom: null, occurredTo: null, eventKind: "occupation_note", summary: "occupation note", }, ] as const; function uuidAt(index: number) { return `00000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`; } function existenceProbe(input: { key: string; domain: string; year: number; question: string; }): 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: [...ACTIVE], expected_outcomes: [ { answer_class: "yes", supports: ["04:51"], conflicts: ["04:47"] }, { answer_class: "weak_yes", supports: [], conflicts: [] }, { answer_class: "no", supports: ["04:47"], conflicts: ["04:51"] }, { answer_class: "unsure", supports: [], conflicts: [] }, ], information_gain: 0.4, source: "dasha_boundary", choice_kind: "existence", style_options: EXISTENCE_OPTIONS, }; } const D9: ConflictProbe = { id: "contrast:varga.d9.style", semantic_key: "varga.d9.style", candidate_split_hash: "varga.d9.style", domain: "relationship", year: 0, question: "亲密关系里更接近哪一种", candidate_ids: [...ACTIVE], expected_outcomes: [ { answer_class: "yes", supports: ["04:51"], conflicts: ["04:47"] }, { answer_class: "weak_yes", supports: ["04:47"], conflicts: ["04:51"] }, { answer_class: "no", supports: [], conflicts: [] }, { answer_class: "unsure", supports: [], conflicts: [] }, ], information_gain: 0.3, source: "varga_contrast", choice_kind: "varga_style", }; const D10: ConflictProbe = { id: "contrast:varga.d10.style", semantic_key: "varga.d10.style", candidate_split_hash: "varga.d10.style", domain: "career", year: 0, question: "平时做事更接近哪一种", candidate_ids: [...ACTIVE], expected_outcomes: [ { answer_class: "yes", supports: ["04:51"], conflicts: ["04:47"] }, { answer_class: "weak_yes", supports: ["04:47"], conflicts: ["04:51"] }, { answer_class: "no", supports: [], conflicts: [] }, { answer_class: "unsure", supports: [], conflicts: [] }, ], information_gain: 0.3, source: "varga_contrast", choice_kind: "varga_style", }; const CAREER_MONTH = existenceProbe({ key: "career.2023.05.dasha_boundary", domain: "career", year: 2023, question: "2023 年 5 月前后有没有入职或换工作", }); const CAREER_YEAR = existenceProbe({ key: "career.2023.dasha_activation", domain: "career", year: 2023, question: "2023 年前后工作上有没有入职或换工作", }); const RELOC = existenceProbe({ key: "relocation.2018.05.dasha_boundary", domain: "relocation", year: 2018, question: "2018 年 5 月前后有没有搬家", }); const FINANCE = existenceProbe({ key: "finance.2024.03.dasha_boundary", domain: "finance", year: 2024, question: "2024 年 3 月前后钱上有没有明显变化", }); const ASKED_PROBES = [D9, D10, CAREER_MONTH, CAREER_YEAR, RELOC, FINANCE]; const LEFTOVER_PROBE = existenceProbe({ key: "career.2021.04.dasha_boundary", domain: "career", year: 2021, question: "2021 年 4 月前后有没有入职或换工作", }); const ACTION_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1"; const GATE_SENTENCE = /还差(?: \d+ 件)?带月份的经历|两件事的日期还没对清|当前还排不出可比较的候选时间|还差另一个领域的带月份经历/; function liveState(extraProbes: readonly ConflictProbe[] = []): InferenceState { const probes = [...ASKED_PROBES, ...extraProbes]; 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 = [ { probe: D9, answer_class: "yes" as const }, { probe: D10, answer_class: "yes" as const }, { probe: CAREER_MONTH, answer_class: "yes" as const }, { probe: CAREER_YEAR, answer_class: "no" as const }, { probe: RELOC, answer_class: "no" as const }, { probe: FINANCE, answer_class: "no" as const }, ].map((item) => ({ probe_id: item.probe.id, semantic_key: item.probe.semantic_key, candidate_split_hash: item.probe.candidate_split_hash, answer_class: item.answer_class, classified_from: "choice" as const, })); const raw = { algorithm_version: INFERENCE_ALGORITHM_VERSION, candidate_set_id: candidateSetId("04:47", "05:15", TIMES), revision: 6, phase: "discrimination" as const, result_status: "discriminating" as const, range_start: "04:47", range_end: "05:15", candidates, events: [ { id: "e-edu-start", domain: "education", year: 2016, precision: "month" as const, usage: "training" as const }, { id: "e-edu-end", domain: "education", 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: "holdout" as const }, { id: "e-finance", domain: "finance", year: 2026, precision: "month" as const, usage: "training" as const }, { id: "e-reloc", domain: "relocation", year: 2023, precision: "month" as const, usage: "training" as const }, { id: "e-health", domain: "health_pressure", year: 2026, precision: "month" 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, "accident-shape inference must pass asInferenceState"); return loaded; } function eventProbeRow(probe: ConflictProbe) { return { year: probe.year, year_label: probe.year > 0 ? `${probe.year} 年前后` : "", domain: probe.domain, event_family: probe.domain === "career" ? "入职、换工作或职责加重" : probe.domain, 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, }; } 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 accidentDossier(extra: { acceptanceAllowed?: boolean; acceptanceReasons?: string[]; mismatchSnapshot?: boolean; leftoverProbe?: ConflictProbe; holdoutUnavailable?: boolean; } = {}): DecisionDossier { const base = liveState(extra.leftoverProbe ? [extra.leftoverProbe] : []); const evidence = extra.holdoutUnavailable ? EVIDENCE.filter((item) => item.id !== "e-rel-end") : EVIDENCE; const state = { ...base, events: extra.holdoutUnavailable ? base.events.filter((item) => item.usage !== "holdout" && item.id !== "e-rel-end") : base.events, holdout_passed: extra.holdoutUnavailable ? true : base.holdout_passed, }; const fingerprint = evidenceLedgerFingerprint(evidence as never); const acceptanceAllowed = extra.acceptanceAllowed !== false; return { evidence, conversationSummary: { activeFocus: null, declinedSkippedTopics: [{ target_domain: "family", status: "declined" }], }, latestResult: { resultId: "55555555-5555-4555-8555-555555555555", selectionAllowed: acceptanceAllowed, confirmationAllowed: false, evidenceLedgerFingerprint: fingerprint, candidates: extra.mismatchSnapshot ? [{ candidateId: uuidAt(0), time: "03:00", rank: 1, relativeSupport: 10 }] : TIMES.map((time, index) => ({ candidateId: uuidAt(index), time, rank: index + 1, relativeSupport: Math.round(SCORES[time] ?? 0), })), representativeTime: "04:51", decisionReceipt: { accept_allowed: acceptanceAllowed, acceptance_allowed: acceptanceAllowed, propose_allowed: extra.acceptanceAllowed === false ? false : true, selection_allowed: extra.acceptanceAllowed === false ? false : true, confirmation_allowed: false, ...(extra.acceptanceReasons ? { acceptance_reasons: extra.acceptanceReasons } : {}), inference_state: state, discriminating_event_probes: [ ...ASKED_PROBES.map(eventProbeRow), ...(extra.leftoverProbe ? [eventProbeRow(extra.leftoverProbe)] : []), ], oos_blind_prompts: extra.holdoutUnavailable ? [] : [{ domain: "career", user_meaning: "工作这条线还没用过。有没有记得大概时间的入职或换工作?", used_for_scoring: false, }], }, }, case: { acceptedTime: null, status: "collecting_evidence" }, }; } function rpcDossier(decision: DecisionDossier, extra: { activeFocus?: ReturnType } = {}) { 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-06T00:00:00.000Z", })); return dossierFixture({ evidence, latestResult: candidateSnapshotFixture({ selectionAllowed: decision.latestResult?.selectionAllowed ?? true, confirmationAllowed: false, representativeTime: "04:51", 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, })) ?? snapshotCandidates(), decisionReceipt: { ...(decision.latestResult?.decisionReceipt ?? {}) }, }), conversationSummary: { confirmed_evidence_summary: [], pending_revisions: [], active_focus: extra.activeFocus ?? null, declined_skipped_topics: decision.conversationSummary.declinedSkippedTopics, candidate_divergence_summary: null, missing_evidence_categories: [], last_result_policy: null, summary_version: 1, updated_at: "2026-09-06T00:00:00.000Z", }, }); } function idleHandlers(decision: DecisionDossier, extra: { activeFocus?: ReturnType; allowFocus?: boolean; turns?: unknown[]; } = {}) { return fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => { const row = rpcDossier(decision, extra); return extra.turns ? { ...row, turns: extra.turns } : row; }, 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: args.p_inference && typeof args.p_inference === "object" ? (args.p_inference as { probe_id?: string }).probe_id ?? "p-cd" : "p-cd", 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.allowFocus ? (_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-06T00:00:00.000Z", resolved_at: null, asked_turn_id: args.p_asked_turn_id ?? null, }, idempotent: false, }) : (_fn, args) => { throw new Error(`must not persist collect focus ${String(args.p_question_id ?? args.p_target_domain)}`); }, finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }), get_agentic_rectification_turn_receipt: () => null, }); } function leftoverFocus(probe: ConflictProbe) { return activeFocusFixture({ intent: "distinguish_candidates", targetDomain: probe.domain, questionId: `probe:${probe.semantic_key}`, expectedAnswerSchema: { choice: { prompt: probe.question, option_a: EXISTENCE_OPTIONS[0].label, option_b: EXISTENCE_OPTIONS[1].label, option_c: EXISTENCE_OPTIONS[2].label, option_d: EXISTENCE_OPTIONS[3].label, options: EXISTENCE_OPTIONS.map((option, index) => ({ key: (["A", "B", "C", "D"] as const)[index]!, label: option.label, answer_class: option.answer_class, })), }, probe_id: probe.id, semantic_key: probe.semantic_key, candidate_split_hash: probe.candidate_split_hash, }, }); } function assistantAppendCalls(calls: Array<{ fn: string; args: Record }>) { return calls.filter((item) => ( item.fn === "append_agentic_rectification_turn" && typeof item.args.p_assistant_message === "string" && String(item.args.p_assistant_message).trim() )); } // 原值: /就能开始筛|现在记下的是|范围还能再收一截|目前范围|当前范围|范围已经收到|能问的都问完了/ // 新值: 加上「收到 … 这 N 分钟 / 按现有信息分不开 / 说出来我接着算」 // 原因: BUG-689 把「能问的都问完了」换成交付邀请;BUG-692 短语表跟上。不含「只微调排序」(BUG-688)。 const EXIT_CARRIER_COPY = /就能开始筛|现在记下的是|范围还能再收一截|目前范围|当前范围|范围已经收到|能问的都问完了|收到.{0,80}这 \d+ 分钟|按现有信息分不开|说出来我接着算|再对照几件经历会更准|能把它们分开的是这几条线/; function exitAppendCalls(calls: Array<{ fn: string; args: Record }>) { return assistantAppendCalls(calls).filter((item) => ( GATE_SENTENCE.test(String(item.args.p_assistant_message)) || EXIT_CARRIER_COPY.test(String(item.args.p_assistant_message)) )); } function gateAppendCalls(calls: Array<{ fn: string; args: Record }>) { return assistantAppendCalls(calls).filter((item) => GATE_SENTENCE.test(String(item.args.p_assistant_message))); } function gateBodyCount(text: string | null | undefined) { return (text ?? "").match(new RegExp(GATE_SENTENCE.source, "g"))?.length ?? 0; } function fakeAgentStream(chunks: Array<{ type: string; payload?: Record }>) { const streamResult = { fullStream: (async function* () { for (const item of chunks) yield item; })(), totalUsage: Promise.resolve({ inputTokens: 10, outputTokens: 20 }), }; return { stream: async () => streamResult, getSkill: async () => ({ name: RECTIFICATION_SKILL_NAME, instructions: "skill" }), }; } 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 })); } test("skill version is 10.0.26 after the targeted-collect-cards bump", () => { // 原值: "10.0.25" // 原值: "10.0.26" // 新值: "10.0.27" // 原因: 出卡精度门槛 + 引导式补经历 // 原因: BUG-668 定向补事第四选项写进 Skill // 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人 assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.29"); }); test("USER_COLLECT_QUESTION no longer has an other fallback", () => { const srcRoot = new URL("../src/", import.meta.url); const files = [ "lib/rectification-agentic/v9/method-followup.ts", "lib/rectification-agentic/v9/answer-choice.ts", "mastra/rectification-v9-tools.ts", "app/api/rectification/agent/route.ts", "lib/rectification-agentic/user-copy.ts", ]; const hits: string[] = []; for (const relative of files) { const source = readFileSync(new URL(relative, srcRoot), "utf8"); for (const line of source.split("\n")) { if (line.includes("USER_COLLECT_QUESTION.other") || line.includes("collectQuestionRetryByDomain.other")) { hits.push(`${relative}: ${line.trim()}`); } } } assert.deepEqual(hits, []); }); test("collect spoken stop stays on choice cards; range line is status only", () => { // 原值: 口述停止按钮和范围小字共用 CHOICE_STOP_LABEL / onStop // 新值: CHOICE_STOP_LABEL 只在选择题卡;composer-wrap 不再挂先这样 // 原因: BUG-595 决策 2 const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8"); const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8"); const rangeFn = chat.slice( chat.indexOf("function RectificationReadonlyRange"), chat.indexOf("type RectificationAgenticChatProps"), ); const wrap = chat.slice(chat.indexOf("className=\"composer-wrap\""), chat.indexOf("className=\"composer-footer\"")); assert.doesNotMatch(wrap, /rectification-collect-stop/); // 原值: 容器源码含 CHOICE_STOP_LABEL。 // 新值: 常量在行组件;容器仍有 submitStop,composer-wrap 仍不得托管。 // 原因: BUG-725。 const messageEntry = readFileSync(new URL("../src/components/rectification-message-entry.tsx", import.meta.url), "utf8"); assert.match(messageEntry, /CHOICE_STOP_LABEL/); assert.match(chat, /kind === "collect_spoken"/); assert.match(chat, /function submitStop/); assert.match(chat, /RectificationReadonlyRange/); assert.match(rangeFn, /role="status"/); assert.doesNotMatch(rangeFn, /onStop/); assert.doesNotMatch(rangeFn, /