import assert from "node:assert/strict"; import test from "node:test"; import { generateRectificationNarrative, validateNarrativeAgainstPacket, type RectificationNarrativeModelOutput, } from "../src/lib/conversational-rectification/narrative-agent.ts"; import { validationReceiptSchema } from "../src/lib/conversational-rectification/persistence-contracts.ts"; import type { RectificationTechnicalPacket } from "../src/lib/conversational-rectification/technical-packet.ts"; function syntheticTechnicalPacket(): RectificationTechnicalPacket { return { calculationVersion: "rectification-technical-v1", candidate: { status: "pending_validation", representativeTime: "05:20", range: { startTime: "05:16", endTime: "05:24" }, }, useBoundary: "该时间与范围仅是待验证候选,可用于比较稳定层和分钟敏感层,不能视为出生记录中的确定分钟。", candidateModelRefs: ["synthetic-candidate-model-v1"], candidateDifferenceRefs: ["difference-d9-relationship", "difference-d10-career"], candidateWeights: { "05:10": 0.4, "05:30": 0.6 }, partitionIds: ["private-partition-early", "private-partition-late"], d1Stability: "stable", boundaryDistanceMinutes: 4, sensitivityScope: { source: "time_linked_candidate_scan_samples", rangeStart: "05:16", rangeEnd: "05:24", sampleTimes: ["05:16", "05:24"], }, stableLayers: [{ layer: "D1", values: ["Cancer"], referenceIds: ["consult-d1-ascendant"] }], sensitiveLayers: [ { layer: "D9", values: ["Leo", "Virgo"], referenceIds: ["consult-d9-candidate-difference"] }, { layer: "D10", values: ["Libra", "Scorpio"], referenceIds: ["consult-d10-candidate-difference"] }, ], supportedSensitiveLayers: ["D9", "D10"], scoredHistoricalEvidence: [], suggestedDomains: [ { domain: "relationship", layer: "D9", reason: "D9 在候选范围内呈现 Leo / Virgo 差异,可用已发生的关系事件区分。" }, { domain: "career", layer: "D10", reason: "D10 在候选范围内呈现 Libra / Scorpio 差异,可用已发生的事业事件区分。" }, ], referenceIds: [ "difference-d9-relationship", "difference-d10-career", "consult-d1-ascendant", "consult-d9-candidate-difference", "consult-d10-candidate-difference", ], futureWindows: [{ label: "2028 career context window", startDate: "2028-03-01", endDate: "2028-05-31", scoreable: false, }], }; } function richOutput(): RectificationNarrativeModelOutput { const packet = syntheticTechnicalPacket(); return { narrative: [ "当前仍在核对 05:16–05:24 的候选范围,还不能把其中某一分钟当作确定出生时间。", "先说一件已经发生的重要关系经历好吗?尽量写明哪一年、哪一月以及发生了什么。", ].join("\n"), candidateStatus: "pending_validation", representativeTime: "05:20", rangeStart: "05:16", rangeEnd: "05:24", useBoundary: packet.useBoundary, stableLayers: ["D1"], sensitiveLayers: ["D9", "D10"], referenceIds: ["consult-d1-ascendant"], domainReasons: [ { domain: "relationship", layer: "D9", reason: packet.suggestedDomains[0]?.reason ?? "" }, { domain: "career", layer: "D10", reason: packet.suggestedDomains[1]?.reason ?? "" }, ], evidenceRequest: { domains: ["relationship"], datePrecision: "month_preferred", prompt: "请提供已经发生的真实关系或事业事件,并尽量说明哪一年、哪一月以及发生了什么。", }, }; } function generator(outputs: readonly unknown[], prompts: string[] = []) { let index = 0; return { modelId: "synthetic-narrative-model", async generate(prompt: string) { prompts.push(prompt); const output = outputs[Math.min(index, outputs.length - 1)]; index += 1; return { text: JSON.stringify(output) }; }, }; } test("validates a rich first-turn narrative against the technical packet", async () => { const packet = syntheticTechnicalPacket(); const output = richOutput(); assert.deepEqual(validateNarrativeAgainstPacket(output, packet, "first"), { valid: true, issues: [] }); const result = await generateRectificationNarrative({ phase: "first", packet, generator: generator([output]), }); assert.equal(result.attempts, 1); assert.equal(result.fallbackUsed, false); assert.equal(result.allowEvidenceScoringAdvance, true); assert.equal(validationReceiptSchema.safeParse(result.validationReceipt).success, true); assert.equal(result.output.candidateStatus, "pending_validation"); assert.match(result.narrative, /05:16–05:24[\s\S]*不能/); assert.doesNotMatch(result.narrative, /\bD\d+\b/); assert.equal((result.narrative.match(/[??]/g) ?? []).length, 1); assert.match(result.narrative, /已经发生[\s\S]*哪一年[\s\S]*哪一月/); assert.doesNotMatch(result.narrative, /^哪一个时间段[\s\S]*\d{4}[–—-]\d{4}/); }); test("rejects invented representative times, layers, and references", () => { const packet = syntheticTechnicalPacket(); const invalid = { ...richOutput(), representativeTime: "06:45", sensitiveLayers: ["D9", "D60"], referenceIds: ["invented-reference"], } satisfies RectificationNarrativeModelOutput; const result = validateNarrativeAgainstPacket(invalid, packet, "first"); assert.equal(result.valid, false); assert.ok(result.issues.some((issue) => issue.includes("representativeTime"))); assert.ok(result.issues.some((issue) => issue.includes("D60"))); assert.ok(result.issues.some((issue) => issue.includes("invented-reference"))); }); test("accepts a concise first turn without exposing technical layer values", () => { const packet = syntheticTechnicalPacket(); const invalid = { ...richOutput(), narrative: "当前仍在核对 05:16–05:24 的候选范围,不能视为已经确认的出生分钟。先说一件过去的重要关系经历好吗?请写明哪一年、哪一月。", } satisfies RectificationNarrativeModelOutput; const result = validateNarrativeAgainstPacket(invalid, packet, "first"); assert.deepEqual(result, { valid: true, issues: [] }); }); test("rejects duplicated generic domain reasons that are not packet-grounded", () => { const packet = syntheticTechnicalPacket(); const relationshipReason = richOutput().domainReasons[0]; assert.ok(relationshipReason); const invalid = { ...richOutput(), domainReasons: [ { ...relationshipReason, reason: "D9 may matter for this question" }, { ...relationshipReason, reason: "D9 may matter for another question" }, ], } satisfies RectificationNarrativeModelOutput; const result = validateNarrativeAgainstPacket(invalid, packet, "first"); assert.equal(result.valid, false); assert.ok(result.issues.some((issue) => issue.includes("packet discrimination explanation"))); }); test("rejects a generic broad-year choice questionnaire and uses a safe scoring-compatible fallback", async () => { const invalid = { ...richOutput(), narrative: [ "05:20 是 05:16–05:24 范围内的待验证候选,不能视为已经确认的出生分钟。", "D1 的 Cancer 保持稳定;D9 的 Leo / Virgo 与 D10 的 Libra / Scorpio 都有分钟敏感差异。", "关系事件可区分 D9,事业事件可区分 D10。请按已经发生的经历选择哪一年、哪一月:A. 2018–2020;B. 2021–2023,哪个时间段更符合?", ].join("\n"), evidenceRequest: { domains: ["relationship", "career"], datePrecision: "month_preferred", prompt: "请按过去经历选择哪一年、哪一月:A. 2018–2020;B. 2021–2023,哪个时间段更符合?", }, } satisfies RectificationNarrativeModelOutput; const direct = validateNarrativeAgainstPacket(invalid, syntheticTechnicalPacket(), "first"); assert.equal(direct.valid, false); assert.ok(direct.issues.some((issue) => issue.includes("broad-year choice questionnaire"))); const result = await generateRectificationNarrative({ phase: "first", packet: syntheticTechnicalPacket(), generator: generator([invalid, invalid]), }); assert.equal(result.attempts, 2); assert.equal(result.fallbackUsed, true); assert.equal(result.allowEvidenceScoringAdvance, true); }); test("rejects generic individual-year options even without a written range", () => { const output = richOutput(); assert.ok(output.evidenceRequest); const invalid = { ...output, evidenceRequest: { ...output.evidenceRequest, prompt: "请按过去已经发生的事件选择哪一年、哪一月更符合:A. 2018年;B. 2021年。", }, } satisfies RectificationNarrativeModelOutput; const result = validateNarrativeAgainstPacket(invalid, syntheticTechnicalPacket(), "first"); assert.equal(result.valid, false); assert.ok(result.issues.some((issue) => issue.includes("broad-year choice questionnaire"))); }); for (const prompt of [ "请提供已经发生的真实事件:2018年还是2021年?也请说明哪一月以及发生了什么。", "请提供已经发生的真实事件,并说明哪一年:2018年、2021年;也请说明哪一月以及发生了什么。", "请提供已经发生的真实事件:2018年或2021年,也请说明哪一月以及发生了什么。", ]) { test(`rejects a rich first turn that proposes multiple years: ${prompt}`, () => { const output = richOutput(); assert.ok(output.evidenceRequest); const invalid = { ...output, evidenceRequest: { ...output.evidenceRequest, prompt }, } satisfies RectificationNarrativeModelOutput; const result = validateNarrativeAgainstPacket(invalid, syntheticTechnicalPacket(), "first"); assert.equal(result.valid, false); assert.ok(result.issues.some((issue) => issue.includes("broad-year choice questionnaire"))); }); } test("accepts a request for one real past event's year and month without proposed years", () => { const output = richOutput(); assert.ok(output.evidenceRequest); const legitimate = { ...output, evidenceRequest: { ...output.evidenceRequest, prompt: "请提供一件已经发生的真实事件,并说明是哪一年、哪一月以及发生了什么。", }, } satisfies RectificationNarrativeModelOutput; assert.deepEqual( validateNarrativeAgainstPacket(legitimate, syntheticTechnicalPacket(), "first"), { valid: true, issues: [] }, ); }); test("rejects ungrounded layers and references nested in a domain reason", () => { const output = richOutput(); const firstReason = output.domainReasons[0]; assert.ok(firstReason); const invalid = { ...output, domainReasons: [ { ...firstReason, reason: `${firstReason.reason} D60 另见 invented-reference。` }, ...output.domainReasons.slice(1), ], } satisfies RectificationNarrativeModelOutput; const result = validateNarrativeAgainstPacket(invalid, syntheticTechnicalPacket(), "first"); assert.equal(result.valid, false); assert.ok(result.issues.some((issue) => issue.includes("domainReasons[0].reason") && issue.includes("D60"))); assert.ok(result.issues.some((issue) => issue.includes("domainReasons[0].reason") && issue.includes("invented-reference"))); }); test("rejects ungrounded times and references nested in the evidence request prompt", () => { const output = richOutput(); assert.ok(output.evidenceRequest); const invalid = { ...output, evidenceRequest: { ...output.evidenceRequest, prompt: `${output.evidenceRequest.prompt} 请以 06:45 和 invented-reference 为准。`, }, } satisfies RectificationNarrativeModelOutput; const result = validateNarrativeAgainstPacket(invalid, syntheticTechnicalPacket(), "first"); assert.equal(result.valid, false); assert.ok(result.issues.some((issue) => issue.includes("evidenceRequest.prompt") && issue.includes("06:45"))); assert.ok(result.issues.some((issue) => issue.includes("evidenceRequest.prompt") && issue.includes("invented-reference"))); }); test("rejects an invented plain-text technical reference omitted from the reference list", () => { const packet = syntheticTechnicalPacket(); const invalid = { ...richOutput(), narrative: `${richOutput().narrative}\n另见 invented-reference。`, }; const result = validateNarrativeAgainstPacket(invalid, packet, "first"); assert.equal(result.valid, false); assert.ok(result.issues.some((issue) => issue.includes("invented-reference"))); }); test("retries expression exactly once with the same grounded packet", async () => { const packet = syntheticTechnicalPacket(); const prompts: string[] = []; const result = await generateRectificationNarrative({ phase: "first", packet, generator: generator([{ ...richOutput(), representativeTime: "06:45" }, richOutput()], prompts), }); assert.equal(result.attempts, 2); assert.equal(result.fallbackUsed, false); assert.equal(result.allowEvidenceScoringAdvance, true); assert.equal(prompts.length, 2); assert.match(prompts[0] ?? "", /rectification-technical-v1/); assert.match(prompts[1] ?? "", /rectification-technical-v1/); assert.match(prompts[0] ?? "", /time_linked_candidate_scan_samples/); assert.equal(prompts.some((prompt) => prompt.includes("candidateWeights")), false); assert.equal(prompts.some((prompt) => prompt.includes("private-partition")), false); }); test("uses a deterministic one-question conversational fallback after the second mismatch", async () => { const packet = syntheticTechnicalPacket(); const invalid = { ...richOutput(), sensitiveLayers: ["D60"] }; const first = await generateRectificationNarrative({ phase: "first", packet, generator: generator([invalid, invalid]), }); const second = await generateRectificationNarrative({ phase: "first", packet, generator: generator([invalid, invalid]), }); assert.equal(first.attempts, 2); assert.equal(first.fallbackUsed, true); assert.equal(first.allowEvidenceScoringAdvance, true); assert.equal(first.narrative, second.narrative); assert.match(first.narrative, /05:16–05:24[\s\S]*不能/); assert.doesNotMatch(first.narrative, /\bD\d+\b/); assert.match(first.narrative, /已经发生[\s\S]*年[\s\S]*月/); assert.equal((first.narrative.match(/[??]/g) ?? []).length, 1); assert.deepEqual(first.output.evidenceRequest?.domains, ["relationship"]); }); test("bounds fallback validation issues for the durable receipt", async () => { const inventedReference = `invented-${"x".repeat(500)}`; const invalid = { ...richOutput(), narrative: `${richOutput().narrative}\n另见 ${inventedReference}。`, }; const result = await generateRectificationNarrative({ phase: "first", packet: syntheticTechnicalPacket(), generator: generator([invalid, invalid]), }); assert.equal(result.fallbackUsed, true); assert.ok(result.validationReceipt.issues.length <= 20); assert.ok(result.validationReceipt.issues.every((issue) => issue.length <= 240)); }); test("builds distinct first, intermediate, and final grounded prompts", async () => { for (const phase of ["first", "intermediate", "final"] as const) { const prompts: string[] = []; await generateRectificationNarrative({ phase, packet: syntheticTechnicalPacket(), generator: generator([richOutput()], prompts), }); assert.match(prompts[0] ?? "", new RegExp(`\\"phase\\":\\"${phase}\\"`)); } });