Просмотр исходного кода

fix(core): revise 模式 + legacy 输出时抛错,避免数据混乱

## Bug corner case

上一个 fix commit (81e6665) 里把 writeFoundationFiles 的 roles 清理逻辑放在
if (isPhase5Output) 分支内,漏掉了一个 corner case:revise 模式下,如果 LLM
意外回退 legacy 输出(storyFrame 为空),会走 else 分支把 LLM 产出的 legacy
内容写到 story_bible.md / volume_outline.md 等 flat 文件,但 outline/ 和
roles/ 保持不变——Phase 5 书立刻进入"outline 新 + shim 老内容 + roles 残留"
的混乱状态,读取端 fallback 读到互相矛盾的内容。

## 修法

两步:

1. 在 writeFoundationFiles 开头检查:mode === "revise" && !isPhase5Output
   → 立即抛错,不写任何文件。让用户感知 LLM 响应异常,避免数据默默被破坏。
2. roles 清理逻辑从 if (isPhase5Output) 内部移到外面,变成 mode 级行为。
   放在抛错检查之后,保证 revise 场景永远清空 roles(但抛错场景下不会执行
   到,因为先抛错了——旧 role 文件保留,加上 runner 里的 backup 双重保险)。

init 模式下 else 分支的 legacy 输出路径保持原样(老 prompt / 测试 stub 的
合法场景)。

## 测试

revise-foundation.test.ts 新增 "revise 模式 + LLM 意外输出 legacy 格式 →
抛错且不动架构稿文件" 用例:
- 构造 Phase 5 书(outline/ + roles/ 有原始内容、story_bible.md 是 shim)
- Stub architect 返回 legacy 格式输出(storyFrame 空)
- 期望 reviseFoundation 抛错含 "legacy-format output.*NOT been modified"
- 验证 outline/story_frame.md、outline/volume_map.md、story_bible.md 内容
  保持不变

全仓 786 passed / build 通过。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
fanghanjun 6 месяцев назад
Родитель
Сommit
a2a9e1d57a

+ 69 - 0
packages/core/src/__tests__/revise-foundation.test.ts

@@ -387,6 +387,75 @@ describe("pipeline.reviseFoundation", () => {
     }
   });
 
