433 lines
15 KiB
TypeScript
433 lines
15 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { randomUUID } from "node:crypto";
|
|
import test from "node:test";
|
|
|
|
import {
|
|
diagnosticsSummarySchema,
|
|
storedPublicMessageSchema,
|
|
type DiagnosticsSummary,
|
|
type QuestionOpportunity,
|
|
} from "../src/lib/rectification-agent/contracts.ts";
|
|
import { processRectificationAgentTurn } from "../src/lib/rectification-agent/orchestrator.ts";
|
|
import { runBoundedReasoner, sanitizeReasoningSummary } from "../src/lib/rectification-agent/reasoner-agent.ts";
|
|
import { createRectificationV4CaseService } from "../src/lib/rectification-v4/case-service.ts";
|
|
import type { CandidateEngineResult } from "../src/lib/rectification-v4/candidate-engine.ts";
|
|
import type {
|
|
CalculationSpec,
|
|
LifeEventRevision,
|
|
RectificationAnalysisTrace,
|
|
RectificationV4Case,
|
|
RectificationV4Turn,
|
|
} from "../src/lib/rectification-v4/contracts.ts";
|
|
import { calculationSpecHash } from "../src/lib/rectification-v4/fingerprints.ts";
|
|
import { createRectificationV4MemoryStore } from "../src/lib/rectification-v4/memory-store.ts";
|
|
import { projectAnalysisMessages } from "../src/lib/rectification-v4/supabase-store.ts";
|
|
import type { ClaimedRectificationV4Job } from "../src/lib/rectification-v4/store.ts";
|
|
import { createRectificationV4Worker } from "../src/lib/rectification-v4/worker.ts";
|
|
import { v5EngineResult, withV5Mode } from "./rectification-v5-test-support.ts";
|
|
|
|
const now = "2026-07-29T00:00:00.000Z";
|
|
const spec: CalculationSpec = {
|
|
version: "rectification-calculation-spec-v4",
|
|
birthDate: "1997-08-08",
|
|
candidateRange: { start: "05:00", end: "06:00" },
|
|
latitude: 36.419,
|
|
longitude: 114.213,
|
|
timezoneOffsetHours: 8,
|
|
ayanamsa: "lahiri",
|
|
nodeMode: "mean",
|
|
minuteStep: 1,
|
|
};
|
|
|
|
function event(
|
|
domain: LifeEventRevision["domain"],
|
|
eventKind: LifeEventRevision["eventKind"],
|
|
summary: string,
|
|
date: string,
|
|
): LifeEventRevision {
|
|
return {
|
|
id: randomUUID(),
|
|
eventId: randomUUID(),
|
|
revision: 1,
|
|
domain,
|
|
eventKind,
|
|
subject: "self",
|
|
relatedPerson: null,
|
|
summary,
|
|
rawText: `${date} ${summary}`,
|
|
dateRange: { start: `${date}-01`, end: `${date}-28`, precision: "month", label: date },
|
|
scoreability: "scoreable",
|
|
supersedesRevisionId: null,
|
|
createdAt: now,
|
|
};
|
|
}
|
|
|
|
function makeClaimed(events: readonly LifeEventRevision[]): ClaimedRectificationV4Job {
|
|
const caseId = randomUUID();
|
|
const turn: RectificationV4Turn = {
|
|
id: randomUUID(),
|
|
caseId,
|
|
caseVersion: 1,
|
|
questionId: randomUUID(),
|
|
questionDomain: "other",
|
|
questionTargetEventId: null,
|
|
question: "请换一个方向,补充一件时间较清楚的经历。",
|
|
answer: "记不清了,换一个吧。",
|
|
modelId: null,
|
|
actionId: randomUUID(),
|
|
createdAt: now,
|
|
};
|
|
const caseValue: RectificationV4Case = {
|
|
id: caseId,
|
|
userId: randomUUID(),
|
|
protocol: "rectification-evidence-v5",
|
|
version: 1,
|
|
status: "processing",
|
|
phase: "extracting_evidence",
|
|
calculationSpec: spec,
|
|
calculationSpecHash: calculationSpecHash(spec),
|
|
evidenceSetHash: "e".repeat(64),
|
|
currentQuestion: null,
|
|
latestSnapshot: null,
|
|
orchestrationModelId: null,
|
|
narrationModelId: null,
|
|
skillVersion: "birth-time-rectification-v6",
|
|
promptVersion: "rectification-agent-v6-1",
|
|
algorithmVersion: "rectification-v5-matrix-scoring-1",
|
|
deploymentMode: "v5_agent",
|
|
agentMode: "deterministic_fallback",
|
|
featureSnapshotId: null,
|
|
latestDiagnosticsId: null,
|
|
acceptedRange: null,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
return {
|
|
case: caseValue,
|
|
turn,
|
|
turns: [turn],
|
|
events,
|
|
attemptedRefinementEventIds: [],
|
|
job: {
|
|
id: randomUUID(),
|
|
caseId,
|
|
status: "processing",
|
|
phase: "extracting_evidence",
|
|
expectedCaseVersion: 1,
|
|
evidenceSetHash: caseValue.evidenceSetHash,
|
|
calculationSpecHash: caseValue.calculationSpecHash,
|
|
errorCode: null,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
},
|
|
};
|
|
}
|
|
|
|
const opportunity: QuestionOpportunity = {
|
|
contractVersion: "semantic-question-v2",
|
|
opportunityId: randomUUID(),
|
|
kind: "ask_new_event",
|
|
domain: "career",
|
|
targetEventId: null,
|
|
goal: "收集一件有大致日期的新经历。",
|
|
requestedFields: ["new_dated_event"],
|
|
anchors: [],
|
|
contextFacts: [],
|
|
forbiddenMoves: [
|
|
"switch_target_event",
|
|
"ask_multiple_questions",
|
|
"claim_exact_birth_minute",
|
|
"invent_event",
|
|
"invent_date",
|
|
"expose_private_score",
|
|
"expose_internal_id",
|
|
"expose_technique_trace",
|
|
],
|
|
fallbackPrompt: "请再说一件时间比较明确的经历。",
|
|
reason: "补充可区分的证据。",
|
|
expectedInformationGain: .8,
|
|
dateSensitivity: .5,
|
|
candidateSplitRelevance: .5,
|
|
domainCoverageGain: .8,
|
|
recallEase: .8,
|
|
novelty: 1,
|
|
repetitionPenalty: 0,
|
|
privacyCost: 0,
|
|
utility: .85,
|
|
active: true,
|
|
};
|
|
|
|
const diagnostics: DiagnosticsSummary = diagnosticsSummarySchema.parse({
|
|
id: randomUUID(),
|
|
caseId: randomUUID(),
|
|
snapshotId: randomUUID(),
|
|
primaryClusterRetentionRate: .8,
|
|
leaveOneEventOutRetentionRate: .8,
|
|
leaveOneDomainOutRetentionRate: .8,
|
|
dateSensitivityRetentionRate: .8,
|
|
neighborSupportMinutes: 3,
|
|
primarySecondaryMarginPercent: 12,
|
|
clusterMassRatio: .8,
|
|
unstableEventIds: [],
|
|
mostDiscriminatingLayers: [],
|
|
eventDateSensitivity: [],
|
|
candidateSplits: [],
|
|
calculationHash: "d".repeat(64),
|
|
createdAt: now,
|
|
});
|
|
|
|
function analysisToolLabels(trace: RectificationAnalysisTrace, category: RectificationAnalysisTrace["toolCalls"][number]["category"]): string[] {
|
|
return trace.toolCalls.filter((call) => call.category === category).map((call) => call.label);
|
|
}
|
|
|
|
test("provider reasoning keeps safe summaries and rejects private or copied content", async () => {
|
|
const safeSummary = "现有材料还不足,宜先补充一件时间较清楚的经历。";
|
|
const reasoned = await runBoundedReasoner({
|
|
caseValue: makeClaimed([]).case,
|
|
snapshot: null,
|
|
diagnostics,
|
|
opportunities: [opportunity],
|
|
generateDecision: async () => ({
|
|
object: { action: "ask_question", opportunityId: opportunity.opportunityId, narrativeFocus: ["uncertainty"] },
|
|
reasoningSummary: safeSummary,
|
|
reasoningSource: "provider_summary",
|
|
}),
|
|
});
|
|
assert.equal(reasoned.reasoningSummary, safeSummary);
|
|
|
|
const unsafe = [
|
|
"参考 00000000-0000-4000-8000-000000000901",
|
|
"候选是 05:13",
|
|
"score 较高",
|
|
"snapshotId 已更新",
|
|
"opportunityId 已选中",
|
|
"执行 tool call",
|
|
"采用 D9 继续判断",
|
|
"候选更接近清晨五点十三分",
|
|
"保留率低于百分之六十五",
|
|
];
|
|
for (const value of unsafe) assert.equal(sanitizeReasoningSummary(value), null, value);
|
|
|
|
const userText = "2016年9月离家去外地上大学";
|
|
assert.equal(sanitizeReasoningSummary(`用户提到${userText},所以继续。`, [userText]), null);
|
|
assert.equal(sanitizeReasoningSummary("母亲去世后需要继续收集证据。", ["母亲去世"]), null);
|
|
assert.equal(sanitizeReasoningSummary("癌症使这项证据需要谨慎处理。", ["癌症"]), null);
|
|
|
|
const unverifiedSource = await runBoundedReasoner({
|
|
caseValue: makeClaimed([]).case,
|
|
snapshot: null,
|
|
diagnostics,
|
|
opportunities: [opportunity],
|
|
generateDecision: async () => ({
|
|
object: { action: "ask_question", opportunityId: opportunity.opportunityId, narrativeFocus: [] },
|
|
reasoningSummary: safeSummary,
|
|
}),
|
|
});
|
|
assert.equal(unverifiedSource.reasoningSummary, null);
|
|
});
|
|
|
|
test("below the scoring gate does not claim candidate scanning, diagnostics, or techniques", async () => {
|
|
let scoreCalls = 0;
|
|
const result = await processRectificationAgentTurn({
|
|
claimed: makeClaimed([
|
|
event("education", "education_milestone", "离家去外地上大学", "2016-09"),
|
|
event("career", "career_change", "开始第一份工作", "2020-04"),
|
|
]),
|
|
engine: { score: async () => { scoreCalls += 1; throw new Error("candidate_engine_should_not_run"); } },
|
|
now: new Date(now),
|
|
});
|
|
const trace = result.publicMessage.analysisTrace;
|
|
assert.ok(trace);
|
|
assert.equal(scoreCalls, 0);
|
|
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.deepEqual(trace.toolCalls, []);
|
|
assert.deepEqual(trace.techniques, []);
|
|
});
|
|
|
|
test("scoring trace records one real engine call and only matrix-confirmed techniques", async () => {
|
|
const claimed = makeClaimed([
|
|
event("education", "education_milestone", "离家去外地上大学", "2016-09"),
|
|
event("relocation", "relocation", "搬到北京长期居住", "2018-08"),
|
|
event("career", "career_change", "开始负责商业巡演公司", "2023-09"),
|
|
]);
|
|
let scoreCalls = 0;
|
|
const result = await processRectificationAgentTurn({
|
|
claimed,
|
|
engine: {
|
|
score: async ({ calculationSpec, events }) => {
|
|
scoreCalls += 1;
|
|
const base = v5EngineResult(calculationSpec, events);
|
|
const contributionMatrix: CandidateEngineResult["contributionMatrix"] = Object.fromEntries(
|
|
Object.entries(base.contributionMatrix).map(([eventId, candidates]) => [
|
|
eventId,
|
|
Object.fromEntries(Object.entries(candidates).map(([time, contribution]) => [time, {
|
|
...contribution,
|
|
rule_ids: ["vimshottari_dasha", "D60"],
|
|
technique_layers: ["D2", "D60"],
|
|
}])),
|
|
]),
|
|
);
|
|
return { ...base, contributionMatrix };
|
|
},
|
|
},
|
|
now: new Date(now),
|
|
});
|
|
const trace = result.publicMessage.analysisTrace;
|
|
assert.ok(trace);
|
|
assert.equal(scoreCalls, 1);
|
|
assert.equal(result.snapshot?.canConfirmExactMinute, false);
|
|
assert.equal(trace.stages.some((stage) => stage.phase === "scoring_candidates"), true);
|
|
assert.equal(trace.stages.some((stage) => stage.phase === "checking_robustness"), true);
|
|
assert.deepEqual(analysisToolLabels(trace, "candidate_engine"), ["候选分钟扫描与稳定性诊断"]);
|
|
assert.deepEqual(analysisToolLabels(trace, "diagnostic"), []);
|
|
assert.deepEqual(new Set(trace.techniques), new Set(["Vimshottari Dasha", "D2"]));
|
|
assert.equal(trace.techniques.includes("D60"), false);
|
|
assert.equal(trace.techniques.includes("D9"), false);
|
|
assert.equal(trace.techniques.includes("D10"), false);
|
|
assert.deepEqual(analysisToolLabels(trace, "agent_diagnostic"), []);
|
|
});
|
|
|
|
test("read-only Agent diagnostics are traced only when the reasoner actually requests one", async () => {
|
|
const caseValue = makeClaimed([]).case;
|
|
const direct = await runBoundedReasoner({
|
|
caseValue,
|
|
snapshot: null,
|
|
diagnostics,
|
|
opportunities: [opportunity],
|
|
generateDecision: async () => ({ object: { action: "ask_question", opportunityId: opportunity.opportunityId, narrativeFocus: [] } }),
|
|
});
|
|
assert.deepEqual(direct.toolCalls, []);
|
|
|
|
const phases: string[] = [];
|
|
const diagnosticRun = await runBoundedReasoner({
|
|
caseValue,
|
|
snapshot: null,
|
|
diagnostics,
|
|
opportunities: [opportunity],
|
|
generateDecision: async (_prompt, phase) => {
|
|
phases.push(phase);
|
|
return phase === "initial"
|
|
? { object: { action: "run_diagnostic", diagnostic: "neighbor_stability" } }
|
|
: { object: { action: "ask_question", opportunityId: opportunity.opportunityId, narrativeFocus: ["uncertainty"] } };
|
|
},
|
|
});
|
|
assert.deepEqual(phases, ["initial", "after_diagnostic"]);
|
|
assert.deepEqual(diagnosticRun.toolCalls.map((call) => [call.tool, call.diagnostic, call.outcome]), [
|
|
["run_rectification_diagnostics", "neighbor_stability", "succeeded"],
|
|
]);
|
|
});
|
|
|
|
test("old public messages without analysisTrace remain readable and are omitted from trace history", async () => {
|
|
await withV5Mode("v5_agent", async () => {
|
|
const store = createRectificationV4MemoryStore();
|
|
const service = createRectificationV4CaseService(store, { now: () => new Date(now) });
|
|
const worker = createRectificationV4Worker({
|
|
store,
|
|
now: () => new Date(now),
|
|
engine: { score: async () => { throw new Error("candidate_engine_should_not_run"); } },
|
|
});
|
|
const userId = randomUUID();
|
|
const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
|
|
const queued = await service.answer({
|
|
userId,
|
|
caseId: created.case.id,
|
|
actionId: randomUUID(),
|
|
expectedCaseVersion: created.case.version,
|
|
answer: "2016年9月离家去外地上大学",
|
|
});
|
|
assert.ok(queued?.job);
|
|
assert.equal(await worker.runOnce(), true);
|
|
|
|
const legacyMessage = storedPublicMessageSchema.parse({
|
|
acknowledgement: "你提到的是离家去外地上大学。",
|
|
candidateUpdate: null,
|
|
limitation: null,
|
|
question: "请再说一件时间比较明确的经历。",
|
|
});
|
|
assert.equal(legacyMessage.analysisTrace, undefined);
|
|
store.publicMessages.set(queued.job.id, legacyMessage);
|
|
assert.deepEqual(await store.loadAnalysisMessages(userId, created.case.id), []);
|
|
});
|
|
});
|
|
|
|
|
|
function storedMessageWithTrace(trace: unknown): Record<string, unknown> {
|
|
return {
|
|
acknowledgement: "承接这段经历。",
|
|
candidateUpdate: null,
|
|
limitation: null,
|
|
question: "请再说一件时间比较明确的经历。",
|
|
analysisTrace: trace,
|
|
};
|
|
}
|
|
|
|
test("Supabase analysis projection orders multiple turns and maps job ids to turn ids", () => {
|
|
const earlierJobId = randomUUID();
|
|
const laterJobId = randomUUID();
|
|
const earlierTurnId = randomUUID();
|
|
const laterTurnId = randomUUID();
|
|
const earlierTrace: RectificationAnalysisTrace = {
|
|
status: "completed",
|
|
stages: [],
|
|
toolCalls: [],
|
|
techniques: ["D2"],
|
|
reasoningSummary: null,
|
|
reasoningSource: "none",
|
|
};
|
|
const laterTrace: RectificationAnalysisTrace = {
|
|
status: "completed",
|
|
stages: [],
|
|
toolCalls: [],
|
|
techniques: ["D4"],
|
|
reasoningSummary: null,
|
|
reasoningSource: "none",
|
|
};
|
|
|
|
const projected = projectAnalysisMessages([
|
|
{ job_id: laterJobId, message: storedMessageWithTrace(laterTrace), created_at: "2026-07-29T02:00:00.000Z" },
|
|
{ job_id: earlierJobId, message: storedMessageWithTrace(earlierTrace), created_at: "2026-07-29T01:00:00.000Z" },
|
|
], [
|
|
{ id: laterJobId, turn_id: laterTurnId },
|
|
{ id: earlierJobId, turn_id: earlierTurnId },
|
|
]);
|
|
|
|
assert.deepEqual(projected, [
|
|
{ sourceTurnId: earlierTurnId, trace: earlierTrace },
|
|
{ sourceTurnId: laterTurnId, trace: laterTrace },
|
|
]);
|
|
});
|
|
|
|
test("Supabase analysis projection ignores legacy messages without analysisTrace", () => {
|
|
const jobId = randomUUID();
|
|
const turnId = randomUUID();
|
|
assert.deepEqual(projectAnalysisMessages([{
|
|
job_id: jobId,
|
|
message: {
|
|
acknowledgement: "承接这段经历。",
|
|
candidateUpdate: null,
|
|
limitation: null,
|
|
question: "请再说一件时间比较明确的经历。",
|
|
},
|
|
created_at: "2026-07-29T01:00:00.000Z",
|
|
}], [{ id: jobId, turn_id: turnId }]), []);
|
|
});
|
|
|
|
test("Supabase analysis projection ignores invalid traces", () => {
|
|
const jobId = randomUUID();
|
|
const turnId = randomUUID();
|
|
assert.deepEqual(projectAnalysisMessages([{
|
|
job_id: jobId,
|
|
message: storedMessageWithTrace({
|
|
status: "invented",
|
|
stages: [],
|
|
toolCalls: [],
|
|
techniques: [],
|
|
reasoningSummary: null,
|
|
reasoningSource: "none",
|
|
}),
|
|
created_at: "2026-07-29T01:00:00.000Z",
|
|
}], [{ id: jobId, turn_id: turnId }]), []);
|
|
});
|