367 lines
15 KiB
TypeScript
367 lines
15 KiB
TypeScript
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}\\"`));
|
||
}
|
||
});
|