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, type DecisionDossier, } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts"; import { 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 { runV9AgentTurn } from "../src/lib/rectification-agentic/v9/agent-run.ts"; import { composeIdleGapIntoSpoken } from "../src/lib/rectification-agentic/v9/collect-prompt.ts"; import { COLLECT_KIND_ORDER, anchoredFollowups, collectionQuestionPool, preciseGapNarration, } from "../src/lib/rectification-agentic/v9/collection-question-pool.ts"; import { applyOccupationCollectLedgerNorm, trainingScoreableGate } from "../src/lib/rectification-agentic/v9/evidence-model.ts"; import { buildMethodFollowupPlan, followupFromPoolItem } from "../src/lib/rectification-agentic/v9/method-followup.ts"; import { parseAgentChoiceCopy } from "../src/lib/rectification-agentic/v9/choice-card.ts"; import { isRenderableChoiceOpenQuestion } from "../src/lib/rectification-agentic/v9/server-focus.ts"; import { interviewCollectWaiting, rectificationQuestionGapState, } from "../src/lib/rectification-surface-state.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 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 health2024 = { id: "e-health-2024", status: "confirmed" as const, domain: "health_pressure", datePrecision: "month" as const, occurredFrom: "2024-10-01", occurredTo: "2024-10-31", eventKind: "self_health_event", summary: "2024年10月一次身体事故", }; const career2024 = { id: "e-career-2024", status: "confirmed" as const, domain: "career", datePrecision: "month" as const, occurredFrom: "2024-04-01", occurredTo: null, eventKind: "career_entry", summary: "2024年4月开始做程序员", }; const occupationNote = { id: "e-occupation-note", status: "confirmed" as const, domain: "occupation", datePrecision: "unknown" as const, occurredFrom: null, occurredTo: null, eventKind: "occupation_note", summary: "程序员", }; const inviteDeclinedTopic = { target_domain: "other", status: "declined", intent: "collect_method_evidence", questionId: "collect:invite:more", target_kind: "invite_more", }; const OCCUPATION_FOCUS = { intent: "collect_method_evidence" as const, targetDomain: "occupation", targetKind: "occupation_note", questionId: "collect:occupation:collect_method_evidence", }; function askedPoolTopics() { return [ inviteDeclinedTopic, { target_domain: "career", status: "resolved", intent: "collect_method_evidence", questionId: "collect:anchor:education_completion:2020", target_kind: "anchor:education_completion:2020", }, { target_domain: "relocation", status: "resolved", intent: "collect_method_evidence", questionId: "collect:anchor:education_start:2016", target_kind: "anchor:education_start:2016", }, ...COLLECT_KIND_ORDER.flatMap((kind) => [ { target_domain: kind, status: "resolved", intent: "collect_method_evidence", questionId: `collect:anchor:after_event:${kind === "education" ? "2016" : "2020"}`, target_kind: `anchor:after_event:${kind === "education" ? "2016" : "2020"}`, }, { target_domain: kind, status: "resolved", intent: "collect_method_evidence", questionId: `collect:generic:${kind}`, target_kind: `generic:${kind}`, }, ]), ]; } 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 ASKED_PROBES = [ existenceProbe({ key: "career.2023.05.dasha_boundary", domain: "career", year: 2023, question: "2023 年 5 月前后有没有入职或换工作", }), ]; const LEFTOVER_PROBE = existenceProbe({ key: "career.2021.04.dasha_boundary", domain: "career", year: 2021, question: "2021 年 4 月前后有没有入职或换工作", }); function liveState(extraProbes: readonly ConflictProbe[] = []) { 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 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: 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: health2024.id, domain: "health_pressure", year: 2024, precision: "month" as const, usage: "training" as const }, { id: career2024.id, domain: "career", year: 2024, precision: "month" as const, usage: "holdout" as const }, ], probes, answered_probes: ASKED_PROBES.map((probe) => ({ probe_id: probe.id, semantic_key: probe.semantic_key, candidate_split_hash: probe.candidate_split_hash, answer_class: "no" as const, classified_from: "choice" as const, })), 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); 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 fourEventDossier(): DecisionDossier { const evidence = [educationStart, educationEnd, health2024, career2024, occupationNote]; const state = liveState([LEFTOVER_PROBE]); const fingerprint = evidenceLedgerFingerprint(evidence as never); return { evidence, conversationSummary: { activeFocus: null, declinedSkippedTopics: [inviteDeclinedTopic], }, latestResult: { resultId: "55555555-5555-4555-8555-555555555555", selectionAllowed: false, confirmationAllowed: false, evidenceLedgerFingerprint: fingerprint, candidates: TIMES.map((time, index) => ({ candidateId: uuidAt(index), time, rank: index + 1, relativeSupport: Math.round(SCORES[time] ?? 0), })), representativeTime: "04:51", decisionReceipt: { accept_allowed: false, acceptance_allowed: false, propose_allowed: false, selection_allowed: false, confirmation_allowed: false, acceptance_reasons: ["insufficient_events"], inference_state: state, discriminating_event_probes: [ ...ASKED_PROBES.map(eventProbeRow), eventProbeRow(LEFTOVER_PROBE), ], oos_blind_prompts: [], }, }, 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-11T00: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, })) ?? [], 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-11T00:00:00.000Z", }, }); } function idleHandlers(decision: DecisionDossier, extra: { activeFocus?: ReturnType; allowFocus?: boolean; } = {}) { return fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => rpcDossier(decision, extra), get_agentic_rectification_case_compute: () => computeFixture(), append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }), 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-11T00: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 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.25", () => { // 原值: "10.0.24" // 新值: "10.0.25" // 原因: BUG-661/662/663 定向补事改逐条点选,Skill 升版 assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.25"); }); test("two education events do not spawn birth-year reverse questions", () => { const pool = collectionQuestionPool([educationStart, educationEnd]); const prompts = pool.map((item) => item.prompt).join("\n"); assert.equal(pool[0]?.kind, "invite"); assert.doesNotMatch(prompts, /年前后/); const plan = buildMethodFollowupPlan({ evidence: [educationStart, educationEnd], birthDate: "1997-08-08", }); assert.doesNotMatch(plan.next_followup?.user_prompt_hint ?? "", /年前后/); assert.doesNotMatch(plan.next_followup?.spoken_prompt ?? "", /年前后/); }); test("after declining invite, the next follow-up is the user-year career anchor", () => { const asked = new Set(); const anchors = anchoredFollowups([educationStart, educationEnd], new Set(), asked); const job = anchors.find((item) => item.key === "collect:anchor:education_completion:2020"); assert.ok(job); assert.equal(job?.domain, "career"); const followup = followupFromPoolItem(job!); assert.equal(followup.domain, "career"); assert.match(followup.user_prompt_hint, /2020 年毕业后第一份工作/); const afterDecline = collectionQuestionPool( [educationStart, educationEnd], [inviteDeclinedTopic], ); assert.doesNotMatch(afterDecline.map((item) => item.prompt).join("\n"), /年前后/); assert.equal(afterDecline[0]?.domain, "career"); }); test("dated occupation answer plus the replay ledger opens the training gate with one holdout", () => { const remapped = applyOccupationCollectLedgerNorm(OCCUPATION_FOCUS, [{ domain: "career" as const, eventKind: "career_entry" as const, datePrecision: "month" as const, occurredFrom: "2024-04-01", occurredTo: null, }]); const evidence = [ educationStart, educationEnd, health2024, ...remapped.map((item, index) => ({ id: index === 0 ? "e-occupation-note" : "e-career-2024", status: "confirmed" as const, domain: item.domain, datePrecision: item.datePrecision, occurredFrom: item.occurredFrom, occurredTo: item.occurredTo, eventKind: item.eventKind, summary: index === 0 ? "程序员" : "2024年4月开始做程序员", })), ]; const gate = trainingScoreableGate(evidence); assert.equal(gate.holdoutCount, 1); assert.equal(gate.trainingCount, 3); assert.ok(gate.trainingDomainCount >= 2); assert.equal(gate.open, true); assert.equal(gate.holdoutStatus, "reserved"); }); test("four dated month events persist a leftover discriminator card", async () => { resetDeliveryTurnGuardForTests(); const dossier = fourEventDossier(); const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); assert.equal(decision.nextAction, "ask_candidate_discriminator"); const accounting = idleHandlers(dossier, { allowFocus: true }); const { result: idle } = await warnLines(() => persistNextInterviewIfIdle({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID, })); const persisted = idle as Awaited>; const focusCalls = accounting.calls.filter((item) => item.fn === "set_agentic_rectification_conversation_focus"); assert.equal(focusCalls.length > 0, true); assert.equal(focusCalls[0]?.args.p_intent, "distinguish_candidates"); const schema = focusCalls[0]?.args.p_expected_answer_schema; const copy = parseAgentChoiceCopy(schema); assert.ok(copy, "persisted focus must carry a choice schema"); assert.equal(isRenderableChoiceOpenQuestion({ question_id: String(focusCalls[0]?.args.p_question_id ?? ""), prompt: copy.prompt, status: "created", kind: "choice", focus_id: FOCUS_ID, probe_id: typeof (schema as { probe_id?: unknown } | undefined)?.probe_id === "string" ? (schema as { probe_id: string }).probe_id : null, }), true); assert.equal(persisted.choiceReady, true); }); test("idle gap copy joins the evidence recap instead of opening a second turn", () => { const joined = composeIdleGapIntoSpoken("记下了。", "现在记下的是2016 年 9 月上大学和2020 年 6 月毕业。再来一件不是上学的、记得大概年月的事就能开始筛,比如第一份工作、谈恋爱或结婚。"); assert.match(joined, /^记下了。现在记下的是/); assert.match(joined, /就能开始筛/); assert.equal(composeIdleGapIntoSpoken(joined, "现在记下的是重复。"), joined); assert.equal( composeIdleGapIntoSpoken("目前范围 04:49–04:53。", "目前范围 04:49–04:53。"), "目前范围 04:49–04:53。", ); }); test("undated occupation answer keeps the training gate closed and writes a precise gap", async () => { resetDeliveryTurnGuardForTests(); const evidence = [educationStart, educationEnd, occupationNote]; const declinedSkippedTopics = askedPoolTopics(); assert.equal(collectionQuestionPool(evidence, declinedSkippedTopics).length, 0); const gap = preciseGapNarration(evidence, declinedSkippedTopics); assert.match(gap, /现在记下的是/); assert.match(gap, /就能开始筛/); assert.doesNotMatch(gap, /领域|做不了|还差 \d+ 件/); const careerOrLoveOrFamily = ["第一份工作", "谈恋爱", "家里"].filter((token) => gap.includes(token)); assert.ok(careerOrLoveOrFamily.length >= 2, gap); const dossier: DecisionDossier = { evidence, conversationSummary: { activeFocus: null, declinedSkippedTopics: declinedSkippedTopics, }, latestResult: null, case: { acceptedTime: null, status: "collecting_evidence" }, }; const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); assert.equal(trainingScoreableGate(evidence).open, false); assert.equal(interviewCollectWaiting({ stopReason: decision.stopReason, sessionOutcome: decision.sessionOutcome, questionMissing: true, }), true); assert.equal(rectificationQuestionGapState({ liveQuestionVisible: false, questionMissing: true, questionLoadFailed: false, collectWaiting: true, busy: false, readonly: false, regenerating: false, snapshotLoaded: true, resumableCase: true, retryAttempts: 0, }), "collect_waiting"); const accounting = idleHandlers(dossier); const { result: idle } = await warnLines(() => persistNextInterviewIfIdle({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID, })); const persisted = idle as Awaited>; assert.equal( accounting.calls.some((item) => item.fn === "set_agentic_rectification_conversation_focus"), false, ); assert.equal(persisted.terminalNote, true); assert.match(persisted.hostNarration ?? "", /现在记下的是/); assert.match(persisted.hostNarration ?? "", /就能开始筛/); assert.doesNotMatch(persisted.hostNarration ?? "", /领域|做不了|还差 \d+ 件/); const agentAccounting = idleHandlers(dossier); const result = await runV9AgentTurn({ userId: USER_ID, caseId: CASE_ID, sessionId: SESSION_ID, requestId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", action: "evidence", message: "程序员", modelName: "gpt-4o-mini", accounting: agentAccounting.client, billing: { reserve: async () => ({ success: true, status: 200 }), complete: async () => true, release: async () => true, }, emit: async () => {}, buildAgent: async () => fakeAgentStream([ { type: "start" }, { type: "tool-call", payload: { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } } }, { type: "tool-result", payload: { toolName: "skill" } }, { type: "tool-call", payload: { toolName: "rectification-read-case", args: { caseId: CASE_ID } } }, { type: "tool-result", payload: { toolName: "rectification-read-case" } }, { type: "text-delta", payload: { text: "记下了。" } }, { type: "finish" }, ]) as never, }); assert.equal(result.ok, true); assert.match(result.answerText, /现在记下的是/); assert.match(result.answerText, /就能开始筛/); assert.doesNotMatch(result.answerText, /领域|做不了|还差 \d+ 件/); const finalized = agentAccounting.calls.find((item) => item.fn === "finalize_agentic_rectification_turn"); const appended = agentAccounting.calls.find((item) => ( item.fn === "append_agentic_rectification_turn" && typeof item.args.p_assistant_message === "string" && String(item.args.p_assistant_message).includes("就能开始筛") )); assert.ok(finalized || appended, "gap copy must land on the evidence turn"); });