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

feat: genre CLI commands and pipeline genre passthrough

- genre list/show/create/copy CLI commands
- runner and agent pipeline pass genre to all agents
- revise command supports --mode flag
majx_mac 8 месяцев назад
Родитель
Сommit
daa3082eca

+ 154 - 0
packages/cli/src/commands/genre.ts

@@ -0,0 +1,154 @@
+import { Command } from "commander";
+import { writeFile, mkdir, readFile } from "node:fs/promises";
+import { join } from "node:path";
+import { listAvailableGenres, readGenreProfile, getBuiltinGenresDir } from "@actalk/inkos-core";
+import { findProjectRoot, log, logError } from "../utils.js";
+
+export const genreCommand = new Command("genre")
+  .description("Manage genre profiles");
+
+genreCommand
+  .command("list")
+  .description("List all available genre profiles (built-in + project)")
+  .action(async () => {
+    try {
+      const root = findProjectRoot();
+      const genres = await listAvailableGenres(root);
+
+      if (genres.length === 0) {
+        log("No genre profiles found.");
+        return;
+      }
+
+      log("Available genres:\n");
+      for (const g of genres) {
+        const tag = g.source === "project" ? "[project]" : "[builtin]";
+        log(`  ${g.id.padEnd(12)} ${g.name.padEnd(8)} ${tag}`);
+      }
+      log(`\nTotal: ${genres.length} genre(s)`);
+    } catch (e) {
+      logError(`Failed to list genres: ${e}`);
+      process.exit(1);
+    }
+  });
+
+genreCommand
+  .command("show")
+  .description("Display a genre profile")
+  .argument("<id>", "Genre ID (e.g. xuanhuan, urban, horror)")
+  .action(async (id: string) => {
+    try {
+      const root = findProjectRoot();
+      const { profile, body } = await readGenreProfile(root, id);
+
+      log(`Genre: ${profile.name} (${profile.id})\n`);
+      log(`  Chapter types:      ${profile.chapterTypes.join(", ")}`);
+      log(`  Fatigue words:      ${profile.fatigueWords.join(", ")}`);
+      log(`  Numerical system:   ${profile.numericalSystem}`);
+      log(`  Power scaling:      ${profile.powerScaling}`);
+      log(`  Era research:       ${profile.eraResearch}`);
+      log(`  Pacing rule:        ${profile.pacingRule}`);
+      log(`  Satisfaction types: ${profile.satisfactionTypes.join(", ")}`);
+      log(`  Audit dimensions:   ${profile.auditDimensions.join(", ")}`);
+
+      if (body) {
+        log(`\n--- Body ---\n${body}`);
+      }
+    } catch (e) {
+      logError(`Failed to show genre: ${e}`);
+      process.exit(1);
+    }
+  });
+
+genreCommand
+  .command("create")
+  .description("Scaffold a new genre profile in the project genres/ directory")
+  .argument("<id>", "Genre ID (e.g. scifi, wuxia, romance)")
+  .option("--name <name>", "Genre display name", "")
+  .option("--numerical", "Enable numerical system", false)
+  .option("--power", "Enable power scaling", false)
+  .option("--era", "Enable era research", false)
+  .action(async (id: string, opts) => {
+    try {
+      const root = findProjectRoot();
+      const genresDir = join(root, "genres");
+      const filePath = join(genresDir, `${id}.md`);
+
+      // Check if already exists
+      try {
+        await readFile(filePath, "utf-8");
+        logError(`Genre profile already exists: ${filePath}`);
+        process.exit(1);
+      } catch { /* file doesn't exist, good */ }
+
+      await mkdir(genresDir, { recursive: true });
+
+      const name = opts.name || id;
+      const template = `---
+name: ${name}
+id: ${id}
+chapterTypes: ["推进章", "布局章", "过渡章", "回收章"]
+fatigueWords: ["震惊", "不可思议", "难以置信"]
+numericalSystem: ${opts.numerical}
+powerScaling: ${opts.power}
+eraResearch: ${opts.era}
+pacingRule: "每2-3章有一个明确的进展或反馈"
+satisfactionTypes: ["目标达成", "困难克服", "真相揭示"]
+auditDimensions: [1,2,3,6,7,8,9,10,13,14,15,16,17,18,19]
+---
+
+## 题材禁忌
+
+- (根据题材添加禁忌)
+
+## 叙事指导
+
+(根据题材描述叙事重心和风格要求)
+`;
+
+      await writeFile(filePath, template, "utf-8");
+      log(`Created genre profile: ${filePath}`);
+      log(`Edit the file to customize chapter types, fatigue words, rules, etc.`);
+    } catch (e) {
+      logError(`Failed to create genre: ${e}`);
+      process.exit(1);
+    }
+  });
+
+genreCommand
+  .command("copy")
+  .description("Copy a built-in genre profile to project for customization")
+  .argument("<id>", "Genre ID to copy (e.g. xuanhuan)")
+  .action(async (id: string) => {
+    try {
+      const root = findProjectRoot();
+      const builtinDir = getBuiltinGenresDir();
+      const srcPath = join(builtinDir, `${id}.md`);
+      const genresDir = join(root, "genres");
+      const destPath = join(genresDir, `${id}.md`);
+
+      // Check if project override already exists
+      try {
+        await readFile(destPath, "utf-8");
+        logError(`Project genre profile already exists: ${destPath}`);
+        process.exit(1);
+      } catch { /* doesn't exist, good */ }
+
+      let content: string;
+      try {
+        content = await readFile(srcPath, "utf-8");
+      } catch {
+        logError(`Built-in genre "${id}" not found. Use 'inkos genre list' to see available genres.`);
+        process.exit(1);
+        return;
+      }
+
+      await mkdir(genresDir, { recursive: true });
+      await writeFile(destPath, content, "utf-8");
+      log(`Copied to: ${destPath}`);
+      log(`This project-level copy will override the built-in profile.`);
+    } catch (e) {
+      logError(`Failed to copy genre: ${e}`);
+      process.exit(1);
+    }
+  });