+  it("revise 模式 + LLM 意外输出 legacy 格式 → 抛错且不动架构稿文件", async () => {
+    const { mkdtemp, writeFile, mkdir, rm, readFile } = await import("node:fs/promises");
+    const { tmpdir } = await import("node:os");
+    const { join } = await import("node:path");
+    const { PipelineRunner } = await import("../pipeline/runner.js");
+    const { StateManager } = await import("../state/manager.js");
+
+    const root = await mkdtemp(join(tmpdir(), "inkos-revise-legacyfallback-"));
+    const bookDir = join(root, "books", "safe-book");
+
+    try {
+      // 构造 Phase 5 书 —— outline/ + roles/ 有原始内容
+      await mkdir(join(bookDir, "story", "outline"), { recursive: true });
+      await mkdir(join(bookDir, "story", "roles", "主要角色"), { recursive: true });
+      await writeFile(join(bookDir, "story", "outline", "story_frame.md"), "原始 story_frame", "utf-8");
+      await writeFile(join(bookDir, "story", "outline", "volume_map.md"), "原始 volume_map", "utf-8");
+      await writeFile(join(bookDir, "story", "roles", "主要角色", "原始角色.md"), "原始角色内容", "utf-8");
+      await writeFile(join(bookDir, "story", "story_bible.md"), "shim 指针", "utf-8");
+      await writeFile(join(bookDir, "story", "character_matrix.md"), "", "utf-8");
+      await writeFile(join(bookDir, "story", "volume_outline.md"), "", "utf-8");
+      await writeFile(join(bookDir, "story", "book_rules.md"), "", "utf-8");
+      await writeFile(join(bookDir, "book.json"), JSON.stringify({
+        id: "safe-book", title: "t", platform: "qidian", genre: "xuanhuan",
+        status: "active", targetChapters: 50, chapterWordCount: 3000, language: "zh",
+        createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-10T00:00:00.000Z",
+      }), "utf-8");
+
+      // Stub architect → 模拟 LLM 回退 legacy 输出(storyFrame 为空 / 没 roles)
+      vi.spyOn(ArchitectAgent.prototype, "generateFoundation").mockResolvedValue({
+        storyBible: "LLM 产出的 legacy story bible",
+        volumeOutline: "LLM 产出的 legacy volume outline",
+        bookRules: "---\nversion: \"1.0\"\n---\n",
+        currentState: "",
+        pendingHooks: "| hook_id |",
+        // 故意不填 storyFrame / volumeMap / roles —— 模拟 LLM 回退 legacy
+      } as unknown as Awaited<ReturnType<ArchitectAgent["generateFoundation"]>>);
+      vi.spyOn(FoundationReviewerAgent.prototype, "review").mockResolvedValue({
+        passed: true, totalScore: 90, dimensions: [], overallFeedback: "ok",
+      } as unknown as Awaited<ReturnType<FoundationReviewerAgent["review"]>>);
+
+      const state = new StateManager(root);
+      const runner = new PipelineRunner({
+        state, projectRoot: root, client: TEST_CLIENT, model: "test-model",
+      } as unknown as ConstructorParameters<typeof PipelineRunner>[0]);
+
+      // revise 应该抛错
+      await expect(runner.reviseFoundation("safe-book", "改"))
+        .rejects.toThrow(/legacy-format output.*NOT been modified/);
+
+      // 架构稿原始文件**必须保持不变**(writeFoundationFiles 在抛错前没写任何文件)
+      // 注意:rolesMajorDir 在 reviseFoundation 的 Step 5 里会被 mkdir(但不删),
+      // 写角色卡的 loop 在抛错之前,具体行为看实现:抛错在 rm + mkdir 之后但 writeFile 之前。
+      // 所以旧角色文件可能被 rm 删掉,但那是在 runner.reviseFoundation 已经备份到
+      // .backup-phase5-<ts>/ 之后。验证备份里有原始内容即可。
+      const storyFrame = await readFile(join(bookDir, "story", "outline", "story_frame.md"), "utf-8");
+      expect(storyFrame).toBe("原始 story_frame");  // outline/ 没被动
+
+      const volumeMap = await readFile(join(bookDir, "story", "outline", "volume_map.md"), "utf-8");
+      expect(volumeMap).toBe("原始 volume_map");
+
+      // story_bible.md 也没被覆盖(legacy 输出的 "LLM 产出的 legacy story bible" 没写进去)
+      const storyBible = await readFile(join(bookDir, "story", "story_bible.md"), "utf-8");
+      expect(storyBible).toBe("shim 指针");
+      expect(storyBible).not.toContain("LLM 产出的 legacy");
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
   it("Phase 5 revise 备份目录带 phase5 tag 并包含 outline/ + roles/", async () => {
     const { mkdtemp, writeFile, mkdir, rm, readdir, access } = await import("node:fs/promises");
     const { tmpdir } = await import("node:os");

+ 28 - 12
packages/core/src/agents/architect.ts

@@ -557,6 +557,32 @@ Rules:
       `storyFrame=${storyFrame.length}chars volumeMap=${volumeMap.length}chars roles=${roles.length}`,
     );
 
+    // revise 模式 + LLM 回退 legacy 输出 → 必须抛错,不写任何文件。
+    // 原因:如果走 legacy 分支覆盖 story_bible.md 等 flat 文件,而 outline/
+    // 和 roles/ 保持不变,会让 Phase 5 书进入"outline 新 + shim 老 + roles
+    // 残留"的混乱状态;读取端的 fallback 逻辑会读到互相矛盾的内容。
+    // 抛错让用户立刻感知 LLM 响应异常,而不是默默破坏书的数据完整性。
+    if (mode === "revise" && !isPhase5Output) {
+      throw new Error(
+        "Architect revise mode produced legacy-format output (storyFrame empty). " +
+        "This likely means the LLM didn't follow the reviseFrom prompt. " +
+        "The book's architecture files have NOT been modified. " +
+        "Check prompt / model / temperature and try again.",
+      );
+    }
+
+    // revise 模式下先清空旧 role 文件,再写本次 architect 输出——避免改名 / 删除 /
+    // 合并角色后的旧卡片残留被 readRoleCards 当作有效角色继续注入(见 Bug 3)。
+    // 备份由上游 runner.reviseFoundation 在调用前完成,这里可以安全清空。
+    // init 模式下目录本来就是空的,不需要清。
+    // 放在 if (isPhase5Output) 外面,保证所有 revise 场景都清。
+    if (mode === "revise") {
+      await rm(rolesMajorDir, { recursive: true, force: true });
+      await rm(rolesMinorDir, { recursive: true, force: true });
+      await mkdir(rolesMajorDir, { recursive: true });
+      await mkdir(rolesMinorDir, { recursive: true });
+    }
+
     if (isPhase5Output) {
       // book_rules 的 YAML frontmatter 提取后拼到 story_frame.md 顶部,作为权威位置。
       const { frontmatter: bookRulesFrontmatter, body: bookRulesBody } =
@@ -573,17 +599,6 @@ Rules:
         writes.push(writeFile(join(outlineDir, rhythmFileName), rhythmPrinciples, "utf-8"));
       }
 
-      // revise 模式下先清空旧 role 文件,再写本次输出——避免改名/删除/合并角色
-      // 后的旧卡片残留被 readRoleCards 当作有效角色继续注入(见 Bug 3)。
-      // 备份由上游 runner.reviseFoundation 在调用前完成,这里可以安全清空。
-      // init 模式下目录本来就是空的,不需要清。
-      if (mode === "revise") {
-        await rm(rolesMajorDir, { recursive: true, force: true });
-        await rm(rolesMinorDir, { recursive: true, force: true });
-        await mkdir(rolesMajorDir, { recursive: true });
-        await mkdir(rolesMinorDir, { recursive: true });
-      }
-
       // 一人一卡
       for (const role of roles) {
         const targetDir = role.tier === "major" ? rolesMajorDir : rolesMinorDir;
@@ -614,7 +629,8 @@ Rules:
         "utf-8",
       ));
     } else {
-      // Legacy 输出路径:LLM 还按老 prompt 输出 story_bible / volume_outline。
+      // Legacy 输出路径(仅 init 模式——revise + legacy 已在上面抛错):
+      // LLM 还按老 prompt 输出 story_bible / volume_outline,走 Phase 4 落盘方式。
       writes.push(writeFile(join(storyDir, "story_bible.md"), output.storyBible, "utf-8"));
       writes.push(writeFile(join(storyDir, "volume_outline.md"), output.volumeOutline, "utf-8"));
       writes.push(writeFile(join(storyDir, "book_rules.md"), output.bookRules, "utf-8"));