Files
Jyotisha/frontend/tests/conversational-narrative-agent.test.ts
T
2026-07-25 01:17:14 +08:00

967 lines
36 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: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:1605: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("preserves a complete model-authored first-turn narrative verbatim", async () => {
const packet = syntheticTechnicalPacket();
const output = {
...richOutput(),
narrative: "我们先从你印象最深的一次关系变化聊起,好吗?",
evidenceRequest: {
domains: ["relationship" as const],
datePrecision: "month_preferred" as const,
prompt: "那段关系大约是什么时候发生的?",
},
};
const result = await generateRectificationNarrative({
phase: "first",
packet,
generator: generator([output]),
});
assert.equal(result.attempts, 1);
assert.equal(result.fallbackUsed, false);
assert.equal(result.narrative, output.narrative);
assert.doesNotMatch(result.narrative, /05:1605:24|不能直接当作已经确认/);
assert.equal(result.output.evidenceRequest?.prompt, "那段关系大约是什么时候发生的?");
});
test("completes a first-turn introduction with the model-authored question", async () => {
const result = await generateRectificationNarrative({
phase: "first",
packet: syntheticTechnicalPacket(),
generator: generator([{
narrative: "我们先把你记得的出生范围当作起点,再用真实经历逐步核对。",
evidenceRequest: {
domains: ["relationship"],
datePrecision: "month_preferred",
prompt: "先说一件时间比较明确的关系转折,好吗?",
},
}]),
});
assert.equal(result.fallbackUsed, false);
assert.match(result.narrative, /真实经历逐步核对。[\s\S]*关系转折,好吗?/);
assert.equal((result.narrative.match(/[?]/g) ?? []).length, 1);
});
test("repairs a natural intermediate follow-up prompt without discarding the specific reply", async () => {
const packet = syntheticTechnicalPacket();
const output = {
...richOutput(),
narrative: "你把毕业和读研的衔接说清楚了,这两段会分别记录。下一步想确认毕业后的第一份工作是什么时候开始的?",
evidenceRequest: {
domains: ["career" as const],
datePrecision: "month_preferred" as const,
prompt: "第一份工作是什么时候开始的?",
},
};
const result = await generateRectificationNarrative({
phase: "intermediate",
packet,
generator: generator([output]),
});
assert.equal(result.attempts, 1);
assert.equal(result.fallbackUsed, false);
assert.match(result.narrative, /毕业和读研的衔接/);
assert.doesNotMatch(result.narrative, /当前累计/);
assert.equal(result.output.evidenceRequest?.prompt, "第一份工作是什么时候开始的?");
});
test("preserves the target event in an intermediate detail follow-up", async () => {
const evidenceId = "00000000-0000-4000-8000-000000000321";
const output = {
...richOutput(),
narrative: "记下了你在 2017 年 5 月参加工作。那是第一份正式工作,还是实习或兼职?",
evidenceRequest: {
domains: ["career" as const],
datePrecision: "month_preferred" as const,
prompt: "那是第一份正式工作,还是实习或兼职?",
followUp: { kind: "event_detail" as const, evidenceId },
},
};
const result = await generateRectificationNarrative({
phase: "intermediate",
packet: syntheticTechnicalPacket(),
generator: generator([output]),
context: {
eventLedger: [{
id: evidenceId,
rawText: "2017年5月参加工作",
dateLabel: "2017-05",
summary: "参加工作",
domain: "career",
extractionStatus: "clear",
active: true,
correctsEvidenceIds: [],
}],
},
});
assert.equal(result.fallbackUsed, false);
assert.deepEqual(result.output.evidenceRequest?.followUp, {
kind: "event_detail",
evidenceId,
});
});
test("appends the model-authored question when an intermediate reply only acknowledges the event", async () => {
const output = {
...richOutput(),
narrative: "已记录本科毕业后衔接读研,这是一段连续教育转折,暂时不重复计数。",
evidenceRequest: {
domains: ["career" as const],
datePrecision: "month_preferred" as const,
prompt: "毕业后的第一份工作是什么时候开始的?",
},
};
const result = await generateRectificationNarrative({
phase: "intermediate",
packet: syntheticTechnicalPacket(),
generator: generator([output]),
});
assert.equal(result.fallbackUsed, false);
assert.match(result.narrative, /连续教育转折[\s\S]*第一份工作是什么时候开始的?/);
assert.equal((result.narrative.match(/[?]/g) ?? []).length, 1);
});
test("repairs a narrative that stops at a dangling question introduction", async () => {
const output = {
...richOutput(),
narrative: [
"谢谢你补充了当时的压力感受,这让这段经历更完整。",
"为了判断这段经历的具体节奏,我需要确认一个关键信息——",
].join("\n\n"),
evidenceRequest: {
domains: ["career" as const],
datePrecision: "month_preferred" as const,
prompt: "你感到压力最强的时候,大约是哪一年、哪一个月?",
},
};
const result = await generateRectificationNarrative({
phase: "intermediate",
packet: syntheticTechnicalPacket(),
generator: generator([output]),
});
assert.equal(result.fallbackUsed, false);
assert.match(result.narrative, /关键信息——[\s\S]*压力最强的时候[\s\S]*哪一年、哪一个月/);
assert.equal((result.narrative.match(/[?]/g) ?? []).length, 1);
});
test("turns a complete intermediate acknowledgement into a visible one-question turn", async () => {
const output = {
...richOutput(),
narrative: "这次入职会作为一条事业事件记录,但还需要知道后续是否发生过离职、转岗或升职。",
evidenceRequest: {
domains: ["career" as const],
datePrecision: "month_preferred" as const,
prompt: "这份工作后来第一次发生明确变化是在什么时候?",
},
};
const result = await generateRectificationNarrative({
phase: "intermediate",
packet: syntheticTechnicalPacket(),
generator: generator([output]),
});
assert.equal(result.fallbackUsed, false);
assert.match(result.narrative, /离职、转岗或升职。[\s\S]*第一次发生明确变化是在什么时候?/);
assert.equal((result.narrative.match(/[?]/g) ?? []).length, 1);
});
test("hides an uninvoked technique inventory during evidence collection", async () => {
const prompts: string[] = [];
const packet = {
...syntheticTechnicalPacket(),
expertWorkflow: {
boundary: "not_auto_rectified" as const,
candidateWindows: [{
startTime: "05:16",
endTime: "05:24",
status: "pending_validation" as const,
}],
techniqueAuditTable: [{
technique: "KP cusp / sub-lord",
status: "blocked" as const,
evidence: [],
boundary: "当前评分合同没有可审计结果。",
}],
confirmationAllowed: false,
hardBlockers: ["minute_holdout_not_ready"],
gates: {},
},
};
await generateRectificationNarrative({
phase: "intermediate",
packet,
generator: generator([richOutput()], prompts),
});
assert.match(prompts[0] ?? "", /expertWorkflow/);
assert.doesNotMatch(prompts[0] ?? "", /KP cusp \/ sub-lord/);
assert.doesNotMatch(prompts[0] ?? "", /minute_holdout_not_ready/);
assert.match(prompts[0] ?? "", /blockedOrNotEvaluatedTechniquesMustNeverBeClaimedAsUsed/);
});
test("appends the three auditable tables to every final Agent answer", async () => {
const packet: RectificationTechnicalPacket = {
...syntheticTechnicalPacket(),
scoredHistoricalEvidence: [{
evidenceId: "00000000-0000-4000-8000-000000000602",
domain: "career",
candidateTime: "05:20",
score: 3.5,
ruleRefs: ["vim-md-career"],
}],
expertWorkflow: {
boundary: "not_auto_rectified",
candidateWindows: [{
startTime: "05:16",
endTime: "05:24",
status: "ready_for_confirmation",
}],
techniqueAuditTable: [{
technique: "VedAstro official validation",
status: "used",
evidence: ["official_verified", "event_discrimination_pass"],
boundary: "胜出分钟必须在已发生事件扫描中严格领先次优分钟。",
}],
confirmationAllowed: true,
hardBlockers: [],
gates: {},
},
};
const output: RectificationNarrativeModelOutput = {
...richOutput(),
narrative: "当前证据支持 05:20 作为建议确认的分钟;写回前仍需你明确确认。",
evidenceRequest: null,
};
const prompts: string[] = [];
const result = await generateRectificationNarrative({
phase: "final",
packet,
context: {
eventLedger: [{
id: "00000000-0000-4000-8000-000000000602",
rawText: "2019年7月入职第一家公司",
dateLabel: "2019-07",
summary: "入职第一家公司",
domain: "career",
extractionStatus: "clear",
active: true,
correctsEvidenceIds: [],
}],
},
generator: generator([output], prompts),
});
assert.match(result.narrative, /### Technique Audit Table/);
assert.match(result.narrative, /VedAstro official validation/);
assert.match(result.narrative, /### 事件验证表/);
assert.match(result.narrative, /2019-07[\s\S]*入职第一家公司[\s\S]*已纳入验证[\s\S]*已纳入当前候选比较/);
assert.doesNotMatch(result.narrative, /3\.5|得分 \/ 状态|内部(?:分数|权重)/);
assert.doesNotMatch(prompts[0] ?? "", /"score"\s*:\s*3\.5/);
assert.match(result.narrative, /### 候选时间差异表/);
assert.match(result.narrative, /05:1605:24[\s\S]*D9[\s\S]*minute_sensitive/);
});
test("appends canonical final tables when the model only emits empty or incomplete headings", async () => {
const output: RectificationNarrativeModelOutput = {
...richOutput(),
narrative: [
"当前证据已经形成建议结论,写回前仍需确认。",
"### Technique Audit Table",
"### 事件验证表",
"| 时间 | 事件 |",
"|---|---|",
"### 候选时间差异表",
].join("\n\n"),
evidenceRequest: null,
};
const result = await generateRectificationNarrative({
phase: "final",
packet: syntheticTechnicalPacket(),
generator: generator([output]),
});
assert.equal(result.fallbackUsed, false);
assert.match(result.narrative, /\| 技法 \| 状态 \| 证据 \| 使用边界 \|/);
assert.match(result.narrative, /\| 时间 \| 事件 \| 领域 \| 验证状态 \| 结论 \|/);
assert.match(result.narrative, /\| 候选范围 \| 层 \| 状态 \| 差异 \/ 证据 \|/);
assert.equal((result.narrative.match(/### Technique Audit Table/g) ?? []).length, 2);
assert.equal((result.narrative.match(/### 事件验证表/g) ?? []).length, 2);
assert.equal((result.narrative.match(/### 候选时间差异表/g) ?? []).length, 2);
});
test("rejects a model-authored event table that exposes private numeric scoring", async () => {
const packet: RectificationTechnicalPacket = {
...syntheticTechnicalPacket(),
scoredHistoricalEvidence: [{
evidenceId: "00000000-0000-4000-8000-000000000602",
domain: "career",
candidateTime: "05:20",
score: 3.5,
ruleRefs: ["vim-md-career"],
}],
};
const unsafe: RectificationNarrativeModelOutput = {
...richOutput(),
narrative: [
"当前证据形成了候选结论。",
"### 事件验证表",
"| 时间 | 事件 | 得分 / 状态 |",
"|---|---|---|",
"| 2019-07 | 入职第一家公司 | 3.5 |",
].join("\n"),
evidenceRequest: null,
};
const result = await generateRectificationNarrative({
phase: "final",
packet,
generator: generator([unsafe, unsafe]),
});
assert.equal(result.fallbackUsed, true);
assert.doesNotMatch(result.narrative, /3\.5|得分 \/ 状态/);
assert.match(result.narrative, /\| 时间 \| 事件 \| 领域 \| 验证状态 \| 结论 \|/);
});
test("passes the user's latest concrete event to an intermediate skill-guided reply", async () => {
const prompts: string[] = [];
await generateRectificationNarrative({
phase: "intermediate",
packet: syntheticTechnicalPacket(),
context: {
latestEvidence: [{
dateLabel: "2023-09",
summary: "离开家乡去上海开始第一份长期工作",
domain: "career",
}],
},
generator: generator([richOutput()], prompts),
});
assert.match(prompts[0] ?? "", /离开家乡去上海开始第一份长期工作/);
assert.match(prompts[0] ?? "", /respondToLatestEvidenceBeforeAsking/);
assert.match(prompts[0] ?? "", /doNotRepeatCandidateBoundaryUnlessItChangedOrTheUserAsked/);
});
test("keeps internal event domains and suggested-domain routing out of the narrator context", async () => {
const prompts: string[] = [];
await generateRectificationNarrative({
phase: "intermediate",
packet: syntheticTechnicalPacket(),
context: {
latestEvidence: [{
id: "00000000-0000-4000-8000-000000000111",
dateLabel: "2020-04",
summary: "去石油化工研究院实习做研究员",
domain: "other",
}],
eventLedger: [{
id: "00000000-0000-4000-8000-000000000111",
rawText: "2020年4月去石油化工研究院实习做研究员",
dateLabel: "2020-04",
summary: "去石油化工研究院实习做研究员",
domain: "career",
extractionStatus: "clear",
active: true,
correctsEvidenceIds: [],
}],
},
generator: generator([richOutput()], prompts),
});
const prompt = JSON.parse(prompts[0] ?? "{}") as {
conversationContext?: {
latestEvidence?: Array<Record<string, unknown>>;
eventLedger?: Array<Record<string, unknown>>;
};
packet?: Record<string, unknown>;
};
assert.equal(prompt.conversationContext?.latestEvidence?.[0]?.domain, undefined);
assert.equal(prompt.conversationContext?.eventLedger?.[0]?.domain, undefined);
assert.equal(prompt.packet?.suggestedDomains, undefined);
});
test("passes the active event ledger and unresolved facts to the intermediate agent", async () => {
const prompts: string[] = [];
await generateRectificationNarrative({
phase: "intermediate",
packet: syntheticTechnicalPacket(),
context: {
latestUserText: "23年关系结束后发生过一次交通事故",
latestEvidence: [{
dateLabel: "2023",
summary: "关系结束后发生过一次交通事故",
domain: "health_pressure",
}],
eventLedger: [{
id: "relationship-ending",
rawText: "2024年8月8日一段重要关系结束",
dateLabel: "2024-08-08",
summary: "一段重要关系结束",
domain: "relationship",
extractionStatus: "clear",
active: true,
correctsEvidenceIds: [],
}],
unresolvedEvidence: [{
id: "accident-year-conflict",
rawText: "23年关系结束后发生过一次交通事故",
summary: "关系结束后发生过一次交通事故",
domain: "health_pressure",
dateLabel: "2023",
}],
},
generator: generator([richOutput()], prompts),
});
const prompt = prompts[0] ?? "";
assert.match(prompt, /23年关系结束后发生过一次交通事故/);
assert.match(prompt, /2024-08-08/);
assert.match(prompt, /一段重要关系结束/);
assert.match(prompt, /continueCurrentEventWhenItRemainsInformative/);
assert.match(prompt, /resolveDateContradictionsBeforeScoring/);
assert.match(prompt, /mergeSameEventDetailsWithoutDoubleCounting/);
});
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 natural first turn without a forced range, boundary, or date request", () => {
const packet = syntheticTechnicalPacket();
const invalid = {
...richOutput(),
narrative: "我们先从你印象最深、最愿意讲的一段人生变化开始。发生了什么?",
} satisfies RectificationNarrativeModelOutput;
const result = validateNarrativeAgainstPacket(invalid, packet, "first");
assert.deepEqual(result, { valid: true, issues: [] });
});
test("accepts natural domain explanations while still rejecting unknown domain-layer pairs", () => {
const packet = syntheticTechnicalPacket();
const relationshipReason = richOutput().domainReasons[0];
assert.ok(relationshipReason);
const paraphrased = {
...richOutput(),
domainReasons: [
{ ...relationshipReason, reason: "D9 may matter for this question" },
{ ...relationshipReason, reason: "D9 may matter for another question" },
],
} satisfies RectificationNarrativeModelOutput;
assert.deepEqual(validateNarrativeAgainstPacket(paraphrased, packet, "first"), { valid: true, issues: [] });
const ungrounded = {
...richOutput(),
domainReasons: [{ domain: "finance" as const, layer: "D60", reason: "This invented layer should be rejected." }],
} satisfies RectificationNarrativeModelOutput;
const result = validateNarrativeAgainstPacket(ungrounded, packet, "first");
assert.equal(result.valid, false);
assert.ok(result.issues.some((issue) => issue.includes("domain reason finance/D60")));
});
test("allows packet-grounded technical tables during an intermediate turn", () => {
const packet = syntheticTechnicalPacket();
const output = {
...richOutput(),
narrative: [
"你补充的关系变化已经记入当前事件线。",
"### 候选时间差异表",
"| 层级 | 当前观察 |",
"| --- | --- |",
"| D9 | 在候选范围内呈分钟敏感差异 |",
"下一步我想继续了解这段关系结束后的直接变化。",
].join("\n"),
} satisfies RectificationNarrativeModelOutput;
assert.deepEqual(validateNarrativeAgainstPacket(output, packet, "intermediate"), { valid: true, issues: [] });
});
test("rejects a generic broad-year choice questionnaire without replacing the first Agent answer with a template", 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")));
await assert.rejects(
generateRectificationNarrative({
phase: "first",
packet: syntheticTechnicalPacket(),
generator: generator([invalid, invalid]),
}),
/RectificationNarrativeUnavailable/,
);
});
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("accepts a natural reply that mentions several known dates before asking a non-year alternative", () => {
const output = richOutput();
const legitimate = {
...output,
narrative: "你在2014年开始读研,并在2017年正常毕业,这条教育线已经完整。毕业后的第一份工作是直接入职,还是先休息了一段时间?",
evidenceRequest: {
domains: ["career" as const],
datePrecision: "month_preferred" as const,
prompt: "毕业后的第一份工作是什么时候开始的?",
},
} satisfies RectificationNarrativeModelOutput;
assert.deepEqual(
validateNarrativeAgainstPacket(legitimate, syntheticTechnicalPacket(), "intermediate"),
{ 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 citation-marked 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("does not mistake ordinary machine-readable status words for technical citations", () => {
const output = {
...richOutput(),
narrative: `${richOutput().narrative}\n当前仍是 pending_validation,并遵守 not_auto_rectified 边界。`,
} satisfies RectificationNarrativeModelOutput;
assert.deepEqual(
validateNarrativeAgainstPacket(output, syntheticTechnicalPacket(), "first"),
{ valid: true, issues: [] },
);
});
test("retries a grounded validation failure once with a compact 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.ok((prompts[0]?.length ?? Number.POSITIVE_INFINITY) < 5_000);
assert.equal(prompts.some((prompt) => prompt.includes("scoredHistoricalEvidence")), false);
assert.equal(prompts.some((prompt) => prompt.includes("referenceIds")), false);
assert.equal(prompts.some((prompt) => prompt.includes("candidateWeights")), false);
assert.equal(prompts.some((prompt) => prompt.includes("private-partition")), false);
});
test("gives each validation attempt a fresh bounded deadline", async () => {
const signals: Array<AbortSignal | undefined> = [];
const attempts: Array<1 | 2 | undefined> = [];
const invalid = { ...richOutput(), representativeTime: "06:45" };
const result = await generateRectificationNarrative({
phase: "first",
packet: syntheticTechnicalPacket(),
generator: {
modelId: "test-model",
async generate(_prompt, options) {
signals.push(options?.signal);
attempts.push(options?.attempt);
return { text: JSON.stringify(signals.length === 1 ? invalid : richOutput()) };
},
},
});
assert.equal(result.fallbackUsed, false);
assert.equal(signals.length, 2);
assert.ok(signals[0] instanceof AbortSignal);
assert.ok(signals[1] instanceof AbortSignal);
assert.notEqual(signals[0], signals[1]);
assert.deepEqual(attempts, [1, 2]);
});
test("accepts lightweight model-authored output and injects packet facts on the server", async () => {
const packet = syntheticTechnicalPacket();
const result = await generateRectificationNarrative({
phase: "first",
packet,
generator: generator([{
narrative: "先从一件你印象最深、时间也比较明确的人生转折说起吧。",
evidenceRequest: {
datePrecision: "year_accepted",
prompt: "那件事大约发生在哪一年?",
},
}]),
});
assert.equal(result.attempts, 1);
assert.equal(result.output.candidateStatus, packet.candidate.status);
assert.equal(result.output.representativeTime, packet.candidate.representativeTime);
assert.deepEqual(result.output.stableLayers, ["D1"]);
assert.deepEqual(result.output.sensitiveLayers, ["D9", "D10"]);
assert.deepEqual(result.output.referenceIds, []);
assert.deepEqual(result.output.domainReasons, packet.suggestedDomains);
assert.deepEqual(result.output.evidenceRequest?.domains, ["relationship", "career"]);
});
test("replaces legacy model-selected evidence domains instead of rejecting the answer", async () => {
const packet = syntheticTechnicalPacket();
const output = richOutput();
const result = await generateRectificationNarrative({
phase: "intermediate",
packet,
generator: generator([{
...output,
evidenceRequest: {
...output.evidenceRequest,
domains: ["finance"],
},
}]),
});
assert.equal(result.attempts, 1);
assert.deepEqual(result.output.evidenceRequest?.domains, ["relationship", "career"]);
});
test("uses a fresh second provider request after the first attempt times out", async () => {
let calls = 0;
const signals: Array<AbortSignal | undefined> = [];
const result = await generateRectificationNarrative({
phase: "first",
packet: syntheticTechnicalPacket(),
generator: {
modelId: "pro-model",
async generate(_prompt, options) {
calls += 1;
signals.push(options?.signal);
if (options?.attempt === 1) {
throw new DOMException("timed out", "TimeoutError");
}
return {
text: JSON.stringify(richOutput()),
modelId: "flash-model",
};
},
},
});
assert.equal(calls, 2);
assert.equal(result.attempts, 2);
assert.equal(result.fallbackUsed, false);
assert.equal(result.validationReceipt.modelId, "flash-model");
assert.equal(result.validationReceipt.retryCount, 1);
assert.ok(signals[0] instanceof AbortSignal);
assert.ok(signals[1] instanceof AbortSignal);
assert.notEqual(signals[0], signals[1]);
});
test("records first-turn generation timeouts separately from schema failures", async () => {
const warnings: string[] = [];
const originalWarn = console.warn;
console.warn = (...values: unknown[]) => warnings.push(values.map(String).join(" "));
try {
await assert.rejects(
generateRectificationNarrative({
phase: "first",
packet: syntheticTechnicalPacket(),
generator: {
modelId: "test-model",
async generate() { throw new DOMException("timed out", "TimeoutError"); },
},
}),
/RectificationNarrativeUnavailable/,
);
} finally {
console.warn = originalWarn;
}
assert.match(warnings.at(-1) ?? "", /"issueCodes":\["timeout"\]/);
});
test("rejects the first turn instead of presenting a deterministic template as an agent answer", async () => {
const packet = syntheticTechnicalPacket();
const invalid = { ...richOutput(), sensitiveLayers: ["D60"] };
await assert.rejects(
generateRectificationNarrative({
phase: "first",
packet,
generator: generator([invalid, invalid]),
}),
/RectificationNarrativeUnavailable/,
);
});
test("rejects an invalid intermediate narrative instead of showing a deterministic template", async () => {
const inventedReference = `invented-${"x".repeat(500)}`;
const invalid = {
...richOutput(),
narrative: `${richOutput().narrative}\n另见【${inventedReference}】。`,
};
await assert.rejects(
generateRectificationNarrative({
phase: "intermediate",
packet: syntheticTechnicalPacket(),
generator: generator([invalid, invalid]),
}),
/RectificationNarrativeUnavailable/,
);
});
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}\\"`));
}
});