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

fix(planner): support chapter range outline anchors

Ma 7 месяцев назад
Родитель
Сommit
c442a9a805
2 измененных файлов с 235 добавлено и 8 удалено
  1. 165 0
      packages/core/src/__tests__/planner.test.ts
  2. 70 8
      packages/core/src/agents/planner.ts

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

@@ -242,6 +242,171 @@ describe("PlannerAgent", () => {
     expect(result.intent.goal).not.toBe("**");
   });
 
+  it("uses Chinese chapter-range outline nodes when the chapter falls inside the range", async () => {
+    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",
+          "",
+          "## 第1-6章",
+          "Stay with the early city setup and mentor fallout.",
+          "",
+          "## 第7-20章",
+          "Track the merchant guild's escape route through the western canal.",
+          "",
+        ].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: 7,
+    });
+
+    expect(result.intent.outlineNode).toContain("merchant guild's escape route");
+    expect(result.intent.goal).toContain("merchant guild's escape route");
+    expect(result.intent.goal).not.toContain("Describe the long-horizon vision");
+  });
+
+  it("uses English chapter-range outline nodes when the chapter falls inside the range", 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 1-3",
+          "Keep the opening pressure on the first examiner.",
+          "",
+          "### Chapter 4-6",
+          "Recover the sealed ledger before dawn.",
+          "",
+        ].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: 5,
+    });
+
+    expect(result.intent.outlineNode).toContain("sealed ledger");
+    expect(result.intent.goal).toContain("sealed ledger");
+    expect(result.intent.goal).not.toContain("Describe the long-horizon vision");
+  });
+
+  it("falls back to the first outline directive when no range matches", async () => {
+    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",
+          "",
+          "## 第1-6章",
+          "Stay with the early city setup and mentor fallout.",
+          "",
+          "## 第7-20章",
+          "Track the merchant guild's escape route through the western canal.",
+          "",
+        ].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: 25,
+    });
+
+    expect(result.intent.outlineNode).toContain("Stay with the early city setup");
+    expect(result.intent.goal).toContain("Stay with the early city setup");
+    expect(result.intent.goal).not.toContain("merchant guild's escape route");
+  });
+
   it("preserves hard facts from state and canon in mustKeep", async () => {
     const planner = new PlannerAgent({
       client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],

+ 70 - 8
packages/core/src/agents/planner.ts

@@ -299,18 +299,18 @@ export class PlannerAgent extends BaseAgent {
 
   private findOutlineNode(volumeOutline: string, chapterNumber: number): string | undefined {
     const lines = volumeOutline.split("\n").map((line) => line.trim()).filter(Boolean);
-    const chapterPatterns = [
+    const exactHeadingPatterns = [
       new RegExp(`^#+\\s*Chapter\\s*${chapterNumber}\\b`, "i"),
       new RegExp(`^#+\\s*第\\s*${chapterNumber}\\s*章`),
     ];
-    const inlinePatterns = [
+    const exactInlinePatterns = [
       new RegExp(`^(?:[-*]\\s+)?(?:\\*\\*)?Chapter\\s*${chapterNumber}(?:[::-])?(?:\\*\\*)?\\s*(.+)$`, "i"),
       new RegExp(`^(?:[-*]\\s+)?(?:\\*\\*)?第\\s*${chapterNumber}\\s*章(?:[::-])?(?:\\*\\*)?\\s*(.+)$`),
     ];
 
     for (let index = 0; index < lines.length; index += 1) {
       const line = lines[index]!;
-      const match = inlinePatterns
+      const match = exactInlinePatterns
         .map((pattern) => line.match(pattern))
         .find((result): result is RegExpMatchArray => Boolean(result));
       if (!match) continue;
@@ -326,12 +326,37 @@ export class PlannerAgent extends BaseAgent {
       }
     }
 
-    const heading = lines.find((line) => chapterPatterns.some((pattern) => pattern.test(line)));
-    if (!heading) return this.extractFirstDirective(volumeOutline);
+    const exactHeading = lines.find((line) => exactHeadingPatterns.some((pattern) => pattern.test(line)));
+    if (exactHeading) {
+      const headingIndex = lines.indexOf(exactHeading);
+      const nextLine = lines[headingIndex + 1];
+      return nextLine && !nextLine.startsWith("#") ? nextLine : exactHeading.replace(/^#+\s*/, "");
+    }
+
+    for (let index = 0; index < lines.length; index += 1) {
+      const line = lines[index]!;
+      const match = this.matchRangeOutlineLine(line, chapterNumber);
+      if (!match) continue;
+
+      const inlineContent = this.cleanOutlineContent(match[3]);
+      if (inlineContent) {
+        return inlineContent;
+      }
+
+      const nextContent = this.findNextOutlineContent(lines, index + 1);
+      if (nextContent) {
+        return nextContent;
+      }
+    }
 
-    const headingIndex = lines.indexOf(heading);
-    const nextLine = lines[headingIndex + 1];
-    return nextLine && !nextLine.startsWith("#") ? nextLine : heading.replace(/^#+\s*/, "");
+    const rangeHeading = lines.find((line) => this.matchRangeOutlineHeading(line, chapterNumber));
+    if (rangeHeading) {
+      const headingIndex = lines.indexOf(rangeHeading);
+      const nextLine = lines[headingIndex + 1];
+      return nextLine && !nextLine.startsWith("#") ? nextLine : rangeHeading.replace(/^#+\s*/, "");
+    }
+
+    return this.extractFirstDirective(volumeOutline);
   }
 
   private cleanOutlineContent(content?: string): string | undefined {
@@ -364,6 +389,43 @@ export class PlannerAgent extends BaseAgent {
     return undefined;
   }
 
+  private matchRangeOutlineLine(line: string, chapterNumber: number): RegExpMatchArray | undefined {
+    const patterns = [
+      /^(?:[-*]\s+)?(?:\*\*)?Chapter\s*(\d+)\s*[-~–—]\s*(\d+)(?:[::-])?(?:\*\*)?\s*(.+)$/i,
+      /^(?:[-*]\s+)?(?:\*\*)?第\s*(\d+)\s*[-~–—]\s*(\d+)\s*章(?:[::-])?(?:\*\*)?\s*(.+)$/i,
+    ];
+
+    for (const pattern of patterns) {
+      const match = line.match(pattern);
+      if (!match) continue;
+      if (this.isChapterWithinRange(match[1], match[2], chapterNumber)) {
+        return match;
+      }
+    }
+
+    return undefined;
+  }
+
+  private matchRangeOutlineHeading(line: string, chapterNumber: number): boolean {
+      return this.matchRangeOutlineLine(line, chapterNumber) !== undefined
+      || [
+        /^#+\s*Chapter\s*(\d+)\s*[-~–—]\s*(\d+)\b/i,
+        /^#+\s*第\s*(\d+)\s*[-~–—]\s*(\d+)\s*章/i,
+      ].some((pattern) => {
+        const match = line.match(pattern);
+        return match ? this.isChapterWithinRange(match[1], match[2], chapterNumber) : false;
+      });
+  }
+
+  private isChapterWithinRange(startText: string | undefined, endText: string | undefined, chapterNumber: number): boolean {
+    const start = Number.parseInt(startText ?? "", 10);
+    const end = Number.parseInt(endText ?? "", 10);
+    if (!Number.isFinite(start) || !Number.isFinite(end)) return false;
+    const lower = Math.min(start, end);
+    const upper = Math.max(start, end);
+    return chapterNumber >= lower && chapterNumber <= upper;
+  }
+
   private hasKeywordOverlap(left: string, right: string): boolean {
     const keywords = this.extractKeywords(left);
     if (keywords.length === 0) return false;