fix(rectification): fall back when narrative models fail

This commit is contained in:
Jesse_Chen
2026-07-26 14:27:17 +08:00
parent 68c1bdba46
commit f8576a01e0
3 changed files with 50 additions and 49 deletions
+15
View File
@@ -1444,3 +1444,18 @@
- 防复发:跨语言 wire schema 必须覆盖 Python 真源可返回的枚举;诊断状态在适配边界归一化,内部领域模型继续保持最小稳定集合。
- 相关记录:BUG-067、BUG-075
- 修复版本:本次修复提交
## BUG-078 | 叙事模型超时导致已完成的事件评分整轮返回 503
- 状态:resolved
- 首次发现:2026-07-26
- 最近更新:2026-07-26
- 影响面:生时校正开放叙事、历史事件保存、候选收敛、Narrative Agent 降级路径
- 用户现象:历史事件已被成功抽取和技术评分,但主叙事模型与备用模型超时后,整轮返回 503,用户必须重试且无法看到已记录结果。
- 触发条件:非最终轮的两次叙事生成均超时、报错或未通过安全校验。
- 根因:安全的 packet 驱动兜底已经存在,却只允许最终轮使用;首轮和中间轮在两次生成失败后直接抛出 `RectificationNarrativeUnavailable`,把非关键的文案依赖变成了事件记录与收敛的硬依赖。
- 修复:所有阶段在两次生成失败后统一使用既有的确定性安全兜底;保留已完成的事件记录、技术评分与候选推进,`evidenceRequest` 为 null,不机械追问,也不展示模型未验证内容。
- 验证:回归覆盖首轮超时、首轮非法问卷、首轮非法技术层和中间轮虚构引用,均返回安全兜底且不泄漏被拒内容;Narrative Agent 聚焦测试、目标 ESLint、生产构建与 `git diff --check` 通过。
- 防复发:叙事模型只能影响自然语言表达,不能成为事件持久化和确定性收敛的单点故障;降级输出必须来自已验证 packet。
- 相关记录:BUG-067、BUG-075、BUG-077
- 修复版本:本次修复提交
@@ -784,17 +784,6 @@ export async function generateRectificationNarrative(input: {
issues = [...issues, ...attemptIssues];
}
}
if (input.phase !== "final") {
logNarrativeGeneration({
phase: input.phase,
retryCount: 1,
fallbackUsed: false,
source: "failed",
issues: boundedReceiptIssues(issues),
startedAt,
});
throw new Error("RectificationNarrativeUnavailable");
}
const output = fallbackOutput(input.packet, input.phase);
const narrative = input.phase === "final"
? appendFinalAnalysisTables(output.narrative, input.packet, input.context ?? {})
@@ -569,7 +569,7 @@ test("allows packet-grounded technical tables during an intermediate turn", () =
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 () => {
test("replaces a generic broad-year choice questionnaire with the safe fallback", async () => {
const invalid = {
...richOutput(),
narrative: [
@@ -588,14 +588,14 @@ test("rejects a generic broad-year choice questionnaire without replacing the fi
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/,
);
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", () => {
@@ -1030,52 +1030,49 @@ test("records first-turn generation timeouts separately from schema failures", a
const warnings: string[] = [];
const originalWarn = console.warn;
console.warn = (...values: unknown[]) => warnings.push(values.map(String).join(" "));
let result: Awaited<ReturnType<typeof generateRectificationNarrative>>;
try {
await assert.rejects(
generateRectificationNarrative({
phase: "first",
packet: syntheticTechnicalPacket(),
generator: {
modelId: "test-model",
async generate() { throw new DOMException("timed out", "TimeoutError"); },
},
}),
/RectificationNarrativeUnavailable/,
);
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("rejects the first turn instead of presenting a deterministic template as an agent answer", async () => {
test("falls back safely when the first-turn model output is invalid", async () => {
const packet = syntheticTechnicalPacket();
const invalid = { ...richOutput(), sensitiveLayers: ["D60"] };
await assert.rejects(
generateRectificationNarrative({
phase: "first",
packet,
generator: generator([invalid, invalid]),
}),
/RectificationNarrativeUnavailable/,
);
const result = await generateRectificationNarrative({
phase: "first",
packet,
generator: generator([invalid, invalid]),
});
assert.equal(result.fallbackUsed, true);
assert.doesNotMatch(result.narrative, /D60/);
});
test("rejects an invalid intermediate narrative instead of showing a deterministic template", async () => {
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}】。`,
};
await assert.rejects(
generateRectificationNarrative({
phase: "intermediate",
packet: syntheticTechnicalPacket(),
generator: generator([invalid, invalid]),
}),
/RectificationNarrativeUnavailable/,
);
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 () => {