diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 56ed733f..701bdaca 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -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 +- 修复版本:本次修复提交 diff --git a/frontend/src/lib/conversational-rectification/narrative-agent.ts b/frontend/src/lib/conversational-rectification/narrative-agent.ts index 26f8e3d9..b951f372 100644 --- a/frontend/src/lib/conversational-rectification/narrative-agent.ts +++ b/frontend/src/lib/conversational-rectification/narrative-agent.ts @@ -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 ?? {}) diff --git a/frontend/tests/conversational-narrative-agent.test.ts b/frontend/tests/conversational-narrative-agent.test.ts index 7ddde858..b4018e00 100644 --- a/frontend/tests/conversational-narrative-agent.test.ts +++ b/frontend/tests/conversational-narrative-agent.test.ts @@ -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>; 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 () => {