Explorar el Código

feat(architect): add foundation reviewer and new-spacetime requirements

Three changes:

1. Architect fanfic prompt now requires a "new spacetime":
   - Explicit divergence point from source material
   - Original core conflict (not retelling original plot)
   - 5-chapter ignition constraint
   - 50% fresh scene locations

2. New FoundationReviewerAgent reviews architect output before
   writing begins. Scores 5 dimensions (source DNA, new space,
   core conflict, opening momentum, pacing feasibility).
   Threshold: 80 total, 60 floor per dimension.
   Rejects with specific feedback, architect regenerates.
   Max 2 retries before accepting best effort.

3. Review loop integrated into initBook (original mode) and
   initFanficBook (fanfic mode). Both now go through
   generate → review → (reject → regenerate) cycle.

This addresses the #2 problem (斗破同人 75.5): fanfic architect
was retelling original plot because nothing required it to create
new narrative space.
Ma hace 7 meses
padre
commit
160f3a2704

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

@@ -15,6 +15,7 @@ import { ContinuityAuditor, type AuditIssue, type AuditResult } from "../agents/
 import { ReviserAgent, type ReviseOutput } from "../agents/reviser.js";
 import { ChapterAnalyzerAgent } from "../agents/chapter-analyzer.js";
 import { StateValidatorAgent } from "../agents/state-validator.js";
+import { FoundationReviewerAgent } from "../agents/foundation-reviewer.js";
 import type { BookConfig } from "../models/book.js";
 import type { ChapterMeta } from "../models/chapter.js";
 import { MemoryDB } from "../state/memory-db.js";
