Przeglądaj źródła

feat(core): 旧书架构稿升级路径 — architect revise + pipeline.reviseFoundation

Task 3.1:sub_agent 的 architect 参数新增 Optional revise + feedback 字段。
architect case 在 revise=true 时走 pipeline.reviseFoundation 分支,其他参数不变。

Task 3.2:
- architect.generateFoundation 新增第 4 个 options 参数,可带 reviseFrom 把
  老的条目式架构稿全文 + 用户反馈传进来。buildRevisePrompt 把这些拼成转换
  前缀注入到 system prompt 头部。原 3-参调用点(initBook / initFanficBook)
  保持不变。
- runner.reviseFoundation(bookId, feedback) 方法:先把 story_bible.md /
  volume_outline.md / book_rules.md / character_matrix.md 备份到
  story/.backup-phase4-<timestamp>/,读原内容喂给 architect(reviseFrom
  分支产出 Phase 5 结构),跑一次 foundation-reviewer(失败只 warn 不阻断),
  最后用 writeFoundationFiles 覆盖旧文件为 shim。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
fanghanjun 6 miesięcy temu
rodzic
commit
e4d94ca602

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

@@ -0,0 +1,200 @@
+import { describe, it, expect, vi, afterEach } from "vitest";
+import { ArchitectAgent } from "../agents/architect.js";
+import { FoundationReviewerAgent } from "../agents/foundation-reviewer.js";
+import type { ArchitectOutput } from "../agents/architect.js";
+
+describe("architect generateFoundation with reviseFrom option", () => {
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });
+
+  it("injects legacy content into the system prompt when reviseFrom is supplied", async () => {
+    const agent = new ArchitectAgent({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0, maxTokensCap: null,
+          extra: {},
+        },
+      },
+      model: "test-model",
+      projectRoot: process.cwd(),
+    });
+
+    const chatSpy = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
+      .mockResolvedValue({
+        content: [
+          "=== SECTION: story_frame ===",
+          "## 主题",
+          "新段落主题",
+          "",
+          "=== SECTION: volume_map ===",
+          "## 段 1",
+          "新卷一",
+          "",
+          "=== SECTION: roles ===",
+          "---ROLE---",
+          "tier: major",
+          "name: 林辞",
+          "---CONTENT---",
+          "主角",
+          "",
+          "=== SECTION: book_rules ===",
+          "---",
+          "version: \"1.0\"",
+          "---",
+          "",
+          "=== SECTION: pending_hooks ===",
+          "| hook_id |",
+        ].join("\n"),
+        usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
+      });
+
+    await agent.generateFoundation(
+      {
+        id: "test-book", title: "测试书", platform: "qidian", genre: "xuanhuan",
+        status: "active", targetChapters: 50, chapterWordCount: 3000, language: "zh",
+        createdAt: "2026-04-19T00:00:00.000Z", updatedAt: "2026-04-19T00:00:00.000Z",
+      },
+      undefined,
+      undefined,
+      {
+        reviseFrom: {
+          storyBible: "- 旧世界观:架空唐代\n- 旧主角:林辞",
+          volumeOutline: "## 第一卷\n- 1. 主角登场",
+          bookRules: "## 规则\n- 禁现代词",
+          characterMatrix: "林辞 - 主角",
+          userFeedback: "升级到段落式架构稿",
+        },
+      },
+    );
+
+    const systemMsg = (chatSpy.mock.calls[0]?.[0] as Array<{ role: string; content: string }>)[0]!;
+    expect(systemMsg.content).toContain("把一本已有书的架构稿从条目式升级");
+    expect(systemMsg.content).toContain("旧世界观:架空唐代");
+    expect(systemMsg.content).toContain("升级到段落式架构稿");
+  });
+
+  it("does not inject revisePrompt when reviseFrom is absent", async () => {
+    const agent = new ArchitectAgent({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0, maxTokensCap: null,
+          extra: {},
+        },
+      },
+      model: "test-model",
+      projectRoot: process.cwd(),
+    });
+
+    const chatSpy = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
+      .mockResolvedValue({
+        content: [
+          "=== SECTION: story_frame ===", "## 主题", "段落",
+          "=== SECTION: volume_map ===", "## 段 1", "卷一",
+          "=== SECTION: roles ===", "---ROLE---", "tier: major", "name: X", "---CONTENT---", "主角",
+          "=== SECTION: book_rules ===", "---", "version: \"1.0\"", "---",
+          "=== SECTION: pending_hooks ===", "| hook_id |",
+        ].join("\n"),
+        usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
+      });
+
+    await agent.generateFoundation({
+      id: "test-book", title: "测试", platform: "qidian", genre: "xuanhuan",
+      status: "active", targetChapters: 50, chapterWordCount: 3000, language: "zh",
+      createdAt: "2026-04-19T00:00:00.000Z", updatedAt: "2026-04-19T00:00:00.000Z",
+    });
+
+    const systemMsg = (chatSpy.mock.calls[0]?.[0] as Array<{ role: string; content: string }>)[0]!;
+    expect(systemMsg.content).not.toContain("把一本已有书的架构稿从条目式升级");
+  });
+});
+
+describe("pipeline.reviseFoundation", () => {
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });
+
+  it("backs up legacy files and writes Phase 5 output", async () => {
+    const { mkdtemp, writeFile, mkdir, rm, access, readdir } = 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-e2e-"));
+    const bookDir = join(root, "books", "legacy-book");
+
+    try {
+      // Construct a 旧书 on disk with 4 legacy files
+      await mkdir(join(bookDir, "story"), { recursive: true });
+      await writeFile(join(bookDir, "story", "story_bible.md"), "# 旧书架构稿\n\n- 架空唐代\n- 主角林辞", "utf-8");
+      await writeFile(join(bookDir, "story", "volume_outline.md"), "## 第一卷\n- 主角登场", "utf-8");
+      await writeFile(join(bookDir, "story", "book_rules.md"), "## 规则\n- 禁现代词", "utf-8");
+      await writeFile(join(bookDir, "story", "character_matrix.md"), "## 角色\n林辞 - 主角", "utf-8");
+      await writeFile(join(bookDir, "book.json"), JSON.stringify({
+        id: "legacy-book", title: "旧书", platform: "qidian", genre: "xuanhuan",
+        status: "active", targetChapters: 50, chapterWordCount: 3000, language: "zh",
+        createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z",
+      }), "utf-8");
+
+      // Stub architect.generateFoundation → Phase 5 output
+      const mockFoundation: ArchitectOutput = {
+        storyBible: "(shim)",
+        volumeOutline: "(shim)",
+        bookRules: "---\nversion: \"1.0\"\n---\n",
+        currentState: "",
+        pendingHooks: "| hook_id |",
+        storyFrame: "## 主题\n\n段落式主题",
+        volumeMap: "## 段 1\n\n卷一段落",
+        roles: [{ tier: "major", name: "林辞", content: "主角段落描写" }],
+      };
+      vi.spyOn(ArchitectAgent.prototype, "generateFoundation").mockResolvedValue(mockFoundation);
+      vi.spyOn(FoundationReviewerAgent.prototype, "review").mockResolvedValue({
+        passed: true, totalScore: 90, dimensions: [], overallFeedback: "ok",
+      } as unknown as Awaited<ReturnType<FoundationReviewerAgent["review"]>>);
+
+      // Minimal config for PipelineRunner
+      const state = new StateManager(root);
+      const runner = new PipelineRunner({
+        state,
+        projectRoot: root,
+        client: {
+          provider: "openai",
+          apiFormat: "chat",
+          stream: false,
+          defaults: {
+            temperature: 0.7,
+            maxTokens: 4096,
+            thinkingBudget: 0, maxTokensCap: null,
+            extra: {},
+          },
+        },
+        model: "test-model",
+      } as unknown as ConstructorParameters<typeof PipelineRunner>[0]);
+
+      await runner.reviseFoundation("legacy-book", "升级到段落式");
+
+      // New files created
+      await expect(access(join(bookDir, "story", "outline", "story_frame.md"))).resolves.not.toThrow();
+      await expect(access(join(bookDir, "story", "outline", "volume_map.md"))).resolves.not.toThrow();
+      await expect(access(join(bookDir, "story", "roles", "主要角色", "林辞.md"))).resolves.not.toThrow();
+      // Backup exists
+      const storyEntries = await readdir(join(bookDir, "story"));
+      const backupDir = storyEntries.find((e) => e.startsWith(".backup-phase4-"));
+      expect(backupDir).toBeDefined();
+      await expect(access(join(bookDir, "story", backupDir!, "story_bible.md"))).resolves.not.toThrow();
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+});

+ 18 - 1
packages/core/src/agent/agent-tools.ts

@@ -75,6 +75,12 @@ const SubAgentParams = Type.Object({
   ], { description: "architect only: writing language. Default: zh" })),
   targetChapters: Type.Optional(Type.Number({ description: "architect only: total chapter count. Default: 200" })),
   chapterWordCount: Type.Optional(Type.Number({ description: "architect/writer: words per chapter. Default: 3000" })),
