Browse Source

feat: add chapter planner

Ma 7 months ago
parent
commit
c5f0cb1a24
2 changed files with 396 additions and 0 deletions
  1. 135 0
      packages/core/src/__tests__/planner.test.ts
  2. 261 0
      packages/core/src/agents/planner.ts

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

@@ -0,0 +1,135 @@
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import type { BookConfig } from "../models/book.js";
+import { PlannerAgent } from "../agents/planner.js";
+
+describe("PlannerAgent", () => {
+  let root: string;
+  let bookDir: string;
+  let storyDir: string;
+  let book: BookConfig;
+
+  beforeEach(async () => {
+    root = await mkdtemp(join(tmpdir(), "inkos-planner-test-"));
+    bookDir = join(root, "books", "planner-book");
+    storyDir = join(bookDir, "story");
+    await mkdir(join(storyDir, "runtime"), { recursive: true });
+
+    book = {
+      id: "planner-book",
+      title: "Planner Book",
+      platform: "tomato",
+      genre: "xuanhuan",
+      status: "active",
+      targetChapters: 20,
+      chapterWordCount: 3000,
+      createdAt: "2026-03-22T00:00:00.000Z",
+      updatedAt: "2026-03-22T00:00:00.000Z",
+    };
+
+    await Promise.all([
+      writeFile(
+        join(storyDir, "author_intent.md"),
+        "# Author Intent\n\nKeep the book emotionally centered on the mentor-student bond.\n",
+        "utf-8",
+      ),
+      writeFile(
+        join(storyDir, "current_focus.md"),
+        "# Current Focus\n\nBring the focus back to the mentor conflict before opening new subplots.\n",
+        "utf-8",
+      ),
+      writeFile(
+        join(storyDir, "story_bible.md"),
+        "# Story Bible\n\n- The jade seal cannot be destroyed.\n",
+        "utf-8",
+      ),
+      writeFile(
+        join(storyDir, "volume_outline.md"),
+        "# Volume Outline\n\n## Chapter 3\nTrack the merchant guild's escape route.\n",
+        "utf-8",
+      ),
+      writeFile(
+        join(storyDir, "book_rules.md"),
+        "---\nprohibitions:\n  - Do not reveal the mastermind\n---\n\n# Book Rules\n",
+        "utf-8",
+      ),
+      writeFile(
+        join(storyDir, "current_state.md"),
+        "# Current State\n\n- Lin Yue still hides the broken oath token.\n",
+        "utf-8",
+      ),
+      writeFile(
+        join(storyDir, "pending_hooks.md"),
+        "# Pending Hooks\n\n- Why the mentor vanished after the trial.\n",
+        "utf-8",
+      ),
+      writeFile(
+        join(storyDir, "chapter_summaries.md"),
+        "# Chapter Summaries\n\n| 2 | Trial fallout | Mentor left without explanation |\n",
+        "utf-8",
+      ),
+    ]);
+  });
+
+  afterEach(async () => {
+    await rm(root, { recursive: true, force: true });
+  });
+
+  it("uses current focus as the chapter goal and writes a chapter intent file", async () => {
+    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: 3,
+    });
+
+    expect(result.intent.goal).toContain("mentor conflict");
+    await expect(readFile(result.runtimePath, "utf-8")).resolves.toContain("mentor conflict");
+  });
+
+  it("preserves hard facts from state and canon in mustKeep", async () => {
+    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: 3,
+    });
+
+    expect(result.intent.mustKeep).toContain("Lin Yue still hides the broken oath token.");
+    expect(result.intent.mustKeep).toContain("The jade seal cannot be destroyed.");
+  });
+
+  it("records conflicts when the external request diverges from the outline", async () => {
+    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: 3,
+      externalContext: "Ignore the guild chase and bring the focus back to mentor conflict.",
+    });
+
+    expect(result.intent.conflicts).toHaveLength(1);
+    expect(result.intent.conflicts[0]?.type).toBe("outline_vs_request");
+    await expect(readFile(result.runtimePath, "utf-8")).resolves.toContain("outline_vs_request");
+  });
+});

+ 261 - 0
packages/core/src/agents/planner.ts

