Преглед изворни кода

fix(pipeline): wire foundation review retries and hook budget

Ma пре 7 месеци
родитељ
комит
e771dee73a

+ 131 - 0
packages/core/src/__tests__/architect.test.ts

@@ -146,6 +146,137 @@ describe("ArchitectAgent", () => {
     expect(messages[0]?.content).not.toContain("## 叙事视角");
   });
 
+  it("embeds reviewer feedback into original foundation regeneration prompts", 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 book: BookConfig = {
+      id: "review-feedback-book",
+      title: "雾港回灯",
+      platform: "tomato",
+      genre: "urban",
+      status: "active",
+      targetChapters: 60,
+      chapterWordCount: 2200,
+      language: "zh",
+      createdAt: "2026-04-03T00:00:00.000Z",
+      updatedAt: "2026-04-03T00:00:00.000Z",
+    };
+
+    const chat = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
+      .mockResolvedValue({
+        content: [
+          "=== SECTION: story_bible ===",
+          "# 故事圣经",
+          "",
+          "=== SECTION: volume_outline ===",
+          "# 卷纲",
+          "",
+          "=== SECTION: book_rules ===",
+          "---",
+          "version: \"1.0\"",
+          "---",
+          "",
+          "=== SECTION: current_state ===",
+          "# 当前状态",
+          "",
+          "=== SECTION: pending_hooks ===",
+          "# 待回收伏笔",
+        ].join("\n"),
+        usage: ZERO_USAGE,
+      });
+
+    await agent.generateFoundation(
+      book,
+      undefined,
+      "请把核心冲突收紧,并明确新空间不是旧案重演。",
+    );
+
+    const messages = chat.mock.calls[0]?.[0] as Array<{ role: string; content: string }>;
+    expect(messages[0]?.content).toContain("上一轮审核反馈");
+    expect(messages[0]?.content).toContain("请把核心冲突收紧");
+    expect(messages[0]?.content).toContain("明确新空间不是旧案重演");
+  });
+
+  it("embeds reviewer feedback into fanfic foundation regeneration prompts", 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 book: BookConfig = {
+      id: "fanfic-review-feedback-book",
+      title: "三体:回声舱",
+      platform: "tomato",
+      genre: "other",
+      status: "active",
+      targetChapters: 60,
+      chapterWordCount: 2200,
+      language: "zh",
+      createdAt: "2026-04-03T00:00:00.000Z",
+      updatedAt: "2026-04-03T00:00:00.000Z",
+    };
+
+    const chat = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
+      .mockResolvedValue({
+        content: [
+          "=== SECTION: story_bible ===",
+          "# 故事圣经",
+          "",
+          "=== SECTION: volume_outline ===",
+          "# 卷纲",
+          "",
+          "=== SECTION: book_rules ===",
+          "---",
+          "version: \"1.0\"",
+          "---",
+          "",
+          "=== SECTION: current_state ===",
+          "# 当前状态",
+          "",
+          "=== SECTION: pending_hooks ===",
+          "# 待回收伏笔",
+        ].join("\n"),
+        usage: ZERO_USAGE,
+      });
+
+    await agent.generateFanficFoundation(
+      book,
+      "# 原作正典\n- 罗辑在面壁计划中留下了一处空档。",
+      "canon",
+      "请明确分岔点,并用原创冲突替代原作重走。",
+    );
+
+    const messages = chat.mock.calls[0]?.[0] as Array<{ role: string; content: string }>;
+    expect(messages[0]?.content).toContain("上一轮审核反馈");
+    expect(messages[0]?.content).toContain("请明确分岔点");
+    expect(messages[0]?.content).toContain("原创冲突替代原作重走");
+  });
+
   it("strips assistant-style trailing coda from the final pending hooks section", async () => {
     const agent = new ArchitectAgent({
       client: {

+ 84 - 0
packages/core/src/__tests__/pipeline-runner.test.ts

@@ -348,6 +348,90 @@ describe("PipelineRunner", () => {
     }
   });
 
+  it("feeds foundation review feedback into the regeneration call after a rejection", async () => {
+    const { root, runner, bookId } = await createRunnerFixture();
+    const reviewer = new FoundationReviewerAgent({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0, maxTokensCap: null,
+        },
+      } as ConstructorParameters<typeof PipelineRunner>[0]["client"],
+      model: "test-model",
+      projectRoot: root,
+      bookId,
+    });
+    const foundation = {
+      storyBible: "# Story Bible",
+      volumeOutline: "# Volume Outline",
+      bookRules: "---\nversion: \"1.0\"\n---\n\n# Book Rules",
+      currentState: "# Current State",
+      pendingHooks: "# Pending Hooks",
+    };
+    const generate = vi.fn(async (_reviewFeedback?: string) => foundation);
+    const reviewMock = vi.mocked(FoundationReviewerAgent.prototype.review);
+
+    reviewMock.mockReset();
+    reviewMock
+      .mockResolvedValueOnce({
+        passed: false,
+        totalScore: 68,
+        dimensions: [
+          {
+            name: "核心冲突",
+            score: 58,
+            feedback: "核心冲突不够集中,主线悬念没有站稳。",
+          },
+          {
+            name: "开篇节奏",
+            score: 76,
+            feedback: "前五章起势偏慢,爆点不够前置。",
+          },
+        ],
+        overallFeedback: "请把冲突收紧,并在更早的位置建立爆点。",
+      })
+      .mockResolvedValueOnce({
+        passed: true,
+        totalScore: 88,
+        dimensions: [],
+        overallFeedback: "通过",
+      });
+
+    try {
+      const result = await (runner as unknown as {
+        generateAndReviewFoundation: (params: {
+          readonly generate: (reviewFeedback?: string) => Promise<typeof foundation>;
+          readonly reviewer: FoundationReviewerAgent;
+          readonly mode: "original";
+          readonly language: "zh";
+          readonly stageLanguage: "zh";
+          readonly maxRetries: number;
+        }) => Promise<typeof foundation>;
+      }).generateAndReviewFoundation({
+        generate,
+        reviewer,
+        mode: "original",
+        language: "zh",
+        stageLanguage: "zh",
+        maxRetries: 2,
+      });
+
+      expect(result).toEqual(foundation);
+      expect(generate).toHaveBeenCalledTimes(2);
+      expect(generate.mock.calls[0]?.[0]).toBeUndefined();
+      expect(generate.mock.calls[1]?.[0]).toContain("请把冲突收紧,并在更早的位置建立爆点。");
+      expect(generate.mock.calls[1]?.[0]).toContain("核心冲突");
+      expect(generate.mock.calls[1]?.[0]).toContain("核心冲突不够集中");
+      expect(generate.mock.calls[1]?.[0]).toContain("开篇节奏");
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
   it("bootstraps missing control documents for legacy books before writing", async () => {
     const { root, runner, bookId } = await createRunnerFixture();
 

+ 76 - 0
packages/core/src/__tests__/planner.test.ts

@@ -1329,4 +1329,80 @@ describe("PlannerAgent", () => {
     ]));
     expect(result.intent.hookAgenda.pressureMap).toEqual([]);
   });
+
+  it("renders hook budget from total active hooks instead of the selected hook snapshot", async () => {
+    const stateDir = join(storyDir, "state");
+    await mkdir(stateDir, { recursive: true });
+
+    const hooks = Array.from({ length: 12 }, (_, index) => ({
+      hookId: `hook-${index + 1}`,
+      startChapter: index + 1,
+      type: index < 6 ? "route" : "mystery",
+      status: "open",
+      lastAdvancedChapter: index < 6 ? 25 - index : 12 - index,
+      expectedPayoff: index < 6 ? "Route debt payoff" : "Dormant mystery payoff",
+      notes: index < 6
+        ? `Route pressure thread ${index + 1} stays relevant.`
+        : `Dormant thread ${index + 1} should not be selected into the primary context.`,
+    }));
+
+    await Promise.all([
+      writeFile(
+        join(stateDir, "manifest.json"),
+        JSON.stringify({
+          schemaVersion: 2,
+          language: "en",
+          lastAppliedChapter: 25,
+          projectionVersion: 1,
+          migrationWarnings: [],
+        }, null, 2),
+        "utf-8",
+      ),
+      writeFile(
+        join(stateDir, "current_state.json"),
+        JSON.stringify({
+          chapter: 25,
+          facts: [],
+        }, null, 2),
+        "utf-8",
+      ),
+      writeFile(
+        join(stateDir, "chapter_summaries.json"),
+        JSON.stringify({
+          rows: [],
+        }, null, 2),
+        "utf-8",
+      ),
+      writeFile(
+        join(stateDir, "hooks.json"),
+        JSON.stringify({ hooks }, null, 2),
+        "utf-8",
+      ),
+    ]);
+
+    book = {
+      ...book,
+      genre: "other",
+      language: "en",
+      targetChapters: 40,
+    };
+
+    const planner = new PlannerAgent({
+      client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
+      model: "test-model",
+      projectRoot: root,
+      bookId: book.id,
+    });
+
+    const result = await planner.planChapter({
+      book,
+      bookDir,
+      chapterNumber: 26,
+      externalContext: "Keep the chapter on the route pressure.",
+    });
+
+    const intentMarkdown = await readFile(result.runtimePath, "utf-8");
+    expect(intentMarkdown).toContain("12 active hooks");
+    expect(intentMarkdown).not.toContain("8 active hooks");
+  });
 });

+ 31 - 2
packages/core/src/agents/architect.ts

@@ -19,7 +19,11 @@ export class ArchitectAgent extends BaseAgent {
     return "architect";
   }
 
-  async generateFoundation(book: BookConfig, externalContext?: string): Promise<ArchitectOutput> {
+  async generateFoundation(
+    book: BookConfig,
+    externalContext?: string,
+    reviewFeedback?: string,
+  ): Promise<ArchitectOutput> {
     const { profile: gp, body: genreBody } =
       await readGenreProfile(this.ctx.projectRoot, book.genre);
     const resolvedLanguage = book.language ?? gp.language;
@@ -27,6 +31,7 @@ export class ArchitectAgent extends BaseAgent {
     const contextBlock = externalContext
       ? `\n\n## 外部指令\n以下是来自外部系统的创作指令,请将其融入设定中:\n\n${externalContext}\n`
       : "";
+    const reviewFeedbackBlock = this.buildReviewFeedbackBlock(reviewFeedback, resolvedLanguage);
 
     const numericalBlock = gp.numericalSystem
       ? `- 有明确的数值/资源体系可追踪
@@ -231,7 +236,7 @@ ${eraBlock}
 4. 伏笔前后呼应,不留悬空线
 5. 配角有独立动机,不是工具人`;
 
-    const systemPrompt = `你是一个专业的网络小说架构师。你的任务是为一本新的${gp.name}小说生成完整的基础设定。${contextBlock}
+    const systemPrompt = `你是一个专业的网络小说架构师。你的任务是为一本新的${gp.name}小说生成完整的基础设定。${contextBlock}${reviewFeedbackBlock}
 
 要求:
 - 平台:${book.platform}
@@ -581,9 +586,11 @@ ${keyPrinciplesPrompt}`;
     book: BookConfig,
     fanficCanon: string,
     fanficMode: FanficMode,
+    reviewFeedback?: string,
   ): Promise<ArchitectOutput> {
     const { profile: gp, body: genreBody } =
       await readGenreProfile(this.ctx.projectRoot, book.genre);
+    const reviewFeedbackBlock = this.buildReviewFeedbackBlock(reviewFeedback, book.language ?? "zh");
 
     const MODE_INSTRUCTIONS: Record<FanficMode, string> = {
       canon: "剧情发生在原作空白期或未详述的角度。不可改变原作已确立的事实。",
@@ -604,6 +611,8 @@ ${MODE_INSTRUCTIONS[fanficMode]}
 3. **5章内引爆**:volume_outline 的第1卷必须在前5章内建立核心悬念,不允许用3章做铺垫才到引爆点
 4. **场景新鲜度**:至少50%的关键场景发生在原作未出现的地点或情境中
 
+${reviewFeedbackBlock}
+
 ## 原作正典
 ${fanficCanon}
 
@@ -662,6 +671,26 @@ prohibitions:
     return this.parseSections(response.content);
   }
 
+  private buildReviewFeedbackBlock(
+    reviewFeedback: string | undefined,
+    language: "zh" | "en",
+  ): string {
+    const trimmed = reviewFeedback?.trim();
+    if (!trimmed) return "";
+
+    if (language === "en") {
+      return `\n\n## Previous Review Feedback
+The previous foundation draft was rejected. You must explicitly fix the following issues in this regeneration instead of paraphrasing the same design:
+
+${trimmed}\n`;
+    }
+
+    return `\n\n## 上一轮审核反馈
+上一轮基础设定未通过审核。你必须在这次重生中明确修复以下问题,不能只换措辞重写同一套方案:
+
+${trimmed}\n`;
+  }
+
   private parseSections(content: string): ArchitectOutput {
     const parsedSections = new Map<string, string>();
     const sectionPattern = /^\s*===\s*SECTION\s*[::]\s*([^\n=]+?)\s*===\s*$/gim;

+ 4 - 6
packages/core/src/agents/planner.ts

@@ -97,7 +97,6 @@ export class PlannerAgent extends BaseAgent {
       outlineNode,
       matchedOutlineAnchor,
       chapterSummaries,
-      activeHookCount,
     });
 
     const intent = ChapterIntentSchema.parse({
@@ -118,6 +117,7 @@ export class PlannerAgent extends BaseAgent {
       input.book.language ?? "zh",
       renderHookSnapshot(memorySelection.hooks, input.book.language ?? "zh"),
       renderSummarySnapshot(memorySelection.summaries, input.book.language ?? "zh"),
+      activeHookCount,
     );
     await writeFile(runtimePath, intentMarkdown, "utf-8");
 
@@ -140,7 +140,6 @@ export class PlannerAgent extends BaseAgent {
     readonly outlineNode: string | undefined;
     readonly matchedOutlineAnchor: boolean;
     readonly chapterSummaries: string;
-    readonly activeHookCount?: number;
   }): Pick<ChapterIntent, "sceneDirective" | "arcDirective" | "moodDirective" | "titleDirective"> {
     const recentSummaries = parseChapterSummariesMarkdown(input.chapterSummaries)
       .filter((summary) => summary.chapter < input.chapterNumber)
@@ -389,9 +388,7 @@ export class PlannerAgent extends BaseAgent {
       : `Avoid another ${repeatedToken}-centric title. Pick a new image or action focus for this chapter title.`;
   }
 
-  private renderHookBudget(pendingHooks: string, language: "zh" | "en"): string {
-    const hookLines = pendingHooks.split("\n").filter((line) => line.startsWith("|") && /^\|\s*[A-Za-z\u4e00-\u9fff]/.test(line));
-    const activeCount = hookLines.length;
+  private renderHookBudget(activeCount: number, language: "zh" | "en"): string {
     const cap = 12;
     if (activeCount < 10) {
       return language === "en"
@@ -646,6 +643,7 @@ export class PlannerAgent extends BaseAgent {
     language: "zh" | "en",
     pendingHooks: string,
     chapterSummaries: string,
+    activeHookCount: number,
   ): string {
     const conflictLines = intent.conflicts.length > 0
       ? intent.conflicts.map((conflict) => `- ${conflict.type}: ${conflict.resolution}`).join("\n")
@@ -689,7 +687,7 @@ export class PlannerAgent extends BaseAgent {
         ? intent.hookAgenda.avoidNewHookFamilies.map((item) => `- ${item}`).join("\n")
         : "- none",
       "",
-      this.renderHookBudget(pendingHooks, language),
+      this.renderHookBudget(activeHookCount, language),
     ].join("\n");
 
     return [

+ 49 - 6
packages/core/src/pipeline/runner.ts

@@ -244,7 +244,7 @@ export class PipelineRunner {
   }
 
   private async generateAndReviewFoundation(params: {
-    readonly generate: () => Promise<ArchitectOutput>;
+    readonly generate: (reviewFeedback?: string) => Promise<ArchitectOutput>;
     readonly reviewer: FoundationReviewerAgent;
     readonly mode: "original" | "fanfic" | "series";
     readonly sourceCanon?: string;
@@ -286,9 +286,7 @@ export class PipelineRunner {
         en: `Foundation rejected (${review.totalScore}/100), regenerating...`,
       });
 
-      // Feed review feedback back to architect via the next generation
-      // (the architect will see different random seed due to temperature)
-      foundation = await params.generate();
+      foundation = await params.generate(this.buildFoundationReviewFeedback(review, params.language));
     }
 
     // Final review
@@ -306,6 +304,42 @@ export class PipelineRunner {
     return foundation;
   }
 
+  private buildFoundationReviewFeedback(
+    review: {
+      readonly dimensions: ReadonlyArray<{
+        readonly name: string;
+        readonly score: number;
+        readonly feedback: string;
+      }>;
+      readonly overallFeedback: string;
+    },
+    language: "zh" | "en",
+  ): string {
+    const dimensionLines = review.dimensions
+      .map((dimension) => (
+        language === "en"
+          ? `- ${dimension.name} [${dimension.score}]: ${dimension.feedback}`
+          : `- ${dimension.name}(${dimension.score}分):${dimension.feedback}`
+      ))
+      .join("\n");
+
+    return language === "en"
+      ? [
+          "## Overall Feedback",
+          review.overallFeedback,
+          "",
+          "## Dimension Notes",
+          dimensionLines || "- none",
+        ].join("\n")
+      : [
+          "## 总评",
+          review.overallFeedback,
+          "",
+          "## 分项问题",
+          dimensionLines || "- 无",
+        ].join("\n");
+  }
+
   private agentCtx(bookId?: string): AgentContext {
     return {
       client: this.config.client,
@@ -413,7 +447,11 @@ export class PipelineRunner {
     const reviewer = new FoundationReviewerAgent(this.agentCtxFor("foundation-reviewer", book.id));
     const resolvedLanguage = (book.language ?? gp.language) === "en" ? "en" as const : "zh" as const;
     const foundation = await this.generateAndReviewFoundation({
-      generate: () => architect.generateFoundation(book, this.config.externalContext),
+      generate: (reviewFeedback) => architect.generateFoundation(
+        book,
+        this.config.externalContext,
+        reviewFeedback,
+      ),
       reviewer,
       mode: "original",
       language: resolvedLanguage,
@@ -500,7 +538,12 @@ export class PipelineRunner {
     const { profile: gp } = await this.loadGenreProfile(book.genre);
     const resolvedLanguage = (book.language ?? gp.language) === "en" ? "en" as const : "zh" as const;
     const foundation = await this.generateAndReviewFoundation({
-      generate: () => architect.generateFanficFoundation(book, fanficCanon, fanficMode),
+      generate: (reviewFeedback) => architect.generateFanficFoundation(
+        book,
+        fanficCanon,
+        fanficMode,
+        reviewFeedback,
+      ),
       reviewer,
       mode: "fanfic",
       sourceCanon: fanficCanon,