Bladeren bron

fix(planner): harden exact and range outline matching

Ma 7 maanden geleden
bovenliggende
commit
2061c7ef30
2 gewijzigde bestanden met toevoegingen van 248 en 56 verwijderingen
  1. 177 10
      packages/core/src/__tests__/planner.test.ts
  2. 71 46
      packages/core/src/agents/planner.ts

+ 177 - 10
packages/core/src/__tests__/planner.test.ts

@@ -242,7 +242,61 @@ describe("PlannerAgent", () => {
     expect(result.intent.goal).not.toBe("**");
   });
 
-  it("uses Chinese chapter-range outline nodes when the chapter falls inside the range", async () => {
+  it("does not confuse Chapter 1 with Chapter 10 when matching exact English chapter labels", async () => {
+    book = {
+      ...book,
+      genre: "other",
+      language: "en",
+    };
+
+    await Promise.all([
+      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 10",
+          "This late-volume node should not be selected for chapter one.",
+          "",
+          "### Chapter 1",
+          "Open with the dead examiner and the sealed folio dispute.",
+          "",
+        ].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: 1,
+    });
+
+    expect(result.intent.outlineNode).toContain("dead examiner");
+    expect(result.intent.outlineNode).not.toContain("late-volume");
+    expect(result.intent.goal).toContain("dead examiner");
+  });
+
+  it("uses inline Chinese exact chapter labels with a title suffix", async () => {
     await Promise.all([
       writeFile(
         join(storyDir, "author_intent.md"),
@@ -266,10 +320,59 @@ describe("PlannerAgent", () => {
         [
           "# Volume Outline",
           "",
-          "## 第1-6章",
+          "第 7 章:在码头接头并截住逃跑账房。",
+          "",
+        ].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("在码头接头");
+    expect(result.intent.goal).toContain("在码头接头");
+    expect(result.intent.goal).not.toContain("Describe the long-horizon vision");
+  });
+
+  it("uses standalone Chinese chapter-range labels 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章",
+          "第7-20章",
           "Track the merchant guild's escape route through the western canal.",
           "",
         ].join("\n"),
@@ -295,7 +398,7 @@ describe("PlannerAgent", () => {
     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 () => {
+  it("uses standalone English chapter-range labels at the start of the range", async () => {
     book = {
       ...book,
       genre: "other",
@@ -325,10 +428,10 @@ describe("PlannerAgent", () => {
         [
           "# Volume Outline",
           "",
-          "### Chapter 1-3",
+          "Chapter 1-3",
           "Keep the opening pressure on the first examiner.",
           "",
-          "### Chapter 4-6",
+          "Chapter 4-6",
           "Recover the sealed ledger before dawn.",
           "",
         ].join("\n"),
@@ -346,15 +449,78 @@ describe("PlannerAgent", () => {
     const result = await planner.planChapter({
       book,
       bookDir,
-      chapterNumber: 5,
+      chapterNumber: 4,
     });
 
     expect(result.intent.outlineNode).toContain("sealed ledger");
     expect(result.intent.goal).toContain("sealed ledger");
+    expect(result.intent.outlineNode).not.toContain("6");
     expect(result.intent.goal).not.toContain("Describe the long-horizon vision");
   });
 
-  it("falls back to the first outline directive when no range matches", async () => {
+  it("uses the next paragraph for bold standalone English chapter-range labels instead of bleeding into the next 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.",
+          "",
+          "**Chapter 7-9:**",
+          "Trigger the registry fire and expose the false witness.",
+          "",
+        ].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.outlineNode).toContain("sealed ledger");
+    expect(result.intent.outlineNode).not.toContain("registry fire");
+    expect(result.intent.goal).toContain("sealed ledger");
+  });
+
+  it("falls back to the first outline directive when no standalone range matches", async () => {
     await Promise.all([
       writeFile(
         join(storyDir, "author_intent.md"),
@@ -378,10 +544,10 @@ describe("PlannerAgent", () => {
         [
           "# Volume Outline",
           "",
-          "## 第1-6章",
+          "第1-6章",
           "Stay with the early city setup and mentor fallout.",
           "",
-          "## 第7-20章",
+          "第7-20章",
           "Track the merchant guild's escape route through the western canal.",
           "",
         ].join("\n"),
@@ -404,6 +570,7 @@ describe("PlannerAgent", () => {
 
     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.outlineNode).not.toBe("第1-6章");
     expect(result.intent.goal).not.toContain("merchant guild's escape route");
   });
 

+ 71 - 46
packages/core/src/agents/planner.ts

@@ -299,20 +299,10 @@ 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 exactHeadingPatterns = [
-      new RegExp(`^#+\\s*Chapter\\s*${chapterNumber}\\b`, "i"),
-      new RegExp(`^#+\\s*第\\s*${chapterNumber}\\s*章`),
-    ];
-    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 = exactInlinePatterns
-        .map((pattern) => line.match(pattern))
-        .find((result): result is RegExpMatchArray => Boolean(result));
+      const match = this.matchExactOutlineLine(line, chapterNumber);
       if (!match) continue;
 
       const inlineContent = this.cleanOutlineContent(match[1]);
@@ -326,13 +316,6 @@ export class PlannerAgent extends BaseAgent {
       }
     }
 
-    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);
@@ -349,11 +332,32 @@ export class PlannerAgent extends BaseAgent {
       }
     }
 
-    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*/, "");
+    for (let index = 0; index < lines.length; index += 1) {
+      const line = lines[index]!;
+      if (!this.isOutlineAnchorLine(line)) continue;
+
+      const exactMatch = this.matchAnyExactOutlineLine(line);
+      if (exactMatch) {
+        const inlineContent = this.cleanOutlineContent(exactMatch[1]);
+        if (inlineContent) {
+          return inlineContent;
+        }
+      }
+
+      const rangeMatch = this.matchAnyRangeOutlineLine(line);
+      if (rangeMatch) {
+        const inlineContent = this.cleanOutlineContent(rangeMatch[3]);
+        if (inlineContent) {
+          return inlineContent;
+        }
+      }
+
+      const nextContent = this.findNextOutlineContent(lines, index + 1);
+      if (nextContent) {
+        return nextContent;
+      }
+
+      break;
     }
 
     return this.extractFirstDirective(volumeOutline);
@@ -369,17 +373,18 @@ export class PlannerAgent extends BaseAgent {
   private findNextOutlineContent(lines: ReadonlyArray<string>, startIndex: number): string | undefined {
     for (let index = startIndex; index < lines.length; index += 1) {
       const line = lines[index]!;
-      if (!line || line.startsWith("#")) {
+      if (!line) {
         continue;
       }
 
-      if (
-        /^(?:[-*]\s+)?(?:\*\*)?Chapter\s*\d+(?:[::-])?(?:\*\*)?\s*$/i.test(line)
-        || /^(?:[-*]\s+)?(?:\*\*)?第\s*\d+\s*章(?:[::-])?(?:\*\*)?\s*$/.test(line)
-      ) {
+      if (this.isOutlineAnchorLine(line)) {
         return undefined;
       }
 
+      if (line.startsWith("#")) {
+        continue;
+      }
+
       const cleaned = this.cleanOutlineContent(line);
       if (cleaned) {
         return cleaned;
@@ -389,32 +394,52 @@ export class PlannerAgent extends BaseAgent {
     return undefined;
   }
 
-  private matchRangeOutlineLine(line: string, chapterNumber: number): RegExpMatchArray | undefined {
+  private matchExactOutlineLine(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,
+      new RegExp(`^(?:#+\\s*)?(?:[-*]\\s+)?(?:\\*\\*)?Chapter\\s*${chapterNumber}(?!\\d|\\s*[-~–—]\\s*\\d)(?:[::-])?(?:\\*\\*)?\\s*(.*)$`, "i"),
+      new RegExp(`^(?:#+\\s*)?(?:[-*]\\s+)?(?:\\*\\*)?第\\s*${chapterNumber}\\s*章(?!\\d|\\s*[-~–—]\\s*\\d)(?:[::-])?(?:\\*\\*)?\\s*(.*)$`),
     ];
 
-    for (const pattern of patterns) {
-      const match = line.match(pattern);
-      if (!match) continue;
-      if (this.isChapterWithinRange(match[1], match[2], chapterNumber)) {
-        return match;
-      }
+    return patterns
+      .map((pattern) => line.match(pattern))
+      .find((result): result is RegExpMatchArray => Boolean(result));
+  }
+
+  private matchAnyExactOutlineLine(line: string): RegExpMatchArray | undefined {
+    const patterns = [
+      /^(?:#+\s*)?(?:[-*]\s+)?(?:\*\*)?Chapter\s*\d+(?!\s*[-~–—]\s*\d)(?:[::-])?(?:\*\*)?\s*(.*)$/i,
+      /^(?:#+\s*)?(?:[-*]\s+)?(?:\*\*)?第\s*\d+\s*章(?!\s*[-~–—]\s*\d)(?:[::-])?(?:\*\*)?\s*(.*)$/i,
+    ];
+
+    return patterns
+      .map((pattern) => line.match(pattern))
+      .find((result): result is RegExpMatchArray => Boolean(result));
+  }
+
+  private matchRangeOutlineLine(line: string, chapterNumber: number): RegExpMatchArray | undefined {
+    const match = this.matchAnyRangeOutlineLine(line);
+    if (!match) return undefined;
+    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 matchAnyRangeOutlineLine(line: string): RegExpMatchArray | undefined {
+    const patterns = [
+      /^(?:#+\s*)?(?:[-*]\s+)?(?:\*\*)?Chapter\s*(\d+)\s*[-~–—]\s*(\d+)\b(?:[::-])?(?:\*\*)?\s*(.*)$/i,
+      /^(?:#+\s*)?(?:[-*]\s+)?(?:\*\*)?第\s*(\d+)\s*[-~–—]\s*(\d+)\s*章(?:[::-])?(?:\*\*)?\s*(.*)$/i,
+    ];
+
+    return patterns
+      .map((pattern) => line.match(pattern))
+      .find((result): result is RegExpMatchArray => Boolean(result));
+  }
+
+  private isOutlineAnchorLine(line: string): boolean {
+    return this.matchAnyExactOutlineLine(line) !== undefined
+      || this.matchAnyRangeOutlineLine(line) !== undefined;
   }
 
   private isChapterWithinRange(startText: string | undefined, endText: string | undefined, chapterNumber: number): boolean {