@@ -0,0 +1,261 @@
+import { readFile, writeFile, mkdir } from "node:fs/promises";
+import { join } from "node:path";
+import { BaseAgent } from "./base.js";
+import type { BookConfig } from "../models/book.js";
+import { parseBookRules } from "../models/book-rules.js";
+import { ChapterIntentSchema, type ChapterConflict, type ChapterIntent } from "../models/input-governance.js";
+
+export interface PlanChapterInput {
+  readonly book: BookConfig;
+  readonly bookDir: string;
+  readonly chapterNumber: number;
+  readonly externalContext?: string;
+}
+
+export interface PlanChapterOutput {
+  readonly intent: ChapterIntent;
+  readonly intentMarkdown: string;
+  readonly plannerInputs: ReadonlyArray<string>;
+  readonly runtimePath: string;
+}
+
+export class PlannerAgent extends BaseAgent {
+  get name(): string {
+    return "planner";
+  }
+
+  async planChapter(input: PlanChapterInput): Promise<PlanChapterOutput> {
+    const storyDir = join(input.bookDir, "story");
+    const runtimeDir = join(storyDir, "runtime");
+    await mkdir(runtimeDir, { recursive: true });
+
+    const sourcePaths = {
+      authorIntent: join(storyDir, "author_intent.md"),
+      currentFocus: join(storyDir, "current_focus.md"),
+      storyBible: join(storyDir, "story_bible.md"),
+      volumeOutline: join(storyDir, "volume_outline.md"),
+      bookRules: join(storyDir, "book_rules.md"),
+      currentState: join(storyDir, "current_state.md"),
+      pendingHooks: join(storyDir, "pending_hooks.md"),
+      chapterSummaries: join(storyDir, "chapter_summaries.md"),
+    } as const;
+
+    const [
+      authorIntent,
+      currentFocus,
+      storyBible,
+      volumeOutline,
+      bookRulesRaw,
+      currentState,
+      pendingHooks,
+      chapterSummaries,
+    ] = await Promise.all([
+      this.readFileOrDefault(sourcePaths.authorIntent),
+      this.readFileOrDefault(sourcePaths.currentFocus),
+      this.readFileOrDefault(sourcePaths.storyBible),
+      this.readFileOrDefault(sourcePaths.volumeOutline),
+      this.readFileOrDefault(sourcePaths.bookRules),
+      this.readFileOrDefault(sourcePaths.currentState),
+      this.readFileOrDefault(sourcePaths.pendingHooks),
+      this.readFileOrDefault(sourcePaths.chapterSummaries),
+    ]);
+
+    const goal = this.deriveGoal(input.externalContext, currentFocus, authorIntent, input.chapterNumber);
+    const outlineNode = this.findOutlineNode(volumeOutline, input.chapterNumber);
+    const parsedRules = parseBookRules(bookRulesRaw);
+    const mustKeep = this.collectMustKeep(currentState, storyBible);
+    const mustAvoid = this.collectMustAvoid(currentFocus, parsedRules.rules.prohibitions);
+    const styleEmphasis = this.collectStyleEmphasis(authorIntent, currentFocus);
+    const conflicts = this.collectConflicts(input.externalContext, outlineNode, volumeOutline);
+
+    const intent = ChapterIntentSchema.parse({
+      chapter: input.chapterNumber,
+      goal,
+      outlineNode,
+      mustKeep,
+      mustAvoid,
+      styleEmphasis,
+      conflicts,
+    });
+
+    const runtimePath = join(runtimeDir, `chapter-${String(input.chapterNumber).padStart(4, "0")}.intent.md`);
+    const intentMarkdown = this.renderIntentMarkdown(intent, pendingHooks, chapterSummaries);
+    await writeFile(runtimePath, intentMarkdown, "utf-8");
+
+    return {
+      intent,
+      intentMarkdown,
+      plannerInputs: Object.values(sourcePaths),
+      runtimePath,
+    };
+  }
+
+  private deriveGoal(
+    externalContext: string | undefined,
+    currentFocus: string,
+    authorIntent: string,
+    chapterNumber: number,
+  ): string {
+    const first = this.extractFirstDirective(externalContext);
+    if (first) return first;
+    const focus = this.extractFirstDirective(currentFocus);
+    if (focus) return focus;
+    const author = this.extractFirstDirective(authorIntent);
+    if (author) return author;
+    return `Advance chapter ${chapterNumber} with clear narrative focus.`;
+  }
+
+  private collectMustKeep(currentState: string, storyBible: string): string[] {
+    return this.unique([
+      ...this.extractListItems(currentState, 2),
+      ...this.extractListItems(storyBible, 2),
+    ]).slice(0, 4);
+  }
+
+  private collectMustAvoid(currentFocus: string, prohibitions: ReadonlyArray<string>): string[] {
+    const focusAvoids = currentFocus
+      .split("\n")
+      .map((line) => line.trim())
+      .filter((line) =>
+        line.startsWith("-") &&
+        /avoid|don't|do not|不要|别|禁止/i.test(line),
+      )
+      .map((line) => line.replace(/^-\s*/, ""));
+
+    return this.unique([...focusAvoids, ...prohibitions]).slice(0, 6);
+  }
+
+  private collectStyleEmphasis(authorIntent: string, currentFocus: string): string[] {
+    return this.unique([
+      ...this.extractListItems(currentFocus, 2),
+      ...this.extractListItems(authorIntent, 2),
+    ]).slice(0, 4);
+  }
+
+  private collectConflicts(
+    externalContext: string | undefined,
+    outlineNode: string | undefined,
+    volumeOutline: string,
+  ): ChapterConflict[] {
+    if (!externalContext) return [];
+    const outlineText = outlineNode ?? volumeOutline;
+    if (!outlineText || outlineText === "(文件尚未创建)") return [];
+    const indicatesOverride = /ignore|skip|defer|instead|不要|别|先别|暂停/i.test(externalContext);
+    if (!indicatesOverride && this.hasKeywordOverlap(externalContext, outlineText)) return [];
+
+    return [
+      {
+        type: "outline_vs_request",
+        resolution: "allow local outline deferral",
+      },
+    ];
+  }
+
+  private extractFirstDirective(content?: string): string | undefined {
+    if (!content) return undefined;
+    return content
+      .split("\n")
+      .map((line) => line.trim())
+      .find((line) => line.length > 0 && !line.startsWith("#") && !line.startsWith("-"));
+  }
+
+  private extractListItems(content: string, limit: number): string[] {
+    return content
+      .split("\n")
+      .map((line) => line.trim())
+      .filter((line) => line.startsWith("-"))
+      .map((line) => line.replace(/^-\s*/, ""))
+      .slice(0, limit);
+  }
+
+  private findOutlineNode(volumeOutline: string, chapterNumber: number): string | undefined {
+    const lines = volumeOutline.split("\n").map((line) => line.trim()).filter(Boolean);
+    const chapterPatterns = [
+      new RegExp(`^#+\\s*Chapter\\s*${chapterNumber}\\b`, "i"),
+      new RegExp(`^#+\\s*第\\s*${chapterNumber}\\s*章`),
+    ];
+
+    const heading = lines.find((line) => chapterPatterns.some((pattern) => pattern.test(line)));
+    if (!heading) return this.extractFirstDirective(volumeOutline);
+
+    const headingIndex = lines.indexOf(heading);
+    const nextLine = lines[headingIndex + 1];
+    return nextLine && !nextLine.startsWith("#") ? nextLine : heading.replace(/^#+\s*/, "");
+  }
+
+  private hasKeywordOverlap(left: string, right: string): boolean {
+    const keywords = this.extractKeywords(left);
+    if (keywords.length === 0) return false;
+    const normalizedRight = right.toLowerCase();
+    return keywords.some((keyword) => normalizedRight.includes(keyword.toLowerCase()));
+  }
+
+  private extractKeywords(content: string): string[] {
+    const english = content.match(/[a-z]{4,}/gi) ?? [];
+    const chinese = content.match(/[\u4e00-\u9fff]{2,4}/g) ?? [];
+    return this.unique([...english, ...chinese]);
+  }
+
+  private renderIntentMarkdown(
+    intent: ChapterIntent,
+    pendingHooks: string,
+    chapterSummaries: string,
+  ): string {
+    const conflictLines = intent.conflicts.length > 0
+      ? intent.conflicts.map((conflict) => `- ${conflict.type}: ${conflict.resolution}`).join("\n")
+      : "- none";
+
+    const mustKeep = intent.mustKeep.length > 0
+      ? intent.mustKeep.map((item) => `- ${item}`).join("\n")
+      : "- none";
+
+    const mustAvoid = intent.mustAvoid.length > 0
+      ? intent.mustAvoid.map((item) => `- ${item}`).join("\n")
+      : "- none";
+
+    const styleEmphasis = intent.styleEmphasis.length > 0
+      ? intent.styleEmphasis.map((item) => `- ${item}`).join("\n")
+      : "- none";
+
+    return [
+      "# Chapter Intent",
+      "",
+      "## Goal",
+      intent.goal,
+      "",
+      "## Outline Node",
+      intent.outlineNode ?? "(not found)",
+      "",
+      "## Must Keep",
+      mustKeep,
+      "",
+      "## Must Avoid",
+      mustAvoid,
+      "",
+      "## Style Emphasis",
+      styleEmphasis,
+      "",
+      "## Conflicts",
+      conflictLines,
+      "",
+      "## Pending Hooks Snapshot",
+      pendingHooks,
+      "",
+      "## Chapter Summaries Snapshot",
+      chapterSummaries,
+      "",
+    ].join("\n");
+  }
+
+  private unique(values: ReadonlyArray<string>): string[] {
+    return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
+  }
+
+  private async readFileOrDefault(path: string): Promise<string> {
+    try {
+      return await readFile(path, "utf-8");
+    } catch {
+      return "(文件尚未创建)";
+    }
+  }
+}