refactor(rectification): let director agent own interview flow
This commit is contained in:
@@ -253,7 +253,10 @@ test("below the scoring gate does not claim candidate scanning, diagnostics, or
|
||||
assert.equal(result.snapshot, null);
|
||||
assert.equal(trace.stages.some((stage) => stage.phase === "scoring_candidates"), false);
|
||||
assert.equal(trace.stages.some((stage) => stage.phase === "checking_robustness"), false);
|
||||
assert.equal(trace.stages.some((stage) => /安全校验|服务器安全问题/.test(stage.label)), true);
|
||||
assert.equal(
|
||||
trace.stages.some((stage) => stage.phase === "rendering" && stage.status === "completed"),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(trace.toolCalls, []);
|
||||
assert.deepEqual(trace.techniques, []);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
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"]);
|
||||
});
|
||||
@@ -75,7 +75,7 @@ test("answer is durably queued and a processing case reload restores its active
|
||||
assert.equal(JSON.stringify([...store.jobs.values()]), before);
|
||||
}));
|
||||
|
||||
test("V5 agent fallback persists Agent Run, Public Message and a server-owned opportunity", async () => withMode("v5_agent", async () => {
|
||||
test("V5 agent fallback persists the Director decision, Public Message and next question", async () => withMode("v5_agent", async () => {
|
||||
const store = createRectificationV4MemoryStore();
|
||||
const service = createRectificationV4CaseService(store, { now: fixedNow });
|
||||
const userId = randomUUID();
|
||||
@@ -97,10 +97,10 @@ test("V5 agent fallback persists Agent Run, Public Message and a server-owned op
|
||||
assert.ok(event && run && message);
|
||||
assert.equal(run.deploymentMode, "v5_agent");
|
||||
assert.equal(run.validatedDecision.mode, "deterministic_fallback");
|
||||
assert.equal(run.validatedDecision.selectedOpportunity?.kind, "ask_new_event");
|
||||
assert.equal(run.validatedDecision.selectedOpportunity?.targetEventId, null);
|
||||
assert.equal(run.validatedDecision.decision.action, "ask_question");
|
||||
assert.equal(run.validatedDecision.selectedOpportunity, null);
|
||||
assert.equal(done?.case.currentQuestion?.targetEventId, null);
|
||||
assert.match(done?.case.currentQuestion?.prompt ?? "", /离家去外地上大学/);
|
||||
assert.ok((done?.case.currentQuestion?.prompt ?? "").length > 0);
|
||||
assert.doesNotMatch(done?.case.currentQuestion?.prompt ?? "", /具体哪一天|几号/);
|
||||
assert.equal(message.question, done?.case.currentQuestion?.prompt);
|
||||
assert.equal(done?.case.latestSnapshot, null);
|
||||
@@ -125,8 +125,8 @@ test("V5 shadow runs and persists V5 artifacts but keeps the legacy visible proj
|
||||
const run = [...store.agentRuns.values()][0];
|
||||
assert.ok(queued?.job && event && run);
|
||||
assert.equal(run.deploymentMode, "v5_shadow");
|
||||
assert.equal(run.validatedDecision.selectedOpportunity?.kind, "ask_new_event");
|
||||
assert.equal(run.validatedDecision.selectedOpportunity?.targetEventId, null);
|
||||
assert.equal(run.validatedDecision.decision.action, "ask_question");
|
||||
assert.equal(run.validatedDecision.selectedOpportunity, null);
|
||||
assert.equal(done.case.currentQuestion?.targetEventId, null);
|
||||
assert.match(done.case.currentQuestion?.reason ?? "", /V4 legacy projector/);
|
||||
assert.match(store.publicMessages.get(queued.job.id)?.acknowledgement ?? "", /我记下了/);
|
||||
@@ -181,10 +181,10 @@ test("V5 Agent regenerate rewrites only the current semantic question and replay
|
||||
let realizationCalls = 0;
|
||||
const service = createRectificationV4CaseService(store, {
|
||||
now: fixedNow,
|
||||
regenerateQuestion: async ({ opportunity }) => {
|
||||
regenerateDirectorQuestion: async ({ currentQuestion }) => {
|
||||
realizationCalls += 1;
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
return opportunity.fallbackPrompt;
|
||||
return `${currentQuestion}(换一种问法)`;
|
||||
},
|
||||
});
|
||||
const userId = randomUUID();
|
||||
|
||||
Reference in New Issue
Block a user