Explorar o código

fix(book-analysis): 章节识别锚定行首消除幻影章节 + 故事导图缺失章节校验

parseNovelChapters 正则锚定行首,只把独立成行的第X章当作章节,兼容卷章同行格式,消除正文跨章引用误拆的幻影章节

故事导图提取后校验章节覆盖,AI 输出漏章时明确报错重试,不再静默保存不完整导图

新增回归测试(analysis-engine.spec.ts 4 条、story-analysis-adapter.spec.ts 漏章报错 1 条)
Mochocyang hai 2 semanas
pai
achega
a366f9a6ad

+ 71 - 0
src/lib/novel/book-analysis/analysis-engine.spec.ts

@@ -0,0 +1,71 @@
+import { describe, expect, it } from "vitest"
+import { parseNovelChapters } from "./analysis-engine"
+
+describe("parseNovelChapters 章节识别(锚定行首,防幻影章节)", () => {
+  it("正文里的“第X章”跨章引用不会被误拆成章节", () => {
+    const text = [
+      "第一章 山间小村",
+      "",
+      "他想起前文第三章提到过的那件事。",
+      "这一章里他又提到了第四章的内容。",
+      "第二章 拜师",
+      "",
+      "正说话间,第五章的伏笔开始显现。",
+      "第三章 下山",
+      "",
+      "(正文完)",
+    ].join("\n")
+    const chapters = parseNovelChapters(text)
+    expect(chapters).toHaveLength(3)
+    expect(chapters.map((chapter) => chapter.title)).toEqual([
+      "第一章 山间小村",
+      "第二章 拜师",
+      "第三章 下山",
+    ])
+    expect(chapters.map((chapter) => chapter.order)).toEqual([1, 2, 3])
+  })
+
+  it("兼容“第X卷卷名+第X章标题”同行格式,标题不含卷名", () => {
+    const text = [
+      "第五卷名震一方第六百四十八章 至木灵婴",
+      "",
+      "这一卷的正文内容。",
+      "第六百四十九章 预兆",
+      "",
+      "又一章正文。",
+    ].join("\n")
+    const chapters = parseNovelChapters(text)
+    expect(chapters).toHaveLength(2)
+    expect(chapters[0].title).toBe("第六百四十八章 至木灵婴")
+    expect(chapters[1].title).toBe("第六百四十九章 预兆")
+    // 章节正文应从章标题开始截取,行首的卷名前缀不会混入正文
+    expect(chapters[0].content).not.toContain("第五卷名震一方")
+    expect(chapters[0].content).toContain("这一卷的正文内容")
+    expect(chapters[0].order).toBe(1)
+    expect(chapters[1].order).toBe(2)
+  })
+
+  it("阿拉伯数字与中文数字章节号混合均可识别", () => {
+    const text = [
+      "第1章 起始",
+      "正文一。",
+      "第二章 推进",
+      "正文二。",
+      "第103章 大转折",
+      "正文三。",
+    ].join("\n")
+    const chapters = parseNovelChapters(text)
+    expect(chapters).toHaveLength(3)
+    expect(chapters.map((chapter) => chapter.title)).toEqual([
+      "第1章 起始",
+      "第二章 推进",
+      "第103章 大转折",
+    ])
+  })
+
+  it("没有章节标记时抛出明确错误", () => {
+    expect(() => parseNovelChapters("这是一篇没有章节标题的小说正文。")).toThrow(
+      "未能识别到章节标记",
+    )
+  })
+})

+ 11 - 4
src/lib/novel/book-analysis/analysis-engine.ts