@@ -199,6 +200,12 @@ async function createRunnerFixture(
 
 describe("PipelineRunner", () => {
   beforeEach(() => {
+    vi.spyOn(FoundationReviewerAgent.prototype, "review").mockResolvedValue({
+      passed: true,
+      totalScore: 85,
+      dimensions: [],
+      overallFeedback: "auto-pass for test",
+    });
     vi.spyOn(LengthNormalizerAgent.prototype, "normalizeChapter").mockImplementation(
       async ({ chapterContent, lengthSpec }) => ({
         normalizedContent: chapterContent,

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

@@ -597,6 +597,13 @@ ${keyPrinciplesPrompt}`;
 ## 同人模式:${fanficMode}
 ${MODE_INSTRUCTIONS[fanficMode]}
 
+## 新时空要求(关键)
+你必须为这本同人设计一个**原创的叙事空间**,而不是复述原作剧情。具体要求:
+1. **明确分岔点**:story_bible 必须标注"本作从原作的哪个节点分岔",或"本作发生在原作未涉及的什么时空"
+2. **独立核心冲突**:volume_outline 的核心冲突必须是原创的,不是原作情节的翻版。原作角色可以出现,但他们面对的是新问题
+3. **5章内引爆**:volume_outline 的第1卷必须在前5章内建立核心悬念,不允许用3章做铺垫才到引爆点
+4. **场景新鲜度**:至少50%的关键场景发生在原作未出现的地点或情境中
+
 ## 原作正典
 ${fanficCanon}
 
@@ -606,8 +613,8 @@ ${genreBody}
 ## 关键原则
 1. **不发明主要角色** — 主要角色必须来自原作正典的角色档案
 2. 可以添加原创配角,但必须在 story_bible 中标注为"原创角色"
-3. story_bible 保留原作世界观,标注同人的改动/扩展部分
-4. volume_outline 以原作事件为锚点,标注哪些是原作事件、哪些是同人原创
+3. story_bible 保留原作世界观,标注同人的改动/扩展部分,并明确写出**分岔点**和**新时空设定**
+4. volume_outline 不得复述原作剧情节拍。每卷的核心事件必须是原创的,标注"原创"
 5. book_rules 的 fanficMode 必须设为 "${fanficMode}"
 6. 主角设定来自原作角色档案中的第一个角色(或用户在标题中暗示的角色)
 

+ 204 - 0
packages/core/src/agents/foundation-reviewer.ts

@@ -0,0 +1,204 @@
+import { BaseAgent } from "./base.js";
+import type { ArchitectOutput } from "./architect.js";
+
+export interface FoundationReviewResult {
+  readonly passed: boolean;
+  readonly totalScore: number;
+  readonly dimensions: ReadonlyArray<{
+    readonly name: string;
+    readonly score: number;
+    readonly feedback: string;
+  }>;
+  readonly overallFeedback: string;
+}
+
+const PASS_THRESHOLD = 80;
+const DIMENSION_FLOOR = 60;
+
+export class FoundationReviewerAgent extends BaseAgent {
+  get name(): string {
+    return "foundation-reviewer";
+  }
+
+  async review(params: {
+    readonly foundation: ArchitectOutput;
+    readonly mode: "original" | "fanfic" | "series";
+    readonly sourceCanon?: string;
+    readonly styleGuide?: string;
+    readonly language: "zh" | "en";
+  }): Promise<FoundationReviewResult> {
+    const canonBlock = params.sourceCanon
+      ? `\n## 原作正典参照\n${params.sourceCanon.slice(0, 8000)}\n`
+      : "";
+    const styleBlock = params.styleGuide
+      ? `\n## 原作风格参照\n${params.styleGuide.slice(0, 2000)}\n`
+      : "";
+
+    const dimensions = params.mode === "original"
+      ? this.originalDimensions(params.language)
+      : this.derivativeDimensions(params.language, params.mode);
+
+    const systemPrompt = params.language === "en"
+      ? this.buildEnglishReviewPrompt(dimensions, canonBlock, styleBlock)
+      : this.buildChineseReviewPrompt(dimensions, canonBlock, styleBlock);
+
+    const userPrompt = this.buildFoundationExcerpt(params.foundation, params.language);
+
+    const response = await this.chat([
+      { role: "system", content: systemPrompt },
+      { role: "user", content: userPrompt },
+    ], { maxTokens: 4096, temperature: 0.3 });
+
+    return this.parseReviewResult(response.content, dimensions);
+  }
+
+  private originalDimensions(language: "zh" | "en"): ReadonlyArray<string> {
+    return language === "en"
+      ? [
+          "Core Conflict (Is there a clear, compelling central conflict that can sustain 40 chapters?)",
+          "Opening Momentum (Can the first 5 chapters create a page-turning hook?)",
+          "World Coherence (Is the worldbuilding internally consistent and specific?)",
+          "Character Differentiation (Are the main characters distinct in voice and motivation?)",
+          "Pacing Feasibility (Does the volume outline have enough variety — not the same beat for 10 chapters?)",
+        ]
+      : [
+          "核心冲突(是否有清晰且有足够张力的核心冲突支撑40章?)",
+          "开篇节奏(前5章能否形成翻页驱动力?)",
+          "世界一致性(世界观是否内洽且具体?)",
+          "角色区分度(主要角色的声音和动机是否各不相同?)",
+          "节奏可行性(卷纲是否有足够变化——不会连续10章同一种节拍?)",
+        ];
+  }
+
+  private derivativeDimensions(language: "zh" | "en", mode: "fanfic" | "series"): ReadonlyArray<string> {
+    const modeLabel = mode === "fanfic"
+      ? (language === "en" ? "Fan Fiction" : "同人")
+      : (language === "en" ? "Series" : "系列");
+
+    return language === "en"
+      ? [
+          `Source DNA Preservation (Does the ${modeLabel} respect the original's world rules, character personalities, and established facts?)`,
+          `New Narrative Space (Is there a clear divergence point or new territory that gives the story room to be ORIGINAL, not a retelling?)`,
+          "Core Conflict (Is the new story's central conflict compelling and distinct from the original?)",
+          "Opening Momentum (Can the first 5 chapters create a page-turning hook without requiring 3 chapters of setup?)",
+          `Pacing Feasibility (Does the outline avoid the trap of re-walking the original's plot beats?)`,
+        ]
+      : [
+          `原作DNA保留(${modeLabel}是否尊重原作的世界规则、角色性格、已确立事实?)`,
+          `新叙事空间(是否有明确的分岔点或新领域,让故事有原创空间,而非复述原作?)`,
+          "核心冲突(新故事的核心冲突是否有足够张力且区别于原作?)",
+          "开篇节奏(前5章能否形成翻页驱动力,不需要3章铺垫?)",
+          `节奏可行性(卷纲是否避免了重走原作剧情节拍的陷阱?)`,
+        ];
+  }
+
+  private buildChineseReviewPrompt(
+    dimensions: ReadonlyArray<string>,
+    canonBlock: string,
+    styleBlock: string,
+  ): string {
+    return `你是一位资深小说编辑,正在审核一本新书的基础设定(世界观 + 大纲 + 规则)。
+
+你需要从以下维度逐项打分(0-100),并给出具体意见:
+
+${dimensions.map((dim, i) => `${i + 1}. ${dim}`).join("\n")}
+
+## 评分标准
+- 80+ 通过,可以开始写作
+- 60-79 有明显问题,需要修改
+- <60 方向性错误,需要重新设计
+
+## 输出格式(严格遵守)
+=== DIMENSION: 1 ===
+分数:{0-100}
+意见:{具体反馈}
+
+=== DIMENSION: 2 ===
+分数:{0-100}
+意见:{具体反馈}
+
+...(每个维度一个 block)
+
+=== OVERALL ===
+总分:{加权平均}
+通过:{是/否}
+总评:{1-2段总结,指出最大的问题和最值得保留的优点}
+${canonBlock}${styleBlock}
+
+审核时要严格。不要因为"还行"就给高分。80分意味着"可以直接开写,不需要改"。`;
+  }
+
+  private buildEnglishReviewPrompt(
+    dimensions: ReadonlyArray<string>,
+    canonBlock: string,
+    styleBlock: string,
+  ): string {
+    return `You are a senior fiction editor reviewing a new book's foundation (worldbuilding + outline + rules).
+
+Score each dimension (0-100) with specific feedback:
+
+${dimensions.map((dim, i) => `${i + 1}. ${dim}`).join("\n")}
+
+## Scoring
+- 80+ Pass — ready to write
+- 60-79 Needs revision
+- <60 Fundamental direction problem
+
+## Output format (strict)
+=== DIMENSION: 1 ===
+Score: {0-100}
+Feedback: {specific feedback}
+
+=== DIMENSION: 2 ===
+Score: {0-100}
+Feedback: {specific feedback}
+
+...
+
+=== OVERALL ===
+Total: {weighted average}
+Passed: {yes/no}
+Summary: {1-2 paragraphs — biggest problem and best quality}
+${canonBlock}${styleBlock}
+
+Be strict. 80 means "ready to write without changes."`;
+  }
+
+  private buildFoundationExcerpt(foundation: ArchitectOutput, language: "zh" | "en"): string {
+    return language === "en"
+      ? `## Story Bible\n${foundation.storyBible.slice(0, 3000)}\n\n## Volume Outline\n${foundation.volumeOutline.slice(0, 3000)}\n\n## Book Rules\n${foundation.bookRules.slice(0, 1500)}\n\n## Initial State\n${foundation.currentState.slice(0, 1000)}\n\n## Initial Hooks\n${foundation.pendingHooks.slice(0, 1000)}`
+      : `## 世界设定\n${foundation.storyBible.slice(0, 3000)}\n\n## 卷纲\n${foundation.volumeOutline.slice(0, 3000)}\n\n## 规则\n${foundation.bookRules.slice(0, 1500)}\n\n## 初始状态\n${foundation.currentState.slice(0, 1000)}\n\n## 初始伏笔\n${foundation.pendingHooks.slice(0, 1000)}`;
+  }
+
+  private parseReviewResult(
+    content: string,
+    dimensions: ReadonlyArray<string>,
+  ): FoundationReviewResult {
+    const parsedDimensions: Array<{ readonly name: string; readonly score: number; readonly feedback: string }> = [];
+
+    for (let i = 0; i < dimensions.length; i++) {
+      const regex = new RegExp(
+        `=== DIMENSION: ${i + 1} ===\\s*[\\s\\S]*?(?:分数|Score)[::]\\s*(\\d+)[\\s\\S]*?(?:意见|Feedback)[::]\\s*([\\s\\S]*?)(?==== |$)`,
+      );
+      const match = content.match(regex);
+      parsedDimensions.push({
+        name: dimensions[i]!,
+        score: match ? parseInt(match[1]!, 10) : 50,
+        feedback: match ? match[2]!.trim() : "(parse failed)",
+      });
+    }
+
+    const totalScore = parsedDimensions.length > 0
+      ? Math.round(parsedDimensions.reduce((sum, d) => sum + d.score, 0) / parsedDimensions.length)
+      : 0;
+    const anyBelowFloor = parsedDimensions.some((d) => d.score < DIMENSION_FLOOR);
+    const passed = totalScore >= PASS_THRESHOLD && !anyBelowFloor;
+
+    const overallMatch = content.match(
+      /=== OVERALL ===[\s\S]*?(?:总评|Summary)[::]\s*([\s\S]*?)$/,
+    );
+    const overallFeedback = overallMatch ? overallMatch[1]!.trim() : "(parse failed)";
+
+    return { passed, totalScore, dimensions: parsedDimensions, overallFeedback };
+  }
+}

+ 85 - 4
packages/core/src/pipeline/runner.ts

@@ -5,7 +5,8 @@ import type { BookConfig, FanficMode } from "../models/book.js";
 import type { ChapterMeta } from "../models/chapter.js";
 import type { NotifyChannel, LLMConfig, AgentLLMOverride, InputGovernanceMode } from "../models/project.js";
 import type { GenreProfile } from "../models/genre-profile.js";
-import { ArchitectAgent } from "../agents/architect.js";
+import { ArchitectAgent, type ArchitectOutput } from "../agents/architect.js";
+import { FoundationReviewerAgent } from "../agents/foundation-reviewer.js";
 import { PlannerAgent, type PlanChapterOutput } from "../agents/planner.js";
 import { ComposerAgent } from "../agents/composer.js";
 import { WriterAgent, type WriteChapterInput, type WriteChapterOutput } from "../agents/writer.js";
@@ -242,6 +243,69 @@ export class PipelineRunner {
     }
   }
 
+  private async generateAndReviewFoundation(params: {
+    readonly generate: () => Promise<ArchitectOutput>;
+    readonly reviewer: FoundationReviewerAgent;
+    readonly mode: "original" | "fanfic" | "series";
+    readonly sourceCanon?: string;
+    readonly styleGuide?: string;
+    readonly language: "zh" | "en";
+    readonly stageLanguage: LengthLanguage;
+    readonly maxRetries?: number;
+  }): Promise<ArchitectOutput> {
+    const maxRetries = params.maxRetries ?? 2;
+    let foundation = await params.generate();
+
+    for (let attempt = 0; attempt < maxRetries; attempt++) {
+      this.logStage(params.stageLanguage, {
+        zh: `审核基础设定(第${attempt + 1}轮)`,
+        en: `reviewing foundation (round ${attempt + 1})`,
+      });
+
+      const review = await params.reviewer.review({
+        foundation,
+        mode: params.mode,
+        sourceCanon: params.sourceCanon,
+        styleGuide: params.styleGuide,
+        language: params.language,
+      });
+
+      this.config.logger?.info(
+        `Foundation review: ${review.totalScore}/100 ${review.passed ? "PASSED" : "REJECTED"}`,
+      );
+      for (const dim of review.dimensions) {
+        this.config.logger?.info(`  [${dim.score}] ${dim.name.slice(0, 40)}`);
+      }
+
+      if (review.passed) {
+        return foundation;
+      }
+
+      this.logWarn(params.stageLanguage, {
+        zh: `基础设定未通过审核(${review.totalScore}分),正在重新生成...`,
+        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();
+    }
+
+    // Final review
+    const finalReview = await params.reviewer.review({
+      foundation,
+      mode: params.mode,
+      sourceCanon: params.sourceCanon,
+      styleGuide: params.styleGuide,
+      language: params.language,
+    });
+    this.config.logger?.info(
+      `Foundation final review: ${finalReview.totalScore}/100 ${finalReview.passed ? "PASSED" : "ACCEPTED (max retries)"}`,
+    );
+
+    return foundation;
+  }
+
   private agentCtx(bookId?: string): AgentContext {
     return {
       client: this.config.client,
@@ -346,7 +410,15 @@ export class PipelineRunner {
 
     this.logStage(stageLanguage, { zh: "生成基础设定", en: "generating foundation" });
     const { profile: gp } = await this.loadGenreProfile(book.genre);
-    const foundation = await architect.generateFoundation(book, this.config.externalContext);
+    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),
+      reviewer,
+      mode: "original",
+      language: resolvedLanguage,
+      stageLanguage,
+    });
     try {
       this.logStage(stageLanguage, { zh: "保存书籍配置", en: "saving book config" });
       await this.state.saveBookConfigAt(stagingBookDir, book);
@@ -421,11 +493,20 @@ export class PipelineRunner {
     this.logStage(stageLanguage, { zh: "导入同人正典", en: "importing fanfic canon" });
     const fanficCanon = await this.importFanficCanon(book.id, sourceText, sourceName, fanficMode);
 
-    // Step 2: Generate foundation from fanfic canon (not from scratch)
+    // Step 2: Generate foundation with review loop
     const architect = new ArchitectAgent(this.agentCtxFor("architect", book.id));
+    const reviewer = new FoundationReviewerAgent(this.agentCtxFor("foundation-reviewer", book.id));
     this.logStage(stageLanguage, { zh: "生成同人基础设定", en: "generating fanfic foundation" });
     const { profile: gp } = await this.loadGenreProfile(book.genre);
-    const foundation = await architect.generateFanficFoundation(book, fanficCanon, fanficMode);
+    const resolvedLanguage = (book.language ?? gp.language) === "en" ? "en" as const : "zh" as const;
+    const foundation = await this.generateAndReviewFoundation({
+      generate: () => architect.generateFanficFoundation(book, fanficCanon, fanficMode),
+      reviewer,
+      mode: "fanfic",
+      sourceCanon: fanficCanon,
+      language: resolvedLanguage,
+      stageLanguage,
+    });
     this.logStage(stageLanguage, { zh: "写入基础设定文件", en: "writing foundation files" });
     await architect.writeFoundationFiles(
       bookDir,