From fdf791953e49dcb5ac77b0f16e5b026881dac073 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Fri, 31 Jul 2026 09:17:49 +0800 Subject: [PATCH] fix(rectification): generate opening with agent --- .../lib/rectification-agent/renderer-agent.ts | 68 ++++++++++++++++++- .../src/lib/rectification-v4/case-service.ts | 31 +++++++-- .../lib/rectification-v4/legacy-projector.ts | 4 +- .../lib/rectification-v4/opening-question.ts | 9 +-- frontend/tests/rectification-agent-v5.test.ts | 35 +++++++++- .../rectification-analysis-trace.test.ts | 13 +++- .../tests/rectification-v4-domain.test.ts | 10 ++- .../tests/rectification-v4-replay.test.ts | 13 +++- .../tests/rectification-v4-service.test.ts | 52 +++++++++++--- 9 files changed, 197 insertions(+), 38 deletions(-) diff --git a/frontend/src/lib/rectification-agent/renderer-agent.ts b/frontend/src/lib/rectification-agent/renderer-agent.ts index 2448218b..3e980f5f 100644 --- a/frontend/src/lib/rectification-agent/renderer-agent.ts +++ b/frontend/src/lib/rectification-agent/renderer-agent.ts @@ -26,6 +26,10 @@ const newEventDomainTerms: Readonly Promise>; function agentFor(modelId: string | null): { id: string; agent: Agent } | null { const selected = (modelId ? resolveLanguageModel(modelId) : null) ?? defaultLanguageModel(); @@ -37,12 +41,74 @@ function agentFor(modelId: string | null): { id: string; agent: Agent } | null { name: "Birth Time Rectification Response Renderer", model: selected.model, skills: [skillPath], - instructions: "Write concise natural Simplified Chinese. Realize exactly one question from the supplied semantic opportunity. For a new event, use the supplied recall cues as optional examples, ask whether one such event happened instead of assuming it did, and preserve the user's ability to say no, forget, decline, or change direction. Do not invent events, ages, date windows or dates, switch targets, interpret the life meaning of an experience, expose ids/scores/techniques, mention a representative minute, or claim an exact birth minute. Avoid canned acknowledgement. Return strict JSON only.", + instructions: "Write concise natural Simplified Chinese for the supplied task. For an opening message, state that the supplied candidate window is only being checked and is not a confirmed birth minute, then invite one clearly remembered experience or connected sequence without a fixed-domain checklist. For a semantic opportunity, realize exactly one question; for a new event, use recall cues only as optional examples and ask whether it happened instead of assuming it did. Preserve the user's ability to be unsure, skip, decline, or change direction. Do not invent events, ages, date windows or dates, switch targets, interpret life meaning, expose ids/scores/techniques, mention a representative minute, or claim an exact birth minute. Avoid canned acknowledgement. Return strict JSON only.", }); agents.set(selected.id, agent); return { id: selected.id, agent }; } + +function validateOpeningMessage(value: unknown, range: Readonly<{ start: string; end: string }>) { + const parsed = openingMessageSchema.safeParse(value); + if (!parsed.success) return { message: null, issues: ["opening_schema_invalid"] }; + const message = parsed.data.message; + const issues: string[] = []; + if (!message.includes(range.start) || !message.includes(range.end)) issues.push("candidate_range_missing"); + if (!/(?:不是|并非|尚未|还未|不能).{0,16}(?:确认|确定)|待(?:核对|验证)/.test(message)) issues.push("unconfirmed_range_missing"); + if ((message.match(/[??]/g) ?? []).length > 1) issues.push("multiple_questions"); + if (!/(?:经历|事情|事件|变化|转折|记得|想得起来)/.test(message) || !/(?:说|讲|分享|回忆|开始)/.test(message)) issues.push("experience_invitation_missing"); + if (internalTerms.test(message)) issues.push("private_detail_exposed"); + const positiveClaims = message.split(/[。;;!??!]/).filter((sentence) => !/(?:不是|并非|尚未|还未|不能)/.test(sentence)).join(" "); + if (exactMinuteClaim.test(positiveClaims)) issues.push("exact_minute_claimed"); + if ((message.match(domainChecklistTerms) ?? []).length >= 3) issues.push("fixed_domain_checklist"); + return { message: issues.length ? null : message, issues }; +} + +export async function generateOpeningQuestion(input: Readonly<{ + caseId: string; + candidateRange: Readonly<{ start: string; end: string }>; + modelId: string | null; + timeoutMs?: number; + generate?: OpeningQuestionGenerator; +}>): Promise { + const selected = input.generate ? null : agentFor(input.modelId); + const modelId = selected?.id ?? input.modelId; + const started = Date.now(); + const deploymentSha = process.env.DEPLOYMENT_SHA?.trim() || process.env.VERCEL_GIT_COMMIT_SHA?.trim() || null; + const generate = input.generate ?? (async (prompt: string) => { + if (!selected) throw new Error("opening_model_unavailable"); + return selected.agent.generate(prompt, { + abortSignal: AbortSignal.timeout(input.timeoutMs ?? 15_000), + structuredOutput: { schema: openingMessageSchema, jsonPromptInjection: "inline" }, + }); + }); + const context = { + task: "Write the opening message for this new rectification case.", + candidateRange: input.candidateRange, + requirements: [ + "State that this candidate range is unconfirmed and only being checked.", + "Invite one clearly remembered experience or a connected sequence.", + "Use at most one natural question and no fixed-domain checklist.", + "Allow the user to be unsure, skip, or change direction.", + ], + }; + + recordRectificationAgentTelemetry({ caseId: input.caseId, phase: "renderer", outcome: "started", modelId, toolName: null, decisionAction: "opening_question", durationMs: null, errorCode: null, deploymentSha }); + try { + let result = validateOpeningMessage((await generate(JSON.stringify(context), "generate")).object, input.candidateRange); + if (!result.message) { + recordRectificationAgentTelemetry({ caseId: input.caseId, phase: "renderer", outcome: "rejected", modelId, toolName: null, decisionAction: "opening_question", durationMs: Date.now() - started, errorCode: result.issues[0] ?? "opening_rejected", deploymentSha }); + result = validateOpeningMessage((await generate(JSON.stringify({ ...context, task: "Repair the rejected opening message once.", validationIssues: result.issues }), "repair")).object, input.candidateRange); + } + if (!result.message) throw new Error(`opening_rejected:${result.issues.join(",")}`); + recordRectificationAgentTelemetry({ caseId: input.caseId, phase: "renderer", outcome: "succeeded", modelId, toolName: null, decisionAction: "opening_question", durationMs: Date.now() - started, errorCode: null, deploymentSha }); + return result.message; + } catch (error) { + recordRectificationAgentTelemetry({ caseId: input.caseId, phase: "renderer", outcome: "failed", modelId, toolName: null, decisionAction: "opening_question", durationMs: Date.now() - started, errorCode: error instanceof Error ? error.message.slice(0, 120) : "opening_failed", deploymentSha }); + throw error; + } +} + function normalized(value: string): string { return value.normalize("NFKC").replace(/[“”"'\s,,。.!!??::;;]/g, ""); } diff --git a/frontend/src/lib/rectification-v4/case-service.ts b/frontend/src/lib/rectification-v4/case-service.ts index ae8c015f..ae54fdfb 100644 --- a/frontend/src/lib/rectification-v4/case-service.ts +++ b/frontend/src/lib/rectification-v4/case-service.ts @@ -9,7 +9,7 @@ import { rectificationAgentV5Protocol, rectificationV4AlgorithmVersion, rectific import { selectRectificationDeploymentMode } from "../rectification-agent/feature-policy.ts"; import { CURRENT_RECTIFICATION_PROMPT_VERSION, CURRENT_RECTIFICATION_SKILL_VERSION } from "../rectification-agent/contracts.ts"; import { regenerateDirectorQuestion } from "../rectification-agent/director-agent.ts"; -import { regenerateQuestionRealization } from "../rectification-agent/renderer-agent.ts"; +import { generateOpeningQuestion, regenerateQuestionRealization } from "../rectification-agent/renderer-agent.ts"; import { calculationSpecHash, evidenceSetHash } from "./fingerprints.ts"; import { hasPolicyInvalidScoreableEvents } from "./evidence-ledger.ts"; import { openingQuestion } from "./opening-question.ts"; @@ -23,11 +23,13 @@ export function createRectificationV4CaseService( readonly now?: () => Date; readonly regenerateQuestion?: typeof regenerateQuestionRealization; readonly regenerateDirectorQuestion?: typeof regenerateDirectorQuestion; + readonly generateOpeningQuestion?: typeof generateOpeningQuestion; } = {}, ) { const now = options.now ?? (() => new Date()); const realizeQuestion = options.regenerateQuestion ?? regenerateQuestionRealization; const redirectQuestion = options.regenerateDirectorQuestion ?? regenerateDirectorQuestion; + const generateOpening = options.generateOpeningQuestion ?? generateOpeningQuestion; async function response(userId: string, caseValue: RectificationV4Case, jobId?: string): Promise { const [events, turns, analysis, job] = await Promise.all([ @@ -51,22 +53,39 @@ export function createRectificationV4CaseService( return { async createCase(input: { readonly userId: string; readonly actionId: string; readonly calculationSpec: CalculationSpec }) { + const replay = await store.loadActionCase(input.userId, input.actionId); + if (replay) return response(input.userId, replay); + + const specHash = calculationSpecHash(input.calculationSpec); + const active = await store.findActiveCase(input.userId); + if (active?.calculationSpecHash === specHash) { + return response(input.userId, await store.createCase({ case: active, actionId: input.actionId })); + } + const timestamp = now().toISOString(); const deploymentMode = selectRectificationDeploymentMode(input.userId); + const caseId = randomUUID(); + const orchestrationModelId = process.env.RECTIFICATION_ORCHESTRATION_MODEL_ID?.trim() || null; + const narrationModelId = process.env.RECTIFICATION_NARRATION_MODEL_ID?.trim() || null; + const initialQuestion = openingQuestion(await generateOpening({ + caseId, + candidateRange: input.calculationSpec.candidateRange, + modelId: narrationModelId, + })); const caseValue: RectificationV4Case = { - id: randomUUID(), + id: caseId, userId: input.userId, protocol: deploymentMode === "v4_legacy" ? rectificationV4Protocol : rectificationAgentV5Protocol, version: 0, status: "awaiting_answer", phase: "collecting_evidence", calculationSpec: input.calculationSpec, - calculationSpecHash: calculationSpecHash(input.calculationSpec), + calculationSpecHash: specHash, evidenceSetHash: evidenceSetHash([]), - currentQuestion: openingQuestion(input.calculationSpec.candidateRange), + currentQuestion: initialQuestion, latestSnapshot: null, - orchestrationModelId: process.env.RECTIFICATION_ORCHESTRATION_MODEL_ID?.trim() || null, - narrationModelId: process.env.RECTIFICATION_NARRATION_MODEL_ID?.trim() || null, + orchestrationModelId, + narrationModelId, skillVersion: CURRENT_RECTIFICATION_SKILL_VERSION, promptVersion: CURRENT_RECTIFICATION_PROMPT_VERSION, algorithmVersion: rectificationV4AlgorithmVersion, diff --git a/frontend/src/lib/rectification-v4/legacy-projector.ts b/frontend/src/lib/rectification-v4/legacy-projector.ts index a959a231..753554a2 100644 --- a/frontend/src/lib/rectification-v4/legacy-projector.ts +++ b/frontend/src/lib/rectification-v4/legacy-projector.ts @@ -26,9 +26,7 @@ export function projectLegacyV4Question(input: Readonly<{ id: randomUUID(), domain: "other", targetEventId: null, - prompt: input.latestAnswer - ? "我记下了这段经历。接下来请继续讲另一件你自己最确定、时间也比较清楚的人生变化;可以一次讲几件连续发生的事,我会顺着你的叙述继续核对。" - : "请从你自己最确定、时间也比较清楚的一段人生经历开始说。你可以一次讲几件连续发生的事,不需要按固定领域回答。", + prompt: "我记下了这段经历。接下来请继续讲另一件你自己最确定、时间也比较清楚的人生变化;可以一次讲几件连续发生的事,我会顺着你的叙述继续核对。", recallCost: "low", reason: "V4 legacy projector:保持开放叙述。", }; diff --git a/frontend/src/lib/rectification-v4/opening-question.ts b/frontend/src/lib/rectification-v4/opening-question.ts index 01e6ccc7..e856b3fd 100644 --- a/frontend/src/lib/rectification-v4/opening-question.ts +++ b/frontend/src/lib/rectification-v4/opening-question.ts @@ -1,16 +1,13 @@ import { randomUUID } from "node:crypto"; import type { RectificationV4Question } from "./contracts.ts"; -export function openingQuestion( - candidateRange: Readonly<{ start: string; end: string }>, - id?: string, -): RectificationV4Question { +export function openingQuestion(prompt: string, id?: string): RectificationV4Question { return { id: id ?? randomUUID(), domain: "other", targetEventId: null, - prompt: `我会先在 ${candidateRange.start}–${candidateRange.end} 这个范围内核对,它还不是已确认的出生分钟。请从你自己最确定、时间也比较清楚的一段人生经历开始说;可以一次讲几件连续发生的事,不需要按固定领域回答。`, + prompt, recallCost: "low", - reason: "首轮允许开放叙述,由后续系统根据真实经历选择高信息量问题。", + reason: "首轮由 Agent 根据候选范围生成自然引导。", }; } diff --git a/frontend/tests/rectification-agent-v5.test.ts b/frontend/tests/rectification-agent-v5.test.ts index c72cc79d..87d9c1b5 100644 --- a/frontend/tests/rectification-agent-v5.test.ts +++ b/frontend/tests/rectification-agent-v5.test.ts @@ -11,7 +11,7 @@ import { import { rectificationCanaryBucket, selectRectificationDeploymentMode } from "../src/lib/rectification-agent/feature-policy.ts"; import { buildQuestionOpportunities } from "../src/lib/rectification-agent/opportunity-builder.ts"; import { runBoundedReasoner } from "../src/lib/rectification-agent/reasoner-agent.ts"; -import { realizePublicMessage, validateQuestionRealization } from "../src/lib/rectification-agent/renderer-agent.ts"; +import { generateOpeningQuestion, realizePublicMessage, validateQuestionRealization } from "../src/lib/rectification-agent/renderer-agent.ts"; import { createRectificationV4CaseService } from "../src/lib/rectification-v4/case-service.ts"; import type { CalculationSpec, @@ -25,11 +25,40 @@ import { createRectificationV4MemoryStore } from "../src/lib/rectification-v4/me import { createRectificationV4Worker } from "../src/lib/rectification-v4/worker.ts"; import { v5EngineResult, withV5Mode } from "./rectification-v5-test-support.ts"; +function createTestCaseService( + store: Parameters[0], + options: Parameters[1] = {}, +) { + return createRectificationV4CaseService(store, { + generateOpeningQuestion: async ({ candidateRange }) => + `Agent 将在 ${candidateRange.start}–${candidateRange.end} 的待核对范围内陪你梳理;这并不是已确认的出生分钟。你愿意先说一段自己记得比较清楚的人生经历吗?`, + ...options, + }); +} + const caseId = "00000000-0000-4000-8000-000000000901"; const snapshotId = "00000000-0000-4000-8000-000000000902"; const opportunityId = "00000000-0000-4000-8000-000000000903"; const now = "2026-07-28T00:00:00.000Z"; +test("opening Agent generates and validates the first rectification message", async () => { + const phases: string[] = []; + const message = await generateOpeningQuestion({ + caseId, + candidateRange: { start: "00:00", end: "23:59" }, + modelId: "test-model", + generate: async (_prompt, phase) => { + phases.push(phase); + return phase === "generate" + ? { object: { message: "目前核对的是 00:00–23:59 候选范围,尚未确认出生分钟。请按学业、搬家、感情、工作依次回答。" } } + : { object: { message: "目前核对的是 00:00–23:59 候选范围,并不是已确认的出生分钟。你愿意先从一段自己记得清楚的经历开始说吗?" } }; + }, + }); + assert.deepEqual(phases, ["generate", "repair"]); + assert.match(message, /00:00–23:59/); + assert.match(message, /并不是已确认的出生分钟/); +}); + test("completion artifact fingerprints are canonical and payload-sensitive", () => { const left = rectificationFingerprint({ status: "complete", artifact: { b: 2, a: 1 } }); const reordered = rectificationFingerprint({ artifact: { a: 1, b: 2 }, status: "complete" }); @@ -347,7 +376,7 @@ test("shadow mode persists V5 artifacts while preserving the legacy visible repl async function run(mode: "v4_legacy" | "v5_shadow") { return withV5Mode(mode, async () => { const store = createRectificationV4MemoryStore(); - const service = createRectificationV4CaseService(store, { now: () => new Date(now) }); + const service = createTestCaseService(store, { now: () => new Date(now) }); const worker = createRectificationV4Worker({ store, now: () => new Date(now), @@ -388,7 +417,7 @@ test("shadow mode persists V5 artifacts while preserving the legacy visible repl test("V6 agent conversation follows dated events, respects direction change, and runs the existing V5 engine", async () => { await withV5Mode("v5_agent", async () => { const store = createRectificationV4MemoryStore(); - const service = createRectificationV4CaseService(store, { now: () => new Date("2026-07-29T00:00:00.000Z") }); + const service = createTestCaseService(store, { now: () => new Date("2026-07-29T00:00:00.000Z") }); let scoreCalls = 0; const worker = createRectificationV4Worker({ store, diff --git a/frontend/tests/rectification-analysis-trace.test.ts b/frontend/tests/rectification-analysis-trace.test.ts index 8ed37106..14d7d629 100644 --- a/frontend/tests/rectification-analysis-trace.test.ts +++ b/frontend/tests/rectification-analysis-trace.test.ts @@ -27,6 +27,17 @@ import type { ClaimedRectificationV4Job } from "../src/lib/rectification-v4/stor import { createRectificationV4Worker } from "../src/lib/rectification-v4/worker.ts"; import { passingVedAstroValidation, v5EngineResult, withV5Mode } from "./rectification-v5-test-support.ts"; +function createTestCaseService( + store: Parameters[0], + options: Parameters[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: [] }, @@ -419,7 +430,7 @@ test("read-only Agent diagnostics are traced only when the reasoner actually req 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 service = createTestCaseService(store, { now: () => new Date(now) }); const worker = createRectificationV4Worker({ store, now: () => new Date(now), diff --git a/frontend/tests/rectification-v4-domain.test.ts b/frontend/tests/rectification-v4-domain.test.ts index 8739bf2c..71c9c916 100644 --- a/frontend/tests/rectification-v4-domain.test.ts +++ b/frontend/tests/rectification-v4-domain.test.ts @@ -91,14 +91,12 @@ test("candidate minutes merge into ranked contiguous clusters and never confirm assert.equal(gate.canConfirmExactMinute, false); }); -test("opening question is open narration, not a fixed-domain questionnaire", () => { - const question = openingQuestion({ start: "04:50", end: "05:10" }, randomUUID()); +test("opening question stores the Agent-generated message without a fixed template", () => { + const question = openingQuestion("Agent 生成的首轮引导。", randomUUID()); assert.equal(question.domain, "other"); assert.equal(question.targetEventId, null); - assert.match(question.prompt, /04:50–05:10/); - assert.match(question.prompt, /不是已确认的出生分钟/); - assert.match(question.prompt, /不需要按固定领域回答/); - assert.doesNotMatch(question.prompt, /毕业|搬家|恋爱|工作|财务|健康/); + assert.equal(question.prompt, "Agent 生成的首轮引导。"); + assert.match(question.reason, /Agent/); }); test("Opportunity Builder prioritizes event-local date refinement and never asks family as self health", () => { diff --git a/frontend/tests/rectification-v4-replay.test.ts b/frontend/tests/rectification-v4-replay.test.ts index 9ee496be..d24ec670 100644 --- a/frontend/tests/rectification-v4-replay.test.ts +++ b/frontend/tests/rectification-v4-replay.test.ts @@ -8,6 +8,17 @@ import { createRectificationV4MemoryStore } from "../src/lib/rectification-v4/me import { createRectificationV4Worker } from "../src/lib/rectification-v4/worker.ts"; import { passingVedAstroValidation, v5EngineResult, withV5Mode } from "./rectification-v5-test-support.ts"; +function createTestCaseService( + store: Parameters[0], + options: Parameters[1] = {}, +) { + return createRectificationV4CaseService(store, { + generateOpeningQuestion: async ({ candidateRange }) => + `Agent 将在 ${candidateRange.start}–${candidateRange.end} 的待核对范围内陪你梳理;这并不是已确认的出生分钟。你愿意先说一段自己记得比较清楚的人生经历吗?`, + ...options, + }); +} + const now = () => new Date("2026-07-26T08:00:00.000Z"); const spec: CalculationSpec = { version: "rectification-calculation-spec-v4", @@ -40,7 +51,7 @@ async function answerAndRun( test("V5 golden replay persists the full artifact chain, returns ranges only, and never mutates the profile minute", async () => withV5Mode("v5_agent", async () => { const profile = { active_birth_time: "05:00:00" }; const store = createRectificationV4MemoryStore(); - const service = createRectificationV4CaseService(store, { now }); + const service = createTestCaseService(store, { now }); const candidates: readonly CandidateMinute[] = [ { time: "05:13", score: 100, supportingEventIds: [], conflictingEventIds: [] }, { time: "05:14", score: 99, supportingEventIds: [], conflictingEventIds: [] }, diff --git a/frontend/tests/rectification-v4-service.test.ts b/frontend/tests/rectification-v4-service.test.ts index bf095054..987c2d82 100644 --- a/frontend/tests/rectification-v4-service.test.ts +++ b/frontend/tests/rectification-v4-service.test.ts @@ -6,6 +6,17 @@ import type { CalculationSpec, CandidateSnapshot, LifeEventRevision, PendingEvid import { createRectificationV4MemoryStore } from "../src/lib/rectification-v4/memory-store.ts"; import { createRectificationV4Worker, resolvedPendingEvidence } from "../src/lib/rectification-v4/worker.ts"; +function createTestCaseService( + store: Parameters[0], + options: Parameters[1] = {}, +) { + return createRectificationV4CaseService(store, { + generateOpeningQuestion: async ({ candidateRange }) => + `Agent 将在 ${candidateRange.start}–${candidateRange.end} 的待核对范围内陪你梳理;这并不是已确认的出生分钟。你愿意先说一段自己记得比较清楚的人生经历吗?`, + ...options, + }); +} + const fixedNow = () => new Date("2026-07-28T12:00:00.000Z"); const spec: CalculationSpec = { version: "rectification-calculation-spec-v4", @@ -33,9 +44,28 @@ async function withMode(mode: "v4_legacy" | "v5_shadow" | "v5_agent", run: () } } +test("new cases use the Agent-generated opening and do not regenerate it when resuming", async () => withMode("v5_agent", async () => { + const store = createRectificationV4MemoryStore(); + let calls = 0; + const service = createTestCaseService(store, { + now: fixedNow, + generateOpeningQuestion: async ({ candidateRange }) => { + calls += 1; + return `这是 Agent 为 ${candidateRange.start}–${candidateRange.end} 生成的首轮引导。`; + }, + }); + const userId = randomUUID(); + const first = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec }); + const resumed = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec }); + assert.equal(first.case.currentQuestion?.prompt, "这是 Agent 为 04:30–05:30 生成的首轮引导。"); + assert.equal(first.case.agentMode, "deterministic_fallback"); + assert.equal(resumed.case.id, first.case.id); + assert.equal(calls, 1); +})); + test("same calculation spec resumes while a changed spec abandons the old case and stales its job", async () => withMode("v4_legacy", async () => { const store = createRectificationV4MemoryStore(); - const service = createRectificationV4CaseService(store, { now: fixedNow }); + const service = createTestCaseService(store, { now: fixedNow }); const userId = randomUUID(); const first = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec }); const resumed = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: { ...spec } }); @@ -55,7 +85,7 @@ test("same calculation spec resumes while a changed spec abandons the old case a test("answer is durably queued and a processing case reload restores its active job", async () => withMode("v5_agent", async () => { const store = createRectificationV4MemoryStore(); - const service = createRectificationV4CaseService(store, { now: fixedNow }); + const service = createTestCaseService(store, { now: fixedNow }); const userId = randomUUID(); const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec }); assert.equal(created.case.protocol, "rectification-evidence-v5"); @@ -77,7 +107,7 @@ test("answer is durably queued and a processing case reload restores its active 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 service = createTestCaseService(store, { now: fixedNow }); const userId = randomUUID(); const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec }); const queued = await service.answer({ @@ -108,7 +138,7 @@ test("V5 agent fallback persists the Director decision, Public Message and next test("V5 shadow runs and persists V5 artifacts but keeps the legacy visible projection", async () => withMode("v5_shadow", async () => { const store = createRectificationV4MemoryStore(); - const service = createRectificationV4CaseService(store, { now: fixedNow }); + const service = createTestCaseService(store, { now: fixedNow }); const userId = randomUUID(); const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec }); const queued = await service.answer({ @@ -134,7 +164,7 @@ test("V5 shadow runs and persists V5 artifacts but keeps the legacy visible proj test("V5 shadow keeps legacy year-precision refinement targeted to the original event", async () => withMode("v5_shadow", async () => { const store = createRectificationV4MemoryStore(); - const service = createRectificationV4CaseService(store, { now: fixedNow }); + const service = createTestCaseService(store, { now: fixedNow }); const userId = randomUUID(); const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec }); const queued = await service.answer({ @@ -157,7 +187,7 @@ test("V5 shadow keeps legacy year-precision refinement targeted to the original test("legacy cases are not hard-switched to V5 even when flags change later", async () => { const store = createRectificationV4MemoryStore(); - const service = createRectificationV4CaseService(store, { now: fixedNow }); + const service = createTestCaseService(store, { now: fixedNow }); const userId = randomUUID(); const created = await withMode("v4_legacy", () => service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec })); await withMode("v5_agent", async () => { @@ -179,7 +209,7 @@ test("legacy cases are not hard-switched to V5 even when flags change later", as test("V5 Agent regenerate rewrites only the current semantic question and replays the same action once", async () => withMode("v5_agent", async () => { const store = createRectificationV4MemoryStore(); let realizationCalls = 0; - const service = createRectificationV4CaseService(store, { + const service = createTestCaseService(store, { now: fixedNow, regenerateDirectorQuestion: async ({ currentQuestion }) => { realizationCalls += 1; @@ -247,7 +277,7 @@ test("legacy and shadow cases cannot call the V5 Agent question renderer", async for (const mode of ["v4_legacy", "v5_shadow"] as const) { await withMode(mode, async () => { const store = createRectificationV4MemoryStore(); - const service = createRectificationV4CaseService(store, { now: fixedNow }); + const service = createTestCaseService(store, { now: fixedNow }); const userId = randomUUID(); const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec }); assert.equal(await service.regenerateQuestion({ @@ -263,7 +293,7 @@ test("legacy and shadow cases cannot call the V5 Agent question renderer", async test("worker closes only the uniquely matched historical pending evidence", async () => withMode("v4_legacy", async () => { const store = createRectificationV4MemoryStore(); - const service = createRectificationV4CaseService(store, { now: fixedNow }); + const service = createTestCaseService(store, { now: fixedNow }); const userId = randomUUID(); const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec }); const pending: PendingEvidence = { @@ -299,7 +329,7 @@ test("worker closes only the uniquely matched historical pending evidence", asyn test("worker leaves ambiguous historical pending evidence unresolved", async () => withMode("v4_legacy", async () => { const store = createRectificationV4MemoryStore(); - const service = createRectificationV4CaseService(store, { now: fixedNow }); + const service = createTestCaseService(store, { now: fixedNow }); const userId = randomUUID(); const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec }); const pending = ["后来搬家一次,但记不清时间了", "以前也搬家,时间忘了"].map((rawText): PendingEvidence => ({ @@ -353,7 +383,7 @@ test("date pending evidence closes only after the event date changes", () => { test("legacy scoreable relationship-end snapshots cannot be accepted", async () => withMode("v5_agent", async () => { const store = createRectificationV4MemoryStore(); - const service = createRectificationV4CaseService(store, { now: fixedNow }); + const service = createTestCaseService(store, { now: fixedNow }); const userId = randomUUID(); const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec }); const revision: LifeEventRevision = {