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

refactor: separate guardrails from chapter-specific steering

Ma 7 месяцев назад
Родитель
Сommit
269be25ac2

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

@@ -377,6 +377,72 @@ describe("PipelineRunner", () => {
     }
   });
 
+  it("passes reduced control inputs into auditor and reviser in v2 mode", async () => {
+    const { root, runner, state, bookId } = await createRunnerFixture({
+      inputGovernanceMode: "v2",
+    });
+
+    await Promise.all([
+      writeFile(join(state.bookDir(bookId), "story", "current_focus.md"), "# Current Focus\n\nBring focus back to the mentor conflict.\n", "utf-8"),
+      writeFile(join(state.bookDir(bookId), "story", "volume_outline.md"), "# Volume Outline\n\n## Chapter 1\nTrack the merchant guild trail.\n", "utf-8"),
+      writeFile(join(state.bookDir(bookId), "story", "current_state.md"), "# Current State\n\n- Lin Yue still hides the broken oath token.\n", "utf-8"),
+      writeFile(join(state.bookDir(bookId), "story", "story_bible.md"), "# Story Bible\n\n- The jade seal cannot be destroyed.\n", "utf-8"),
+      writeFile(join(state.bookDir(bookId), "story", "pending_hooks.md"), "# Pending Hooks\n\n- Why the mentor vanished after the trial.\n", "utf-8"),
+    ]);
+
+    vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
+      createWriterOutput({
+        chapterNumber: 1,
+        content: "Needs governed revision.",
+        wordCount: "Needs governed revision.".length,
+      }),
+    );
+    const auditChapter = vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
+      createAuditResult({
+        passed: false,
+        issues: [CRITICAL_ISSUE],
+        summary: "needs revision",
+      }),
+    );
+    const reviseChapter = vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue(
+      createReviseOutput({
+        revisedContent: "Governed revised content.",
+        wordCount: "Governed revised content.".length,
+      }),
+    );
+    vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockResolvedValue(
+      createAnalyzedOutput({
+        content: "Governed revised content.",
+        wordCount: "Governed revised content.".length,
+      }),
+    );
+
+    try {
+      await runner.writeNextChapter(bookId);
+
+      expect(auditChapter.mock.calls[0]?.[4]).toMatchObject({
+        chapterIntent: expect.stringContaining("# Chapter Intent"),
+        contextPackage: expect.objectContaining({
+          selectedContext: expect.any(Array),
+        }),
+        ruleStack: expect.objectContaining({
+          activeOverrides: expect.any(Array),
+        }),
+      });
+      expect(reviseChapter.mock.calls[0]?.[6]).toMatchObject({
+        chapterIntent: expect.stringContaining("# Chapter Intent"),
+        contextPackage: expect.objectContaining({
+          selectedContext: expect.any(Array),
+        }),
+        ruleStack: expect.objectContaining({
+          activeOverrides: expect.any(Array),
+        }),
+      });
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
   it("preserves the legacy fallback when input governance mode is legacy", async () => {
     const { root, runner, bookId } = await createRunnerFixture({
       inputGovernanceMode: "legacy",

+ 77 - 0
packages/core/src/__tests__/writer-prompts.test.ts

@@ -0,0 +1,77 @@
+import { describe, expect, it } from "vitest";
+import type { BookConfig } from "../models/book.js";
+import type { GenreProfile } from "../models/genre-profile.js";
+import { buildWriterSystemPrompt } from "../agents/writer-prompts.js";
+
+const BOOK: BookConfig = {
+  id: "prompt-book",
+  title: "Prompt Book",
+  platform: "tomato",
+  genre: "other",
+  status: "active",
+  targetChapters: 20,
+  chapterWordCount: 3000,
+  createdAt: "2026-03-22T00:00:00.000Z",
+  updatedAt: "2026-03-22T00:00:00.000Z",
+};
+
+const GENRE: GenreProfile = {
+  id: "other",
+  name: "综合",
+  language: "zh",
+  chapterTypes: ["setup", "conflict"],
+  fatigueWords: [],
+  numericalSystem: false,
+  powerScaling: false,
+  eraResearch: false,
+  pacingRule: "",
+  satisfactionTypes: [],
+  auditDimensions: [],
+};
+
+describe("buildWriterSystemPrompt", () => {
+  it("demotes always-on methodology blocks in governed mode", () => {
+    const prompt = buildWriterSystemPrompt(
+      BOOK,
+      GENRE,
+      null,
+      "# Book Rules",
+      "# Genre Body",
+      "# Style Guide\n\nKeep the prose restrained.",
+      undefined,
+      3,
+      "creative",
+      undefined,
+      "zh",
+      "governed",
+    );
+
+    expect(prompt).toContain("## 输入治理契约");
+    expect(prompt).toContain("卷纲是默认规划");
+    expect(prompt).not.toContain("## 六步走人物心理分析");
+    expect(prompt).not.toContain("## 读者心理学框架");
+    expect(prompt).not.toContain("## 黄金三章规则");
+  });
+
+  it("keeps hard guardrails and book/style constraints in governed mode", () => {
+    const prompt = buildWriterSystemPrompt(
+      BOOK,
+      GENRE,
+      null,
+      "# Book Rules\n\n- Do not reveal the mastermind.",
+      "# Genre Body",
+      "# Style Guide\n\nKeep the prose restrained.",
+      undefined,
+      3,
+      "creative",
+      undefined,
+      "zh",
+      "governed",
+    );
+
+    expect(prompt).toContain("## 核心规则");
+    expect(prompt).toContain("## 硬性禁令");
+    expect(prompt).toContain("Do not reveal the mastermind");
+    expect(prompt).toContain("Keep the prose restrained");
+  });
+});

+ 43 - 4
packages/core/src/agents/continuity.ts

@@ -2,6 +2,7 @@ import { BaseAgent } from "./base.js";
 import type { GenreProfile } from "../models/genre-profile.js";
 import type { BookRules } from "../models/book-rules.js";
 import type { FanficMode } from "../models/book.js";
+import type { ContextPackage, RuleStack } from "../models/input-governance.js";
 import { readGenreProfile, readBookRules } from "./rules-reader.js";
 import { getFanficDimensionConfig, FANFIC_DIMENSIONS } from "./fanfic-dimensions.js";
 import { readFile, readdir } from "node:fs/promises";
@@ -206,7 +207,12 @@ export class ContinuityAuditor extends BaseAgent {
     chapterContent: string,
     chapterNumber: number,
     genre?: string,
-    options?: { temperature?: number },
+    options?: {
+      temperature?: number;
+      chapterIntent?: string;
+      contextPackage?: ContextPackage;
+      ruleStack?: RuleStack;
+    },
   ): Promise<AuditResult> {
     const [currentState, ledger, hooks, styleGuideRaw, subplotBoard, emotionalArcs, characterMatrix, chapterSummaries, parentCanon, fanficCanon, volumeOutline] =
       await Promise.all([
@@ -335,6 +341,12 @@ ${dimList}
     const outlineBlock = volumeOutline !== "(文件不存在)"
       ? `\n## 卷纲(用于大纲偏离检测)\n${volumeOutline}\n`
       : "";
+    const reducedControlBlock = options?.chapterIntent && options.contextPackage && options.ruleStack
+      ? this.buildReducedControlBlock(options.chapterIntent, options.contextPackage, options.ruleStack)
+      : "";
+    const styleGuideBlock = reducedControlBlock.length === 0
+      ? `\n## 文风指南\n${styleGuide}`
+      : "";
 
     const prevChapterBlock = previousChapter
       ? `\n## 上一章全文(用于衔接检查)\n${previousChapter}\n`
@@ -347,9 +359,7 @@ ${currentState}
 ${ledgerBlock}
 ## 伏笔池
 ${filteredHooks}
-${subplotBlock}${emotionalBlock}${matrixBlock}${summariesBlock}${canonBlock}${fanficCanonBlock}${outlineBlock}${prevChapterBlock}
-## 文风指南
-${styleGuide}
+${subplotBlock}${emotionalBlock}${matrixBlock}${summariesBlock}${canonBlock}${fanficCanonBlock}${reducedControlBlock || outlineBlock}${prevChapterBlock}${styleGuideBlock}
 
 ## 待审章节内容
 ${chapterContent}`;
@@ -436,6 +446,35 @@ ${chapterContent}`;
     };
   }
 
+  private buildReducedControlBlock(
+    chapterIntent: string,
+    contextPackage: ContextPackage,
+    ruleStack: RuleStack,
+  ): string {
+    const selectedContext = contextPackage.selectedContext
+      .map((entry) => `- ${entry.source}: ${entry.reason}${entry.excerpt ? ` | ${entry.excerpt}` : ""}`)
+      .join("\n");
+    const overrides = ruleStack.activeOverrides.length > 0
+      ? ruleStack.activeOverrides
+        .map((override) => `- ${override.from} -> ${override.to}: ${override.reason} (${override.target})`)
+        .join("\n")
+      : "- none";
+
+    return `\n## 本章控制输入(由 Planner/Composer 编译)
+${chapterIntent}
+
+### 已选上下文
+${selectedContext || "- none"}
+
+### 规则栈
+- 硬护栏:${ruleStack.sections.hard.join("、") || "(无)"}
+- 软约束:${ruleStack.sections.soft.join("、") || "(无)"}
+- 诊断规则:${ruleStack.sections.diagnostic.join("、") || "(无)"}
+
+### 当前覆盖
+${overrides}\n`;
+  }
+
   private extractBalancedJson(text: string): string | null {
     const start = text.indexOf("{");
     if (start === -1) return null;

+ 42 - 3
packages/core/src/agents/reviser.ts

@@ -2,6 +2,7 @@ import { BaseAgent } from "./base.js";
 import type { GenreProfile } from "../models/genre-profile.js";
 import type { BookRules } from "../models/book-rules.js";
 import type { AuditIssue } from "./continuity.js";
+import type { ContextPackage, RuleStack } from "../models/input-governance.js";
 import { readGenreProfile, readBookRules } from "./rules-reader.js";
 import { readFile } from "node:fs/promises";
 import { join } from "node:path";
@@ -53,6 +54,11 @@ export class ReviserAgent extends BaseAgent {
     issues: ReadonlyArray<AuditIssue>,
     mode: ReviseMode = "rewrite",
     genre?: string,
+    options?: {
+      chapterIntent?: string;
+      contextPackage?: ContextPackage;
+      ruleStack?: RuleStack;
+    },
   ): Promise<ReviseOutput> {
     const [currentState, ledger, hooks, styleGuideRaw, volumeOutline, storyBible, characterMatrix, chapterSummaries, parentCanon, fanficCanon] = await Promise.all([
       this.readFileSafe(join(bookDir, "story/current_state.md")),
@@ -147,6 +153,12 @@ ${gp.numericalSystem ? "\n=== UPDATED_LEDGER ===\n(更新后的完整资源账
     const fanficCanonBlock = hasFanficCanon
       ? `\n## 同人正典参照(修稿专用)\n本书为同人作品。修改时参照正典角色档案和世界规则,不可违反正典事实。角色对话必须保留原作语癖。\n${fanficCanon}\n`
       : "";
+    const reducedControlBlock = options?.chapterIntent && options.contextPackage && options.ruleStack
+      ? this.buildReducedControlBlock(options.chapterIntent, options.contextPackage, options.ruleStack)
+      : "";
+    const styleGuideBlock = reducedControlBlock.length === 0
+      ? `\n## 文风指南\n${styleGuide}`
+      : "";
 
     const userPrompt = `请修正第${chapterNumber}章。
 
@@ -158,9 +170,7 @@ ${currentState}
 ${ledgerBlock}
 ## 伏笔池
 ${hooks}
-${outlineBlock}${bibleBlock}${matrixBlock}${summariesBlock}${canonBlock}${fanficCanonBlock}
-## 文风指南
-${styleGuide}
+${reducedControlBlock || outlineBlock}${bibleBlock}${matrixBlock}${summariesBlock}${canonBlock}${fanficCanonBlock}${styleGuideBlock}
 
 ## 待修正章节
 ${chapterContent}`;
@@ -213,4 +223,33 @@ ${chapterContent}`;
       return "(文件不存在)";
     }
   }
+
+  private buildReducedControlBlock(
+    chapterIntent: string,
+    contextPackage: ContextPackage,
+    ruleStack: RuleStack,
+  ): string {
+    const selectedContext = contextPackage.selectedContext
+      .map((entry) => `- ${entry.source}: ${entry.reason}${entry.excerpt ? ` | ${entry.excerpt}` : ""}`)
+      .join("\n");
+    const overrides = ruleStack.activeOverrides.length > 0
+      ? ruleStack.activeOverrides
+        .map((override) => `- ${override.from} -> ${override.to}: ${override.reason} (${override.target})`)
+        .join("\n")
+      : "- none";
+
+    return `\n## 本章控制输入(由 Planner/Composer 编译)
+${chapterIntent}
+
+### 已选上下文
+${selectedContext || "- none"}
+
+### 规则栈
+- 硬护栏:${ruleStack.sections.hard.join("、") || "(无)"}
+- 软约束:${ruleStack.sections.soft.join("、") || "(无)"}
+- 诊断规则:${ruleStack.sections.diagnostic.join("、") || "(无)"}
+
+### 当前覆盖
+${overrides}\n`;
+  }
 }

+ 35 - 11
packages/core/src/agents/writer-prompts.ts

@@ -26,8 +26,10 @@ export function buildWriterSystemPrompt(
   mode: "full" | "creative" = "full",
   fanficContext?: FanficContext,
   languageOverride?: "zh" | "en",
+  inputProfile: "legacy" | "governed" = "legacy",
 ): string {
   const isEnglish = (languageOverride ?? genreProfile.language) === "en";
+  const governed = inputProfile === "governed";
 
   const outputSection = mode === "creative"
     ? buildCreativeOutputFormat(book, genreProfile)
@@ -37,8 +39,9 @@ export function buildWriterSystemPrompt(
     ? [
         buildEnglishGenreIntro(book, genreProfile),
         buildEnglishCoreRules(book),
-        buildEnglishAntiAIRules(),
-        buildEnglishCharacterMethod(),
+        buildGovernedInputContract("en", governed),
+        !governed ? buildEnglishAntiAIRules() : "",
+        !governed ? buildEnglishCharacterMethod() : "",
         buildGenreRules(genreProfile, genreBody),
         buildProtagonistRules(bookRules),
         buildBookRulesBody(bookRulesBody),
@@ -47,19 +50,20 @@ export function buildWriterSystemPrompt(
         fanficContext ? buildFanficCanonSection(fanficContext.fanficCanon, fanficContext.fanficMode) : "",
         fanficContext ? buildCharacterVoiceProfiles(fanficContext.fanficCanon) : "",
         fanficContext ? buildFanficModeInstructions(fanficContext.fanficMode, fanficContext.allowedDeviations) : "",
-        buildEnglishPreWriteChecklist(book, genreProfile),
+        !governed ? buildEnglishPreWriteChecklist(book, genreProfile) : "",
         outputSection,
       ]
     : [
         buildGenreIntro(book, genreProfile),
         buildCoreRules(book),
-        buildAntiAIExamples(),
-        buildCharacterPsychologyMethod(),
-        buildSupportingCharacterMethod(),
-        buildReaderPsychologyMethod(),
-        buildEmotionalPacingMethod(),
-        buildImmersionTechniques(),
-        buildGoldenChaptersRules(chapterNumber),
+        buildGovernedInputContract("zh", governed),
+        !governed ? buildAntiAIExamples() : "",
+        !governed ? buildCharacterPsychologyMethod() : "",
+        !governed ? buildSupportingCharacterMethod() : "",
+        !governed ? buildReaderPsychologyMethod() : "",
+        !governed ? buildEmotionalPacingMethod() : "",
+        !governed ? buildImmersionTechniques() : "",
+        !governed ? buildGoldenChaptersRules(chapterNumber) : "",
         bookRules?.enableFullCastTracking ? buildFullCastTracking() : "",
         buildGenreRules(genreProfile, genreBody),
         buildProtagonistRules(bookRules),
@@ -69,7 +73,7 @@ export function buildWriterSystemPrompt(
         fanficContext ? buildFanficCanonSection(fanficContext.fanficCanon, fanficContext.fanficMode) : "",
         fanficContext ? buildCharacterVoiceProfiles(fanficContext.fanficCanon) : "",
         fanficContext ? buildFanficModeInstructions(fanficContext.fanficMode, fanficContext.allowedDeviations) : "",
-        buildPreWriteChecklist(book, genreProfile),
+        !governed ? buildPreWriteChecklist(book, genreProfile) : "",
         outputSection,
       ];
 
@@ -84,6 +88,26 @@ function buildGenreIntro(book: BookConfig, gp: GenreProfile): string {
   return `你是一位专业的${gp.name}网络小说作家。你为${book.platform}平台写作。`;
 }
 
+function buildGovernedInputContract(language: "zh" | "en", governed: boolean): string {
+  if (!governed) return "";
+
+  if (language === "en") {
+    return `## Input Governance Contract
+
+- Chapter-specific steering comes from the provided chapter intent and composed context package.
+- The outline is the default plan, not unconditional global supremacy.
+- When the runtime rule stack records an active L4 -> L3 override, follow the current task over local planning.
+- Keep hard guardrails compact: canon, continuity facts, and explicit prohibitions still win.`;
+  }
+
+  return `## 输入治理契约
+
+- 本章具体写什么,以提供给你的 chapter intent 和 composed context package 为准。
+- 卷纲是默认规划,不是全局最高规则。
+- 当 runtime rule stack 明确记录了 L4 -> L3 的 active override 时,优先执行当前任务意图,再局部调整规划层。
+- 真正不能突破的只有硬护栏:世界设定、连续性事实、显式禁令。`;
+}
+
 // ---------------------------------------------------------------------------
 // Core rules (~25 universal rules)
 // ---------------------------------------------------------------------------

+ 1 - 0
packages/core/src/agents/writer.ts

@@ -115,6 +115,7 @@ export class WriterAgent extends BaseAgent {
     const creativeSystemPrompt = buildWriterSystemPrompt(
       book, genreProfile, bookRules, bookRulesBody, genreBody, styleGuide, styleFingerprint,
       chapterNumber, "creative", fanficContext, resolvedLanguage,
+      input.chapterIntent ? "governed" : "legacy",
     );
 
     const creativeUserPrompt = input.chapterIntent && input.contextPackage && input.ruleStack

+ 11 - 1
packages/core/src/pipeline/runner.ts

@@ -617,6 +617,13 @@ export class PipelineRunner {
       chapterNumber,
       this.config.externalContext,
     );
+    const reducedControlInput = writeInput.chapterIntent && writeInput.contextPackage && writeInput.ruleStack
+      ? {
+          chapterIntent: writeInput.chapterIntent,
+          contextPackage: writeInput.contextPackage,
+          ruleStack: writeInput.ruleStack,
+        }
+      : undefined;
     const { profile: gp } = await this.loadGenreProfile(book.genre);
 
     // 1. Write chapter
@@ -656,6 +663,7 @@ export class PipelineRunner {
         spotFixIssues,
         "spot-fix",
         book.genre,
+        reducedControlInput,
       );
       totalUsage = PipelineRunner.addUsage(totalUsage, fixResult.tokenUsage);
       if (fixResult.revisedContent.length > 0) {
@@ -672,6 +680,7 @@ export class PipelineRunner {
       finalContent,
       chapterNumber,
       book.genre,
+      reducedControlInput,
     );
     totalUsage = PipelineRunner.addUsage(totalUsage, llmAudit.tokenUsage);
     const aiTellsResult = analyzeAITells(finalContent);
@@ -697,6 +706,7 @@ export class PipelineRunner {
           auditResult.issues,
           "spot-fix",
           book.genre,
+          reducedControlInput,
         );
         totalUsage = PipelineRunner.addUsage(totalUsage, reviseOutput.tokenUsage);
 
@@ -721,7 +731,7 @@ export class PipelineRunner {
             finalContent,
             chapterNumber,
             book.genre,
-            { temperature: 0 },
+            { ...reducedControlInput, temperature: 0 },
           );
           totalUsage = PipelineRunner.addUsage(totalUsage, reAudit.tokenUsage);
           const reAITells = analyzeAITells(finalContent);