274 lines
12 KiB
TypeScript
274 lines
12 KiB
TypeScript
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"]);
|
||
});
|