+ 5 - 3
packages/cli/src/commands/revise.ts

@@ -1,11 +1,12 @@
 import { Command } from "commander";
-import { PipelineRunner } from "@actalk/inkos-core";
+import { PipelineRunner, type ReviseMode } from "@actalk/inkos-core";
 import { loadConfig, createClient, findProjectRoot, log, logError } from "../utils.js";
 
 export const reviseCommand = new Command("revise")
   .description("Revise a chapter based on audit issues")
   .argument("<book-id>", "Book ID")
   .argument("[chapter]", "Chapter number (defaults to latest)")
+  .option("--mode <mode>", "Revise mode: polish, rewrite, rework", "rewrite")
   .option("--json", "Output JSON")
   .action(async (bookId: string, chapterStr: string | undefined, opts) => {
     try {
@@ -20,9 +21,10 @@ export const reviseCommand = new Command("revise")
       });
 
       const chapterNumber = chapterStr ? parseInt(chapterStr, 10) : undefined;
-      if (!opts.json) log(`Revising "${bookId}"${chapterNumber ? ` chapter ${chapterNumber}` : " (latest)"}...`);
+      const mode = opts.mode as ReviseMode;
+      if (!opts.json) log(`Revising "${bookId}"${chapterNumber ? ` chapter ${chapterNumber}` : " (latest)"} [mode: ${mode}]...`);
 
-      const result = await pipeline.reviseDraft(bookId, chapterNumber);
+      const result = await pipeline.reviseDraft(bookId, chapterNumber, mode);
 
       if (opts.json) {
         log(JSON.stringify(result, null, 2));

+ 2 - 0
packages/cli/src/index.ts

@@ -15,6 +15,7 @@ import { draftCommand } from "./commands/draft.js";
 import { auditCommand } from "./commands/audit.js";
 import { reviseCommand } from "./commands/revise.js";
 import { agentCommand } from "./commands/agent.js";
+import { genreCommand } from "./commands/genre.js";
 
 const program = new Command();
 
@@ -38,5 +39,6 @@ program.addCommand(draftCommand);
 program.addCommand(auditCommand);
 program.addCommand(reviseCommand);
 program.addCommand(agentCommand);
+program.addCommand(genreCommand);
 
 program.parse();

+ 39 - 18
packages/core/src/pipeline/agent.ts

@@ -1,6 +1,7 @@
 import type OpenAI from "openai";
 import { PipelineRunner, type PipelineConfig } from "./runner.js";
 import type { Platform, Genre } from "../models/book.js";
+import type { ReviseMode } from "../agents/reviser.js";
 
 /** Tool definitions for the agent loop (OpenAI function calling format). */
 const TOOLS: OpenAI.Chat.Completions.ChatCompletionTool[] = [
@@ -38,12 +39,13 @@ const TOOLS: OpenAI.Chat.Completions.ChatCompletionTool[] = [
     type: "function",
     function: {
       name: "revise_chapter",
-      description: "修订指定章节。根据审计问题做最小幅度修正。",
+      description: "修订指定章节。根据审计问题修正。支持三种模式:polish(润色)、rewrite(改写)、rework(重写)。",
       parameters: {
         type: "object",
         properties: {
           bookId: { type: "string", description: "书籍ID" },
           chapterNumber: { type: "number", description: "章节号(不填则修订最新章)" },
+          mode: { type: "string", enum: ["polish", "rewrite", "rework"], description: "修订模式(默认rewrite)" },
         },
         required: ["bookId"],
       },
@@ -95,7 +97,7 @@ const TOOLS: OpenAI.Chat.Completions.ChatCompletionTool[] = [
     type: "function",
     function: {
       name: "read_truth_files",
-      description: "读取书籍的三大真相文件(状态卡、资源账本、伏笔池)+ 世界观和卷纲。",
+      description: "读取书籍的长期记忆(状态卡、资源账本、伏笔池)+ 世界观和卷纲。",
       parameters: {
         type: "object",
         properties: {
@@ -152,22 +154,40 @@ export async function runAgentLoop(
   const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
     {
       role: "system",
-      content: `你是 InkOS 的智能编排 agent。你可以调用工具来管理网文创作的全流程。
-
-你的能力:
-- 扫描市场趋势(scan_market)
-- 创建新书(create_book)
-- 写草稿(write_draft)
-- 审计章节(audit_chapter)
-- 修订章节(revise_chapter)
-- 查看书籍状态(get_book_status)
-- 读取真相文件(read_truth_files)
-- 列出所有书(list_books)
-- 一键完整管线(write_full_pipeline)
-
-根据用户的自然语言指令,自主决定调用哪些工具、什么顺序。
-如果用户只给了题材或创意但没有明确要扫描市场,直接跳过雷达,用用户提供的信息创建书籍。
-每完成一步,简要汇报进展。`,
+      content: `你是 InkOS 小说写作 Agent。用户是小说作者,你帮他管理从建书到成稿的全过程。
+
+## 工具
+
+| 工具 | 作用 |
+|------|------|
+| list_books | 列出所有书 |
+| get_book_status | 查看书的章数、字数、审计状态 |
+| read_truth_files | 读取长期记忆(状态卡、资源账本、伏笔池)和设定(世界观、卷纲、本书规则) |
+| create_book | 建书,生成世界观、卷纲、本书规则(自动加载题材 genre profile) |
+| write_draft | 写一章草稿(自动加载 genre profile + book_rules) |
+| audit_chapter | 审计章节(18维度,按题材条件启用) |
+| revise_chapter | 修订章节(支持 polish/rewrite/rework 三种模式) |
+| write_full_pipeline | 完整管线:写 → 审 → 改(如需要) |
+| scan_market | 扫描平台排行榜,分析市场趋势 |
+
+## 长期记忆
+
+每本书有三个长期记忆文件,是 Agent 写作和审计的事实依据:
+- **current_state.md** — 角色位置、关系、已知信息、当前冲突
+- **particle_ledger.md** — 物品/资源账本,每笔增减有据可查
+- **pending_hooks.md** — 已埋伏笔、推进状态、预期回收时机
+
+## 管线逻辑
+
+- audit 返回 passed=true → 不需要 revise
+- audit 返回 passed=false 且有 critical → 调 revise,改完可以再 audit
+- write_full_pipeline 会自动走完 写→审→改,适合不需要中间干预的场景
+
+## 规则
+
+- 用户提供了题材/创意但没说要扫描市场 → 跳过 scan_market,直接 create_book
+- 用户说了书名/bookId → 直接操作,不需要先 list_books
+- 每完成一步,简要汇报进展`,
     },
     { role: "user", content: instruction },
   ];
@@ -298,6 +318,7 @@ async function executeTool(
       const result = await pipeline.reviseDraft(
         args.bookId as string,
         args.chapterNumber as number | undefined,
+        (args.mode as ReviseMode) ?? "rewrite",
       );
       return JSON.stringify(result);
     }

+ 35 - 14
packages/core/src/pipeline/runner.ts

@@ -2,12 +2,14 @@ import type OpenAI from "openai";
 import type { BookConfig } from "../models/book.js";
 import type { ChapterMeta } from "../models/chapter.js";
 import type { NotifyChannel } from "../models/project.js";
+import type { GenreProfile } from "../models/genre-profile.js";
 import { ArchitectAgent } from "../agents/architect.js";
 import { WriterAgent } from "../agents/writer.js";
 import { ContinuityAuditor } from "../agents/continuity.js";
-import { ReviserAgent } from "../agents/reviser.js";
+import { ReviserAgent, type ReviseMode } from "../agents/reviser.js";
 import { RadarAgent } from "../agents/radar.js";
 import type { RadarSource } from "../agents/radar-source.js";
+import { readGenreProfile } from "../agents/rules-reader.js";
 import { StateManager } from "../state/manager.js";
 import { dispatchNotification } from "../notify/dispatcher.js";
 import type { AgentContext } from "../agents/base.js";
@@ -54,7 +56,7 @@ export interface TruthFiles {
   readonly pendingHooks: string;
   readonly storyBible: string;
   readonly volumeOutline: string;
-  readonly styleGuide: string;
+  readonly bookRules: string;
 }
 
 export interface BookStatusInfo {
@@ -87,6 +89,11 @@ export class PipelineRunner {
     };
   }
 
+  private async loadGenreProfile(genre: string): Promise<{ profile: GenreProfile }> {
+    const parsed = await readGenreProfile(this.config.projectRoot, genre);
+    return { profile: parsed.profile };
+  }
+
   // ---------------------------------------------------------------------------
   // Atomic operations (composable by OpenClaw or agent mode)
   // ---------------------------------------------------------------------------
@@ -102,8 +109,9 @@ export class PipelineRunner {
 
     await this.state.saveBookConfig(book.id, book);
 
+    const { profile: gp } = await this.loadGenreProfile(book.genre);
     const foundation = await architect.generateFoundation(book, this.config.externalContext);
-    await architect.writeFoundationFiles(bookDir, foundation);
+    await architect.writeFoundationFiles(bookDir, foundation, gp.numericalSystem);
     await this.state.saveChapterIndex(book.id, []);
   }
 
@@ -115,6 +123,8 @@ export class PipelineRunner {
       const bookDir = this.state.bookDir(bookId);
       const chapterNumber = await this.state.getNextChapterNumber(bookId);
 
+      const { profile: gp } = await this.loadGenreProfile(book.genre);
+
       const writer = new WriterAgent(this.agentCtx(bookId));
       const output = await writer.writeChapter({
         book,
@@ -133,7 +143,7 @@ export class PipelineRunner {
       await writeFile(filePath, `# 第${chapterNumber}章 ${output.title}\n\n${output.content}`, "utf-8");
 
       // Save truth files
-      await writer.saveChapter(bookDir, output);
+      await writer.saveChapter(bookDir, output, gp.numericalSystem);
 
       // Update index
       const existingIndex = await this.state.loadChapterIndex(bookId);
@@ -160,6 +170,7 @@ export class PipelineRunner {
 
   /** Audit the latest (or specified) chapter. Read-only, no lock needed. */
   async auditDraft(bookId: string, chapterNumber?: number): Promise<AuditResult & { readonly chapterNumber: number }> {
+    const book = await this.state.loadBookConfig(bookId);
     const bookDir = this.state.bookDir(bookId);
     const targetChapter = chapterNumber ?? (await this.state.getNextChapterNumber(bookId)) - 1;
     if (targetChapter < 1) {
@@ -168,7 +179,7 @@ export class PipelineRunner {
 
     const content = await this.readChapterContent(bookDir, targetChapter);
     const auditor = new ContinuityAuditor(this.agentCtx(bookId));
-    const result = await auditor.auditChapter(bookDir, content, targetChapter);
+    const result = await auditor.auditChapter(bookDir, content, targetChapter, book.genre);
 
     // Update index with audit result
     const index = await this.state.loadChapterIndex(bookId);
@@ -188,9 +199,10 @@ export class PipelineRunner {
   }
 
   /** Revise the latest (or specified) chapter based on audit issues. */
-  async reviseDraft(bookId: string, chapterNumber?: number): Promise<ReviseResult> {
+  async reviseDraft(bookId: string, chapterNumber?: number, mode: ReviseMode = "rewrite"): Promise<ReviseResult> {
     const releaseLock = await this.state.acquireBookLock(bookId);
     try {
+      const book = await this.state.loadBookConfig(bookId);
       const bookDir = this.state.bookDir(bookId);
       const targetChapter = chapterNumber ?? (await this.state.getNextChapterNumber(bookId)) - 1;
       if (targetChapter < 1) {
@@ -207,14 +219,18 @@ export class PipelineRunner {
       // Re-audit to get structured issues (index only stores strings)
       const content = await this.readChapterContent(bookDir, targetChapter);
       const auditor = new ContinuityAuditor(this.agentCtx(bookId));
-      const auditResult = await auditor.auditChapter(bookDir, content, targetChapter);
+      const auditResult = await auditor.auditChapter(bookDir, content, targetChapter, book.genre);
 
       if (auditResult.passed) {
         return { chapterNumber: targetChapter, wordCount: content.length, fixedIssues: ["No issues to fix"] };
       }
 
+      const { profile: gp } = await this.loadGenreProfile(book.genre);
+
       const reviser = new ReviserAgent(this.agentCtx(bookId));
-      const reviseOutput = await reviser.reviseChapter(bookDir, content, targetChapter, auditResult.issues);
+      const reviseOutput = await reviser.reviseChapter(
+        bookDir, content, targetChapter, auditResult.issues, mode, book.genre,
+      );
 
       if (reviseOutput.revisedContent.length === 0) {
         throw new Error("Reviser returned empty content");
@@ -238,7 +254,7 @@ export class PipelineRunner {
       if (reviseOutput.updatedState !== "(状态卡未更新)") {
         await writeFile(join(storyDir, "current_state.md"), reviseOutput.updatedState, "utf-8");
       }
-      if (reviseOutput.updatedLedger !== "(账本未更新)") {
+      if (gp.numericalSystem && reviseOutput.updatedLedger && reviseOutput.updatedLedger !== "(账本未更新)") {
         await writeFile(join(storyDir, "particle_ledger.md"), reviseOutput.updatedLedger, "utf-8");
       }
       if (reviseOutput.updatedHooks !== "(伏笔池未更新)") {
@@ -283,17 +299,17 @@ export class PipelineRunner {
       }
     };
 
-    const [currentState, particleLedger, pendingHooks, storyBible, volumeOutline, styleGuide] =
+    const [currentState, particleLedger, pendingHooks, storyBible, volumeOutline, bookRules] =
       await Promise.all([
         readSafe(join(storyDir, "current_state.md")),
         readSafe(join(storyDir, "particle_ledger.md")),
         readSafe(join(storyDir, "pending_hooks.md")),
         readSafe(join(storyDir, "story_bible.md")),
         readSafe(join(storyDir, "volume_outline.md")),
-        readSafe(join(storyDir, "style_guide.md")),
+        readSafe(join(storyDir, "book_rules.md")),
       ]);
 
-    return { currentState, particleLedger, pendingHooks, storyBible, volumeOutline, styleGuide };
+    return { currentState, particleLedger, pendingHooks, storyBible, volumeOutline, bookRules };
   }
 
   /** Get book status overview. */
@@ -333,6 +349,7 @@ export class PipelineRunner {
     const book = await this.state.loadBookConfig(bookId);
     const bookDir = this.state.bookDir(bookId);
     const chapterNumber = await this.state.getNextChapterNumber(bookId);
+    const { profile: gp } = await this.loadGenreProfile(book.genre);
 
     // 1. Write chapter
     const writer = new WriterAgent(this.agentCtx(bookId));
@@ -349,6 +366,7 @@ export class PipelineRunner {
       bookDir,
       output.content,
       chapterNumber,
+      book.genre,
     );
 
     let finalContent = output.content;
@@ -367,6 +385,8 @@ export class PipelineRunner {
           output.content,
           chapterNumber,
           auditResult.issues,
+          "rewrite",
+          book.genre,
         );
 
         if (reviseOutput.revisedContent.length > 0) {
@@ -379,6 +399,7 @@ export class PipelineRunner {
             bookDir,
             finalContent,
             chapterNumber,
+            book.genre,
           );
 
           // Update state files from revision
@@ -386,7 +407,7 @@ export class PipelineRunner {
           if (reviseOutput.updatedState !== "(状态卡未更新)") {
             await writeFile(join(storyDir, "current_state.md"), reviseOutput.updatedState, "utf-8");
           }
-          if (reviseOutput.updatedLedger !== "(账本未更新)") {
+          if (gp.numericalSystem && reviseOutput.updatedLedger && reviseOutput.updatedLedger !== "(账本未更新)") {
             await writeFile(join(storyDir, "particle_ledger.md"), reviseOutput.updatedLedger, "utf-8");
           }
           if (reviseOutput.updatedHooks !== "(伏笔池未更新)") {
@@ -410,7 +431,7 @@ export class PipelineRunner {
 
     // Save original state files if not revised
     if (!revised) {
-      await writer.saveChapter(bookDir, output);
+      await writer.saveChapter(bookDir, output, gp.numericalSystem);
     }
 
     // 5. Update chapter index