feat: persist rectification analysis traces
This commit is contained in:
@@ -4,6 +4,7 @@ import test from "node:test";
|
||||
import {
|
||||
canRegenerateRectificationMessage,
|
||||
rectificationV4ChatMessages,
|
||||
rectificationPhaseLabel,
|
||||
toggleRectificationFeedback,
|
||||
} from "../src/components/rectification-v4-panel.tsx";
|
||||
import type { RectificationV4ApiResponse } from "../src/lib/rectification-v4/contracts.ts";
|
||||
@@ -59,6 +60,7 @@ function response(overrides: Record<string, unknown> = {}): RectificationV4ApiRe
|
||||
},
|
||||
job: null,
|
||||
events: [],
|
||||
analysis: [],
|
||||
turns: [{
|
||||
id: "00000000-0000-4000-8000-000000000903",
|
||||
caseId: id,
|
||||
@@ -72,7 +74,28 @@ function response(overrides: Record<string, unknown> = {}): RectificationV4ApiRe
|
||||
actionId: "00000000-0000-4000-8000-000000000905",
|
||||
createdAt: now,
|
||||
}],
|
||||
};
|
||||
} as unknown as RectificationV4ApiResponse;
|
||||
}
|
||||
|
||||
function analysisTrace(label: string) {
|
||||
return {
|
||||
status: "completed",
|
||||
stages: [{
|
||||
phase: "extracting_evidence",
|
||||
label,
|
||||
status: "completed",
|
||||
durationMs: 320,
|
||||
}],
|
||||
toolCalls: [{
|
||||
category: "candidate_engine",
|
||||
label: "候选分钟扫描",
|
||||
outcome: "succeeded",
|
||||
durationMs: 840,
|
||||
}],
|
||||
techniques: ["Vimshottari Dasha", "D24"],
|
||||
reasoningSummary: "现有证据更适合继续收集另一件时间明确的经历。",
|
||||
reasoningSource: "provider_summary",
|
||||
} as const;
|
||||
}
|
||||
|
||||
test("v4 rectification reuses the ordinary session message list, composer, and model selector", () => {
|
||||
@@ -93,7 +116,14 @@ test("v4 rectification reuses the ordinary session message list, composer, and m
|
||||
assert.match(component, /<ModelSelector/);
|
||||
assert.match(component, /aria-label="赞"/);
|
||||
assert.match(component, /aria-label="踩"/);
|
||||
assert.match(component, /aria-label="复制回答"/);
|
||||
assert.match(component, /aria-label="重新生成回答"/);
|
||||
assert.match(component, /<details className="rectification-analysis">/);
|
||||
assert.match(component, /<span>分析过程<\/span>/);
|
||||
assert.match(
|
||||
component,
|
||||
/<RectificationAnalysisDetails trace=\{message\.analysisTrace\} \/>[\s\S]*?<div className="rectification-message-actions"/,
|
||||
);
|
||||
assert.match(component, /caseValue\?\.deploymentMode === "v5_agent"/);
|
||||
assert.match(component, /controller\.regenerate\(\)/);
|
||||
assert.match(component, /controller\.answer\(answer, props\.selectedModelId \|\| null\)/);
|
||||
@@ -151,10 +181,95 @@ test("turn history and the context-aware next question render as one chat timeli
|
||||
]);
|
||||
});
|
||||
|
||||
test("processing is an ordinary assistant thinking message after the saved answer", () => {
|
||||
const messages = rectificationV4ChatMessages(response({ currentQuestion: null, status: "processing" }), true);
|
||||
test("completed analysis is attached to the next Agent message by source turn", () => {
|
||||
const base = response();
|
||||
const firstTurn = base.turns[0]!;
|
||||
const secondTurn = {
|
||||
...firstTurn,
|
||||
id: "00000000-0000-4000-8000-000000000906",
|
||||
caseVersion: 2,
|
||||
questionId: "00000000-0000-4000-8000-000000000907",
|
||||
question: "承接复读后再次毕业,你还记得哪次职业变化的大概时间?",
|
||||
answer: "2020年4月去研究院实习。",
|
||||
actionId: "00000000-0000-4000-8000-000000000908",
|
||||
};
|
||||
const firstTrace = analysisTrace("整理第一轮经历");
|
||||
const secondTrace = analysisTrace("整理第二轮经历");
|
||||
const data = {
|
||||
...base,
|
||||
turns: [firstTurn, secondTurn],
|
||||
analysis: [
|
||||
{ sourceTurnId: firstTurn.id, trace: firstTrace },
|
||||
{ sourceTurnId: secondTurn.id, trace: secondTrace },
|
||||
],
|
||||
} as unknown as RectificationV4ApiResponse;
|
||||
|
||||
const assistantMessages = rectificationV4ChatMessages(data, false)
|
||||
.filter((message) => message.role === "assistant");
|
||||
|
||||
assert.equal(assistantMessages[0]?.analysisTrace, undefined);
|
||||
assert.equal(assistantMessages[1]?.analysisTrace, firstTrace);
|
||||
assert.equal(assistantMessages[2]?.analysisTrace, secondTrace);
|
||||
});
|
||||
|
||||
test("legacy and shadow modes do not expose persisted analysis traces", () => {
|
||||
const base = response();
|
||||
const trace = analysisTrace("不应显示");
|
||||
for (const deploymentMode of ["v4_legacy", "v5_shadow"] as const) {
|
||||
const data = {
|
||||
...base,
|
||||
case: { ...base.case, deploymentMode },
|
||||
analysis: [{ sourceTurnId: base.turns[0]!.id, trace }],
|
||||
} as unknown as RectificationV4ApiResponse;
|
||||
assert.equal(
|
||||
rectificationV4ChatMessages(data, false).some((message) => message.analysisTrace),
|
||||
false,
|
||||
deploymentMode,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("processing shows the current server job phase in Chinese", () => {
|
||||
const base = response({ currentQuestion: null, status: "processing", phase: "checking_robustness" });
|
||||
const data = {
|
||||
...base,
|
||||
job: {
|
||||
id: "00000000-0000-4000-8000-000000000909",
|
||||
caseId: id,
|
||||
status: "processing",
|
||||
phase: "checking_robustness",
|
||||
expectedCaseVersion: 2,
|
||||
evidenceSetHash: "b".repeat(64),
|
||||
calculationSpecHash: "a".repeat(64),
|
||||
errorCode: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
} as unknown as RectificationV4ApiResponse;
|
||||
const messages = rectificationV4ChatMessages(data, true);
|
||||
assert.equal(messages.at(-1)?.role, "assistant");
|
||||
assert.equal(messages.at(-1)?.state, "thinking");
|
||||
assert.equal(messages.at(-1)?.text, "正在检查候选范围的稳定性…");
|
||||
assert.equal(rectificationPhaseLabel("planning_question"), "正在选择下一条最有信息量的问题…");
|
||||
});
|
||||
|
||||
test("analysis details render only public labels and preserve the message action icons", () => {
|
||||
const component = readFileSync(new URL("../src/components/rectification-v4-panel.tsx", import.meta.url), "utf8");
|
||||
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
|
||||
assert.match(component, /\{stage\.label\}/);
|
||||
assert.match(component, /\{toolCall\.label\}/);
|
||||
assert.match(component, /trace\.techniques\.join\("、"\)/);
|
||||
assert.match(component, /trace\.reasoningSource === "provider_summary"/);
|
||||
assert.doesNotMatch(component, />\{stage\.phase\}</);
|
||||
assert.doesNotMatch(component, />\{toolCall\.category\}</);
|
||||
assert.doesNotMatch(component, />\{item\.sourceTurnId\}</);
|
||||
assert.match(component, /<ThumbsUp aria-hidden="true" \/>/);
|
||||
assert.match(component, /<ThumbsDown aria-hidden="true" \/>/);
|
||||
assert.match(component, /<Copy aria-hidden="true" \/>/);
|
||||
assert.match(component, /<RotateCcw aria-hidden="true" \/>/);
|
||||
assert.match(css, /\.rectification-analysis > summary/);
|
||||
assert.doesNotMatch(component, /candidateScore|contributionMatrix|opportunityId|snapshotId/);
|
||||
});
|
||||
|
||||
test("range messages never claim an exact confirmed birth minute", () => {
|
||||
|
||||
@@ -361,17 +361,27 @@ test("shadow mode persists V5 artifacts while preserving the legacy visible repl
|
||||
});
|
||||
assert.ok(queued?.job);
|
||||
await worker.runOnce();
|
||||
const loaded = await service.loadCase(userId, created.case.id);
|
||||
return {
|
||||
message: [...store.publicMessages.values()][0],
|
||||
question: (await service.loadCase(userId, created.case.id))?.case.currentQuestion,
|
||||
question: loaded?.case.currentQuestion,
|
||||
analysis: loaded?.analysis ?? [],
|
||||
agentRuns: store.agentRuns.size,
|
||||
};
|
||||
});
|
||||
}
|
||||
const legacy = await run("v4_legacy");
|
||||
const shadow = await run("v5_shadow");
|
||||
assert.deepEqual(shadow.message, legacy.message);
|
||||
const visibleMessage = (message: NonNullable<typeof legacy.message>) => ({
|
||||
acknowledgement: message.acknowledgement,
|
||||
candidateUpdate: message.candidateUpdate,
|
||||
limitation: message.limitation,
|
||||
question: message.question,
|
||||
});
|
||||
assert.deepEqual(visibleMessage(shadow.message!), visibleMessage(legacy.message!));
|
||||
assert.equal(shadow.question?.prompt, legacy.question?.prompt);
|
||||
assert.deepEqual(legacy.analysis, []);
|
||||
assert.deepEqual(shadow.analysis, []);
|
||||
assert.equal(shadow.agentRuns, 1);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
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 }]), []);
|
||||
});
|
||||
@@ -69,6 +69,12 @@ test("V5 completion is lease-bound, hash-bound, ownership-bound and replay-safe"
|
||||
assert.match(migration, /completion_payload_hash = p_completion_payload_hash/);
|
||||
});
|
||||
|
||||
test("V5 completion accepts and replays the full extensible public message JSON", () => {
|
||||
assert.match(migration, /jsonb_typeof\(p_public_message\) is distinct from 'object'/);
|
||||
assert.match(migration, /v_existing_message\.message is distinct from p_public_message/);
|
||||
assert.match(migration, /birth_time_rectification_public_messages[\s\S]*p_public_message/);
|
||||
});
|
||||
|
||||
test("V5 inserts use explicit columns and never mutate the profile birth minute", () => {
|
||||
for (const table of [
|
||||
"birth_time_rectification_v4_events",
|
||||
|
||||
Reference in New Issue
Block a user