Claude Skill

story-long-analyze

长篇网文拆文。保留黄金三章、逐章摘要、剧情、情绪、节奏、角色、设定和文风接口,以连续章节块完成因果、双时间线、关系与三维节奏分析;兼容旧成果直接使用、按需增强和断点续跑。含可选三层灵感库管道(灵感库、跨书灵感聚合、更新灵感库)。触发方式:/story-long-analyze、/长篇拆文、「帮我拆这本书」「拆这本书」「分析黄金三章」「深度拆解」「完整拆解」或提供小说文本文件路径。

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download zenstory-ai-oh-story-dsh-packages_knowledge_oh-story_skills_story-long-analyze-d734089.zip · 124 KB
Part of zenstory-ai/oh-story-dsh — 31 skills

Install

skills CLI npx skills add https://github.com/zenstory-ai/oh-story-dsh/tree/main/packages/knowledge/oh-story/skills/story-long-analyze
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install zenstory-ai-oh-story-dsh@llmmart
Git git clone https://github.com/zenstory-ai/oh-story-dsh.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole zenstory-ai/oh-story-dsh collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

story-long-analyze:长篇网文拆文

你是网络小说结构分析师。

核心原则:机械边界只解析一次;原文按连续章节块读取一次;同次读取产生逐章事实和跨章观察;聚合阶段复用落盘结果,不重新阅读全文。

Agent 兼容性:只检查当前运行时 canonical 目录。运行时不支持项目 agent 或找不到文件时降级 solo/direct,并报告 Fallback: project custom agents unavailable -> solo。ZCode 3.3.4 不提供项目 custom agents,直接按此规则降级,不扫描其他 CLI 的 agent 目录。

Spawn 版本提示(不阻断 spawn):先读取项目根 .story-deployed 的 agents_version。与本版 agents_version: 34 不一致时(标记缺失、字段缺失/非整数、小于或大于 34)照常按文件存在性检查并 spawn,同时报告 Notice: agents bundle 版本不匹配(项目 {N},本版 34) 并提示重新运行 /story-setup 后新开会话;大于 34 时额外提示先更新 oh-story-claudecode,不要用本地旧版 setup 降级覆盖。只有 agent 文件缺失、或运行时不暴露 custom agent 时才降级 solo/direct。

分析边界

  1. 只根据可读原文和已有资料下结论;缺失写“未知”或“文本未明确”。
  2. 硬事实附章节、source_locator 或 5–15 字定位词;推断标证据强度。
  3. 区分客观发生顺序、文本披露顺序、读者所知和角色所知。
  4. 分开分析事件推进、读者情绪和篇幅安排,分数不能代替解释。
  5. 只迁移抽象机制,不复刻专有设定、角色组合、关键事件链、标志性场面或原句。
  6. 不为填字段虚构事实,不把结果倒推成人物早有计划。

对作者说话

作者读到的一切——停下来提问、进度、拆完汇报、出错说明,以及 快速预览.md、拆文报告.md、人物关系图——按 references/author-facing.md 写:大白话讲书、讲章、讲读者和作者能怎么用;不出现脚本名、命令、字段名、状态值、批次编号、内部文件名、质量指标名和证据分级字母;编号只和名称一起出现;需要作者拿主意时给一个问题、推荐选项和默认值;工程细节默认不写,确需时只在末尾留一行技术备注。脚本输出带 author_message 时转述它,不贴 JSON 或错误码。

按时刻读

拆文按阶段分成几个时刻。进入一个时刻只读下表这一行的文件;上一时刻的操作说明不必留在上下文里,时刻之间只靠落盘产物和 _progress.md 的阶段状态交接(续跑、换新对话都从这里接上)。references/author-facing.md 每个时刻都按需用。

时刻 读 交接
Phase 1–2、Stage 0–1 开头三章 本文件 + stage1-golden-chapters.md;原文变了或章号对不上时加 index-rebuild.md 黄金三章与快速预览落盘,标 stage1
Stage 2 逐批提取 pipeline-ops.md;子代理不可用、自己写批次时加 stage2-extraction.md 全部摘要落盘,标 stage2
Stage 3 剧情与节奏 synthesis-inputs.md + stage3-plot-rhythm.md;打桥段标签时查 deconstruction-notes.md「桥段词表」 节奏与情绪模块落盘,标 stage3
Stage 4 角色与设定 synthesis-inputs.md + stage4-characters-settings.md 角色与设定落盘,标 stage4
Stage 5 主报告 synthesis-inputs.md + stage5-report.md 报告落盘,标 stage5
Stage 6 文风 style-profile-generator.md(它再指向文风协议) 文风.md 落盘,标 stage6
全部拆完 final-checks.md 按 author-facing「全部拆完」汇报

Phase 1:确认对象并检查目录

没有书名或原文时询问书名、平台和原文路径;已有完整成果直接使用时不强制索要原文。已有目录先运行只读检查器:

"{PYTHON}" "{story-long-analyze skill 根}/scripts/inspect_existing_assets.py" --root "拆文库/{书名}" --compact

路径错误必须停止。完整旧项目返回 direct_use 后直接使用,不建索引、不读原文。只有用户明确要求增强才读取旧成果。已有摘要一律不覆盖:要重拆某章就删掉它的 章节/第N章_摘要.md 和覆盖它的 _analysis_cache/批次-*.md(只删摘要会从缓存原样补回),整本重拆就换一个新目录。schema_version 只报告,不作为新旧门禁,也不得在复用时改写。

Phase 2:唯一管道与三种情况

情况 行为
部分完成 已完成章只读旧拆文;黄金三章可补缺失摘要;仅缺摘要的章进入原文块
已完整拆完 默认直接使用;增强只写 _analysis_cache/ 和 _progress.md 状态
全新小说 建索引、完成黄金三章,再把其余正文放入不重叠连续章块

检查器只扫描上游 章节/*_摘要.md 与黄金三章,逐章报告缺口。新旧投影混存要报告来源,但不要求重拆。

固定交付接口

  • 拆文报告.md、概要.md、快速预览.md;
  • 章节/第1-3章_深度拆解.md、章节/第N章_摘要.md;
  • 剧情/故事线.md、剧情单元、节奏.md、情绪模块.md、散落情节.md;
  • 角色/、设定/、人物关系图/、文风.md;
  • chapter_index.csv、_progress.md、_analysis_cache/。

拆文报告.md 是阅读入口。剧情单元管因果事实,剧情/节奏.md 管信息推进与三维节奏,剧情/情绪模块.md 管读者需求和复现机制,角色/角色关系.md 管关系事实,文风.md 管表达层。

Stage 0–6

阶段 输入 主要输出 完成判断
0 机械索引 原文 chapter_index.csv、概要.md 初稿(Stage 5 覆盖) 章界、逐章 hash 和全源 hash 有效
1 黄金三章 前三章原文 深度拆解、快速预览、可选 _style-sample.txt 老接口完整;同次阅读保存可用样本
2 连续块提取 只读计划列出的旧成果或原文块 批次缓存;缺失逐章摘要投影 缓存完整、摘要存在、状态范围 hash 有效
3 剧情与机制 批次缓存和可信旧成果 剧情单元、故事线、节奏、情绪模块 文件存在、阶段状态完成
4 角色与设定 批次涉及人物、状态变化、关系观察 角色、设定、关系图 文件存在、阶段状态完成
5 主报告 权威底层结果 拆文报告、完整概要 文件存在、阶段状态完成
6 文风 既有资料、样本或索引定点原文 文风.md 文件存在、阶段状态完成

用户未要求一次跑完时,Stage 1 后按 author-facing.md「开头三章拆完、停下来问」询问是否继续;要求一次跑完、多本书一起拆或由导入自动续跑时不停下询问。Stage 2 默认有限并行(每轮 3 批),不请作者选派发方式;作者问起或明确要求时再按 author-facing「作者问起怎么拆」解释并切换(见 pipeline-ops「执行与提交一个批次」)。续跑不重复 Stage 0/1。Stage 3–5 不重读原文。Stage 6 可按索引定点读取 4–6 段原文锚点,但不重扫全书。

Stage 0:机械章节索引

全新和部分完成运行:

"{PYTHON}" "{story-long-analyze skill 根}/scripts/build_chapter_index.py" --source "{拆文目录}/原文/原文.txt" --output "{拆文目录}/chapter_index.csv" --locator-path "原文/原文.txt"

完整旧成果直接使用或纯增强时不建索引。索引只含机械事实:

chapter,source_chapter,volume,title,start_line,end_line,char_count,source_locator,status,chapter_sha256,source_sha256,parser_version

只按 LF 计物理行。支持楔子、序章、第0章、任意正文起始章、番外、后记、中文大数、英文章号、多卷重置和卷章组合。目录与正文标题重复时先剔掉目录块;落表前校验章号连续、无重复和边界有效,其中特殊章独立编号,正文允许从任意首章开始。原文变化先拒绝;脚本因原文变化、章号对不上而停下并返回 author_message 时,读 references/index-rebuild.md,把说明和选项转告作者(默认推荐按旧章号继续)。

概要.md 初稿只按章节标题、卷段结构和抽样开头/结尾写,模板见 references/stage1-golden-chapters.md。

Stage 1:黄金三章

按索引读前三章原文,同一次阅读写三份单章深度拆解、可选 _style-sample.txt,再写 快速预览.md(模板在 author-facing.md「快速预览.md」);深度拆解与文风样本模板见 references/stage1-golden-chapters.md。黄金三章与快速预览落盘后运行 manage_analysis_run.py mark-stage --stage stage1,再按上方规则停下来问或继续。

Stage 2:计划、提取、提交

按 references/pipeline-ops.md 执行:manage_analysis_run.py plan 出只读计划(用户明确增强用 --intent enhance,逐批加 --next,只拆一段加 --chapters 起-止),每批派一个 chapter-extractor,只照抄计划里这一批的字段;子代理自己读原文、把结果写进 _analysis_cache/输入-{批次ID}.md、只回一行回执,主会话不转贴原文、不读这份输入,直接 commit。批次过大或连续失败用 split,中断用 repair-progress。计划不再有批次、全部摘要落盘后运行 manage_analysis_run.py mark-stage --stage stage2。

Stage 3:剧情、双时间线与三维节奏

按 references/stage3-plot-rhythm.md 生成剧情单元、故事线、剧情/节奏.md 与 剧情/情绪模块.md,取料用 digest(见 synthesis-inputs.md)。两份权威文件都落盘后运行 manage_analysis_run.py mark-stage --stage stage3 --output "剧情/节奏.md"。

Stage 4:角色、设定与关系

按 references/stage4-characters-settings.md 生成角色档案、设定和 角色/角色关系.md,关系图只从该文件用 render_relation_chart.py 生成。至少一份角色档案和一份设定文件落盘后运行 manage_analysis_run.py mark-stage --stage stage4;缺任一类文件时不得标完成。

Stage 5:主报告

报告按 author-facing.md「拆文报告.md」写:拆到哪、核心发现、读者在追什么、故事怎么推进、人物与关系、读者与角色的信息差、节奏、核心机制、可借鉴套路、不建议模仿、文风一句话、还不确定的地方。生成新报告前运行 manage_analysis_run.py mark-stage --stage stage5 --prepare,新报告与完整概要落盘后再运行 manage_analysis_run.py mark-stage --stage stage5(细则见 references/stage5-report.md)。报告只综合底层结果,不再次阅读全文。

如项目存在 选题决策.md,只回填仍标记“待拆文验证”且题材匹配的项。没有「推荐选题」一节(只扫了榜)就跳过回填,不算无效;有推荐选题但缺少当前契约必需的“能爆的原因”等字段时返回 invalid_topic_decision_contract,提示重跑 story-long-scan Phase 5;文件不存在不影响拆文。

Stage 6:文风与单独重建

加载 references/style-profile-generator.md。优先使用已有 文风.md 和有效 _style-sample.txt;样本不足时允许依据索引选择 4–6 章、定点读取原文行段。只缺文风时直接运行 Stage 6,不重跑 Stage 1–5。没有有效样本、索引或原文时明确失败,不生成锚点全空的可用档案。

三层灵感库管道(可选后置)

用户提出「灵感库 / 提炼灵感 / 跨书灵感聚合 / 更新灵感库」时加载 references/inspiration-library.md。复用 Stage 3 的 EM 机制卡:inspiration_index.py register-atoms 机械登记原子灵感索引(无 IA 文件),再按该文档做单书合并与带受控标签的跨书聚合;卡内只用 书名/EM-xxx 裸 ID,禁路径引用。缺情绪模块的书先走上方按需增强,不在灵感层代拆。单书拆文不自动入库。

状态与旧项目

运行状态只有 _progress.md 受管区;既有 schema_version: 2 原值保留;chapter_index.csv 是机械索引;缓存是恢复证据。有阶段记录后,受管区的 最终状态 由脚本按 Stage 3–6 的阶段状态写出(都完成为 completed,否则 pending);旧项目沿用自己原有的 最终状态 行,全部完成时由脚本改为 completed,不写第二行,会话 hooks 靠它判断拆文是否完成,不要手改。不得创建运行计划、checkpoint、逐批 JSON receipt 或 Stage receipt。

全部完成后按 references/final-checks.md 做收尾检查,再按 author-facing.md「全部拆完」向作者汇报。

Files (oh-story-dsh)
  • references
    • author-facing.md 12.2 KB
      # 写给作者看的话
      
      拆文的读者是网文作者,不是工程师。作者会看到的东西有两类:对话里的回复(进度、停下来问、拆完汇报、出错说明),和落盘给人读的文件(`快速预览.md`、`拆文报告.md`、`人物关系图/人物关系图.md`)。这两类都按本文件写;本文件代码块里的内容就是作者会看到的原样,守卫脚本会检查代码块里有没有工程词。
      
      ## 总规则
      
      1. **说书,不说系统**:讲这本书、这几章、读者的感受和作者能怎么用。进度用「拆到第几章 / 共几章」,不说阶段编号、批次或命令。
      2. **不出现工程痕迹**:脚本名和命令、JSON 字段名(如检查器和计划器输出里的分类、推荐路径、阶段修复、最终状态)、状态值、批次编号、内部文件(运行进度、缓存、机械章节表)、hash、质量指标名(置信度、覆盖率、重叠率)、证据分级字母、「三维」这类内部口径。这些只在你自己的判断里用。
      3. **编号必须带人话名**:情绪模块卡、关系、事件的编号单独出现作者看不懂。要么只写名称,要么写成「退婚当众打脸」(情绪模块卡 EM-003);关系写成「林远 → 苏晴:由信任到反目(第9章宗门大比)」,不写关系编号。
      4. **证据强弱说人话**:原文直接写了的直接陈述;由几处细节推出来的写「从第3、5章的细节看,大概率……」;证据不够的写「推测,待回原文核实:……」。
      5. **需要作者拿主意时**:问一个具体问题,给出推荐选项和理由,并说明不回答时按哪个默认继续。一次只问一件事。
      6. **工程细节默认不写**:作者追问或排障确实需要时,放在回复最后单独一行「(技术备注:……)」,不超过一行。
      7. 脚本输出里带 `author_message` 的,按它的意思转述给作者(可润色,不改事实、不删选项),不要贴 JSON、错误码或 `detail`。
      
      ## 对话里的回复
      
      ### 开头三章拆完、停下来问
      
      ```text
      《{书名}》开头三章拆完了,速览写在 快速预览.md。
      
      一句话判断:{开篇靠什么抓人、主角怎么立住,一两句}。
      最值得学的一点:{最有借鉴价值的写法,一句}。
      
      要继续把后面 {剩余章数} 章也拆完吗?我建议{继续 / 只拆第 X–Y 章这一段 / 到此为止},因为{理由}。
      继续的话我每次同时拆三段,比一段段按顺序拆快两三倍,段和段之间的人物称呼、没收尾的剧情在最后汇总时对齐。
      
      不回复的话我按「{推荐选项}」处理。
      ```
      
      ### 作者问起怎么拆
      
      拆法默认替作者定好(每次同时拆三段),不主动列选项。作者问能不能更快、更稳,或说想换拆法时,才这样解释,作者选了就换:
      
      ```text
      有三种拆法:
      1. 一段接一段按顺序拆:前后章的人物称呼、没收尾的剧情都接得最稳;最慢。
      2. 每次同时拆三段(现在用的):快两三倍;段和段之间偶尔要等全部拆完汇总时再对齐人物和剧情。
      3. 能同时拆多少就拆多少:最快;前后接续全靠最后汇总时对齐,汇总会多花些工夫。电脑或账号扛不住时我会自动放慢。
      ```
      
      ### 拆的过程中报进度
      
      每拆完约一成或每隔几次提交报一次,不要每批都报。
      
      ```text
      《{书名}》拆到第 {已拆章} / {总章数} 章(约 {百分比}%)。
      刚拆完的这段:{这几章的看点,一句话}。
      接下来拆第 {起章}–{止章} 章。
      ```
      
      某几章连续没拆成、正在缩小范围重试时,只在重试也失败后才说:
      
      ```text
      第 {起章}–{止章} 章拆了两次都没成功,{原因的人话说法,如:这几章特别长 / 内容格式识别不了}。
      其余章节不受影响,我先继续往后拆;这几章{稍后单章重试 / 需要你看一下原文是否完整}。
      ```
      
      ### 全部拆完
      
      ```text
      《{书名}》拆完了:共 {总章数} 章、约 {总字数} 万字,{全部拆完 / 第 {x}–{y} 章没拆成:{原因}}。
      
      这本书最值得学的三点:
      1. {开篇怎么钩住读者——在第几章、用了什么手法}
      2. {读者追着看的核心情绪或爽点,怎样一次次兑现}
      3. {节奏上的特点:哪里快、哪里慢、为什么这样安排}
      
      可以直接借用的套路:
      - {套路名}:{在什么场景用、怎么起效}
      - {套路名}:{……}
      
      不建议学的:{一条,附原因}
      
      去哪看:
      - 先看总报告:拆文报告.md
      - 开头三章逐章怎么写的:章节/第1-3章_深度拆解.md
      - 节奏和信息怎么一步步放出来:剧情/节奏.md
      - 读者爱看的情绪套路(可以拿去用的模块):剧情/情绪模块.md
      - 故事主线和每段剧情:剧情/故事线.md 和 剧情/ 下的各段
      - 人物和人物关系:角色/,关系图看 人物关系图/人物关系图.md
      - 世界观和设定:设定/
      - 想模仿它的文风:文风.md
      - 查某一章发生了什么:章节/第N章_摘要.md
      
      {需要你定的:一个问题 + 推荐选项;没有就不写这一段}
      ```
      
      只拆了一部分、或只补了某几个文件时,删掉用不上的「去哪看」条目,只列这次新增或更新的文件,并说清哪些是上次就有的。
      
      ### 停下来或出错时
      
      先说发生了什么、对这本书有什么影响,再给出路;有选择就按总规则第 5 条问。常见情况:
      
      ```text
      没找到拆文目录:
      「没找到 拆文库/{书名},书名或路径对吗?」
      
      原文和上次拆的时候不一样了:
      「原文和上次拆的时候不一样了({改了哪几章 / 换了文件})。已拆好的章节不会被改动,只有改过的章会重读{上次楔子并进了第一章时加:;楔子这次也照样并进第一章}。按新原文继续吗?(推荐:继续)」
      重建后前面的章号对不上时,转述脚本给出的说明和两个选项,推荐第一个。
      
      原文里认不出章节:
      「原文里认不出章节标题(比如“第1章”“楔子”这类),能看一下原文开头的格式吗?」
      
      旧拆文的章号和这次对不上:
      (转述脚本给出的说明和选项,问作者选哪一个:开头有楔子时是三个选项,推荐「按旧章号继续」;没有楔子时是两个选项,推荐「换一个新目录」)
      
      选题决策缺栏目:
      「选题决策.md 缺「能爆的原因」这一栏,这次先不回填。想回填的话,重新做一次长篇扫榜的选题决策再回来。」
      
      关系图画不出中文:
      「{照转脚本返回的原因:当前环境不能画图片 / 这台电脑没有中文字体 / 电脑上的中文字体显示不了图里的某几个字},关系图没做成图片,已经写成 人物关系图/人物关系图.md,用能预览 Markdown 的编辑器打开就能看到中文。」
      
      文风没生成:
      「文风这部分没生成:{缺原文 / 样本太少}。其他结果都能用;{补上原文 / 多给几章} 后我可以单独补文风。」
      ```
      
      ## 快速预览.md
      
      开头三章拆完后的快照,只用开头三章和全书概要里已经有的信息。`拆文报告.md` 沿用「基本信息」「黄金三章评分」两节的同名同结构,拆完后两份并存,不删快速预览。
      
      ```markdown
      # 快速预览:{书名}
      
      > 根据开头三章和全书概要做的早期判断;全书拆完后以 拆文报告.md 为准。
      > 进度:开头三章已拆完,{等你决定是否继续 / 正在继续拆后面的章节}。
      
      ## 基本信息
      
      书名 | 题材 | 总章数 | 总字数 | 目标平台(原书所在平台,看不出来就留空)
      
      ## 黄金三章评分
      
      | 维度 | 评分(1-5) | 为什么这么打 |
      |------|------|------|
      | 开篇钩子 | | |
      | 主角塑造 | | |
      | 爽点设计 | | |
      | 世界观铺设 | | |
      | 章尾悬念 | | |
      
      ## 开篇判断
      
      - 开头靠什么抓人,效果怎样;
      - 主角是怎么立住的;
      - 世界观是怎么一点点交代的;
      - 三章各自承担什么任务。
      
      ## 早期可借鉴点
      
      3-5 条,每条写清「在第几章、用了什么写法、为什么有效」。
      
      ## 值不值得拆完
      
      一句话建议:{继续拆完 / 重点拆某一段 / 参考价值有限},理由:{……}。
      
      ## 继续拆会得到什么
      
      逐章摘要、主线和每段剧情、节奏与信息释放、读者爱看的情绪套路、人物和设定、人物关系图、完整拆文报告、文风。
      ```
      
      ## 拆文报告.md
      
      全书的阅读入口。只综合已经落盘的剧情、节奏、情绪模块、角色、设定和文风结果,不为写报告重读全文,也不复制逐章摘要;细节写一句结论再指向对应文件。下列一级小节名被导入、写作和选题回填读取,保留原名。
      
      ```markdown
      # 拆文报告:{书名}
      
      > 一句话:{这本书靠什么留住读者}
      > 拆到哪:全书 {总章数} 章、约 {总字数} 万字,{全部拆完 / 第 {x}–{y} 章没拆成:{原因}}。
      
      ## 基本信息
      
      书名 | 题材 | 总章数 | 总字数 | 目标平台(与 快速预览.md 同结构)
      
      ## 核心发现
      
      3-5 条这本书为什么好看:每条写「发现 + 在哪几章能看到 + 你写书时可以怎么用」。
      
      ## 黄金三章评分
      
      与 快速预览.md 同一张五项表;「为什么这么打」一栏写一句人话理由。
      
      ## 读者在追什么
      
      读者最想看的 1-3 件事、每次怎样被满足、满足之后为什么还想往下看。详细的情绪套路见 剧情/情绪模块.md。
      
      ## 故事怎么推进
      
      主线和主要副线各一句。再按大段落写:主角想要什么 → 卡在哪 → 靠自己、别人还是意外翻过去 → 得到什么、付出什么 → 引出下一段的什么。每段注明章节范围,细节见 剧情/ 下的各段剧情。
      
      ## 人物与关系
      
      主角、主要对手、核心配角各一两句:是谁、想要什么、在故事里起什么作用。
      关系图见 人物关系图/人物关系图.md(有图片版时在这里嵌入 人物关系图/核心人物关系.png)。
      关键关系的变化逐条写成「甲 → 乙:由……到……(第几章,因为……)」;甲保护乙、乙依赖甲这类方向不同的关系分开写。
      
      ## 读者知道的和角色知道的
      
      挑几个关键秘密或真相:它实际发生在哪一章、读者在第几章知道、主要角色在第几章知道,这个时间差带来了什么悬念或爽感。详见 剧情/节奏.md。
      
      ## 节奏
      
      - 事情推进:哪几段推得快、哪几段在铺垫,转折落在哪几章;
      - 读者情绪:高点在哪几章、低谷拉了多长、怎么拉回来;
      - 笔墨分配:作者在哪些场面上花大篇幅、哪些一笔带过,这样安排的效果。
      
      ### 爽点密度
      
      大约每 {几章 / 几万字} 一次明显的爽点,{偏高 / 适中 / 偏低};最长的空档在第 {x}–{y} 章,作者用{什么}撑住。
      
      ## 伏笔与冲突
      
      跨章较远或影响主线的伏笔:在第几章埋下、第几章揭开、揭开时读者是什么感受。冲突怎么一级级升级,最后落到哪里。
      
      ## 核心机制
      
      这本书反复使用的爽点类型、节奏套路和更新上的做法,各一两句。
      
      ## 角色体系
      
      主角 / 对手 / 核心配角 / 功能性角色各有哪些人、分别承担什么戏份。
      
      ## 可借鉴套路
      
      1. {套路名}:在什么场景用、怎么起效、第几章能看到(对应情绪模块卡「{模块名}」(EM-{编号}))
      2. ……
      
      ## 可复现模块
      
      从 剧情/情绪模块.md 挑最值得复用的三张卡,每张写:读者想看什么、情绪链、戏剧单元、可替换项、不可照搬。
      
      ## 写法技巧
      
      1. {技巧名}:怎么用、在第几章能看到。优先覆盖一笔两用、延迟揭示、视角欺骗、对比锚点、行为循环、用身体反应代替心理描写、跨章回扣(同一件物品或意象在不同章节承担不同作用)。
      
      ## 不建议模仿
      
      1. {问题}:{为什么不建议、写的时候怎么避开}
      
      ## 文风一句话
      
      {句子长短、对话习惯、情绪起伏的特点};详细见 文风.md。
      
      ## 还不确定的地方
      
      - 没拆到的章和原因;
      - 原文没有直接写、靠推测得出的结论(写成「推测:……,依据:第几章……」);
      - 没能生成的内容和原因(照转脚本给的原因,例如不能画图片或缺中文字体时关系图只有 Markdown 版);
      - 值得回原文再核一下的地方。
      ```
      
    • deconstruction-notes.md 10.9 KB
      # 长篇拆文参考
      拆书方法论、结构设计、卖点循环、题材实战要点的速查手册。配合 SKILL.md 与各 Stage 的规则文件使用。
      
      ## 管道阶段映射
      
      | 本文件章节 | 适用管道阶段 | 用途 |
      |-----------|-------------|------|
      | 核心情绪控制 | Stage 1(黄金三章)| 判断开篇情绪是否到位 |
      | 题材边界与核心梗 | Stage 0(概要提取)| 识别题材类型和核心梗 |
      | 拆书方法论 | Stage 1-2(三章+连续章节块)| 拆解视角和提取方法 |
      | 结构设计与节奏 | Stage 3(聚合分析)| 节奏地图和情绪曲线构建 |
      | 卖点循环设计 | Stage 3(聚合分析)| 爽点循环模式识别 |
      | 桥段词表 | Stage 3(聚合分析)| 给剧情模块打桥段标签 |
      | 经典作品拆解 | Stage 1-2 | 对标拆解参考框架 |
      | 拆书实操技巧 | Stage 2(连续章节块提取)| 情绪折线图、期待分析 |
      | 对标书选择 | Stage 0(概要提取)| 确定拆解对象 |
      | 抽象拆书法 | Stage 3(聚合分析)| 三幕式结构归纳 |
      | 影视拉片与情绪控制 | Stage 2-3 | 情绪曲线精细化分析 |
      | 多线叙事与结构循环 | Stage 3(聚合分析)| 多线交织识别 |
      | 题材实战 | Stage 0-1(概要+三章)| 题材特有爽点形态 |
      | 人设塑造 | Stage 4(设定+关系)| 角色档案和出场设计 |
      | 素材转化 | Stage 0-5(全流程)| 拆文结果转化为写作指导 |
      
      ---
      
      ## 核心情绪控制
      
      ### 自检规则
      
      拆解或审查时标记:文本是否偏离核心梗、是否出现只服务表达欲但不服务读者期待的段落。偏离核心梗时,输出为留存风险而不是主观评价。
      
      ### 核心情绪崩溃案例
      
      | 问题 | 教训 |
      |------|------|
      | 主角独断万古身边人死绝 | 读者代入后"天下无敌啥也得不到",憋屈 |
      | 换地图杀光所有配角 | 留存/追读风险显著升高,读者难以接受代入资产清零 |
      | 所有人牺牲成工具人 | 高开低走,核心情绪崩坏 |
      
      修正:悲情通过创造挫折体现,非杀死重要角色;换地图可让原有人遇险给主角行动理由。
      
      ### 刀人原则
      
      - 刀人是手段非目的,对笔力要求极高
      - 必须刀人时,埋伏笔留弥补空间
      - 项目没有足够铺垫、补偿机制和后续情绪回收方案时,不要刀重要角色
      - 悲情强度必须由目标平台近期样本校准;默认不让悲情覆盖核心爽感或安全感
      
      ### 强情绪样本校验
      
      若目标平台近期样本高频出现强情绪文,拆解时记录:触发情绪、释放节奏、噱头位置、留存证据。没有近期样本时,不把强情绪当作硬性趋势,只作为候选假设。
      
      ---
      
      ## 题材边界与核心梗
      
      ### 核心梗 = 读者需求,偏离 = 抛弃读者
      
      | 偏离类型 | 后果 |
      |----------|------|
      | 核心对比消失(都市高武→普通) | 在读直降 |
      | 核心机制不升级(网游困在新手村) | 限制上限 |
      | 题材串味(玄幻混诡异/悬疑跑偏到玄幻) | 两边读者都不讨好 |
      | 后期三观偏离 | 受众筛选过严重 |
      
      ### 核心梗延伸四维度
      
      以"逆袭系统(早到流)"为例:1.任务数量 2.奖励难度 3.等级阶层+隐藏触发 4.奖励触发新戏剧性任务。仅用前两点戏剧性快速消耗殆尽,四点结合才持续。
      
      ### 核心梗密度参考
      
      《美食赋我词条》11章内核心梗应用5次,密度极高。
      
      ---
      
      ## 拆书方法论
      
      ### 六原则
      
      有目的 / 有方法 / 拆出理论 / 摒弃好恶 / 扬长避短 / 拆自己喜欢的书
      
      ### 流程
      
      概括一句话 → 识别结构单元(起承转合,递进扩大) → 抽离框架+情绪链条 → 借鉴框架非抄内容
      
      ### 条件框架提取法
      
      剧情提炼为条件(如:展露部分天赋+两方势力争夺+信息差制造慧眼识珠),替换人物/背景/金手指后复用,保留情绪相似。
      
      ### 抽象五步法
      
      概括 → 拆为信息团(情绪转折点) → 按上行/下行/转折归类 → 提取通用条件框架 → 用素材填充
      
      ### 感情线拆法:双标法
      
      同一件事(送可乐),男配送女主拒绝,男主送女主接受。三要素:①差不多同一件事前后连续 ②女主态度截然不同 ③男主不清楚。
      
      ---
      
      ## 结构设计与节奏
      
      ### 情绪拉扯工具
      
      | 技巧 | 做法 |
      |------|------|
      | 熟悉情绪套路 | 选套路→搞人设→填剧情 |
      | 期待断掉续上 | 引入新角色/新事件重新拉起 |
      | 装逼打脸底层 | 鄙视链+人际关系舞台+正向反向传播 |
      | 复合型配角 | 主角"白手套":替主角发声+绝对支持+搞笑 |
      
      ### 望远镜拉期待法
      
      倒计时结构:不断描写"发现→观测→打捞→失败→坠落"——读者都知道子弹会射向主角→巨大期待。
      
      ### 分卷节奏(天才俱乐部案例)
      
      每二三十万字一卷,有明确核心与目标;卷内多线头纠缠打破线性"起承转合";"起"必须抛钩子;每卷结尾为下卷埋线。
      
      ### 时代适应
      
      从强冲突开场→人情冷暖+基础需求展开;文风更细腻;画面剪辑感极强;套路熟悉但两三章速战速决。
      
      ---
      
      ## 卖点循环设计
      
      ### 完美循环(天灾信使)
      
      开小号装逼打脸赚好处→拿好处做主线任务→获得奖励→给小号提升武力→新地图装逼打脸→循环。关键:每次内容不同;情感进展拉住期待感;最终暴露小号身份=装出去的逼兜回主角。
      
      ### 信息密集型循环(苟在初圣宗)
      
      按"轮回"拆分,每轮回=完整故事。力量体系不崩即可无限拉长。
      
      ### 直播流循环
      
      有目标(确定性预期)→ 有对抗(短平快胜负)→ 有阶段性(任务升级)→ 整活正反馈
      
      ---
      
      ## 经典作品拆解
      
      | 作品/作者 | 核心拆解点 |
      |-----------|-----------|
      | 遮天(辰东) | 九龙拉棺倒计时+同学打脸+前女友回归+进荒古禁地(不断切换期待类型) |
      | 夜无疆(辰东) | 大病初愈+无粮+邻居救济→画面剪辑感→熟悉套路两三章速战速决 |
      | 我吃西红柿 | "没有特点"=普适性强;敢写升级突飞猛进+敌人摧枯拉朽;前提三观正 |
      | 赘婿(香蕉) | 写诗装逼教科书:铺设人际→构架舞台→诗作逐层传播→多轮震惊→态度闭环 |
      | 炮火弧线 | 煽情+身临其境;军文核心=国与国、信仰与信仰 |
      
      ---
      
      ## 拆书实操技巧
      
      ### 从期待出发
      
      - 先知先觉类金手指使用场景:介绍世界观/提升实力/装逼前/对比高手前/介绍新人物前
      - 过渡情节写收获/暧昧/小情节,无冲突可跳到冲突前一秒
      
      ### 情绪折线图
      
      横轴=章节,纵轴=情绪正负。控制节奏=控制"富有戏剧性的信息团"出现速度。信息团=能造成情绪转折的情节。
      
      ### 逐段功能标注法
      
      对照原文逐段标记叙事功能(铺垫/转折/高潮/过渡),追踪情绪走向、困境设计、伏笔衔接,输出可复用框架;禁止复制剧情内容或表达。
      
      ---
      
      ## 对标书选择
      
      | 错误 | 原因 |
      |------|------|
      | 只拆一本对标书 | 至少5本同网站同题材同类型参考 |
      | 只拆唯一头部爆款 | 头部作品可能依赖不可复制的作者品牌或历史流量,优先选择同平台同题材、数据稳定、非唯一头部的可复制样本 |
      | 跨网站对标 | 不同网站读者群体不同,写法差异大 |
      
      流程:广泛收集同题材样本→定方向→定参考书5-10本→定对标书
      
      ---
      
      ## 抽象拆书法:三幕式
      
      | 幕 | 核心 |
      |----|------|
      | 第一幕-建立 | 暗示矛盾→主角+金手指→惊人意外→情绪上下拉扯→主线确立 |
      | 第二幕-对抗 | 新世界→更强对手→中点反转→情绪拉扯(下行后必有上行) |
      | 第三幕-决战 | 线索收束→矛盾极限→关键选择(体现人物弧光)→决战+主题升华 |
      
      ---
      
      ## 影视拉片与情绪控制
      
      - 人物态度转变=最能体现情绪
      - 打斗间穿插反应但控制字数,否则截断节奏
      - 用情绪展示境况(感叹/震惊/窃喜)表现优劣
      - 每个动作段落要带出情绪变化
      
      ---
      
      ## 多线叙事与结构循环
      
      ### 多线拆解维度
      
      事件节点(新人物/冲突爆发)/ 情绪链条节点(一句话概括)/ 事业线 / 感情线
      
      感情线模板(单角色):相遇态度→关键转折→态度递进(厌恶→好奇→心动→沦陷)→推倒引爆点
      
      ### 循环层次感
      
      同一框架循环时每次内容必须不同(地图/角色/冲突/情感细节更新)。力量体系不崩可无限拉长。
      
      ---
      
      ## 题材实战
      
      | 题材 | 核心要点 |
      |------|----------|
      | 历史文 | 本质=类同人写法;书名"朝代+开局事件";核心梗=先知先觉震惊名人+改变历史 |
      | 军事文 | 卖点=煽情+身临系境;核心矛盾=国与国/信仰/意识形态 |
      | 种田/脑洞 | 密集小爽点(收藏/能力/面板/震惊);面板设定要细致 |
      | 直播流 | 目标(确定性预期)+对抗(短平快)+阶段性+爽点写法:先告诉读者打算怎么装逼,再装个更大的 |
      | 两界穿梭 | 书名即卖点;核心梗=在A世界低调用B世界资源;长生凡人流:时间尺度大+配角代际更替 |
      | 重生流 | 天然期待感;前世被辜负→这一世报复;爽点可重复使用每次形式不同;番茄标准=简单直给打标签 |
      
      ---
      
      ## 桥段词表
      
      给 Stage 3 的 `剧情/{标题}.md` 打「桥段标签」用的常见桥段词。命中就标,逗号分隔;不在表内但本书确有的桥段照实写,不硬凑、宁缺毋滥。**可扩展种子,不是封闭枚举。**
      
      | 题材 | 常见桥段 |
      |------|----------|
      | 都市/言情 | 打脸、扮猪吃虎、马甲掉落、先婚后爱、追妻火葬场、破镜重圆、替身、契约婚姻、萌宝助攻 |
      | 玄幻/修仙 | 废柴逆袭、扮猪吃虎、越级战斗、夺宝、护短打脸、绝境翻盘、机缘逆天 |
      | 历史/种田 | 先知先觉、改变历史、科举经商崛起、攒钱起家、技能点亮 |
      | 悬疑/无限 | 信息差、反转打脸、规则怪谈、身份揭穿、复盘解谜 |
      | 系统流 | 任务奖励、签到、面板升级、强制任务、积分兑换 |
      | 重生/穿越 | 前世复仇、改命、身份调换、双重生、未卜先知 |
      | 世情/家庭 | 极品亲戚、婆媳争斗、偏心打脸、维权反击、自我觉醒 |
      | 直播/两界 | 直播整活、低调用资源、两界差价、围观震惊 |
      
      ---
      
      ## 人设塑造
      
      ### 第一章立人设
      
      小事件展示性格→贴标签确立;紧急事件快速带入剧情;女角色第一步反应要体现性格。
      
      ### 日常体现人设
      
      重复出现符合人设的行为;配角功能化(嫉妒型:得意→嫉妒;崇拜型:质疑→佩服);配角情绪转变要写出。
      
      ### 开篇逻辑
      
      被动入局→交流了解世界观→陷入困境→明确目标→展示金手指→通过金手指收获
      
      ---
      
      ## 素材转化
      
      从热点提取:识别核心冲突→定主线方向→用经典框架填充素材。
      
      ### 文体决定行文
      
      | 文体 | 行文 |
      |------|------|
      | 打脸爽文 | 节奏快、描写少、言语凝练 |
      | 感情流 | 细水长流、细腻描写 |
      
    • final-checks.md 1.4 KB
      # 收尾检查与维护者回归
      
      全部拆完(或本次要拆的一段拆完)的时刻读本文件的「收尾检查」;「维护者回归」只在改动本 skill 时用。
      
      ## 收尾检查
      
      检查全部通过后,按 [author-facing.md](author-facing.md)「全部拆完」给作者汇报;下面这些检查结果不写进汇报。
      
      - 再运行检查器和计划器;完整项目应 `direct_use`,继续意图应无待处理批次;
      - 对比受保护路径 hash:旧 `章节/`、`剧情/`、`角色/`、`设定/`、`文风.md`、`拆文报告.md` 不得被增强或恢复流程改写;
      - 允许变化的旧项目文件只有 `_progress.md` 受管状态区和 `_analysis_cache/` 新证据;
      - 检查旧 `schema_version` 原值;
      - 检查新投影的情节点序列、主题、基调、类型和“涉及”字段能被导入与写作流程读取。
      
      ## 维护者回归
      
      改动本 skill 后必须回归:黄金三章、逐章摘要、情绪模块、节奏、角色、设定、文风、导入、对标和写作仍可用;旧完整项目直接使用;部分项目只补精确缺章;混存项目报告来源但不重拆;增强/恢复不改旧产物与原 schema。报告未执行的真实模型或跨平台检查,不得用静态 fixture 冒充。
      
      联合验收用 [semantic-acceptance-fixtures.md](semantic-acceptance-fixtures.md) 的六项 0–2 分表,真实模型结果至少 10/12 且无硬失败才算语义通过。
      
    • index-rebuild.md 2.9 KB
      # 原文变化与章号对不上
      
      `build_chapter_index.py` 因原文变化非零退出,或返回 `chapter_mapping_ambiguous`、`index_would_shrink` 时读本文件;把脚本的 `author_message` 按 [author-facing.md](author-facing.md)「停下来或出错时」转告作者。
      
      同源索引直接复用且不重写。原文变化时先非零退出;人工确认后加 `--rebuild`。重建沿用已有索引的章号口径:已有索引第 1 章是正式章节、而原文开头有楔子/序章/第0章(即上次用了 `--fold-prologue`),自动照样并入,无需再加参数;重建出的前面各章与已有索引对不上(`chapter_mapping_ambiguous:position=N` 或 `index_would_shrink`)时不写索引,返回 `author_message`。追加新章时旧章 hash 保持稳定,输出仅列出新增或内容变化的 `pending_chapters`。改动已拆章节的原文只会让覆盖它的批次缓存失效、下次计划重读该批;已有摘要不刷新——要刷新哪章就删掉哪章的 `章节/第N章_摘要.md` 和覆盖它的 `_analysis_cache/批次-*.md` 再续跑(只删摘要会从缓存原样补回)。旧成果按旧章号命名,写新索引前逐一核对:旧摘要一律参与;黄金三章在 `_progress.md` 来自旧版(有「章节边界」表,或没有运行状态受管区)时参与,本次 Stage 1 按索引写的不参与。有旧版「章节边界」表时逐章定位:起始行正好是某章标题行就直接认定;否则按标题找(统一全半角,去掉章号前缀和「(求收藏)」「【二合一】」这类尾注,允许包含关系;标题只有章号时比章号),多个候选取离旧起始行最近的;全部对上才放行;没有表时,原文首章不是第 1 章就无法确认。对不上时不写索引,返回 `chapter_mapping_ambiguous` 和 `author_message`;`plan` 遇到已建好的错位索引同样拒绝。作者的三个选择:
      
      1. **按旧章号继续(推荐)**:`build_chapter_index.py ... --fold-prologue`,开头的楔子/序章/引子/前言/第0章并进第一个正式章节,编号回到旧版口径,旧成果原样复用;输出里的 `folded_into_first_chapter` 列出被并入的章节标签(如「楔子」「第0章」)。楔子内容不会单独拆,要在报告「还不确定的地方」说明。
      2. **楔子单独成章**:把除 `原文/` 外所有按旧章号写的产物——`章节/`、`剧情/`、`角色/`、`设定/`、`人物关系图/`、`快速预览.md`、`概要.md`、`拆文报告.md`、`文风.md`、`_analysis_cache/批次-*.md` 和 `_progress.md`——挪进 `_analysis_cache/legacy/旧章号/`(挪走,不删除),再从 Stage 0 按新章号重拆。旧库只拆到黄金三章时代价最小。
      3. **换一个新目录整本重拆**。
      
      `chapter_index.csv` 已按错误口径建好时,先删掉它(纯机械章节表,可随时重建),再按选择重建;`--rebuild` 只沿用已有索引的口径,不能用来改口径。
      
    • inspiration-library.md 9.7 KB
      # 三层灵感库管道
      
      story-long-analyze 的可选后置管道:把 Stage 3 已抽象好的 EM 机制卡组织成可跨书检索的机制资产。不重新拆文,不写新故事;**单书拆文不自动入库**——只在用户提出「灵感库 / 提炼灵感 / 跨书灵感聚合 / 更新灵感库」时运行。
      
      ## 复用优先(第一原则)
      
      机制与案例分析在拆文主管道已完成一次:`剧情/情绪模块.md` 的 EM 卡是机制全文的**唯一维护点**,剧情单元是案例的唯一维护点。本管道只做登记(IA)、单书归并(NM)、跨书聚合与打标(CBA),任何一层都**不复制** EM 卡正文;卡内引用一律用 `书名/EM-xxx` 式裸 ID,需要读原文时按 ID 经 `灵感索引.csv` 回查(或用 `resolve` 子命令),禁止在卡里堆路径链接——`validate` 会机械拒绝。
      
      ## 输入边界
      
      只读:各书 `剧情/情绪模块.md`(EM 完整卡+其他机制索引)、按 EM 卡「关联节奏/证据来源」定点查证的 `剧情/节奏.md` 与剧情单元、现有 `灵感库/灵感索引.csv` 与 NM/CBA 卡。
      
      **禁止读取 `原文/`、`chapter_index.csv` 与逐章摘要正文。** 一本书没有 `剧情/情绪模块.md` 或完整卡字段不全时,停止并返回 `repair_action`:先用 `--intent enhance` 补跑 Stage 3+(从既有摘要聚合,原文读取可为 0),不得在灵感层代写机制卡。原文追加或局部变化后,主管道 rebuild 并更新 EM 卡,再重跑登记即可(幂等);`validate` 的 `ia_em_set_mismatch` 会指出未重登记的书。
      
      ## 公共目录
      
      从当前工作区向上定位最近的现有 `灵感库/`;没有时在工作区根创建:
      
      ```text
      灵感库/
      ├── 灵感索引.csv                      # 三层统一索引,也是 ID → 位置的唯一解析表
      ├── 单小说灵感合并/{书名}/NM-001.md    # 仅在确有合并时创建
      └── 跨书灵感聚合/CBA-001_{机制名}.md
      ```
      
      - 原子灵感 = Inspiration Atom,ID `IA-001`。**只是索引行,没有文件**——对一张 EM 卡的登记,编号镜像 EM 编号。
      - 单小说灵感合并 = Single-Novel Merge,ID `NM-001`。
      - 跨书灵感聚合 = Cross-Book Aggregation,ID `CBA-001`。
      
      ## 管道三步
      
      ### 1. 原子灵感登记(确定性机械步骤)
      
      ```text
      {PYTHON} "{story-long-analyze skill 根}/scripts/inspiration_index.py" register-atoms --root "{灵感库}" --module "{拆文目录}/剧情/情绪模块.md" --book "{书名}"
      ```
      
      - 完整卡(读者想看什么/情绪链/戏剧单元/可替换项/不可照搬 五字段齐)登记为 `grade=full`;「其他机制索引」条目登记为 `grade=index`。
      - 五字段缺失报 `em_fields_missing`,字段在而值空报 `em_field_value_empty`(多行值合法:值可写在字段名行之后、下一个字段/标题之前的列表或段落里)。机制字段(标题/读者想看什么/情绪链/戏剧单元/可替换项)的专名检查分两级:本卡「不可照搬」已点名该词却仍在抽象字段使用、或 `可替换项` 写成「专名→任意X」(报 `replaceable_antipattern`),都是高置信真引用,报 `source_specific_name_in_mechanism` error;仅角色卡文件名子串命中而无佐证的降为 `leak_suspect` warning 并列出命中字段,人工复核。都指回 Stage 3 修复;「不可照搬」字段本身例外——点名原书专名正是它的职责。
      - 泄漏门的角色名单取自工作区 `拆文库/{书}/角色/`。工作区从 `--root` 向上探测含 `拆文库/` 的目录,探测不到报 `workspace_not_located`(可加 `--workspace` 显式指定),不得回退空名单静默通过;输出恒带 `character_roster` 数,书目录在而 `角色/` 缺失报 `character_roster_missing` warning,提示人工确认无专名。
      - 所有卡的所有问题一次收集报全(`errors` 数组),有任一 error 即整体不写盘。落盘 `剧情/情绪模块.md` 后先用只读的 `check-atoms`(与 register-atoms 同参数)自检:输出卡数、索引数、名单规模与全部问题,不写任何文件。
      - 一次原子写入、重跑逐字节幂等;多本书逐本串行登记,不要并行跑 `register-atoms`。`--module` 必须是 `拆文库/{--book}/` 下的文件(`module_book_mismatch`);卡头须为 `### EM-xxx 名称`,缺名称报 `em_title_missing`,认不出的 EM 标题行报 `em_header_unrecognized`,同卡重复字段报 `em_field_duplicate`——三者都意味着卡被截断或并进了上一张。本步不得调用模型重新概括机制。
      
      ### 2. 单小说灵感合并
      
      只在一本书内确有 ≥2 张 EM 卡同构时创建 NM;孤立机制不建 NM。卡内容是**合并增量**,机制链不抄写:
      
      ```markdown
      # NM-001:{簇名}
      
      - 合并对象:EM-001、EM-003
      - 合并理由:{为何同构,一两句}
      - 差异形态:{各卡触发物/场合/规模的不同}
      - 本书特有偏置:
      - 共同反例/边界:
      ```
      
      ### 3. 跨书灵感聚合
      
      把各书 IA/NM 与现有 CBA 轻量索引比较,只读可能匹配的 active CBA。已建库后的增量先跑只读的 `coverage`:它列出尚未进入任何 active CBA 闭包的原子与现存单书假设卡——聚合比较只对 uncovered 原子做,顺带复核单书假设卡是否因新证据升级为「跨书重复验证」;uncovered 为空即无事可做,不重读已覆盖的 EM 卡。CBA 是**唯一供写作反复读取的卡**,必须自包含跨书归纳的抽象(聚合新产出,不是 EM 原文复制):
      
      ```markdown
      # CBA-001:{机制名}
      
      - 验证状态:{单书假设/跨书重复验证}
      - 来源:{甲书/EM-001、甲书/NM-002、乙书/EM-003}(溯源按 ID 查 灵感索引.csv 或用 resolve)
      
      ## 共同机制链
      {触发 → 读者认知/情绪变化 → 持续阅读动力 → 兑现}
      
      ## 可变参数
      - 人物功能位:/关系动作:/冲突载体:/兑现规模:/节奏位置:
      
      ## 可能适用于
      - 题材:/读者需求:/情绪:/关系动作(可空):/剧情功能:/节奏位置(可空):/适用阶段:/风险:
      
      ## 条件、反例与边界
      - 成立条件:/反例:/不可照搬:/误用风险:
      ```
      
      `novel_count`/`atom_count` 不手写进卡——由索引 `source_ids` 闭包自动核算并校验。只有一部书支持时,最多激活 3 张最有写作复用价值的 CBA 并标 `单书假设`;两部及以上独立来源才标 `跨书重复验证`。标签只表达适用性,一本书来源不能写成市场定论。
      
      ## 索引与受控标签
      
      `灵感索引.csv` 表头严格为:
      
      ```csv
      item_id,layer,title,source_book,path,source_ids,novel_count,atom_count,grade,tags,status
      ```
      
      - IA 行 `path` 为空、`grade` 取 `full/index`;NM/CBA 行 `grade` 留空;IA/NM 的 `tags` 留空,避免写作绕过聚合层。
      - `source_ids` 来源图:IA 行=单个 `EM-xxx`;NM 行=同书 ≥2 个 `EM-xxx`(`|` 分隔);CBA 行=`书名/EM-xxx` 与 `书名/NM-xxx` 混合列表(必须带书名前缀)。引用 NM 时其成员 EM 自动并入闭包,**不需要也不应该再展开列出**;`novel_count`=闭包内不同小说数,`atom_count`=闭包内不同 EM 数。
      - 标签序列化为 `轴=值1|值2;轴=值`。CBA 必填轴:题材、读者需求、情绪、剧情功能、适用阶段、风险;可选轴:关系动作、节奏位置。同义值先归一;不用「好看/有趣/高级」等不可执行词。
      - **标签值受控于库内 `灵感库/标签词表.md`**(每轴一节 `## 轴`、一行一值 `- 值`):建库首轮聚合把用到的值落盘成首版,此后打标先读词表。扩表走同义判定——候选新值逐一与该轴现有值语义比对,重合或从属就用现有值,确属新维度才追加一行;词表膨胀就是查询失效的前兆。query 命中靠值的精确匹配,写入侧与查询侧共用这一张表。
      - `validate` 校验:表头、ID 前缀、EM↔IA 集合一致、NM/CBA 闭包与计数、必填标签轴、单书 active CBA ≤3、验证状态标记、**卡内无路径引用**;词表存在时另拒表外值(`tag_value_not_in_vocabulary`)与缺必填轴的词表(`vocabulary_axis_missing`)——词表缺失时这两项不查(旧库兼容),应尽快补建;同义值靠扩表时的人工比对,不做机械子串判定。列数不对的索引行报 `column_count_mismatch`。
      - `query --tag 轴=值 …` 只筛 `layer=跨书灵感聚合` 且 `status=active`,核心轴(题材/读者需求/情绪/剧情功能/适用阶段)2 分、其余 1 分,无核心轴命中不返回,按分数/来源数/ID 稳定排序取 Top 3–8。返回值另带 `unmatched_tags`(请求值在全库该轴零出现)与 `axis_inventory`(所查各轴现存值):零命中先按它们纠词重查一次,仍零命中才算「库里没有」。
      - `resolve --ref 书名/EM-xxx --ref CBA-001` 把裸 ID 解析为可读位置——需要时才查,不预先展开进任何卡。
      
      ## 写作侧消费约定
      
      写作模块只读标签命中的 active CBA 全卡(Top 3–8),不沿来源下钻 EM/NM;CBA 不作文风样本,也不触发原文读取;同一 CBA 最多派生 2 个候选变化。消费入口只在 story-long-write 的开书、卷纲、细纲三处(适用阶段=设定/卷纲/细纲);逐章写前召回和写手 prompt 不读灵感库,灵感经细纲进入正文。
      
      ## 断点与禁止事项
      
      不设进度文件——索引与卡片现状就是状态;中断后重跑登记(幂等),用 `validate` 报错清单定位未完成层。禁止:回读原文、二次逐块语义分析、任何层复制 EM 卡机制全文、卡内路径链接、自动生成故事种子/角色组合/成稿桥段、复制原作专名与原句(跨书层连角色名都不得出现)、为凑数硬并语义不同的机制。
      
    • pipeline-ops.md 10.9 KB
      # 长篇拆文运行、提交与恢复
      
      ## 唯一状态与三个脚本
      
      生产运行只使用:
      
      1. `build_chapter_index.py`:建立机械章界和逐章原文 hash;
      2. `inspect_existing_assets.py`:只读识别旧成果、当前成果、缺章和修复阶段;
      3. `manage_analysis_run.py`:只读计划,并负责批次提交、拆分、恢复和阶段标记。
      
      Stage 4 另用 `render_relation_chart.py` 从 `角色/角色关系.md` 生成人物关系图,不参与运行状态。
      
      脚本输出只给你看。失败或需要作者决定时,脚本会带 `author_message`(大白话说明和选项),按 [author-facing.md](author-facing.md) 转述给作者;不要把 JSON、错误码或下文的分类、路径名原样贴给作者。
      
      `chapter_index.csv` 是机械章节边界唯一真源。批次和阶段状态只写在 `_progress.md` 的
      `story-long-analyze:runtime-state` 受管区。`_analysis_cache/` 保存完整结果和恢复证据,不承担状态库功能。
      
      既有项目中的 `schema_version: 2` 沿用且不修改;该值只供报告,不用于否定旧成果。`_progress.md` 不再保存机械章节边界镜像。
      
      禁止创建 `run-plan.json`、`batch-checkpoints.json`、逐批 JSON receipt 或 Stage receipt。计划始终打印到标准输出,由当前运行直接消费。
      
      所有命令使用实际 Python 与 skill 根路径:
      
      ```text
      "{PYTHON}" "{story-long-analyze skill 根}/scripts/{脚本名}.py" ...
      ```
      
      ## 1. 先检查目录
      
      ```text
      "{PYTHON}" "{story-long-analyze skill 根}/scripts/inspect_existing_assets.py" --root "{拆文目录}" --compact
      ```
      
      - 路径不存在、不是目录或不可读:非零退出,先修正路径。
      - 已存在的空目录:`empty / new_analysis`。
      - 完整旧项目:`direct_use`,不建索引、不读原文。
      - 部分项目:精确报告 `missing_semantic_chapters` 与 `missing_summary_chapters`。
      - 新旧投影混存:`mixed_sources: true` 并列明逐章来源,仍可直接使用完整项目。
      - `schema_version` 只报告,不参与否定旧项目,也不在检查或复用时改写。
      - `stage_repairs` 中的情绪、节奏和文风修复与 Stage 2 缺章分开处理。
      
      ## 2. 需要原文时建立或校验索引
      
      完整旧成果默认直接使用和纯旧成果增强无需索引。全新或部分完成才运行:
      
      ```text
      "{PYTHON}" "{story-long-analyze skill 根}/scripts/build_chapter_index.py" \
        --source "{拆文目录}/原文/原文.txt" \
        --output "{拆文目录}/chapter_index.csv" \
        --locator-path "原文/原文.txt"
      ```
      
      脚本只按 LF 计算物理行号,支持楔子、序章、第0章、任意正文起始章、番外、后记、多卷和中文大数。CSV 保存内部连续号、来源章号、卷、标题、行界、字符数、`chapter_sha256`、全源 hash 和解析器版本。
      
      同源索引直接复用且不重写。原文变化、重建后章号对不上或旧成果章号对不上时,按 [index-rebuild.md](index-rebuild.md) 处理。
      
      ## 3. 生成只读计划
      
      ```text
      "{PYTHON}" "{story-long-analyze skill 根}/scripts/manage_analysis_run.py" plan \
        --root "{拆文目录}" \
        --intent continue
      ```
      
      意图:
      
      - `continue`:补缺失语义章,以及缓存失效的已记录批次;已有语义但缺摘要时用旧成果投影;
      - `enhance`:只读既有拆文成果形成 `REUSE-{起章}-{止章}` 批次,原文读取数必须为 0。
      
      没有“整本重拆”意图:已有摘要永不覆盖。整本重拆就换一个新目录重新拆。
      
      逐批派发时加 `--next 1` 只取下一批,确认进度用 `--next 0` 只看 `remaining_batches` 与压缩成区间的 `summary_gaps`;全量计划每批约五百字符,整本书反复全量输出会白占主会话上下文。不带 `--next` 时输出全部批次。
      
      作者只要拆某一段时加 `--chapters 起-止`(可与 `--next` 同用):只规划这段里的批次,`remaining_batches` 只数这段,`summary_gaps` 仍是全书。这段拆完即停,不标 `stage2`;按 author-facing「全部拆完」只汇报这一段,再问作者是否接着拆其余章节。
      
      计划只存在内存和标准输出,`state_written` 必须为 `false`。每块最多 3 章、25,000 字符,同一章不能出现在两个原文块;计划原文读取数为 0 时不得派发原文任务;批次 ID 直接使用章节范围。`RAW` 只覆盖缺失章和缓存失效批次,`REUSE` 只读取计划列出的旧成果。黄金三章深拆属于已有语义成果,可以生成缺失摘要,无需再次读取前三章原文。
      
      ## 4. 执行与提交一个批次
      
      `chapter-extractor` 只处理计划中一个批次。**主会话不中转内容**:原文和批次结果都不经过主会话上下文。派发时照抄计划里这一批的字段,路径前加 `{拆文目录}/`,不另选、不推算:
      
      - `batch_id`、`input_kind`;
      - `source_files`(原文块是逐章 `source_locator`,子代理自己按行号读原文)、`chapter_chars`(每章字数)和 `min_plot_points`(每章情节点下限,按字数算好);
      - `input_file`:输出文件 `_analysis_cache/输入-{批次ID}.md`(不写系统 `/tmp`:Windows 没有,多本书同批号会互相覆盖);
      - `handoff_cache`:交接缓存,计划算好的「本批起章之前最近的已提交批次」,为空就不给;子代理只读其中 `### 跨批状态`。
      
      子代理把完整输出写进输出文件,只回一行 `BATCH_WRITTEN` 回执。主会话**不 Read 这份输入文件**,直接提交;提交被拒时重新派发同一批:上面的字段原样再给一遍(子代理补内容要按 `source_locator` 回看原文、按 `min_plot_points` 补情节点),再附错误码,让它用 Edit 只改出错处后再提交(结构整体错乱才整份重写)。提交成功后删掉这份输入。不论哪种派发方式,`commit` 都由主会话逐个执行,不并发跑。
      
      **派发方式**:默认第 2 档有限并行,不请作者在三档里选。作者问起能不能更快或更稳、或明确要求时,才按 author-facing「作者问起怎么拆」用白话解释并切换。三档都用计划给的 `handoff_cache`,它随提交进度自动落到对应的批次上。
      
      1. **串行**:上一批提交成功再派下一批,交接缓存就是紧邻的前一批。优点:剧情点、未决悬念和已确认别名逐批完整接续,Stage 3 合并最省事。缺点:最慢,墙钟时间随批数线性增长。
      2. **有限并行**:每轮同时派 3 批,同轮的交接缓存都是本轮开始前最近一个已提交批次;整轮提交完再派下一轮。优点:约快两到三倍,接续最多滞后一轮。缺点:同轮批次互相看不到对方新建的剧情点与别名,边界处可能重复建 ID,靠 Stage 3 跨块合并收拢。
      3. **不限批次顺序**:不等前批,同时在跑的子代理最多 12 个(宿主或账号上限更低时以环境为准);在跑的少于上限一半时,补派到上限。补派用 `plan --next {在跑数+补派数}`,跳过已在跑的批次号(计划只认已提交)。遇到限流、超时或子代理报错,把上限减半后再补派。优点:最快。缺点:接续最弱,剧情点与别名最依赖 Stage 3 跨块合并和 Stage 4 归并;并发越高,被限流、整批重跑的风险越大。
      
      作者没提、要求一次拆完 / 全量拆、多本书一起拆,或由 story-import 自动续跑时,都按第 2 档;第 1、3 档只在作者明确选择时使用。多本书同拆时按书轮流派,第 3 档的总并发也不超过同一上限。第 1、2 档用 `plan --next 1` / `--next 3` 取批。子代理不可用时主会话自己写批次:先读 [stage2-extraction.md](stage2-extraction.md),提取规则与包裹标记照其中「批次提交格式」:
      
      ```text
      "{PYTHON}" "{story-long-analyze skill 根}/scripts/manage_analysis_run.py" commit \
        --root "{拆文目录}" \
        --input "{拆文目录}/_analysis_cache/输入-RAW-4-6.md" \
        --batch-id "RAW-4-6" \
        --range-sha256 "{plan 输出值}" \
        --source-file "{plan 列出的来源}"
      ```
      
      `REUSE` 批次不传 `--range-sha256`。完整增强可以只输出 `REUSED_CHAPTERS` 与跨章观察;需要补摘要时输出同一套紧凑章节块。
      提交入口会再次检查 3 章与 25,000 字符上限;单个超长章仍允许独占。
      
      提交顺序固定:
      
      1. 在写文件前校验整批范围、标记、所有紧凑字段和情节点(原文块每章不少于 `min_plot_points`、最多 30,否则整批拒收;编号连续、每点带主题标签与基调行);
      2. 对 `RAW` 再算当前范围 hash,与计划值不一致就拒绝;
      3. 原子写入含完整模型输出和最终结束标记的批次缓存;
      4. 只创建缺失的 `章节/第N章_摘要.md`,任何已有摘要都保留,并在结果的 `kept_existing_summary_chapters` 里列出;摘要投影把每个情节点的主题、基调和类型映射到固定枚举(主题/基调映射不上写“其他”),保留“关键事件”“情节点”“涉及”“基调”等旧消费者字段;
      5. 最后更新 `_progress.md` 受管批次表为 `completed`;有阶段记录时按 Stage 3–6 状态重写受管区的 `最终状态`(旧项目改写原有行,不写第二行)。
      
      每条成功行记录章节范围、输入类型、原文范围 hash、状态和缓存路径。受管区外的 BOM、换行、作者备注及既有 `schema_version` 必须逐字节保留。
      
      ## 5. 失败、拆分和重试
      
      模型输出不完整时不提交。批次过大或连续失败时:
      
      ```text
      "{PYTHON}" "{story-long-analyze skill 根}/scripts/manage_analysis_run.py" split \
        --root "{拆文目录}" --batch-id "RAW-4-6"
      ```
      
      也可用 `--at 4` 指定左右边界。脚本在同一个 `_progress.md` 受管区把父块记为 `superseded`,写入两个相邻子块。重新运行 `plan` 后继续使用子块,不会按位置编号覆盖旧记录,也不会把子块重新合成父块。
      
      ## 6. 中断恢复
      
      先重新运行 `plan`。已满足以下三项的成功批次不会出现:
      
      1. 范围内摘要都存在;
      2. 批次缓存完整,最后一个非空标记为 cache end;
      3. `RAW` 状态行的范围 hash 等于当前索引计算值。
      
      如果缓存已完整,但摘要或进度最后一步尚未落盘:
      
      ```text
      "{PYTHON}" "{story-long-analyze skill 根}/scripts/manage_analysis_run.py" repair-progress --root "{拆文目录}"
      ```
      
      恢复只从完整、范围 hash 有效的缓存补缺失摘要并更新状态;不覆盖用户修改过的文件。缓存缺结束标记或范围 hash 失效时报告错误并重跑相应批次。
      
      ## 7. 阶段标记与后续时刻
      
      Stage 1 黄金三章与快速预览落盘后标 `stage1`;计划不再有批次、全部摘要落盘后标 `stage2`:
      
      ```text
      "{PYTHON}" "{story-long-analyze skill 根}/scripts/manage_analysis_run.py" mark-stage --root "{拆文目录}" --stage stage2
      ```
      
      Stage 3–5 的取料(`digest`)与标记见 [synthesis-inputs.md](synthesis-inputs.md),Stage 6 见 [style-profile-generator.md](style-profile-generator.md),全部拆完后的收尾检查见 [final-checks.md](final-checks.md)。
      
    • semantic-acceptance-fixtures.md 5.5 KB
      # 长篇拆文语义验收样例
      
      这些短文本为本项目自有合成样例,用于 review 和联合测试。每章只有一两句,过不了原文块每章 10 个情节点的提交下限,所以只用于 Stage 3+ 与模型语义评估:测试时让对应 Stage 处理原文,再按“应识别的关系”评估;不得仅以关键词或栏目存在判通过。
      
      ## 样例一:事件与多次披露
      
      ### 原文
      
      第一章,掌印官罗衡趁换值时把边军密函交给敌使,交易已经完成。叙述只写他洗掉指缝里的红蜡,没有解释来源。第二章,主角沈照发现封匣完好,罗衡却换了从不用的沉香。第三章,驿卒说昨夜有人持罗衡腰牌出城;罗衡声称腰牌早已遗失,读者第一次能把红蜡、香气和出城记录连起来,沈照仍认为可能是冒用。第四章,敌使被捕并交出密函,罗衡承认交易。
      
      ### 应识别的关系
      
      - 客观事件只有一次:罗衡在第一章完成背叛和交付。
      - 至少四个披露节点:红蜡异常、香气异常、腰牌证词与解释冲突、物证加口供确认。
      - 读者在第三章已高度怀疑,沈照仍保留另一解释;第四章双方才获得确认。
      - 分析要说明判断怎样从“无解释细节”变为“可连接线索”再变成“事实确认”,不能把四个节点写成四次背叛。
      
      ## 样例二:安静告别的三维节奏
      
      ### 原文
      
      车已停在门外,阿芹只需要上车。祖母替她缝好松开的袖扣,又把针线盒里最后一颗旧铜扣塞进她掌心。两人谈的都是路上会不会下雨,谁也没提阿芹此去可能多年不归。作者用三页写针穿过布、手指停顿和几次被岔开的道别。场景结束时,阿芹上车,祖母仍站在门槛内。
      
      ### 应识别的关系
      
      - 事件推进较低到中:外部结果主要是离家成行,但关系进入长期分离的新状态。
      - 情绪强度高:铜扣、回避直说和动作停顿共同制造不舍,不等于“因为是告别所以悲伤”。
      - 篇幅展开度高:一个简单动作被细节、停顿和潜台词展开;应解释展开服务于压住直白告别。
      - 三个分数必须各有证据和理由,不能用“慢节奏”概括全部。
      
      ## 样例三:因果归属不只属于主角
      
      ### 原文
      
      顾野要在天黑前把药送过关桥。守桥人因旧债扣下他,他第一次交涉失败。同行的秋禾主动拿自己的通行牌作抵押,守桥人才放行。两人刚过桥,山洪冲断桥面,追兵被隔在对岸,秋禾也永久失去通行资格。顾野救到了病人,却欠下秋禾无法立刻偿还的人情。
      
      ### 应识别的关系
      
      - 起始目标:顾野送药;阻碍:旧债导致关桥受阻。
      - 改变局面的关键行动由秋禾作出;山洪是后续放大结果的外部事件。
      - 收益包括送药成功和摆脱追兵;代价由秋禾先承担,顾野新增关系债务。
      - 后续行动空间改变:秋禾不能再合法通关,顾野必须处理欠债。不得反推为顾野预先利用山洪。
      
      ## 样例四:机制的成立与失效
      
      ### 原文与变体
      
      原场景中,学徒周宁被诬陷篡改账册。师父一向允许学徒当众核账,三日前又让众人各自封存底稿。周宁在全体同门前拿出封条完整的底稿,对照出诬陷者新增的一笔。师父先前承诺“账目说话”,因此公开认错并恢复周宁资格。
      
      变体中,掌柜长期以惩罚控制伙计,底稿只由掌柜保管,围观者也依赖掌柜发薪。即使伙计拿出一张抄本,众人也可能认为它是伪造品,不敢公开站队。
      
      ### 应识别的关系
      
      - 原机制不是抽象的“先压后扬”,而是公开质疑 → 可独立验证的旧证据 → 权威受先前承诺约束 → 公开纠错。
      - 成立条件包括核账惯例、多人独立封存、师徒关系仍承认程序、见证者有表达空间。
      - 可替换的是行业、证据载体和具体诬陷;不可随意删掉独立验证与权威自我约束。
      - 变体失效源于证据控制和关系权力不同,不应硬套同一兑现。
      
      ## 样例五:资料不足保留未知
      
      ### 原文
      
      密室地面有一串湿脚印,窗闩从内扣住。侦探只说“有人希望我们先看窗户”,本段到此结束。正文没有出现暗道、机关或凶手身份。
      
      ### 应识别的关系
      
      - 可确认:湿脚印、内扣窗闩和侦探认为视线被引向窗户。
      - 不能确认:存在暗道、凶手从窗户进入、脚印由凶手留下、侦探已经知道答案。
      - 未知项写明证据缺口和待后文确认,不用类型小说惯例补齐。
      
      ## 评价表
      
      每项 0–2 分:0 为错误或缺失,1 为部分正确,2 为关系清楚且有证据。总分至少 10/12,且无硬失败,才算通过。
      
      | 项目 | 2 分标准 |
      |---|---|
      | 因果与归属 | 目标、阻碍、改变局面的主体/事件、得失和后续约束连接正确 |
      | 事件与披露 | 客观事件不重复,多次披露和判断变化对应正确 |
      | 知识边界 | 读者与关键角色在各节点的已知/未知分开 |
      | 三维节奏 | 事件、情绪、篇幅分别解释并允许高低组合不同 |
      | 机制迁移 | 写出期待、铺垫、关系/资源/信息条件及失效原因 |
      | 证据纪律 | 结论可定位,未知不被常识或类型惯例填满 |
      
      硬失败包括:把披露当事件重复登记;把配角或外部事件的作用归给主角;用分数替代理由;把未知写成事实;为旧项目伪造当前主产物;主报告与底层事实相互冲突。
      
    • stage1-golden-chapters.md 6.4 KB
      # Stage 0–1:概要初稿与黄金三章
      
      开头时刻(Phase 1 到 Stage 1 停下来问)读本文件。作者读到的 `快速预览.md` 与停下来问的话术见 [author-facing.md](author-facing.md)。
      
      ## Stage 0 概要(thin first-pass)
      
      > **范围说明**:Stage 0 只能基于章节标题 + 卷段结构 + 抽样开头/结尾产出 thin first-pass(~200 字)。**禁止**在此阶段写出 500-1000 字 plot-aware 概要——读取范围不足以支撑那个密度。完整版在 Stage 5 落盘覆盖(见 [stage5-report.md](stage5-report.md)「完整概要」)。
      
      输出 `概要.md`:总字数{X}万 | 总章数{N} | 题材{类型}
      
      | 卷/段 | 章节范围 | 章数 | 预估字数 |
      |-------|----------|------|----------|
      | {卷名} | 第1-X章 | {N} | {W}万 |
      
      首版概要(~200字,thin first-pass):{从章节标题 + 第1章+末章抽样归纳出的核心驱动 + 主角名 + 大致设定,不要硬凑剧情转折}
      章节索引:| 章节 | 标题 | 字数 |
      
      > Stage 5 完成后会就地覆盖此文件的「首版概要」段为「全书概要(500-1000字 plot-aware)」。章节索引和卷段表保留不变。
      
      ## Stage 1 黄金三章
      
      黄金三章拆为三个单章文件,每章一个:`章节/第1章_深度拆解.md`、`章节/第2章_深度拆解.md`、`章节/第3章_深度拆解.md`。每个文件按以下模板输出。
      
      字数约{X}字 | 核心事件:{一句话}
      
      **开篇钩子**(第1章看前500字):类型{悬念/冲突/反差/信息差/代入感} | 手法{描述} | 效果{强/中/弱}—{原因}
      
      **人物出场**:{列表} | 主角塑造{直接描写/对话/行为/他人评价} | 第一印象{描述}
      
      **世界观铺设**:透露{信息} | 隐藏{信息} | 方式{对话/旁白/事件}
      
      **结构拆解**:| 段落范围 | 功能{铺垫/冲突/爽点/收尾} | 字数 |
      
      **爽点分析**:类型{装逼打脸/逆袭反转/获得机缘/信息差碾压/情感满足} | 铺垫{X}字 / 释放{Y}字 / 铺放比{Z:1} | 情绪曲线
      
      **反应层拆解**(如有):
      
      | 层级 | 角色 | 反应 | 篇幅 |
      |------|------|------|------|
      | 质疑/轻视 | | | |
      | 震惊/打脸 | | | |
      | 强者重评 | | | |
      | 扩散传播 | | | |
      
      反应篇幅比{N:1} — 低于1.5 = 爽感不足
      
      **释放后追证**(如有):爽点释放后反派真实态度?{行为+原文引用}
      
      **冲突升级**(如有):{行为} | 升级层级{口头→经济→暴力} | 对角色画像影响
      
      **背景信息揭示**(如有):揭示{什么} | 叙事功能{推翻预设/重新解读/铺垫新线/认知偏差} | 对主线影响
      
      **章尾钩子**:类型{悬念/反转/新信息/新角色} | 内容{描述} | 期待度{强/中/弱}
      
      **可借鉴要素**:{可复用技巧}
      
      > 第二、三章额外关注:信息密度 / 冲突升级(vs上章) / 节奏变化 / 爽点间隔字数。
      > 反派若为非人形(灵气复苏/末世/国运等抽象对抗型),在「爽点分析」处改用抽象对抗型路由:核心对抗面{描述} | 紧迫感来源{描述} | 升级机制{描述} | 叙事替代{用什么替代传统打脸}。
      
      同一次黄金三章阅读优先另写 `_style-sample.txt`,减少 Stage 6 的定点取样;旧项目没有该缓存时,Stage 6 仍可按索引选择 4–6 章并只读取对应行段:
      
      ```text
      <!-- STYLE_SAMPLE:v1 -->
      ## 片段 A
      source_locator: 原文/原文.txt:L{起始行}-L{结束行}
      基调: {紧张/压抑/轻松/温馨/热血}
      示范点: {句长节奏/标点/对话潜台词/动作与反应等}
      {300—500字连续原句}
      ```
      
      共选4—6段,允许同章多段,但范围不得重叠;只保留用于表达分析的最小样本,不建立第二套剧情事实。
      
      ## 快速预览报告
      
      Stage 1 停靠点交付物 `拆文库/{书名}/快速预览.md`,模板见 [author-facing.md](author-facing.md)「快速预览.md」。字段只取 Stage 0/1 已产出数据(概要.md + 黄金三章深度拆解),不引入需 Stage 2-6 才能算出的内容;「基本信息」「黄金三章评分」两节与终态 `拆文报告.md` 同名同结构。终态生成后不删除 `快速预览.md`。
      
      ## 通用速查
      
      ### 爽点类型
      
      | 类型 | 定义 | 例子 |
      |------|------|------|
      | 装逼打脸 | 实力碾压被轻视的人 | 「你也配?」 |
      | 逆袭反转 | 绝境翻盘 | 废材觉醒 |
      | 获得机缘 | 得到别人没有的 | 捡到神器 |
      | 信息差碾压 | 读者和主角知道别人不知道的 | 重生先知优势 |
      | 情感满足 | 人物关系发展 | 告白/团聚/认可 |
      | 扮猪吃虎 | 隐藏实力突然展露 | 弱者实则是强者 |
      
      ### 钩子类型
      
      | 类型 | 定义 | 示例 |
      |------|------|------|
      | 悬念钩 | 未解之谜 | 「剑为什么认他为主?」 |
      | 冲突钩 | 直接对抗 | 开篇追杀 |
      | 反差钩 | 违反常识 | 「穿越第一天就破产」 |
      | 代入钩 | 读者共鸣 | 相似困境 |
      | 信息差钩 | 读者知道角色不知道的 | 「他还不知道面前是……」 |
      
      ### 爽点循环结构
      
      三层微观循环:
      - **铺垫层(蓄势)**:压抑/困境/信息差制造情绪缺口
      - **释放层(爽感)**:展露实力/打脸/碾压,强度由反应层数和篇幅决定
      - **衔接层(承转)**:旧循环结束埋新钩子
      
      反应层递进:质疑/轻视 → 震惊/打脸 → 强者重新评价 → 扩散效应
      反应篇幅比 = 反应总字数 / 装逼字数。低于1.5 = 爽感没接住。
      
      ### 衔接过渡模式
      
      | 模式 | 机制 | 示例 |
      |------|------|------|
      | 打完小的来大的 | 释放层对手引出更强对手 | 打败弟子→师父找上门 |
      | 胜利发现隐患 | 释放成功暴露新问题 | 赢了比赛→发现中毒 |
      | 身份暴露/反转 | 释放中身份被识破 | 打脸后被认出真实身份 |
      | 利益升级 | 成果指向更大目标 | 线索→更深层宝藏 |
      | 关系突变 | 释放改变关系格局 | 救的人变仇家后代 |
      
      ### 题材爽点形态
      
      | 题材 | 释放层形态 | 反应层来源 | 铺垫层缺口 |
      |------|-----------|-----------|-----------|
      | 玄幻/修仙 | 实力碾压/境界突破 | 同门/对手/长老震惊 | 功法残缺/资源匮乏/血脉压制 |
      | 都市 | 预测准确/身份揭露 | 商业对手/前女友/权贵 | 破产/被辞/被退婚 |
      | 系统文 | 解锁稀有技能/任务奖励 | 系统稀有度标注 | 任务失败惩罚/技能冷却 |
      | 历史 | 以弱胜强/先知优势 | 谋士/帝王/敌将震惊 | 信息不对称/身份低微 |
      | 悬疑 | 真相揭露/反杀 | 被揭穿者崩溃/读者恍然 | 线索误导/嫌疑人洗白 |
      
    • stage2-extraction.md 13.6 KB
      # Stage 2:批次提取规则与模板
      
      子代理可用时由 `chapter-extractor` 按自己的定义提取,主会话只按 [pipeline-ops.md](pipeline-ops.md) 派发与提交,不读本文件。子代理不可用、主会话自己写批次时读本文件。
      
      ## 提取方法
      
      先运行 `manage_analysis_run.py plan`,只执行标准输出中的内存计划。Stage 2 不再存在逐章独立调用,也不落盘计划文件。
      
      ### A. 已有成果批次
      
      对 `existing-results` 批次只读取计划列出的旧拆文文件:
      
      1. 上游 `章节/第N章_摘要.md` 优先;
      2. 没有摘要时复用黄金三章;
      3. 同一章只选择一个优先来源。
      
      从旧成果二次提取剧情点、关键事件与披露候选、关系和三维节奏。不得打开原文,不得为了统一格式重写旧逐章文件。旧资料没有的信息留空或写“旧资料未提供”。
      
      ### B. 未拆原文章节块
      
      对 `raw-original` 批次一次读取一个不重叠的连续块:
      
      - 普通短章通常 3 章;
      - 长章或信息密集时 1-2 章;
      - 每块最多 3 章,默认同时受 25,000 字符上限约束;
      - 遇到卷界、来源缺口或空章必须断开;
      - 每章在全书运行计划中只属于一个原文块。
      
      同一次理解产生两层结果:
      
      1. 每章紧凑事实:概要、因果、信息变化、状态变化、三维分数、钩子、证据和情节点(一般 10–20 个、最多 30,短章按字数降下限);
      2. 批次观察:剧情点、关键事件与披露候选、关系、三维异常点和跨批状态。
      
      逐章兼容文件由脚本从紧凑事实机械生成;批次缓存保留全部逐章紧凑事实和跨章观察,作为恢复证据。全局聚合用 `digest` 取跨章观察与所需逐章字段,不整份读缓存,也不逐个打开章节文件。不得第二次读取该块来生成逐章文件,也不得把 50-60 章交给一个 agent 后让它自行拆成多个“批次”。
      
      ### C. 剧情点是主要分析单位
      
      每个剧情点记录:
      
      | 字段 | 说明 |
      |---|---|
      | 章节范围 | 当前可确认的起止章;允许跨批延续 |
      | 起始目标 | 谁想做什么 |
      | 阻碍 | 人物、规则、资源、信息或环境阻碍 |
      | 变化来源 | 改变局面的选择、行动或外部事件 |
      | 局面变化 | 目标、风险、资源、关系、知识或行动方向的前后差异 |
      | 得失 | 收益归谁、代价由谁承担 |
      | 后续影响 | 结果怎样促成或限制后续行动 |
      | 状态 | `open` / `closed` |
      | 证据 | 原文模式用 source locator;已有成果模式用资料路径 |
      
      读取批次不等于剧情点。一个批次可以出现多个剧情点,一个剧情点也可以跨批次。跨批延续时继承剧情点 ID 和当前状态;Stage 3 再统一合并、拆分和校验边界。
      
      ### D. 兼容逐章资料
      
      逐章投影只保留现有导入、写作与查询实际使用的内容:概要、关键事件、因果、局面结果、涉及、信息变化、状态变化、三维节奏、章尾钩子、证据和情节点序列(逐点带基调)。取消逐章重复的长篇写法公式、角色复述和扩写技法长表。写法机制、信息差、关系与节奏集中在剧情点及 Stage 3 权威产物中。
      
      ### E. 跨章观察
      
      批次缓存固定包含:
      
      1. 剧情点;
      2. 仅限主线/关键转折的事件与披露候选;
      3. 有向关系变化;
      4. 事件、情绪、篇幅三维节奏的峰谷与分离点;
      5. 未闭合剧情点、悬念、关系状态和待核事项。
      
      角色实体只记录新别名和状态变化,不在每章重复生成人物小传。
      
      ### 原子提取六大铁律
      
      1. **双序分离**:逐章事实按文本披露顺序排列;客观事件另标故事内发生顺序,不能把倒叙或回忆挪成当前事件
      2. **客观白描**:记录"发生了什么"和结果,原文明说的起因照写,不推测未写出的动机,不用叙事框架词
      3. **信息保真**:不遗漏改变语境的关键细节
      4. **高度浓缩**:一个情节点一句话概括
      5. **复合合并**:为同一戏剧目的服务的连续微动作合并为一个情节点
      6. **客观事实**:提取客观事实,不做叙事分析
      
      **客观白描 vs 叙事框架词**:
      - 禁止:「通过对话,郑松得知张子豪在韩国训练」
      - 正确:「吴志斌告诉郑松,张子豪在韩国训练」
      - 禁止:「林风展现了自己的实力」
      - 正确:「林风三招击败对手,围观者倒吸一口凉气」
      - 禁止:「邵阳感到心碎和愤怒」
      - 正确:「邵阳目睹宋丽与人拥抱,表情由刺痛转为冷漠」
      
      ## 分块与主会话串行
      
      Stage 2 按运行计划分别建立旧成果批次和缺章原文块,每批调用一次 chapter-extractor;原文块同次返回逐章事实和跨章观察,旧成果批次只返回跨章观察。其他阶段只消费这些结果和可复用旧资料。
      
      > **先分清两个互不相干的轴**(混在一个「块」字里会让 >500 章出现差 30-100 倍的两套规格):
      > - **语义分块**=*在哪里切*。按叙事弧切,产出「块元数据」用于组织分析结果,块可以很大(一个 914 章的书通常 5-10 个弧)。它不决定一次读多少。
      > - **处理批次**=*一次读多少*。受上下文容量约束,原文块按索引字数切分,旧成果批次按资料体积切分。下表指的是**处理批次**。
      
      | 单章与上下文情况 | 读取批次策略 | 常见章数 |
      |------|------|--------|
      | 普通章节、上下文充足 | 连续读取并保留跨章状态 | 3 章/批 |
      | 长章、信息密集或上下文较小 | 缩小批次,不截断明显短因果链 | 1-2 章/批 |
      | 超长书 | 批次数量增加;语义弧仍独立于读取批次 | 仍按上述字数与上下文判断 |
      
      > 批次数由原文长度和上下文决定,不设“超过 500 章就固定 10-20 章/批”的第二套规则。
      
      **主会话串行模式(agent 不可用时的降级)**
      
      无法 spawn 子代理时由主会话按同一连续批次契约顺序处理。批次可因主会话上下文更小而缩短,但仍须同次产生逐章事实和跨章观察,不能退回每章一次调用。
      
      **不要做的事**
      
      - 不要把大语义弧整块塞进一次上下文。
      - 不要固定章数而忽略原文字数、上下文容量和章界。
      - 不要在 Stage 3、4、5 各自重新读取同一批原文。
      - 不要用跳读锚点冒充完整语义覆盖。
      
      ## 输出模板
      
      Stage 2 有两种互斥输入。`raw-original` 只处理运行计划列出的未拆章节块;`existing-results` 只读取旧成果做二次提取。两种结果都形成批次剧情点,只有 `raw-original` 生成新的逐章兼容投影。
      
      ### 未拆章节的紧凑语义结果
      
      `chapter-extractor` 在一次章节块阅读中按章输出以下紧凑字段。它是模型输出,不直接充当旧接口文件:
      
      ```markdown
      ## 第{N}章 {标题}
      
      **概要**:{60-160字,按文本披露顺序说明主要行动、原文给出的原因和结果}
      
      **因果**:目标:{谁想做什么}|阻碍:{具体阻碍}|行动:{改变局面的选择/行动/外部事件}|结果:{局面变化}|得失:{承担者及得失}
      
      **关键行动**:{真正改变局面的一个选择、行动或外部事件}
      
      **局面结果**:{行动后的目标、风险、资源或关系净变化}
      
      **涉及人物**:{关键人物,顿号分隔;没有写无}
      
      **信息变化**:{新披露内容|读者所知|关键角色所知或未知}
      
      **状态变化**:{角色/关系/资源/风险的净变化}
      
      **三维节奏**:事件{1-5}/5|情绪{类型+1-5}/5|篇幅{1-3}/3|{三轴差异的一句话原因}
      
      **章尾钩子**:{类型与具体等待;没有写无}
      
      **证据**:{source_locator+5-15字定位词}
      
      **情节点**:
      
      P{序号} **{标题}**:类型{转折点/信息揭示/冲突/解决/铺垫/行动/对话/状态变化} | {白描一句话:谁做了什么、结果如何;原文给出的起因一并写进来,不推测动机;埋伏笔的写出伏笔线索} | 涉及{全名,逗号分隔;无人物写无} | 地点{如明确,否则无} | 物品{如涉及,否则无} | 时间{如明确,否则无}
      {可选引用行:≤400字原文连续切片,或 `原文定位:{可 grep 的原句片段}`}
      
      主题标签{爱情/亲情/友情/权力/金钱/成长/复仇/悬念/搞笑/热血/日常/其他} | 基调:{紧张/轻松/悲伤/热血/爽/甜/温馨/恐怖/压抑/其他}
      
      ---
      
      P{序号+1} ……
      ```
      
      **情节点**是逐章事件序列,下游对标挑章、结构关键点定位和导入反推情绪弧线都按它读:
      
      - 原文块每章一般 10–20 个,按字数调节,最多 30;下限是计划给的 `min_plot_points`(字数÷200 四舍五入,至少 1、至多 10);旧成果补摘要下限 1 个,按旧资料写到的事件拆,不凑数;编号从 P1 连续。太粗(整段打架)丢细节,太细(举起右手)碎片化,恰当粒度是一个完整戏剧事件。
      - 严格按原文事件时间顺序,不重排、不归纳。标题 ≤15 字,不与白描同句。
      - 白描只写可见事实:不替角色补原文没写的情感、意图,不写“精彩”“气氛紧张”这类评价,不用“通过对话得知”这类叙事框架词;原文明说的起因和内心活动照写。
      - 字段名后不加冒号;`主题标签` 只填一个值;每个情节点紧跟自己的 `主题标签X | 基调:Y` 行,`基调:` 用全角冒号。
      - 引用只给关键转折、关键台词、写法样本,每章至多 8 条,其余情节点不写引用行。
      
      ### 批次提交格式
      
      提交给 `manage_analysis_run.py commit` 的文件按下列标记包裹,子代理与主会话自己写批次都一样;标记缺失或顺序不对整批拒收:
      
      ```markdown
      <!-- CHAPTER_START:{N} -->
      ## 第{N}章 {标题}
      {上面的紧凑字段与情节点}
      <!-- CHAPTER_END:{N} -->
      (本批每章一块,章号连续、各恰好一次)
      
      <!-- BATCH_OBSERVATIONS_START -->
      ## 跨章观察
      {下方「批次剧情点」的五个小节}
      <!-- BATCH_OBSERVATIONS_END -->
      ```
      
      - 旧成果批次(`REUSE-*`):计划列出的摘要缺口落在本批时,为本批每章都写章节块(情节点至少 1 个);没有缺口时不写章节块,改在开头写一行 `<!-- REUSED_CHAPTERS:{起章}-{止章} -->`。
      - 跨章观察块恰好一个,放在所有章节块之后。不要输出 `MODEL_OUTPUT_START/END` 或 `story-long-analyze:cache` 标记(脚本自己加)。
      
      `manage_analysis_run.py commit` 校验这些字段后,机械生成缺失的 `章节/第{N}章_摘要.md`:情节点逐条投影并把类型、主题、基调映射到枚举,章级字段保留“概要、关键事件、涉及、章尾钩子、证据”标签。原文块每章情节点少于该章 `min_plot_points` 或多于 30 时整批拒收。同一批完整模型结果和跨章观察写进 `_analysis_cache/批次-{范围ID}.md`,作为恢复证据;全局合并用 `manage_analysis_run.py digest` 取其跨章观察,不整批读取。模型不输出章节卡表,也不为兼容文件再次读取原文。
      
      ### 批次剧情点
      
      `raw-original` 与 `existing-results` 都按范围 ID 写完整批次缓存,内容共用下列结构:
      
      ```markdown
      ## 跨章观察
      
      ### 剧情点
      
      | 剧情点ID | 章节范围 | 起始目标 | 阻碍 | 关键选择/行动/外部事件 | 局面变化 | 得失与承担者 | 后续影响 | 状态 | 证据 |
      |---|---|---|---|---|---|---|---|---|---|
      
      ### 关键事件与披露候选
      
      只列可能进入主线或关键转折表的候选:候选ID|章节|客观事件及大致发生顺序|异常→线索→解释→确认的披露路径|读者所知|关键角色所知|判断变化|叙事作用|证据。过渡动作不建条目。
      
      ### 关系变化
      
      | 关系ID | 主体 → 客体 | 关系动作 | 表面/真实状态 | 触发 | 双方得失 | 变化后状态 | 证据强度 | 证据 |
      |---|---|---|---|---|---|---|---|---|
      
      ### 三维节奏
      
      只列峰值、谷值与三轴明显分离处:章节/剧情点|事件推进及理由|情绪类型/强度及触发|篇幅展开度|展开/压缩/省略/反复的作用|证据。普通章的紧凑分数不在这里重复。
      
      ### 跨批状态
      
      - 已确认别名:{别名→本名,含交接批传下来的}
      - 未闭合剧情点:{ID、主体、目标、阻碍、最后证据}
      - 未决悬念:{读者已知和等待确认的内容}
      - 关系状态:{下一批需继承的有向状态}
      - 待核事项:{冲突、C暂定或缺失}
      ```
      
      剧情点回答“目标 → 阻碍 → 改变局面的行动或外部事件 → 得失 → 后续影响”。它可以跨章和跨读取批次,不能把每个处理批次直接当成剧情点。跨批未闭合时沿用剧情点 ID;Stage 3 再做合并、拆分和边界复核。
      
      ### 已有成果二次提取
      
      `existing-results` 的证据只指向只读计划列出的旧成果文件。完整旧项目增强时用 `<!-- REUSED_CHAPTERS:{起章}-{止章} -->` 声明覆盖,只写批次剧情点及关联观察。计划列出的摘要缺口落在本批时,必须为本批每章从黄金三章生成同一紧凑章块(情节点至少 1 个),否则摘要永远补不上;提交脚本只补缺失文件。旧资料没有的字段写“旧资料未提供”。
      
      每个成功批次由 `manage_analysis_run.py commit` 先写完整缓存、再补缺失摘要、最后写 `_progress.md` 状态行。没有逐文件收据、checkpoint 账本或 Stage receipt。
      
      **Stage 2 输出自检**:输入模式与 `manage_analysis_run.py plan` 的内存计划一致|原文章节块连续、不重叠且不超过3章/25,000字符|已有成果模式没有读取原文|每章紧凑字段完整|情节点不少于 `min_plot_points`、最多 30、按时序、白描客观|剧情点包含因果与得失|只保留关键事件/披露候选|三维节奏分别解释|跨批状态可继续。
      
    • stage3-plot-rhythm.md 29.8 KB
      # Stage 3:剧情、双时间线与三维节奏
      
      Stage 3 时刻读本文件与 [synthesis-inputs.md](synthesis-inputs.md)(取料与事实保真)。给剧情单元打桥段标签时查 [deconstruction-notes.md](deconstruction-notes.md)「桥段词表」。
      
      ## 核心规则
      
      剧情点按“起始目标与阻碍 → 改变局面的选择/行动/外部事件 → 局面变化与得失 → 后续影响”合并。事件发生与信息披露分开;同一事实的异常、线索、解释、确认属于一条披露路径。全局只保留约 8–15 个主线或关键转折节点。
      
      三维节奏分别说明:事件推进 1–5 及状态变化,读者情绪类型/强度 1–5 及触发,篇幅展开度 1–3 及展开/压缩/省略/反复的作用。情绪机制写完整卡,至少覆盖最强三个,值得复现的不设数量上限;其余保留索引。完整卡以 `读者想看什么`、`情绪链`、`戏剧单元`、`可替换项`、`不可照搬` 五个字段齐全为准,字段名按下方「可复现模块卡」的 EM 卡表原样使用;缺任一项即无法登记灵感库。铺垫、成立条件、关键触发物、复现步骤、失效情形等按需增列。索引节标题用字面量 `## 其他机制索引`。
      
      `剧情/情绪模块.md` 与 `剧情/节奏.md` 都落盘后,用 `manage_analysis_run.py mark-stage --stage stage3 --output "剧情/节奏.md"` 标记。命令会同时检查两份必需产物;阶段没有 receipt 或依赖 hash。
      
      ## 聚合方法
      
      ### 0. 故事框架识别(聚合前置)
      
      在剧情聚合之前,先识别全书的故事框架。框架决定聚合策略:
      
      | 框架类型 | 特征 | 聚合策略 |
      |----------|------|----------|
      | 升级流 | 等级体系清晰,按境界分段 | 按等级阶段划分剧情单元 |
      | 复仇线 | 核心矛盾明确,目标驱动 | 按复仇对象/阶段划分 |
      | 日常/单元 | 每卷独立故事,弱连续性 | 按卷/单元划分 |
      | 多线交织 | 多视角/多时间线 | 按线索划分,交叉点单独标记 |
      | 蜕变成长 | 主角内在变化为主线 | 按成长阶段划分 |
      
      识别方法:扫描概要.md(Stage 0 thin first-pass 足够,无需等 Stage 5 全书概要)+ 前3章摘要 + 后3章摘要,判断核心驱动模式。
      输出写入 `剧情/故事线.md` 的框架识别部分。
      
      **预期剧情数量引导**(仅用于发现明显粒度异常,不作为凑数目标):
      
      | 总章数 | 预期独立剧情数 | 依据 |
      |--------|--------------|------|
      | <30 章 | 3-6 个 | 短篇结构紧凑 |
      | 30-100 章 | 5-15 个 | 每条剧情 5-20 章 |
      | 100-300 章 | 10-25 个 | 含主线+支线+补充 |
      | >300 章 | 15-40 个 | 多卷多线结构 |
      
      偏差超过 ±30% 需重新检查粒度。首要原则:保证核心戏剧目标叙事弧的完整性。
      
      **粒度层级混乱检测**(框架识别后自检):
      
      | 混乱模式 | 检测方法 | 修正方式 |
      |---------|---------|---------|
      | 包含关系 | 剧情A 章节范围完全包含剧情B | 保留粒度适中的B,拆分过粗的A |
      | 重叠率 >50% | 两剧情共享章节超过一半 | 检查是否混淆「剧情」和「剧情阶段」 |
      | 剧情仅为另一剧情的开端/结尾 | 剧情A 只占剧情B 的首/尾几章 | 合并为同一剧情,B 的阶段 |
      | 剧情无独立目标 | 目标描述是另一剧情的子目标 | 降级为该剧情的阶段 |
      
      **框架识别后自检**(聚合前必须通过):
      1. 覆盖率检查:所有剧情覆盖的章节数(去重)是否达到 85% 以上
      2. 粒度检查:是否存在过细(单一事件)或过粗(宏观主题)的剧情,执行粒度层级混乱检测
      3. 独立性检查:每条剧情是否满足 4 项独立性标准
      4. 数量检查:剧情总数是否在预期范围内
      
      ### A. 剧情聚合(两步法)
      
      将剧情聚合拆为两步:先从摘要识别剧情大纲,再按大纲分配情节点。
      
      **第一步:剧情大纲识别(从章节摘要)**
      
      基于批次候选单元合并独立剧情,边界或证据不清时再读取相关章节摘要。每条大纲包含:
      
      | 字段 | 说明 |
      |------|------|
      | 标题 | 简洁有力,15字以内 |
      | 概要 | 150-300字,按时序连贯叙述因果,不靠同一连接词反复串联;含核心角色、关键地点、事件发展 |
      | 起始目标 | 谁在本单元开始时想做什么;目标早已建立时引用来源 |
      | 主要阻碍 | 人物、规则、资源、信息或环境造成的阻碍 |
      | 关键变化来源 | 谁的选择/行动或什么外部事件改变局面 |
      | 局面变化 | 目标、风险、关系、资源、知识或行动方向的前后差异 |
      | 得失与后续 | 收益归谁、代价由谁承担,怎样限制或促成后续行动 |
      | 类型 | 主线/爱情/成长/复仇/寻宝/悬疑/战斗/权谋/修炼/危机/谜团/日常/其他 |
      | 主题 | 不超过3个 |
      | 章节范围 | 起止章节号 |
      
      识别完成后执行框架识别自检(4项检查)。
      
      **第二步:情节点分配(按剧情大纲)**
      
      对每条剧情大纲,扫描全部情节点,将直接服务该目标的节点分配进来。
      
      **分配执行逻辑**(4 步):
      1. **主题溯源与节点收集**:针对每条剧情大纲的核心目标,扫描全部情节点,将直接服务、推动或阻碍该目标的事件节点挑出
      2. **边界识别**:识别剧情的起点(触发事件/前置条件)和终点(完成/失败标志),以及连接两者的关键过渡节点
      3. **索引构建与验证**:将筛选出的情节点按时间顺序排列,验证覆盖完整性
      4. **画像数据补充**:基于最终节点集合,补充完善概要、结构分布等字段
      
      **情节点筛选标准**(判定归属时使用):
      - **主题相关**:情节点内容与剧情概要、主题一致
      - **角色匹配**:情节点涉及的角色与剧情核心角色相关
      - **因果关系**:情节点与剧情发展有因果或逻辑延续
      - **目标导向**:情节点推动或阻碍剧情目标实现
      - **章节范围**:优先选择剧情章节范围内的情节点
      
      **剧情粒度标准(核心要求)**:
      
      | 粒度 | 示例 | 问题 |
      |------|------|------|
      | 过细(单一事件) | "获得神器"(1-2章)、"首次修炼"(第3章) | 这些是事件,不是独立剧情 |
      | 过粗(宏观主题) | "主角的成长之路"(1-100章) | 这是主题,不是具体剧情 |
      | **合适** | "家族觉醒与传承认知"(1-6章)、"神器获得与导师相遇"(7-13章) | 有独立目标、冲突、角色群和完整叙事弧 |
      
      **剧情独立性判断标准**(必须同时满足):
      1. **独立的核心目标**:有明确的、可衡量的戏剧目标(不是宏观主题或抽象概念)
      2. **独立的主要冲突**:有具体的对抗力量和冲突事件(不是某个大冲突的子阶段)
      3. **相对独立的角色群**:有该剧情特有的核心角色(可以与其他剧情共享部分角色)
      4. **独立的叙事节奏**:有自己的紧张-缓和节奏
      
      **三层覆盖策略**:
      1. **主线剧情**(优先级最高):推动整体故事发展,通常占 60-70% 章节
      2. **支线剧情**:感情线、成长线、战斗线等,通常占 20-30% 章节
      3. **补充剧情**:过渡剧情、日常剧情,确保覆盖率 ≥85%
      
      **剧情类型**:
      - 主线(推动整体故事发展的核心剧情)
      - 爱情(感情关系发展)
      - 成长(角色能力/心智/地位提升)
      - 复仇(复仇相关)
      - 寻宝(争夺物品或目标)
      - 悬疑(推理、真相揭露)
      - 战斗(战争、比武、对决)
      - 权谋(政治斗争、权力争夺)
      - 修炼(修炼、突破、传承)
      - 危机(危机应对、解救、逃脱)
      - 谜团(世界观未解之谜、身世秘密、隐藏真相)
      - 日常(日常生活、过渡性内容)
      - 其他(无法归入以上类型)
      
      每条剧情提取:标题、概要(150-300字,按时序连贯叙述因果)、核心目标、核心冲突、类型、章节范围。
      标记结构分布:铺垫期/发展期/高潮期/收尾期 各包含哪些章节。
      
      **核心目标类型参考**:
      - 获得型:"赢得大比冠军""获得关键道具/传承""攻占要塞"
      - 解决型:"解除家族危机""偿还巨额债务""摆脱追杀"
      - 创造型:"创立新宗门""研发新招式""开创一个流派"
      - 探索型:"调查父母失踪真相""探索上古遗迹""揭露阴谋"
      
      ### B. 故事线提取
      
      将多条剧情聚合为「故事线」:标题、描述(300-600字发展阶段划分)、主要人物、主题关键词、包含的剧情列表(至少1个剧情)。
      
      **故事线类型**:主线/感情线/成长线/复仇线/夺宝线/冲突线/谜团线/其他
      
      **故事线描述要求**:
      - 划分 3-8 个发展阶段(periodization),寻找关键转折点或里程碑事件作为阶段边界
      - 每个阶段含:核心冲突、关键角色及作用、能力/资源/地位演进、主题母题
      - 独立追踪伏笔与回收关系链(前后呼应的伏笔-回收对)
      - 标注与其他故事线的交织关系(并行/交织/依赖)
      - 包含剧情按时序排列,每条故事线至少包含1个剧情
      
      ### C. 节奏索引与情绪模块生成
      
      Stage 3 聚合后必须生成两个权威产物,供 Stage 5 报告摘要和 `story-long-write` 直接读取:
      
      1. **`剧情/节奏.md`(节奏权威)**
         - 输入:批次关键事件与披露候选、三维节奏异常点、逐章紧凑事实、剧情单元和故事线。
         - 输出:全书约 8–15 个节点的关键双时间线表、三维节奏表、关键信息推进、爽点循环、情绪触动点和爆发节奏。
         - 生成法:只选择改变主线、关系、核心风险或读者判断的事件;客观事件只登记一次,异常、线索、解释和确认写进同一事件的披露路径。对关键区间分别判断事件状态变化、读者情绪触发和篇幅展开/压缩/省略。
         - 校验:每条 `EV/RV/AX/RH/TR` 都能回指章节、情节点或定位;过程没有展示时写“文本未展示”,不推断作者有意省略。
      
      2. **`剧情/情绪模块.md`(模块权威)**
         - 输入:故事框架识别、剧情单元、`剧情/节奏.md`、读者需求判断、桥段标签。
         - 输出:读者需求 / 情绪引擎、故事框架与套路运行图、完整模块卡(至少覆盖前三强,不设数量上限)、其他机制索引、重组与复现指南。
         - 生成法:先抽象“读者为什么期待兑现”,再把具体剧情抽成情绪链和功能位;完整模块卡必须五个门禁字段齐全(读者想看什么、情绪链、戏剧单元、可替换项、不可照搬),必要铺垫、成立条件、失效情形、反例等按需增列,其他机制只留用途、范围和证据引用。
         - 校验:best-effort 为模块标注来源(关联 `剧情/节奏.md` 的 RH/TR 条目或章节情节点),无把握时留空、不硬凑、不删模块;禁止把原文专名、独特事件顺序、标志性台词写成可复用模板。
      
      **权威关系**:`剧情/节奏.md` 管节奏/触动点,`剧情/情绪模块.md` 管读者需求/模块复现。`拆文报告.md` 与 `剧情/故事线.md` 只能摘要或引用这两个文件,不能成为并列权威。
      
      ### 语义弧识别
      
      基于批次观察和章节摘要识别自然分界。读取批次只是上下文单位,不能直接当成剧情单元:
      
      **分块原则(四大铁律)**:
      1. **语义连贯**:每块是一个相对独立的内容单元(大故事阶段或主要事件线)
      2. **自然分界**:在内容转折点、场景切换、时间跨越处切分,不在紧密剧情中间切
      3. **大小适中**:语义完整性优先于大小均匀;不设固定章数,也不要求一个语义弧一次读进上下文
      4. **避免割裂**:不在紧密剧情中间切分
      
      **题材特化分块参考**:
      
      | 题材 | 分块依据 | 示例 |
      |------|---------|------|
      | 修仙/升级 | 境界突破、地图切换 | 炼气期→筑基期→金丹期 |
      | 都市 | 事件线、身份转变 | 学生→创业→商业帝国 |
      | 历史 | 历史阶段、战役 | 起兵→统一北方→南征 |
      | 玄幻 | 世界地图、势力变化 | 东域→中州→上界 |
      | 有卷/部/篇 | 优先按原结构 | 按作者划分的卷/部 |
      
      **无明显结构**时保留连续批次边界,并把无法确认的语义归属标为待聚合。**硬约束**:所有确实需要读原文的章节都被唯一读取批次覆盖,已有成果章进入旧资料批次;两者互斥且各自不重叠。剧情单元可以跨批次,也可以在一个批次中出现多个。
      
      每块输出元数据:`块标题 | 起止章节 | 核心主题(2-5个) | 关键事件(2-5个) | 主角阶段`。
      
      ### 跨块合并(大型小说 >500 章)
      
      分块处理后,相邻块的边界剧情可能被机械切分割裂。Stage 3 聚合时执行跨块合并检查:
      
      **合并判断标准**(必须同时满足):
      1. ✓ 同一核心事件/目标:讲述的是同一个核心事件(不是两个独立事件)
      2. ✓ 主要人物相同:涉及的主要人物相同
      3. ✓ 剧情发展连续:剧情 B 是剧情 A 的自然延续
      4. ✓ 非因果独立事件:不是因果关系的两个独立事件
      
      **应该合并**:
      - "竞选县长秘书(准备阶段)" + "竞选县长秘书(投票阶段)" → 同一事件被分块割裂
      - "修炼突破(前期)" + "修炼突破(后期)" → 同一突破过程
      
      **不应合并**:
      - "在办公室立足" + "竞选县长秘书" → 两个独立目标
      - "炼气期修炼" + "筑基期修炼" → 不同阶段的独立剧情
      - "东域修炼" + "中州历练" → 不同地图的独立剧情
      
      **保守原则**:当不确定时,倾向于不合并(保持独立)。
      
      **边界检测原则**:只检查相邻块中接近块边界的剧情对,远离边界的剧情不参与跨块合并。
      
      ## 输出模板
      
      ### Stage 3 前置:故事框架识别
      
      输出写入 `剧情/故事线.md` 顶部:
      
      ```markdown
      ## 故事框架
      
      | 项目 | 内容 |
      |------|------|
      | 框架类型 | {升级流/复仇线/日常单元/多线交织/蜕变成长/混合} |
      | 核心驱动 | {一句话描述全书的核心叙事引擎} |
      | 主轴矛盾 | {贯穿全书的根本矛盾} |
      | 升级机制 | {力量/地位/关系的递进方式,如无则填"—"} |
      | 叙事节奏模式 | {铺垫→冲突→爽点→新悬念 的典型周期} |
      | 判断依据 | {哪些章节/情节点支撑此判断} |
      | 预期剧情数 | {基于总章数的预期范围,参考上方预期剧情数量引导} |
      ```
      
      **框架识别后自检**(写入 `剧情/故事线.md` 框架部分之后):
      
      ```markdown
      ## 框架识别自检
      
      | 检查项 | 结果 | 说明 |
      |--------|------|------|
      | 覆盖率 | {X%} | {已覆盖章节数/总章节数} |
      | 粒度检查 | {通过/不通过} | {如不通过,说明哪个剧情过细/过粗} |
      | 独立性检查 | {通过/不通过} | {如不通过,说明哪个剧情不满足哪项标准} |
      | 数量检查 | {通过/不通过} | {实际剧情数 vs 预期范围} |
      ```
      
      ### Stage 3 权威产物分工
      
      Stage 3 除剧情文件外,必须写一个轻量索引 `剧情/README.md`,再分别写两个权威文件:
      
      ```markdown
      # 剧情目录索引
      
      | 文件 | 权威范围 | 下游用法 |
      |---|---|---|
      | `节奏.md` | 关键信息推进、爽点循环、情绪触动点、爆发节奏 | 产出 `rhythm_reference` |
      | `情绪模块.md` | 读者需求、情绪引擎、套路框架、可复现模块卡 | 产出 `selected_emotion_module` |
      | `故事线.md` | 故事框架与故事线摘要 | 投影摘要,不覆盖权威文件 |
      | `拆文报告.md` | 人类阅读报告 | 投影摘要,不覆盖权威文件 |
      
      冲突时以 `节奏.md` / `情绪模块.md` 为准;两个文件用 EM/RH/TR ID 互相引用。
      
      ## 剧情单元清单
      
      | 剧情单元 | 类型 | 桥段标签 | 章节范围 | 体量 |
      |---|---|---|---|---|
      | `{标题}.md` | {类型} | {桥段标签} | 第{X}-{Y}章 | 共{N}章,约{M}万字 |
      
      清单仅是检索索引投影(各列取剧情单元既有字段),剧情定义权威在各剧情单元文件;节奏/情绪权威仍在 `节奏.md` / `情绪模块.md`。
      ```
      
      - `剧情/节奏.md` 是**节奏与触发点权威索引**:关键信息推进、爽点循环、情绪触动点、爆发节奏、长间隔风险都在这里落盘。
      - `剧情/情绪模块.md` 是**读者需求与可复现模块权威索引**:读者为什么爱看、情绪引擎如何运转、爽文套路如何嵌进故事框架、如何重组复现都在这里落盘。
      - `剧情/故事线.md` 和 `拆文报告.md` 只写摘要和引用,不复制完整模块定义;冲突时以下游写作读取 `剧情/节奏.md` / `剧情/情绪模块.md` 为准。
      
      ### Stage 3 剧情
      
      输出 `剧情/{标题}.md`:
      
      | 项目 | 内容 |
      |------|------|
      | 标题 | 简洁有力,15字以内 |
      | 类型 | {主线/爱情/成长/复仇/寻宝/悬疑/战斗/权谋/修炼/危机/谜团/日常/其他} — 悬疑=主动推理调查,谜团=世界观/身世等未解之谜 |
      | 概要 | {150-300字,按时序连贯叙述这条剧情线的起承转合与因果,不靠同一连接词反复串联;含核心角色及定位、关键地点、关键道具(如有)、对主角/世界的影响} |
      | 起始目标 | {谁起初想做什么;若目标在本单元前已建立,引用首次建立位置} |
      | 主要阻碍 | {人物、规则、资源、信息或环境造成的具体阻碍} |
      | 关键变化来源 | {谁的选择/行动,或什么外部事件改变了局面;不默认由主角主动造成} |
      | 局面变化 | {目标、风险、关系、资源、知识或行动方向从什么状态变成什么状态} |
      | 得失与承担者 | {谁得到什么、谁失去什么或承担什么代价} |
      | 后续影响 | {结果如何促成、限制或迫使后续行动;未完成则标 open} |
      | 关键信息功能 | {这条剧情负责让读者获得/等待/误判/确认什么信息} |
      | 读者需求 | {满足安全感/优越感/期待感/情感补偿/认知反转/陪伴感等哪类需求} |
      | 情绪模块ID | {引用 `剧情/情绪模块.md` 中的 EM-001 等,不在此重复完整卡片} |
      | 套路框架位置 | {退婚打脸/迟来追悔/升级试炼/掉马震惊/以小搏大/其他;说明它如何在本故事框架内运转} |
      | 桥段标签 | 从桥段词表选命中项,逗号分隔;可补表外确有桥段;无匹配留空,不硬凑(词表见 [deconstruction-notes.md](deconstruction-notes.md)「桥段词表」) |
      | 章节范围 | 第{X}-{Y}章(共{N}章,约{M}万字) |
      
      结构分布:铺垫期 | 发展期 | 高潮期 | 收尾期(各含章节范围)
      
      情节点索引:| 序号 | 章节 | 描述 | 归属置信度 |
      
      ### Stage 3 故事线
      
      输出 `剧情/故事线.md`(在框架识别之后):
      
      每条故事线:
      
      | 项目 | 内容 |
      |------|------|
      | 标题 | 简洁有力,15字以内 |
      | 类型 | {主线/感情线/成长线/复仇线/夺宝线/冲突线/谜团线/其他} |
      | 描述 | {300-600字,划分3-8个发展阶段,每个阶段含:核心冲突、关键角色及作用、能力/资源/地位演进、主题母题。追踪伏笔与回收关系链。标注与其他故事线的交织关系} |
      | 情绪引擎摘要 | {1-2句概述这条线如何制造缺口、延迟满足、释放和余波;详情指向 `剧情/情绪模块.md`} |
      | 套路运行方式 | {爽文/情感/悬疑等套路如何嵌入故事框架而不是孤立桥段;详情指向模块ID} |
      | 主题 | {不超过3个} |
      | 包含剧情 | {剧情标题列表,按时序排列。每条故事线至少包含1个剧情} |
      | 模块引用 | {EM-001, EM-002;只写引用,不复制完整卡片} |
      
      故事线关系:并行 / 交织 / 依赖
      
      ### Stage 3 节奏.md(权威索引)
      
      输出 `剧情/节奏.md`:
      
      ```markdown
      # 节奏索引:{书名}
      
      > 权威范围:信息披露、读者/角色知识边界、事件/情绪/篇幅三维节奏、爽点循环和情绪触动点。`拆文报告.md` 只引用本文件摘要。
      
      ## 全书情绪节奏总览
      
      - 情绪折线:{压抑/期待 → 紧张/加压 → 爽感/反转 → 余波/新危机;标注关键章节区间}
      - 爽点频率:{每 N 章一次小爽点;每 M 章一次中高潮;大高潮位置}
      - 高潮分布:小高潮:第 {X/Y/Z} 章;中高潮:第 {X/Y} 章;大高潮:第 {X} 章
      - 冲突升级路径:{低级矛盾 → 中级矛盾 → 高级矛盾 → 终局矛盾;标注触发章节}
      - 伏笔跨章地图:{伏笔内容 | 第X章埋入方式 → 第Y章回收效果;仅列跨度超过2章或影响主线的伏笔}
      
      ## 循环单元
      
      - 小循环(约 3 章):{缺口/误判 → 加压/试探 → 释放/新钩子;可变项是什么}
      - 中循环(约 7 章):{阶段目标 → 多轮阻碍 → 中段反转 → 阶段爽点 → 更大问题}
      - 大循环(约 15 章):{卷内目标 → 资源/关系/敌手升级 → 核心冲突爆发 → 余波与转场}
      
      ## 关键信息推进表
      
      | 章节/范围 | 关键信息 | 扩写方式 | 推进功能 | 节奏效果 | 关联剧情/模块 |
      |---|---|---|---|---|---|
      | 第{N}章 | {读者必须获得/误判/等待的信息} | {对话铺垫/事件验证/反应层/延迟揭示/多段回扣} | {立目标/加压/释放/转场/埋钩} | {加速/减速/蓄力/爆发/冷却} | {剧情标题 / EM-001} |
      
      ## 关键双时间线(全书约 8–15 个节点)
      
      | 关键事件ID | 客观发生时间/顺序 | 事件事实 | 披露路径与章节 | 读者所知 | 关键角色所知 | 披露前后判断变化 | 叙事作用 | 证据位置 |
      |---|---|---|---|---|---|---|---|---|
      | EV-001 | {书内时间或相对顺序} | {只登记一次的关键事件} | {RV-001异常→RV-002线索→RV-003确认;对应章节} | {各披露阶段的边界} | {关键角色在各阶段知道/不知道什么} | {读者或角色从何种判断变成何种判断} | {悬念/误导/反转/共情/期待} | {章节+source_locator+定位词} |
      
      只选择改变主线、人物关系、核心风险或读者判断的节点。一个事件的多次披露合并进同一行的披露路径,不把多次披露算成多次客观事件;“作者真相”只填文本已经证实的事实。
      
      ## 三维节奏表
      
      | 节奏ID | 章节/剧情单元 | 事件推进1-5及理由 | 读者情绪类型/强度1-5及触发 | 篇幅展开度1-3 | 展开/压缩/省略/反复及作用 | 证据 |
      |---|---|---|---|---|---|---|
      | AX-001 | {第X-Y章/剧情单元} | {改变了哪些状态;重要性、范围、可逆性} | {情绪及文本触发} | {1/2/3} | {具体安排与作用;过程未知只写未展示} | {单元ID+章节定位} |
      
      ## 爽点循环索引
      
      | 循环ID | 章节范围 | 铺垫层 | 释放层 | 反应层 | 衔接层/新钩子 | 强度递进 |
      |---|---|---|---|---|---|---|
      | RH-001 | 第{X}-{Y}章 | {压抑/误判/期待缺口} | {打脸/掉马/目标达成/情感满足} | {谁震惊/后悔/重新评价} | {旧爽点后立起的新期待} | {比上一轮强/弱在哪里} |
      
      ## 情绪触动点索引
      
      | 触动点ID | 章节 | 触发事件 | 目标读者情绪 | 爆发点 | 余波/冷却 | 复现提示 |
      |---|---|---|---|---|---|---|
      | TR-001 | 第{N}章 | {触发读者情绪的具体情节} | {期待/心疼/愤怒/爽/甜/热血/恐惧} | {情绪最高句/段或事件} | {角色反应/关系变化/新悬念} | {保留情绪链,不搬运具体事件} |
      
      ## 爆发节奏总结
      
      - 爆发密度:{每N章一次 / 每N字一次 / 卷内峰值位置}
      - 爆发形态:{递进 / 延迟满足 / W形 / 阶梯 / 单元循环}
      - 长间隔风险:{哪些区间触动点过稀,作者如何用小钩子维持}
      - 下游写作提醒:{新书复现时每章/每卷如何安排触动点}
      ```
      
      ### Stage 3 情绪模块.md(权威索引)
      
      输出 `剧情/情绪模块.md`:
      
      ```markdown
      # 情绪模块:{书名}
      
      > 权威范围:读者需求 / 情绪引擎 / 套路框架运转 / 可复现模块。禁止把原文具体桥段当模板照搬。
      
      ## 读者需求 / 情绪引擎
      
      | 读者需求 | 本书满足方式 | 证据章节/情节点 | 持续追读机制 | 可迁移边界 |
      |---|---|---|---|---|
      | {优越感/安全感/被理解/替代性复仇/关系补偿/认知惊喜等} | {故事如何持续交付} | {第N章/Px} | {下一层期待如何被立起} | {可保留情绪逻辑;必须替换具体设定/人物/事件} |
      
      ## 故事框架与套路运行图
      
      | 框架/套路 | 在本书中的位置 | 运行方式 | 为什么有效 | 失效风险 |
      |---|---|---|---|---|
      | {退婚打脸/升级流/迟来追悔/掉马/以小搏大/修罗场/其他} | {章节/剧情单元} | {铺垫→延迟→释放→余波} | {对应读者需求} | {照搬会雷同/人设不适配/铺垫不足} |
      
      ## 其他机制索引
      
      标题用字面量 `## 其他机制索引`,不改写成同义说法。除完整卡外只保留:机制ID|名称|用途|适用章节/关系|证据引用。不要复制完整卡片。
      
      ## 可复现模块卡
      
      至少展开全书最强的 3 个,值得复现的机制不设数量上限;不足 3 个按实际数量写,不用弱机制凑数。
      
      ### EM-001 {模块名}
      
      `读者想看什么`、`情绪链`、`戏剧单元`、`可替换项`、`不可照搬` 五项是灵感库登记门禁,缺任一项登记被拒;其余各行按需增列。字段名按本表原样写,不用同义名(读者期待/替换项/不可照搬项等一律不识别)。值可多行:字段名行没写值时,其后到下一个字段/标题前的列表或段落都算该字段的值。`可替换项` 写「功能位→任意X」(如「欺压者→任何压制主角的人」),不写「原书专名→任意X」——专名一旦写进抽象字段就是泄漏,登记会拒。
      
      | 字段 | 内容 |
      |---|---|
      | 读者想看什么 | {一句话抽象需求} |
      | 情绪链 | {缺口 → 加压 → 触发 → 爆发 → 余波} |
      | 戏剧单元 | {去素材后的结构,如“被轻视者在公开场合用结果反证”} |
      | 关键触发物 | {可替换功能位:误判者/见证者/代价/证据/奖励} |
      | 必要铺垫 | {哪些前置信息、行为、关系或承诺让读者形成期待} |
      | 成立条件 | {人物关系、资源、信息边界、价值选择和读者预期} |
      | 复现步骤 | {1. 建缺口 2. 加反应层 3. 控制爆发点 4. 立新钩子} |
      | 可替换项 | {角色身份、场景、道具、对手类型、目标结果} |
      | 不可照搬 | {原文专名、具体事件顺序、标志性台词、独特设定} |
      | 失效情形 | {换关系、动机、代价或披露顺序后为什么可能无效} |
      | 反例/边界 | {文本中的弱兑现、失败尝试或当前无法验证之处} |
      | 关联节奏 | {`剧情/节奏.md` RH-001 / TR-001} |
      | 证据来源 | {剧情单元、EV/RV/AX、章节和原文定位;旧资料则写实际路径与证据等级} |
      
      ## 重组与复现指南
      
      1. 先选读者需求,再选模块卡,不要从原文桥段倒抄。
      2. 保留情绪链和功能位,替换人物、场景、动机、道具、事件素材。
      3. 复现到新书大纲时,至少改变:冲突对象、公开/私密场景、触发证据、余波方向中的三项。
      4. 若人设与模块冲突,以人设驱动重构模块;不要为了套套路扭曲角色。
      ```
      
      ## 质量阈值体系
      
      Stage 3 完成后自检(Stage 4 的硬事实按 [synthesis-inputs.md](synthesis-inputs.md) 做可溯源自检):
      
      | 指标 | 阈值 | 计算 | 不达标处理 |
      |------|------|------|------------|
      | 置信度 | >= 0.85 | 有明确归属的情节点 / 剧情单元内情节点总数 | 低于 0.85 标记「待复核」 |
      | 覆盖率 | 85%-95% | 已归类情节点 / 总情节点数 | <85% 触发孤立情节二次分类;>95% 复核边界 |
      | 关键信息覆盖 | 每章有结论 | 含有效信息行或明确“无独立信息变化”的章节数 / 摘要章节数 | 按原输入类型回到 Stage 2 补提取;旧成果章仍不得回读原文 |
      | 模块来源标注 | best-effort | 情绪模块卡尽量标注 RH/TR 或章节情节点来源 | 无把握的来源留空,不硬凑、不删模块(同桥段标签的 best-effort 处理)|
      | 披露引用完整 | 100% | 关键双时间线中每个 `RV-*` 都属于同一行的 `EV-*` 披露路径,同一客观事件不因多次披露重复建档 | 缺引用或重复事件时回到批次观察合并 |
      | 三维解释完整 | 100% | `AX-*` 的事件、情绪、篇幅三轴都有理由与证据 | 缺轴时补“无明显变化/文本未展示”及依据,不用分数占位 |
      | 重叠率 | <= 35% | 跨剧情单元共享情节点 / 总情节点数 | >35% 提示边界模糊,建议合并 |
      
      > **小体量节选(< 30 章、单主线连续叙事)的特例**:此类素材按时序连续切分时,覆盖率天然接近 100%、散落情节接近 0,这是线性结构的正常结果,不算过度归并。此时复核重点是「有无把不同剧情强行并进同一条」,而非覆盖率数值本身——不必为压到 95% 以下而人为拆分。
      
      ## 散落情节兜底
      
      阶段 3 聚合完成后执行(6 步,含覆盖率验证):
      
      1. **计算孤立比例**:孤立情节点数 / 总情节点数。若比例 < 5%,跳过后续步骤,直接标记为「少量散落,无需处理」
      2. **筛选未分配情节点**:用 `digest --part chapters --points brief` 按章节窗口列出情节点简表,收集未归类到任何剧情单元的情节点
      3. **三层置信度归入**:按角色重叠、地点重叠、因果关系三条线索计算相关性
         - **强相关**(0.8-1.0):角色匹配 + 主题相关 → 归入现有剧情单元,标记 `[孤立情节归入]`
         - **中等相关**(0.5-0.8):角色匹配 或 主题相关 → 归入并标记 `[低置信归入]`,待复核
         - **弱相关**(<0.5):不建议强行归入,宁可放入未分配
      4. **主题聚类**:弱相关的情节点按主题关键词聚类
         - 聚类结果 >= 5 个情节点 → 形成候选剧情单元,标记 `[聚类生成]`
         - 聚类结果 < 5 个 → 不单独建条
      5. **散落情节归档**:仍无法归类的写入 `散落情节.md`,按章节排列,标注原因
         - 不丢弃任何情节点,不要强行分配到不相关的剧情
         - 在拆文报告中统计散落情节点数量和占比
      6. **覆盖率验证**:
         - 覆盖率 = (总数 - 散落数) / 总数 × 100%
         - 目标:85%-95%
         - < 85% → 回到步骤 3,降低置信度阈值重试
         - > 95% → 检查是否有过度归并,复核边界剧情单元
      
    • stage4-characters-settings.md 11.2 KB
      # Stage 4:角色、设定与关系
      
      Stage 4 时刻读本文件与 [synthesis-inputs.md](synthesis-inputs.md)(取料与事实保真)。
      
      ## 核心规则
      
      从 Stage 2 的 `涉及人物`、状态变化和批次关系观察归一实体,再结合 Stage 3 剧情单元生成角色档案与设定。关系记录动作方向、触发、双方得失、表面/真实状态、阶段变化和证据;“甲保护乙”与“乙依赖甲”分别记录。
      
      关系图只从 `角色/角色关系.md` 生成:`"{PYTHON}" "{story-long-analyze skill 根}/scripts/render_relation_chart.py" --root "{拆文目录}" --png`。主产物是 `人物关系图/人物关系图.md`(Mermaid + 文字清单,任何 Markdown 查看器都显示中文);PNG 只在找到能显示图中全部文字的字体时生成(表情等符号不画进图片)。字体不够就不出图,不得自行改画拼音或首字母版,把脚本的 `author_message` 转告作者。
      
      至少一份角色档案和一份设定文件落盘后运行 `manage_analysis_run.py mark-stage --stage stage4`;缺任一类文件时不得标完成。
      
      ## 角色过滤来源
      
      Stage 4 从紧凑章块的 `涉及人物`、`状态变化` 和批次 `关系变化` 汇总角色提及,再应用下表过滤;不依赖已删除的独立“出场人物表”。
      
      | 分类 | 标准 | 提取深度 |
      |------|------|----------|
      | 不提取 | 出场1次且无台词(通用路人) | 跳过 |
      | 简化提取 | 出场2-3次且单一功能 | 功能标签+首次章节 |
      | 完整提取 | 出场3次以上/有台词且推动剧情/与主角直接互动 | 完整档案 |
      
      别名合并:主条目用首次正式名,别名列于 `aliases: []`。
      
      ## 角色合并
      
      **合并**:
      1. 收集所有章节的角色提及数据
      2. 别名匹配(仅 proper_name 和 nickname,置信度 ≥0.85)
      3. descriptor/title 永不触发合并
      4. 确认后保留首次正式名
      
      ## 角色与设定提取
      
      Stage 2 紧凑事实中的 `涉及人物`、`状态变化` 与批次 `关系变化` 在此阶段归一为完整档案。
      
      ### 角色两阶段模型
      
      **阶段 A:轻量提及(Stage 2 输出)**
      - 每章 `涉及人物` 记录关键人物,`状态变化` 记录本章可见净变化;
      - 批次 `关系变化` 补充有方向的动作、触发与得失;
      - 不另建出场人物表,不在单章内完成跨章人物定型。
      
      **阶段 B:完整档案(Stage 4 构建)**
      - 合并逐章 `涉及人物`、`状态变化` 和批次关系观察
      - 跨章节别名解析(批次 `跨批状态` 的「已确认别名」是现成线索)
      - 群体称呼、无名路人和泛称(亲属、社交、身份、年龄外貌类称呼,如大哥、掌柜、老师、小伙子)不建档,除非原文固定用它指代某一个人
      - 构建完整角色档案
      
      ### 一人一实体原则
      
      - **绝对禁止**将不同人物的信息合并到同一个实体中
      - 每个角色实体必须对应唯一的一个人物
      - 如果无法确定两个称呼是否指向同一人物,必须分开创建实体
      
      ### 别名解析规则
      
      **别名类型**:
      
      | 类型 | 定义 | 可合并 | 示例 |
      |------|------|--------|------|
      | proper_name | 专名/全名/常用名 | 是 | "希尔曼""林雷·巴鲁克" |
      | nickname | 绰号/外号,须有同指证据 | 是(置信度≥0.85) | "龙血战士林雷" |
      | descriptor | 描述性称谓 | 否 | "红发壮汉""随从""队长" |
      | title | 头衔/职务 | 否 | "护卫队长""家主""族长" |
      
      **合并约束**:
      - 仅 proper_name 和 nickname(置信度 ≥0.85)可用于合并
      - descriptor 和 title **永不**触发实体合并
      - nickname 须提供同指证据:同位说明、括号别名、上下文指代、明确改名
      
      **Archetype 量化判断**:
      
      | 类型 | 标准 | 提取深度 |
      |------|------|----------|
      | **主角** | 出现章节 ≥50% 总章节 + 推动主线 + 有完整成长轨迹 | 完整档案+弧线+动机链 |
      | **反派** | 与主角对立 + 推动核心冲突 + 有明确动机 | 完整档案+动机链 |
      | **核心配角** | 出现章节 ≥20% OR 推动重要支线 OR 有独立人格和成长 | 完整档案+关系 |
      | **功能角色** | 出现章节 <20% + 作用有限(提供信息/道具/一次性互动) | 简化档案 |
      
      边界模糊优先归入更低等级。**特殊**:前期 minor 后期 supporting → 判为 supporting。重要导师/伙伴即使出现少也可判为 supporting。
      
      **角色档案结构**(200-500字):
      1. 身份背景(1-2句)
      2. 核心经历(3-5句,按时序)
      3. 性格特质(1-2句,从行为归纳)
      4. 能力特长(1-2句)
      5. 人际关系(1-2句)
      6. 成长轨迹(如有,1句)
      
      ### 金手指合并规则
      
      - 相互关联、共同作用的元素**合并为一个**(如"戒指+导师灵魂"是一个整体)
      - 同一事物的不同描述角度**不拆分**
      - 只有完全独立、互不依赖的能力来源才拆分为多个
      - 长篇特点:金手指可能复杂多样,需详尽描述演化过程和多重能力
      - 无金手指时注明
      
      **金手指类型**:system(系统)/ space(空间)/ rebirth(重生)/ transmigration(穿越)/ special_physique(特殊体质)/ artifact(神器)/ bloodline(血脉)/ other(其他)
      
      ### 世界观(按主题拆分到多个文件)
      
      字段与落盘位置:
      
      | 字段 | 说明 | 落盘文件 |
      |------|------|----------|
      | 类型 | 奇幻/现实/平行世界 | 各文件 frontmatter 或开头注明 |
      | 力量体系 | 名称、等级、晋升方式(文本描述) | `设定/世界观/力量体系.md`(>=200字独立,否则并入 背景设定) |
      | 地理 | 分布、主要区域、关键地点(文本描述) | `设定/世界观/地理.md`(同上,>=200字独立) |
      | 势力 | 门派/组织/家族/国家 | **每个势力一个文件** `设定/势力/{势力名}.md`(>=200字独立;不足合并到 `设定/世界观/背景设定.md`) |
      | 核心规则 | 世界运转的基本规则 | `设定/世界观/背景设定.md` |
      | 特殊设定 | 区别于现实的独特设定 | `设定/世界观/背景设定.md`(与核心规则同文件) |
      | 金手指 | 见上方「金手指合并规则」 | `设定/世界观/金手指.md`(统一在 `设定/世界观/` 子目录下,不放扁平 `设定/金手指.md`) |
      
      > 模板见下方「输出模板」。下游导入只原样同步当前主题文件,不再现场拆分扁平文件。
      
      ## 人物关系提取
      
      每对关系:
      
      | 字段 | 说明 |
      |------|------|
      | 主体 → 客体 | 有向关系;反向动作存在时另记一条 |
      | 关系动作/类型 | 保护、依赖、控制、服从、欺骗、竞争等动作及家人/师徒/朋友/敌人/恋人/同事/上下级/商业/其他类型 |
      | 表面/真实状态 | 文本允许区分时分别记录;未证实的真实状态标推断等级 |
      | 触发事件 | 导致关系建立或变化的具体行动/事件 |
      | 双方得失 | 信任、资源、地位、行动空间、风险、债务、暴露或失控 |
      | 变化后状态 | 本阶段结束时的状态及后续约束 |
      | 证据 | A/B/C 强度、章节、情节点或 source locator |
      
      ### 关系提取策略
      
      **数据源**:从已提取的情节点描述中提取关系(不从原文提取),更高效、更聚焦。
      
      **批量提取**:复用 Stage 2 每个连续章节批次的关系观察,只记录新关系或关系变化;不为了固定章数单独重读原文。
      
      **关系演变追踪**:当同一对角色关系发生变化时,记录演变轨迹:
      ```
      第{N}章:{关系状态A} → 第{M}章:{关系状态B}
      触发事件:{具体事件}
      情感转变:{正面→负面/负面→正面/中立→复杂 等}
      ```
      
      **最终状态合并**:同一有向关系的多阶段状态保留最新状态作为主记录,历史变化写入演变轨迹。反向关系动作不能因角色对相同而被去重。
      
      **隐含关系推断**:当角色间没有直接互动但有间接证据时:
      - 通过第三方的评价/描述推断(置信度标 0.7)
      - 通过共同出现的频率和场合推断(置信度标 0.6)
      - 推断关系标记 `[推断]`,与直接证据关系区分
      
      **关系网络密度**:
      - 统计主要角色(主角+核心配角)的关系数量
      - 关系数 < 3 → 关系网偏薄,检查是否有遗漏
      - 关系数 > 10 → 关系网过密,检查是否有误合并
      
      ## 输出模板
      
      > **Stage 4 直接按主题拆分输出多个文件**(与当前导入、长篇写作项目结构对齐)。下游不再现场拆分扁平文件。
      > **事实保真**:等级/数值/距离/属性/势力数等硬事实回原文核对,原文未给的写「原文未明确」、禁编造填空(见 [synthesis-inputs.md](synthesis-inputs.md)「合成阶段事实保真」)。
      
      `设定/世界观/力量体系.md`:
      - 文本描述,含等级、晋升条件。长篇注意多层级力量体系
      - 内容 < 200 字且不构成独立体系 → 合并到 `设定/世界观/背景设定.md`,本文件可省略
      
      `设定/世界观/地理.md`:
      - 分布、主要区域、关键地点(文本描述)
      - 内容 < 200 字 → 合并到 `背景设定.md`,本文件可省略
      
      `设定/世界观/背景设定.md`:
      - 核心规则 + 特殊设定
      - 内容不足独立成文件的「力量体系 / 地理 / 势力」也合并到这里
      - 无特殊世界观时:本文件输出 "本书为现实题材,无特殊世界观设定"
      
      `设定/世界观/金手指.md`:
      - 类型{system/space/rebirth/transmigration/special_physique/artifact/bloodline/other} | 名称 | 描述(300-600字,含能力/获取/机制/进化史/多重能力演化)| 核心机制 | 当前能力
      - 合并规则见上方「金手指合并规则」。
      - 无金手指时:输出空文件,注明"本书无明显金手指设定"
      
      `设定/势力/{势力名}.md`(每个核心势力一个文件):
      - 名称 / 类型(门派/组织/家族/国家)/ 核心人物 / 立场倾向 / 与其他势力关系 / 关键事件
      - 内容 >= 200 字时独立;不足合并到 `设定/世界观/背景设定.md`(不丢失信息)
      
      `角色/{角色名}.md`(每角色):
      - 200-500字档案(身份背景→核心经历→性格特质→能力特长→人际关系→成长轨迹)
      - archetype:protagonist/antagonist/supporting/minor
      - 关键情节(3-5个转折点,按时序排列)
      - 成长弧线(character_arc:如有明显变化则总结,如"从普通少年成长为觉醒血脉的强者")
      - 别名列表(标注类型和置信度)
      
      `角色/角色关系.md` 从逐章事实和批次观察聚合,不重新读整本原文:
      
      | 关系ID | 主体 → 客体 | 关系动作/类型 | 表面关系 | 真实关系 | 触发事件 | 双方得失 | 变化后状态 | 证据强度 | 证据 |
      |---|---|---|---|---|---|---|---|---|---|
      
      同一对角色的最新状态和历史演变都保留;“甲保护乙”与“乙依赖甲”分别记录。关系图只由此文件生成,不手画、不另建关系事实,命令见上方「核心规则」。它总是写出 `人物关系图/人物关系图.md`(Mermaid 图 + 文字清单,任何 Markdown 查看器都显示中文,作为主产物);只有装了 matplotlib 且找到能显示图中全部文字的字体(不算 LastResort 这类只画方框的兜底字体)时,才另画 `核心人物关系.png` 与 `关键关系演变.png`,表情等符号不画进图片。字体不够就不出图,绝不改用拼音或首字母,并把脚本返回的说明转告作者。
      
    • stage5-report.md 3.1 KB
      # Stage 5:主报告与完整概要
      
      Stage 5 时刻读本文件与 [synthesis-inputs.md](synthesis-inputs.md)。`拆文报告.md` 的模板与写法在 [author-facing.md](author-facing.md)「拆文报告.md」。
      
      ## 备份与标记
      
      生成新的 `拆文报告.md` 前先执行:
      
      ```text
      "{PYTHON}" "{story-long-analyze skill 根}/scripts/manage_analysis_run.py" mark-stage --root "{拆文目录}" --stage stage5 --prepare
      ```
      
      若旧报告存在,命令会完整复制到 `_analysis_cache/legacy/拆文报告.md`;已存在的首份备份不覆盖。当前旧报告与首份备份不同时,另存一份带内容 hash 的历史备份。其他旧产物与已有摘要不得覆盖。新报告落盘后再用不带 `--prepare` 的 `mark-stage` 标记完成。
      
      ## 完整概要
      
      整体故事框架识别 + 500-1000字高密度全书概要(涵盖主要剧情线阶段性发展、核心人物作用、关键转折点、因果关系)。
      
      > **怎么来**:Stage 5 从已落盘的 `剧情/故事线.md`(含故事框架识别)与各剧情单元文件自行生成概要,写入 `概要.md`,不等前面时刻交来文本。覆盖 Stage 0 的 thin first-pass 段,章节索引和卷段表保留。
      
      ## 报告写法
      
      输出 `拆文报告.md`,模板与写法规则见 [author-facing.md](author-facing.md)「拆文报告.md」。它是 `快速预览.md` 的超集(「基本信息」「黄金三章评分」两节同名同结构),面向作者阅读:不出现脚本名、字段名、状态值、批次编号、质量指标名和证据分级字母;情绪模块卡、事件、披露、节奏节点的编号只在带名称时出现(如「退婚当众打脸」(EM-003)),关系写成「甲 → 乙:由……到……」。
      
      报告只综合权威底层结果,不为写总报告重新阅读全文,也不复制所有逐章摘要。每条结论写一句人话,再指向对应文件和章节;机械可追溯信息(`source_locator`、证据强度、`EV/RV/AX/EM` 全量编号)留在 `剧情/节奏.md`、`剧情/情绪模块.md`、剧情单元和 `角色/角色关系.md` 里。
      
      ### 三层灵感库最小消费契约
      
      - 单书案例以 `剧情/*.md` 的剧情单元为准,可迁移机制以 `剧情/情绪模块.md` 的 `EM-*` 为准;主报告是阅读入口,不作为第二份结构化真源。
      - 客观事件、披露节点和三维节奏分别使用 `EV-*`、`RV-*`、`AX-*`;披露必须引用事件;机制卡通过 ID 引用,报告只用「名称(ID)」的带名引用,避免跨层复制全文。
      - 每条可检索结论保留剧情单元/章节、`source_locator` 或定位词及证据强度;`chapter_index.csv` 的逐章 hash 和 `_progress.md` 批次范围 hash 用于判断原文范围是否变化。
      - 下游先运行兼容检查器读取 `classification`、`current_capabilities`、`conflicts` 和覆盖范围。缺项时请求长篇拆文的定点增强;不得把旧资料概括成伪造的当前主产物,也不得为入库重新阅读全文。
      - 是否进入灵感库、跨书归并、差异保留和检索策略由三层灵感库能力决定;单书拆文不自动入库。
      
    • style-profile-generator.md 4.4 KB
      # 文风档案生成与单独重建
      
      > **何时加载**:story-long-analyze Stage 6。Stage 6 可以在完整管道末尾运行,也可以在已有拆文只缺 `文风.md` 时单独运行;单独运行不得触发 Stage 1–5。
      
      ## 原则
      
      1. 文风档案描述作者实际写法,不评价作者水平。
      2. 情绪和节奏意图以 `剧情/情绪模块.md`、`剧情/节奏.md` 为准,文风只管表达层。
      3. 已有完整 `文风.md` 时原样保留;用户明确要求增强时才更新。
      4. Stage 6 不重扫全书。允许使用索引行号定点回读 4–6 段原文,以补齐可验证锚点。
      5. 摘要措辞不能冒充作者原句;缺少原文和有效样本时明确报错。
      
      ## 来源顺序
      
      1. 既有 `文风.md`;
      2. 项目内非空且含连续原文的 `_style-sample.txt`;
      3. `章节/第1-3章_深度拆解.md` 中带定位的短引和写法证据;
      4. `_analysis_cache/批次-*.md`、`章节/*_摘要.md` 与 `拆文报告.md` 的结构层观察;
      5. 仍缺锚点时,读取 `chapter_index.csv`,按旧版“覆盖主要基调”的方式选择 4–6 章,再按 `start_line/end_line` 定点读取原文片段。
      
      第 5 项只读取被选章节的局部行段,不允许从头到尾扫描原文。没有索引的旧项目可以仅运行机械索引脚本;这一步不写 `_progress.md`,也不启动其他分析阶段。
      
      ## 单独重建文风
      
      当 story-long-write 或用户发现 `文风.md` 缺失时:
      
      1. 运行检查器,确认其他拆文成果可用且 `stage_repairs` 包含 `stage6_style`;
      2. 若 `_style-sample.txt` 有效,直接用它;
      3. 否则确认 `chapter_index.csv` 与原文存在。缺索引时只运行:
      
         ```text
         "{PYTHON}" "{story-long-analyze skill 根}/scripts/build_chapter_index.py" --source "{拆文目录}/原文/原文.txt" --output "{拆文目录}/chapter_index.csv" --locator-path "原文/原文.txt"
         ```
      
      4. 从索引中选择 4–6 章,优先覆盖紧张、轻松、冲突、关系回收、高潮与收束等已有资料能确认的不同基调;按行号只取每章一段 300–500 字连续原句,写入或更新 `_style-sample.txt`;
      5. 按 [style-profile-protocol.md](style-profile-protocol.md) 生成 `文风.md`;
      6. 文件成功落盘后运行:
      
         ```text
         "{PYTHON}" "{story-long-analyze skill 根}/scripts/manage_analysis_run.py" mark-stage --root "{拆文目录}" --stage stage6 --output "文风.md"
         ```
      
      索引、原文和有效样本都不存在时停止,返回“无法取得可验证文风锚点”,不得生成锚点全空的可用文风。
      
      ## 档案内容
      
      - 叙述视角、段落推进、对白比例、动作与心理安排;
      - 句长、标点、口吻和角色语气,只从原文锚点归纳;
      - 情绪转场引用三维节奏和篇幅安排,不把剧情强度当文风;
      - 每项标 `high / med / low` 置信度及来源路径;
      - 原文锚点 4–6 段,每段标章节、行号、基调和示范点。
      
      ## 字段速查
      
      完整模板见 [style-profile-protocol.md](style-profile-protocol.md)。
      
      | 段落 | 内容 | 必标可信度 | 上限 |
      |---|---|---|---|
      | 生成记录 | 参考了哪些资料 + 抽样看了哪几章 + 文风是否可用 | 否 | <100 字 |
      | 整体语感 | 句长分布 / 标点习惯 / 段落节奏 | 是(每子项一标) | ~500 字 |
      | 对话技法 | 潜台词模式 / 对话标签 / 角色语气区分 | 是(潜台词必标) | ~400 字 |
      | 情绪交替模式 | 章内基调切换 / 跨章基调周期 / 喜剧↔重击转场 | 是(章内切换必标) | ~400 字 |
      | 可借鉴技巧 | 写法技巧 Top 5 + 可借鉴套路 Top 3(从拆文报告引用;情绪/节奏意图只引用 `剧情/情绪模块.md` / `剧情/节奏.md`,不在文风中重定义) | 否 | ~300 字 |
      | 分层模仿建议 | 基础层 / 进阶层 / 适配层,各写可执行边界 | 否 | ~350 字 |
      | **原文锚点片段** | **4-6 段 × 300-500 字**,按基调分类,标"出处 + 行号 + 示范点" | 否 | ~2400 字 |
      | 不可模仿 | 对标书缺陷或不适合本项目的技法 | 否 | <100 字 |
      
      **总上限 ~4000 字**。
      
      ## 质量检查
      
      - 每条高置信结论都有资料路径或可核原文锚点;
      - 4–6 段锚点覆盖不同基调,且来自索引指定的局部行段;
      - 摘要没有被当成作者原句;
      - `文风.md` 保持现有下游字段,未获增强请求时不覆盖旧文件;
      - Stage 6 失败只报告本阶段缺口,不回头重跑 Stage 1–5。
      
    • style-profile-protocol.md 6.6 KB
      # 文风协议
      
      > **何时加载**:story-long-analyze Stage 6 执行前。下游写作 skill 直接读 `文风.md`,不加载本协议。
      
      ## 产物定义
      
      `拆文库/{书名}/文风.md` 是整书级写作技法视图,聚合:
      
      - 句长 / 标点 / 段落节奏(从已有样本或按索引定点读取的可验证原句统计)
      - 对话潜台词模式 + 角色语气区分
      - 章内 + 跨章情绪交替周期(看每章基调如何变化)
      - `拆文报告.md` 已有的「写法技巧」「可借鉴套路」
      - 分层模仿建议(基础层 / 进阶层 / 适配层,强调学手法不搬桥段)
      - 4-6 段可验证原文锚点片段(范例片段用;优先已有样本,没有时按索引定点读取,都不可用时明确失败)
      
      ## 文件路径
      
      - **写**:`拆文库/{书名}/文风.md`(analyze 独占)
      - **读**:该书被显式选为另一项目的外部对标时,由 story-import 或 story-long-write 首次引用同步到 `{项目}/对标/{书名}/文风.md`;story-long-write 读项目对标视图(回退拆文库)。story-import 正在重建的本书不走这条同步。
      
      ## 字数预算
      
      - **硬上限 ~4000 字**
      - 描述部分(整体语感 + 对话技法 + 情绪交替模式 + 可借鉴技巧 + 分层模仿建议)≤ 1800 字
      - 原文锚点片段 4-6 段 × 300-500 字 ≈ 1600-2400 字
      - 不可模仿 + 生成记录 ≤ 100 字
      
      ## 模板
      
      ```markdown
      # {书名} 文风
      
      ## 生成记录
      - 参考资料:拆文报告.md、黄金三章深度拆解、章节摘要
      - 抽样章节:第 {K1}/{K2}/{K3}/{K4}[/{K5}/{K6}] 章(每章取 300-500 字连续行段)
      - 生成时间:{date}
      - 适用对标书路径:拆文库/{书名}/
      - 文风可用:是  # 若原文缺失或锚点不足,写“否:原因”
      
      ## 整体语感
      - 句长分布:{在 4-6 段合并样本上确定性测量——短句(<15字)占比 X%、中句(15-30)Y%、长句(>30)Z%、平均句长 N 字、标点密度 M%。一句概括语感。`confidence: high`(数据由机械统计得出,不是模型估计)。}
        - confidence: high | med | low
      - 标点习惯:{破折号/省略号/句号/感叹号/分号 的高频用法。附 2-3 个原文短片段示例。}
        - confidence: high | med | low
      - 段落节奏:{平均段长、单段单动作 vs 多动作堆叠、断行习惯。}
        - confidence: high | med | low
      
      ## 对话技法
      - 潜台词模式:{2-3 种典型潜台词手法(问非所答 / 语气反差 / 信息隐瞒等),每种附 1 段原文示例。}
        - confidence: high | med | low
      - 对话标签习惯:{说话动词多样性、动作替代说话标签的频率、对话与动作的穿插比例。}
      - 角色语气区分:{主角和 1-2 个核心配角的口头禅/句式差异,引用原文样本句。}
      
      ## 情绪交替模式
      - 章内基调切换:{统计章节内情节点基调序列——典型章节是否在 紧张↔轻松 或 热血↔温馨 之间切换、切换频率(每章 N 次)。}
        - confidence: high | med | low
      - 跨章基调周期:{前 20 章「章基调」序列,识别“虐 3 章爽 1 章”之类周期。}
      - 喜剧↔重击的转场手法:{对标书在 轻松→悲伤 锐角转场时用了什么手法,举 1-2 个原文锚点。}
      
      ## 可借鉴技巧(从 拆文报告.md 直接引用)
      - 写法技巧 Top 5:
        1. {技巧名}:{一句话用法说明}
        2. ...
      - 可借鉴套路 Top 3:
        1. {套路名}:{适用场景}
        2. ...
      
      ## 分层模仿建议
      - 基础层(必学):{词汇偏好、句式节奏、对话标签、描写重心中最容易迁移的 3-5 条;只学表达习惯,不复制原句}
      - 进阶层(结构):{节奏推进、伏笔埋回、视角切换、场景衔接中最值得复用的 3-5 条;替换人物/场景/道具后再使用}
      - 适配层(本书化):{哪些技法适合当前项目,哪些会导致人设/题材错位;明确不要搬运专名、标志性台词、独特桥段和事件顺序}
      
      ## 原文锚点片段
      
      > 每片段 300-500 字,**用于 narrative-writer 写作时的范例片段**。优先复用旧文风、黄金三章或批次中已经保存并带定位的连续原句;没有有效样本时,Stage 6 可按 `chapter_index.csv` 定点读取 4-6 段连续原文。不得重扫全书。模仿手法、不抄字句。
      
      ### 片段 A — 基调:紧张
      **出处**:第 {K} 章 第 {段号} 段(行 {L1}-{L2})
      **示范点**:{句长节奏(该处长短如何分布) / 标点位置 / 一笔两用 等}
      
      ```
      {300-500 字原文}
      ```
      
      ### 片段 B — 基调:悲伤/压抑
      **出处**:第 {K} 章 第 {段号} 段(行 {L1}-{L2})
      **示范点**:{对话潜台词手法}
      
      ```
      {300-500 字原文}
      ```
      
      ### 片段 C — 基调:轻松/搞笑
      **出处**:第 {K} 章 第 {段号} 段(行 {L1}-{L2})
      **示范点**:{句子节奏 / 角色语气区分}
      
      ```
      {300-500 字原文}
      ```
      
      ### 片段 D — 基调:热血/爽点
      **出处**:第 {K} 章 第 {段号} 段(行 {L1}-{L2})
      **示范点**:{爽点铺放比 / 动作描写句长}
      
      ```
      {300-500 字原文}
      ```
      
      > 优先覆盖项目可能用到、且对标书中样本充足的基调:紧张、悲伤/压抑、轻松/温馨、热血。按拆文里实际分布挑 4-6 段;缺哪个基调就写“本书该基调样本不足,跳过”,不要编造。
      
      ## 不可模仿
      - {对标书的明显缺陷或不适合当前项目的技法。可选段落,可空。}
      ```
      
      ## confidence 字段语义
      
      | 值 | 触发条件 | 下游处理 |
      |---|---|---|
      | `high` | 数据直接可读(如基调序列从摘要 grep 得出) | narrative-writer 优先采纳,覆盖默认 Gate |
      | `med` | 从样本归纳且样本充足(如看章节基调走向、对话潜台词整理) | narrative-writer 参考,与默认 Gate 协商 |
      | `low` | 样本不足/原文缺失 | narrative-writer 让位回默认 Gate(不强制采纳) |
      
      ## 可用性语义
      
      - `文风可用:是` → 文风可用于写作,narrative-writer 按 confidence 分级使用。
      - `文风可用:否:{原因}` → 文风质量不足(例如原文缺失、锚点全是占位符)。story-explorer 读取时返回 `gaps.profile_degenerate: true`,narrative-writer 跳过文风,按默认 Gates 写作,避免被误导。
      
      ## 覆盖与不可模仿原则
      
      - **覆盖**:文风优先级排在 Gate D(节奏调整)、Gate B(句式去套路)、标点默认习惯之上——这些 Gate 是去 AI 味的**默认值**,文风有更具体的指令时文风赢。
      - **不可覆盖**:细纲事件、信息揭露边界、用户字数范围、文件结构。禁用词、结尾、比喻等表达默认值按 style-resolution 的优先级裁决,对标文风低置信时让位给默认值。
      
      精确决议表见 `.claude/agents/narrative-writer.md` 的“被调用协议”段。
      
    • synthesis-inputs.md 4.1 KB
      # Stage 3–5:取料与事实保真
      
      Stage 3、4、5 各自的时刻都读本文件。
      
      ## 取料
      
      不整份 Read 批次缓存(每批约七成是和逐章摘要重复的章节块),改用只读的 `digest` 按需取料,输出是 Markdown:
      
      ```text
      "{PYTHON}" "{story-long-analyze skill 根}/scripts/manage_analysis_run.py" digest --root "{拆文目录}" --part observations [--chapters 1-60]
      "{PYTHON}" "{story-long-analyze skill 根}/scripts/manage_analysis_run.py" digest --root "{拆文目录}" --part chapters --chapters 1-60 --fields 三维节奏,涉及,状态变化 --points brief
      ```
      
      - `observations`:按章序汇总已提交批次的跨章观察(剧情点、关键事件与披露候选、关系变化、三维峰谷、跨批状态),是 Stage 3–4 的主语料;
      - `chapters`:从逐章摘要抽字段(概要、关键事件、因果、局面结果、涉及、信息变化、状态变化、三维节奏、章尾钩子、证据),`--points brief` 给每点「标题|类型|基调」,`full` 另带白描;
      - 全书放不进一次上下文时按 `--chapters` 分窗口取、分组合并,窗口间只带剧情状态、关键候选与证据引用,不扩写内容;只有关键证据冲突才定点 Read 单章 `章节/第N章_摘要.md`,最多增加一次定点核验;
      - 旧项目没有批次缓存时,直接复用现有剧情、节奏、情绪、关系和逐章资料,不要为了统一格式先把旧摘要重新压缩一遍;证据不足或冲突时标未知或待核;
      - Stage 3–5 不回读原文,也不各自重读同一批原文;Stage 6 可以按索引定点读取 4–6 段文风锚点,但不得重新扫描整本原文。
      
      ## 阶段标记
      
      Stage 1 黄金三章与快速预览落盘后标 `stage1`;计划不再有批次时标 `stage2`。Stage 3–6 各运行一次。文件成功原子落盘后再标记:
      
      ```text
      "{PYTHON}" "{story-long-analyze skill 根}/scripts/manage_analysis_run.py" mark-stage \
        --root "{拆文目录}" --stage stage3 --output "剧情/节奏.md"
      ```
      
      阶段完成只看约定产物存在且进度行完成;没有依赖 hash 或 Stage receipt。`mark-stage` 会按阶段检查:Stage 1 的黄金三章与快速预览、Stage 2 的全部摘要、Stage 3 的情绪模块与节奏、Stage 4 的角色与设定、Stage 5 的报告、Stage 6 的文风。Stage 6 的单独重建见 [style-profile-generator.md](style-profile-generator.md),允许按索引定点读取 4–6 段原文,不重扫全书,也不触发其他阶段。
      
      ## 合成阶段事实保真(阶段 3-5:剧情/设定/角色/报告)
      
      阶段 3-5 从章节摘要二次合成,离原文已两跳,最易把「摘要没说的」用合理推断补成「看似坐实的事实」——这是拆文事实错误的最大来源(典型:给「双系魔法师」凭空补第二系、把坐骑等级安到骑手头上、给原文没给的字段编一个数值、把出场区间填成连续跨度含没出场的章)。强模型也照样漂移,因为越远离原文,模型越靠世界知识与"合理性"填空。三条硬约束:
      
      1. **硬事实必须可溯源**:力量等级、数值、距离、属性、势力数量、谁对谁说了什么、角色出场章节——先检查批次证据或已有事实的章节和定位;命中不到就改写为“资料未明确”或删除,不为聚合二次读取原文。
      2. **缺失写「原文未明确」,禁编造填空**:原文没给的字段,写「原文未明确/节选未涉及」,绝不用近义项、别处的范例或惯例补一个看似合理的值。
      3. **跨指代不串**:相邻实体的属性不互相挪用——坐骑等级≠骑手等级、A 的台词≠B 的台词、本章统计数字≠相邻章数字。
      
      落盘后做一次**事实可溯源自检**:抽查设定、角色和报告里的硬事实,逐条核对到本次允许使用的证据源。`raw-original` 可按 `source_locator` 机械定位原文,`existing-results` 只能核对计划列出的旧成果文件;旧成果没有的细节改写为「旧资料未提供」或删除,不借核证之名回读已覆盖章节原文。此自检独立于 Stage 3 的质量阈值——阈值查归类质量,它查事实真伪。
      
  • scripts
    • build_chapter_index.py 20.4 KB
      #!/usr/bin/env python3
      """Build the mechanical, content-addressed chapter index.
      
      The CSV is the only source for chapter boundaries. This script never writes
      analysis progress and never interprets story semantics.
      """
      
      from __future__ import annotations
      
      import argparse
      import csv
      import hashlib
      import io
      import json
      import os
      import re
      import statistics
      import sys
      import tempfile
      from pathlib import Path
      from typing import Any, Dict, List, Optional, Sequence, Tuple
      
      from inspect_existing_assets import legacy_mapping_check
      
      
      PARSER_VERSION = "3"
      CSV_COLUMNS = (
          "chapter", "source_chapter", "volume", "title", "start_line", "end_line",
          "char_count", "source_locator", "status", "chapter_sha256", "source_sha256",
          "parser_version",
      )
      NUMBER = r"〇零一二三四五六七八九十百千万两0-9"
      VOLUME_RE = re.compile(rf"^\s*第(?P<number>[{NUMBER}]+)卷(?P<title>.*)$")
      COMBINED_RE = re.compile(rf"^\s*第(?P<volume>[{NUMBER}]+)卷\s*第(?P<chapter>[{NUMBER}]+)章(?P<title>.*)$")
      CHAPTER_RE = re.compile(rf"^\s*第(?P<number>[{NUMBER}]+)章(?P<title>.*)$")
      ENGLISH_RE = re.compile(r"^\s*Chapter\s+(?P<number>[0-9]+)\b(?P<title>.*)$", re.IGNORECASE)
      NUMERIC_RE = re.compile(r"^\s*(?P<number>[0-9]+)[..、]\s*(?P<title>.*)$")
      SPECIAL_RE = re.compile(
          rf"^\s*(?P<label>楔子|序章|引子|前言|后记|尾声|番外(?:[{NUMBER}]+)?)"
          r"(?:[\s::\-—]+(?P<title>.*))?\s*$"
      )
      TITLE_PREFIX_RE = re.compile(r"^[\s\-—::、..]+")
      DIGITS = {"〇": 0, "零": 0, "一": 1, "二": 2, "两": 2, "三": 3, "四": 4,
                "五": 5, "六": 6, "七": 7, "八": 8, "九": 9}
      UNITS = {"十": 10, "百": 100, "千": 1000, "万": 10000}
      
      
      def sha256(data: bytes) -> str:
          return hashlib.sha256(data).hexdigest()
      
      
      def atomic_write(path: Path, data: bytes) -> None:
          path.parent.mkdir(parents=True, exist_ok=True)
          fd, temporary = tempfile.mkstemp(prefix=".%s." % path.name, suffix=".tmp", dir=str(path.parent))
          try:
              with os.fdopen(fd, "wb") as handle:
                  handle.write(data)
                  handle.flush()
                  os.fsync(handle.fileno())
              os.replace(temporary, str(path))
          except BaseException:
              try:
                  os.unlink(temporary)
              except OSError:
                  pass
              raise
      
      
      def decode_source(raw: bytes) -> str:
          for encoding in ("utf-8-sig", "utf-8", "gb18030"):
              try:
                  return raw.decode(encoding)
              except UnicodeDecodeError:
                  continue
          raise ValueError("source_encoding_unsupported")
      
      
      def physical_lines(text: str) -> List[str]:
          """Split on LF only so CSV line numbers agree with grep -n and editors."""
          lines = text.split("\n")
          if lines and lines[-1] == "":
              lines.pop()
          return [line[:-1] if line.endswith("\r") else line for line in lines]
      
      
      def parse_number(raw: str) -> int:
          if raw.isdigit():
              return int(raw)
          if not any(character in UNITS for character in raw):
              try:
                  return int("".join(str(DIGITS[character]) for character in raw))
              except (KeyError, ValueError) as exc:
                  raise ValueError("chapter_number_invalid:%s" % raw) from exc
          total = 0
          section = 0
          number = 0
          for character in raw:
              if character in DIGITS:
                  number = DIGITS[character]
              elif character in UNITS:
                  unit = UNITS[character]
                  if unit == 10000:
                      total += (section + number) * unit
                      section = 0
                  else:
                      section += (number or 1) * unit
                  number = 0
              else:
                  raise ValueError("chapter_number_invalid:%s" % raw)
          return total + section + number
      
      
      def clean_title(raw: str, fallback: str) -> str:
          title = TITLE_PREFIX_RE.sub("", raw or "").strip()
          return title or fallback
      
      
      def heading_candidates(lines: Sequence[str]) -> List[Dict[str, Any]]:
          explicit = []  # type: List[Dict[str, Any]]
          numeric_candidates = []  # type: List[Dict[str, Any]]
          volume_number = None  # type: Optional[int]
          volume_title = ""
          for line_number, line in enumerate(lines, start=1):
              combined = COMBINED_RE.match(line)
              if combined:
                  raw_volume = combined.group("volume")
                  raw_chapter = combined.group("chapter")
                  volume_number = parse_number(raw_volume)
                  volume_title = "第%s卷" % raw_volume
                  explicit.append({
                      "line": line_number, "heading_kind": "combined",
                      "source_chapter": str(parse_number(raw_chapter)),
                      "number_value": parse_number(raw_chapter), "volume_number": volume_number,
                      "volume": volume_title,
                      "title": clean_title(combined.group("title"), "第%s章" % raw_chapter),
                  })
                  continue
              volume = VOLUME_RE.match(line)
              if volume and not CHAPTER_RE.match(line):
                  raw_volume = volume.group("number")
                  volume_number = parse_number(raw_volume)
                  volume_title = clean_title(volume.group("title"), "第%s卷" % raw_volume)
                  continue
              chapter = CHAPTER_RE.match(line)
              english = ENGLISH_RE.match(line)
              match = chapter or english
              if match:
                  raw_chapter = match.group("number")
                  value = parse_number(raw_chapter)
                  explicit.append({
                      "line": line_number, "heading_kind": "chapter" if chapter else "english",
                      "source_chapter": str(value), "number_value": value,
                      "volume_number": volume_number, "volume": volume_title,
                      "title": clean_title(match.group("title"), "第%s章" % raw_chapter),
                  })
                  continue
              special = SPECIAL_RE.match(line)
              if special:
                  label = special.group("label")
                  explicit.append({
                      "line": line_number, "heading_kind": "special", "source_chapter": label,
                      "number_value": None, "volume_number": volume_number, "volume": volume_title,
                      "title": clean_title(special.group("title") or "", label),
                  })
                  continue
              numeric = NUMERIC_RE.match(line)
              if numeric:
                  raw_chapter = numeric.group("number")
                  value = parse_number(raw_chapter)
                  numeric_candidates.append({
                      "line": line_number, "heading_kind": "numeric", "source_chapter": str(value),
                      "number_value": value, "volume_number": volume_number, "volume": volume_title,
                      "title": clean_title(numeric.group("title"), "第%s章" % raw_chapter),
                  })
          return explicit if explicit else numeric_candidates
      
      
      def identity(candidate: Dict[str, Any]) -> Tuple[Optional[int], str]:
          return candidate.get("volume_number"), str(candidate["source_chapter"])
      
      
      def drop_leading_toc(candidates: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
          if len(candidates) < 6:
              return candidates
          max_prefix = min(len(candidates) // 2, 500)
          for prefix_count in range(max_prefix, 2, -1):
              prefix = candidates[:prefix_count]
              if prefix[0]["line"] > 200:
                  continue
              gaps = [prefix[index]["line"] - prefix[index - 1]["line"] for index in range(1, len(prefix))]
              if not gaps or statistics.median(gaps) > 3:
                  continue
              signature_length = min(8, prefix_count)
              signature = [identity(item) for item in prefix[:signature_length]]
              for body_start in range(prefix_count, len(candidates) - signature_length + 1):
                  body_signature = [identity(item) for item in candidates[body_start:body_start + signature_length]]
                  if signature == body_signature and candidates[body_start]["line"] - prefix[-1]["line"] > 3:
                      return candidates[body_start:]
          return candidates
      
      
      def duplicate_title_key(title: str) -> str:
          return re.sub(r"\s*[((][^))]*[))]\s*$", "", title).strip()
      
      
      def drop_adjacent_duplicate_headings(candidates: List[Dict[str, Any]], lines: Sequence[str]) -> List[Dict[str, Any]]:
          deduplicated = []  # type: List[Dict[str, Any]]
          for candidate in candidates:
              if deduplicated:
                  previous = deduplicated[-1]
                  gap = candidate["line"] - previous["line"]
                  between = lines[previous["line"]:candidate["line"] - 1]
                  if (previous.get("heading_kind") == "numeric"
                          and candidate.get("heading_kind") == "numeric"
                          and identity(candidate) == identity(previous) and 1 <= gap <= 3
                          and all(not line.strip() for line in between)
                          and duplicate_title_key(candidate["title"]) == duplicate_title_key(previous["title"])):
                      continue
              deduplicated.append(candidate)
          return deduplicated
      
      
      def validate_numbering(candidates: Sequence[Dict[str, Any]]) -> None:
          previous = None  # type: Optional[Dict[str, Any]]
          for position, candidate in enumerate(candidates, start=1):
              number = candidate.get("number_value")
              # 第0章 behaves like a prologue. The first positive chapter may start at
              # any number so excerpts such as 第五章 can be indexed.
              if number is None or number == 0:
                  continue
              if previous is None:
                  previous = candidate
                  continue
              if candidate["volume_number"] == previous["volume_number"]:
                  expected = previous["number_value"] + 1
                  if number != expected:
                      kind = "chapter_number_duplicate" if number <= previous["number_value"] else "chapter_number_gap"
                      raise ValueError("%s:expected=%s:actual=%s:position=%s" % (kind, expected, number, position))
              else:
                  expected = previous["number_value"] + 1
                  if number not in (1, expected):
                      raise ValueError("volume_chapter_number_invalid:expected=1_or_%s:actual=%s:position=%s" % (expected, number, position))
              previous = candidate
      
      
      def normalized_chapter_text(lines: Sequence[str], start_line: int, end_line: int) -> str:
          selected = list(lines[start_line - 1:end_line])
          while selected and not selected[-1].strip():
              selected.pop()
          return "\n".join(selected)
      
      
      def fold_leading_specials(candidates: List[Dict[str, Any]]) -> Tuple[List[Dict[str, Any]], List[str]]:
          """Merge 楔子/序章/第0章… before the first numbered chapter into that chapter.
      
          Only on request: it reproduces the numbering of older runs that did not
          count a prologue, so their 第N章 files keep pointing at the same chapter.
          """
          first = next((index for index, item in enumerate(candidates)
                        if item.get("number_value") is not None and item["number_value"] >= 1), None)
          if not first:
              return candidates, []
          folded = [str(item["source_chapter"]) if item["number_value"] is None else "第0章"
                    for item in candidates[:first]]
          body = dict(candidates[first])
          body["line"] = candidates[0]["line"]
          return [body] + candidates[first + 1:], folded
      
      
      def build_boundaries(text: str, locator_path: str, source_hash: str,
                           fold_prologue: bool = False) -> Tuple[List[Dict[str, Any]], List[str]]:
          lines = physical_lines(text)
          candidates = drop_adjacent_duplicate_headings(drop_leading_toc(heading_candidates(lines)), lines)
          if not candidates:
              raise ValueError("chapter_heading_not_found")
          validate_numbering(candidates)
          folded = []  # type: List[str]
          if fold_prologue:
              candidates, folded = fold_leading_specials(candidates)
          rows = []  # type: List[Dict[str, Any]]
          for index, candidate in enumerate(candidates):
              start_line = candidate["line"]
              end_line = candidates[index + 1]["line"] - 1 if index + 1 < len(candidates) else len(lines)
              if end_line < start_line:
                  raise ValueError("chapter_boundary_invalid:position=%s" % (index + 1))
              body = "\n".join(lines[start_line:end_line])
              char_count = len(re.sub(r"\s+", "", body))
              chapter_text = normalized_chapter_text(lines, start_line, end_line)
              rows.append({
                  "chapter": index + 1, "source_chapter": candidate["source_chapter"],
                  "volume": candidate["volume"], "title": candidate["title"],
                  "start_line": start_line, "end_line": end_line, "char_count": char_count,
                  "source_locator": "%s:L%s-L%s" % (locator_path, start_line, end_line),
                  "status": "ok" if char_count else "empty",
                  "chapter_sha256": sha256(normalized_chapter_text(lines, start_line, end_line).encode("utf-8")),
                  "source_sha256": source_hash, "parser_version": PARSER_VERSION,
              })
          return rows, folded
      
      
      def csv_payload(rows: Sequence[Dict[str, Any]]) -> bytes:
          buffer = io.StringIO(newline="")
          writer = csv.DictWriter(buffer, fieldnames=CSV_COLUMNS, lineterminator="\n")
          writer.writeheader()
          for row in rows:
              writer.writerow({column: row[column] for column in CSV_COLUMNS})
          return buffer.getvalue().encode("utf-8-sig")
      
      
      def read_existing(path: Path) -> Tuple[bytes, List[Dict[str, str]]]:
          data = path.read_bytes()
          with io.StringIO(data.decode("utf-8-sig"), newline="") as handle:
              reader = csv.DictReader(handle)
              rows = list(reader)
              fields = tuple(reader.fieldnames or ())
          if not rows or "chapter" not in fields or "source_chapter" not in fields:
              raise ValueError("existing_index_invalid")
          return data, rows
      
      
      def reusable_index(path: Path, source_hash: str, locator_path: str) -> Optional[Tuple[bytes, List[Dict[str, str]]]]:
          try:
              data, rows = read_existing(path)
              with io.StringIO(data.decode("utf-8-sig"), newline="") as handle:
                  fields = tuple(csv.DictReader(handle).fieldnames or ())
          except (OSError, UnicodeError, csv.Error, ValueError):
              return None
          if fields != CSV_COLUMNS:
              return None
          for expected, row in enumerate(rows, start=1):
              try:
                  valid_numbers = (int(row["chapter"]) == expected and int(row["start_line"]) >= 1
                                   and int(row["end_line"]) >= int(row["start_line"])
                                   and int(row["char_count"]) >= 0)
              except (KeyError, TypeError, ValueError):
                  return None
              if not valid_numbers or not re.fullmatch(r"[0-9a-f]{64}", row.get("chapter_sha256", "")):
                  return None
              if row.get("source_sha256") != source_hash or row.get("parser_version") != PARSER_VERSION:
                  return None
              if row.get("status") not in {"ok", "empty"}:
                  return None
              if not str(row.get("source_locator", "")).startswith(locator_path + ":L"):
                  return None
          return data, rows
      
      
      def mapping_signature(row: Dict[str, Any]) -> Tuple[str, str]:
          return str(row.get("volume", "")), str(row.get("source_chapter", ""))
      
      
      class RebuildMismatch(ValueError):
          def __init__(self, code: str, author_message: str) -> None:
              super().__init__(code)
              self.author_message = author_message
      
      
      REBUILD_MISMATCH_MESSAGE = (
          "重建章节表时发现,%s。照这样继续,已拆好的章节会和原文错开,所以章节表没有改。请选一种:"
          "① 换回上次拆文时用的那份原文再继续(推荐);② 换一个新目录,整本重新拆。"
      )
      
      
      def index_was_folded(old_rows: Sequence[Dict[str, Any]], new_rows: Sequence[Dict[str, Any]]) -> bool:
          """True when the existing index merged a prologue into chapter one.
      
          A folded index starts with a numbered chapter although the text opens with
          楔子/序章/第0章; a rebuild keeps that numbering instead of shifting it.
          """
          old_first = str(old_rows[0].get("source_chapter", "")) if old_rows else ""
          new_first = str(new_rows[0].get("source_chapter", "")) if new_rows else ""
          return (old_first.isdigit() and int(old_first) >= 1
                  and not (new_first.isdigit() and int(new_first) >= 1))
      
      
      def compare_rebuild(old_rows: Sequence[Dict[str, Any]], new_rows: Sequence[Dict[str, Any]]) -> List[int]:
          for position, old_row in enumerate(old_rows[:len(new_rows)]):
              if mapping_signature(old_row) != mapping_signature(new_rows[position]):
                  raise RebuildMismatch(
                      "chapter_mapping_ambiguous:position=%s" % (position + 1),
                      REBUILD_MISMATCH_MESSAGE % ("第%s章和上次的章节表对不上(上次是「%s」,这次是「%s」)" % (
                          position + 1, old_row.get("title", ""), new_rows[position]["title"])))
          if len(new_rows) < len(old_rows):
              raise RebuildMismatch("chapter_mapping_ambiguous:index_would_shrink",
                                    REBUILD_MISMATCH_MESSAGE % "这次认出的章节比上次少")
          return [position for position, row in enumerate(new_rows, start=1)
                  if position > len(old_rows) or old_rows[position - 1].get("chapter_sha256") != row["chapter_sha256"]]
      
      
      def parse_args() -> argparse.Namespace:
          parser = argparse.ArgumentParser(description=__doc__)
          parser.add_argument("--source", required=True, type=Path)
          parser.add_argument("--output", required=True, type=Path)
          parser.add_argument("--locator-path")
          parser.add_argument("--rebuild", action="store_true")
          parser.add_argument("--fold-prologue", action="store_true",
                              help="merge 楔子/序章/第0章 before the first numbered chapter into it")
          return parser.parse_args()
      
      
      def fail(error: str, author_message: Optional[str] = None) -> int:
          payload = {"ok": False, "error": error}  # type: Dict[str, Any]
          if author_message:
              payload["author_message"] = author_message
          print(json.dumps(payload, ensure_ascii=False))
          return 2
      
      
      def main() -> int:
          for stream in (sys.stdout, sys.stderr):
              if hasattr(stream, "reconfigure"):
                  stream.reconfigure(encoding="utf-8")
          args = parse_args()
          try:
              if not args.source.is_file():
                  raise ValueError("source_not_found:%s" % args.source)
              raw = args.source.read_bytes()
              source_hash = sha256(raw)
              locator_path = (args.locator_path or "原文/%s" % args.source.name).replace("\\", "/")
              if not locator_path.startswith("原文/") or ".." in locator_path.split("/"):
                  raise ValueError("locator_path_must_stay_under_original")
              if args.output.exists() and not args.output.is_file():
                  raise ValueError("output_is_not_file:%s" % args.output)
              if args.output.is_file() and not args.rebuild:
                  reusable = reusable_index(args.output, source_hash, locator_path)
                  if reusable is None:
                      return fail("existing_index_incompatible",
                                  "原文和上次建章节表时不一样了(内容、文件名或识别规则变了)。确认原文就是要拆的版本后,再重建章节表。")
                  _, existing_rows = reusable
                  print(json.dumps({"ok": True, "reused": True, "parsed_source": False,
                                    "chapters": len(existing_rows), "pending_chapters": []}, ensure_ascii=False))
                  return 0
              text = decode_source(raw)
              rows, folded = build_boundaries(text, locator_path, source_hash, args.fold_prologue)
              pending = list(range(1, len(rows) + 1))
              old_count = 0
              if args.output.is_file():
                  _, old_rows = read_existing(args.output)
                  old_count = len(old_rows)
                  if not args.fold_prologue and index_was_folded(old_rows, rows):
                      rows, folded = build_boundaries(text, locator_path, source_hash, True)
                  pending = compare_rebuild(old_rows, rows)
              else:
                  mapping = legacy_mapping_check(args.output.parent, rows)
                  if mapping:
                      return fail("chapter_mapping_ambiguous:%s:legacy_chapters=%s" % (
                          mapping["code"], ",".join(map(str, mapping["legacy_chapters"]))), str(mapping["author_message"]))
              data = csv_payload(rows)
              if not args.output.is_file() or args.output.read_bytes() != data:
                  atomic_write(args.output, data)
              print(json.dumps({
                  "ok": True, "reused": False, "parsed_source": True, "rebuilt": bool(old_count),
                  "chapters": len(rows), "empty_chapters": sum(row["status"] == "empty" for row in rows),
                  "pending_chapters": pending, "unchanged_chapters": len(rows) - len(pending),
                  "source_sha256": source_hash, "parser_version": PARSER_VERSION,
                  "folded_into_first_chapter": folded,
              }, ensure_ascii=False))
              return 0
          except RebuildMismatch as exc:
              return fail(str(exc), exc.author_message)
          except (OSError, UnicodeError, ValueError, csv.Error) as exc:
              return fail(str(exc))
      
      
      if __name__ == "__main__":
          raise SystemExit(main())
      
    • inspect_existing_assets.py 31.3 KB
      #!/usr/bin/env python3
      """Inspect reusable long-analysis assets without changing user files.
      
      Only the upstream directory contract is recognized: ``章节/第N章_摘要.md``,
      golden-three-chapter analyses, and the aggregate files. Missing chapters are
      reported exactly so a run only fills gaps.
      """
      
      from __future__ import annotations
      
      import argparse
      import csv
      import json
      import re
      import sys
      import unicodedata
      from pathlib import Path
      from typing import Iterable
      
      
      SUMMARY_RE = re.compile(r"^第0*(\d+)章_摘要\.md$")
      GOLDEN_RE = re.compile(r"^第0*(\d+)章_深度拆解\.md$")
      SCHEMA_RE = re.compile(r"schema_version\s*[::]\s*v?(\d+)", re.IGNORECASE)
      TOTAL_RE = re.compile(r"总章数\s*(?:[::]|\|)\s*(\d+)")
      TOTAL_FALLBACK_RE = re.compile(r"总章数\s+(\d+)\s*章?")
      TABLE_CHAPTER_TOTAL_RE = re.compile(r"\|\s*章节数\s*\|\s*(\d+)\s*(?:章)?\s*\|")
      COVERAGE_TOTAL_RE = re.compile(
          r"(?:输入覆盖\s*[::]\s*全文|精读覆盖\s*[::]\s*第\s*1\s*[—–-]\s*)(\d+)\s*章"
      )
      FINAL_RE = re.compile(r"(?:最终状态|当前状态)\s*[::]\s*([a-z0-9_]+)", re.IGNORECASE)
      SOURCE_HASH_RE = re.compile(
          r"(?:输入版本|SHA-256|source_sha256)\s*(?:[::]|\|)\s*([0-9a-f]{64})", re.IGNORECASE
      )
      PROJECTION_RE = re.compile(
          r"<!--\s*story-long-analyze:projection\s+runtime=(?P<runtime>[^\s]+)\s+"
          r"source=(?P<source>[^\s]+)(?:\s+[^>]*?)?\s*-->"
      )
      STATE_START = "<!-- story-long-analyze:runtime-state:start -->"
      STATE_END = "<!-- story-long-analyze:runtime-state:end -->"
      # v0.7.x wrote a 「章节边界」 table (章号 | 标题 | 起始行 | 字数) into _progress.md.
      BOUNDARY_HEADING_RE = re.compile(r"^#{1,6}\s*章节边界")
      TITLE_LABEL_RE = re.compile(
          r"^\s*(?:第(?P<number>[〇零一二三四五六七八九十百千万两0-9]+)章|第[〇零一二三四五六七八九十百千万两0-9]+[卷回节]"
          r"|卷[〇零一二三四五六七八九十百千万两0-9]+|Chapter\s*(?P<english>[0-9]+)|(?P<numeric>[0-9]+)[.、](?![0-9])"
          r"|(?P<special>楔子|序章|引子|前言|后记|尾声|番外[〇零一二三四五六七八九十百千万两0-9]*))",
          re.IGNORECASE,
      )
      # Author notes appended to a heading, e.g. 「(求收藏)」「【二合一】」.
      TITLE_NOTE_RE = re.compile(r"[(\[【〔〖][^()\[\]【】〔〕〖〗]*[)\]】〕〗]\s*$")
      TITLE_NOISE_RE = re.compile(r"[\s\-—::、.,;!?\"“”'‘’《》「」『』()\[\]【】〔〕〖〗]+")
      
      
      def nonempty(path: Path) -> bool:
          try:
              return path.is_file() and path.stat().st_size > 0
          except OSError:
              return False
      
      
      def any_nonempty(paths: Iterable[Path]) -> bool:
          return any(nonempty(path) for path in paths)
      
      
      def read_text(path: Path) -> str | None:
          if not nonempty(path):
              return None
          try:
              return path.read_text(encoding="utf-8-sig")
          except (OSError, UnicodeError):
              return None
      
      
      def read_progress(path: Path) -> tuple[int | None, int | None, str | None]:
          text = read_text(path)
          if text is None:
              return None, None, "unreadable" if path.exists() else None
          schema_match = SCHEMA_RE.search(text)
          total_match = TOTAL_RE.search(text) or COVERAGE_TOTAL_RE.search(text)
          final_match = FINAL_RE.search(text)
          return (
              int(schema_match.group(1)) if schema_match else None,
              int(total_match.group(1)) if total_match else None,
              final_match.group(1).lower() if final_match else None,
          )
      
      
      def read_declared_total(paths: Iterable[Path]) -> tuple[int | None, str | None]:
          for path in paths:
              text = read_text(path)
              if text is None:
                  continue
              match = TOTAL_RE.search(text) or TOTAL_FALLBACK_RE.search(text) or TABLE_CHAPTER_TOTAL_RE.search(text)
              if match and int(match.group(1)) > 0:
                  return int(match.group(1)), path.as_posix()
          return None, None
      
      
      def read_source_hash(paths: Iterable[Path]) -> tuple[str | None, str | None]:
          for path in paths:
              text = read_text(path)
              if text is None:
                  continue
              match = SOURCE_HASH_RE.search(text)
              if match:
                  return match.group(1).lower(), path.as_posix()
          return None, None
      
      
      def read_index_chapters(path: Path) -> tuple[list[int], list[str]]:
          if not nonempty(path):
              return [], []
          errors: list[str] = []
          chapters: list[int] = []
          try:
              with path.open("r", encoding="utf-8-sig", newline="") as handle:
                  reader = csv.DictReader(handle)
                  required = {"chapter", "char_count", "source_locator", "status", "source_sha256"}
                  missing = required - set(reader.fieldnames or [])
                  if missing:
                      return [], ["chapter_index.csv 缺列:" + ", ".join(sorted(missing))]
                  for row_number, row in enumerate(reader, start=2):
                      try:
                          chapters.append(int(row["chapter"]))
                      except (TypeError, ValueError):
                          errors.append(f"chapter_index.csv 第 {row_number} 行章号无效")
          except (OSError, UnicodeError, csv.Error) as exc:
              return [], [f"chapter_index.csv 不可读:{exc}"]
          if len(chapters) != len(set(chapters)):
              errors.append("chapter_index.csv 存在重复章号")
          unique = sorted(set(chapters))
          if unique and unique != list(range(1, max(unique) + 1)):
              errors.append("chapter_index.csv 章号不连续")
          return unique, errors
      
      
      def read_index_identities(path: Path) -> list[dict[str, str]]:
          if not nonempty(path):
              return []
          try:
              with path.open("r", encoding="utf-8-sig", newline="") as handle:
                  return [dict(row) for row in csv.DictReader(handle)]
          except (OSError, UnicodeError, csv.Error):
              return []
      
      
      def read_managed_stages(text: str | None) -> tuple[bool, dict[str, dict[str, str]]]:
          if not text or text.count(STATE_START) != 1 or text.count(STATE_END) != 1:
              return False, {}
          match = re.search(re.escape(STATE_START) + r"(.*?)" + re.escape(STATE_END), text, re.DOTALL)
          if not match:
              return False, {}
          stages: dict[str, dict[str, str]] = {}
          section = None
          for line in match.group(1).splitlines():
              if line.strip() == "### 阶段状态":
                  section = "stages"
                  continue
              if line.startswith("### "):
                  section = None
                  continue
              if section != "stages" or not line.lstrip().startswith("|"):
                  continue
              cells = [cell.strip() for cell in line.strip().strip("|").split("|")]
              if len(cells) >= 3 and cells[0] not in {"阶段", "---"}:
                  stages[cells[0].lower()] = {"status": cells[1].lower(), "output": cells[2]}
          return True, stages
      
      
      def collect_numbered_files(
          directory: Path, pattern: re.Pattern[str]
      ) -> tuple[list[int], dict[int, Path], list[str]]:
          chapters: list[int] = []
          sources: dict[int, Path] = {}
          duplicates: list[str] = []
          if not directory.is_dir():
              return chapters, sources, duplicates
          for path in sorted(directory.iterdir()):
              match = pattern.match(path.name)
              if not match or not nonempty(path):
                  continue
              chapter = int(match.group(1))
              if chapter in sources:
                  duplicates.append(f"第{chapter}章:{sources[chapter].as_posix()} / {path.as_posix()}")
                  continue
              chapters.append(chapter)
              sources[chapter] = path
          return sorted(chapters), sources, duplicates
      
      
      def compact_ranges(chapters: Iterable[int]) -> list[str]:
          values = sorted(set(chapters))
          if not values:
              return []
          ranges: list[str] = []
          start = previous = values[0]
          for chapter in values[1:]:
              if chapter == previous + 1:
                  previous = chapter
                  continue
              ranges.append(str(start) if start == previous else f"{start}-{previous}")
              start = previous = chapter
          ranges.append(str(start) if start == previous else f"{start}-{previous}")
          return ranges
      
      
      def relative(root: Path, path: Path) -> str:
          try:
              return path.resolve().relative_to(root.resolve()).as_posix()
          except (OSError, ValueError):
              return path.as_posix()
      
      
      def summary_kind(path: Path) -> str:
          text = read_text(path) or ""
          return "three_script_projection" if PROJECTION_RE.search(text[:1000]) else "upstream_summary"
      
      
      def read_legacy_boundaries(text: str | None) -> list[dict[str, object]]:
          """Rows of the v0.7.x 「章节边界」 table: old chapter number, title, start line."""
          if not text:
              return []
          rows: list[dict[str, object]] = []
          in_section = False
          header: list[str] | None = None
          for line in text.splitlines():
              stripped = line.strip()
              if stripped.startswith("#"):
                  if in_section and rows:
                      break
                  in_section = bool(BOUNDARY_HEADING_RE.match(stripped))
                  header = None
                  continue
              if not in_section or not stripped.startswith("|"):
                  continue
              cells = [cell.strip() for cell in stripped.strip("|").split("|")]
              if header is None:
                  if "章号" in cells:
                      header = cells
                  continue
              if not "".join(cells).strip("-: "):
                  continue
              record = dict(zip(header, cells))
              number = re.search(r"\d+", record.get("章号", ""))
              start = re.search(r"\d+", record.get("起始行", ""))
              if not number:
                  continue
              rows.append({
                  "chapter": int(number.group(0)),
                  "title": record.get("标题", ""),
                  "start_line": int(start.group(0)) if start else None,
              })
          return rows
      
      
      def title_parts(title: str) -> tuple[str, str, str]:
          """(label, strict key, loose key) of a heading; loose drops trailing author notes."""
          text = unicodedata.normalize("NFKC", str(title or "")).strip()
          label = ""
          for _ in range(3):
              match = TITLE_LABEL_RE.match(text)
              if not match or not match.group(0).strip():
                  break
              for group in ("number", "english", "numeric", "special"):
                  if match.group(group):
                      label = match.group(group)
              text = text[match.end():]
          strict = TITLE_NOISE_RE.sub("", text)
          loose = text
          while TITLE_NOTE_RE.search(loose):
              loose = TITLE_NOTE_RE.sub("", loose)
          return label, strict, TITLE_NOISE_RE.sub("", loose) or strict
      
      
      def label_matches(label: str, row: dict[str, object]) -> bool:
          source = str(row.get("source_chapter", "")).strip()
          if not label or not source:
              return False
          if label == source:
              return True
          try:
              from build_chapter_index import parse_number
              return source.isdigit() and parse_number(label) == int(source)
          except ValueError:
              return False
      
      
      def title_candidates(title: str, index_rows: list[dict[str, object]]) -> list[dict[str, object]]:
          """Index rows whose title agrees with an old table title, best tier first."""
          label, strict, loose = title_parts(title)
          if strict:
              keys = [(row, title_parts(str(row.get("title", "")))) for row in index_rows]
              for tier in (
                  [row for row, (_, row_strict, _) in keys if row_strict == strict],
                  [row for row, (_, _, row_loose) in keys
                   if row_loose and (row_loose == loose or row_loose in loose or loose in row_loose)],
              ):
                  if tier:
                      return tier
          # Retitled or title-less headings still carry their chapter number.
          return [row for row in index_rows if label_matches(label, row)]
      
      
      def map_legacy_row(old: dict[str, object], index_rows: list[dict[str, object]]) -> int | None:
          """Index chapter an old boundary row points at.
      
          A start line that is exactly a chapter's heading line decides on its own.
          Otherwise the title picks the chapter (nearest to the old start line when
          several match); a title-less row falls back to the chapter holding the line.
          """
          start = old.get("start_line")
          if isinstance(start, int):
              for row in index_rows:
                  if int(row["start_line"]) == start:
                      return int(row["chapter"])
          matches = title_candidates(str(old.get("title", "")), index_rows)
          if not isinstance(start, int):
              return int(matches[0]["chapter"]) if len(matches) == 1 else None
          if not matches and not any(title_parts(str(old.get("title", "")))[:2]):
              matches = [row for row in index_rows if int(row["start_line"]) <= start <= int(row["end_line"])]
      
          def distance(row: dict[str, object]) -> int:
              first, last = int(row["start_line"]), int(row["end_line"])
              return 0 if first <= start <= last else min(abs(start - first), abs(start - last))
      
          ranked = sorted(matches, key=distance)
          if not ranked or (len(ranked) > 1 and distance(ranked[0]) == distance(ranked[1])):
              return None
          return int(ranked[0]["chapter"])
      
      
      def leading_special_labels(index_rows: list[dict[str, object]]) -> list[str]:
          """Prologue-like chapters (楔子、序章、第0章…) before the first numbered chapter."""
          labels: list[str] = []
          for row in index_rows:
              source = str(row.get("source_chapter", "")).strip()
              if source.isdigit() and int(source) >= 1:
                  break
              labels.append("第0章" if source == "0" else source)
          return labels
      
      
      def mapping_author_message(detail: str, labels: list[str], done: str) -> str:
          """One plain-language stop message with the author's choices (no field names)."""
          if labels:
              prologue = "、".join("「%s」" % label for label in labels)
              return (
                  "先停一下:%s。这次重新识别章节时,开头的%s被算成了单独一章,"
                  "照这样续拆,旧文件会整体错开一章——%s没人拆、有的章被拆两遍。请选一种:"
                  "① 按旧章号继续(推荐,旧拆文当时没把%s算作一章时选这个):%s并进第一章,"
                  "已拆好的%s原样复用,%s不单独拆;"
                  "② %s单独算一章:这本书已有的拆文结果全部挪进备份目录(不删除),按新章号重拆;"
                  "③ 换一个新目录,整本重新拆。"
                  % (detail, prologue, prologue, prologue, prologue, done, prologue, prologue)
              )
          return (
              "先停一下:%s,没法确认已拆好的%s各对应哪一章。请选一种:"
              "① 换一个新目录,整本重新拆(推荐);② 如果原文换过版本或被改过,换回拆文时用的那份原文再续拆。"
              % (detail, done)
          )
      
      
      def legacy_mapping_check(root: Path, index_rows: list[dict[str, object]]) -> dict[str, object] | None:
          """Stop when files numbered by an older run no longer match the chapter index.
      
          Old summaries always predate the index. Golden-chapter analyses count as old
          when ``_progress.md`` comes from v0.7.x (a 「章节边界」 table or no runtime
          block); this run's Stage 1 writes them against the index. The old boundary
          table, when present, decides; otherwise a source that does not start at
          chapter one is ambiguous.
          """
          if not index_rows:
              return None
          progress_text = read_text(root / "_progress.md")
          boundaries = read_legacy_boundaries(progress_text)
          legacy_progress = progress_text is not None and (bool(boundaries) or STATE_START not in progress_text)
          _, summary_sources, _ = collect_numbered_files(root / "章节", SUMMARY_RE)
          old_chapters = {chapter for chapter, path in summary_sources.items() if summary_kind(path) == "upstream_summary"}
          old_kinds = ["逐章摘要"] if old_chapters else []
          if legacy_progress:
              golden, _, _ = collect_numbered_files(root / "章节", GOLDEN_RE)
              if golden:
                  old_chapters.update(golden)
                  old_kinds.insert(0, "开头三章拆解")
          if not old_chapters:
              return None
          rows = sorted(index_rows, key=lambda row: int(row["chapter"]))
          labels = leading_special_labels(rows)
          done = "、".join(old_kinds)
          if boundaries:
              by_old = {int(row["chapter"]): row for row in boundaries}
              # Chapters the old run appended after its table stay on the same numbering
              # as long as every chapter the table does cover lines up.
              checked = sorted(chapter for chapter in old_chapters if chapter <= max(by_old)) or sorted(old_chapters)
              shifted = []
              unknown = []
              for chapter in checked:
                  old = by_old.get(chapter)
                  mapped = map_legacy_row(old, rows) if old else None
                  if mapped is None:
                      unknown.append(chapter)
                  elif mapped != chapter:
                      shifted.append((chapter, mapped))
              if not shifted and not unknown:
                  return None
              if shifted:
                  old_number, new_number = shifted[0]
                  title = next((str(row.get("title", "")) for row in rows if int(row["chapter"]) == new_number), "")
                  detail = "旧拆文的第%s章「%s」,在这次的章节表里是第%s章" % (old_number, title, new_number)
              else:
                  detail = "旧进度里的章节表和原文对不上(第%s章找不到)" % compact_ranges(unknown)[0]
              code = "legacy_boundary_shift" if shifted else "legacy_boundary_unmatched"
          else:
              first_source = str(rows[0].get("source_chapter", "")).strip()
              if first_source.isdigit() and int(first_source) == 1:
                  return None
              detail = "原文开头是「%s」,而旧拆文没有留下能核对章号的章节表" % (first_source or "未知")
              code = "legacy_identity_unverifiable"
          return {
              "code": code,
              "conflict": "旧拆文章号与当前索引不一致:" + detail,
              "author_message": mapping_author_message(detail, labels, done),
              "fold_prologue_available": bool(labels),
              "legacy_chapters": sorted(old_chapters),
          }
      
      
      def inspect(root: Path, expected_override: int | None) -> dict[str, object]:
          root = root.resolve()
          schema, progress_total, final_state = read_progress(root / "_progress.md")
          progress_text = read_text(root / "_progress.md")
          index_chapters, index_errors = read_index_chapters(root / "chapter_index.csv")
          index_identities = read_index_identities(root / "chapter_index.csv")
          managed_state_present, managed_stages = read_managed_stages(progress_text)
      
          summaries, summary_sources, duplicate_summaries = collect_numbered_files(root / "章节", SUMMARY_RE)
          golden, golden_sources, duplicate_golden = collect_numbered_files(root / "章节", GOLDEN_RE)
      
          preferred_paths: dict[int, str] = {}
          preferred_kinds: dict[int, str] = {}
          for chapter, path in summary_sources.items():
              preferred_paths[chapter] = relative(root, path)
              preferred_kinds[chapter] = summary_kind(path)
          for chapter, path in golden_sources.items():
              if chapter not in preferred_paths:
                  preferred_paths[chapter] = relative(root, path)
                  preferred_kinds[chapter] = "golden_analysis"
      
          expected = expected_override
          expected_source = "argument" if expected is not None else None
          if expected is None and index_chapters and not index_errors:
              expected = max(index_chapters)
              expected_source = "chapter_index.csv"
          if expected is None and progress_total is not None and final_state in {"completed", "completed_with_errors"}:
              expected = progress_total
              expected_source = "_progress.md"
          if expected is None:
              expected, source = read_declared_total((root / "拆文报告.md", root / "概要.md", root / "快速预览.md"))
              expected_source = Path(source).name if source else None
          if expected is None and progress_total is not None:
              expected = progress_total
              expected_source = "_progress.md"
      
          expected_set = set(range(1, expected + 1)) if expected else set()
          union_semantic_set = set(preferred_paths)
          missing_semantic = sorted(expected_set - union_semantic_set)
          out_of_range_semantic = sorted(union_semantic_set - expected_set) if expected else []
          missing_summaries = sorted(expected_set - set(summaries))
          out_of_range_summaries = sorted(set(summaries) - expected_set) if expected else []
      
          primary = {
              "emotion_module": nonempty(root / "剧情" / "情绪模块.md"),
              "rhythm": nonempty(root / "剧情" / "节奏.md"),
          }
          plot_dir = root / "剧情"
          assets = {
              "report": nonempty(root / "拆文报告.md"),
              "style": nonempty(root / "文风.md"),
              "storyline": nonempty(plot_dir / "故事线.md"),
              "plot_units": any_nonempty(
                  path
                  for path in plot_dir.glob("*.md")
                  if path.name not in {"README.md", "故事线.md", "节奏.md", "情绪模块.md", "散落情节.md"}
              )
              if plot_dir.is_dir()
              else False,
              "characters": any_nonempty((root / "角色").glob("*.md")) if (root / "角色").is_dir() else False,
              "settings": any_nonempty((root / "设定").rglob("*.md")) if (root / "设定").is_dir() else False,
              "batch_cache": any_nonempty((root / "_analysis_cache").glob("批次-*.md"))
              if (root / "_analysis_cache").is_dir()
              else False,
              "chapter_index": nonempty(root / "chapter_index.csv"),
          }
      
          has_any = bool(union_semantic_set) or any(assets.values()) or any(primary.values()) or nonempty(root / "_progress.md")
          semantic_coverage_complete = bool(union_semantic_set) and (
              (expected is not None and not missing_semantic and not out_of_range_semantic)
              or (expected is None and final_state in {"completed", "completed_with_errors"})
          )
          summary_coverage_complete = (
              bool(summaries) and expected is not None and not missing_summaries and not out_of_range_summaries
          )
          standard_coverage_complete = bool(union_semantic_set) and expected is not None and union_semantic_set == expected_set
          legacy_core = assets["report"] and summary_coverage_complete and (assets["storyline"] or assets["plot_units"])
          managed_pipeline_complete = all(
              managed_stages.get(stage, {}).get("status") == "completed"
              for stage in ("stage1", "stage2", "stage3", "stage4", "stage5", "stage6")
          )
          usable_upstream_complete = (
              all(primary.values())
              and assets["report"]
              and standard_coverage_complete
              and (
                  managed_pipeline_complete
                  or (
                      final_state in {"completed", "completed_with_errors"}
                      and (assets["storyline"] or assets["plot_units"])
                  )
              )
          )
      
          if expected:
              preferred_paths = {chapter: path for chapter, path in preferred_paths.items() if chapter in expected_set}
              preferred_kinds = {chapter: kind for chapter, kind in preferred_kinds.items() if chapter in expected_set}
          semantic_set = set(preferred_paths)
      
          full_result_available = usable_upstream_complete or legacy_core
          mixed_sources = len(set(preferred_kinds.values())) > 1
      
          # Files numbered by an older run can drift from the index; this run's golden
          # analyses and projections are written against it.
          mapping_conflicts: list[str] = []
          mapping_blocked_chapters: list[int] = []
          mapping = legacy_mapping_check(root, index_identities) if index_identities else None
          if mapping:
              mapping_blocked_chapters = sorted(expected_set or set(index_chapters))
              mapping_conflicts.append(str(mapping["conflict"]))
      
          stage_repairs: list[str] = []
          if semantic_coverage_complete:
              legacy_completed = final_state in {"completed", "completed_with_errors"}
              if not primary["emotion_module"]:
                  stage_repairs.append("stage3_emotion")
              if not primary["rhythm"]:
                  stage_repairs.append("stage3_rhythm")
              if not legacy_completed and (not assets["characters"] or not assets["settings"]):
                  stage_repairs.append("stage4")
              if not assets["report"]:
                  stage_repairs.append("stage5")
              if not assets["style"]:
                  stage_repairs.append("stage6_style")
              for stage in ("stage3", "stage4", "stage5", "stage6"):
                  row = managed_stages.get(stage)
                  if row and row.get("status") != "completed" and stage not in stage_repairs:
                      stage_repairs.append(stage)
                  elif managed_state_present and not legacy_completed and row is None:
                      # New three-script runs require a completed stage row even when
                      # a file was manually placed before the interruption.
                      if stage not in stage_repairs and not any(item.startswith(stage + "_") for item in stage_repairs):
                          stage_repairs.append(stage)
      
          if not has_any:
              classification = "empty"
              recommended_path = "new_analysis"
          elif usable_upstream_complete:
              classification = "current_complete"
              recommended_path = "repair_stages" if stage_repairs else "direct_use"
          elif legacy_core:
              classification = "legacy_complete"
              recommended_path = "repair_stages" if stage_repairs else "direct_use"
          elif semantic_coverage_complete:
              classification = "partial_or_mixed"
              recommended_path = "repair_stages" if stage_repairs else "enhance_existing"
          else:
              classification = "partial_or_mixed"
              recommended_path = "continue_partial" if expected and missing_semantic else "enhance_existing"
      
          conflicts = list(index_errors)
          conflicts.extend(f"重复摘要:{item}" for item in duplicate_summaries)
          conflicts.extend(f"重复黄金三章:{item}" for item in duplicate_golden)
          conflicts.extend(mapping_conflicts)
          if final_state == "completed" and not usable_upstream_complete:
              conflicts.append("进度标记 completed,但当前主产物或上游逐章覆盖不完整")
          if final_state == "completed_with_errors" and not semantic_coverage_complete:
              conflicts.append("进度标记 completed_with_errors,需按失败与待核记录确认可用范围")
          if index_chapters and expected and max(index_chapters) != expected:
              conflicts.append("机械索引章数与期望章数不一致")
      
          source_hash, source_hash_path = read_source_hash((root / "_progress.md",))
      
          return {
              "root": str(root),
              "classification": classification,
              "recommended_path": recommended_path,
              "schema_version": schema,
              "final_state": final_state,
              "expected_chapters": expected,
              "expected_chapters_source": expected_source,
              # Original keys remain for callers that need the upstream interface itself.
              "completed_summary_chapters": summaries,
              "missing_summary_chapters": missing_summaries,
              "out_of_range_summary_chapters": out_of_range_summaries,
              # Runtime routing must use the selected semantic family below, never summary filenames alone.
              "completed_semantic_chapters": sorted(semantic_set),
              "missing_semantic_chapters": missing_semantic,
              "out_of_range_semantic_chapters": out_of_range_semantic,
              "semantic_coverage_complete": semantic_coverage_complete,
              "full_result_available": full_result_available,
              "chapter_sources": {
                  "priority": ["upstream_summary", "three_script_projection", "golden_analysis"],
                  "upstream_summary": {"chapters": summaries, "ranges": compact_ranges(summaries)},
                  "golden_analysis": {"chapters": golden, "ranges": compact_ranges(golden)},
                  "preferred_by_chapter": {str(chapter): preferred_kinds[chapter] for chapter in sorted(preferred_kinds)},
                  "preferred_paths": {str(chapter): preferred_paths[chapter] for chapter in sorted(preferred_paths)},
              },
              "source_hash": source_hash,
              "source_hash_source": source_hash_path,
              "primary_artifacts": primary,
              "reusable_assets": assets,
              "legacy_capabilities": {
                  "benchmark_reference": full_result_available or assets["report"] or assets["plot_units"] or bool(semantic_set),
                  "import_facts": bool(semantic_set),
                  "writing_style_reference": assets["style"],
              },
              "current_capabilities": {
                  "emotion_module_recall": primary["emotion_module"],
                  "rhythm_reference_recall": primary["rhythm"],
                  "source_location": assets["chapter_index"],
              },
              "mixed_sources": mixed_sources,
              "stage_repairs": stage_repairs,
              "managed_stage_status": managed_stages,
              "chapter_mapping_conflicts": mapping_conflicts,
              "chapter_mapping_blocked_chapters": mapping_blocked_chapters,
              "chapter_mapping_author_message": mapping["author_message"] if mapping else None,
              "provenance_requires_review": mixed_sources or (has_any and schema is None),
              "conflicts": conflicts,
              "notes": [
                  "本检查只扫描传入书目目录中的上游标准路径,不扫描其他项目或磁盘。",
                  "运行路由必须使用 completed_semantic_chapters;缺少逐章摘要文件不等于缺少语义成果。",
                  "direct_use 表示默认直接复用;只有用户明确要求增强时才二次提取已有成果。",
                  "整本重拆换一个新目录;本检查不提供就地重拆入口。",
              ],
          }
      
      
      def compact_payload(payload: dict[str, object]) -> dict[str, object]:
          """Remove per-chapter arrays from the human/model-facing inspection view."""
          result = dict(payload)
          semantic = result.pop("completed_semantic_chapters")
          missing = result.pop("missing_semantic_chapters")
          result["semantic_coverage"] = {
              "completed_count": len(semantic),
              "completed_ranges": compact_ranges(semantic),
              "missing_count": len(missing),
              "missing_ranges": compact_ranges(missing),
          }
          result.pop("completed_summary_chapters", None)
          result.pop("missing_summary_chapters", None)
          result.pop("out_of_range_summary_chapters", None)
          result.pop("out_of_range_semantic_chapters", None)
          source_info = dict(result["chapter_sources"])
          source_info.pop("preferred_by_chapter", None)
          source_info.pop("preferred_paths", None)
          for key in ("upstream_summary", "golden_analysis"):
              entry = dict(source_info[key])
              entry["count"] = len(entry.pop("chapters"))
              source_info[key] = entry
          result["chapter_sources"] = source_info
          return result
      
      
      def main() -> int:
          for stream in (sys.stdout, sys.stderr):
              if hasattr(stream, "reconfigure"):
                  stream.reconfigure(encoding="utf-8")
          parser = argparse.ArgumentParser(description=__doc__)
          parser.add_argument("--root", type=Path, required=True, help="拆文库/{书名} 目录")
          parser.add_argument("--expected-chapters", type=int, help="已知总章数;省略时按标准路径优先推断")
          parser.add_argument("--compact", action="store_true", help="省略逐章数组,输出适合运行路由读取的范围摘要")
          args = parser.parse_args()
          if args.expected_chapters is not None and args.expected_chapters < 1:
              parser.error("--expected-chapters 必须大于 0")
          try:
              if not args.root.exists():
                  raise ValueError("root_not_found:%s" % args.root)
              if not args.root.is_dir():
                  raise ValueError("root_is_not_directory:%s" % args.root)
              # Force a directory read here so an unreadable path fails before it can
              # be mistaken for an empty new-analysis project.
              next(args.root.iterdir(), None)
              payload = inspect(args.root, args.expected_chapters)
              if args.compact:
                  payload = compact_payload(payload)
              print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True))
              return 0
          except (OSError, UnicodeError, ValueError) as exc:
              print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False))
              return 2
      
      
      if __name__ == "__main__":
          raise SystemExit(main())
      
    • inspiration_index.py 38.4 KB
      #!/usr/bin/env python3
      """Register inspiration atoms from EM mechanism cards, then validate/query the index.
      
      三层灵感库的机械层。原子灵感(IA)不再生成独立文件:每个 IA 是
      `灵感索引.csv` 里的一行登记,机制全文只在 `拆文库/{书}/剧情/情绪模块.md`
      的 EM 卡一处维护,需要时按 ID 回查。NM/CBA 卡内禁止路径引用,
      来源只记 `书名/EM-xxx` 式裸 ID;路径解析统一走索引。
      """
      
      from __future__ import annotations
      
      import argparse
      import csv
      import json
      import os
      import re
      import sys
      import tempfile
      from pathlib import Path
      from typing import Any
      
      COLUMNS = (
          "item_id",
          "layer",
          "title",
          "source_book",
          "path",
          "source_ids",
          "novel_count",
          "atom_count",
          "grade",
          "tags",
          "status",
      )
      LAYERS = {"原子灵感": "IA-", "单小说灵感合并": "NM-", "跨书灵感聚合": "CBA-"}
      TAG_AXES = {"题材", "读者需求", "情绪", "关系动作", "剧情功能", "节奏位置", "适用阶段", "风险"}
      REQUIRED_CBA_AXES = {"题材", "读者需求", "情绪", "剧情功能", "适用阶段", "风险"}
      CORE_QUERY_AXES = {"题材", "读者需求", "情绪", "剧情功能", "适用阶段"}
      # EM 完整卡必须具备的五个灵感映射字段(缺失=Stage 3 卡片质量问题,报错回拆文侧修复)
      EM_REQUIRED_FIELDS = ("读者想看什么", "情绪链", "戏剧单元", "可替换项", "不可照搬")
      # 专名泄漏扫描范围:排除「不可照搬」——该字段的职责就是点名原书专名
      EM_LEAK_SCAN_FIELDS = ("读者想看什么", "情绪链", "戏剧单元", "可替换项")
      EM_HEADER_RE = re.compile(r"^###\s+[*_]*(EM-[0-9]{2,})(?![0-9])[*_]*\s*(?:[·\-—::||]\s*)?(.*?)[\s*_]*$")
      # 以 EM 编号开头、却不是 `### EM-xxx` 的标题行:下一张卡会被静默并进上一张
      EM_MISPLACED_HEADER_RE = re.compile(r"^#{1,6}\s+[*_【\[]*EM-[0-9]")
      EM_TABLE_SEPARATOR_RE = re.compile(r"^:?-+:?$")
      EM_INDEX_ID_RE = re.compile(r"(EM-[0-9]{2,})")
      # 字段行的两种体裁:表格 `| 字段 | 值 |` 与粗体列表 `- **字段**:值`(前导 `- ` 可省)
      EM_BOLD_FIELD_RE = re.compile(r"^[-*]?\s*\*{2,3}([^*]+?)(?:[::]\*{2,3}|\*{2,3}\s*[::])\s*(.*)$")
      # 同义字段名归一;表头行的首列词不作为字段
      EM_FIELD_ALIASES = {"不可照搬项": "不可照搬", "可替换项目": "可替换项"}
      EM_TABLE_HEADER_KEYS = {"字段", "维度", "---", ""}
      CARD_PATH_RE = re.compile(r"\]\(|\.md\)|原子灵感/|单小说灵感合并/|跨书灵感聚合/|拆文库/")
      
      
      def atomic_write_text(path: Path, text: str, encoding: str = "utf-8") -> None:
          path.parent.mkdir(parents=True, exist_ok=True)
          fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
          try:
              with os.fdopen(fd, "w", encoding=encoding, newline="") as handle:
                  handle.write(text)
                  handle.flush()
                  os.fsync(handle.fileno())
              os.replace(temp_name, path)
          except BaseException:
              try:
                  os.unlink(temp_name)
              except OSError:
                  pass
              raise
      
      
      def csv_text(rows: list[dict[str, str]]) -> str:
          import io
      
          buffer = io.StringIO(newline="")
          writer = csv.DictWriter(buffer, fieldnames=COLUMNS, lineterminator="\n")
          writer.writeheader()
          writer.writerows(rows)
          return "" + buffer.getvalue()
      
      
      def normalize_em_field(key: str) -> str:
          """去掉字段名上的粗体标记与空白,再按同义表归一。"""
          return EM_FIELD_ALIASES.get(key.strip().strip("*").strip(), key.strip().strip("*").strip())
      
      
      def parse_em_module(module_text: str) -> tuple[list[dict[str, str]], list[tuple[str, str]], list[str]]:
          """Return (complete cards, index-only entries, structural problems) from 情绪模块.md text.
      
          完整卡=`### EM-xxx 名称` 小节内的字段行,表格 `|字段|内容|` 与粗体列表 `- **字段**:内容` 都接受;
          粗体字段同行没写值时,吸收其后到下一个字段/标题为止的列表或段落行作为多行值。
          索引条目=「其他机制索引」小节里出现 EM-xxx 的行(机制ID|名称|…)。
          认不出的 EM 标题行和同卡重复字段记为问题:它们意味着下一张卡被并进了上一张。
          """
          lines = module_text.split("\n")
          cards: list[dict[str, str]] = []
          index_entries: list[tuple[str, str]] = []
          problems: list[str] = []
          current: dict[str, str] | None = None
          pending_field: str | None = None
          in_index_section = False
          for line in lines:
              stripped = line.strip()
              header = EM_HEADER_RE.match(stripped)
              if header:
                  current = {"em_id": header.group(1), "title": header.group(2)}
                  cards.append(current)
                  in_index_section = False
                  pending_field = None
                  continue
              if EM_MISPLACED_HEADER_RE.match(stripped):
                  problems.append(f"em_header_unrecognized:{stripped[:40]}——EM 卡标题须写成 `### EM-xxx 名称`")
                  current = None
                  pending_field = None
                  continue
              if stripped.startswith("## "):
                  in_index_section = "其他机制索引" in stripped
                  current = None
                  pending_field = None
                  continue
              if current is not None and stripped.startswith("|"):
                  pending_field = None
                  cells = [cell.strip() for cell in stripped.strip("|").split("|")]
                  if len(cells) >= 2:
                      key = normalize_em_field(cells[0])
                      if key not in EM_TABLE_HEADER_KEYS and not EM_TABLE_SEPARATOR_RE.match(key):
                          if key in EM_REQUIRED_FIELDS and key in current:
                              problems.append(f"{current['em_id']}:em_field_duplicate:{key}")
                          current[key] = cells[1]
                  continue
              if current is not None:
                  # 多行值:字段名行之后缩进的列表行一律是续行,哪怕它自己带粗体小标题
                  if pending_field is not None and stripped and line[:1] in (" ", "\t"):
                      appended = stripped.lstrip("-*").strip()
                      current[pending_field] = f"{current[pending_field]};{appended}" if current[pending_field] else appended
                      continue
                  bold = EM_BOLD_FIELD_RE.match(stripped)
                  if bold:
                      key = normalize_em_field(bold.group(1))
                      value = bold.group(2).strip()
                      if key in EM_REQUIRED_FIELDS and key in current:
                          problems.append(f"{current['em_id']}:em_field_duplicate:{key}")
                      current[key] = value
                      pending_field = None if value else key
                      continue
                  if pending_field is not None and stripped.startswith("#"):
                      pending_field = None
                      continue
                  if pending_field is not None and stripped:
                      appended = stripped.lstrip("-*").strip()
                      if appended:
                          current[pending_field] = (
                              f"{current[pending_field]};{appended}" if current[pending_field] else appended
                          )
                      continue
              if in_index_section and stripped:
                  match = EM_INDEX_ID_RE.search(stripped)
                  if match:
                      parts = [part.strip() for part in re.split(r"[||]", stripped.strip("|| ")) if part.strip()]
                      name = parts[1] if len(parts) >= 2 and parts[0] == match.group(1) else (parts[0] if parts else match.group(1))
                      if name == match.group(1) and len(parts) >= 2:
                          name = parts[1]
                      index_entries.append((match.group(1), name))
          return cards, index_entries, problems
      
      
      def resolve_workspace(root: Path, explicit: Path | None = None) -> Path:
          """定位含 `拆文库/` 的工作区——泄漏门名单与 EM 集合校验都依赖它。
      
          显式 `--workspace` 优先;否则从 `--root` 起向上最多探测 5 级。
          定位失败必须报错而不是回退空名单:名单为空集时泄漏检查等于没跑。
          """
          if explicit is not None:
              if (explicit / "拆文库").is_dir():
                  return explicit
              raise ValueError(f"workspace_invalid:{explicit} 下没有 拆文库/")
          candidate = root.resolve()
          for _ in range(6):
              if (candidate / "拆文库").is_dir():
                  return candidate
              if candidate.parent == candidate:
                  break
              candidate = candidate.parent
          raise ValueError("workspace_not_located:从 --root 向上未找到含 拆文库/ 的目录——用 --workspace 显式指定")
      
      
      EM_ARROW_RE = re.compile(r"→|⟶|->")
      
      
      def replaceable_antipattern_hits(value: str, names: set[str]) -> list[str]:
          """「专名→任意X」反模式:箭头左侧命中角色名单即高置信真引用。"""
          hits: set[str] = set()
          for segment in re.split(r"[;;,,、||]", value):
              if not EM_ARROW_RE.search(segment):
                  continue
              left = EM_ARROW_RE.split(segment)[0]
              hits.update(name for name in names if name in left)
          return sorted(hits)
      
      
      def character_names(workspace: Path, book: str) -> set[str]:
          role_dir = workspace / "拆文库" / book / "角色"
          names: set[str] = set()
          if role_dir.is_dir():
              for entry in role_dir.rglob("*.md"):
                  stem = re.sub(r"^\d+[-_.、\s]*", "", entry.stem)
                  if len(stem) >= 2 and stem not in {"角色关系", "README", "readme", "索引", "目录"}:
                      names.add(stem)
          return names
      
      
      def load_numbered_rows(root: Path) -> tuple[list[tuple[int, dict[str, str]]], list[str]]:
          """索引行连同其文件行号;列数不对的行报错并剔除,行号仍按文件实际位置。"""
          errors: list[str] = []
          index_path = root / "灵感索引.csv"
          try:
              with index_path.open("r", encoding="utf-8-sig", newline="") as handle:
                  reader = csv.DictReader(handle)
                  rows: list[tuple[int, dict[str, str]]] = []
                  for row in reader:
                      if None in row or None in row.values():
                          errors.append(f"line_{reader.line_num}:column_count_mismatch")
                          continue
                      rows.append((reader.line_num, row))
                  if tuple(reader.fieldnames or ()) != COLUMNS:
                      errors.append("index_header_mismatch")
          except (OSError, UnicodeError, csv.Error) as exc:
              return [], [f"index_unreadable:{exc}"]
          return rows, errors
      
      
      def load_rows(root: Path) -> tuple[list[dict[str, str]], list[str]]:
          numbered, errors = load_numbered_rows(root)
          return [row for _, row in numbered], errors
      
      
      class RegisterError(ValueError):
          """携带完整问题清单的登记失败——一次报全,避免逐轮试跑。"""
      
          def __init__(self, errors: list[str], warnings: list[str] | None = None) -> None:
              super().__init__("\n".join(errors))
              self.errors = errors
              self.warnings = warnings or []
      
      
      def analyze_module(root: Path, module_path: Path, book: str,
                         workspace: Path | None = None) -> dict[str, Any]:
          """解析并检查一本书的 EM 卡,收集全部 errors/warnings,不写盘。"""
          errors: list[str] = []
          warnings: list[str] = []
          if not book or book in {".", ".."} or "/" in book or "\\" in book:
              raise RegisterError([f"book_name_invalid:{book!r}——--book 只写书名本身"])
          try:
              ws = resolve_workspace(root, workspace)
          except ValueError as exc:
              raise RegisterError([str(exc)]) from exc
          try:
              module_text = module_path.read_text(encoding="utf-8-sig")
          except (OSError, UnicodeError) as exc:
              raise RegisterError([f"emotion_module_unreadable:{exc}"]) from exc
          cards, index_entries, problems = parse_em_module(module_text)
          if not cards and not index_entries:
              raise RegisterError(["emotion_module_has_no_em_cards"])
          errors.extend(problems)
      
          book_dir = ws / "拆文库" / book
          if not book_dir.is_dir():
              errors.append(f"book_dir_not_found:拆文库/{book}——工作区 {ws} 下没有这本书,泄漏门无法取角色名单")
          elif book_dir.resolve() not in module_path.resolve().parents:
              errors.append(f"module_book_mismatch:{module_path} 不在 拆文库/{book}/ 下——--book 与 --module 必须是同一本书")
          names = character_names(ws, book)
          if not names and book_dir.is_dir():
              warnings.append(f"character_roster_missing:拆文库/{book}/角色/ 不存在或为空——泄漏门本次没有名单可查,请人工确认卡内无专名")
      
          seen: set[str] = set()
          atom_rows: list[dict[str, str]] = []
          for card in cards:
              em_id = card["em_id"]
              if em_id in seen:
                  errors.append(f"em_id_duplicate:{em_id}")
                  continue
              seen.add(em_id)
              card_errors = 0
              if not card["title"].strip():
                  errors.append(f"{em_id}:em_title_missing——EM 卡标题须写成 `### {em_id} 名称`")
                  card_errors += 1
              absent = [field for field in EM_REQUIRED_FIELDS if field not in card]
              empty = [field for field in EM_REQUIRED_FIELDS if field in card and not card[field].strip()]
              if absent:
                  errors.append(f"{em_id}:em_fields_missing:{'|'.join(absent)}——请回 story-long-analyze Stage 3 补全该模块卡")
                  card_errors += 1
              if empty:
                  errors.append(f"{em_id}:em_field_value_empty:{'|'.join(empty)}——字段在但值为空;多行值须紧跟字段名行(支持列表/段落)")
                  card_errors += 1
      
              field_texts = {"标题": card.get("title", "")}
              field_texts.update({field: card.get(field, "") for field in EM_LEAK_SCAN_FIELDS})
              hit_locations = {
                  name: [field for field, text in field_texts.items() if name in text]
                  for name in sorted(names)
                  if any(name in text for text in field_texts.values())
              }
              antipattern = replaceable_antipattern_hits(card.get("可替换项", ""), names)
              non_portable = card.get("不可照搬", "")
              for name in antipattern:
                  errors.append(f"{em_id}:replaceable_antipattern:{name}——「专名→任意X」把专名写进抽象字段,改写成「功能位→任意X」")
                  card_errors += 1
              for name, locations in hit_locations.items():
                  if name in antipattern:
                      continue
                  if name in non_portable:
                      errors.append(f"{em_id}:source_specific_name_in_mechanism:{name}@{'|'.join(locations)}——本卡「不可照搬」已点名该专名却仍在抽象字段使用,请回 Stage 3 去专名后重试")
                      card_errors += 1
                  else:
                      warnings.append(f"{em_id}:leak_suspect:{name}@{'|'.join(locations)}——角色卡名与通用职能词无法机械区分,请人工复核;确认是专名请回 Stage 3 修卡")
              if card_errors == 0:
                  atom_rows.append(_ia_row(book, em_id, card["title"], grade="full"))
          for em_id, title in index_entries:
              if em_id in seen:
                  continue
              seen.add(em_id)
              for name in sorted(name for name in names if name in title):
                  warnings.append(f"{em_id}:leak_suspect:{name}@索引标题——请人工复核;确认是专名请回 Stage 3 修索引条目")
              atom_rows.append(_ia_row(book, em_id, title, grade="index"))
          return {
              "book": book,
              "cards_full": len(cards),
              "cards_index": sum(1 for em_id, _ in index_entries if em_id not in {card["em_id"] for card in cards}),
              "character_roster": len(names),
              "errors": errors,
              "warnings": warnings,
              "atom_rows": atom_rows,
          }
      
      
      def check_atoms(root: Path, module_path: Path, book: str,
                      workspace: Path | None = None) -> dict[str, Any]:
          """只读自检:报出一本书的卡数、名单规模与全部问题,不写任何文件。"""
          try:
              report = analyze_module(root, module_path, book, workspace)
          except RegisterError as exc:
              return {"ok": False, "book": book, "errors": exc.errors, "warnings": exc.warnings}
          report.pop("atom_rows")
          report["ok"] = not report["errors"]
          return report
      
      
      def register_atoms(root: Path, module_path: Path, book: str,
                         workspace: Path | None = None) -> dict[str, Any]:
          report = analyze_module(root, module_path, book, workspace)
          if report["errors"]:
              raise RegisterError(report["errors"], report["warnings"])
          atom_rows = report["atom_rows"]
      
          existing, errors = load_rows(root) if (root / "灵感索引.csv").is_file() else ([], [])
          if errors:
              raise RegisterError(errors, report["warnings"])
          preserved = [
              row
              for row in existing
              if not (row.get("layer") == "原子灵感" and row.get("source_book") == book)
          ]
          layer_order = {"原子灵感": 0, "单小说灵感合并": 1, "跨书灵感聚合": 2}
          combined = preserved + atom_rows
          combined.sort(
              key=lambda row: (
                  layer_order.get(row.get("layer", ""), 9),
                  row.get("source_book", ""),
                  row.get("item_id", ""),
              )
          )
          atomic_write_text(root / "灵感索引.csv", csv_text(combined), encoding="utf-8")
          return {
              "ok": True,
              "book": book,
              "atoms_full": sum(1 for row in atom_rows if row["grade"] == "full"),
              "atoms_index": sum(1 for row in atom_rows if row["grade"] == "index"),
              "index_writes": 1,
              "character_roster": report["character_roster"],
              "warnings": report["warnings"],
          }
      
      
      def _ia_row(book: str, em_id: str, title: str, grade: str) -> dict[str, str]:
          return {
              "item_id": em_id.replace("EM-", "IA-"),
              "layer": "原子灵感",
              "title": title.strip(),
              "source_book": book,
              "path": "",
              "source_ids": em_id,
              "novel_count": "1",
              "atom_count": "1",
              "grade": grade,
              "tags": "",
              "status": "active",
          }
      
      
      def parse_tags(raw: str) -> tuple[dict[str, set[str]], list[str]]:
          result: dict[str, set[str]] = {}
          errors: list[str] = []
          if not raw.strip():
              return result, errors
          for part in raw.split(";"):
              if not part.strip():
                  continue
              if "=" not in part:
                  errors.append(f"tag_missing_equals:{part}")
                  continue
              axis, values = (piece.strip() for piece in part.split("=", 1))
              if axis not in TAG_AXES:
                  errors.append(f"tag_axis_unknown:{axis}")
                  continue
              parsed = {value.strip() for value in values.split("|") if value.strip()}
              if not parsed:
                  errors.append(f"tag_value_empty:{axis}")
                  continue
              result.setdefault(axis, set()).update(parsed)
          return result, errors
      
      
      def positive_int(raw: str) -> int | None:
          try:
              value = int(raw)
          except (TypeError, ValueError):
              return None
          return value if value >= 1 else None
      
      
      def source_ids(raw: str) -> list[str]:
          return [item.strip() for item in re.split(r"[|;]", raw) if item.strip()]
      
      
      def load_book_em_ids(workspace: Path, source_book: str) -> tuple[set[str], str | None]:
          module_path = workspace / "拆文库" / source_book / "剧情" / "情绪模块.md"
          try:
              module_text = module_path.read_text(encoding="utf-8-sig")
          except (OSError, UnicodeError):
              return set(), "emotion_module_unreadable"
          cards, index_entries, _ = parse_em_module(module_text)
          ids = {card["em_id"] for card in cards} | {em_id for em_id, _ in index_entries}
          return ids, None
      
      
      def validate(root: Path, workspace: Path | None = None) -> list[str]:
          numbered, errors = load_numbered_rows(root)
          rows = [row for _, row in numbered]
          try:
              ws: Path | None = resolve_workspace(root, workspace)
          except ValueError as exc:
              ws = None
              workspace_error = str(exc)
          vocabulary = load_vocabulary(root)
          if vocabulary is not None:
              errors.extend(vocabulary_errors(vocabulary))
          seen: set[tuple[str, str, str]] = set()
          ia_by_book: dict[str, dict[str, dict[str, str]]] = {}
          nm_by_book: dict[str, dict[str, dict[str, str]]] = {}
          active_single_book_cba: dict[str, int] = {}
          for number, row in numbered:
              item_id = row.get("item_id", "").strip()
              layer = row.get("layer", "").strip()
              book = row.get("source_book", "").strip()
              prefix = LAYERS.get(layer)
              if not prefix:
                  errors.append(f"line_{number}:layer_invalid")
                  continue
              unique_key = (layer, book if layer != "跨书灵感聚合" else "", item_id)
              if not item_id.startswith(prefix) or unique_key in seen:
                  errors.append(f"line_{number}:item_id_invalid_or_duplicate")
              seen.add(unique_key)
              if layer == "原子灵感":
                  ia_by_book.setdefault(book, {})[item_id] = row
                  if row.get("path", "").strip():
                      errors.append(f"line_{number}:ia_must_not_have_card_file")
                  if row.get("grade", "").strip() not in {"full", "index"}:
                      errors.append(f"line_{number}:ia_grade_invalid")
              else:
                  if layer == "单小说灵感合并":
                      nm_by_book.setdefault(book, {})[item_id] = row
                  relative = row.get("path", "").strip().replace("\\", "/")
                  if not relative.startswith(f"{layer}/") or ".." in relative.split("/"):
                      errors.append(f"line_{number}:path_outside_layer")
                      card_text = ""
                  elif not (root / Path(relative)).is_file():
                      errors.append(f"line_{number}:path_missing")
                      card_text = ""
                  else:
                      try:
                          card_text = (root / Path(relative)).read_text(encoding="utf-8")
                      except (OSError, UnicodeError):
                          card_text = ""
                  if card_text and CARD_PATH_RE.search(card_text):
                      errors.append(f"line_{number}:path_reference_in_card——卡内只允许裸 ID,路径按 ID 查灵感索引.csv")
                  if row.get("grade", "").strip():
                      errors.append(f"line_{number}:grade_reserved_for_ia")
              tags, tag_errors = parse_tags(row.get("tags", ""))
              errors.extend(f"line_{number}:{error}" for error in tag_errors)
              if layer == "跨书灵感聚合":
                  missing = REQUIRED_CBA_AXES - set(tags)
                  if missing:
                      errors.append(f"line_{number}:cba_tags_missing:{'|'.join(sorted(missing))}")
                  if vocabulary is not None:
                      for axis, values in sorted(tags.items()):
                          if axis not in vocabulary:
                              continue
                          for value in sorted(values - set(vocabulary[axis])):
                              errors.append(f"line_{number}:tag_value_not_in_vocabulary:{axis}={value}——先对照 标签词表.md:同义就用表内值,确属新维度先受控扩表")
                  novel_count = positive_int(row.get("novel_count", ""))
                  if novel_count is None:
                      errors.append(f"line_{number}:novel_count_invalid")
                  if positive_int(row.get("atom_count", "")) is None:
                      errors.append(f"line_{number}:atom_count_invalid")
                  if not row.get("source_ids", "").strip():
                      errors.append(f"line_{number}:source_ids_missing")
                  if novel_count == 1 and "单书假设" not in card_text:
                      errors.append(f"line_{number}:single_book_hypothesis_marker_missing")
                  if novel_count is not None and novel_count >= 2 and "跨书重复验证" not in card_text:
                      errors.append(f"line_{number}:cross_book_validation_marker_missing")
              elif tags:
                  errors.append(f"line_{number}:tags_reserved_for_cba")
      
          for book, atoms in ia_by_book.items():
              if ws is None:
                  errors.append(f"book_{book}:{workspace_error}")
                  module_error: str | None = "workspace_not_located"
                  em_ids = set()
              else:
                  em_ids, module_error = load_book_em_ids(ws, book)
                  if module_error:
                      errors.append(f"book_{book}:{module_error}")
              registered: set[str] = set()
              for item_id, row in atoms.items():
                  refs = source_ids(row.get("source_ids", ""))
                  if len(refs) != 1 or not refs[0].startswith("EM-"):
                      errors.append(f"{book}/{item_id}:ia_source_em_invalid")
                      continue
                  if refs[0].replace("EM-", "IA-") != item_id:
                      errors.append(f"{book}/{item_id}:ia_id_must_mirror_em_id")
                  registered.add(refs[0])
                  if positive_int(row.get("novel_count", "")) != 1 or positive_int(row.get("atom_count", "")) != 1:
                      errors.append(f"{book}/{item_id}:ia_counts_must_equal_one")
              if not module_error and registered != em_ids:
                  missing = sorted(em_ids - registered)
                  extra = sorted(registered - em_ids)
                  errors.append(f"book_{book}:ia_em_set_mismatch:missing={missing}:extra={extra}")
      
          for book, merges in nm_by_book.items():
              atoms = ia_by_book.get(book, {})
              atom_em = {row.get("source_ids", "").strip() for row in atoms.values()}
              for item_id, row in merges.items():
                  refs = source_ids(row.get("source_ids", ""))
                  if len(refs) < 2:
                      errors.append(f"{book}/{item_id}:nm_requires_at_least_two_sources")
                      continue
                  unknown = sorted(set(refs) - atom_em)
                  if unknown:
                      errors.append(f"{book}/{item_id}:nm_source_em_missing:{unknown}")
                  if positive_int(row.get("novel_count", "")) != 1:
                      errors.append(f"{book}/{item_id}:nm_novel_count_must_equal_one")
                  if positive_int(row.get("atom_count", "")) != len(set(refs) & atom_em):
                      errors.append(f"{book}/{item_id}:nm_atom_count_mismatch")
      
          for row in rows:
              if row.get("layer") != "跨书灵感聚合":
                  continue
              cba_id = row.get("item_id", "").strip()
              expanded: set[tuple[str, str]] = set()
              unknown_refs: list[str] = []
              for raw_ref in source_ids(row.get("source_ids", "")):
                  if "/" not in raw_ref:
                      unknown_refs.append(raw_ref)
                      continue
                  book, ref = raw_ref.rsplit("/", 1)
                  if ref.startswith("NM-") and ref in nm_by_book.get(book, {}):
                      for em_ref in source_ids(nm_by_book[book][ref].get("source_ids", "")):
                          expanded.add((book, em_ref))
                  elif ref.startswith("EM-") and ref.replace("EM-", "IA-") in ia_by_book.get(book, {}):
                      expanded.add((book, ref))
                  else:
                      unknown_refs.append(raw_ref)
              if unknown_refs:
                  errors.append(f"{cba_id}:cba_source_missing:{sorted(unknown_refs)}")
              if not expanded:
                  errors.append(f"{cba_id}:cba_requires_sources")
                  continue
              novels = {book for book, _ in expanded}
              if len(novels) == 1 and row.get("status", "").strip() == "active":
                  only = next(iter(novels))
                  active_single_book_cba[only] = active_single_book_cba.get(only, 0) + 1
              if positive_int(row.get("novel_count", "")) != len(novels):
                  errors.append(f"{cba_id}:cba_novel_count_mismatch")
              if positive_int(row.get("atom_count", "")) != len(expanded):
                  errors.append(f"{cba_id}:cba_atom_count_mismatch")
          for book, count in sorted(active_single_book_cba.items()):
              if count > 3:
                  errors.append(f"book_{book}:active_single_book_cba_limit_exceeded:{count}")
          return errors
      
      
      def load_vocabulary(root: Path) -> dict[str, list[str]] | None:
          """读 `灵感库/标签词表.md`:`## 轴` 小节下的 `- 值` 行。文件不存在返回 None。"""
          path = root / "标签词表.md"
          if not path.is_file():
              return None
          vocabulary: dict[str, list[str]] = {}
          current_axis: str | None = None
          try:
              text = path.read_text(encoding="utf-8-sig")
          except (OSError, UnicodeError):
              return None
          for line in text.split("\n"):
              stripped = line.strip()
              if stripped.startswith("## "):
                  axis = stripped[3:].strip()
                  current_axis = axis if axis in TAG_AXES else None
                  if current_axis is not None:
                      vocabulary.setdefault(current_axis, [])
                  continue
              if current_axis is not None and stripped[:2] in {"- ", "* "}:
                  value = stripped[2:].split("(")[0].split("(")[0].strip()
                  if value and value not in vocabulary[current_axis]:
                      vocabulary[current_axis].append(value)
          return vocabulary
      
      
      def vocabulary_errors(vocabulary: dict[str, list[str]]) -> list[str]:
          """词表自身的健康检查:必填轴齐全。同义判定交给扩表时的人工比对。"""
          return [
              f"vocabulary_axis_missing:{axis}"
              for axis in sorted(REQUIRED_CBA_AXES - set(vocabulary))
          ]
      
      
      def coverage(root: Path) -> dict[str, Any]:
          """增量入库的机械导航:报出未进入任何 active CBA 闭包的原子。
      
          跨书聚合的 LLM 工作只需要看 uncovered 原子与现存单书假设卡,
          不必重读已覆盖的 EM 卡。只读,不写盘。
          """
          rows, errors = load_rows(root)
          if errors:
              raise ValueError(";".join(errors))
          ia_by_book: dict[str, set[str]] = {}
          nm_members: dict[tuple[str, str], set[str]] = {}
          for row in rows:
              book = row.get("source_book", "").strip()
              if row.get("layer") == "原子灵感" and row.get("status", "").strip() == "active":
                  for ref in source_ids(row.get("source_ids", "")):
                      ia_by_book.setdefault(book, set()).add(ref)
              elif row.get("layer") == "单小说灵感合并":
                  nm_members[(book, row.get("item_id", "").strip())] = set(source_ids(row.get("source_ids", "")))
          covered: set[tuple[str, str]] = set()
          single_book_hypotheses: list[str] = []
          for row in rows:
              if row.get("layer") != "跨书灵感聚合" or row.get("status", "").strip() != "active":
                  continue
              if positive_int(row.get("novel_count", "")) == 1:
                  single_book_hypotheses.append(row.get("item_id", "").strip())
              for raw_ref in source_ids(row.get("source_ids", "")):
                  if "/" not in raw_ref:
                      continue
                  book, ref = raw_ref.rsplit("/", 1)
                  if ref.startswith("NM-"):
                      covered.update((book, em_ref) for em_ref in nm_members.get((book, ref), set()))
                  else:
                      covered.add((book, ref))
          books: dict[str, dict[str, Any]] = {}
          uncovered_total = 0
          for book in sorted(ia_by_book):
              uncovered = sorted(em for em in ia_by_book[book] if (book, em) not in covered)
              uncovered_total += len(uncovered)
              books[book] = {
                  "atoms": len(ia_by_book[book]),
                  "covered": len(ia_by_book[book]) - len(uncovered),
                  "uncovered": uncovered,
              }
          return {
              "ok": True,
              "books": books,
              "uncovered_total": uncovered_total,
              "single_book_hypotheses": sorted(single_book_hypotheses),
          }
      
      
      def requested_tags(values: list[str]) -> dict[str, set[str]]:
          tags, errors = parse_tags(";".join(values))
          if errors:
              raise ValueError(";".join(errors))
          return tags
      
      
      def query(root: Path, values: list[str], limit: int) -> dict[str, Any]:
          """返回 matches 之外还带纠词元数据:请求值在全库零出现时点名
          `unmatched_tags`,并给出所查各轴的现存值清单——零命中先纠词重查,
          而不是把词表漂移误记成「库里没有」。"""
          rows, errors = load_rows(root)
          if errors:
              raise ValueError(";".join(errors))
          wanted = requested_tags(values)
          axis_values: dict[str, set[str]] = {axis: set() for axis in wanted}
          matches: list[dict[str, Any]] = []
          for row in rows:
              if row.get("layer") != "跨书灵感聚合" or row.get("status") != "active":
                  continue
              tags, tag_errors = parse_tags(row.get("tags", ""))
              if tag_errors:
                  continue
              for axis in axis_values:
                  axis_values[axis].update(tags.get(axis, set()))
              score = 0
              matched: list[str] = []
              core_match = False
              for axis, wanted_values in wanted.items():
                  overlap = wanted_values & tags.get(axis, set())
                  if overlap:
                      score += (2 if axis in CORE_QUERY_AXES else 1) * len(overlap)
                      matched.extend(f"{axis}={value}" for value in sorted(overlap))
                      if axis in CORE_QUERY_AXES:
                          core_match = True
              if score <= 0 or not core_match:
                  continue
              matches.append(
                  {
                      "item_id": row["item_id"],
                      "title": row["title"],
                      "path": row["path"],
                      "score": score,
                      "matched_tags": matched,
                      "novel_count": positive_int(row.get("novel_count", "")) or 0,
                      "atom_count": positive_int(row.get("atom_count", "")) or 0,
                  }
              )
          matches.sort(key=lambda item: (-item["score"], -item["novel_count"], -item["atom_count"], item["item_id"]))
          unmatched = [
              f"{axis}={value}"
              for axis in sorted(wanted)
              for value in sorted(wanted[axis] - axis_values[axis])
          ]
          return {
              "matches": matches[: max(3, min(limit, 8))],
              "unmatched_tags": unmatched,
              "axis_inventory": {axis: sorted(found) for axis, found in sorted(axis_values.items())},
              "vocabulary_loaded": load_vocabulary(root) is not None,
          }
      
      
      def resolve(root: Path, refs: list[str]) -> dict[str, Any]:
          """按 `书名/EM-xxx`、`书名/NM-xxx` 或 `CBA-xxx` 裸 ID 解析可读位置——需要时才查。"""
          rows, errors = load_rows(root)
          if errors:
              raise ValueError(";".join(errors))
          by_key: dict[tuple[str, str], dict[str, str]] = {}
          for row in rows:
              book = row.get("source_book", "").strip() if row.get("layer") != "跨书灵感聚合" else ""
              by_key[(book, row.get("item_id", "").strip())] = row
          resolved: list[dict[str, str]] = []
          missing: list[str] = []
          for raw_ref in refs:
              if "/" in raw_ref:
                  book, ref = raw_ref.rsplit("/", 1)
              else:
                  book, ref = "", raw_ref
              item = ref.replace("EM-", "IA-") if ref.startswith("EM-") else ref
              row = by_key.get((book if not item.startswith("CBA-") else "", item))
              if row is None:
                  missing.append(raw_ref)
                  continue
              if row.get("layer") == "原子灵感":
                  location = f"拆文库/{book}/剧情/情绪模块.md#{row.get('source_ids', '')}"
              else:
                  location = row.get("path", "")
              resolved.append({"ref": raw_ref, "item_id": row.get("item_id", ""), "title": row.get("title", ""), "location": location})
          return {"ok": not missing, "resolved": resolved, "missing": missing}
      
      
      def parse_args() -> argparse.Namespace:
          parser = argparse.ArgumentParser()
          subparsers = parser.add_subparsers(dest="command", required=True)
          register_parser = subparsers.add_parser("register-atoms")
          register_parser.add_argument("--root", required=True, type=Path)
          register_parser.add_argument("--module", required=True, type=Path)
          register_parser.add_argument("--book", required=True)
          register_parser.add_argument("--workspace", type=Path, default=None)
          check_parser = subparsers.add_parser("check-atoms")
          check_parser.add_argument("--root", required=True, type=Path)
          check_parser.add_argument("--module", required=True, type=Path)
          check_parser.add_argument("--book", required=True)
          check_parser.add_argument("--workspace", type=Path, default=None)
          validate_parser = subparsers.add_parser("validate")
          validate_parser.add_argument("--root", required=True, type=Path)
          validate_parser.add_argument("--workspace", type=Path, default=None)
          coverage_parser = subparsers.add_parser("coverage")
          coverage_parser.add_argument("--root", required=True, type=Path)
          query_parser = subparsers.add_parser("query")
          query_parser.add_argument("--root", required=True, type=Path)
          query_parser.add_argument("--tag", action="append", default=[])
          query_parser.add_argument("--limit", type=int, default=6)
          resolve_parser = subparsers.add_parser("resolve")
          resolve_parser.add_argument("--root", required=True, type=Path)
          resolve_parser.add_argument("--ref", action="append", default=[], required=True)
          return parser.parse_args()
      
      
      def main() -> int:
          if hasattr(sys.stdout, "reconfigure"):
              sys.stdout.reconfigure(encoding="utf-8")
          args = parse_args()
          try:
              return run(args)
          except (OSError, UnicodeError) as exc:
              message = f"io_error:{exc}"
              print(json.dumps({"ok": False, "error": message, "errors": [message]}, ensure_ascii=False))
              return 2
      
      
      def run(args: argparse.Namespace) -> int:
          if args.command == "register-atoms":
              try:
                  payload = register_atoms(args.root, args.module, args.book.strip(), args.workspace)
              except ValueError as exc:
                  errors = getattr(exc, "errors", None) or [str(exc)]
                  warnings = getattr(exc, "warnings", [])
                  print(json.dumps({"ok": False, "errors": errors, "warnings": warnings}, ensure_ascii=False))
                  return 2
              print(json.dumps(payload, ensure_ascii=False))
              return 0
          if args.command == "check-atoms":
              report = check_atoms(args.root, args.module, args.book.strip(), args.workspace)
              print(json.dumps(report, ensure_ascii=False))
              return 0 if report["ok"] else 1
          if args.command == "validate":
              errors = validate(args.root, args.workspace)
              print(json.dumps({"ok": not errors, "errors": errors}, ensure_ascii=False))
              return 0 if not errors else 1
          if args.command == "coverage":
              try:
                  payload = coverage(args.root)
              except ValueError as exc:
                  print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False))
                  return 2
              print(json.dumps(payload, ensure_ascii=False))
              return 0
          if args.command == "resolve":
              try:
                  payload = resolve(args.root, args.ref)
              except ValueError as exc:
                  print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False))
                  return 2
              print(json.dumps(payload, ensure_ascii=False))
              return 0 if payload["ok"] else 1
          try:
              payload = query(args.root, args.tag, args.limit)
          except ValueError as exc:
              print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False))
              return 2
          print(json.dumps({"ok": True, **payload}, ensure_ascii=False))
          return 0
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • manage_analysis_run.py 56.4 KB
      #!/usr/bin/env python3
      """Plan, commit, split, and recover long-analysis batches.
      
      Runtime state lives only in the managed block of ``_progress.md``. Plans are
      printed as JSON and are never persisted. Batch caches are complete recovery
      evidence, not a second state database. An existing chapter summary is never
      overwritten: to redo a chapter, delete its summary and plan again.
      """
      
      from __future__ import annotations
      
      import argparse
      import codecs
      import csv
      import hashlib
      import json
      import os
      import re
      import sys
      import tempfile
      from pathlib import Path
      from typing import Any, Dict, Iterable, List, Optional, Sequence, Set, Tuple
      
      from inspect_existing_assets import inspect
      
      
      STATE_START = "<!-- story-long-analyze:runtime-state:start -->"
      STATE_END = "<!-- story-long-analyze:runtime-state:end -->"
      CACHE_START = "<!-- story-long-analyze:cache:start -->"
      CACHE_END = "<!-- story-long-analyze:cache:end -->"
      MODEL_START = "<!-- MODEL_OUTPUT_START -->"
      MODEL_END = "<!-- MODEL_OUTPUT_END -->"
      CHAPTER_TOKEN_RE = re.compile(r"<!--\s*CHAPTER_(START|END):(\d+)\s*-->")
      CHAPTER_BLOCK_RE = re.compile(
          r"<!--\s*CHAPTER_START:(\d+)\s*-->\s*(.*?)\s*<!--\s*CHAPTER_END:\1\s*-->", re.DOTALL
      )
      BATCH_ID_RE = re.compile(r"^(RAW|REUSE)-(\d+)-(\d+)$")
      COMPACT_FIELDS = (
          "概要", "因果", "关键行动", "局面结果", "涉及人物", "信息变化", "状态变化",
          "三维节奏", "章尾钩子", "证据",
      )
      POINT_HEADER_RE = re.compile(r"^P(\d+)\s+\*\*(.+?)\*\*\s*[::]\s*(.+)$")
      POINT_TAG_RE = re.compile(r"^主题标签\s*[::]?\s*([^||]*?)\s*[||]\s*基调\s*[::]?\s*(.*?)\s*$")
      THEMES = ("爱情", "亲情", "友情", "权力", "金钱", "成长", "复仇", "悬念", "搞笑", "热血", "日常", "其他")
      TONES = ("紧张", "轻松", "悲伤", "热血", "爽", "甜", "温馨", "恐怖", "压抑", "其他")
      POINT_TYPES = ("转折点", "信息揭示", "冲突", "解决", "铺垫", "行动", "对话", "状态变化")
      THEME_ALIASES = {"恋爱": "爱情", "权谋": "权力", "政治": "权力", "幽默": "搞笑"}
      TONE_ALIASES = {"悲痛": "悲伤", "伤感": "悲伤", "悲愤": "悲伤", "痛快": "爽", "解气": "爽", "惊悚": "恐怖",
                      "恐惧": "恐怖", "危险": "紧张", "危急": "紧张", "绝望": "压抑", "无力": "压抑", "释然": "轻松"}
      POINT_ALIASES = {"揭示": "信息揭示", "转折": "转折点", "变化": "状态变化", "动作": "行动",
                       "交谈": "对话", "化解": "解决"}
      MIN_PLOT_POINTS = 10
      MAX_PLOT_POINTS = 30
      CHARS_PER_FLOOR_POINT = 200
      MAX_CHAPTERS = 3
      MAX_CHARS = 25_000
      # Stage 3-6 each run once over complete Stage 2 output; their rows decide 最终状态.
      FINAL_STAGES = ("stage3", "stage4", "stage5", "stage6")
      LEGACY_FINAL_RE = re.compile(r"(?m)^([ \t]*(?:[-*][ \t]*)?最终状态[ \t]*[::][ \t]*)([A-Za-z0-9_]+)")
      
      
      class RunError(ValueError):
          def __init__(self, code: str, detail: str, author_message: Optional[str] = None) -> None:
              super().__init__(detail)
              self.code = code
              self.detail = detail
              self.author_message = author_message
      
      
      def sha256(data: bytes) -> str:
          return hashlib.sha256(data).hexdigest()
      
      
      def normalized(text: str) -> str:
          return text.replace("\r\n", "\n").replace("\r", "\n")
      
      
      def atomic_write(path: Path, data: bytes) -> None:
          path.parent.mkdir(parents=True, exist_ok=True)
          fd, temporary = tempfile.mkstemp(prefix=".%s." % path.name, suffix=".tmp", dir=str(path.parent))
          try:
              with os.fdopen(fd, "wb") as handle:
                  handle.write(data)
                  handle.flush()
                  os.fsync(handle.fileno())
              os.replace(temporary, str(path))
          except BaseException:
              try:
                  os.unlink(temporary)
              except OSError:
                  pass
              raise
      
      
      def require_root(root: Path) -> Path:
          root = root.resolve()
          if not root.exists():
              raise RunError("root_not_found", str(root))
          if not root.is_dir():
              raise RunError("root_is_not_directory", str(root))
          try:
              next(root.iterdir(), None)
          except OSError as exc:
              raise RunError("root_unreadable", str(exc)) from exc
          return root
      
      
      def read_index(root: Path, index_arg: Optional[Path] = None) -> List[Dict[str, Any]]:
          path = (index_arg.resolve() if index_arg else root / "chapter_index.csv")
          if not path.is_file():
              raise RunError("chapter_index_required", str(path))
          rows = []  # type: List[Dict[str, Any]]
          try:
              with path.open("r", encoding="utf-8-sig", newline="") as handle:
                  reader = csv.DictReader(handle)
                  required = {"chapter", "start_line", "end_line", "char_count", "source_locator", "chapter_sha256"}
                  missing = required - set(reader.fieldnames or ())
                  if missing:
                      raise RunError("chapter_index_invalid", "missing columns: %s" % ", ".join(sorted(missing)))
                  for raw in reader:
                      row = dict(raw)
                      try:
                          row["chapter"] = int(row["chapter"])
                          row["start_line"] = int(row["start_line"])
                          row["end_line"] = int(row["end_line"])
                          row["char_count"] = int(row["char_count"])
                      except (TypeError, ValueError) as exc:
                          raise RunError("chapter_index_invalid", "numeric column invalid") from exc
                      if not re.fullmatch(r"[0-9a-f]{64}", str(row["chapter_sha256"])):
                          raise RunError("chapter_index_invalid", "chapter_sha256 invalid")
                      rows.append(row)
          except (OSError, UnicodeError, csv.Error) as exc:
              raise RunError("chapter_index_unreadable", str(exc)) from exc
          if [row["chapter"] for row in rows] != list(range(1, len(rows) + 1)):
              raise RunError("chapter_index_invalid", "chapter ids must be continuous from 1")
          return rows
      
      
      def range_sha256(rows: Sequence[Dict[str, Any]], start: int, end: int) -> str:
          selected = [row for row in rows if start <= row["chapter"] <= end]
          if [row["chapter"] for row in selected] != list(range(start, end + 1)):
              raise RunError("range_not_in_index", "%s-%s" % (start, end))
          payload = "range-v1\n" + "".join(
              "%s:%s\n" % (row["chapter"], row["chapter_sha256"]) for row in selected
          )
          return sha256(payload.encode("ascii"))
      
      
      def decode_progress(raw: bytes) -> Tuple[str, bool, str]:
          bom = raw.startswith(codecs.BOM_UTF8)
          text = raw.decode("utf-8-sig")
          newline = "\r\n" if "\r\n" in text else "\n"
          return text, bom, newline
      
      
      def empty_state() -> Dict[str, Any]:
          return {"batches": {}, "stages": {}}
      
      
      def table_cells(line: str) -> List[str]:
          return [cell.strip() for cell in line.strip().strip("|").split("|")]
      
      
      def load_state(progress: Path) -> Tuple[Dict[str, Any], bytes]:
          raw = progress.read_bytes() if progress.is_file() else b""
          text, _, _ = decode_progress(raw)
          state = empty_state()
          start_count = text.count(STATE_START)
          end_count = text.count(STATE_END)
          if start_count != end_count or start_count > 1:
              raise RunError(
                  "progress_state_block_invalid",
                  "expected zero or one complete managed state block; found start=%s end=%s"
                  % (start_count, end_count),
              )
          match = re.search(re.escape(STATE_START) + r"(.*?)" + re.escape(STATE_END), text, re.DOTALL)
          if not match:
              return state, raw
          section = None
          for line in match.group(1).splitlines():
              if line.strip() == "### 批次状态":
                  section = "batches"
                  continue
              if line.strip() == "### 阶段状态":
                  section = "stages"
                  continue
              if not line.lstrip().startswith("|") or set(line.replace("|", "").replace("-", "").replace(":", "").strip()) == set():
                  continue
              cells = table_cells(line)
              if section == "batches" and cells and cells[0] not in {"批次ID", "---"} and len(cells) >= 7:
                  try:
                      start, end = [int(value) for value in cells[1].split("-", 1)]
                  except (ValueError, IndexError):
                      continue
                  state["batches"][cells[0]] = {
                      "batch_id": cells[0], "start": start, "end": end, "input_kind": cells[2],
                      "range_sha256": cells[3], "status": cells[4],
                      "parent": "" if cells[5] == "-" else cells[5],
                      "cache": "" if cells[6] == "-" else cells[6],
                  }
              elif section == "stages" and cells and cells[0] not in {"阶段", "---"} and len(cells) >= 3:
                  state["stages"][cells[0]] = {"status": cells[1], "output": "" if cells[2] == "-" else cells[2]}
          return state, raw
      
      
      def final_status(stages: Dict[str, Dict[str, str]]) -> str:
          """Value for the ``最终状态`` line that session hooks read (Stage 3-6 rows)."""
          statuses = [stages.get(stage, {}).get("status") for stage in FINAL_STAGES]
          if all(status in {"completed", "completed_with_errors"} for status in statuses):
              return "completed_with_errors" if "completed_with_errors" in statuses else "completed"
          return "pending"
      
      
      def render_state(state: Dict[str, Any], newline: str, final_line: bool = True) -> str:
          lines = [STATE_START, "## 长篇拆文运行状态", ""]
          if final_line and state["stages"]:
              lines.extend(["- 最终状态:%s" % final_status(state["stages"]), ""])
          lines.extend(["### 批次状态",
                        "| 批次ID | 章节范围 | 输入 | 原文范围hash | 状态 | 父批次 | 缓存 |",
                        "|---|---|---|---|---|---|---|"])
          batches = list(state["batches"].values())
          batches.sort(key=lambda row: (row["start"], row["end"], row["batch_id"]))
          for row in batches:
              lines.append("| %s | %s-%s | %s | %s | %s | %s | %s |" % (
                  row["batch_id"], row["start"], row["end"], row["input_kind"],
                  row["range_sha256"], row["status"], row.get("parent") or "-", row.get("cache") or "-"))
          lines.extend(["", "### 阶段状态", "| 阶段 | 状态 | 产物 |", "|---|---|---|"])
          for stage in sorted(state["stages"]):
              row = state["stages"][stage]
              lines.append("| %s | %s | %s |" % (stage, row["status"], row.get("output") or "-"))
          lines.append(STATE_END)
          return newline.join(lines)
      
      
      def write_state(progress: Path, state: Dict[str, Any]) -> bool:
          raw = progress.read_bytes() if progress.is_file() else b""
          text, bom, newline = decode_progress(raw)
          pattern = re.compile(re.escape(STATE_START) + r".*?" + re.escape(STATE_END), re.DOTALL)
          # Hooks read the first 最终状态 in the file. A legacy project keeps its own
          # line as that single value; the runtime only promotes it once Stage 3-6
          # are all complete and never demotes it.
          existing = pattern.search(text)
          head = text[:existing.start()] if existing else text
          legacy = LEGACY_FINAL_RE.search(head)
          status = final_status(state["stages"])
          if legacy and status != "pending" and legacy.group(2) != status:
              head = head[:legacy.start(2)] + status + head[legacy.end(2):]
              text = head + (text[existing.start():] if existing else "")
          block = render_state(state, newline, final_line=legacy is None)
          if pattern.search(text):
              updated = pattern.sub(lambda _: block, text, count=1)
          else:
              if not text:
                  text = "# 深度拆解进度" + newline + "- schema_version: 2" + newline
              separator = "" if text.endswith(newline + newline) else (newline if text.endswith(newline) else newline + newline)
              updated = text + separator + block + newline
          data = ((codecs.BOM_UTF8 if bom else b"") + updated.encode("utf-8"))
          if data == raw:
              return False
          atomic_write(progress, data)
          return True
      
      
      def min_plot_points(chapter_chars: Optional[int]) -> int:
          """Raw-chapter floor: chars / 200 rounded, at least 1, at most 10 (a ~540-char prologue needs 3)."""
          if chapter_chars is None:
              return MIN_PLOT_POINTS
          return max(1, min(MIN_PLOT_POINTS, (chapter_chars + CHARS_PER_FLOOR_POINT // 2) // CHARS_PER_FLOOR_POINT))
      
      
      def chars_by_chapter(rows: Optional[Sequence[Dict[str, Any]]]) -> Dict[int, int]:
          return {row["chapter"]: row["char_count"] for row in rows or []}
      
      
      def summary_path(root: Path, chapter: int) -> Path:
          return root / "章节" / ("第%s章_摘要.md" % chapter)
      
      
      def cache_complete(path: Path) -> bool:
          try:
              text = normalized(path.read_text(encoding="utf-8-sig"))
          except (OSError, UnicodeError):
              return False
          return text.rstrip().endswith(CACHE_END) and text.count(MODEL_START) == 1 and text.count(MODEL_END) == 1
      
      
      def cache_path(root: Path, batch_id: str) -> Path:
          return root / "_analysis_cache" / ("批次-%s.md" % batch_id)
      
      
      def completed_batch(root: Path, row: Dict[str, Any], rows: Optional[Sequence[Dict[str, Any]]]) -> bool:
          if row.get("status") not in {"completed", "success"} or not all(summary_path(root, chapter).is_file() for chapter in range(row["start"], row["end"] + 1)):
              return False
          path = root / row.get("cache", "") if row.get("cache") else cache_path(root, row["batch_id"])
          if not cache_complete(path):
              return False
          try:
              metadata = parse_cache(path)
          except (OSError, UnicodeError, RunError):
              return False
          if (metadata.get("batch_id"), metadata.get("start"), metadata.get("end")) != (
              row["batch_id"], row["start"], row["end"]
          ):
              return False
          if metadata.get("range_sha256") != row.get("range_sha256"):
              return False
          if row["input_kind"] == "raw-original":
              return rows is not None and row.get("range_sha256") == range_sha256(rows, row["start"], row["end"])
          return True
      
      
      def compact_ranges(chapters: Iterable[int]) -> List[Tuple[int, int]]:
          values = sorted(set(chapters))
          if not values:
              return []
          result = []
          start = previous = values[0]
          for value in values[1:]:
              if value == previous + 1:
                  previous = value
              else:
                  result.append((start, previous))
                  start = previous = value
          result.append((start, previous))
          return result
      
      
      def chunk_range(start: int, end: int, index_by_chapter: Optional[Dict[int, Dict[str, Any]]]) -> List[Tuple[int, int]]:
          result = []
          current_start = start
          count = 0
          chars = 0
          previous = start - 1
          for chapter in range(start, end + 1):
              chapter_chars = index_by_chapter[chapter]["char_count"] if index_by_chapter else 0
              if count and (count + 1 > MAX_CHAPTERS or chars + chapter_chars > MAX_CHARS):
                  result.append((current_start, previous))
                  current_start = chapter
                  count = 0
                  chars = 0
              count += 1
              chars += chapter_chars
              previous = chapter
          result.append((current_start, previous))
          return result
      
      
      def invalid_batch_targets(root: Path, rows: Optional[Sequence[Dict[str, Any]]],
                                state: Dict[str, Any]) -> Tuple[Set[int], Set[int]]:
          """Chapters of recorded batches that no longer verify (cache lost or source changed)."""
          raw = set()
          reuse = set()
          for batch in state["batches"].values():
              chapters = set(range(batch["start"], batch["end"] + 1))
              # Split children stay owed until committed, even when summaries exist.
              pending_child = batch.get("parent") and batch.get("status") == "planned"
              if not pending_child and (
                  batch.get("status") not in {"completed", "success"} or completed_batch(root, batch, rows)
              ):
                  continue
              if batch.get("input_kind") == "raw-original":
                  raw.update(chapters)
              elif batch.get("input_kind") == "existing-results":
                  reuse.update(chapters)
          return raw, reuse
      
      
      def add_recoverable_caches(root: Path, state: Dict[str, Any],
                                 rows: Optional[Sequence[Dict[str, Any]]]) -> Tuple[List[Dict[str, Any]], Set[int]]:
          """Use complete caches in this read-only plan without persisting recovery."""
          cache_dir = root / "_analysis_cache"
          recoverable = []
          covered = set()  # type: Set[int]
          for path in sorted(cache_dir.glob("批次-*.md")) if cache_dir.is_dir() else []:
              try:
                  metadata = parse_cache(path)
                  batch_id = metadata["batch_id"]
                  input_kind, start, end = parse_batch_id(batch_id)
                  if metadata["input_kind"] != input_kind:
                      continue
                  current_hash = metadata["range_sha256"]
                  if input_kind == "raw-original":
                      if rows is None or current_hash != range_sha256(rows, start, end):
                          continue
                  records = parse_model_output(metadata["model_output"], start, end, input_kind, chars_by_chapter(rows))
                  missing = [chapter for chapter in range(start, end + 1) if not summary_path(root, chapter).is_file()]
                  if missing and not all(chapter in records for chapter in missing):
                      continue
                  covered.update(range(start, end + 1))
                  current = state["batches"].get(batch_id)
                  needs_state_repair = current is None or not completed_batch(root, current, rows)
                  if missing or needs_state_repair:
                      recoverable.append({"batch_id": batch_id, "cache": path.relative_to(root).as_posix(),
                                          "missing_summary_chapters": missing,
                                          "state_repair_required": needs_state_repair})
                      if missing:
                          continue
                  recovered = {
                      "batch_id": batch_id, "start": start, "end": end, "input_kind": input_kind,
                      "range_sha256": current_hash, "status": "completed", "parent": "",
                      "cache": path.relative_to(root).as_posix(),
                  }
                  if current is None or not completed_batch(root, current, rows):
                      state["batches"][batch_id] = recovered
              except (OSError, UnicodeError, RunError, ValueError):
                  continue
          return recoverable, covered
      
      
      def plan_command(args: argparse.Namespace) -> Dict[str, Any]:
          root = require_root(args.root)
          report = inspect(root, args.expected_chapters)
          state, _ = load_state(root / "_progress.md")
          index_rows = None  # type: Optional[List[Dict[str, Any]]]
          index_path = args.index.resolve() if args.index else root / "chapter_index.csv"
          if index_path.is_file():
              index_rows = read_index(root, index_path)
          recoverable_caches, cache_covered = add_recoverable_caches(root, state, index_rows)
          expected = report.get("expected_chapters") or (len(index_rows) if index_rows else None)
          if not expected:
              raise RunError("expected_chapters_unknown", "build the chapter index or pass --expected-chapters")
          semantic = set(report["completed_semantic_chapters"])
          summaries = set(report["completed_summary_chapters"])
          all_chapters = set(range(1, int(expected) + 1))
          invalid_raw, invalid_reuse = invalid_batch_targets(root, index_rows, state)
      
          raw_targets = (all_chapters - semantic) | invalid_raw
          if args.intent == "enhance":
              reuse_targets = (semantic - raw_targets) | invalid_reuse
          else:
              reuse_targets = ((semantic - summaries) | invalid_reuse) - raw_targets
          raw_targets -= cache_covered
          reuse_targets -= cache_covered
          window = parse_chapter_window(args.chapters)
          if window:
              if window[0] > int(expected):
                  raise RunError("invalid_chapter_window", "%s-%s beyond %s chapters" % (window[0], window[1], expected))
              in_window = set(range(window[0], window[1] + 1))
              raw_targets &= in_window
              reuse_targets &= in_window
          if raw_targets and index_rows is None:
              raise RunError("chapter_index_required", "raw-original work remains")
          if index_rows is not None:
              absent = raw_targets - {row["chapter"] for row in index_rows}
              if absent:
                  raise RunError("range_not_in_index", ",".join(map(str, sorted(absent))))
          mapping_blocked = set(report.get("chapter_mapping_blocked_chapters", []))
          if mapping_blocked & (raw_targets | reuse_targets):
              raise RunError(
                  "chapter_mapping_ambiguous",
                  "; ".join(report.get("chapter_mapping_conflicts", [])) or "legacy chapter identity is unresolved",
                  report.get("chapter_mapping_author_message"),
              )
      
          selected = []  # type: List[Dict[str, Any]]
          index_by_chapter = {row["chapter"]: row for row in index_rows or []}
          preferred_paths = report["chapter_sources"]["preferred_paths"]
          for kind, targets in (("raw-original", raw_targets), ("existing-results", reuse_targets)):
              for range_start, range_end in compact_ranges(targets):
                  for start, end in chunk_range(range_start, range_end, index_by_chapter if kind == "raw-original" else None):
                      selected.append({"input_kind": kind, "start": start, "end": end})
      
          # Persisted split children replace any recombined parent range on later plans.
          split_children = [
              row for row in state["batches"].values()
              if row.get("parent") and row.get("status") != "superseded"
          ]
          for child in split_children:
              for item in list(selected):
                  if item["input_kind"] == child["input_kind"] and item["start"] <= child["start"] and child["end"] <= item["end"]:
                      selected.remove(item)
                      if item["start"] < child["start"]:
                          selected.append({"input_kind": item["input_kind"], "start": item["start"], "end": child["start"] - 1})
                      selected.append({"input_kind": child["input_kind"], "start": child["start"], "end": child["end"]})
                      if child["end"] < item["end"]:
                          selected.append({"input_kind": item["input_kind"], "start": child["end"] + 1, "end": item["end"]})
                      break
      
          # Handoff = the committed batch that ends nearest before a batch's first chapter.
          committed = [row for row in state["batches"].values() if completed_batch(root, row, index_rows)]
      
          def handoff_cache(start: int) -> Optional[str]:
              before = [row for row in committed if row["end"] < start]
              if not before:
                  return None
              row = max(before, key=lambda value: (value["end"], value["start"]))
              return row.get("cache") or cache_path(root, row["batch_id"]).relative_to(root).as_posix()
      
          batches = []
          raw_reads = 0
          result_reads = 0
          for item in sorted(selected, key=lambda value: (value["start"], value["end"], value["input_kind"])):
              prefix = "RAW" if item["input_kind"] == "raw-original" else "REUSE"
              batch_id = "%s-%s-%s" % (prefix, item["start"], item["end"])
              current_range_hash = range_sha256(index_rows, item["start"], item["end"]) if item["input_kind"] == "raw-original" else "existing-results"
              prior = state["batches"].get(batch_id)
              if prior and completed_batch(root, prior, index_rows):
                  continue
              if item["input_kind"] == "raw-original":
                  sources = [index_by_chapter[chapter]["source_locator"] for chapter in range(item["start"], item["end"] + 1)]
                  raw_reads += len(sources)
              else:
                  sources = sorted(set(preferred_paths.get(str(chapter), "") for chapter in range(item["start"], item["end"] + 1)) - {""})
                  result_reads += len(sources)
              chapter_chars = [index_by_chapter[chapter]["char_count"] for chapter in range(item["start"], item["end"] + 1)] \
                  if item["input_kind"] == "raw-original" else []
              batches.append({
                  "batch_id": batch_id, "chapter_range": [item["start"], item["end"]],
                  "input_kind": item["input_kind"], "range_sha256": current_range_hash,
                  "source_files": sources, "cache": "_analysis_cache/批次-%s.md" % batch_id,
                  "chapter_chars": chapter_chars,
                  # Reuse gap summaries have no length to scale by: at least one point each.
                  "min_plot_points": [min_plot_points(chars) for chars in chapter_chars]
                  if chapter_chars else [1] * (item["end"] - item["start"] + 1),
                  "input_file": "_analysis_cache/输入-%s.md" % batch_id,
                  "handoff_cache": handoff_cache(item["start"]),
              })
          required_stages = list(report.get("stage_repairs", []))
          if batches or recoverable_caches:
              required_stages = sorted(set(required_stages + ["stage2"]))
          summary_gaps = sorted(all_chapters - summaries)
          payload = {
              "ok": True, "root": str(root), "intent": args.intent,
              "classification": report["classification"], "recommended_path": report["recommended_path"],
              "mixed_sources": report["mixed_sources"], "batches": batches,
              "remaining_batches": len(batches),
              "recoverable_caches": recoverable_caches,
              "summary_gaps": summary_gaps,
              "read_counts": {"raw_chapters": raw_reads, "existing_result_files": result_reads},
              "required_stages": required_stages,
              "state_written": False,
          }
          if window:
              payload["chapter_window"] = [window[0], window[1]]
          if args.next is not None:
              if args.next < 0:
                  raise RunError("invalid_next", str(args.next))
              # Dispatch one batch at a time without re-reading the whole remaining plan;
              # counts above still describe everything that is left.
              payload["batches"] = batches[:args.next]
              payload["summary_gaps"] = ["%s-%s" % pair if pair[0] != pair[1] else str(pair[0])
                                         for pair in compact_ranges(summary_gaps)]
          return payload
      
      
      def parse_batch_id(batch_id: str) -> Tuple[str, int, int]:
          match = BATCH_ID_RE.fullmatch(batch_id)
          if not match:
              raise RunError("invalid_batch_id", batch_id)
          start, end = int(match.group(2)), int(match.group(3))
          if start < 1 or end < start:
              raise RunError("invalid_batch_range", batch_id)
          return ("raw-original" if match.group(1) == "RAW" else "existing-results", start, end)
      
      
      def compact_field(body: str, name: str) -> str:
          match = re.search(r"(?m)^\*\*%s\*\*\s*[::]\s*(\S.*)$" % re.escape(name), body)
          if not match:
              raise RunError("chapter_schema_incomplete", "missing field: %s" % name)
          value = match.group(1).strip()
          if "{" in value or "}" in value:
              raise RunError("template_placeholder", name)
          return value
      
      
      def map_enum(value: str, allowed: Sequence[str], aliases: Dict[str, str], default: str = "其他") -> str:
          """Map one enum value; with several listed values, the first that maps wins."""
          for token in [value.strip()] + re.split(r"[、/,,;;\s]+", value.strip()):
              if token in allowed:
                  return token
              if token in aliases:
                  return aliases[token]
              for item in allowed:
                  if item != "其他" and token.startswith(item):
                      return item
          return default
      
      
      def parse_plot_points(body: str, chapter: int, minimum: int) -> List[Dict[str, Any]]:
          """Parse the repeated ``P{n}`` blocks that follow ``**情节点**:``."""
          match = re.search(r"(?m)^\*\*情节点\*\*\s*[::]\s*$", body)
          if not match:
              raise RunError("chapter_schema_incomplete", "chapter %s missing field: 情节点" % chapter)
          points = []  # type: List[Dict[str, Any]]
          for line in body[match.end():].split("\n"):
              stripped = line.strip()
              header = POINT_HEADER_RE.match(stripped)
              if header:
                  segments = [item.strip() for item in re.split(r"[||]", header.group(3))]
                  # Tolerate the tag written inline at the end of the P line.
                  inline = POINT_TAG_RE.match(" | ".join(segments[-2:])) if len(segments) >= 3 else None
                  rest = segments[1:-2] if inline else segments[1:]
                  if not segments[0].startswith("类型") or not rest or not rest[0]:
                      raise RunError("plot_point_invalid", "chapter %s P%s needs 类型 and 白描" % (chapter, header.group(1)))
                  points.append({"number": int(header.group(1)), "title": header.group(2).strip(),
                                 "type": segments[0][2:].lstrip(" ::"), "rest": rest, "quote": [],
                                 "tag": (inline.group(1), inline.group(2)) if inline else None})
                  continue
              if not stripped or stripped == "---":
                  continue
              if not points or points[-1]["tag"] is not None:
                  raise RunError("plot_point_invalid", "chapter %s: unexpected line %r" % (chapter, stripped[:40]))
              tag = POINT_TAG_RE.match(stripped)
              if tag:
                  points[-1]["tag"] = (tag.group(1), tag.group(2))
              else:
                  points[-1]["quote"].append(stripped)
          if [point["number"] for point in points] != list(range(1, len(points) + 1)):
              raise RunError("plot_point_invalid", "chapter %s: points must be numbered P1..Pn" % chapter)
          if not minimum <= len(points) <= MAX_PLOT_POINTS:
              raise RunError("plot_point_count", "chapter %s has %s points; expected %s-%s"
                             % (chapter, len(points), minimum, MAX_PLOT_POINTS))
          for point in points:
              if point["tag"] is None:
                  raise RunError("plot_point_invalid", "chapter %s P%s missing 主题标签/基调 line" % (chapter, point["number"]))
              text = "%s %s %s" % (point["title"], " ".join(point["rest"]), " ".join(point["quote"]))
              if "{" in text or "}" in text:
                  raise RunError("template_placeholder", "chapter %s P%s" % (chapter, point["number"]))
          return points
      
      
      def parse_model_output(text: str, start: int, end: int, input_kind: str,
                             chapter_chars: Optional[Dict[int, int]] = None) -> Dict[int, Dict[str, Any]]:
          text = normalized(text)
          if "BATCH_ERROR:" in text:
              raise RunError("extractor_reported_error", "model returned BATCH_ERROR")
          if any(marker in text for marker in (CACHE_START, CACHE_END, MODEL_START, MODEL_END)):
              raise RunError("reserved_marker_in_output", "model output contains a runtime cache marker")
          expected_tokens = [token for chapter in range(start, end + 1) for token in (("START", chapter), ("END", chapter))]
          actual_tokens = [(match.group(1), int(match.group(2))) for match in CHAPTER_TOKEN_RE.finditer(text)]
          if input_kind == "raw-original" and actual_tokens != expected_tokens:
              raise RunError("chapter_marker_mismatch", "expected %s; received %s" % (expected_tokens, actual_tokens))
          if actual_tokens and actual_tokens != expected_tokens:
              raise RunError("chapter_marker_mismatch", "expected %s; received %s" % (expected_tokens, actual_tokens))
          records = {}  # type: Dict[int, Dict[str, Any]]
          for match in CHAPTER_BLOCK_RE.finditer(text):
              chapter = int(match.group(1))
              body = match.group(2).strip()
              if not re.search(r"(?m)^##\s+第%s章(?:\s+.*)?$" % chapter, body):
                  raise RunError("chapter_schema_incomplete", "chapter %s heading missing" % chapter)
              fields = {name: compact_field(body, name) for name in COMPACT_FIELDS}  # type: Dict[str, Any]
              # Raw floor scales with chapter length; projections rebuilt from old results need only one beat.
              minimum = min_plot_points((chapter_chars or {}).get(chapter)) if input_kind == "raw-original" else 1
              fields["情节点"] = parse_plot_points(body, chapter, minimum)
              records[chapter] = fields
          if input_kind == "raw-original" and set(records) != set(range(start, end + 1)):
              raise RunError("chapter_block_missing", "%s-%s" % (start, end))
          if records and set(records) != set(range(start, end + 1)):
              raise RunError("chapter_block_missing", "partial reuse projection is not allowed")
          if not records and input_kind == "existing-results":
              marker = re.search(r"<!--\s*REUSED_CHAPTERS:(\d+)-(\d+)\s*-->", text)
              if not marker or (int(marker.group(1)), int(marker.group(2))) != (start, end):
                  raise RunError("reused_range_mismatch", "%s-%s" % (start, end))
          if text.count("<!-- BATCH_OBSERVATIONS_START -->") != 1 or text.count("<!-- BATCH_OBSERVATIONS_END -->") != 1:
              raise RunError("batch_marker_mismatch", "one complete cross-chapter observation block is required")
          observation_start = text.index("<!-- BATCH_OBSERVATIONS_START -->")
          observation_end = text.index("<!-- BATCH_OBSERVATIONS_END -->")
          last_source_marker = max((match.end() for match in CHAPTER_TOKEN_RE.finditer(text)), default=0)
          reused_marker = re.search(r"<!--\s*REUSED_CHAPTERS:\d+-\d+\s*-->", text)
          if reused_marker:
              last_source_marker = max(last_source_marker, reused_marker.end())
          if observation_start < last_source_marker or observation_end <= observation_start:
              raise RunError("batch_marker_order", "cross-chapter observations must follow source coverage")
          return records
      
      
      def render_plot_point(point: Dict[str, Any]) -> str:
          point_type = map_enum(point["type"], POINT_TYPES, POINT_ALIASES, default="行动")
          theme = map_enum(point["tag"][0], THEMES, THEME_ALIASES)
          tone = map_enum(point["tag"][1], TONES, TONE_ALIASES)
          lines = ["P%s **%s**:%s" % (point["number"], point["title"], " | ".join(["类型" + point_type] + point["rest"]))]
          lines.extend(point["quote"])
          lines.extend(["", "主题标签%s | 基调:%s" % (theme, tone)])
          return "\n".join(lines)
      
      
      def render_summary(chapter: int, fields: Dict[str, Any], source_kind: str,
                         chapter_hash: str, batch_id: str) -> bytes:
          text = (
              "<!-- story-long-analyze:projection runtime=single-state-v1 source=%s chapter_sha256=%s batch=%s -->\n"
              "## 第%s章\n\n**概要**:%s\n\n**关键事件**:\n1. %s\n\n"
              "**因果**:%s\n\n**局面结果**:%s\n\n**涉及**:%s\n\n"
              "**信息变化**:%s\n\n**状态变化**:%s\n\n**三维节奏**:%s\n\n"
              "**章尾钩子**:%s\n\n**证据**:%s\n\n**情节点**:\n\n%s\n"
          ) % (
              source_kind, chapter_hash, batch_id, chapter, fields["概要"], fields["关键行动"],
              fields["因果"], fields["局面结果"], fields["涉及人物"], fields["信息变化"],
              fields["状态变化"], fields["三维节奏"], fields["章尾钩子"], fields["证据"],
              "\n\n---\n\n".join(render_plot_point(point) for point in fields["情节点"]),
          )
          return text.encode("utf-8")
      
      
      def render_cache(batch_id: str, start: int, end: int, input_kind: str,
                       range_hash: str, source_files: Sequence[str], model_output: str,
                       projection_schema: str = "compact-v3") -> bytes:
          text = (
              "%s\n# 批次 %s\n- batch_id: %s\n- chapters: %s-%s\n- input_kind: %s\n"
              "- range_sha256: %s\n- projection_schema: %s\n"
              "- source_files: %s\n%s\n%s\n%s\n%s\n"
          ) % (CACHE_START, batch_id, batch_id, start, end, input_kind, range_hash, projection_schema,
                 json.dumps(list(source_files), ensure_ascii=False), MODEL_START,
                 normalized(model_output).strip(), MODEL_END, CACHE_END)
          return text.encode("utf-8")
      
      
      def parse_cache(path: Path) -> Dict[str, Any]:
          text = normalized(path.read_text(encoding="utf-8-sig"))
          if not text.rstrip().endswith(CACHE_END):
              raise RunError("cache_incomplete", str(path))
          metadata = {}  # type: Dict[str, Any]
          for key in ("batch_id", "chapters", "input_kind", "range_sha256", "projection_schema"):
              match = re.search(r"(?m)^- %s:\s*(.+)$" % key, text)
              if not match:
                  raise RunError("cache_invalid", "missing %s" % key)
              metadata[key] = match.group(1).strip()
          try:
              start, end = [int(value) for value in metadata["chapters"].split("-", 1)]
          except ValueError:
              raise RunError("cache_invalid", "chapters: %s" % metadata["chapters"])
          model_match = re.search(re.escape(MODEL_START) + r"\n(.*?)\n" + re.escape(MODEL_END), text, re.DOTALL)
          if not model_match:
              raise RunError("cache_invalid", "model output markers missing")
          metadata.update({"start": start, "end": end, "model_output": model_match.group(1)})
          return metadata
      
      
      def source_files_from_args(values: Optional[Sequence[str]]) -> List[str]:
          return list(values or [])
      
      
      def commit_from_cache(root: Path, metadata: Dict[str, Any], cache: Path,
                            index_rows: Optional[Sequence[Dict[str, Any]]]) -> Dict[str, Any]:
          batch_id = metadata["batch_id"]
          input_kind, id_start, id_end = parse_batch_id(batch_id)
          start, end = metadata["start"], metadata["end"]
          if (start, end, input_kind) != (id_start, id_end, metadata["input_kind"]):
              raise RunError("cache_invalid", "batch metadata mismatch")
          if input_kind == "raw-original":
              if index_rows is None:
                  raise RunError("chapter_index_required", batch_id)
              current_hash = range_sha256(index_rows, start, end)
              if metadata["range_sha256"] != current_hash:
                  raise RunError("range_hash_mismatch", batch_id)
          else:
              current_hash = metadata["range_sha256"]
          records = parse_model_output(metadata["model_output"], start, end, input_kind, chars_by_chapter(index_rows))
          hashes = {row["chapter"]: row["chapter_sha256"] for row in index_rows or []}
          created = []
          kept = []
          for chapter, fields in sorted(records.items()):
              path = summary_path(root, chapter)
              if path.exists():
                  kept.append(chapter)
                  continue
              chapter_hash = hashes.get(chapter, "0" * 64)
              atomic_write(path, render_summary(chapter, fields, input_kind, chapter_hash, batch_id))
              created.append(path.relative_to(root).as_posix())
              failure_after = os.environ.get("STORY_ANALYZE_FAIL_AFTER_SUMMARIES")
              if failure_after and len(created) >= int(failure_after):
                  raise OSError("injected_failure_after_%s_summaries" % failure_after)
          missing = [chapter for chapter in range(start, end + 1) if not summary_path(root, chapter).is_file()]
          if missing:
              raise RunError("summary_projection_missing", ",".join(map(str, missing)))
          state, _ = load_state(root / "_progress.md")
          state["batches"][batch_id] = {
              "batch_id": batch_id, "start": start, "end": end, "input_kind": input_kind,
              "range_sha256": current_hash, "status": "completed", "parent": "",
              "cache": cache.relative_to(root).as_posix(),
          }
          changed = write_state(root / "_progress.md", state)
          return {"batch_id": batch_id, "created_summaries": created,
                  "kept_existing_summary_chapters": kept, "progress_updated": changed}
      
      
      def commit_command(args: argparse.Namespace) -> Dict[str, Any]:
          root = require_root(args.root)
          input_kind, start, end = parse_batch_id(args.batch_id)
          if end - start + 1 > MAX_CHAPTERS:
              raise RunError("batch_too_large", "%s exceeds %s chapters" % (args.batch_id, MAX_CHAPTERS))
          text = args.input.read_text(encoding="utf-8-sig")
          index_rows = read_index(root, args.index) if input_kind == "raw-original" or (args.index or root / "chapter_index.csv").is_file() else None
          records = parse_model_output(text, start, end, input_kind, chars_by_chapter(index_rows))
          if input_kind == "raw-original" and end > start:
              total_chars = sum(row["char_count"] for row in index_rows if start <= row["chapter"] <= end)
              if total_chars > MAX_CHARS:
                  raise RunError("batch_too_large", "%s exceeds %s characters" % (args.batch_id, MAX_CHARS))
          current_hash = range_sha256(index_rows, start, end) if input_kind == "raw-original" else "existing-results"
          if input_kind == "raw-original" and not args.range_sha256:
              raise RunError("range_hash_required", "pass the value printed by plan")
          if args.range_sha256 and args.range_sha256 != current_hash:
              raise RunError("range_hash_mismatch", args.batch_id)
          # Parsing above validates the whole result before the first write.
          path = cache_path(root, args.batch_id)
          data = render_cache(args.batch_id, start, end, input_kind, current_hash,
                              source_files_from_args(args.source_file), text)
          if not path.is_file() or path.read_bytes() != data:
              if path.is_file():
                  old_data = path.read_bytes()
                  history = root / "_analysis_cache" / "legacy" / (
                      "%s.%s.md" % (path.stem, sha256(old_data)[:12])
                  )
                  if not history.exists():
                      atomic_write(history, old_data)
              atomic_write(path, data)
          if os.environ.get("STORY_ANALYZE_FAIL_AFTER_CACHE") == "1":
              raise OSError("injected_failure_after_cache")
          metadata = parse_cache(path)
          result = commit_from_cache(root, metadata, path, index_rows)
          result.update({"ok": True, "cache": path.relative_to(root).as_posix(), "validated_chapters": sorted(records)})
          return result
      
      
      def repair_command(args: argparse.Namespace) -> Dict[str, Any]:
          root = require_root(args.root)
          index_rows = read_index(root, args.index) if (args.index or root / "chapter_index.csv").is_file() else None
          paths = [cache_path(root, args.batch_id)] if args.batch_id else sorted((root / "_analysis_cache").glob("批次-*.md"))
          state, _ = load_state(root / "_progress.md")
          repaired = []
          skipped = []
          errors = []
          for path in paths:
              # A cache replaced by a split or by other committed batches is history, not a repair target.
              batch_id = path.stem[len("批次-"):]
              if BATCH_ID_RE.fullmatch(batch_id):
                  _, start, end = parse_batch_id(batch_id)
                  covered = set()
                  for other in state["batches"].values():
                      if other["batch_id"] != batch_id and completed_batch(root, other, index_rows):
                          covered.update(range(other["start"], other["end"] + 1))
                  if state["batches"].get(batch_id, {}).get("status") == "superseded" or set(range(start, end + 1)) <= covered:
                      skipped.append({"cache": path.relative_to(root).as_posix(), "reason": "superseded"})
                      continue
              try:
                  metadata = parse_cache(path)
                  repaired.append(commit_from_cache(root, metadata, path, index_rows))
              except (OSError, UnicodeError, RunError) as exc:
                  errors.append({"cache": path.as_posix(), "error": getattr(exc, "code", str(exc)), "detail": str(exc)})
          return {"ok": not errors, "repaired": repaired, "skipped": skipped, "errors": errors}
      
      
      def split_command(args: argparse.Namespace) -> Dict[str, Any]:
          root = require_root(args.root)
          input_kind, start, end = parse_batch_id(args.batch_id)
          if start >= end:
              raise RunError("batch_not_splittable", args.batch_id)
          index_rows = read_index(root, args.index) if input_kind == "raw-original" else None
          split_at = args.at
          if split_at is None:
              if index_rows:
                  counts = {row["chapter"]: row["char_count"] for row in index_rows}
                  total = sum(counts[chapter] for chapter in range(start, end + 1))
                  running = 0
                  split_at = start
                  for chapter in range(start, end):
                      running += counts[chapter]
                      split_at = chapter
                      if running >= total / 2:
                          break
              else:
                  split_at = (start + end) // 2
          if split_at < start or split_at >= end:
              raise RunError("invalid_split_point", str(split_at))
          state, _ = load_state(root / "_progress.md")
          parent_hash = range_sha256(index_rows, start, end) if index_rows else "existing-results"
          state["batches"][args.batch_id] = {
              "batch_id": args.batch_id, "start": start, "end": end, "input_kind": input_kind,
              "range_sha256": parent_hash, "status": "superseded", "parent": "", "cache": "",
          }
          prefix = "RAW" if input_kind == "raw-original" else "REUSE"
          children = []
          for child_start, child_end in ((start, split_at), (split_at + 1, end)):
              child_id = "%s-%s-%s" % (prefix, child_start, child_end)
              child_hash = range_sha256(index_rows, child_start, child_end) if index_rows else "existing-results"
              existing = state["batches"].get(child_id)
              if not existing or existing.get("status") not in {"completed", "success"}:
                  state["batches"][child_id] = {
                      "batch_id": child_id, "start": child_start, "end": child_end,
                      "input_kind": input_kind, "range_sha256": child_hash, "status": "planned",
                      "parent": args.batch_id, "cache": "",
                  }
              children.append(child_id)
          write_state(root / "_progress.md", state)
          return {"ok": True, "parent": args.batch_id, "status": "superseded", "children": children}
      
      
      def stage_required_outputs(root: Path, stage: str) -> List[str]:
          if stage == "stage1":
              expected = inspect(root, None).get("expected_chapters") or 3
              required = ["章节/第%s章_深度拆解.md" % chapter for chapter in range(1, min(3, int(expected)) + 1)]
              required.append("快速预览.md")
              return required
          if stage == "stage2":
              expected = inspect(root, None).get("expected_chapters")
              if not expected:
                  raise RunError("expected_chapters_unknown", "Stage 2 completion requires a known chapter count")
              return ["章节/第%s章_摘要.md" % chapter for chapter in range(1, int(expected) + 1)]
          if stage == "stage3":
              return ["剧情/情绪模块.md", "剧情/节奏.md"]
          if stage == "stage4":
              character_files = sorted(path for path in (root / "角色").glob("*.md") if path.is_file() and path.stat().st_size)
              setting_files = sorted(path for path in (root / "设定").rglob("*.md") if path.is_file() and path.stat().st_size)
              missing = []
              if not character_files:
                  missing.append("角色/*.md")
              if not setting_files:
                  missing.append("设定/**/*.md")
              if missing:
                  raise RunError("stage_output_missing", ",".join(missing))
              return [character_files[0].relative_to(root).as_posix(), setting_files[0].relative_to(root).as_posix()]
          if stage == "stage5":
              return ["拆文报告.md"]
          if stage == "stage6":
              return ["文风.md"]
          raise RunError("stage_unknown", stage)
      
      
      def mark_stage_command(args: argparse.Namespace) -> Dict[str, Any]:
          root = require_root(args.root)
          if args.prepare:
              if args.stage != "stage5":
                  raise RunError("prepare_stage_unsupported", args.stage)
              report = root / "拆文报告.md"
              backup = root / "_analysis_cache" / "legacy" / "拆文报告.md"
              created = False
              selected_backup = backup
              if report.is_file():
                  report_data = report.read_bytes()
                  if backup.exists() and backup.read_bytes() != report_data:
                      selected_backup = backup.with_name("拆文报告.%s.md" % sha256(report_data)[:12])
                  if not selected_backup.exists():
                      atomic_write(selected_backup, report_data)
                      created = True
              return {"ok": True, "stage": args.stage, "prepared": True,
                      "legacy_report_backup": selected_backup.relative_to(root).as_posix() if selected_backup.is_file() else None,
                      "backup_created": created, "progress_updated": False}
          required_outputs = stage_required_outputs(root, args.stage) if args.status == "completed" else []
          missing_required = [
              name for name in required_outputs
              if not (root / name).is_file() or (root / name).stat().st_size == 0
          ]
          if missing_required:
              raise RunError("stage_output_missing", ",".join(missing_required))
          output = args.output
          relative_output = ""
          if output:
              path = output if output.is_absolute() else root / output
              if args.status == "completed" and (not path.is_file() or path.stat().st_size == 0):
                  raise RunError("stage_output_missing", str(path))
              try:
                  relative_output = path.resolve().relative_to(root).as_posix()
              except ValueError:
                  raise RunError("stage_output_outside_root", str(path))
          elif required_outputs:
              relative_output = ";".join(required_outputs)
          state, _ = load_state(root / "_progress.md")
          state["stages"][args.stage] = {"status": args.status, "output": relative_output}
          changed = write_state(root / "_progress.md", state)
          return {"ok": True, "stage": args.stage, "status": args.status, "progress_updated": changed}
      
      
      OBS_START = "<!-- BATCH_OBSERVATIONS_START -->"
      OBS_END = "<!-- BATCH_OBSERVATIONS_END -->"
      # Labels as projected by render_summary (legacy summaries use the same labels).
      SUMMARY_FIELDS = ("概要", "关键事件", "因果", "局面结果", "涉及", "信息变化", "状态变化",
                        "三维节奏", "章尾钩子", "证据")
      
      
      def parse_chapter_window(value: Optional[str]) -> Optional[Tuple[int, int]]:
          if not value:
              return None
          match = re.fullmatch(r"(\d+)(?:-(\d+))?", value.strip())
          if not match:
              raise RunError("invalid_chapter_window", value)
          start = int(match.group(1))
          end = int(match.group(2) or start)
          if start < 1 or end < start:
              raise RunError("invalid_chapter_window", value)
          return start, end
      
      
      def digest_caches(root: Path) -> List[Tuple[int, int, str, Path]]:
          """Complete caches of committed batches, falling back to every complete cache on disk."""
          state, _ = load_state(root / "_progress.md")
          chosen = {}  # type: Dict[str, Tuple[int, int, str, Path]]
          for row in state["batches"].values():
              if row.get("status") not in {"completed", "success"}:
                  continue
              path = root / row["cache"] if row.get("cache") else cache_path(root, row["batch_id"])
              if cache_complete(path):
                  chosen[row["batch_id"]] = (row["start"], row["end"], row["batch_id"], path)
          if not chosen:
              for path in sorted((root / "_analysis_cache").glob("批次-*.md")):
                  try:
                      _, start, end = parse_batch_id(path.stem[len("批次-"):])
                  except RunError:
                      continue
                  if cache_complete(path):
                      chosen[path.stem] = (start, end, path.stem[len("批次-"):], path)
          return sorted(chosen.values())
      
      
      def summary_field(text: str, name: str) -> str:
          if name == "关键事件":
              match = re.search(r"(?ms)^\*\*关键事件\*\*\s*[::]\s*\n(.*?)(?=^\*\*|\Z)", text)
              return " ".join(line.strip() for line in match.group(1).splitlines() if line.strip()) if match else ""
          match = re.search(r"(?m)^\*\*%s\*\*\s*[::]\s*(\S.*)$" % re.escape(name), text)
          return match.group(1).strip() if match else ""
      
      
      def summary_points(text: str, mode: str) -> List[str]:
          lines = text.split("\n")
          points = []  # type: List[str]
          for index, line in enumerate(lines):
              header = POINT_HEADER_RE.match(line.strip())
              if not header:
                  continue
              segments = [item.strip() for item in re.split(r"[||]", header.group(3))]
              tone = ""
              for follow in lines[index + 1:index + 8]:
                  tag = POINT_TAG_RE.match(follow.strip())
                  if tag:
                      tone = tag.group(2)
                      break
                  if POINT_HEADER_RE.match(follow.strip()):
                      break
              point_type = segments[0][2:].lstrip(" ::") if segments[0].startswith("类型") else segments[0]
              if mode == "brief":
                  points.append("P%s %s|%s|%s" % (header.group(1), header.group(2).strip(), point_type, tone or "—"))
              else:
                  points.append("%s |基调:%s" % (line.strip(), tone or "—"))
          return points
      
      
      def digest_command(args: argparse.Namespace) -> Dict[str, Any]:
          root = require_root(args.root)
          window = parse_chapter_window(args.chapters)
          parts = []  # type: List[str]
          if args.part == "observations":
              for start, end, batch_id, path in digest_caches(root):
                  if window and (end < window[0] or start > window[1]):
                      continue
                  model = parse_cache(path)["model_output"]
                  if OBS_START not in model or OBS_END not in model:
                      continue
                  body = model.split(OBS_START, 1)[1].split(OBS_END, 1)[0].strip()
                  body = re.sub(r"(?m)^##\s*跨章观察\s*\n", "", body).strip()
                  parts.append("## %s(第%s-%s章)\n\n%s" % (batch_id, start, end, body))
          else:
              fields = [item.strip() for item in (args.fields or "").split(",") if item.strip()]
              unknown = [item for item in fields if item not in SUMMARY_FIELDS]
              if unknown:
                  raise RunError("unknown_summary_field", ",".join(unknown))
              if not fields and args.points == "none":
                  raise RunError("digest_empty_request", "pass --fields and/or --points")
              chapters = sorted(
                  int(match.group(1)) for match in
                  (re.fullmatch(r"第(\d+)章_摘要", path.stem) for path in (root / "章节").glob("第*章_摘要.md"))
                  if match
              )
              for chapter in chapters:
                  if window and not window[0] <= chapter <= window[1]:
                      continue
                  text = normalized(summary_path(root, chapter).read_text(encoding="utf-8-sig"))
                  lines = ["### 第%s章" % chapter]
                  lines.extend("- %s:%s" % (name, summary_field(text, name) or "未提供") for name in fields)
                  if args.points != "none":
                      lines.extend(summary_points(text, args.points))
                  parts.append("\n".join(lines))
          if not parts:
              raise RunError("digest_nothing_found", "no committed batch caches or summaries in range")
          return {"ok": True, "text": "\n\n".join(parts) + "\n"}
      
      
      def build_parser() -> argparse.ArgumentParser:
          parser = argparse.ArgumentParser(description=__doc__)
          sub = parser.add_subparsers(dest="command", required=True)
          plan = sub.add_parser("plan", help="print a read-only in-memory plan")
          plan.add_argument("--root", required=True, type=Path)
          plan.add_argument("--index", type=Path)
          plan.add_argument("--expected-chapters", type=int)
          plan.add_argument("--intent", choices=("continue", "enhance"), default="continue")
          plan.add_argument("--next", type=int, metavar="N",
                            help="only print the first N batches (0 = counts only); remaining_batches stays total")
          plan.add_argument("--chapters", help="only plan this chapter window, e.g. 40-80")
          plan.set_defaults(handler=plan_command)
          commit = sub.add_parser("commit", help="validate and atomically commit one batch")
          commit.add_argument("--root", required=True, type=Path)
          commit.add_argument("--input", required=True, type=Path)
          commit.add_argument("--batch-id", required=True)
          commit.add_argument("--range-sha256")
          commit.add_argument("--index", type=Path)
          commit.add_argument("--source-file", action="append")
          commit.set_defaults(handler=commit_command)
          split = sub.add_parser("split", help="persist a failed batch split")
          split.add_argument("--root", required=True, type=Path)
          split.add_argument("--batch-id", required=True)
          split.add_argument("--at", type=int)
          split.add_argument("--index", type=Path)
          split.set_defaults(handler=split_command)
          repair = sub.add_parser("repair-progress", help="recover missing projections/state from complete caches")
          repair.add_argument("--root", required=True, type=Path)
          repair.add_argument("--batch-id")
          repair.add_argument("--index", type=Path)
          repair.set_defaults(handler=repair_command)
          stage = sub.add_parser("mark-stage", help="mark a stage after its output is present")
          stage.add_argument("--root", required=True, type=Path)
          stage.add_argument("--stage", required=True, choices=("stage1", "stage2", "stage3", "stage4", "stage5", "stage6"))
          stage.add_argument("--status", choices=("completed", "completed_with_errors"), default="completed")
          stage.add_argument("--output", type=Path)
          stage.add_argument("--prepare", action="store_true", help="before Stage 5, preserve the existing report")
          stage.set_defaults(handler=mark_stage_command)
          digest = sub.add_parser("digest", help="print Stage 3-5 reading material without whole-cache reads")
          digest.add_argument("--root", required=True, type=Path)
          digest.add_argument("--part", required=True, choices=("observations", "chapters"))
          digest.add_argument("--chapters", help="chapter window such as 1-40")
          digest.add_argument("--fields", help="comma-separated summary fields, e.g. 三维节奏,涉及,状态变化")
          digest.add_argument("--points", choices=("none", "brief", "full"), default="none")
          digest.set_defaults(handler=digest_command)
          return parser
      
      
      def main() -> int:
          for stream in (sys.stdout, sys.stderr):
              if hasattr(stream, "reconfigure"):
                  stream.reconfigure(encoding="utf-8")
          parser = build_parser()
          args = parser.parse_args()
          try:
              payload = args.handler(args)
              if args.command == "digest":
                  sys.stdout.write(payload["text"])
                  return 0
              print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True))
              return 0 if payload.get("ok", True) else 2
          except (OSError, UnicodeError, RunError) as exc:
              payload = {"ok": False, "error": getattr(exc, "code", "io_error"), "detail": str(exc)}
              if getattr(exc, "author_message", None):
                  payload["author_message"] = exc.author_message
              print(json.dumps(payload, ensure_ascii=False, indent=2))
              return 2
      
      
      if __name__ == "__main__":
          raise SystemExit(main())
      
    • render_relation_chart.py 19 KB
      #!/usr/bin/env python3
      """Render the character-relationship charts from 角色/角色关系.md.
      
      The Markdown file ``人物关系图/人物关系图.md`` (Mermaid graphs plus a plain
      list) is always written: it shows Chinese names in any Markdown viewer. PNG
      pictures are optional (``--png``) and are drawn only when matplotlib and a font
      that really contains every drawn character are available. Emoji and other
      symbols are left out of the pictures; without a font for the remaining text no
      PNG is written, so names are never replaced by pinyin, initials or empty boxes.
      """
      
      from __future__ import annotations
      
      import argparse
      import json
      import math
      import os
      import re
      import shutil
      import subprocess
      import sys
      import tempfile
      import unicodedata
      from pathlib import Path
      from typing import Dict, List, Optional, Tuple
      
      
      RELATION_FILE = Path("角色") / "角色关系.md"
      CHART_DIR = Path("人物关系图")
      CHART_MD = CHART_DIR / "人物关系图.md"
      CORE_PNG = CHART_DIR / "核心人物关系.png"
      EVOLUTION_PNG = CHART_DIR / "关键关系演变.png"
      # 「甲 → 乙」, 「甲 ↔ 乙」 (both ways) and chains such as 「甲 → 乙 → 丙」.
      ARROW_RE = re.compile(r"\s*(↔|<->|⇔|<=>|→|->|⇒|=>|—>|->)\s*")
      BOTH_WAYS = {"↔", "<->", "⇔", "<=>"}
      # Common Simplified-Chinese capable fonts on macOS, Windows and Linux.
      CJK_FONT_NAMES = (
          "PingFang SC", "Hiragino Sans GB", "Heiti SC", "STHeiti", "Songti SC", "STSong",
          "Microsoft YaHei", "SimHei", "SimSun", "DengXian",
          "Noto Sans CJK SC", "Noto Sans SC", "Noto Serif CJK SC", "Source Han Sans SC",
          "Source Han Sans CN", "WenQuanYi Micro Hei", "WenQuanYi Zen Hei", "Sarasa Gothic SC",
          "Arial Unicode MS",
      )
      # Fonts that answer every character with a placeholder box; they never count.
      FALLBACK_FONT_RE = re.compile(r"last\s*resort|adobe\s*blank", re.IGNORECASE)
      BASE_TEXT = "中文人物关系未命名→…"
      MD_VIEW = "关系图已经写在 人物关系图/人物关系图.md 里,用能预览 Markdown 的编辑器打开就能看到中文。"
      NO_FONT_MESSAGE = (
          "这台电脑上没有找到能显示中文的字体,所以没有生成关系图图片(硬画会把人名变成拼音或方块)。"
          + MD_VIEW + "装上任意一款中文字体(如思源黑体、文泉驿)后再生成一次,就能得到图片版。"
      )
      MISSING_GLYPH_MESSAGE = (
          "这台电脑上的中文字体显示不了关系图里的「%s」,所以没有生成图片(硬画会变成方块)。"
          + MD_VIEW + "装上字更全的中文字体(如思源黑体)后再生成一次,就能得到图片版。"
      )
      PNG_FAILED_MESSAGE = "关系图图片这次没画成。" + MD_VIEW
      NO_MATPLOTLIB_MESSAGE = "当前环境不能画图片," + MD_VIEW
      
      
      class ChartError(ValueError):
          def __init__(self, code: str, author_message: str) -> None:
              super().__init__(code)
              self.code = code
              self.author_message = author_message
      
      
      def atomic_write(path: Path, data: bytes) -> None:
          path.parent.mkdir(parents=True, exist_ok=True)
          fd, temporary = tempfile.mkstemp(prefix=".%s." % path.name, suffix=".tmp", dir=str(path.parent))
          try:
              with os.fdopen(fd, "wb") as handle:
                  handle.write(data)
              os.replace(temporary, str(path))
          except BaseException:
              try:
                  os.unlink(temporary)
              except OSError:
                  pass
              raise
      
      
      def cells(line: str) -> List[str]:
          """Table cells; an escaped 「\\|」 stays inside its cell."""
          stripped = line.strip()
          stripped = stripped[1:] if stripped.startswith("|") else stripped
          stripped = stripped[:-1] if stripped.endswith("|") and not stripped.endswith("\\|") else stripped
          return [cell.strip().replace("\\|", "|") for cell in re.split(r"(?<!\\)\|", stripped)]
      
      
      def pick(row: Dict[str, str], *names: str) -> str:
          for name in names:
              for key, value in row.items():
                  if name in key and value and value not in {"-", "—", "无"}:
                      return value
          return ""
      
      
      SOURCE_KEYS = ("主体", "角色A", "人物A", "A", "甲")
      TARGET_KEYS = ("客体", "角色B", "人物B", "B", "乙")
      
      
      def exact(row: Dict[str, str], keys: Tuple[str, ...]) -> str:
          for key in keys:
              value = row.get(key, "")
              if value and value not in {"-", "—"}:
                  return value
          return ""
      
      
      def parse_relations(text: str) -> Tuple[List[Dict[str, str]], int]:
          """Relations of a relationship table, plus the number of rows left out.
      
          Current files use one 「主体 → 客体」 column; older ones use separate
          主体/客体 or A/B columns. Any table with such a pair of columns counts.
          """
          relations = []  # type: List[Dict[str, str]]
          skipped = 0
          header = None  # type: Optional[List[str]]
          for line in text.splitlines():
              stripped = line.strip()
              if not stripped.startswith("|"):
                  header = None
                  continue
              row_cells = cells(stripped)
              if header is None:
                  names = set(row_cells)
                  if (any("主体" in cell and "客体" in cell for cell in row_cells) or "关系方向" in names
                          or (names & set(SOURCE_KEYS) and names & set(TARGET_KEYS))):
                      header = row_cells
                  continue
              if not "".join(row_cells).strip("-: "):
                  continue
              row = dict(zip(header, row_cells))
              pair = next((value for key, value in row.items() if ("主体" in key and "客体" in key) or key == "关系方向"), "")
              tokens = [part.strip() for part in ARROW_RE.split(pair)] if pair else []
              names, arrows = tokens[0::2], tokens[1::2]
              if len(names) < 2:
                  names = [exact(row, SOURCE_KEYS), exact(row, TARGET_KEYS)]
                  arrows = ["→"]
              if len(names) < 2 or not all(names):
                  skipped += 1
                  continue
              fields = {
                  "id": pick(row, "关系ID", "ID"),
                  "label": pick(row, "关系动作", "类型", "表面关系"),
                  "state": pick(row, "变化后状态", "真实关系", "表面关系"),
                  "trigger": pick(row, "触发事件", "触发"),
              }
              for index, arrow in enumerate(arrows):
                  ends = [(names[index], names[index + 1])]
                  if arrow in BOTH_WAYS:
                      ends.append((names[index + 1], names[index]))
                  for source, target in ends:
                      relations.append(dict(fields, source=source, target=target))
          return relations, skipped
      
      
      def mermaid_text(value: str, placeholder: str) -> str:
          value = re.sub(r"[`*]|%{2,}", "", value)
          value = value.replace('"', "'").replace("|", "/").replace("\n", " ").strip()
          return value or placeholder
      
      
      def latest_by_pair(relations: List[Dict[str, str]]) -> List[Dict[str, str]]:
          latest = {}  # type: Dict[Tuple[str, str], Dict[str, str]]
          for relation in relations:
              latest[(relation["source"], relation["target"])] = relation
          return list(latest.values())
      
      
      def evolutions(relations: List[Dict[str, str]]) -> List[Tuple[Tuple[str, str], List[Dict[str, str]]]]:
          history = {}  # type: Dict[Tuple[str, str], List[Dict[str, str]]]
          for relation in relations:
              history.setdefault((relation["source"], relation["target"]), []).append(relation)
          return [(pair, rows) for pair, rows in history.items() if len(rows) > 1]
      
      
      def render_markdown(book: str, relations: List[Dict[str, str]], png_note: str) -> str:
          names = []  # type: List[str]
          for relation in relations:
              for name in (relation["source"], relation["target"]):
                  if name not in names:
                      names.append(name)
          node = {name: "p%s" % index for index, name in enumerate(names, start=1)}
          lines = ["# 人物关系图:%s" % book, "",
                   "> 由 角色/角色关系.md 生成;关系事实以该文件为准。%s" % png_note, "",
                   "## 核心人物关系", "", "```mermaid", "graph LR"]
          lines.extend('    %s["%s"]' % (node[name], mermaid_text(name, "未命名")) for name in names)
          for relation in latest_by_pair(relations):
              label = relation["state"] or relation["label"] or "关系"
              lines.append('    %s -->|"%s"| %s' % (node[relation["source"]], mermaid_text(label, "关系"),
                                                   node[relation["target"]]))
          lines.extend(["```", ""])
          for relation in latest_by_pair(relations):
              label = relation["state"] or relation["label"] or "关系"
              lines.append("- %s → %s:%s" % (relation["source"], relation["target"], label))
          lines.extend(["", "## 关键关系演变", ""])
          changes = evolutions(relations)
          if not changes:
              lines.append("本书没有记录到同一对人物前后变化的关系。")
          for (source, target), rows in changes:
              steps = []
              for row in rows:
                  state = row["state"] or row["label"] or "关系"
                  steps.append("%s(%s)" % (state, row["trigger"]) if row["trigger"] else state)
              lines.append("- %s → %s:%s" % (source, target, " → ".join(steps)))
          return "\n".join(lines) + "\n"
      
      
      def png_text(value: str, placeholder: str = "") -> str:
          """Text as drawn in the pictures: emoji and other symbols are left out."""
          kept = "".join(char for char in value
                         if char in BASE_TEXT or not unicodedata.category(char).startswith(("S", "C", "M")))
          return re.sub(r"\s+", " ", kept).strip() or placeholder
      
      
      def font_face(path: str):  # noqa: ANN201 - matplotlib type
          from matplotlib.ft2font import FT2Font
          return FT2Font(path)
      
      
      def missing_glyphs(path: str, text: str) -> Optional[List[str]]:
          """Characters of text the font (face 0) lacks; None when the font is unusable."""
          if FALLBACK_FONT_RE.search(os.path.basename(path)):
              return None
          try:
              font = font_face(path)
              if FALLBACK_FONT_RE.search(str(getattr(font, "family_name", ""))):
                  return None
              return sorted({char for char in text if not char.isspace() and not font.get_char_index(ord(char))})
          except Exception:  # noqa: BLE001 - any font loading failure means "not usable"
              return None
      
      
      def font_candidates() -> List[str]:
          """Installed fonts worth trying, well-known Chinese fonts first."""
          try:
              from matplotlib import font_manager
          except ImportError:
              return []
          by_name = {}  # type: Dict[str, List[str]]
          for entry in font_manager.fontManager.ttflist:
              paths = by_name.setdefault(entry.name, [])
              if entry.fname not in paths:
                  paths.append(entry.fname)
          candidates = [path for name in CJK_FONT_NAMES for path in by_name.get(name, [])]
          if shutil.which("fc-list"):
              try:
                  listed = subprocess.run(["fc-list", ":lang=zh", "file"], capture_output=True, text=True,
                                          timeout=10, check=False).stdout
              except (OSError, subprocess.SubprocessError):
                  listed = ""
              candidates.extend(line.split(":", 1)[0].strip() for line in listed.splitlines())
          return candidates
      
      
      def find_cjk_font(text: str) -> Tuple[Optional[str], List[str]]:
          """(font that draws every character of text, []) or (None, characters no Chinese font draws)."""
          text = text + BASE_TEXT
          override = os.environ.get("STORY_ANALYZE_CHART_FONT")
          if override == "none":
              return None, []
          fewest = None  # type: Optional[List[str]]
          seen = set()
          for path in [override] if override else font_candidates():
              if not path or path in seen:
                  continue
              seen.add(path)
              missing = missing_glyphs(path, text)
              if missing is None or any(char in BASE_TEXT for char in missing):
                  continue
              if not missing:
                  return path, []
              if fewest is None or len(missing) < len(fewest):
                  fewest = missing
          return None, fewest or []
      
      
      def matplotlib_available() -> bool:
          try:
              import matplotlib  # noqa: F401
          except ImportError:
              return False
          return True
      
      
      def short_label(value: str, limit: int = 10) -> str:
          value = png_text(re.sub(r"[`*]|\[[^\]]*\]", "", value), "关系")
          return value if len(value) <= limit else value[:limit - 1] + "…"
      
      
      def draw_png(relations: List[Dict[str, str]], font_path: str, root: Path) -> List[str]:
          import matplotlib
          matplotlib.use("Agg")
          from matplotlib import font_manager
          import matplotlib.pyplot as plt
      
          font = font_manager.FontProperties(fname=font_path)
          (root / CHART_DIR).mkdir(parents=True, exist_ok=True)
          written = []
          core = latest_by_pair(relations)
          degree = {}  # type: Dict[str, int]
          for relation in core:
              for name in (relation["source"], relation["target"]):
                  degree[name] = degree.get(name, 0) + 1
          # The most connected character (usually the protagonist) sits in the middle.
          names = sorted(degree, key=lambda name: -degree[name])
          ring = names[1:] if len(names) > 2 else names
          positions = {name: (math.cos(2 * math.pi * index / len(ring)), math.sin(2 * math.pi * index / len(ring)))
                       for index, name in enumerate(ring)}
          if len(names) > 2:
              positions[names[0]] = (0.0, 0.0)
          size = min(14, 8 + 0.3 * len(names))
          figure, axis = plt.subplots(figsize=(size, size))
          axis.set_axis_off()
          bend = 0.18  # curve both directions of a pair apart so their labels do not overlap
          for relation in core:
              (x1, y1), (x2, y2) = positions[relation["source"]], positions[relation["target"]]
              axis.annotate("", xy=(x2, y2), xytext=(x1, y1),
                            arrowprops={"arrowstyle": "->", "color": "#666666", "shrinkA": 22, "shrinkB": 22,
                                        "connectionstyle": "arc3,rad=%s" % bend})
              label_x = (x1 + x2) / 2 + bend * (y2 - y1) / 2
              label_y = (y1 + y2) / 2 - bend * (x2 - x1) / 2
              axis.text(label_x, label_y, short_label(relation["state"] or relation["label"] or "关系"),
                        fontproperties=font, fontsize=9, ha="center", va="center", color="#333333",
                        bbox={"boxstyle": "round", "fc": "white", "ec": "none", "alpha": 0.8})
          for name, (x, y) in positions.items():
              axis.text(x, y, png_text(name, "未命名"), fontproperties=font, fontsize=13, ha="center", va="center",
                        bbox={"boxstyle": "round,pad=0.5", "fc": "#e8eef7", "ec": "#4a6fa5"})
          axis.set_aspect("equal")
          axis.set_xlim(-1.4, 1.4)
          axis.set_ylim(-1.4, 1.4)
          figure.savefig(str(root / CORE_PNG), dpi=150, bbox_inches="tight")
          plt.close(figure)
          written.append(CORE_PNG.as_posix())
      
          changes = evolutions(relations)
          if changes:
              figure, axis = plt.subplots(figsize=(10, 1.2 + 0.8 * len(changes)))
              axis.set_axis_off()
              for row_index, ((source, target), rows) in enumerate(changes):
                  y = len(changes) - row_index
                  pair = "%s → %s" % (png_text(source, "未命名"), png_text(target, "未命名"))
                  axis.text(0, y, pair, fontproperties=font, fontsize=11, ha="left", va="center")
                  for step, row in enumerate(rows):
                      x = 3 + step * 2.2
                      if step:
                          axis.annotate("", xy=(x - 0.6, y), xytext=(x - 1.6, y),
                                        arrowprops={"arrowstyle": "->", "color": "#a58a4a"})
                      axis.text(x, y, png_text(row["state"] or row["label"], "关系"), fontproperties=font,
                                fontsize=10, ha="center", va="center",
                                bbox={"boxstyle": "round", "fc": "#f3efe6", "ec": "#a58a4a"})
                      if row["trigger"]:
                          axis.text(x, y - 0.32, png_text(row["trigger"]), fontproperties=font, fontsize=8,
                                    ha="center", va="center", color="#666666")
              axis.set_xlim(-0.2, 3 + 2.2 * max(len(rows) for _, rows in changes))
              axis.set_ylim(0.3, len(changes) + 0.7)
              figure.savefig(str(root / EVOLUTION_PNG), dpi=150, bbox_inches="tight")
              plt.close(figure)
              written.append(EVOLUTION_PNG.as_posix())
          return written
      
      
      def render(root: Path, want_png: bool) -> Dict[str, object]:
          source = root / RELATION_FILE
          try:
              text = source.read_text(encoding="utf-8-sig")
          except OSError:
              raise ChartError("relation_file_missing", "还没有 角色/角色关系.md,先整理人物关系,再生成关系图。")
          relations, skipped = parse_relations(text)
          if not relations:
              raise ChartError("relation_table_empty", "角色/角色关系.md 里没有认得出的关系表(需要「主体 → 客体」一列),关系图没有生成。")
          # The Markdown chart is the main result: write it before trying any picture.
          atomic_write(root / CHART_MD, render_markdown(root.name, relations, "").encode("utf-8"))
          png_written = []  # type: List[str]
          messages = []  # type: List[str]
          if skipped:
              messages.append("角色/角色关系.md 里有 %s 行没认出是谁和谁的关系,没画进关系图。" % skipped)
          if want_png:
              drawn = "".join(png_text(value) for relation in relations
                              for value in (relation["source"], relation["target"], relation["state"],
                                            relation["label"], relation["trigger"]))
              font_path, missing = find_cjk_font(drawn)
              if not matplotlib_available():
                  messages.append(NO_MATPLOTLIB_MESSAGE)
              elif not font_path:
                  messages.append(MISSING_GLYPH_MESSAGE % "".join(missing[:5]) if missing else NO_FONT_MESSAGE)
              else:
                  try:
                      png_written = draw_png(relations, font_path, root)
                  except Exception:  # noqa: BLE001 - the Markdown chart is already written
                      messages.append(PNG_FAILED_MESSAGE)
                  if png_written:
                      atomic_write(root / CHART_MD,
                                   render_markdown(root.name, relations, "图片版见同目录的 PNG。").encode("utf-8"))
          return {
              "ok": True, "markdown": CHART_MD.as_posix(), "png": png_written,
              "relations": len(relations), "skipped_rows": skipped,
              "author_message": "".join(messages) or None,
          }
      
      
      def main() -> int:
          for stream in (sys.stdout, sys.stderr):
              if hasattr(stream, "reconfigure"):
                  stream.reconfigure(encoding="utf-8")
          parser = argparse.ArgumentParser(description=__doc__)
          parser.add_argument("--root", required=True, type=Path, help="拆文库/{书名} 目录")
          parser.add_argument("--png", action="store_true", help="also draw PNG pictures when a Chinese font exists")
          args = parser.parse_args()
          try:
              if not args.root.is_dir():
                  raise ChartError("root_not_found", "没找到这本书的拆文目录,确认书名或路径。")
              payload = render(args.root.resolve(), args.png)
          except ChartError as exc:
              payload = {"ok": False, "error": exc.code, "author_message": exc.author_message}
          except (OSError, UnicodeError) as exc:
              payload = {"ok": False, "error": "io_error", "detail": str(exc),
                         "author_message": "关系图文件写入失败,检查拆文目录是否可写。"}
          print(json.dumps(payload, ensure_ascii=False, indent=2))
          return 0 if payload.get("ok") else 2
      
      
      if __name__ == "__main__":
          raise SystemExit(main())
      
  • SKILL.md 14 KB
    ---
    name: story-long-analyze
    version: 1.0.0
    description: "长篇网文拆文。保留黄金三章、逐章摘要、剧情、情绪、节奏、角色、设定和文风接口,以连续章节块完成因果、双时间线、关系与三维节奏分析;兼容旧成果直接使用、按需增强和断点续跑。含可选三层灵感库管道(灵感库、跨书灵感聚合、更新灵感库)。触发方式:/story-long-analyze、/长篇拆文、「帮我拆这本书」「拆这本书」「分析黄金三章」「深度拆解」「完整拆解」或提供小说文本文件路径。"
    metadata: {"openclaw":{"source":"https://github.com/zenstory-ai/oh-story-claudecode"}}
    ---
    # story-long-analyze:长篇网文拆文
    
    你是网络小说结构分析师。
    
    **核心原则:机械边界只解析一次;原文按连续章节块读取一次;同次读取产生逐章事实和跨章观察;聚合阶段复用落盘结果,不重新阅读全文。**
    
    > Agent 兼容性:只检查当前运行时 canonical 目录。运行时不支持项目 agent 或找不到文件时降级 solo/direct,并报告 `Fallback: project custom agents unavailable -> solo`。ZCode 3.3.4 不提供项目 custom agents,直接按此规则降级,不扫描其他 CLI 的 agent 目录。
    >
    > Spawn 版本提示(不阻断 spawn):先读取项目根 `.story-deployed` 的 `agents_version`。与本版 `agents_version: 34` 不一致时(标记缺失、字段缺失/非整数、小于或大于 34)照常按文件存在性检查并 spawn,同时报告 `Notice: agents bundle 版本不匹配(项目 {N},本版 34)` 并提示重新运行 `/story-setup` 后新开会话;大于 34 时额外提示先更新 oh-story-claudecode,不要用本地旧版 setup 降级覆盖。只有 agent 文件缺失、或运行时不暴露 custom agent 时才降级 solo/direct。
    
    ## 分析边界
    
    1. 只根据可读原文和已有资料下结论;缺失写“未知”或“文本未明确”。
    2. 硬事实附章节、`source_locator` 或 5–15 字定位词;推断标证据强度。
    3. 区分客观发生顺序、文本披露顺序、读者所知和角色所知。
    4. 分开分析事件推进、读者情绪和篇幅安排,分数不能代替解释。
    5. 只迁移抽象机制,不复刻专有设定、角色组合、关键事件链、标志性场面或原句。
    6. 不为填字段虚构事实,不把结果倒推成人物早有计划。
    
    ## 对作者说话
    
    作者读到的一切——停下来提问、进度、拆完汇报、出错说明,以及 `快速预览.md`、`拆文报告.md`、人物关系图——按 [references/author-facing.md](references/author-facing.md) 写:大白话讲书、讲章、讲读者和作者能怎么用;不出现脚本名、命令、字段名、状态值、批次编号、内部文件名、质量指标名和证据分级字母;编号只和名称一起出现;需要作者拿主意时给一个问题、推荐选项和默认值;工程细节默认不写,确需时只在末尾留一行技术备注。脚本输出带 `author_message` 时转述它,不贴 JSON 或错误码。
    
    ## 按时刻读
    
    拆文按阶段分成几个时刻。进入一个时刻只读下表这一行的文件;上一时刻的操作说明不必留在上下文里,时刻之间只靠落盘产物和 `_progress.md` 的阶段状态交接(续跑、换新对话都从这里接上)。[references/author-facing.md](references/author-facing.md) 每个时刻都按需用。
    
    | 时刻 | 读 | 交接 |
    |---|---|---|
    | Phase 1–2、Stage 0–1 开头三章 | 本文件 + [stage1-golden-chapters.md](references/stage1-golden-chapters.md);原文变了或章号对不上时加 [index-rebuild.md](references/index-rebuild.md) | 黄金三章与快速预览落盘,标 `stage1` |
    | Stage 2 逐批提取 | [pipeline-ops.md](references/pipeline-ops.md);子代理不可用、自己写批次时加 [stage2-extraction.md](references/stage2-extraction.md) | 全部摘要落盘,标 `stage2` |
    | Stage 3 剧情与节奏 | [synthesis-inputs.md](references/synthesis-inputs.md) + [stage3-plot-rhythm.md](references/stage3-plot-rhythm.md);打桥段标签时查 [deconstruction-notes.md](references/deconstruction-notes.md)「桥段词表」 | 节奏与情绪模块落盘,标 `stage3` |
    | Stage 4 角色与设定 | [synthesis-inputs.md](references/synthesis-inputs.md) + [stage4-characters-settings.md](references/stage4-characters-settings.md) | 角色与设定落盘,标 `stage4` |
    | Stage 5 主报告 | [synthesis-inputs.md](references/synthesis-inputs.md) + [stage5-report.md](references/stage5-report.md) | 报告落盘,标 `stage5` |
    | Stage 6 文风 | [style-profile-generator.md](references/style-profile-generator.md)(它再指向文风协议) | `文风.md` 落盘,标 `stage6` |
    | 全部拆完 | [final-checks.md](references/final-checks.md) | 按 author-facing「全部拆完」汇报 |
    
    ## Phase 1:确认对象并检查目录
    
    没有书名或原文时询问书名、平台和原文路径;已有完整成果直接使用时不强制索要原文。已有目录先运行只读检查器:
    
    ```text
    "{PYTHON}" "{story-long-analyze skill 根}/scripts/inspect_existing_assets.py" --root "拆文库/{书名}" --compact
    ```
    
    路径错误必须停止。完整旧项目返回 `direct_use` 后直接使用,不建索引、不读原文。只有用户明确要求增强才读取旧成果。已有摘要一律不覆盖:要重拆某章就删掉它的 `章节/第N章_摘要.md` 和覆盖它的 `_analysis_cache/批次-*.md`(只删摘要会从缓存原样补回),整本重拆就换一个新目录。`schema_version` 只报告,不作为新旧门禁,也不得在复用时改写。
    
    ## Phase 2:唯一管道与三种情况
    
    | 情况 | 行为 |
    |---|---|
    | 部分完成 | 已完成章只读旧拆文;黄金三章可补缺失摘要;仅缺摘要的章进入原文块 |
    | 已完整拆完 | 默认直接使用;增强只写 `_analysis_cache/` 和 `_progress.md` 状态 |
    | 全新小说 | 建索引、完成黄金三章,再把其余正文放入不重叠连续章块 |
    
    检查器只扫描上游 `章节/*_摘要.md` 与黄金三章,逐章报告缺口。新旧投影混存要报告来源,但不要求重拆。
    
    ### 固定交付接口
    
    - `拆文报告.md`、`概要.md`、`快速预览.md`;
    - `章节/第1-3章_深度拆解.md`、`章节/第N章_摘要.md`;
    - `剧情/故事线.md`、剧情单元、`节奏.md`、`情绪模块.md`、`散落情节.md`;
    - `角色/`、`设定/`、`人物关系图/`、`文风.md`;
    - `chapter_index.csv`、`_progress.md`、`_analysis_cache/`。
    
    `拆文报告.md` 是阅读入口。剧情单元管因果事实,`剧情/节奏.md` 管信息推进与三维节奏,`剧情/情绪模块.md` 管读者需求和复现机制,`角色/角色关系.md` 管关系事实,`文风.md` 管表达层。
    
    ### Stage 0–6
    
    | 阶段 | 输入 | 主要输出 | 完成判断 |
    |---|---|---|---|
    | 0 机械索引 | 原文 | `chapter_index.csv`、`概要.md` 初稿(Stage 5 覆盖) | 章界、逐章 hash 和全源 hash 有效 |
    | 1 黄金三章 | 前三章原文 | 深度拆解、快速预览、可选 `_style-sample.txt` | 老接口完整;同次阅读保存可用样本 |
    | 2 连续块提取 | 只读计划列出的旧成果或原文块 | 批次缓存;缺失逐章摘要投影 | 缓存完整、摘要存在、状态范围 hash 有效 |
    | 3 剧情与机制 | 批次缓存和可信旧成果 | 剧情单元、故事线、节奏、情绪模块 | 文件存在、阶段状态完成 |
    | 4 角色与设定 | 批次涉及人物、状态变化、关系观察 | 角色、设定、关系图 | 文件存在、阶段状态完成 |
    | 5 主报告 | 权威底层结果 | 拆文报告、完整概要 | 文件存在、阶段状态完成 |
    | 6 文风 | 既有资料、样本或索引定点原文 | `文风.md` | 文件存在、阶段状态完成 |
    
    用户未要求一次跑完时,Stage 1 后按 author-facing.md「开头三章拆完、停下来问」询问是否继续;要求一次跑完、多本书一起拆或由导入自动续跑时不停下询问。Stage 2 默认有限并行(每轮 3 批),不请作者选派发方式;作者问起或明确要求时再按 author-facing「作者问起怎么拆」解释并切换(见 pipeline-ops「执行与提交一个批次」)。续跑不重复 Stage 0/1。Stage 3–5 不重读原文。Stage 6 可按索引定点读取 4–6 段原文锚点,但不重扫全书。
    
    ## Stage 0:机械章节索引
    
    全新和部分完成运行:
    
    ```text
    "{PYTHON}" "{story-long-analyze skill 根}/scripts/build_chapter_index.py" --source "{拆文目录}/原文/原文.txt" --output "{拆文目录}/chapter_index.csv" --locator-path "原文/原文.txt"
    ```
    
    完整旧成果直接使用或纯增强时不建索引。索引只含机械事实:
    
    ```csv
    chapter,source_chapter,volume,title,start_line,end_line,char_count,source_locator,status,chapter_sha256,source_sha256,parser_version
    ```
    
    只按 LF 计物理行。支持楔子、序章、第0章、任意正文起始章、番外、后记、中文大数、英文章号、多卷重置和卷章组合。目录与正文标题重复时先剔掉目录块;落表前校验章号连续、无重复和边界有效,其中特殊章独立编号,正文允许从任意首章开始。原文变化先拒绝;脚本因原文变化、章号对不上而停下并返回 `author_message` 时,读 [references/index-rebuild.md](references/index-rebuild.md),把说明和选项转告作者(默认推荐按旧章号继续)。
    
    `概要.md` 初稿只按章节标题、卷段结构和抽样开头/结尾写,模板见 [references/stage1-golden-chapters.md](references/stage1-golden-chapters.md)。
    
    ## Stage 1:黄金三章
    
    按索引读前三章原文,同一次阅读写三份单章深度拆解、可选 `_style-sample.txt`,再写 `快速预览.md`(模板在 author-facing.md「快速预览.md」);深度拆解与文风样本模板见 [references/stage1-golden-chapters.md](references/stage1-golden-chapters.md)。黄金三章与快速预览落盘后运行 `manage_analysis_run.py mark-stage --stage stage1`,再按上方规则停下来问或继续。
    
    ## Stage 2:计划、提取、提交
    
    按 [references/pipeline-ops.md](references/pipeline-ops.md) 执行:`manage_analysis_run.py plan` 出只读计划(用户明确增强用 `--intent enhance`,逐批加 `--next`,只拆一段加 `--chapters 起-止`),每批派一个 `chapter-extractor`,只照抄计划里这一批的字段;子代理自己读原文、把结果写进 `_analysis_cache/输入-{批次ID}.md`、只回一行回执,主会话不转贴原文、不读这份输入,直接 `commit`。批次过大或连续失败用 `split`,中断用 `repair-progress`。计划不再有批次、全部摘要落盘后运行 `manage_analysis_run.py mark-stage --stage stage2`。
    
    ## Stage 3:剧情、双时间线与三维节奏
    
    按 [references/stage3-plot-rhythm.md](references/stage3-plot-rhythm.md) 生成剧情单元、故事线、`剧情/节奏.md` 与 `剧情/情绪模块.md`,取料用 `digest`(见 synthesis-inputs.md)。两份权威文件都落盘后运行 `manage_analysis_run.py mark-stage --stage stage3 --output "剧情/节奏.md"`。
    
    ## Stage 4:角色、设定与关系
    
    按 [references/stage4-characters-settings.md](references/stage4-characters-settings.md) 生成角色档案、设定和 `角色/角色关系.md`,关系图只从该文件用 `render_relation_chart.py` 生成。至少一份角色档案和一份设定文件落盘后运行 `manage_analysis_run.py mark-stage --stage stage4`;缺任一类文件时不得标完成。
    
    ## Stage 5:主报告
    
    报告按 author-facing.md「拆文报告.md」写:拆到哪、核心发现、读者在追什么、故事怎么推进、人物与关系、读者与角色的信息差、节奏、核心机制、可借鉴套路、不建议模仿、文风一句话、还不确定的地方。生成新报告前运行 `manage_analysis_run.py mark-stage --stage stage5 --prepare`,新报告与完整概要落盘后再运行 `manage_analysis_run.py mark-stage --stage stage5`(细则见 [references/stage5-report.md](references/stage5-report.md))。报告只综合底层结果,不再次阅读全文。
    
    如项目存在 `选题决策.md`,只回填仍标记“待拆文验证”且题材匹配的项。没有「推荐选题」一节(只扫了榜)就跳过回填,不算无效;有推荐选题但缺少当前契约必需的“能爆的原因”等字段时返回 `invalid_topic_decision_contract`,提示重跑 `story-long-scan` Phase 5;文件不存在不影响拆文。
    
    ## Stage 6:文风与单独重建
    
    加载 [references/style-profile-generator.md](references/style-profile-generator.md)。优先使用已有 `文风.md` 和有效 `_style-sample.txt`;样本不足时允许依据索引选择 4–6 章、定点读取原文行段。只缺文风时直接运行 Stage 6,不重跑 Stage 1–5。没有有效样本、索引或原文时明确失败,不生成锚点全空的可用档案。
    
    ## 三层灵感库管道(可选后置)
    
    用户提出「灵感库 / 提炼灵感 / 跨书灵感聚合 / 更新灵感库」时加载 [references/inspiration-library.md](references/inspiration-library.md)。复用 Stage 3 的 EM 机制卡:`inspiration_index.py register-atoms` 机械登记原子灵感索引(无 IA 文件),再按该文档做单书合并与带受控标签的跨书聚合;卡内只用 `书名/EM-xxx` 裸 ID,禁路径引用。缺情绪模块的书先走上方按需增强,不在灵感层代拆。单书拆文不自动入库。
    
    ## 状态与旧项目
    
    运行状态只有 `_progress.md` 受管区;既有 `schema_version: 2` 原值保留;`chapter_index.csv` 是机械索引;缓存是恢复证据。有阶段记录后,受管区的 `最终状态` 由脚本按 Stage 3–6 的阶段状态写出(都完成为 `completed`,否则 `pending`);旧项目沿用自己原有的 `最终状态` 行,全部完成时由脚本改为 `completed`,不写第二行,会话 hooks 靠它判断拆文是否完成,不要手改。不得创建运行计划、checkpoint、逐批 JSON receipt 或 Stage receipt。
    
    全部完成后按 [references/final-checks.md](references/final-checks.md) 做收尾检查,再按 author-facing.md「全部拆完」向作者汇报。
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related