Files
Jyotisha/frontend/tests/conversational-narrative-agent.test.ts
T
2026-07-21 01:29:12 +08:00

376 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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:20 是 05:1605:24 范围内的待验证候选,不是已经确认的出生分钟。",
"D1 上升在范围内保持 Cancer,属于稳定层【consult-d1-ascendant】;D9 与 D10 分别出现 Leo/Virgo、Libra/Scorpio 的分钟敏感变化。",
"因此关系事件可区分 D9,事业事件可区分 D10。请提供已经发生的真实事件,尽量写明哪一年、哪一月以及发生了什么。",
"未来窗口只能作为背景,不能计入既成事件评分。",
].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", "career"],
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, /待验证候选/);
assert.match(result.narrative, /D1/);
assert.match(result.narrative, /D9[\s\S]*D10/);
assert.match(result.narrative, /关系[\s\S]*D9[\s\S]*事业[\s\S]*D10/);
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("rejects a first turn that names layer tokens without their stable and sensitive values", () => {
const packet = syntheticTechnicalPacket();
const invalid = {
...richOutput(),
narrative: [
"05:20 是 05:1605:24 范围内的待验证候选,不能视为已经确认的出生分钟。",
"D1 是稳定层,D9 和 D10 是分钟敏感层。",
"关系事件可区分 D9,事业事件可区分 D10。请提供已经发生的真实事件,写明哪一年、哪一月。",
].join("\n"),
} satisfies RectificationNarrativeModelOutput;
const result = validateNarrativeAgainstPacket(invalid, packet, "first");
assert.equal(result.valid, false);
assert.ok(result.issues.some((issue) => issue.includes("stable evidence semantics")));
assert.ok(result.issues.some((issue) => issue.includes("sensitive evidence semantics")));
});
test("rejects duplicated generic domain reasons that omit a packet discrimination pair", () => {
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 pairs")));
});
test("rejects a generic broad-year choice questionnaire and falls back without scoring", async () => {
const invalid = {
...richOutput(),
narrative: [
"05:20 是 05:1605:24 范围内的待验证候选,不能视为已经确认的出生分钟。",
"D1 的 Cancer 保持稳定;D9 的 Leo / Virgo 与 D10 的 Libra / Scorpio 都有分钟敏感差异。",
"关系事件可区分 D9,事业事件可区分 D10。请按已经发生的经历选择哪一年、哪一月:A. 20182020B. 20212023,哪个时间段更符合?",
].join("\n"),
evidenceRequest: {
domains: ["relationship", "career"],
datePrecision: "month_preferred",
prompt: "请按过去经历选择哪一年、哪一月:A. 20182020B. 20212023,哪个时间段更符合?",
},
} 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, false);
});
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 rich Chinese fallback after the second mismatch and holds scoring", 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, false);
assert.equal(first.narrative, second.narrative);
assert.match(first.narrative, /05:20[\s\S]*待验证/);
assert.match(first.narrative, /D1[\s\S]*稳定/);
assert.match(first.narrative, /D9[\s\S]*D10[\s\S]*敏感/);
assert.match(first.narrative, /已经发生[\s\S]*年[\s\S]*月/);
assert.match(first.narrative, /未来[\s\S]*不能[\s\S]*评分/);
});
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}\\"`));
}
});