fix(rectification): fall back when narrative models fail
This commit is contained in:
@@ -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, /2018–2020/);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
|
||||
Reference in New Issue
Block a user