Parcourir la source

feat(planner): emit structured writing directives

Ma il y a 7 mois
Parent
commit
6be56cf08c

+ 12 - 0
packages/core/src/__tests__/models.test.ts

@@ -495,6 +495,10 @@ describe("ChapterIntentSchema", () => {
       chapter: 12,
       goal: "Pull focus back to the mentor conflict",
       outlineNode: "Volume 2 / Chapter 12",
+      sceneDirective: "Break the repeated investigation-room rhythm with a location change.",
+      arcDirective: "Advance toward the next concrete arc beat instead of replaying the fallback setup.",
+      moodDirective: "Release pressure for one chapter before the next escalation.",
+      titleDirective: "Avoid another ledger title and use a new concrete image.",
       mustKeep: ["Protagonist remains injured"],
       mustAvoid: ["Do not reveal the mastermind"],
       styleEmphasis: ["dialogue tension", "character conflict"],
@@ -517,6 +521,10 @@ describe("ChapterIntentSchema", () => {
 
     expect(result.chapter).toBe(12);
     expect(result.goal).toContain("mentor conflict");
+    expect(result.sceneDirective).toContain("location change");
+    expect(result.arcDirective).toContain("arc beat");
+    expect(result.moodDirective).toContain("Release pressure");
+    expect(result.titleDirective).toContain("ledger title");
     expect(result.conflicts).toHaveLength(1);
     expect(result.hookAgenda.mustAdvance).toEqual(["H019"]);
     expect(result.hookAgenda.eligibleResolve).toEqual(["H045"]);
@@ -533,6 +541,10 @@ describe("ChapterIntentSchema", () => {
       goal: "Establish the protagonist's first setback",
     });
 
+    expect(result.sceneDirective).toBeUndefined();
+    expect(result.arcDirective).toBeUndefined();
+    expect(result.moodDirective).toBeUndefined();
+    expect(result.titleDirective).toBeUndefined();
     expect(result.mustKeep).toEqual([]);
     expect(result.mustAvoid).toEqual([]);
     expect(result.styleEmphasis).toEqual([]);

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

@@ -253,6 +253,77 @@ describe("PlannerAgent", () => {
     ]));
   });
 
+  it("emits structured directives when fallback planning, chapter type repetition, and title collapse stack up", async () => {
+    book = {
+      ...book,
+      genre: "other",
+      language: "en",
+    };
+
+    await Promise.all([
+      writeFile(
+        join(storyDir, "author_intent.md"),
+        "# Author Intent\n\n(Describe the long-horizon vision for this book here.)\n",
+        "utf-8",
+      ),
+      writeFile(
+        join(storyDir, "current_focus.md"),
+        [
+          "# Current Focus",
+          "",
+          "## Active Focus",
+          "",
+          "(Describe what the next 1-3 chapters should prioritize.)",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+      writeFile(
+        join(storyDir, "volume_outline.md"),
+        [
+          "# Volume Outline",
+          "",
+          "## Chapter 8",
+          "Expose the registry clerk's hidden ledger in the floodgate archive.",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+      writeFile(
+        join(storyDir, "chapter_summaries.md"),
+        [
+          "# Chapter Summaries",
+          "",
+          "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
+          "| --- | --- | --- | --- | --- | --- | --- | --- |",
+          "| 1 | Ledger in Rain | Taryn | Taryn checks the first false folio | None | hook advanced | tight | investigation |",
+          "| 2 | Ledger at Dusk | Taryn | Taryn questions the dock clerk | None | hook advanced | tight | investigation |",
+          "| 3 | Ledger Below | Taryn | Taryn searches the under-archive | None | hook advanced | tight | investigation |",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+    ]);
+
+    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: 4,
+    });
+
+    expect(result.intent.arcDirective).toContain("fallback");
+    expect(result.intent.sceneDirective).toContain("investigation");
+    expect(result.intent.titleDirective?.toLowerCase()).toContain("ledger");
+    expect(result.intent.moodDirective).toBeUndefined();
+  });
+
   it("ignores the default current_focus placeholder and falls back to author intent when no chapter outline is available", async () => {
     await Promise.all([
       writeFile(

+ 130 - 1
packages/core/src/agents/planner.ts

@@ -6,6 +6,7 @@ import { parseBookRules } from "../models/book-rules.js";
 import { ChapterIntentSchema, type ChapterConflict, type ChapterIntent } from "../models/input-governance.js";
 import {
   buildPlannerHookAgenda,
+  parseChapterSummariesMarkdown,
   renderHookSnapshot,
   renderSummarySnapshot,
   retrieveMemorySelection,
@@ -40,6 +41,7 @@ export class PlannerAgent extends BaseAgent {
       currentFocus: join(storyDir, "current_focus.md"),
       storyBible: join(storyDir, "story_bible.md"),
       volumeOutline: join(storyDir, "volume_outline.md"),
+      chapterSummaries: join(storyDir, "chapter_summaries.md"),
       bookRules: join(storyDir, "book_rules.md"),
       currentState: join(storyDir, "current_state.md"),
     } as const;
@@ -49,6 +51,7 @@ export class PlannerAgent extends BaseAgent {
       currentFocus,
       storyBible,
       volumeOutline,
+      chapterSummaries,
       bookRulesRaw,
       currentState,
     ] = await Promise.all([
@@ -56,11 +59,13 @@ export class PlannerAgent extends BaseAgent {
       this.readFileOrDefault(sourcePaths.currentFocus),
       this.readFileOrDefault(sourcePaths.storyBible),
       this.readFileOrDefault(sourcePaths.volumeOutline),
+      this.readFileOrDefault(sourcePaths.chapterSummaries),
       this.readFileOrDefault(sourcePaths.bookRules),
       this.readFileOrDefault(sourcePaths.currentState),
     ]);
 
     const outlineNode = this.findOutlineNode(volumeOutline, input.chapterNumber);
+    const matchedOutlineAnchor = this.hasMatchedOutlineAnchor(volumeOutline, input.chapterNumber);
     const goal = this.deriveGoal(input.externalContext, currentFocus, authorIntent, outlineNode, input.chapterNumber);
     const parsedRules = parseBookRules(bookRulesRaw);
     const mustKeep = this.collectMustKeep(currentState, storyBible);
@@ -79,11 +84,20 @@ export class PlannerAgent extends BaseAgent {
       hooks: memorySelection.activeHooks,
       chapterNumber: input.chapterNumber,
     });
+    const directives = this.buildStructuredDirectives({
+      chapterNumber: input.chapterNumber,
+      language: input.book.language,
+      volumeOutline,
+      outlineNode,
+      matchedOutlineAnchor,
+      chapterSummaries,
+    });
 
     const intent = ChapterIntentSchema.parse({
       chapter: input.chapterNumber,
       goal,
       outlineNode,
+      ...directives,
       mustKeep,
       mustAvoid,
       styleEmphasis,
@@ -105,13 +119,38 @@ export class PlannerAgent extends BaseAgent {
       plannerInputs: [
         ...Object.values(sourcePaths),
         join(storyDir, "pending_hooks.md"),
-        join(storyDir, "chapter_summaries.md"),
         ...(memorySelection.dbPath ? [memorySelection.dbPath] : []),
       ],
       runtimePath,
     };
   }
 
+  private buildStructuredDirectives(input: {
+    readonly chapterNumber: number;
+    readonly language?: string;
+    readonly volumeOutline: string;
+    readonly outlineNode: string | undefined;
+    readonly matchedOutlineAnchor: boolean;
+    readonly chapterSummaries: string;
+  }): Pick<ChapterIntent, "sceneDirective" | "arcDirective" | "moodDirective" | "titleDirective"> {
+    const recentSummaries = parseChapterSummariesMarkdown(input.chapterSummaries)
+      .filter((summary) => summary.chapter < input.chapterNumber)
+      .sort((left, right) => right.chapter - left.chapter)
+      .slice(0, 4);
+
+    return {
+      arcDirective: this.buildArcDirective(
+        input.language,
+        input.volumeOutline,
+        input.outlineNode,
+        input.matchedOutlineAnchor,
+      ),
+      sceneDirective: this.buildSceneDirective(input.language, recentSummaries),
+      moodDirective: undefined,
+      titleDirective: this.buildTitleDirective(input.language, recentSummaries),
+    };
+  }
+
   private deriveGoal(
     externalContext: string | undefined,
     currentFocus: string,
@@ -275,6 +314,75 @@ export class PlannerAgent extends BaseAgent {
     return this.extractListItems(focusSection, limit);
   }
 
+  private buildArcDirective(
+    language: string | undefined,
+    volumeOutline: string,
+    outlineNode: string | undefined,
+    matchedOutlineAnchor: boolean,
+  ): string | undefined {
+    if (matchedOutlineAnchor || !outlineNode || volumeOutline === "(文件尚未创建)") {
+      return undefined;
+    }
+
+    return this.isChineseLanguage(language)
+      ? "不要继续依赖卷纲的 fallback 指令,必须把本章推进到新的弧线节点或地点变化。"
+      : "Do not keep leaning on the outline fallback. Force this chapter toward a fresh arc beat or location change.";
+  }
+
+  private buildSceneDirective(
+    language: string | undefined,
+    recentSummaries: ReadonlyArray<{ chapterType: string }>,
+  ): string | undefined {
+    const repeatedType = this.findRepeatedValue(
+      recentSummaries.map((summary) => summary.chapterType),
+      3,
+    );
+    if (!repeatedType) {
+      return undefined;
+    }
+
+    return this.isChineseLanguage(language)
+      ? `最近章节连续停留在“${repeatedType}”,本章必须更换场景容器、地点或行动方式。`
+      : `Recent chapters are stuck in repeated ${repeatedType} beats. Change the scene container, location, or action pattern this chapter.`;
+  }
+
+  private buildTitleDirective(
+    language: string | undefined,
+    recentSummaries: ReadonlyArray<{ title: string }>,
+  ): string | undefined {
+    const tokenCounts = new Map<string, number>();
+
+    for (const summary of recentSummaries) {
+      for (const token of this.extractKeywords(summary.title)) {
+        tokenCounts.set(token, (tokenCounts.get(token) ?? 0) + 1);
+      }
+    }
+
+    const repeatedToken = [...tokenCounts.entries()]
+      .sort((left, right) => right[1] - left[1])
+      .find((entry) => entry[1] >= 3)?.[0];
+    if (!repeatedToken) {
+      return undefined;
+    }
+
+    return this.isChineseLanguage(language)
+      ? `标题不要再围绕“${repeatedToken}”重复命名,换一个新的意象或动作焦点。`
+      : `Avoid another ${repeatedToken}-centric title. Pick a new image or action focus for this chapter title.`;
+  }
+
+  private findRepeatedValue(values: ReadonlyArray<string>, threshold: number): string | undefined {
+    const counts = new Map<string, number>();
+
+    for (const value of values.map((value) => value.trim()).filter(Boolean)) {
+      counts.set(value, (counts.get(value) ?? 0) + 1);
+      if ((counts.get(value) ?? 0) >= threshold) {
+        return value;
+      }
+    }
+
+    return undefined;
+  }
+
   private extractSection(content: string, headings: ReadonlyArray<string>): string | undefined {
     const targets = headings.map((heading) => this.normalizeHeading(heading));
     const lines = content.split("\n");
@@ -434,6 +542,14 @@ export class PlannerAgent extends BaseAgent {
     return undefined;
   }
 
+  private hasMatchedOutlineAnchor(volumeOutline: string, chapterNumber: number): boolean {
+    const lines = volumeOutline.split("\n").map((line) => line.trim()).filter(Boolean);
+    return lines.some((line) =>
+      this.matchExactOutlineLine(line, chapterNumber) !== undefined
+      || this.matchRangeOutlineLine(line, chapterNumber) !== undefined,
+    );
+  }
+
   private matchExactOutlineLine(line: string, chapterNumber: number): RegExpMatchArray | undefined {
     const patterns = [
       new RegExp(`^(?:#+\\s*)?(?:[-*]\\s+)?(?:\\*\\*)?Chapter\\s*${chapterNumber}(?!\\d|\\s*[-~–—]\\s*\\d)(?:[::-])?(?:\\*\\*)?\\s*(.*)$`, "i"),
@@ -524,6 +640,12 @@ export class PlannerAgent extends BaseAgent {
     const styleEmphasis = intent.styleEmphasis.length > 0
       ? intent.styleEmphasis.map((item) => `- ${item}`).join("\n")
       : "- none";
+    const directives = [
+      intent.arcDirective ? `- arc: ${intent.arcDirective}` : undefined,
+      intent.sceneDirective ? `- scene: ${intent.sceneDirective}` : undefined,
+      intent.moodDirective ? `- mood: ${intent.moodDirective}` : undefined,
+      intent.titleDirective ? `- title: ${intent.titleDirective}` : undefined,
+    ].filter(Boolean).join("\n") || "- none";
     const hookAgenda = [
       "### Must Advance",
       intent.hookAgenda.mustAdvance.length > 0
@@ -564,6 +686,9 @@ export class PlannerAgent extends BaseAgent {
       "## Style Emphasis",
       styleEmphasis,
       "",
+      "## Structured Directives",
+      directives,
+      "",
       "## Hook Agenda",
       hookAgenda,
       "",
@@ -583,6 +708,10 @@ export class PlannerAgent extends BaseAgent {
     return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
   }
 
+  private isChineseLanguage(language: string | undefined): boolean {
+    return (language ?? "zh").toLowerCase().startsWith("zh");
+  }
+
   private async readFileOrDefault(path: string): Promise<string> {
     try {
       return await readFile(path, "utf-8");

+ 4 - 0
packages/core/src/models/input-governance.ts

@@ -21,6 +21,10 @@ export const ChapterIntentSchema = z.object({
   chapter: z.number().int().min(1),
   goal: z.string().min(1),
   outlineNode: z.string().optional(),
+  sceneDirective: z.string().min(1).optional(),
+  arcDirective: z.string().min(1).optional(),
+  moodDirective: z.string().min(1).optional(),
+  titleDirective: z.string().min(1).optional(),
   mustKeep: z.array(z.string()).default([]),
   mustAvoid: z.array(z.string()).default([]),
   styleEmphasis: z.array(z.string()).default([]),