+  revise: Type.Optional(Type.Boolean({
+    description: "architect only: true 表示在已有书上重新生成架构稿(比如把旧的条目式格式升级成段落式架构稿 + 一人一卡的角色目录、或者按 feedback 调整某些细节),而不是新建书籍。需要同时提供 bookId",
+  })),
+  feedback: Type.Optional(Type.String({
+    description: "architect only: revise 模式下的调整要求。举例:把架构稿从条目式升级成段落式架构稿、某个角色设定需要重新设计、主线冲突表达太弱需要加强等。如果是架构稿评审未通过要求重写的场景,把评审意见的 overallFeedback 原样传入即可",
+  })),
   // -- reviser params --
   mode: Type.Optional(Type.Union([
     Type.Literal("spot-fix"),
@@ -120,7 +126,7 @@ export function createSubAgentTool(
       _signal?: AbortSignal,
       onUpdate?: AgentToolUpdateCallback,
     ): Promise<AgentToolResult<undefined>> {
-      const { agent, instruction, bookId, title, chapterNumber, genre, platform, language, targetChapters, chapterWordCount, mode, format, approvedOnly } = params;
+      const { agent, instruction, bookId, title, chapterNumber, genre, platform, language, targetChapters, chapterWordCount, revise, feedback, mode, format, approvedOnly } = params;
 
       const progress = (msg: string) => {
         onUpdate?.(textResult(msg));
@@ -129,6 +135,17 @@ export function createSubAgentTool(
       try {
         switch (agent) {
           case "architect": {
+            if (revise) {
+              if (!bookId) {
+                return textResult("Error: architect revise 模式需要 bookId,用于定位要重写的书。");
+              }
+              progress(`Revising foundation for "${bookId}"...`);
+              await pipeline.reviseFoundation(bookId, feedback ?? instruction);
+              progress(`Foundation revised for "${bookId}".`);
+              return textResult(
+                `Book "${bookId}" 架构稿已按要求重写。原书的条目式架构稿已备份到 story/.backup-phase4-<时间戳>/。`,
+              );
+            }
             if (activeBookId) {
               return textResult("当前已有书籍,不需要建书。如果你想创建新书,请先回到首页。");
             }

+ 57 - 1
packages/core/src/agents/architect.ts

@@ -49,6 +49,15 @@ export class ArchitectAgent extends BaseAgent {
     book: BookConfig,
     externalContext?: string,
     reviewFeedback?: string,
+    options?: {
+      reviseFrom?: {
+        storyBible: string;
+        volumeOutline: string;
+        bookRules: string;
+        characterMatrix: string;
+        userFeedback: string;
+      };
+    },
   ): Promise<ArchitectOutput> {
     const { profile: gp, body: genreBody } =
       await readGenreProfile(this.ctx.projectRoot, book.genre);
@@ -58,6 +67,9 @@ export class ArchitectAgent extends BaseAgent {
       ? `\n\n## 外部指令\n以下是来自外部系统的创作指令,请将其融入设定中:\n\n${externalContext}\n`
       : "";
     const reviewFeedbackBlock = this.buildReviewFeedbackBlock(reviewFeedback, resolvedLanguage);
+    const revisePrompt = options?.reviseFrom
+      ? this.buildRevisePrompt(options.reviseFrom)
+      : "";
 
     const numericalBlock = gp.numericalSystem
       ? `- 有明确的数值/资源体系可追踪
@@ -72,9 +84,10 @@ export class ArchitectAgent extends BaseAgent {
       ? "- 需要年代考据支撑(在 book_rules 中设置 eraConstraints)"
       : "";
 
-    const systemPrompt = resolvedLanguage === "en"
+    const basePrompt = resolvedLanguage === "en"
       ? this.buildEnglishFoundationPrompt(book, gp, genreBody, contextBlock, reviewFeedbackBlock, numericalBlock, powerBlock, eraBlock)
       : this.buildChineseFoundationPrompt(book, gp, genreBody, contextBlock, reviewFeedbackBlock, numericalBlock, powerBlock, eraBlock);
+    const systemPrompt = revisePrompt + basePrompt;
 
     const langPrefix = resolvedLanguage === "en"
       ? `【LANGUAGE OVERRIDE】ALL output (story_frame, volume_map, roles, book_rules, pending_hooks) MUST be written in English. Character names, place names, and all prose must be in English. The === SECTION: === tags remain unchanged. Do NOT emit rhythm_principles or current_state sections — rhythm principles live inside the last paragraph of volume_map; environment/era anchors (when relevant) are woven into story_frame's world-tonal-ground paragraph.\n\n`
@@ -91,6 +104,49 @@ export class ArchitectAgent extends BaseAgent {
     return this.parseSections(response.content);
   }
 
+  private buildRevisePrompt(reviseFrom: {
+    storyBible: string;
+    volumeOutline: string;
+    bookRules: string;
+    characterMatrix: string;
+    userFeedback: string;
+  }): string {
+    return `你在把一本已有书的架构稿从条目式升级成段落式架构稿 + 一人一卡的角色目录。
+
+原书信息(条目式架构稿原文,这是权威内容,必须完整保留里面的世界观 / 角色 / 主线 / 伏笔):
+
+【story_bible.md 全文】
+${reviseFrom.storyBible || "(无)"}
+
+【volume_outline.md 全文】
+${reviseFrom.volumeOutline || "(无)"}
+
+【book_rules.md 全文】
+${reviseFrom.bookRules || "(无)"}
+
+【character_matrix.md 全文】
+${reviseFrom.characterMatrix || "(无)"}
+
+你的任务:
+1. 把 story_bible 的内容重新组织成 4 段段落式 story_frame.md(主题 / 核心冲突 / 世界观底色 / 终局方向)
+2. 把 volume_outline 的内容重新组织成 5 段 volume_map.md + 末尾 1 段节奏原则
+3. 把 character_matrix 里的每个角色拆成一份 roles/主要角色/<name>.md 或 roles/次要角色/<name>.md
+
+严格约束:
+- 世界观设定、角色设定、主线走向、已埋下的伏笔一个字都不能丢
+- 如果原内容里有 bullet 点,把它们连成段落;不要只是把 bullet 转成句号分隔
+- 主次角色的判断依据:character_matrix 里已标注的如果有,沿用;没有的话,把在 volume_outline 里出现频次高、或者承担主线冲突的列为主要,其余为次要
+- 基调 / 语气不要改变
+- 如果原内容里留有空白或未展开项,保留为空,不要主动补全
+
+用户额外要求:
+${reviseFrom.userFeedback || "(无)"}
+
+---
+
+`;
+  }
+
   // -------------------------------------------------------------------------
   // Phase 5 段落式 prompt — zh
   // -------------------------------------------------------------------------

+ 79 - 0
packages/core/src/pipeline/runner.ts

@@ -514,6 +514,85 @@ export class PipelineRunner {
     }
   }
 
+  /**
+   * Revise an existing book's foundation — read the legacy 条目式 架构稿, ask architect
+   * to re-emit as Phase 5 段落式 + 一人一卡 roles 目录, back up the originals under
+   * story/.backup-phase4-<timestamp>/, and overwrite the legacy files with shims.
+   */
+  async reviseFoundation(bookId: string, feedback: string): Promise<void> {
+    const bookDir = this.state.bookDir(bookId);
+    const storyDir = join(bookDir, "story");
+
+    // 1. Back up the original legacy files before overwriting
+    const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
+    const backupDir = join(storyDir, `.backup-phase4-${timestamp}`);
+    await mkdir(backupDir, { recursive: true });
+    const legacyFiles = ["story_bible.md", "volume_outline.md", "book_rules.md", "character_matrix.md"];
+    for (const fileName of legacyFiles) {
+      try {
+        const content = await readFile(join(storyDir, fileName), "utf-8");
+        await writeFile(join(backupDir, fileName), content, "utf-8");
+      } catch {
+        /* 文件不存在就跳过 */
+      }
+    }
+
+    // 2. Load the original legacy content to feed the architect
+    const book = await this.state.loadBookConfig(bookId);
+    const [oldStoryBible, oldVolumeOutline, oldBookRules, oldCharacterMatrix] = await Promise.all([
+      readFile(join(storyDir, "story_bible.md"), "utf-8").catch(() => ""),
+      readFile(join(storyDir, "volume_outline.md"), "utf-8").catch(() => ""),
+      readFile(join(storyDir, "book_rules.md"), "utf-8").catch(() => ""),
+      readFile(join(storyDir, "character_matrix.md"), "utf-8").catch(() => ""),
+    ]);
+
+    // 3. Call architect with reviseFrom option → new Phase 5 foundation
+    const architect = new ArchitectAgent(this.agentCtxFor("architect", bookId));
+    const foundation = await architect.generateFoundation(book, undefined, undefined, {
+      reviseFrom: {
+        storyBible: oldStoryBible,
+        volumeOutline: oldVolumeOutline,
+        bookRules: oldBookRules,
+        characterMatrix: oldCharacterMatrix,
+        userFeedback: feedback,
+      },
+    });
+
+    // 4. Optional: run foundation-reviewer once — warn on failure, don't block
+    const reviewer = new FoundationReviewerAgent(this.agentCtxFor("foundation-reviewer", bookId));
+    const resolvedLanguage = (book.language ?? "zh") === "en" ? "en" as const : "zh" as const;
+    try {
+      const review = await reviewer.review({
+        foundation,
+        mode: "original",
+        language: resolvedLanguage,
+      } as Parameters<FoundationReviewerAgent["review"]>[0]);
+      if (!review.passed) {
+        this.config.logger?.warn?.(
+          `[reviseFoundation] 审核未通过,仍接受转换结果。反馈:${review.overallFeedback ?? ""}`,
+        );
+      }
+    } catch (error) {
+      this.config.logger?.warn?.(
+        `[reviseFoundation] 审核调用失败,跳过:${error instanceof Error ? error.message : String(error)}`,
+      );
+    }
+
+    // 5. Ensure Phase 5 目录存在,然后写新文件(覆盖旧的 flat md 为 shim)
+    const outlineDir = join(storyDir, "outline");
+    await mkdir(outlineDir, { recursive: true });
+    await mkdir(join(storyDir, "roles", "主要角色"), { recursive: true });
+    await mkdir(join(storyDir, "roles", "次要角色"), { recursive: true });
+
+    const { profile: gp } = await this.loadGenreProfile(book.genre);
+    await architect.writeFoundationFiles(
+      bookDir,
+      foundation,
+      gp.numericalSystem,
+      book.language ?? gp.language,
+    );
+  }
+
   /** Import external source material and generate fanfic_canon.md */
   async importFanficCanon(
     bookId: string,