Răsfoiți Sursa

fix(planner): distribute numbered beats within chapter range sections

When findOutlineNode matches a multi-line "章节范围" format section
(e.g., ch13-17 in 卷七), it now extracts the specific numbered beat
for the current chapter:

  ch13 → beat 0 ("抵达新地点,发现物证")
  ch14 → beat 1 ("与对手首次正面交锋")
  ch15 → beat 2 ("盟友背叛,局势逆转")
  etc.

Previously ch13-17 all got the same first beat. Now each chapter
gets its own beat from the section's numbered turning points.

Added regression test verifying that ch4/5/6/8 in a 4-8 range each
get their respective beats.

All 520 tests pass.
Ma 7 luni în urmă
părinte
comite
677373b7eb

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

@@ -1409,4 +1409,74 @@ describe("PlannerAgent", () => {
     expect(intentMarkdown).toContain("12 active hooks");
     expect(intentMarkdown).toContain("12 active hooks");
     expect(intentMarkdown).not.toContain("8 active hooks");
     expect(intentMarkdown).not.toContain("8 active hooks");
   });
   });
+
+  it("matches multi-line 章节范围 format and distributes beats across chapters", async () => {
+    await writeFile(
+      join(storyDir, "volume_outline.md"),
+      [
+        "## 已有章节部分",
+        "",
+        "### 卷一:开端",
+        "- **章节范围**:1-3章",
+        "- **核心冲突**:初始设定",
+        "- **关键转折**:",
+        "  1. 主角登场并发现异常",
+        "  2. 遭遇第一次危机",
+        "  3. 获得关键线索",
+        "",
+        "## 后续预测部分",
+        "",
+        "### 卷二:深入",
+        "- **章节范围**:4-8章",
+        "- **核心冲突**:深入调查",
+        "- **关键转折**:",
+        "  1. 抵达新地点,发现物证",
+        "  2. 与对手首次正面交锋",
+        "  3. 盟友背叛,局势逆转",
+        "  4. 主角被迫转入地下",
+        "  5. 发现幕后真相的第一条线",
+        "",
+      ].join("\n"),
+      "utf-8",
+    );
+
+    const planner = new PlannerAgent({
+      client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
+      model: "test-model",
+      projectRoot: root,
+      bookId: book.id,
+    });
+
+    // Chapter 4 should get beat 0 (first beat of 卷二): "抵达新地点"
+    const result4 = await planner.planChapter({
+      book,
+      bookDir,
+      chapterNumber: 4,
+    });
+    expect(result4.intent.outlineNode).toContain("抵达新地点");
+
+    // Chapter 6 should get beat 2 (third beat): "盟友背叛"
+    const result6 = await planner.planChapter({
+      book,
+      bookDir,
+      chapterNumber: 6,
+    });
+    expect(result6.intent.outlineNode).toContain("盟友背叛");
+
+    // Chapter 8 should get beat 4 (fifth beat): "幕后真相"
+    const result8 = await planner.planChapter({
+      book,
+      bookDir,
+      chapterNumber: 8,
+    });
+    expect(result8.intent.outlineNode).toContain("幕后真相");
+
+    // Chapter 5 should get beat 1: "正面交锋"
+    const result5 = await planner.planChapter({
+      book,
+      bookDir,
+      chapterNumber: 5,
+    });
+    expect(result5.intent.outlineNode).toContain("正面交锋");
+  });
 });
 });

+ 27 - 2
packages/core/src/agents/planner.ts

@@ -493,10 +493,14 @@ export class PlannerAgent extends BaseAgent {
       }
       }
 
 
       // For "章节范围" format, the volume title is above this line.
       // For "章节范围" format, the volume title is above this line.
-      // Collect the heading + all content below until the next heading.
+      // Collect the heading + all content below until the next heading,
+      // then pick the beat corresponding to this specific chapter.
+      const rangeStart = Number(match[1]);
       const sectionContent = this.extractSectionAroundRange(lines, index);
       const sectionContent = this.extractSectionAroundRange(lines, index);
       if (sectionContent) {
       if (sectionContent) {
-        return sectionContent;
+        const beatIndex = chapterNumber - rangeStart;
+        const specificBeat = this.extractNumberedBeat(sectionContent, beatIndex);
+        return specificBeat ?? sectionContent;
       }
       }
 
 
       const nextContent = this.findNextOutlineContent(lines, index + 1);
       const nextContent = this.findNextOutlineContent(lines, index + 1);
@@ -588,6 +592,27 @@ export class PlannerAgent extends BaseAgent {
     return content.length > 0 ? content : undefined;
     return content.length > 0 ? content : undefined;
   }
   }
 
 
+  /**
+   * Extract the Nth numbered beat from a section.
+   * Beats are lines starting with "1.", "2.", "3." etc. in "关键转折" blocks.
+   * beatIndex=0 → 1st beat, beatIndex=1 → 2nd beat, etc.
+   */
+  private extractNumberedBeat(section: string, beatIndex: number): string | undefined {
+    if (beatIndex < 0) return undefined;
+
+    const beats: string[] = [];
+    for (const line of section.split("\n")) {
+      const trimmed = line.trim();
+      // Match "1. ...", "2. ..." or "1) ..." patterns
+      if (/^\d+[.)]\s/.test(trimmed)) {
+        beats.push(trimmed.replace(/^\d+[.)]\s*/, ""));
+      }
+    }
+
+    if (beats.length === 0 || beatIndex >= beats.length) return undefined;
+    return beats[beatIndex];
+  }
+
   private findNextOutlineContent(lines: ReadonlyArray<string>, startIndex: number): string | undefined {
   private findNextOutlineContent(lines: ReadonlyArray<string>, startIndex: number): string | undefined {
     for (let index = startIndex; index < lines.length; index += 1) {
     for (let index = startIndex; index < lines.length; index += 1) {
       const line = lines[index]!;
       const line = lines[index]!;