@@ -34,7 +34,11 @@ export interface ParsedNovelChapter {
 }
 
 export function parseNovelChapters(content: string): ParsedNovelChapter[] {
-  const chapterRegex = /第[零〇一二三四五六七八九十百千万两0-9]+章[^\n]*/gi
+  // 锚定行首(m 多行模式),只把独立成行的“第X章”标题当作章节;
+  // 兼容“第X卷卷名+第X章标题”同行的格式(如“第五卷名震一方第六百四十八章”),
+  // 卷前缀仅作为行首锚定条件,章标题用捕获组1单独提取(避免标题混入卷名)。
+  // 避免把正文里“第X章提到过”这类跨章引用误拆成幻影章节。
+  const chapterRegex = /^(?:第[零〇一二三四五六七八九十百千万两0-9]+卷[^。!?!?\n]{0,32}?)?(第[零〇一二三四五六七八九十百千万两0-9]+章[^\n]*)/gim
   const matches = Array.from(content.matchAll(chapterRegex))
 
   if (matches.length === 0) {
@@ -42,10 +46,13 @@ export function parseNovelChapters(content: string): ParsedNovelChapter[] {
   }
 
   return matches.map((match, index) => {
-    const startIdx = match.index!
-    const endIdx = matches[index + 1]?.index ?? content.length
+    // 章节正文起点取章标题(捕获组1)的位置,行首卷前缀不会混入正文
+    const startIdx = match.index! + match[0].indexOf(match[1])
+    const endIdx = matches[index + 1]
+      ? matches[index + 1].index! + matches[index + 1][0].indexOf(matches[index + 1][1])
+      : content.length
     return {
-      title: match[0].trim(),
+      title: match[1].trim(),
       content: content.slice(startIdx, endIdx).trim(),
       order: index + 1,
     }

+ 53 - 0
src/lib/novel/book-analysis/story-analysis-adapter.spec.ts

@@ -169,4 +169,57 @@ describe("story analysis adapter", () => {
       signal: new AbortController().signal,
     })).rejects.toThrow("旧版四段格式")
   })
+
+  it("AI 输出漏掉区块最后一章时明确报错,不静默保存不完整导图", async () => {
+    const allChapters = Array.from({ length: 10 }, (_, i) => {
+      const order = 21 + i
+      const id = `ch-${String(order).padStart(4, "0")}`
+      return { id, title: `第${order}章`, order, content: `第${order}章正文。` }
+    })
+    // AI 只输出前 9 章,漏掉 ch-0030(第 30 章),模拟输出截断丢最后一章
+    const mapJson = JSON.stringify({
+      mainLineLabel: "主线",
+      mainSummary: "摘要",
+      chapters: allChapters.slice(0, 9).map((chapter) => ({
+        id: chapter.id,
+        order: chapter.order,
+        title: chapter.title,
+        summary: "s",
+        mainEvents: [{ label: "e", beats: [], characters: [] }],
+      })),
+    })
+    const adapter = createStoryAnalysisAdapter({
+      callModel: vi.fn(async () => mapJson),
+      loadMetadata: vi.fn(async () => metadata),
+      loadChapters: vi.fn(async () => allChapters),
+      recognizeCharacters: vi.fn(async () => []),
+      now: () => 10,
+    })
+    const inputTask = task()
+    await expect(adapter.runChunk({
+      task: inputTask,
+      skill: "story",
+      bookPath: inputTask.bookPath,
+      projectPath: inputTask.projectPath,
+      llmConfig: {} as LlmConfig,
+      chunk: {
+        version: 1,
+        id: "chunk-0021-0030",
+        taskId: inputTask.id,
+        skill: "story",
+        chapterIds: allChapters.map((chapter) => chapter.id),
+        startOrder: 21,
+        endOrder: 30,
+        wordCount: 1000,
+        status: "running",
+        attempts: 1,
+        resultPath: null,
+        error: null,
+        startedAt: 1,
+        completedAt: null,
+        updatedAt: 1,
+      },
+      signal: new AbortController().signal,
+    })).rejects.toThrow("缺少第 30 章")
+  })
 })

+ 8 - 0
src/lib/novel/book-analysis/story-analysis-adapter.ts

