import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; import test from "node:test"; import { diagnosticsSummarySchema, type RectificationTurnPlan } from "../src/lib/rectification-agent/contracts.ts"; import { buildRectificationCaseDossier, regenerateDirectorQuestion, runRectificationDirector, validateRectificationTurnPlan } from "../src/lib/rectification-agent/director-agent.ts"; import { mergeDirectorReconciliation } from "../src/lib/rectification-agent/orchestrator.ts"; import type { CalculationSpec, CandidateSnapshot, LifeEventRevision, PendingEvidence, RectificationV4Case, RectificationV4Turn } from "../src/lib/rectification-v4/contracts.ts"; import { stageAgentEvidenceProposals } from "../src/lib/rectification-v4/extraction.ts"; import { calculationSpecHash } from "../src/lib/rectification-v4/fingerprints.ts"; const now = "2026-07-30T00:00:00.000Z"; const caseId = "00000000-0000-4000-8000-000000000701"; const spec: CalculationSpec = { version: "rectification-calculation-spec-v4", birthDate: "1993-04-17", candidateRange: { start: "05:00", end: "06:00" }, latitude: 36.683333, longitude: 114.35, timezoneId: "Asia/Shanghai", timezoneOffsetHours: 8, birthTimeSource: "approximate", ayanamsa: "lahiri", nodeMode: "mean", minuteStep: 1, }; const caseValue: RectificationV4Case = { id: caseId, userId: "00000000-0000-4000-8000-000000000702", protocol: "rectification-evidence-v5", version: 3, status: "processing", phase: "reasoning", calculationSpec: spec, calculationSpecHash: calculationSpecHash(spec), evidenceSetHash: "e".repeat(64), currentQuestion: null, latestSnapshot: null, orchestrationModelId: null, narrationModelId: null, skillVersion: "birth-time-rectification-v6", promptVersion: "rectification-director-v1", algorithmVersion: "rectification-v5-matrix-scoring-1", deploymentMode: "v5_agent", agentMode: "agent", featureSnapshotId: null, latestDiagnosticsId: null, acceptedRange: null, createdAt: now, updatedAt: now, }; function event(overrides: Partial = {}): LifeEventRevision { return { id: randomUUID(), eventId: randomUUID(), revision: 1, domain: "education", eventKind: "education_milestone", subject: "self", relatedPerson: null, summary: "2016年9月大学入学", rawText: "2016年9月大学入学", dateRange: { start: "2016-09-01", end: "2016-09-30", precision: "month", label: "2016年9月" }, scoreability: "scoreable", supersedesRevisionId: null, createdAt: now, ...overrides, }; } function turn(index: number, overrides: Partial = {}): RectificationV4Turn { return { id: randomUUID(), caseId, caseVersion: index + 1, questionId: null, questionDomain: null, questionTargetEventId: null, question: `问题${index}`, answer: `回答${index}`, modelId: null, actionId: randomUUID(), createdAt: now, ...overrides, }; } function plan(overrides: Partial = {}): RectificationTurnPlan { return { contractVersion: "rectification-turn-plan-v1", targetDisposition: "not_applicable", evidenceProposals: [], action: { type: "ask_question", focus: { mode: "collect_independent_event", targetEventId: null, domain: null, requestedFacts: ["independent_event"], rationaleCodes: ["need_independent_event"], }, question: "接下来想从哪段变化继续聊?", optionalQuickReplies: [], }, publicReply: { acknowledgement: "我已按你的描述整理这轮线索。", candidateCommentary: null, limitation: "目前仍不足以确认具体出生分钟。", }, ...overrides, }; } function dossier(events: readonly LifeEventRevision[] = [], turns: readonly RectificationV4Turn[] = [], pendingEvidence: readonly PendingEvidence[] = []) { return buildRectificationCaseDossier({ caseValue, turns, events, pendingEvidence, snapshot: null, diagnostics: null, targetDisposition: "not_applicable", currentTargetEventId: null, }); } const diagnostics = diagnosticsSummarySchema.parse({ id: "00000000-0000-4000-8000-000000000703", caseId, snapshotId: "00000000-0000-4000-8000-000000000704", primaryClusterRetentionRate: 0.8, leaveOneEventOutRetentionRate: 0.8, leaveOneDomainOutRetentionRate: 0.8, dateSensitivityRetentionRate: 0.8, neighborSupportMinutes: 3, primarySecondaryMarginPercent: 8, clusterMassRatio: 0.6, unstableEventIds: [], mostDiscriminatingLayers: ["D9"], eventDateSensitivity: [], candidateSplits: [], calculationHash: "d".repeat(64), createdAt: now, }); test("dossier keeps recent raw turns, useful earlier context, refusals, pending evidence, and the complete revision ledger", () => { const sharedEventId = randomUUID(); const events = Array.from({ length: 15 }, (_, index) => event({ eventId: index < 2 ? sharedEventId : randomUUID(), revision: index < 2 ? index + 1 : 1, supersedesRevisionId: index === 1 ? randomUUID() : null, summary: `事件${index}`, rawText: `事件${index}`, })); const targetEventId = events.at(-1)!.eventId; const turns = Array.from({ length: 14 }, (_, index) => turn(index)); turns[1] = turn(1, { questionDomain: "relationship", questionTargetEventId: targetEventId, answer: "这件事不方便说,换个方向。" }); const pending: PendingEvidence = { id: randomUUID(), caseId, turnId: turns[0]!.id, rawText: "后来搬过一次家", reasonCode: "date_unresolved", targetEventId, resolvedEventId: null, createdAt: now, resolvedAt: null, }; const value = dossier(events, turns, [pending]); assert.equal(value.conversation.recentRawTurns.length, 12); assert.equal(value.conversation.recentRawTurns[0]?.question, "问题2"); assert.match(value.conversation.earlierConversationSummary ?? "", /问题0/); assert.match(value.conversation.earlierConversationSummary ?? "", /不方便说/); assert.deepEqual(value.interviewState.declinedDomains, ["relationship"]); assert.equal(value.interviewState.pendingEvidence[0]?.reasonCode, "date_unresolved"); assert.equal(value.interviewState.pendingEvidence[0]?.targetEventId, targetEventId); assert.equal(value.eventLedger.length, 15); assert.equal(value.eventLedger[0]?.status, "superseded"); assert.equal(value.eventLedger[1]?.status, "active"); assert.equal(value.case.location.timezoneId, "Asia/Shanghai"); }); test("a natural question and multiple grounded event proposals pass without domain keywords or anchors", () => { const latestAnswer = "2018年9月搬到北京,2020年4月开始第一份工作。"; const value = plan({ evidenceProposals: [ { operation: "create", targetEventId: null, sourceSpan: "2018年9月搬到北京", dateText: "2018年9月", proposedSummary: "搬到北京", proposedDomain: "relocation", proposedEventKind: "relocation", proposedSubject: "self", proposedRelatedPerson: null, confidence: "high" }, { operation: "create", targetEventId: null, sourceSpan: "2020年4月开始第一份工作", dateText: "2020年4月", proposedSummary: "开始第一份工作", proposedDomain: "career", proposedEventKind: "career_change", proposedSubject: "self", proposedRelatedPerson: null, confidence: "high" }, ], }); assert.deepEqual(validateRectificationTurnPlan({ plan: value, dossier: dossier(), latestAnswer, phase: "evidence" }).issues, []); const staged = stageAgentEvidenceProposals({ caseId, rawText: latestAnswer, sourceTurnId: randomUUID(), asOfDate: "2026-07-30", existing: [], proposals: value.evidenceProposals, now: new Date(now) }); assert.equal(staged.revisions.length, 2); assert.deepEqual(new Set(staged.revisions.map((item) => item.domain)), new Set(["relocation", "career"])); }); test("server rejects invented sources, private details, exact minutes, and ungated ranges", () => { const latestAnswer = "2018年9月搬到北京。"; const invented = plan({ evidenceProposals: [{ operation: "create", targetEventId: null, sourceSpan: "2020年工作", dateText: "2020年", proposedSummary: "开始工作", proposedDomain: "career", proposedEventKind: "career_change", proposedSubject: "self", proposedRelatedPerson: null, confidence: "low" }] }); assert.ok(validateRectificationTurnPlan({ plan: invented, dossier: dossier(), latestAnswer, phase: "evidence" }).issues.includes("evidence_source_not_in_latest_answer")); const unsafe = plan({ publicReply: { acknowledgement: "内部 eventId 是 00000000-0000-4000-8000-000000000799。", candidateCommentary: "出生时间是05:13。", limitation: null } }); const unsafeIssues = validateRectificationTurnPlan({ plan: unsafe, dossier: dossier(), latestAnswer, phase: "final" }).issues; assert.ok(unsafeIssues.includes("private_detail_exposed")); assert.ok(unsafeIssues.includes("exact_minute_claimed")); const range = plan({ action: { type: "offer_candidate_range", snapshotId: "00000000-0000-4000-8000-000000000704" } }); assert.ok(validateRectificationTurnPlan({ plan: range, dossier: dossier(), latestAnswer, phase: "final" }).issues.includes("candidate_range_gate_failed")); }); test("revisions keep the server-owned event id and append revision history", () => { const target = event({ eventId: "00000000-0000-4000-8000-000000000705" }); const rawText = "其实是2016年10月大学入学。"; const staged = stageAgentEvidenceProposals({ caseId, rawText, sourceTurnId: randomUUID(), asOfDate: "2026-07-30", existing: [target], proposals: [{ operation: "revise_date", targetEventId: target.eventId, sourceSpan: "2016年10月", dateText: "2016年10月", proposedSummary: "2016年10月大学入学", proposedDomain: "education", proposedEventKind: "education_milestone", proposedSubject: "self", proposedRelatedPerson: null, confidence: "high" }], now: new Date(now), }); assert.equal(staged.revisions.length, 1); assert.equal(staged.revisions[0]?.eventId, target.eventId); assert.equal(staged.revisions[0]?.revision, 2); assert.equal(staged.revisions[0]?.dateRange.start, "2016-10-01"); assert.equal(staged.revisions[0]?.dateRange.end, "2016-10-31"); assert.equal(staged.revisions[0]?.summary, target.summary); }); test("date revisions ignore model attempts to replace event identity", () => { const target = event({ eventId: "00000000-0000-4000-8000-000000000708" }); const rawText = "2018年9月搬到北京。"; const staged = stageAgentEvidenceProposals({ caseId, rawText, sourceTurnId: randomUUID(), asOfDate: "2026-07-30", existing: [target], proposals: [{ operation: "revise_date", targetEventId: target.eventId, sourceSpan: "2018年9月", dateText: "2018年9月", proposedSummary: "2018年9月创办公司", proposedDomain: "relocation", proposedEventKind: "relocation", proposedSubject: "self", proposedRelatedPerson: null, confidence: "high" }], now: new Date(now), }); assert.equal(staged.revisions.length, 1); assert.equal(staged.pending.length, 0); assert.equal(staged.revisions[0]?.eventId, target.eventId); assert.equal(staged.revisions[0]?.domain, target.domain); assert.equal(staged.revisions[0]?.eventKind, target.eventKind); assert.equal(staged.revisions[0]?.subject, target.subject); assert.equal(staged.revisions[0]?.summary, target.summary); }); test("server-closed target dispositions cannot be overwritten by the Director", () => { const target = event(); const pending: PendingEvidence = { id: randomUUID(), caseId, turnId: randomUUID(), rawText: "不想说了,换一个。", reasonCode: "event_unparsed", targetEventId: target.eventId, resolvedEventId: null, createdAt: now, resolvedAt: null, }; for (const targetDisposition of ["unknown", "declined", "direction_change"] as const) { const merged = mergeDirectorReconciliation({ server: { revisions: [], pending: [], unansweredTargetEventId: null, targetDisposition }, staged: { revisions: [], pending: [pending], unansweredTargetEventId: target.eventId, targetDisposition: "unresolved" }, proposedDisposition: "unresolved", currentTargetEventId: target.eventId, }); assert.equal(merged.targetDisposition, targetDisposition); assert.equal(merged.unansweredTargetEventId, null); assert.deepEqual(merged.pending, []); } }); test("target date-only replies inherit identity and missing year", () => { const target = event(); const cases = [ ["2020年4月", "2020-04-01", "2020-04-30"], ["4月", "2016-04-01", "2016-04-30"], ["8月8号", "2016-08-08", "2016-08-08"], ["上半年", "2016-01-01", "2016-06-30"], ["10月至12月", "2016-10-01", "2016-12-31"], ] as const; for (const [rawText, start, end] of cases) { const staged = stageAgentEvidenceProposals({ caseId, rawText, sourceTurnId: randomUUID(), asOfDate: "2026-07-30", existing: [target], proposals: [{ operation: "revise_date", targetEventId: target.eventId, sourceSpan: rawText, dateText: rawText, proposedSummary: "模型不得改写", proposedDomain: "other", proposedEventKind: "other", proposedSubject: "family", proposedRelatedPerson: "mother", confidence: "high" }], now: new Date(now), }); assert.equal(staged.pending.length, 0, rawText); assert.equal(staged.revisions.length, 1, rawText); assert.equal(staged.revisions[0]?.eventId, target.eventId, rawText); assert.equal(staged.revisions[0]?.domain, target.domain, rawText); assert.equal(staged.revisions[0]?.eventKind, target.eventKind, rawText); assert.equal(staged.revisions[0]?.subject, target.subject, rawText); assert.equal(staged.revisions[0]?.summary, target.summary, rawText); assert.equal(staged.revisions[0]?.dateRange.start, start, rawText); assert.equal(staged.revisions[0]?.dateRange.end, end, rawText); } }); test("explicit event-kind correction appends a revision to the same event", () => { const target = event({ domain: "relationship", eventKind: "relationship_start", subject: "self", relatedPerson: "partner", summary: "关系开始", rawText: "2020年关系开始" }); const rawText = "不是关系开始,是分手"; const staged = stageAgentEvidenceProposals({ caseId, rawText, sourceTurnId: randomUUID(), asOfDate: "2026-07-30", existing: [target], proposals: [{ operation: "reclassify", targetEventId: target.eventId, sourceSpan: rawText, dateText: null, proposedSummary: "分手", proposedDomain: "relationship", proposedEventKind: "relationship_end", proposedSubject: "self", proposedRelatedPerson: "partner", confidence: "high" }], now: new Date(now), }); assert.equal(staged.pending.length, 0); assert.equal(staged.revisions[0]?.eventId, target.eventId); assert.equal(staged.revisions[0]?.revision, 2); assert.equal(staged.revisions[0]?.eventKind, "relationship_end"); }); test("explicit subject correction is grounded and enters pending review", () => { const target = event({ domain: "health_pressure", eventKind: "self_health_event", subject: "self", relatedPerson: null, summary: "手术", rawText: "2020年我做了手术" }); const rawText = "这次是母亲手术,不是我"; const staged = stageAgentEvidenceProposals({ caseId, rawText, sourceTurnId: randomUUID(), asOfDate: "2026-07-30", existing: [target], proposals: [{ operation: "reclassify", targetEventId: target.eventId, sourceSpan: rawText, dateText: null, proposedSummary: "母亲手术", proposedDomain: "family", proposedEventKind: "family_health_event", proposedSubject: "family", proposedRelatedPerson: "mother", confidence: "high" }], now: new Date(now), }); assert.equal(staged.pending.length, 0); assert.equal(staged.revisions[0]?.eventId, target.eventId); assert.equal(staged.revisions[0]?.subject, "family"); assert.equal(staged.revisions[0]?.relatedPerson, "mother"); assert.equal(staged.revisions[0]?.scoreability, "pending_review"); }); test("declined targets cannot be reopened and diagnostics stay in a bounded tool loop", async () => { const target = event({ eventId: "00000000-0000-4000-8000-000000000706" }); const targetDossier = buildRectificationCaseDossier({ caseValue, turns: [], events: [target], snapshot: null, diagnostics: null, targetDisposition: "declined", currentTargetEventId: target.eventId }); const reopened = plan({ targetDisposition: "declined", action: { type: "ask_question", focus: { mode: "clarify_existing_event", targetEventId: target.eventId, domain: target.domain, requestedFacts: ["month"], rationaleCodes: ["retry"] }, question: "再说说那件事?", optionalQuickReplies: [] } }); assert.ok(validateRectificationTurnPlan({ plan: reopened, dossier: targetDossier, latestAnswer: "不想说", phase: "final" }).issues.includes("declined_target_reopened")); const reopenedWithoutId = plan({ targetDisposition: "declined", action: { type: "ask_question", focus: { mode: "clarify_existing_event", targetEventId: null, domain: target.domain, requestedFacts: ["month"], rationaleCodes: ["retry"] }, question: "再说说那件事?", optionalQuickReplies: [] } }); assert.ok(validateRectificationTurnPlan({ plan: reopenedWithoutId, dossier: targetDossier, latestAnswer: "不想说", phase: "final" }).issues.includes("declined_target_reopened")); const phases: string[] = []; const prompts: string[] = []; const result = await runRectificationDirector({ caseValue, dossier: dossier(), latestAnswer: "", phase: "final", diagnostics, generatePlan: async (prompt, phase) => { phases.push(phase); prompts.push(prompt); return { object: phases.length <= 2 ? plan({ action: { type: "request_diagnostic", diagnostic: phases.length === 1 ? "candidate_split" : "date_sensitivity" } }) : plan() }; }, }); assert.equal(result.mode, "agent"); assert.deepEqual(phases, ["final", "after_diagnostic", "after_diagnostic"]); assert.equal(result.toolCalls.length, 2); assert.deepEqual(JSON.parse(prompts[2]!).diagnosticResults.map((item: { diagnostic: string }) => item.diagnostic), ["candidate_split", "date_sensitivity"]); assert.equal(result.plan.action.type, "ask_question"); }); test("the same Director gets one repair attempt before deterministic fallback", async () => { const phases: string[] = []; const result = await runRectificationDirector({ caseValue, dossier: dossier(), latestAnswer: "2018年9月搬到北京。", phase: "evidence", diagnostics, generatePlan: async (_prompt, phase) => { phases.push(phase); return { object: phase === "repair" ? plan() : plan({ evidenceProposals: [{ operation: "create", targetEventId: null, sourceSpan: "不存在的内容", dateText: "2020年", proposedSummary: "开始工作", proposedDomain: "career", proposedEventKind: "career_change", proposedSubject: "self", proposedRelatedPerson: null, confidence: "low" }] }) }; }, }); assert.equal(result.mode, "agent"); assert.deepEqual(phases, ["evidence", "repair"]); assert.equal(result.fallbackReason, null); }); test("manual question regeneration preserves focus and repairs unsafe text once", async () => { const phases: string[] = []; const question = await regenerateDirectorQuestion({ caseValue, currentQuestion: "除了这段经历,你还想从哪件事继续?", latestAnswer: "2016年9月大学入学", acceptedEvents: [event()], focus: { mode: "collect_independent_event", targetEventId: null, domain: null, requestedFacts: ["independent_event"], rationaleCodes: ["need_independent_event"], }, generateQuestion: async (_prompt, phase) => { phases.push(phase); return { object: { question: phase === "regenerate" ? "请确认出生时间05:13?" : "除了这段经历,你还想从哪件事继续?" } }; }, }); assert.equal(question, "除了这段经历,你还想从哪件事继续?"); assert.deepEqual(phases, ["regenerate", "repair"]); }); test("public reply rejects technique names and multiple independent questions without rejecting one natural question", () => { for (const technique of ["D2", "D4", "D9", "D10", "D11", "D24", "D30", "KP", "Vimshottari", "Narayana", "Shadbala", "Ashtakavarga"]) { const issues = validateRectificationTurnPlan({ plan: plan({ publicReply: { acknowledgement: `${technique} 更支持这段经历。`, candidateCommentary: null, limitation: null } }), dossier: dossier(), latestAnswer: "", phase: "final", }).issues; assert.ok(issues.includes("private_detail_exposed"), technique); } const multiple = plan({ action: { type: "ask_question", focus: { mode: "collect_independent_event", targetEventId: null, domain: null, requestedFacts: ["independent_event"], rationaleCodes: ["test"] }, question: "你记得它发生在哪一年。那时发生了什么。", optionalQuickReplies: [] } }); assert.ok(validateRectificationTurnPlan({ plan: multiple, dossier: dossier(), latestAnswer: "", phase: "final" }).issues.includes("multiple_questions")); const single = plan({ action: { type: "ask_question", focus: { mode: "collect_independent_event", targetEventId: null, domain: null, requestedFacts: ["independent_event"], rationaleCodes: ["test"] }, question: "你还记得一件时间大致确定的重要经历吗?", optionalQuickReplies: [] } }); assert.deepEqual(validateRectificationTurnPlan({ plan: single, dossier: dossier(), latestAnswer: "", phase: "final" }).issues, []); }); test("candidate range requires the current approved snapshot id", () => { const snapshot: CandidateSnapshot = { id: diagnostics.snapshotId, caseId, caseVersion: 3, evidenceSetHash: "e".repeat(64), calculationSpecHash: "c".repeat(64), algorithmVersion: "rectification-v5-matrix-scoring-1", candidates: [{ time: "05:12", score: 10, supportingEventIds: [], conflictingEventIds: [] }], clusters: [{ rank: 1, startTime: "05:12", endTime: "05:18", representativeTime: "05:13", widthMinutes: 7, peakScore: 10, scoreMass: 1 }], robustness: { neighborSupportMinutes: 7, leaveOneOutRetentionRate: .8, leaveOneDomainOutRetentionRate: .8, dateSensitivityRetentionRate: .8, calculationSpecHashMatched: true }, canConfirmExactMinute: false, canAcceptRange: true, gateReasons: [], createdAt: now, }; const approvedDossier = buildRectificationCaseDossier({ caseValue, turns: [], events: [], snapshot, diagnostics, targetDisposition: "not_applicable", currentTargetEventId: null }); const accepted = plan({ action: { type: "offer_candidate_range", snapshotId: snapshot.id } }); assert.deepEqual(validateRectificationTurnPlan({ plan: accepted, dossier: approvedDossier, latestAnswer: "", phase: "final" }).issues, []); const wrongSnapshot = plan({ action: { type: "offer_candidate_range", snapshotId: randomUUID() } }); assert.ok(validateRectificationTurnPlan({ plan: wrongSnapshot, dossier: approvedDossier, latestAnswer: "", phase: "final" }).issues.includes("candidate_range_gate_failed")); const legacyRelationshipEnd = event({ domain: "relationship", eventKind: "relationship_end", scoreability: "scoreable" }); const staleDossier = buildRectificationCaseDossier({ caseValue, turns: [], events: [legacyRelationshipEnd], snapshot, diagnostics, targetDisposition: "not_applicable", currentTargetEventId: null }); assert.equal(staleDossier.candidateState.publicRangeAllowed, false); assert.ok(validateRectificationTurnPlan({ plan: accepted, dossier: staleDossier, latestAnswer: "", phase: "final" }).issues.includes("candidate_range_gate_failed")); });