640 lines
25 KiB
TypeScript
640 lines
25 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,
|
||
CandidateMinute,
|
||
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 { passingVedAstroValidation, v5EngineResult, withV5Mode } from "./rectification-v5-test-support.ts";
|
||
|
||
function createTestCaseService(
|
||
store: Parameters<typeof createRectificationV4CaseService>[0],
|
||
options: Parameters<typeof createRectificationV4CaseService>[1] = {},
|
||
) {
|
||
return createRectificationV4CaseService(store, {
|
||
generateOpeningQuestion: async ({ candidateRange }) =>
|
||
`Agent 将在 ${candidateRange.start}–${candidateRange.end} 的待核对范围内陪你梳理;这并不是已确认的出生分钟。你愿意先说一段自己记得比较清楚的人生经历吗?`,
|
||
...options,
|
||
});
|
||
}
|
||
|
||
const now = "2026-07-29T00:00:00.000Z";
|
||
const rangeReadyCandidates: CandidateMinute[] = [
|
||
{ time: "05:13", score: 100, supportingEventIds: [], conflictingEventIds: [] },
|
||
{ time: "05:14", score: 99, supportingEventIds: [], conflictingEventIds: [] },
|
||
{ time: "05:15", score: 98, supportingEventIds: [], conflictingEventIds: [] },
|
||
{ time: "05:16", score: 60, supportingEventIds: [], conflictingEventIds: [] },
|
||
{ time: "05:17", score: 97.8, supportingEventIds: [], conflictingEventIds: [] },
|
||
{ time: "05:18", score: 97.7, supportingEventIds: [], conflictingEventIds: [] },
|
||
{ time: "05:19", score: 97.6, supportingEventIds: [], conflictingEventIds: [] },
|
||
];
|
||
|
||
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,
|
||
pendingEvidence: [],
|
||
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.equal(
|
||
trace.stages.some((stage) => stage.phase === "rendering" && stage.status === "completed"),
|
||
true,
|
||
);
|
||
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, rangeReadyCandidates);
|
||
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("VedAstro post-validation runs only after the local range gate and blocks publication on safe failure", async () => {
|
||
const claimed = makeClaimed([
|
||
event("education", "education_milestone", "离家去外地上大学", "2016-09"),
|
||
event("relocation", "relocation", "搬到北京长期居住", "2018-08"),
|
||
event("career", "career_change", "开始负责商业巡演公司", "2023-09"),
|
||
event("relationship", "relationship_start", "开始一段长期关系", "2021-05"),
|
||
event("finance", "finance_change", "收入结构发生明显变化", "2024-02"),
|
||
]);
|
||
let validationCalls = 0;
|
||
const blocked = await processRectificationAgentTurn({
|
||
claimed,
|
||
engine: {
|
||
score: async ({ calculationSpec, events }) => v5EngineResult(calculationSpec, events, rangeReadyCandidates),
|
||
validateWithVedAstro: async ({ candidateTimes }) => {
|
||
validationCalls += 1;
|
||
assert.deepEqual(candidateTimes, ["05:13", "05:14"]);
|
||
return passingVedAstroValidation(candidateTimes, {
|
||
status: "blocked",
|
||
providerStatus: "timeout",
|
||
blockers: ["vedastro_timeout"],
|
||
minuteSensitiveValidation: { comparisonReady: false, discriminated: false, discriminatedLayers: [] },
|
||
});
|
||
},
|
||
},
|
||
now: new Date(now),
|
||
});
|
||
assert.equal(validationCalls, 1);
|
||
assert.equal(blocked.snapshot?.canAcceptRange, false);
|
||
assert.equal(blocked.snapshot?.canConfirmExactMinute, false);
|
||
assert.ok(blocked.snapshot?.gateReasons.includes("vedastro_validation_not_passed"));
|
||
assert.equal(blocked.diagnostics?.externalValidation?.status, "blocked");
|
||
assert.deepEqual(analysisToolLabels(blocked.publicMessage.analysisTrace!, "diagnostic"), ["VedAstro 事后校验"]);
|
||
|
||
validationCalls = 0;
|
||
const localGateBlocked = await processRectificationAgentTurn({
|
||
claimed,
|
||
engine: {
|
||
score: async ({ calculationSpec, events }) => {
|
||
const result = v5EngineResult(calculationSpec, events, rangeReadyCandidates);
|
||
return { ...result, robustness: { ...result.robustness, leaveOneDomainOutRetentionRate: 0 } };
|
||
},
|
||
validateWithVedAstro: async ({ candidateTimes }) => {
|
||
validationCalls += 1;
|
||
return passingVedAstroValidation(candidateTimes);
|
||
},
|
||
},
|
||
now: new Date(now),
|
||
});
|
||
assert.equal(validationCalls, 0);
|
||
assert.equal(localGateBlocked.snapshot?.canAcceptRange, false);
|
||
assert.equal(localGateBlocked.diagnostics?.externalValidation, undefined);
|
||
});
|
||
|
||
test("legacy and shadow modes never call VedAstro post-validation", async () => {
|
||
const events = [
|
||
event("education", "education_milestone", "离家去外地上大学", "2016-09"),
|
||
event("relocation", "relocation", "搬到北京长期居住", "2018-08"),
|
||
event("career", "career_change", "开始负责商业巡演公司", "2023-09"),
|
||
event("relationship", "relationship_start", "开始一段长期关系", "2021-05"),
|
||
event("finance", "finance_change", "收入结构发生明显变化", "2024-02"),
|
||
];
|
||
for (const deploymentMode of ["v4_legacy", "v5_shadow"] as const) {
|
||
const base = makeClaimed(events);
|
||
let validationCalls = 0;
|
||
const result = await processRectificationAgentTurn({
|
||
claimed: { ...base, case: { ...base.case, deploymentMode } },
|
||
engine: {
|
||
score: async ({ calculationSpec, events: scoreEvents }) => v5EngineResult(calculationSpec, scoreEvents),
|
||
validateWithVedAstro: async ({ candidateTimes }) => {
|
||
validationCalls += 1;
|
||
return passingVedAstroValidation(candidateTimes);
|
||
},
|
||
},
|
||
now: new Date(now),
|
||
});
|
||
assert.equal(validationCalls, 0, deploymentMode);
|
||
assert.equal(result.diagnostics?.externalValidation, undefined, deploymentMode);
|
||
assert.equal(result.snapshot?.canConfirmExactMinute, false, deploymentMode);
|
||
}
|
||
});
|
||
|
||
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 = createTestCaseService(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 }]), []);
|
||
});
|
||
|
||
test("a newly collected year-only event stays current until its month is refined", async () => {
|
||
const base = makeClaimed([]);
|
||
const turn: RectificationV4Turn = {
|
||
...base.turn,
|
||
question: "请先说一段自己记得比较清楚的人生经历。",
|
||
answer: "2016 年离家去外地上大学",
|
||
};
|
||
const result = await processRectificationAgentTurn({
|
||
claimed: { ...base, turn, turns: [turn] },
|
||
engine: { score: async () => { throw new Error("candidate_engine_should_not_run"); } },
|
||
now: new Date(now),
|
||
});
|
||
|
||
assert.equal(result.newEventRevisions.length, 1);
|
||
const created = result.newEventRevisions[0]!;
|
||
assert.equal(created.dateRange.precision, "year");
|
||
assert.equal(result.nextQuestion?.targetEventId, created.eventId);
|
||
assert.match(result.nextQuestion?.prompt ?? "", /离家去外地上大学/);
|
||
assert.match(result.nextQuestion?.prompt ?? "", /交叉核对/);
|
||
assert.match(result.nextQuestion?.prompt ?? "", /哪个月|时间段/);
|
||
assert.doesNotMatch(result.nextQuestion?.prompt ?? "", /还能想到一件/);
|
||
});
|
||
|
||
test("a month-only answer closes the targeted event even when the Agent is unavailable", async () => {
|
||
const university: LifeEventRevision = {
|
||
...event("education", "education_milestone", "离家去外地上大学", "2016-01"),
|
||
dateRange: { start: "2016-01-01", end: "2016-12-31", precision: "year", label: "2016年" },
|
||
};
|
||
const base = makeClaimed([university]);
|
||
const earlierTurn: RectificationV4Turn = {
|
||
...base.turn,
|
||
id: randomUUID(),
|
||
caseVersion: 1,
|
||
questionDomain: "education",
|
||
questionTargetEventId: null,
|
||
question: "请先说一段自己记得比较清楚的人生经历。",
|
||
answer: "2016 年离家去外地上大学",
|
||
};
|
||
const turn: RectificationV4Turn = {
|
||
...base.turn,
|
||
id: randomUUID(),
|
||
caseVersion: 2,
|
||
questionDomain: "education",
|
||
questionTargetEventId: university.eventId,
|
||
question: "你还记得“离家去外地上大学”大概发生在哪个月,或一年中的哪个时间段吗?",
|
||
answer: "9 月",
|
||
};
|
||
const result = await processRectificationAgentTurn({
|
||
claimed: { ...base, turn, turns: [earlierTurn, turn] },
|
||
engine: { score: async () => { throw new Error("candidate_engine_should_not_run"); } },
|
||
generateDirectorPlan: async () => { throw new Error("forced_agent_unavailable"); },
|
||
now: new Date(now),
|
||
});
|
||
|
||
const revision = result.newEventRevisions[0];
|
||
assert.equal(revision?.eventId, university.eventId);
|
||
assert.equal(revision?.revision, 2);
|
||
assert.deepEqual(revision?.dateRange, { start: "2016-09-01", end: "2016-09-30", precision: "month", label: "9 月" });
|
||
assert.deepEqual(result.pendingEvidence, []);
|
||
assert.equal(result.status, "awaiting_answer");
|
||
assert.notEqual(result.nextQuestion?.targetEventId, university.eventId);
|
||
assert.doesNotMatch(result.nextQuestion?.prompt ?? "", /哪个月|一年中的哪个时间段/);
|
||
assert.doesNotMatch(result.nextQuestion?.prompt ?? "", /当前无法安全生成新的不重复问题|先暂停在这里/);
|
||
});
|
||
|
||
test("a completed internship answer is acknowledged, explained, and followed by one contrast-driven domain question", async () => {
|
||
const university = event("education", "education_milestone", "离家去外地上大学", "2016-09");
|
||
const base = makeClaimed([university]);
|
||
const earlierTurn: RectificationV4Turn = {
|
||
...base.turn,
|
||
id: randomUUID(),
|
||
caseVersion: 1,
|
||
questionDomain: "education",
|
||
question: "请说一件时间比较确定的经历。",
|
||
answer: university.rawText,
|
||
};
|
||
const turn: RectificationV4Turn = {
|
||
...base.turn,
|
||
id: randomUUID(),
|
||
caseVersion: 2,
|
||
questionDomain: "career",
|
||
question: "有没有一件工作状态明显变化的经历?",
|
||
answer: "2020 年 4 月去石油化工研究院实习做研究员",
|
||
};
|
||
const result = await processRectificationAgentTurn({
|
||
claimed: { ...base, turn, turns: [earlierTurn, turn] },
|
||
engine: { score: async () => { throw new Error("candidate_engine_should_not_run"); } },
|
||
now: new Date(now),
|
||
});
|
||
|
||
assert.equal(result.newEventRevisions.at(-1)?.domain, "career");
|
||
assert.notEqual(result.nextQuestion?.domain, "education");
|
||
assert.notEqual(result.nextQuestion?.domain, "career");
|
||
assert.match(result.nextQuestion?.prompt ?? "", /石油化工研究院|实习|研究员/);
|
||
assert.match(result.nextQuestion?.prompt ?? "", /交叉核对/);
|
||
assert.doesNotMatch(result.nextQuestion?.prompt ?? "", /你还能想到一件发生在你本人身上、时间大致确定的重要经历吗/);
|
||
assert.equal((result.nextQuestion?.prompt.match(/[??]/g) ?? []).length, 1);
|
||
});
|