Files
Jyotisha/frontend/tests/conversational-narrative-agent.test.ts
T
2026-07-26 20:11:53 +08:00

1177 lines
44 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("allows a first-turn acknowledgement without forcing a question", async () => {
const narrative = "我们先把你记得的出生范围当作起点。你可以按自己的节奏往下讲。";
const result = await generateRectificationNarrative({
phase: "first",
packet: syntheticTechnicalPacket(),
generator: generator([{ narrative, evidenceRequest: null }]),
});
assert.equal(result.fallbackUsed, false);
assert.equal(result.narrative, narrative);
assert.equal(result.output.evidenceRequest, null);
assert.doesNotMatch(result.narrative, /[?]/);
});
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("keeps a natural intermediate acknowledgement without appending a question", async () => {
const narrative = "已记录本科毕业后衔接读研,这是一段连续教育转折,暂时不重复计数。你可以继续讲。";
const result = await generateRectificationNarrative({
phase: "intermediate",
packet: syntheticTechnicalPacket(),
generator: generator([{ narrative, evidenceRequest: null }]),
});
assert.equal(result.fallbackUsed, false);
assert.equal(result.narrative, narrative);
assert.equal(result.output.evidenceRequest, null);
assert.doesNotMatch(result.narrative, /[?]/);
});
test("keeps technical packets and event bookkeeping out of ordinary narrative turns", 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,
context: {
latestUserText: "2016 年离家去外地上大学",
recentConversation: [{ role: "user", text: "2016 年离家去外地上大学" }],
eventLedger: [{
id: "00000000-0000-4000-8000-000000000801",
rawText: "2016 年离家去外地上大学",
dateLabel: "2016",
summary: "离家去外地上大学",
domain: "education",
extractionStatus: "clear",
active: true,
correctsEvidenceIds: [],
}],
},
generator: generator([{
narrative: "第一次长期离开家,生活节奏应该一下子变了很多。你可以接着讲后来发生的事。",
evidenceRequest: null,
}], prompts),
});
assert.match(prompts[0] ?? "", /2016 年离家去外地上大学/);
assert.doesNotMatch(prompts[0] ?? "", /packet/);
assert.doesNotMatch(prompts[0] ?? "", /eventLedger/);
assert.doesNotMatch(prompts[0] ?? "", /expertWorkflow/);
assert.doesNotMatch(prompts[0] ?? "", /KP cusp \/ sub-lord/);
assert.doesNotMatch(prompts[0] ?? "", /minute_holdout_not_ready/);
assert.doesNotMatch(prompts[0] ?? "", /05:16|05:24|D9|D10/);
});
test("drops a hidden follow-up that the user cannot see", async () => {
const hidden = {
narrative: "第一次长期离开家,生活节奏应该一下子变了很多。你可以接着讲后来发生的事。",
evidenceRequest: {
datePrecision: "month_preferred" as const,
prompt: "2016 年离家去外地上大学大概是几月?",
followUp: { kind: "new_event" as const, evidenceId: null },
},
};
const result = await generateRectificationNarrative({
phase: "intermediate",
packet: syntheticTechnicalPacket(),
context: { latestUserText: "2016 年离家去外地上大学" },
generator: generator([hidden]),
});
assert.equal(result.attempts, 1);
assert.equal(result.fallbackUsed, false);
assert.equal(result.output.evidenceRequest, null);
});
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, /候选范围/);
assert.match(result.narrative, /系统验证尚未闭环/);
assert.match(result.narrative, /不会替换当前排盘时间/);
assert.doesNotMatch(result.narrative, /先说一件|再说一件/);
assert.equal(result.output.evidenceRequest, null);
assert.match(result.narrative, /\| 时间 \| 事件 \| 领域 \| 验证状态 \| 结论 \|/);
});
test("passes the user's latest words to an ordinary intermediate reply", async () => {
const prompts: string[] = [];
await generateRectificationNarrative({
phase: "intermediate",
packet: syntheticTechnicalPacket(),
context: {
latestUserText: "2023年9月离开家乡去上海开始第一份长期工作",
latestEvidence: [{
dateLabel: "2023-09",
summary: "离开家乡去上海开始第一份长期工作",
domain: "career",
}],
},
generator: generator([{
narrative: "第一次长期离开家去工作,适应过程应该不轻松。后来最先发生了什么?",
evidenceRequest: null,
}], prompts),
});
assert.match(prompts[0] ?? "", /离开家乡去上海开始第一份长期工作/);
assert.match(prompts[0] ?? "", /不要把自己写成记录员/);
assert.match(prompts[0] ?? "", /必须用一个贴着上下文的开放问题收尾/);
assert.doesNotMatch(prompts[0] ?? "", /latestEvidence/);
assert.doesNotMatch(prompts[0] ?? "", /候选范围.*05:16/);
});
test("adds a lightweight guide when an event reply only empathizes", async () => {
const prompts: string[] = [];
const result = await generateRectificationNarrative({
phase: "intermediate",
packet: syntheticTechnicalPacket(),
context: {
latestUserText: "实习到10月份,发现有人抢项目骗经费,我就辞职了",
latestEvidence: [{
dateLabel: "2020-10",
summary: "因价值观冲突辞职",
domain: "career",
}],
},
generator: generator([{
narrative: "这段经历确实很冲击,也能理解你为什么不愿意继续留下。",
evidenceRequest: null,
}], prompts),
});
assert.equal(result.attempts, 1);
assert.equal(result.fallbackUsed, false);
assert.match(result.narrative, /这件事之后,紧接着发生了什么?$/);
assert.equal(prompts.length, 1);
});
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 {
conversation?: Record<string, unknown>;
packet?: Record<string, unknown>;
};
assert.equal(prompt.conversation?.latestEvidence, undefined);
assert.equal(prompt.conversation?.eventLedger, undefined);
assert.equal(prompt.packet?.suggestedDomains, undefined);
});
test("keeps the event ledger and unresolved facts out of the ordinary 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.doesNotMatch(prompt, /2024-08-08/);
assert.doesNotMatch(prompt, /eventLedger|unresolvedEvidence|resolveDateContradictionsBeforeScoring/);
});
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"),
evidenceRequest: null,
} satisfies RectificationNarrativeModelOutput;
assert.deepEqual(validateNarrativeAgainstPacket(output, packet, "intermediate"), { valid: true, issues: [] });
});
test("replaces a generic broad-year choice questionnaire with the safe fallback", 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.fallbackUsed, true);
assert.equal(result.output.evidenceRequest, null);
assert.doesNotMatch(result.narrative, /20182020/);
});
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("rejects a date confirmation question without structured follow-up state", () => {
const output = richOutput();
assert.ok(output.evidenceRequest);
const invalid = {
...output,
evidenceRequest: {
...output.evidenceRequest,
prompt: "这个10月是2020年10月吗?",
},
} satisfies RectificationNarrativeModelOutput;
const result = validateNarrativeAgainstPacket(invalid, syntheticTechnicalPacket(), "intermediate");
assert.equal(result.valid, false);
assert.ok(result.issues.includes("date confirmation prompt lacks structured proposedDate"));
});
test("retries when an affirmative answer receives the same resolved date question", async () => {
const evidenceId = "11111111-1111-4111-8111-111111111111";
const repeatedPrompt = "这个10月是2020年10月吗?";
const followUp = {
kind: "event_date" as const,
evidenceId,
answerMode: "yes_no" as const,
proposedDate: { value: "2020-10", precision: "month" as const },
};
const output = richOutput();
assert.ok(output.evidenceRequest);
const repeated = {
...output,
evidenceRequest: {
...output.evidenceRequest,
prompt: repeatedPrompt,
followUp,
},
} satisfies RectificationNarrativeModelOutput;
const result = await generateRectificationNarrative({
phase: "intermediate",
packet: syntheticTechnicalPacket(),
context: {
latestUserText: "是的",
previousEvidencePrompt: repeatedPrompt,
previousFollowUp: followUp,
},
generator: generator([repeated, output]),
});
assert.equal(result.attempts, 2);
assert.notEqual(result.output.evidenceRequest?.prompt, repeatedPrompt);
});
test("rejects a follow-up that still targets evidence completed by an affirmative answer", () => {
const evidenceId = "11111111-1111-4111-8111-111111111111";
const previousFollowUp = {
kind: "event_date" as const,
evidenceId,
answerMode: "yes_no" as const,
proposedDate: { value: "2020-10", precision: "month" as const },
};
const output = richOutput();
assert.ok(output.evidenceRequest);
const invalid = {
...output,
evidenceRequest: {
...output.evidenceRequest,
prompt: "这段实习结束后,下一份工作是什么时候开始的?",
followUp: {
kind: "event_detail" as const,
evidenceId,
answerMode: "free_text" as const,
proposedDate: null,
},
},
} satisfies RectificationNarrativeModelOutput;
const result = validateNarrativeAgainstPacket(
invalid,
syntheticTechnicalPacket(),
"intermediate",
{ latestUserText: "是的", previousFollowUp },
);
assert.equal(result.valid, false);
assert.ok(result.issues.includes("resolved follow-up still targets completed evidence"));
});
test("allows natural discussion of an event after it has contributed to scoring", () => {
const evidenceId = "11111111-1111-4111-8111-111111111111";
const packet: RectificationTechnicalPacket = {
...syntheticTechnicalPacket(),
scoredHistoricalEvidence: [{
evidenceId,
domain: "career",
candidateTime: "05:20",
score: 3.5,
ruleRefs: ["vim-md-career"],
}],
};
const output = richOutput();
assert.ok(output.evidenceRequest);
const conversational = {
...output,
narrative: "我理解,这次离开不只是换工作。你当时为什么辞职,是主动还是被动,对生活有什么影响?",
evidenceRequest: {
...output.evidenceRequest,
prompt: "你当时为什么辞职,是主动还是被动,对生活有什么影响?",
followUp: {
kind: "event_detail" as const,
evidenceId,
answerMode: "free_text" as const,
proposedDate: null,
},
},
} satisfies RectificationNarrativeModelOutput;
assert.deepEqual(validateNarrativeAgainstPacket(conversational, packet, "intermediate"), {
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,
narrative: `${output.narrative}\n${output.evidenceRequest?.prompt ?? ""}`,
evidenceRequest: {
...output.evidenceRequest,
domains: ["finance"],
},
}]),
});
assert.equal(result.attempts, 1);
assert.deepEqual(result.output.evidenceRequest?.domains, ["relationship", "career"]);
});
test("keeps authored prose but drops follow-up state when no grounded routing domain exists", async () => {
const packet = { ...syntheticTechnicalPacket(), suggestedDomains: [] };
const result = await generateRectificationNarrative({
phase: "intermediate",
packet,
generator: generator([{
narrative: "这段经历已经记下。你愿意的话,可以继续讲当时发生了什么。",
evidenceRequest: {
domains: ["career"],
datePrecision: "month_preferred",
prompt: "当时发生了什么?",
followUp: { kind: "new_event", evidenceId: null },
},
}]),
});
assert.equal(result.attempts, 1);
assert.equal(result.fallbackUsed, false);
assert.equal(result.output.evidenceRequest, null);
assert.match(result.narrative, /继续讲/);
});
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("retries when a final narrative asks for more evidence", async () => {
const invalid = richOutput();
const valid = {
...richOutput(),
narrative: "当前证据只能支持候选范围,本次不再要求继续提供人生事件。",
evidenceRequest: null,
} satisfies RectificationNarrativeModelOutput;
const result = await generateRectificationNarrative({
phase: "final",
packet: syntheticTechnicalPacket(),
generator: generator([invalid, valid]),
});
assert.equal(result.attempts, 2);
assert.equal(result.fallbackUsed, false);
assert.equal(result.output.evidenceRequest, null);
});
test("does not force a retry when the Agent naturally discusses a scored event", async () => {
const evidenceId = "00000000-0000-4000-8000-000000000709";
const packet = {
...syntheticTechnicalPacket(),
scoredHistoricalEvidence: [{
evidenceId,
domain: "education" as const,
candidateTime: "05:20",
score: 8,
ruleRefs: ["synthetic-education-rule"],
}],
};
const output = {
...richOutput(),
narrative: "我理解,这几个月的学业压力不只是结果,也影响了你当时的选择。",
evidenceRequest: null,
};
const result = await generateRectificationNarrative({
phase: "intermediate",
packet,
context: {
eventLedger: [{
id: evidenceId,
rawText: "1972年12月因为学业压力正式退学",
dateLabel: "1972-12",
summary: "因为学业压力正式退学",
domain: "education",
extractionStatus: "clear",
active: true,
correctsEvidenceIds: [],
}],
},
generator: generator([output]),
});
assert.equal(result.attempts, 1);
assert.equal(result.fallbackUsed, false);
assert.equal(result.output.evidenceRequest, null);
assert.equal(result.narrative, output.narrative);
});
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(" "));
let result: Awaited<ReturnType<typeof generateRectificationNarrative>>;
try {
result = await generateRectificationNarrative({
phase: "first",
packet: syntheticTechnicalPacket(),
generator: {
modelId: "test-model",
async generate() { throw new DOMException("timed out", "TimeoutError"); },
},
});
} finally {
console.warn = originalWarn;
}
assert.equal(result.fallbackUsed, true);
assert.match(warnings.at(-1) ?? "", /"issueCodes":\["timeout"\]/);
});
test("falls back safely when the first-turn model output is invalid", async () => {
const packet = syntheticTechnicalPacket();
const invalid = { ...richOutput(), sensitiveLayers: ["D60"] };
const result = await generateRectificationNarrative({
phase: "first",
packet,
generator: generator([invalid, invalid]),
});
assert.equal(result.fallbackUsed, true);
assert.doesNotMatch(result.narrative, /D60/);
});
test("falls back safely when an intermediate narrative is invalid", async () => {
const inventedReference = `invented-${"x".repeat(500)}`;
const invalid = {
...richOutput(),
narrative: `${richOutput().narrative}\n另见【${inventedReference}】。`,
};
const result = await generateRectificationNarrative({
phase: "intermediate",
packet: syntheticTechnicalPacket(),
generator: generator([invalid, invalid]),
});
assert.equal(result.fallbackUsed, true);
assert.doesNotMatch(result.narrative, /invented-/);
});
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}\\"`));
}
});