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

feat: genre-aware architect, 19-dimension auditor, reviser modes

- architect: generates book_rules.md with genre profile context
- continuity: 19 audit dimensions with genre-conditional activation
- continuity: AI marker word density detection in D10
- reviser: polish/rewrite/rework modes, genre-aware prompt
majx_mac 8 месяцев назад
Родитель
Сommit
7a8496df6e

+ 107 - 37
packages/core/src/agents/architect.ts

@@ -1,13 +1,14 @@
 import { BaseAgent } from "./base.js";
-import type { AgentContext } from "./base.js";
 import type { BookConfig } from "../models/book.js";
-import { readFile, writeFile, mkdir } from "node:fs/promises";
+import type { GenreProfile } from "../models/genre-profile.js";
+import { readGenreProfile } from "./rules-reader.js";
+import { writeFile, mkdir } from "node:fs/promises";
 import { join } from "node:path";
 
 export interface ArchitectOutput {
   readonly storyBible: string;
   readonly volumeOutline: string;
-  readonly styleGuide: string;
+  readonly bookRules: string;
   readonly currentState: string;
   readonly pendingHooks: string;
 }
@@ -18,28 +19,97 @@ export class ArchitectAgent extends BaseAgent {
   }
 
   async generateFoundation(book: BookConfig, externalContext?: string): Promise<ArchitectOutput> {
+    const { profile: gp, body: genreBody } =
+      await readGenreProfile(this.ctx.projectRoot, book.genre);
+
     const contextBlock = externalContext
-      ? `\n\n## 外部指令\n以下是来自外部系统(如 OpenClaw)的创作指令,请将其融入设定中:\n\n${externalContext}\n`
+      ? `\n\n## 外部指令\n以下是来自外部系统的创作指令,请将其融入设定中:\n\n${externalContext}\n`
+      : "";
+
+    const numericalBlock = gp.numericalSystem
+      ? `- 有明确的数值/资源体系可追踪
+- 在 book_rules 中定义 numericalSystemOverrides(hardCap、resourceTypes)`
+      : "- 本题材无数值系统,不需要资源账本";
+
+    const powerBlock = gp.powerScaling
+      ? "- 有明确的战力等级体系"
       : "";
 
-    const systemPrompt = `你是一个专业的网络小说架构师。你的任务是为一本新小说生成完整的基础设定。${contextBlock}
+    const eraBlock = gp.eraResearch
+      ? "- 需要年代考据支撑(在 book_rules 中设置 eraConstraints)"
+      : "";
+
+    const systemPrompt = `你是一个专业的网络小说架构师。你的任务是为一本新的${gp.name}小说生成完整的基础设定。${contextBlock}
 
 要求:
 - 平台:${book.platform}
-- 题材:${book.genre}
+- 题材:${gp.name}(${book.genre})
 - 目标章数:${book.targetChapters}章
 - 每章字数:${book.chapterWordCount}字
 
+## 题材特征
+
+${genreBody}
+
+## 生成要求
+
 你需要生成以下内容,每个部分用 === SECTION: <name> === 分隔:
 
 === SECTION: story_bible ===
-世界观设定、势力分布、核心规则体系、主角设定(身份/金手指/性格底色)、重要配角
+用结构化二级标题组织:
+## 01_世界观
+世界观设定、核心规则体系
+
+## 02_主角
+主角设定(身份/金手指/性格底色/行为边界)
+
+## 03_势力与人物
+势力分布、重要配角(每人:名字、身份、动机、与主角关系、独立目标)
+
+## 04_地理与环境
+地图/场景设定、环境特色
+
+## 05_书名与简介
+- 书名建议:采用"题材+核心爽点+主角行为"的长书名格式,避免文艺化
+- 简介(300字内):第一句抛困境,第二句亮金手指/核心能力,第三句留悬念
 
 === SECTION: volume_outline ===
 卷纲规划,每卷包含:卷名、章节范围、核心冲突、关键转折、收益目标
 
-=== SECTION: style_guide ===
-文风锁定:叙事视角、语言风格、禁忌清单、爽点回路设计、节奏规则
+### 黄金三章法则(前三章必须遵循)
+- 第1章:抛出核心冲突(主角立即面临困境/危机/选择),禁止大段背景灌输
+- 第2章:展示金手指/核心能力(主角如何应对第1章的困境),让读者看到爽点预期
+- 第3章:明确短期目标(主角确立第一个具体可达成的目标),给读者追读理由
+
+=== SECTION: book_rules ===
+生成 book_rules.md 格式的 YAML frontmatter + 叙事指导,包含:
+\`\`\`
+---
+version: "1.0"
+protagonist:
+  name: (主角名)
+  personalityLock: [(3-5个性格关键词)]
+  behavioralConstraints: [(3-5条行为约束)]
+genreLock:
+  primary: ${book.genre}
+  forbidden: [(2-3种禁止混入的文风)]
+${gp.numericalSystem ? `numericalSystemOverrides:
+  hardCap: (根据设定确定)
+  resourceTypes: [(核心资源类型列表)]` : ""}
+prohibitions:
+  - (3-5条本书禁忌)
+chapterTypesOverride: []
+fatigueWordsOverride: []
+additionalAuditDimensions: []
+enableFullCastTracking: false
+---
+
+## 叙事视角
+(描述本书叙事视角和风格)
+
+## 核心冲突驱动
+(描述本书的核心矛盾和驱动力)
+\`\`\`
 
 === SECTION: current_state ===
 初始状态卡(第0章),包含:
@@ -59,15 +129,19 @@ export class ArchitectAgent extends BaseAgent {
 
 生成内容必须:
 1. 符合${book.platform}平台口味
-2. 主角杀伐果断,不圣母
-3. 有明确的数值/资源体系可追踪
-4. 伏笔前后呼应,不留悬空线`;
+2. 符合${gp.name}题材特征
+${numericalBlock}
+${powerBlock}
+${eraBlock}
+3. 主角人设鲜明,有明确行为边界
+4. 伏笔前后呼应,不留悬空线
+5. 配角有独立动机,不是工具人`;
 
     const response = await this.chat([
       { role: "system", content: systemPrompt },
       {
         role: "user",
-        content: `请为标题为"${book.title}"的${book.genre}小说生成完整基础设定。`,
+        content: `请为标题为"${book.title}"的${gp.name}小说生成完整基础设定。`,
       },
     ], { maxTokens: 16384, temperature: 0.8 });
 
@@ -77,34 +151,30 @@ export class ArchitectAgent extends BaseAgent {
   async writeFoundationFiles(
     bookDir: string,
     output: ArchitectOutput,
+    numericalSystem: boolean = true,
   ): Promise<void> {
     const storyDir = join(bookDir, "story");
     await mkdir(storyDir, { recursive: true });
 
-    await Promise.all([
+    const writes: Array<Promise<void>> = [
       writeFile(join(storyDir, "story_bible.md"), output.storyBible, "utf-8"),
-      writeFile(
-        join(storyDir, "volume_outline.md"),
-        output.volumeOutline,
-        "utf-8",
-      ),
-      writeFile(join(storyDir, "style_guide.md"), output.styleGuide, "utf-8"),
-      writeFile(
-        join(storyDir, "current_state.md"),
-        output.currentState,
-        "utf-8",
-      ),
-      writeFile(
-        join(storyDir, "pending_hooks.md"),
-        output.pendingHooks,
-        "utf-8",
-      ),
-      writeFile(
-        join(storyDir, "particle_ledger.md"),
-        "# 资源账本\n\n| 章节 | 期初值 | 来源 | 完整度 | 增量 | 期末值 | 依据 |\n|------|--------|------|--------|------|--------|------|\n| 0 | 0 | 初始化 | - | 0 | 0 | 开书初始 |\n",
-        "utf-8",
-      ),
-    ]);
+      writeFile(join(storyDir, "volume_outline.md"), output.volumeOutline, "utf-8"),
+      writeFile(join(storyDir, "book_rules.md"), output.bookRules, "utf-8"),
+      writeFile(join(storyDir, "current_state.md"), output.currentState, "utf-8"),
+      writeFile(join(storyDir, "pending_hooks.md"), output.pendingHooks, "utf-8"),
+    ];
+
+    if (numericalSystem) {
+      writes.push(
+        writeFile(
+          join(storyDir, "particle_ledger.md"),
+          "# 资源账本\n\n| 章节 | 期初值 | 来源 | 完整度 | 增量 | 期末值 | 依据 |\n|------|--------|------|--------|------|--------|------|\n| 0 | 0 | 初始化 | - | 0 | 0 | 开书初始 |\n",
+          "utf-8",
+        ),
+      );
+    }
+
+    await Promise.all(writes);
   }
 
   private parseSections(content: string): ArchitectOutput {
@@ -119,7 +189,7 @@ export class ArchitectAgent extends BaseAgent {
     return {
       storyBible: extract("story_bible"),
       volumeOutline: extract("volume_outline"),
-      styleGuide: extract("style_guide"),
+      bookRules: extract("book_rules"),
       currentState: extract("current_state"),
       pendingHooks: extract("pending_hooks"),
     };

+ 104 - 16
packages/core/src/agents/continuity.ts

@@ -1,4 +1,7 @@
 import { BaseAgent } from "./base.js";
+import type { GenreProfile } from "../models/genre-profile.js";
+import type { BookRules } from "../models/book-rules.js";
+import { readGenreProfile, readBookRules } from "./rules-reader.js";
 import { readFile } from "node:fs/promises";
 import { join } from "node:path";
 
@@ -15,6 +18,78 @@ export interface AuditIssue {
   readonly suggestion: string;
 }
 
+// Dimension ID → name mapping
+const DIMENSION_MAP: Record<number, string> = {
+  1: "OOC检查",
+  2: "时间线检查",
+  3: "设定冲突",
+  4: "战力崩坏",
+  5: "数值检查",
+  6: "伏笔检查",
+  7: "节奏检查",
+  8: "文风检查",
+  9: "信息越界",
+  10: "词汇疲劳",
+  11: "利益链断裂",
+  12: "年代考据",
+  13: "配角降智",
+  14: "配角工具人化",
+  15: "爽点虚化",
+  16: "台词失真",
+  17: "流水账",
+  18: "知识库污染",
+  19: "视角一致性",
+};
+
+function buildDimensionList(
+  gp: GenreProfile,
+  bookRules: BookRules | null,
+): ReadonlyArray<{ readonly id: number; readonly name: string; readonly note: string }> {
+  const activeIds = new Set(gp.auditDimensions);
+
+  // Add book-level additional dimensions
+  if (bookRules?.additionalAuditDimensions) {
+    for (const d of bookRules.additionalAuditDimensions) {
+      if (typeof d === "number") activeIds.add(d);
+    }
+  }
+
+  // Conditional overrides
+  if (gp.eraResearch || bookRules?.eraConstraints?.enabled) {
+    activeIds.add(12);
+  }
+
+  const dims: Array<{ id: number; name: string; note: string }> = [];
+
+  for (const id of [...activeIds].sort((a, b) => a - b)) {
+    const name = DIMENSION_MAP[id];
+    if (!name) continue;
+
+    let note = "";
+    if (id === 10 && gp.fatigueWords.length > 0) {
+      const words = bookRules?.fatigueWordsOverride && bookRules.fatigueWordsOverride.length > 0
+        ? bookRules.fatigueWordsOverride
+        : gp.fatigueWords;
+      note = `高疲劳词:${words.join("、")}。同时检查AI标记词(仿佛/不禁/宛如/竟然/忽然/猛地)密度,每3000字超过1次即warning`;
+    }
+    if (id === 15 && gp.satisfactionTypes.length > 0) {
+      note = `爽点类型:${gp.satisfactionTypes.join("、")}`;
+    }
+    if (id === 12 && bookRules?.eraConstraints) {
+      const era = bookRules.eraConstraints;
+      const parts = [era.period, era.region].filter(Boolean);
+      if (parts.length > 0) note = `年代:${parts.join(",")}`;
+    }
+    if (id === 19) {
+      note = "检查视角切换是否有过渡、是否与设定视角一致";
+    }
+
+    dims.push({ id, name, note });
+  }
+
+  return dims;
+}
+
 export class ContinuityAuditor extends BaseAgent {
   get name(): string {
     return "continuity-auditor";
@@ -24,27 +99,39 @@ export class ContinuityAuditor extends BaseAgent {
     bookDir: string,
     chapterContent: string,
     chapterNumber: number,
+    genre?: string,
   ): Promise<AuditResult> {
-    const [currentState, ledger, hooks, styleGuide] = await Promise.all([
+    const [currentState, ledger, hooks, styleGuideRaw] = await Promise.all([
       this.readFileSafe(join(bookDir, "story/current_state.md")),
       this.readFileSafe(join(bookDir, "story/particle_ledger.md")),
       this.readFileSafe(join(bookDir, "story/pending_hooks.md")),
       this.readFileSafe(join(bookDir, "story/style_guide.md")),
     ]);
 
-    const systemPrompt = `你是一位严格的网络小说审稿编辑。你的任务是对章节进行连续性、一致性和质量审查。
+    // Load genre profile and book rules
+    const genreId = genre ?? "other";
+    const { profile: gp } = await readGenreProfile(this.ctx.projectRoot, genreId);
+    const parsedRules = await readBookRules(bookDir);
+    const bookRules = parsedRules?.rules ?? null;
+
+    // Fallback: use book_rules body when style_guide.md doesn't exist
+    const styleGuide = styleGuideRaw !== "(文件不存在)"
+      ? styleGuideRaw
+      : (parsedRules?.body ?? "(无文风指南)");
+
+    const dimensions = buildDimensionList(gp, bookRules);
+    const dimList = dimensions
+      .map((d) => `${d.id}. ${d.name}${d.note ? `(${d.note})` : ""}`)
+      .join("\n");
+
+    const protagonistBlock = bookRules?.protagonist
+      ? `\n主角人设锁定:${bookRules.protagonist.name},${bookRules.protagonist.personalityLock.join("、")},行为约束:${bookRules.protagonist.behavioralConstraints.join("、")}`
+      : "";
+
+    const systemPrompt = `你是一位严格的${gp.name}网络小说审稿编辑。你的任务是对章节进行连续性、一致性和质量审查。${protagonistBlock}
 
 审查维度:
-1. OOC检查:角色行为是否符合已确立人设
-2. 时间线检查:时间/地点是否连贯
-3. 设定冲突:是否违反已确立的世界观规则
-4. 战力崩坏:是否出现不合理的实力变化
-5. 数值检查:资源/数值变动是否与账本一致
-6. 伏笔检查:是否有遗漏或矛盾的伏笔
-7. 节奏检查:是否拖沓或跳跃
-8. 文风检查:是否偏离风格指南
-9. 信息越界:角色是否知道了不该知道的信息
-10. 词汇疲劳:是否有过度重复的表达
+${dimList}
 
 输出格式必须为 JSON:
 {
@@ -62,14 +149,15 @@ export class ContinuityAuditor extends BaseAgent {
 
 只有当存在 critical 级别问题时,passed 才为 false。`;
 
+    const ledgerBlock = gp.numericalSystem
+      ? `\n## 资源账本\n${ledger}`
+      : "";
+
     const userPrompt = `请审查第${chapterNumber}章。
 
 ## 当前状态卡
 ${currentState}
-
-## 资源账本
-${ledger}
-
+${ledgerBlock}
 ## 伏笔池
 ${hooks}
 

+ 51 - 19
packages/core/src/agents/reviser.ts

@@ -1,9 +1,13 @@
 import { BaseAgent } from "./base.js";
-import type { BookConfig } from "../models/book.js";
+import type { GenreProfile } from "../models/genre-profile.js";
+import type { BookRules } from "../models/book-rules.js";
 import type { AuditIssue } from "./continuity.js";
-import { readFile, writeFile } from "node:fs/promises";
+import { readGenreProfile, readBookRules } from "./rules-reader.js";
+import { readFile } from "node:fs/promises";
 import { join } from "node:path";
 
+export type ReviseMode = "polish" | "rewrite" | "rework";
+
 export interface ReviseOutput {
   readonly revisedContent: string;
   readonly wordCount: number;
@@ -13,6 +17,12 @@ export interface ReviseOutput {
   readonly updatedHooks: string;
 }
 
+const MODE_DESCRIPTIONS: Record<ReviseMode, string> = {
+  polish: "润色:只改表达、节奏、段落呼吸,不改事实与剧情结论",
+  rewrite: "改写:可改叙述顺序、画面、力度,但保留核心事实与人物动机",
+  rework: "重写:可重构场景推进和冲突组织,但不改主设定和大事件结果",
+};
+
 export class ReviserAgent extends BaseAgent {
   get name(): string {
     return "reviser";
@@ -23,28 +33,50 @@ export class ReviserAgent extends BaseAgent {
     chapterContent: string,
     chapterNumber: number,
     issues: ReadonlyArray<AuditIssue>,
+    mode: ReviseMode = "rewrite",
+    genre?: string,
   ): Promise<ReviseOutput> {
-    const [currentState, ledger, hooks, styleGuide] = await Promise.all([
+    const [currentState, ledger, hooks, styleGuideRaw] = await Promise.all([
       this.readFileSafe(join(bookDir, "story/current_state.md")),
       this.readFileSafe(join(bookDir, "story/particle_ledger.md")),
       this.readFileSafe(join(bookDir, "story/pending_hooks.md")),
       this.readFileSafe(join(bookDir, "story/style_guide.md")),
     ]);
 
+    // Load genre profile and book rules
+    const genreId = genre ?? "other";
+    const { profile: gp } = await readGenreProfile(this.ctx.projectRoot, genreId);
+    const parsedRules = await readBookRules(bookDir);
+    const bookRules = parsedRules?.rules ?? null;
+
+    // Fallback: use book_rules body when style_guide.md doesn't exist
+    const styleGuide = styleGuideRaw !== "(文件不存在)"
+      ? styleGuideRaw
+      : (parsedRules?.body ?? "(无文风指南)");
+
     const issueList = issues
       .map((i) => `- [${i.severity}] ${i.category}: ${i.description}\n  建议: ${i.suggestion}`)
       .join("\n");
 
-    const systemPrompt = `你是一位专业的网络小说修稿编辑。你的任务是根据审稿意见对章节进行最小幅度修正。
+    const modeDesc = MODE_DESCRIPTIONS[mode];
+    const numericalRule = gp.numericalSystem
+      ? "\n3. 数值错误必须精确修正,前后对账"
+      : "";
+    const protagonistBlock = bookRules?.protagonist
+      ? `\n\n主角人设锁定:${bookRules.protagonist.name},${bookRules.protagonist.personalityLock.join("、")}。修改不得违反人设。`
+      : "";
+
+    const systemPrompt = `你是一位专业的${gp.name}网络小说修稿编辑。你的任务是根据审稿意见对章节进行修正。${protagonistBlock}
+
+修稿模式:${modeDesc}
 
 修稿原则:
-1. 只修问题,不改风格
-2. 修根因,不做表面润色
-3. 数值错误必须精确修正,前后对账
+1. 按模式控制修改幅度
+2. 修根因,不做表面润色${numericalRule}
 4. 伏笔状态必须与伏笔池同步
 5. 不改变剧情走向和核心冲突
 6. 保持原文的语言风格和节奏
-7. 修改后同步更新状态卡、账本、伏笔池
+7. 修改后同步更新状态卡${gp.numericalSystem ? "、账本" : ""}、伏笔池
 
 输出格式:
 
@@ -56,13 +88,14 @@ export class ReviserAgent extends BaseAgent {
 
 === UPDATED_STATE ===
 (更新后的完整状态卡)
-
-=== UPDATED_LEDGER ===
-(更新后的完整资源账本)
-
+${gp.numericalSystem ? "\n=== UPDATED_LEDGER ===\n(更新后的完整资源账本)" : ""}
 === UPDATED_HOOKS ===
 (更新后的完整伏笔池)`;
 
+    const ledgerBlock = gp.numericalSystem
+      ? `\n## 资源账本\n${ledger}`
+      : "";
+
     const userPrompt = `请修正第${chapterNumber}章。
 
 ## 审稿问题
@@ -70,10 +103,7 @@ ${issueList}
 
 ## 当前状态卡
 ${currentState}
-
-## 资源账本
-${ledger}
-
+${ledgerBlock}
 ## 伏笔池
 ${hooks}
 
@@ -91,10 +121,10 @@ ${chapterContent}`;
       { temperature: 0.3, maxTokens: 16384 },
     );
 
-    return this.parseOutput(response.content);
+    return this.parseOutput(response.content, gp);
   }
 
-  private parseOutput(content: string): ReviseOutput {
+  private parseOutput(content: string, gp: GenreProfile): ReviseOutput {
     const extract = (tag: string): string => {
       const regex = new RegExp(
         `=== ${tag} ===\\s*([\\s\\S]*?)(?==== [A-Z_]+ ===|$)`,
@@ -114,7 +144,9 @@ ${chapterContent}`;
         .map((l) => l.trim())
         .filter((l) => l.length > 0),
       updatedState: extract("UPDATED_STATE") || "(状态卡未更新)",
-      updatedLedger: extract("UPDATED_LEDGER") || "(账本未更新)",
+      updatedLedger: gp.numericalSystem
+        ? (extract("UPDATED_LEDGER") || "(账本未更新)")
+        : "",
       updatedHooks: extract("UPDATED_HOOKS") || "(伏笔池未更新)",
     };
   }