Files
Jyotisha/frontend/tests/rectification-director.test.ts
T

274 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 type { CalculationSpec, LifeEventRevision, 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> = {}): 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): RectificationV4Turn {
return {
id: randomUUID(),
caseId,
caseVersion: index + 1,
questionId: null,
questionDomain: null,
questionTargetEventId: null,
question: `问题${index}`,
answer: `回答${index}`,
modelId: null,
actionId: randomUUID(),
createdAt: now,
};
}
function plan(overrides: Partial<RectificationTurnPlan> = {}): 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[] = []) {
return buildRectificationCaseDossier({
caseValue,
turns,
events,
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 twelve raw turns 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 value = dossier(events, Array.from({ length: 14 }, (_, index) => turn(index)));
assert.equal(value.conversation.recentRawTurns.length, 12);
assert.equal(value.conversation.recentRawTurns[0]?.question, "问题2");
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", 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");
});
test("declined targets cannot be reopened and a diagnostic is closed in one 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 phases: string[] = [];
const result = await runRectificationDirector({
caseValue,
dossier: dossier(),
latestAnswer: "",
phase: "final",
diagnostics,
generatePlan: async (_prompt, phase) => {
phases.push(phase);
return { object: phase === "final" ? plan({ action: { type: "request_diagnostic", diagnostic: "candidate_split" } }) : plan() };
},
});
assert.equal(result.mode, "agent");
assert.deepEqual(phases, ["final", "after_diagnostic"]);
assert.equal(result.toolCalls.length, 1);
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"]);
});