@@ -216,6 +216,14 @@ export function createStoryAnalysisAdapter(
       if (!map) {
         throw new Error("故事导图提取失败:AI 输出缺少主线事件或章节结构,请重试该区块")
       }
+      // 校验导图是否覆盖了本区块全部章节:AI 输出若被截断会从末尾漏掉最后一章,
+      // 这里比对归一化后的章节 ID,缺失时明确报错而非静默保存不完整导图
+      const normalizeChapterId = (id: string): string => id.replace(/^ch-?0*(\d+)$/i, "ch-$1")
+      const mappedChapterIds = new Set(map.chapters.map((chapter) => normalizeChapterId(chapter.id)))
+      const missingChapters = chapters.filter((chapter) => !mappedChapterIds.has(normalizeChapterId(chapter.id)))
+      if (missingChapters.length > 0) {
+        throw new Error(`故事导图缺少第 ${missingChapters.map((chapter) => chapter.order).join("、")} 章,请重试该区块`)
+      }
       return {
         result: { map, rangeChapterIds: chunk.chapterIds },
         evidence: storyEvidence(task.id, task.bookId, chunk.id, chapters, dependencies.now()),

+ 43 - 0
zhangjietiquxiufu-分支说明.md

@@ -0,0 +1,43 @@
+# zhangjietiquxiufu 分支说明
+
+## 分支用途
+
+本分支为「章节提取修复」功能分支,修复拆书库/书库分析模块的章节识别与故事导图提取问题。
+
+## 修复背景
+
+用户反馈:拆书库提取章节范围 21~30 章时,结果只覆盖到 21~29 章;31~40 章只覆盖到 31~39 章,总是遗漏最后一章(1~10 章正常)。排查后确认两个根因:
+
+1. **章节识别正则未锚定行首**,会把正文里的「第X章」跨章引用误当成章节标题,产生幻影章节,导致章节序号与真实章节错位,范围末尾章节被切碎/合并,内容缺失。
+2. **故事导图解析不做章节数量校验**,AI 输出被截断漏掉最后一章时,不完整的导图会被静默保存,覆盖范围停留在 21~29 / 31~39。
+
+## 使用要求
+
+1. 本分支只修复「章节提取」相关问题,不混入其他功能改动。
+2. 章节识别(parseNovelChapters)只把独立成行的「第X章」标题当作章节;兼容「第X卷卷名+第X章标题」同行格式,卷名不混入章标题与正文。
+3. 故事导图提取后校验是否覆盖所选全部章节,缺失章节时明确报错提示重试,不再静默保存不完整导图。
+4. 本分支必须保持稳定、可打包。
+5. 修改完成后先跑源码相关测试,再跑旧功能回归测试,确认无回退后再考虑打包。
+
+## 更新记录
+
+- 2026-08-26:创建分支(从 main 派生),开始修复章节提取问题。
+- 2026-08-26:修复章节识别正则(src/lib/novel/book-analysis/analysis-engine.ts):
+  - 正则从 `第[零〇一二三四五六七八九十百千万两0-9]+章[^\n]*` 改为锚定行首 + 可选「第X卷」前缀:`^(?:第[零〇一二三四五六七八九十百千万两0-9]+卷[^。!?!?\n]{0,32}?)?(第[零〇一二三四五六七八九十百千万两0-9]+章[^\n]*)`(gim)。
+  - 章标题用捕获组单独提取,正文起点取章标题位置,行首卷名不混入标题与正文。
+  - 真实数据验证:《武炼巅峰》源文件幻影章节从 5962 → 5917(消除 45 个);《凡人修仙传》从 2621 → 2574(消除 47 个),21~40 范围章节完全正确。
+- 2026-08-26:故事导图增加章节覆盖校验(src/lib/novel/book-analysis/story-analysis-adapter.ts):
+  - parseStoryMapResult 返回后,比对导图章节 ID 与所选全部章节,缺失时抛出「故事导图缺少第 N 章,请重试该区块」,不再静默保存不完整导图。
+- 2026-08-26:新增回归测试:
+  - analysis-engine.spec.ts:正文跨章引用不误拆、卷章同行标题提取(不含卷名)、中英文数字章节混用、无章节标记报错,共 4 条。
+  - story-analysis-adapter.spec.ts:新增「AI 输出漏掉区块最后一章时明确报错」1 条。
+  - 清理指向用户本地文件的临时复现测试(repro-*.spec.ts,不提交)。
+- 2026-08-26:验证:
+  - typecheck 通过;
+  - book-analysis 目录 44 个测试文件 346 条用例全部通过;
+  - batch-import / dismantling / plot-framework 相关 82 条用例通过;
+  - 全量回归 533 文件通过,15 个失败文件均为既有历史基线问题(fast-check 缺失、window 未定义等),与本次改动无关,无新增回退。
+
+## 提交状态
+
+- 尚未提交(开发中,待用户确认后提交)。