Răsfoiți Sursa

feat(v5.3): 添加追读力分类标准和完整测试套件

v5.3 核心参考文档:
- references/reading-power-taxonomy.md: 追读力分类标准(钩子5类型/爽点8模式/微兑现7类型)
- references/genre-profiles.md: 8种题材配置档案

新增测试文件:
- test_api_client.py: API 客户端测试
- test_config.py: 配置模块测试
- test_entity_linker_cli.py: 实体链接 CLI 测试
- test_migrate_state_to_sqlite.py: 迁移脚本测试
- test_rag_adapter.py: RAG 适配器测试
- test_sql_state_manager.py: SQL 状态管理测试
- test_state_manager_extra.py: 状态管理额外测试
- test_style_sampler_cli.py: 风格采样 CLI 测试

项目配置:
- CLAUDE.md: 项目指南
- pytest.ini: pytest 配置
- .coveragerc: 覆盖率配置

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
lingfengQAQ 4 luni în urmă
părinte
comite
7cdc1f3c11

+ 469 - 0
.claude/references/genre-profiles.md

@@ -0,0 +1,469 @@
+# 题材配置档案 (Genre Profiles) v5.3
+
+> **定位**:本文档定义各题材的追读力配置参数,供 Step 1.5 / Context Agent / Checkers 读取。
+>
+> **原则**:配置用于"调整权重和建议",不做硬性裁决。
+
+---
+
+## 一、Profile 字段说明
+
+### 1.1 核心字段
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `id` | string | 题材唯一标识(英文小写) |
+| `name` | string | 题材中文名 |
+| `description` | string | 一句话描述核心卖点 |
+| `tags` | string[] | 可叠加的题材标签(预留多标签扩展) |
+
+### 1.2 钩子配置 (hook_config)
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `preferred_types` | string[] | 偏好钩子类型(按优先级排序) |
+| `strength_baseline` | string | 默认钩子强度:strong/medium/weak |
+| `chapter_end_required` | boolean | 章末是否必须有钩子 |
+| `transition_allowance` | number | 过渡章豁免上限(连续多少章可降级) |
+
+### 1.3 爽点配置 (coolpoint_config)
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `preferred_patterns` | string[] | 偏好爽点模式(按优先级排序) |
+| `density_per_chapter` | string | 每章爽点密度:high(2+)/medium(1)/low(0-1) |
+| `combo_interval` | number | combo爽点间隔(每N章至少1个) |
+| `milestone_interval` | number | 阶段性胜利间隔(每N章至少1个) |
+
+### 1.4 微兑现配置 (micropayoff_config)
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `preferred_types` | string[] | 偏好微兑现类型 |
+| `min_per_chapter` | number | 每章最少微兑现数量 |
+| `transition_min` | number | 过渡章最少微兑现数量 |
+
+### 1.5 节奏红线 (pacing_config)
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `stagnation_threshold` | number | 节奏停滞阈值(连续N章无推进=HARD-003) |
+| `strand_quest_max` | number | Quest主线最大连续章数 |
+| `strand_fire_gap_max` | number | Fire感情线最大断档章数 |
+| `transition_max_consecutive` | number | 过渡章最大连续数 |
+
+### 1.6 约束豁免 (override_config)
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `allowed_rationale_types` | string[] | 允许的Override理由类型 |
+| `debt_multiplier` | number | 债务倍率(>1表示该题材更严格) |
+| `payback_window_default` | number | 默认偿还窗口(章数) |
+
+---
+
+## 二、内置题材 Profiles
+
+### 2.1 爽文/系统流 (shuangwen)
+
+```yaml
+id: shuangwen
+name: 爽文/系统流
+description: 金手指开挂,快节奏升级,打脸装逼一条龙
+tags: [shuangwen]
+
+hook_config:
+  preferred_types: [渴望钩, 危机钩, 情绪钩]
+  strength_baseline: medium
+  chapter_end_required: true
+  transition_allowance: 2
+
+coolpoint_config:
+  preferred_patterns: [装逼打脸, 扮猪吃虎, 越级反杀, 迪化误解]
+  density_per_chapter: high
+  combo_interval: 5
+  milestone_interval: 10
+
+micropayoff_config:
+  preferred_types: [能力兑现, 资源兑现, 认可兑现]
+  min_per_chapter: 2
+  transition_min: 1
+
+pacing_config:
+  stagnation_threshold: 3
+  strand_quest_max: 5
+  strand_fire_gap_max: 15
+  transition_max_consecutive: 2
+
+override_config:
+  allowed_rationale_types: [TRANSITIONAL_SETUP, ARC_TIMING]
+  debt_multiplier: 1.0
+  payback_window_default: 3
+```
+
+**题材特点**:
+- 追求高密度爽点,读者期待快节奏
+- 章末必须有明确期待(要突破了/要打脸了/要发财了)
+- 过渡章容忍度低,最多连续2章
+
+---
+
+### 2.2 修仙/玄幻 (xianxia)
+
+```yaml
+id: xianxia
+name: 修仙/玄幻
+description: 逆天改命,残酷法则,机缘与争斗并存
+tags: [xianxia]
+
+hook_config:
+  preferred_types: [危机钩, 渴望钩, 选择钩]
+  strength_baseline: medium
+  chapter_end_required: true
+  transition_allowance: 3
+
+coolpoint_config:
+  preferred_patterns: [越级反杀, 扮猪吃虎, 身份掉马, 反派翻车]
+  density_per_chapter: medium
+  combo_interval: 8
+  milestone_interval: 15
+
+micropayoff_config:
+  preferred_types: [能力兑现, 资源兑现, 信息兑现]
+  min_per_chapter: 1
+  transition_min: 1
+
+pacing_config:
+  stagnation_threshold: 4
+  strand_quest_max: 6
+  strand_fire_gap_max: 12
+  transition_max_consecutive: 3
+
+override_config:
+  allowed_rationale_types: [TRANSITIONAL_SETUP, WORLD_RULE_CONSTRAINT, ARC_TIMING]
+  debt_multiplier: 0.9
+  payback_window_default: 5
+```
+
+**题材特点**:
+- 需要世界观构建,允许更多铺垫章
+- 境界突破是核心期待
+- 设定约束可作为合理Override理由
+
+---
+
+### 2.3 言情/甜宠 (romance)
+
+```yaml
+id: romance
+name: 言情/甜宠
+description: 情感互动,关系推进,心动与虐心交织
+tags: [romance]
+
+hook_config:
+  preferred_types: [情绪钩, 渴望钩, 选择钩]
+  strength_baseline: medium
+  chapter_end_required: true
+  transition_allowance: 2
+
+coolpoint_config:
+  preferred_patterns: [甜蜜超预期, 身份掉马, 迪化误解]
+  density_per_chapter: medium
+  combo_interval: 6
+  milestone_interval: 12
+
+micropayoff_config:
+  preferred_types: [关系兑现, 情绪兑现, 认可兑现]
+  min_per_chapter: 1
+  transition_min: 1
+
+pacing_config:
+  stagnation_threshold: 4
+  strand_quest_max: 4
+  strand_fire_gap_max: 5
+  transition_max_consecutive: 2
+
+override_config:
+  allowed_rationale_types: [TRANSITIONAL_SETUP, CHARACTER_CREDIBILITY, ARC_TIMING]
+  debt_multiplier: 1.0
+  payback_window_default: 4
+```
+
+**题材特点**:
+- 感情线是绝对核心,断档容忍度极低
+- 情绪钩是王牌(心疼/心动/吃醋)
+- 关系进展是最重要的微兑现
+
+---
+
+### 2.4 悬疑/推理 (mystery)
+
+```yaml
+id: mystery
+name: 悬疑/推理
+description: 谜题驱动,逻辑推演,真相一步步揭示
+tags: [mystery]
+
+hook_config:
+  preferred_types: [悬念钩, 危机钩, 选择钩]
+  strength_baseline: medium
+  chapter_end_required: true
+  transition_allowance: 2
+
+coolpoint_config:
+  preferred_patterns: [反派翻车, 身份掉马]
+  density_per_chapter: low
+  combo_interval: 10
+  milestone_interval: 20
+
+micropayoff_config:
+  preferred_types: [信息兑现, 线索兑现]
+  min_per_chapter: 1
+  transition_min: 1
+
+pacing_config:
+  stagnation_threshold: 3
+  strand_quest_max: 8
+  strand_fire_gap_max: 20
+  transition_max_consecutive: 2
+
+override_config:
+  allowed_rationale_types: [LOGIC_INTEGRITY, TRANSITIONAL_SETUP, ARC_TIMING]
+  debt_multiplier: 0.8
+  payback_window_default: 5
+```
+
+**题材特点**:
+- 逻辑完整性优先于爽点密度
+- 信息兑现是核心微兑现(每章需揭示新线索)
+- LOGIC_INTEGRITY可作为降级钩子强度的合理理由
+
+---
+
+### 2.5 规则怪谈 (rules-mystery)
+
+```yaml
+id: rules-mystery
+name: 规则怪谈
+description: 诡异规则,生存推理,反杀怪谈
+tags: [rules-mystery, horror]
+
+hook_config:
+  preferred_types: [危机钩, 悬念钩, 选择钩]
+  strength_baseline: strong
+  chapter_end_required: true
+  transition_allowance: 1
+
+coolpoint_config:
+  preferred_patterns: [越级反杀, 反派翻车]
+  density_per_chapter: medium
+  combo_interval: 5
+  milestone_interval: 8
+
+micropayoff_config:
+  preferred_types: [信息兑现, 线索兑现, 能力兑现]
+  min_per_chapter: 1
+  transition_min: 1
+
+pacing_config:
+  stagnation_threshold: 2
+  strand_quest_max: 4
+  strand_fire_gap_max: 15
+  transition_max_consecutive: 1
+
+override_config:
+  allowed_rationale_types: [LOGIC_INTEGRITY, WORLD_RULE_CONSTRAINT]
+  debt_multiplier: 1.2
+  payback_window_default: 2
+```
+
+**题材特点**:
+- 紧张氛围要求高钩子强度
+- 过渡章容忍度极低(1章)
+- 规则约束是合理Override理由
+
+---
+
+### 2.6 都市异能 (urban-power)
+
+```yaml
+id: urban-power
+name: 都市异能
+description: 现代背景,隐藏超能,低调装逼
+tags: [urban, power]
+
+hook_config:
+  preferred_types: [危机钩, 渴望钩, 情绪钩]
+  strength_baseline: medium
+  chapter_end_required: true
+  transition_allowance: 2
+
+coolpoint_config:
+  preferred_patterns: [扮猪吃虎, 装逼打脸, 身份掉马, 迪化误解]
+  density_per_chapter: high
+  combo_interval: 5
+  milestone_interval: 10
+
+micropayoff_config:
+  preferred_types: [认可兑现, 能力兑现, 关系兑现]
+  min_per_chapter: 2
+  transition_min: 1
+
+pacing_config:
+  stagnation_threshold: 3
+  strand_quest_max: 5
+  strand_fire_gap_max: 10
+  transition_max_consecutive: 2
+
+override_config:
+  allowed_rationale_types: [TRANSITIONAL_SETUP, ARC_TIMING]
+  debt_multiplier: 1.0
+  payback_window_default: 3
+```
+
+**题材特点**:
+- 装逼打脸系列是核心爽点
+- 现代背景要求身份隐藏→掉马的节奏控制
+- 社会地位变化是重要微兑现
+
+---
+
+### 2.7 知乎短篇 (zhihu-short)
+
+```yaml
+id: zhihu-short
+name: 知乎短篇
+description: 短平快,强反转,情绪冲击
+tags: [short, zhihu]
+
+hook_config:
+  preferred_types: [情绪钩, 悬念钩, 选择钩]
+  strength_baseline: strong
+  chapter_end_required: true
+  transition_allowance: 0
+
+coolpoint_config:
+  preferred_patterns: [反派翻车, 身份掉马, 甜蜜超预期]
+  density_per_chapter: high
+  combo_interval: 2
+  milestone_interval: 3
+
+micropayoff_config:
+  preferred_types: [情绪兑现, 信息兑现, 关系兑现]
+  min_per_chapter: 2
+  transition_min: 2
+
+pacing_config:
+  stagnation_threshold: 1
+  strand_quest_max: 2
+  strand_fire_gap_max: 3
+  transition_max_consecutive: 0
+
+override_config:
+  allowed_rationale_types: []
+  debt_multiplier: 2.0
+  payback_window_default: 1
+```
+
+**题材特点**:
+- 不允许过渡章(每章必须有收获)
+- 极高钩子强度要求
+- 债务倍率最高(短篇不允许欠债)
+
+---
+
+### 2.8 替身文/虐文 (substitute)
+
+```yaml
+id: substitute
+name: 替身文/虐文
+description: 情感纠葛,误解与反转,追妻火葬场
+tags: [substitute, angst]
+
+hook_config:
+  preferred_types: [情绪钩, 选择钩, 悬念钩]
+  strength_baseline: strong
+  chapter_end_required: true
+  transition_allowance: 2
+
+coolpoint_config:
+  preferred_patterns: [身份掉马, 反派翻车, 甜蜜超预期]
+  density_per_chapter: medium
+  combo_interval: 5
+  milestone_interval: 10
+
+micropayoff_config:
+  preferred_types: [情绪兑现, 关系兑现, 认可兑现]
+  min_per_chapter: 1
+  transition_min: 1
+
+pacing_config:
+  stagnation_threshold: 3
+  strand_quest_max: 3
+  strand_fire_gap_max: 4
+  transition_max_consecutive: 2
+
+override_config:
+  allowed_rationale_types: [CHARACTER_CREDIBILITY, ARC_TIMING, TRANSITIONAL_SETUP]
+  debt_multiplier: 1.0
+  payback_window_default: 4
+```
+
+**题材特点**:
+- 情绪钩是绝对核心(虐心→心疼→期待)
+- 身份掉马是王牌爽点
+- 感情线断档容忍度极低
+
+---
+
+## 三、Profile 加载机制
+
+### 3.1 加载时机
+
+1. **Step 1.5**:根据 `state.json → project.genre` 加载对应profile
+2. **Context Agent**:将profile相关字段注入创作任务书
+3. **Checkers**:根据profile调整检测阈值和建议权重
+
+### 3.2 多标签支持(预留)
+
+当前为单标签模式。未来支持多标签时:
+- 使用 `tags` 字段叠加
+- 冲突字段取更严格的值
+- 例:`[romance, mystery]` → 感情线断档取 min(5, 20) = 5
+
+### 3.3 自定义Profile
+
+用户可在 `state.json` 中覆盖默认值:
+
+```json
+{
+  "project": {
+    "genre": "xianxia",
+    "genre_overrides": {
+      "pacing_config": {
+        "stagnation_threshold": 5
+      }
+    }
+  }
+}
+```
+
+---
+
+## 四、与 Taxonomy 的关系
+
+| Taxonomy 定义 | Profile 配置 |
+|--------------|-------------|
+| 钩子类型清单 | 哪些类型偏好 |
+| 爽点模式清单 | 哪些模式偏好 |
+| 微兑现类型清单 | 哪些类型偏好 |
+| Hard/Soft 标准 | 阈值调整 |
+| Override 理由类型 | 哪些理由允许 |
+
+---
+
+## 五、版本历史
+
+| 版本 | 日期 | 变更 |
+|------|------|------|
+| v5.3 | 2026-02-01 | 初版,包含8个内置题材profile |

+ 354 - 0
.claude/references/reading-power-taxonomy.md

@@ -0,0 +1,354 @@
+# 追读力分类标准 (Reading Power Taxonomy) v5.3
+
+> **定位**:本文档定义"追读力"相关的统一分类标准,供 Step 1.5 / Context Agent / Checkers 共享使用。
+>
+> **原则**:所有分类用于"指导性建议",不做硬性评分裁决。
+
+---
+
+## 一、钩子类型 (Hook Types)
+
+### 1.1 现有类型(兼容映射)
+
+| 旧类型 | 新 Taxonomy | 说明 |
+|--------|-------------|------|
+| 危机型 | 危机钩 (Crisis Hook) | 敌人出现/危险逼近 |
+| 反常型 | 悬念钩 (Mystery Hook) | 信息缺口/未解之谜 |
+| 利益型 | 渴望钩 (Desire Hook) | 好事即将发生/奖励可期 |
+
+### 1.2 扩展类型
+
+#### 1.2.1 情绪钩 (Emotion Hook)
+
+**定义**:通过触发读者的强烈情绪反应(愤怒/心疼/共情/不公/羞耻/心动)来驱动阅读。
+
+**触发场景**:
+- 主角遭受不白之冤
+- 被信任的人背叛
+- 弱者被欺凌
+- 关系突破/心动时刻
+
+**题材适配**:
+| 题材 | 偏好情绪 | 强度建议 |
+|------|---------|---------|
+| 言情 | 心疼/心动/羞耻 | strong |
+| 爽文 | 愤怒/不公 | medium→strong |
+| 悬疑 | 共情/恐惧 | medium |
+
+**过渡章降级**:可用"轻情绪"(如淡淡的担忧/期待)替代强情绪。
+
+**软提示问句**:
+- 本章结尾读者会产生什么情绪?
+- 这个情绪是否足以让他们想知道"接下来怎么办"?
+
+**常见误用**:
+- ❌ 情绪无来由(没有铺垫就要求读者共情)
+- ❌ 情绪与角色行为不匹配
+- ❌ 过度煽情导致疲劳
+
+**示例**:
+- 爽文:"萧炎看着倒在血泊中的药老,拳头攥紧——他发誓,这笔账一定要讨回来。"
+- 言情:"她终于明白,那个从不解释的男人,一直在用自己的方式保护她。"
+- 悬疑:"监控里那个身影,分明是三天前已经死去的人。"
+
+---
+
+#### 1.2.2 选择钩 (Choice Hook)
+
+**定义**:通过设置两难抉择/高风险决策来驱动阅读,读者想知道角色会如何选择。
+
+**触发场景**:
+- 生死二选一
+- 利益与道义冲突
+- 误会导致的抉择
+- 时间压力下的决定
+
+**题材适配**:
+| 题材 | 偏好选择类型 | 强度建议 |
+|------|-------------|---------|
+| 悬疑 | 真相vs安全 | strong |
+| 言情 | 感情vs现实 | medium→strong |
+| 爽文 | 冒险vs稳妥 | medium |
+
+**过渡章降级**:可用"小选择"(如去哪/见谁/信谁)替代生死抉择。
+
+**软提示问句**:
+- 角色面临什么选择?
+- 两个选项各有什么代价?
+- 读者会站在哪一边?
+
+**常见误用**:
+- ❌ 选择没有代价(假两难)
+- ❌ 正确答案太明显
+- ❌ 选择与角色性格不符
+
+**示例**:
+- 悬疑:"门后传来女儿的哭声,但规则明确写着:不要开门。"
+- 言情:"机票已经订好,行李已经打包——她只需要决定,要不要回头看他最后一眼。"
+- 爽文:"吞下这颗丹药,有三成概率突破,七成概率走火入魔。"
+
+---
+
+#### 1.2.3 渴望钩 (Desire Hook)
+
+**定义**:通过展示可期待的奖励/成就/进展来驱动阅读,读者想看到愿望实现的过程。
+
+**触发场景**:
+- 突破在即
+- 宝物将得
+- 复仇时机成熟
+- 关系即将进展
+- 真相呼之欲出
+
+**子类型**:
+| 子类型 | 描述 | 示例 |
+|--------|------|------|
+| 成长渴望 | 想看主角变强 | "再有三天,就能突破了" |
+| 关系渴望 | 想看CP发糖 | "她答应了那个约定" |
+| 复仇渴望 | 想看反派倒霉 | "王少不知道,他的末日近在眼前" |
+| 真相渴望 | 想知道谜底 | "答案或许就在这块玉佩里" |
+| 收获渴望 | 想看获得好东西 | "这朵火莲,足以让他脱胎换骨" |
+
+**题材适配**:
+| 题材 | 偏好渴望类型 | 强度建议 |
+|------|-------------|---------|
+| 爽文 | 成长/复仇/收获 | strong |
+| 言情 | 关系/真相 | medium→strong |
+| 悬疑 | 真相 | strong |
+
+**过渡章降级**:可用"小期待"(如即将见到某人/即将到达某地)替代大期待。
+
+**软提示问句**:
+- 读者在期待什么?
+- 这个期待什么时候会实现?
+- 本章是否给了实现的希望?
+
+**常见误用**:
+- ❌ 期待一直不兑现(狼来了效应)
+- ❌ 期待太容易实现(没有张力)
+- ❌ 期待与读者不共鸣
+
+**示例**:
+- 爽文:"明日宗门大比,所有嘲笑过他的人,都将见证他的崛起。"
+- 言情:"他说,等这件事结束,有话要对她说。"
+- 悬疑:"十年前那场火灾的真相,就藏在这份档案里。"
+
+---
+
+### 1.3 钩子强度分级
+
+| 强度 | 适用场景 | 特征 |
+|------|---------|------|
+| **strong** | 卷末/关键转折/大冲突前 | 读者必须立刻知道后续 |
+| **medium** | 普通剧情章 | 读者想知道,但可以等 |
+| **weak** | 过渡章/铺垫章 | 维持阅读惯性即可 |
+
+### 1.4 章内 vs 章末
+
+| 位置 | 常用钩子 | 作用 |
+|------|---------|------|
+| **章内** | 悬念钩、情绪钩 | 保持章内沉浸 |
+| **章末** | 危机钩、选择钩、渴望钩 | 驱动点下一章 |
+
+---
+
+## 二、爽点模式 (Cool-Point Patterns)
+
+### 2.1 现有6种模式(保留)
+
+| 模式 | 标识 | 典型触发 |
+|------|------|---------|
+| 装逼打脸 | Flex & Counter | 嘲讽→反转→震惊 |
+| 扮猪吃虎 | Underdog Reveal | 示弱→暴露→碾压 |
+| 越级反杀 | Underdog Victory | 差距→策略→逆转 |
+| 打脸权威 | Authority Challenge | 权威→挑战→成功 |
+| 反派翻车 | Villain Downfall | 得意→反杀→落幕 |
+| 甜蜜超预期 | Sweet Surprise | 期待→超预期→升华 |
+
+### 2.2 扩展模式
+
+#### 2.2.1 迪化误解 (Misunderstanding Elevation)
+
+**定义**:主角做了一件普通/随意的事,配角通过脑补认为主角深不可测、高人隐世。
+
+**核心结构**:
+1. 主角随意行为(无心插柳)
+2. 配角信息差(不知道主角真实情况)
+3. 配角脑补升华(合理化主角行为)
+4. 读者优越感(我知道真相)
+
+**题材适配**:
+| 题材 | 适用度 | 注意事项 |
+|------|--------|---------|
+| 爽文/系统流 | 高 | 避免过于刻意 |
+| 日常/轻松 | 高 | 可作为喜剧元素 |
+| 严肃/悬疑 | 低 | 容易破坏氛围 |
+
+**替代建议**:
+- 如果迪化用多了 → 换"真实实力展示"
+- 如果配角太蠢 → 增加合理脑补的信息支撑
+
+**示例**:
+- "萧炎只是随口说了句'还行',长老却浑身一震——这等天才,竟如此谦逊!"
+- "他只是因为没钱才住破庙,众人却以为他在闭关悟道。"
+
+---
+
+#### 2.2.2 身份掉马 (Identity Reveal)
+
+**定义**:隐藏身份在关键时刻被揭露,造成巨大反差和震撼。
+
+**核心结构**:
+1. 身份伪装(长期铺垫)
+2. 关键时刻(危机/高光)
+3. 身份揭露(意外或主动)
+4. 周围反应(震惊/后悔/敬畏)
+
+**题材适配**:
+| 题材 | 适用度 | 常见身份反差 |
+|------|--------|-------------|
+| 豪门/言情 | 高 | 穷亲戚→真千金 |
+| 爽文/玄幻 | 高 | 废物→隐藏大佬 |
+| 悬疑 | 中 | 路人→关键人物 |
+
+**替代建议**:
+- 如果掉马用多了 → 换"能力展示"或"背景揭示"
+- 如果没有铺垫 → 先补充身份暗示
+
+**示例**:
+- "当那枚玉佩从她怀中滚落,所有人的脸色都变了——那是皇室的信物。"
+- "你们口中的废物萧炎,便是我斗帝强者萧炎的转世。"
+
+---
+
+### 2.3 结构提示(30/40/30 软化版)
+
+> **注意**:以下结构仅供参考,不作为硬性要求。
+
+| 阶段 | 占比建议 | 作用 |
+|------|---------|------|
+| 铺垫 (Setup) | ~30% | 建立信息差、压力、期待 |
+| 兑现 (Delivery) | ~40% | 核心爽点执行 |
+| 余波 (Aftermath) | ~30% | 反应、收获、新期待 |
+
+**软提示问句**:
+- 爽点之前是否有足够的铺垫/压力?
+- 兑现时刻是否有足够的展开?
+- 爽点之后是否有反应和余味?
+
+---
+
+## 三、即时满足/微兑现 (Micro-Payoff)
+
+### 3.1 定义
+
+**微兑现**:章节内给读者的"小收获",让读者感觉"这章没白看"。
+
+> 与大爽点不同,微兑现更轻量、更频繁,用于维持章内沉浸。
+
+### 3.2 微兑现类型
+
+| 类型 | 描述 | 示例 |
+|------|------|------|
+| **信息兑现** | 揭示新信息/线索 | "原来那把钥匙的真正用途是..." |
+| **关系兑现** | 关系推进/确认 | "她第一次主动握住了他的手" |
+| **能力兑现** | 能力提升/新技能 | "他终于掌握了这门功法的精髓" |
+| **资源兑现** | 获得物品/资源 | "储物袋里竟然还藏着一颗聚气丹" |
+| **认可兑现** | 获得认可/面子 | "在场所有人看他的眼神都变了" |
+| **情绪兑现** | 情绪释放/共鸣 | "他终于说出了压在心底的那句话" |
+| **线索兑现** | 伏笔回收/推进 | "三年前的那件事,终于有了眉目" |
+
+### 3.3 题材偏好
+
+| 题材 | 偏好微兑现 | 每章建议数量 |
+|------|-----------|-------------|
+| 爽文 | 能力/资源/认可 | 2-3 |
+| 言情 | 关系/情绪/认可 | 1-2 |
+| 悬疑 | 信息/线索 | 1-2 |
+| 日常 | 关系/情绪 | 1 |
+
+### 3.4 过渡章微兑现
+
+过渡章可降低要求,但仍需至少1个微兑现:
+
+| 过渡章允许 | 过渡章不建议 |
+|-----------|-------------|
+| 信息兑现(新线索) | 大爽点 |
+| 关系兑现(小互动) | 强冲突 |
+| 情绪兑现(轻情绪) | 高密度节奏 |
+
+**软提示问句**:
+- 读者看完这章会获得什么?
+- 是否有"这章有收获"的感觉?
+
+---
+
+## 四、约束分层标准
+
+### 4.1 Hard Invariants(硬约束)
+
+> **违反 = MUST_FIX,不可申诉跳过**
+
+| ID | 约束名称 | 定义 | 触发条件 |
+|----|---------|------|---------|
+| HARD-001 | 可读性底线 | 关键信息缺失导致看不懂 | 读者无法理解"发生了什么/谁在做什么/为什么" |
+| HARD-002 | 承诺违背 | 上章钩子完全不兑现 | 明确的章末承诺在下章无任何回应 |
+| HARD-003 | 节奏灾难 | 连续N章无任何推进 | 无新信息/无关系变化/无能力变化/无局势变化(N由题材profile决定)|
+| HARD-004 | 冲突真空 | 整章无问题/目标/代价 | 读者无法回答"这章要解决什么" |
+
+### 4.2 Soft Guidance(软建议)
+
+> **违反 = 可申诉,但需记录Override Contract并承担债务**
+
+包括但不限于:
+- 章末钩子强度不足
+- 微兑现缺失
+- 情绪曲线平淡
+- 模式重复疲劳
+- 段落过长影响可读性
+
+### 4.3 rationale_type 枚举
+
+当违背 Soft Guidance 时,必须选择以下理由之一:
+
+| 类型 | 描述 | 债务影响 |
+|------|------|---------|
+| `TRANSITIONAL_SETUP` | 铺垫/过渡需要 | 标准 |
+| `LOGIC_INTEGRITY` | 剧情逻辑/悬疑公平性优先 | 减少 |
+| `CHARACTER_CREDIBILITY` | 人物可信度/成长节奏优先 | 减少 |
+| `WORLD_RULE_CONSTRAINT` | 设定/规则约束导致无法兑现 | 减少 |
+| `ARC_TIMING` | 长线大回收的节奏安排 | 标准(需明确窗口)|
+| `GENRE_CONVENTION` | 题材惯例 | 标准(需引用profile)|
+| `EDITORIAL_INTENT` | 作者主观意图 | 增加(配额更严)|
+
+---
+
+## 五、兼容性说明
+
+### 5.1 与现有 checker 的对接
+
+| 现有 Checker | 使用的 Taxonomy |
+|--------------|----------------|
+| `reader-pull-checker` | 钩子类型、钩子强度、Hard-002 |
+| `high-point-checker` | 爽点模式、微兑现 |
+| `pacing-checker` | Hard-003 (节奏灾难) |
+| `continuity-checker` | Hard-001 (可读性底线) |
+
+### 5.2 输出字段映射
+
+| 新字段 | 对应现有字段 | 说明 |
+|--------|-------------|------|
+| `hook_type` | 兼容扩展 | 新增情绪钩/选择钩/渴望钩 |
+| `hook_strength` | 保持不变 | strong/medium/weak |
+| `coolpoint_pattern` | 兼容扩展 | 新增迪化误解/身份掉马 |
+| `micro_payoffs` | 新增 | 微兑现列表 |
+| `hard_violations` | 新增 | 硬约束违规列表 |
+| `soft_suggestions` | 新增 | 软建议列表 |
+
+---
+
+## 六、版本历史
+
+| 版本 | 日期 | 变更 |
+|------|------|------|
+| v5.3 | 2026-02-01 | 初版,新增情绪钩/选择钩/渴望钩、迪化误解/身份掉马、微兑现体系、Hard/Soft分层 |

+ 485 - 0
.claude/scripts/data_modules/tests/test_api_client.py

@@ -0,0 +1,485 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+API Client tests
+"""
+
+import asyncio
+import json
+import pytest
+
+from data_modules.config import DataModulesConfig
+from data_modules.api_client import (
+    EmbeddingAPIClient,
+    RerankAPIClient,
+    ModalAPIClient,
+    get_client,
+)
+
+
+class FakeResponse:
+    def __init__(self, status, json_data=None, text_data=""):
+        self.status = status
+        self._json = json_data
+        if text_data:
+            self._text = text_data
+        elif json_data is not None:
+            self._text = json.dumps(json_data, ensure_ascii=False)
+        else:
+            self._text = ""
+
+    async def __aenter__(self):
+        return self
+
+    async def __aexit__(self, exc_type, exc, tb):
+        return False
+
+    async def json(self):
+        return self._json
+
+    async def text(self):
+        return self._text
+
+
+class FakeSession:
+    def __init__(self, responses):
+        self._responses = list(responses)
+        self.closed = False
+
+    def post(self, *args, **kwargs):
+        if not self._responses:
+            raise AssertionError("No more responses")
+        resp = self._responses.pop(0)
+        if isinstance(resp, Exception):
+            raise resp
+        return resp
+
+    async def close(self):
+        self.closed = True
+
+
+@pytest.mark.asyncio
+async def test_embedding_client_success_and_retry(tmp_path, monkeypatch):
+    config = DataModulesConfig.from_project_root(tmp_path)
+    config.embed_api_type = "openai"
+    config.api_max_retries = 2
+    client = EmbeddingAPIClient(config)
+
+    responses = [
+        FakeResponse(500, text_data="err"),
+        FakeResponse(
+            200,
+            json_data={
+                "data": [
+                    {"embedding": [0.1, 0.2], "index": 1},
+                    {"embedding": [0.3, 0.4], "index": 0},
+                ]
+            },
+        ),
+    ]
+    fake_session = FakeSession(responses)
+
+    async def fake_get_session():
+        return fake_session
+
+    monkeypatch.setattr(client, "_get_session", fake_get_session)
+    result = await client.embed(["a", "b"])
+    assert result == [[0.3, 0.4], [0.1, 0.2]]
+    assert client.stats.total_calls == 1
+    assert client.stats.errors == 0
+
+
+@pytest.mark.asyncio
+async def test_embedding_client_timeout_and_error(tmp_path, monkeypatch):
+    config = DataModulesConfig.from_project_root(tmp_path)
+    config.embed_api_type = "openai"
+    config.api_max_retries = 1
+    client = EmbeddingAPIClient(config)
+
+    responses = [asyncio.TimeoutError()]
+    fake_session = FakeSession(responses)
+
+    async def fake_get_session():
+        return fake_session
+
+    monkeypatch.setattr(client, "_get_session", fake_get_session)
+    result = await client.embed(["x"])
+    assert result is None
+    assert client.stats.errors == 1
+
+
+@pytest.mark.asyncio
+async def test_embedding_batch(tmp_path, monkeypatch):
+    config = DataModulesConfig.from_project_root(tmp_path)
+    config.embed_batch_size = 2
+    client = EmbeddingAPIClient(config)
+
+    async def fake_embed(texts):
+        if len(texts) == 2:
+            return [[1.0, 0.0], [0.0, 1.0]]
+        return None
+
+    monkeypatch.setattr(client, "embed", fake_embed)
+    result = await client.embed_batch(["a", "b", "c"], skip_failures=True)
+    assert result[0] is not None
+    assert result[2] is None
+
+    result_fail = await client.embed_batch(["a", "b", "c"], skip_failures=False)
+    assert result_fail == []
+
+
+def test_embedding_build_url_and_payload(tmp_path):
+    config = DataModulesConfig.from_project_root(tmp_path)
+    config.embed_api_type = "openai"
+    config.embed_base_url = "https://api.example.com"
+    client = EmbeddingAPIClient(config)
+    assert client._build_url().endswith("/v1/embeddings")
+    payload = client._build_payload(["hi"])
+    assert payload["model"] == config.embed_model
+
+    config.embed_base_url = "https://api.example.com/v1"
+    assert client._build_url().endswith("/v1/embeddings")
+
+    config.embed_base_url = "https://api.example.com/v1/embeddings"
+    assert client._build_url().endswith("/v1/embeddings")
+
+    config.embed_api_type = "modal"
+    config.embed_base_url = "https://modal.example.com/embed"
+    assert client._build_url() == "https://modal.example.com/embed"
+    payload = client._build_payload(["hi"])
+    assert "encoding_format" not in payload
+
+
+@pytest.mark.asyncio
+async def test_rerank_client_success(tmp_path, monkeypatch):
+    config = DataModulesConfig.from_project_root(tmp_path)
+    config.rerank_api_type = "openai"
+    config.api_max_retries = 1
+    client = RerankAPIClient(config)
+
+    responses = [
+        FakeResponse(
+            200,
+            json_data={"results": [{"index": 0, "relevance_score": 0.9}]},
+        )
+    ]
+    fake_session = FakeSession(responses)
+
+    async def fake_get_session():
+        return fake_session
+
+    monkeypatch.setattr(client, "_get_session", fake_get_session)
+    result = await client.rerank("q", ["doc1"], top_n=1)
+    assert result[0]["index"] == 0
+    assert client.stats.total_calls == 1
+
+
+@pytest.mark.asyncio
+async def test_rerank_retry_and_empty(tmp_path, monkeypatch):
+    config = DataModulesConfig.from_project_root(tmp_path)
+    config.rerank_api_type = "openai"
+    config.api_max_retries = 2
+    client = RerankAPIClient(config)
+
+    responses = [
+        FakeResponse(503, text_data="err"),
+        FakeResponse(
+            200,
+            json_data={"results": [{"index": 0, "relevance_score": 0.8}]},
+        ),
+    ]
+    fake_session = FakeSession(responses)
+
+    async def fake_get_session():
+        return fake_session
+
+    monkeypatch.setattr(client, "_get_session", fake_get_session)
+    result = await client.rerank("q", ["doc1"], top_n=1)
+    assert result[0]["relevance_score"] == 0.8
+
+    assert await client.rerank("q", []) == []
+
+
+@pytest.mark.asyncio
+async def test_modal_client_warmup_and_passthrough(tmp_path, monkeypatch):
+    config = DataModulesConfig.from_project_root(tmp_path)
+    client = ModalAPIClient(config)
+
+    async def fake_warmup():
+        return None
+
+    async def fake_embed(texts):
+        return [[0.1, 0.2] for _ in texts]
+
+    async def fake_rerank(query, documents, top_n=None):
+        return [{"index": 0, "relevance_score": 1.0}]
+
+    monkeypatch.setattr(client._embed_client, "warmup", fake_warmup)
+    monkeypatch.setattr(client._rerank_client, "warmup", fake_warmup)
+    monkeypatch.setattr(client._embed_client, "embed", fake_embed)
+    monkeypatch.setattr(client._rerank_client, "rerank", fake_rerank)
+
+    await client.warmup()
+    assert client._warmed_up["embed"] is True
+    assert client._warmed_up["rerank"] is True
+
+    emb = await client.embed(["hi"])
+    assert emb[0] == [0.1, 0.2]
+    rr = await client.rerank("q", ["doc"])
+    assert rr[0]["index"] == 0
+
+
+def test_get_client_singleton(tmp_path):
+    cfg = DataModulesConfig.from_project_root(tmp_path)
+    client1 = get_client(cfg)
+    client2 = get_client()
+    assert client1 is client2
+    client3 = get_client(cfg)
+    assert client3 is not client1
+
+
+@pytest.mark.asyncio
+async def test_embedding_empty_and_error_paths(tmp_path, monkeypatch):
+    config = DataModulesConfig.from_project_root(tmp_path)
+    config.embed_api_key = "sk-test"
+    config.api_max_retries = 1
+    client = EmbeddingAPIClient(config)
+
+    assert await client.embed([]) == []
+
+    headers = client._build_headers()
+    assert headers["Authorization"] == "Bearer sk-test"
+
+    fake_session = FakeSession([FakeResponse(400, text_data="bad request")])
+
+    async def fake_get_session():
+        return fake_session
+
+    monkeypatch.setattr(client, "_get_session", fake_get_session)
+    result = await client.embed(["x"])
+    assert result is None
+    assert client.stats.errors == 1
+
+
+@pytest.mark.asyncio
+async def test_embedding_exception_and_close(tmp_path, monkeypatch):
+    config = DataModulesConfig.from_project_root(tmp_path)
+    config.api_max_retries = 1
+    client = EmbeddingAPIClient(config)
+
+    class BoomSession:
+        def __init__(self):
+            self.closed = False
+
+        def post(self, *args, **kwargs):
+            raise RuntimeError("boom")
+
+        async def close(self):
+            self.closed = True
+
+    session = BoomSession()
+
+    async def fake_get_session():
+        return session
+
+    monkeypatch.setattr(client, "_get_session", fake_get_session)
+    result = await client.embed(["x"])
+    assert result is None
+    assert client.stats.errors == 1
+
+    client._session = session
+    await client.close()
+    assert session.closed is True
+
+
+def test_rerank_headers_payload_and_stats(tmp_path, capsys):
+    config = DataModulesConfig.from_project_root(tmp_path)
+    config.rerank_api_key = "rk-test"
+    client = RerankAPIClient(config)
+
+    headers = client._build_headers()
+    assert headers["Authorization"] == "Bearer rk-test"
+
+    payload = client._build_payload("q", ["doc"], top_n=2)
+    assert payload["top_n"] == 2
+
+    modal = ModalAPIClient(config)
+    modal._embed_client.stats.total_calls = 1
+    modal._embed_client.stats.total_time = 2.0
+    modal.print_stats()
+    output = capsys.readouterr().out
+    assert "EMBED" in output
+
+
+@pytest.mark.asyncio
+async def test_rerank_non_retry_error(tmp_path, monkeypatch):
+    config = DataModulesConfig.from_project_root(tmp_path)
+    config.api_max_retries = 1
+    client = RerankAPIClient(config)
+
+    fake_session = FakeSession([FakeResponse(400, text_data="bad request")])
+
+    async def fake_get_session():
+        return fake_session
+
+    monkeypatch.setattr(client, "_get_session", fake_get_session)
+    result = await client.rerank("q", ["doc"])
+    assert result is None
+    assert client.stats.errors == 1
+
+
+@pytest.mark.asyncio
+async def test_embedding_session_parse_and_retry_paths(tmp_path, monkeypatch):
+    config = DataModulesConfig.from_project_root(tmp_path)
+    config.embed_api_type = "modal"
+    config.api_max_retries = 2
+    config.api_retry_delay = 0
+    client = EmbeddingAPIClient(config)
+
+    session = await client._get_session()
+    assert session is not None
+    await client.close()
+
+    assert client._parse_response({}) is None
+    parsed = client._parse_response({"data": [{"embedding": [1.0, 2.0]}]})
+    assert parsed == [[1.0, 2.0]]
+
+    responses = [
+        asyncio.TimeoutError(),
+        FakeResponse(200, text_data=json.dumps({"data": [{"embedding": [0.1], "index": 0}]})),
+    ]
+    fake_session = FakeSession(responses)
+
+    async def fake_get_session():
+        return fake_session
+
+    monkeypatch.setattr(client, "_get_session", fake_get_session)
+    result = await client.embed(["x"])
+    assert result == [[0.1]]
+
+
+@pytest.mark.asyncio
+async def test_embedding_exception_retry_and_batch(tmp_path, monkeypatch):
+    config = DataModulesConfig.from_project_root(tmp_path)
+    config.api_max_retries = 2
+    config.api_retry_delay = 0
+    client = EmbeddingAPIClient(config)
+
+    responses = [
+        RuntimeError("boom"),
+        FakeResponse(200, text_data=json.dumps({"data": [{"embedding": [0.2], "index": 0}]})),
+    ]
+    fake_session = FakeSession(responses)
+
+    async def fake_get_session():
+        return fake_session
+
+    monkeypatch.setattr(client, "_get_session", fake_get_session)
+    result = await client.embed(["x"])
+    assert result == [[0.2]]
+
+    assert await client.embed_batch([]) == []
+
+    async def fake_embed(texts):
+        return [[0.0] for _ in texts]
+
+    monkeypatch.setattr(client, "embed", fake_embed)
+    await client.warmup()
+    assert client._warmed_up is True
+
+
+@pytest.mark.asyncio
+async def test_rerank_modal_retry_and_warmup(tmp_path, monkeypatch):
+    config = DataModulesConfig.from_project_root(tmp_path)
+    config.rerank_api_type = "modal"
+    config.rerank_base_url = "https://modal.example.com/rerank"
+    config.api_max_retries = 2
+    config.api_retry_delay = 0
+    client = RerankAPIClient(config)
+
+    session = await client._get_session()
+    assert session is not None
+    await client.close()
+
+    payload = client._build_payload("q", ["doc"], top_n=1)
+    assert payload["top_n"] == 1
+    assert client._build_url() == "https://modal.example.com/rerank"
+    assert client._parse_response({"results": [{"index": 0}]}) == [{"index": 0}]
+
+    responses = [
+        asyncio.TimeoutError(),
+        FakeResponse(200, json_data={"results": [{"index": 0, "relevance_score": 1.0}]}),
+    ]
+    fake_session = FakeSession(responses)
+
+    async def fake_get_session():
+        return fake_session
+
+    monkeypatch.setattr(client, "_get_session", fake_get_session)
+    result = await client.rerank("q", ["doc"])
+    assert result[0]["index"] == 0
+
+    responses = [
+        RuntimeError("boom"),
+        FakeResponse(200, json_data={"results": [{"index": 0, "relevance_score": 0.5}]}),
+    ]
+    fake_session = FakeSession(responses)
+
+    async def fake_get_session2():
+        return fake_session
+
+    monkeypatch.setattr(client, "_get_session", fake_get_session2)
+    result = await client.rerank("q", ["doc"])
+    assert result[0]["relevance_score"] == 0.5
+
+    async def fake_rerank(query, docs, top_n=None):
+        return [{"index": 0, "relevance_score": 1.0}]
+
+    monkeypatch.setattr(client, "rerank", fake_rerank)
+    await client.warmup()
+    assert client._warmed_up is True
+
+
+@pytest.mark.asyncio
+async def test_modal_client_helpers(tmp_path, monkeypatch, capsys):
+    config = DataModulesConfig.from_project_root(tmp_path)
+    client = ModalAPIClient(config)
+
+    async def fake_embed_batch(texts, skip_failures=True):
+        return [[0.1] for _ in texts]
+
+    monkeypatch.setattr(client._embed_client, "embed_batch", fake_embed_batch)
+    result = await client.embed_batch(["a", "b"])
+    assert result[0] == [0.1]
+
+    async def fail_warmup():
+        raise RuntimeError("fail")
+
+    async def ok_warmup():
+        return None
+
+    monkeypatch.setattr(client, "_warmup_embed", fail_warmup)
+    monkeypatch.setattr(client, "_warmup_rerank", ok_warmup)
+    await client.warmup()
+    output = capsys.readouterr().out
+    assert "[FAIL]" in output
+
+    async def fake_get_session():
+        return FakeSession([])
+
+    monkeypatch.setattr(client._embed_client, "_get_session", fake_get_session)
+    session = await client._get_session()
+    assert session is not None
+
+    closed = {"embed": False, "rerank": False}
+
+    async def close_embed():
+        closed["embed"] = True
+
+    async def close_rerank():
+        closed["rerank"] = True
+
+    monkeypatch.setattr(client._embed_client, "close", close_embed)
+    monkeypatch.setattr(client._rerank_client, "close", close_rerank)
+    await client.close()
+    assert closed["embed"] and closed["rerank"]

+ 42 - 0
.claude/scripts/data_modules/tests/test_config.py

@@ -0,0 +1,42 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+Config tests
+"""
+
+import os
+
+from data_modules import config as config_module
+from data_modules.config import DataModulesConfig, get_config, set_project_root
+
+
+def test_config_paths_and_defaults(tmp_path):
+    cfg = DataModulesConfig.from_project_root(tmp_path)
+    assert cfg.project_root == tmp_path
+    assert cfg.webnovel_dir.name == ".webnovel"
+    assert cfg.state_file.name == "state.json"
+    assert cfg.index_db.name == "index.db"
+    assert cfg.rag_db.name == "rag.db"
+    assert cfg.vector_db.name == "vectors.db"
+
+    cfg.ensure_dirs()
+    assert cfg.webnovel_dir.exists()
+
+
+def test_get_config_and_set_project_root(tmp_path):
+    set_project_root(tmp_path)
+    cfg = get_config()
+    assert cfg.project_root == tmp_path
+
+
+def test_load_dotenv(monkeypatch, tmp_path):
+    # prepare .env
+    env_path = tmp_path / ".env"
+    env_path.write_text("EMBED_BASE_URL=https://example.com\n", encoding="utf-8")
+
+    monkeypatch.chdir(tmp_path)
+    monkeypatch.delenv("EMBED_BASE_URL", raising=False)
+
+    # call loader explicitly
+    config_module._load_dotenv()
+    assert os.environ.get("EMBED_BASE_URL") == "https://example.com"

+ 97 - 0
.claude/scripts/data_modules/tests/test_entity_linker_cli.py

@@ -0,0 +1,97 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+EntityLinker extra tests + CLI
+"""
+
+import sys
+
+import pytest
+
+from data_modules.entity_linker import EntityLinker, main as linker_main
+from data_modules.index_manager import IndexManager, EntityMeta
+
+
+@pytest.fixture
+def temp_project(tmp_path):
+    from data_modules.config import DataModulesConfig
+    cfg = DataModulesConfig.from_project_root(tmp_path)
+    cfg.ensure_dirs()
+    return cfg
+
+
+def test_process_extraction_and_register_new_entities(temp_project):
+    linker = EntityLinker(temp_project)
+    idx = IndexManager(temp_project)
+    idx.upsert_entity(
+        EntityMeta(
+            id="xiaoyan",
+            type="角色",
+            canonical_name="萧炎",
+            current={},
+            first_appearance=1,
+            last_appearance=1,
+        )
+    )
+
+    results, warnings = linker.process_extraction_result(
+        [
+            {
+                "mention": "萧炎",
+                "candidates": ["xiaoyan"],
+                "suggested": "xiaoyan",
+                "confidence": 0.7,
+            },
+            {
+                "mention": "宗主",
+                "candidates": ["zongzhu"],
+                "suggested": "zongzhu",
+                "confidence": 0.4,
+            },
+        ]
+    )
+
+    assert len(results) == 2
+    assert len(warnings) == 2
+
+    registered = linker.register_new_entities(
+        [
+            {
+                "suggested_id": "hongyi",
+                "name": "红衣女子",
+                "type": "角色",
+                "mentions": ["红衣", "女子"],
+            }
+        ]
+    )
+    assert registered == ["hongyi"]
+    aliases = idx.get_entity_aliases("hongyi")
+    assert "红衣女子" in aliases
+
+
+def test_entity_linker_cli(temp_project, monkeypatch, capsys):
+    idx = IndexManager(temp_project)
+    idx.upsert_entity(
+        EntityMeta(
+            id="xiaoyan",
+            type="角色",
+            canonical_name="萧炎",
+            current={},
+            first_appearance=1,
+            last_appearance=1,
+        )
+    )
+
+    def run_cli(args):
+        monkeypatch.setattr(sys, "argv", ["entity_linker"] + args)
+        linker_main()
+
+    root = str(temp_project.project_root)
+
+    run_cli(["--project-root", root, "register-alias", "--entity", "xiaoyan", "--alias", "炎帝"])
+    run_cli(["--project-root", root, "lookup", "--mention", "炎帝"])
+    run_cli(["--project-root", root, "lookup", "--mention", "不存在"])
+    run_cli(["--project-root", root, "lookup-all", "--mention", "炎帝"])
+    run_cli(["--project-root", root, "list-aliases", "--entity", "xiaoyan"])
+
+    capsys.readouterr()

+ 209 - 0
.claude/scripts/data_modules/tests/test_migrate_state_to_sqlite.py

@@ -0,0 +1,209 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+migrate_state_to_sqlite tests
+"""
+
+import json
+
+import pytest
+
+import data_modules.migrate_state_to_sqlite as migrate_module
+from data_modules.migrate_state_to_sqlite import (
+    migrate_state_to_sqlite,
+    _slim_world_settings,
+    _slim_relationships,
+)
+from data_modules.config import DataModulesConfig
+from data_modules.index_manager import IndexManager
+
+
+@pytest.fixture
+def temp_project(tmp_path):
+    cfg = DataModulesConfig.from_project_root(tmp_path)
+    cfg.ensure_dirs()
+    return cfg
+
+
+def test_migrate_state_missing_file(tmp_path):
+    cfg = DataModulesConfig.from_project_root(tmp_path)
+    stats = migrate_state_to_sqlite(cfg, dry_run=True, backup=False, verbose=False)
+    assert stats["entities"] == 0
+
+
+def test_migrate_state_to_sqlite_flow(temp_project):
+    state = {
+        "entities_v3": {
+            "角色": {
+                "xiaoyan": {
+                    "canonical_name": "萧炎",
+                    "tier": "核心",
+                    "desc": "主角",
+                    "current": {"realm": "斗者"},
+                    "first_appearance": 1,
+                    "last_appearance": 2,
+                    "is_protagonist": True,
+                }
+            }
+        },
+        "alias_index": {
+            "萧炎": [{"type": "角色", "id": "xiaoyan"}]
+        },
+        "state_changes": [
+            {"entity_id": "xiaoyan", "field": "realm", "old": "斗者", "new": "斗师", "reason": "突破", "chapter": 2}
+        ],
+        "structured_relationships": [
+            {"from_entity": "xiaoyan", "to_entity": "yaolao", "type": "师徒", "description": "收徒", "chapter": 1}
+        ],
+        "world_settings": {
+            "power_system": [{"name": "斗者"}, {"name": "斗师"}],
+            "factions": [{"name": "天云宗", "type": "宗门"}],
+            "locations": [{"name": "天云宗"}],
+        },
+        "plot_threads": {"active_threads": [], "foreshadowing": []},
+        "relationships": {},
+        "review_checkpoints": [],
+        "project_info": {"title": "测试书名"},
+    }
+    temp_project.state_file.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
+
+    stats = migrate_state_to_sqlite(temp_project, dry_run=True, backup=False, verbose=False)
+    assert stats["entities"] == 1
+    assert stats["aliases"] == 1
+
+    stats = migrate_state_to_sqlite(temp_project, dry_run=False, backup=False, verbose=False)
+    assert stats["entities"] == 1
+
+    # state.json 被精简
+    saved = json.loads(temp_project.state_file.read_text(encoding="utf-8"))
+    assert saved.get("_migrated_to_sqlite") is True
+    assert "entities_v3" not in saved
+
+    # SQLite 中可查询实体
+    idx = IndexManager(temp_project)
+    entity = idx.get_entity("xiaoyan")
+    assert entity is not None
+
+
+def test_slim_helpers():
+    world = {
+        "power_system": [{"name": "斗者"}],
+        "factions": [{"name": "天云宗", "type": "宗门"}],
+        "locations": [{"name": "天云宗"}],
+    }
+    slim = _slim_world_settings(world)
+    assert slim["power_system"][0] == "斗者"
+
+    rels = _slim_relationships({"a": 1})
+    assert rels["a"] == 1
+
+
+def test_slim_helpers_non_dict():
+    assert _slim_world_settings("bad") == {}
+    assert _slim_relationships("bad") == {}
+
+
+def test_migrate_state_verbose_and_dry_run(temp_project, capsys):
+    state = {
+        "entities_v3": {},
+        "alias_index": {},
+        "state_changes": [],
+        "structured_relationships": [],
+        "world_settings": {},
+        "plot_threads": {},
+        "relationships": {},
+        "review_checkpoints": [],
+        "project_info": {},
+    }
+    temp_project.state_file.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
+
+    stats = migrate_state_to_sqlite(temp_project, dry_run=True, backup=False, verbose=True)
+    output = capsys.readouterr().out
+    assert stats["errors"] == 0
+    assert "dry-run" in output or "dry run" in output
+
+
+def test_migrate_state_cli_main(tmp_path, monkeypatch, capsys):
+    project_root = tmp_path
+    args = [
+        "migrate_state_to_sqlite",
+        "--project-root",
+        str(project_root),
+        "--dry-run",
+        "--no-backup",
+    ]
+    monkeypatch.setattr("sys.argv", args)
+    migrate_module.main()
+    output = capsys.readouterr().out
+    assert "state.json" in output
+
+def test_migrate_state_backup_and_skips(temp_project):
+    state = {
+        "entities_v3": {
+            "角色": {
+                "good": {"canonical_name": "好人"},
+                "bad": "not-dict",
+            }
+        },
+        "alias_index": {
+            "好人": [{"type": "角色", "id": "good"}],
+            "坏条目": ["oops", {"type": "角色"}],
+        },
+        "state_changes": ["bad", {"field": "realm"}],
+        "structured_relationships": ["bad", {"from_entity": "", "to_entity": ""}],
+        "relationships": {},
+        "world_settings": {},
+        "plot_threads": {},
+        "review_checkpoints": [],
+        "project_info": {},
+    }
+    temp_project.state_file.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
+
+    stats = migrate_state_to_sqlite(temp_project, dry_run=False, backup=True, verbose=False)
+    assert stats["entities"] == 1
+    assert stats["skipped"] >= 3
+
+    backups = list(temp_project.state_file.parent.glob("state.json.backup-*"))
+    assert backups
+
+
+def test_migrate_state_error_branches(tmp_path, monkeypatch):
+    cfg = DataModulesConfig.from_project_root(tmp_path)
+    cfg.ensure_dirs()
+    state = {
+        "entities_v3": {"角色": {"boom": {"canonical_name": "爆"}}},
+        "alias_index": {"爆": [{"type": "角色", "id": "boom"}]},
+        "state_changes": [
+            {"entity_id": "boom", "field": "realm", "old": "", "new": "斗者", "reason": "测试", "chapter": 1}
+        ],
+        "structured_relationships": [
+            {"from_entity": "boom", "to_entity": "yao", "type": "相识", "description": "测试", "chapter": 1}
+        ],
+        "relationships": {},
+        "world_settings": {},
+        "plot_threads": {},
+        "review_checkpoints": [],
+        "project_info": {},
+    }
+    cfg.state_file.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
+
+    class BoomSQL:
+        def __init__(self, *args, **kwargs):
+            pass
+
+        def upsert_entity(self, *args, **kwargs):
+            raise RuntimeError("boom")
+
+        def register_alias(self, *args, **kwargs):
+            raise RuntimeError("boom")
+
+        def record_state_change(self, *args, **kwargs):
+            raise RuntimeError("boom")
+
+        def upsert_relationship(self, *args, **kwargs):
+            raise RuntimeError("boom")
+
+    monkeypatch.setattr(migrate_module, "SQLStateManager", BoomSQL)
+
+    stats = migrate_state_to_sqlite(cfg, dry_run=False, backup=False, verbose=False)
+    assert stats["errors"] >= 4

+ 160 - 0
.claude/scripts/data_modules/tests/test_rag_adapter.py

@@ -0,0 +1,160 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+RAGAdapter tests
+"""
+
+import sys
+import json
+import asyncio
+
+import pytest
+
+import data_modules.rag_adapter as rag_module
+from data_modules.rag_adapter import RAGAdapter
+from data_modules.config import DataModulesConfig
+
+
+class StubClient:
+    async def embed(self, texts):
+        return [[1.0, 0.0] for _ in texts]
+
+    async def embed_batch(self, texts, skip_failures=True):
+        return [[1.0, 0.0] for _ in texts]
+
+    async def rerank(self, query, documents, top_n=None):
+        top_n = top_n or len(documents)
+        return [{"index": i, "relevance_score": 1.0 / (i + 1)} for i in range(min(top_n, len(documents)))]
+
+
+class StubClientWithFailures(StubClient):
+    async def embed_batch(self, texts, skip_failures=True):
+        if len(texts) == 1:
+            return [None]
+        return [None, [1.0, 0.0]]
+
+
+@pytest.fixture
+def temp_project(tmp_path, monkeypatch):
+    cfg = DataModulesConfig.from_project_root(tmp_path)
+    cfg.ensure_dirs()
+    monkeypatch.setattr(rag_module, "get_client", lambda config: StubClient())
+    return cfg
+
+
+@pytest.mark.asyncio
+async def test_store_and_search(temp_project):
+    adapter = RAGAdapter(temp_project)
+    chunks = [
+        {"chapter": 1, "scene_index": 1, "content": "萧炎在天云宗修炼斗气"},
+        {"chapter": 1, "scene_index": 2, "content": "药老传授炼药技巧"},
+    ]
+    stored = await adapter.store_chunks(chunks)
+    assert stored == 2
+
+    vec_results = await adapter.vector_search("萧炎", top_k=2)
+    assert len(vec_results) == 2
+
+    bm25_results = adapter.bm25_search("萧炎", top_k=2)
+    assert len(bm25_results) >= 1
+
+    stats = adapter.get_stats()
+    assert stats["vectors"] == 2
+
+
+@pytest.mark.asyncio
+async def test_store_chunks_with_embedding_failure(tmp_path, monkeypatch):
+    cfg = DataModulesConfig.from_project_root(tmp_path)
+    cfg.ensure_dirs()
+    monkeypatch.setattr(rag_module, "get_client", lambda config: StubClientWithFailures())
+
+    adapter = RAGAdapter(cfg)
+    chunks = [
+        {"chapter": 1, "scene_index": 1, "content": "短内容"},
+        {"chapter": 1, "scene_index": 2, "content": "稍长内容用于索引"},
+    ]
+    stored = await adapter.store_chunks(chunks)
+    assert stored == 1
+
+
+@pytest.mark.asyncio
+async def test_hybrid_search_full_scan(temp_project):
+    adapter = RAGAdapter(temp_project)
+    await adapter.store_chunks(
+        [{"chapter": 1, "scene_index": 1, "content": "萧炎修炼"}]
+    )
+    results = await adapter.hybrid_search("萧炎", vector_top_k=5, bm25_top_k=5, rerank_top_n=1)
+    assert results
+    assert results[0].source == "hybrid"
+
+
+@pytest.mark.asyncio
+async def test_hybrid_search_prefilter(tmp_path, monkeypatch):
+    cfg = DataModulesConfig.from_project_root(tmp_path)
+    cfg.ensure_dirs()
+    cfg.vector_full_scan_max_vectors = 0
+    monkeypatch.setattr(rag_module, "get_client", lambda config: StubClient())
+    adapter = RAGAdapter(cfg)
+    await adapter.store_chunks(
+        [
+            {"chapter": 1, "scene_index": 1, "content": "萧炎修炼"},
+            {"chapter": 2, "scene_index": 1, "content": "药老出场"},
+        ]
+    )
+    results = await adapter.hybrid_search("药老", vector_top_k=2, bm25_top_k=2, rerank_top_n=1)
+    assert results
+
+
+def test_vector_helpers(temp_project):
+    adapter = RAGAdapter(temp_project)
+    emb = [1.0, 0.0]
+    data = adapter._serialize_embedding(emb)
+    assert adapter._deserialize_embedding(data) == emb
+
+    assert adapter._cosine_similarity([0.0, 0.0], [1.0, 0.0]) == 0.0
+
+
+def test_recent_and_fetch_vectors(temp_project):
+    adapter = RAGAdapter(temp_project)
+    with adapter._get_conn() as conn:
+        cursor = conn.cursor()
+        cursor.execute(
+            "INSERT INTO vectors (chunk_id, chapter, scene_index, content, embedding) VALUES (?, ?, ?, ?, ?)",
+            ("ch1_s1", 1, 1, "内容", b""),
+        )
+        conn.commit()
+
+    assert adapter._get_vectors_count() == 1
+    assert adapter._get_recent_chunk_ids(1) == ["ch1_s1"]
+    rows = adapter._fetch_vectors_by_chunk_ids(["ch1_s1"])
+    assert len(rows) == 1
+
+
+def test_rag_adapter_cli(temp_project, monkeypatch, capsys):
+    # stats
+    def run_cli(args):
+        monkeypatch.setattr(sys, "argv", ["rag_adapter"] + args)
+        rag_module.main()
+
+    root = str(temp_project.project_root)
+    run_cli(["--project-root", root, "stats"])
+
+    # index-chapter
+    run_cli(
+        [
+            "--project-root",
+            root,
+            "index-chapter",
+            "--chapter",
+            "1",
+            "--scenes",
+            json.dumps([{"index": 1, "summary": "摘要", "content": "内容"}], ensure_ascii=False),
+        ]
+    )
+
+    # search
+    run_cli(["--project-root", root, "search", "--query", "内容", "--mode", "bm25", "--top-k", "5"])
+    run_cli(["--project-root", root, "search", "--query", "内容", "--mode", "vector", "--top-k", "5"])
+    run_cli(["--project-root", root, "search", "--query", "内容", "--mode", "hybrid", "--top-k", "5"])
+
+    capsys.readouterr()

+ 207 - 0
.claude/scripts/data_modules/tests/test_sql_state_manager.py

@@ -0,0 +1,207 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+SQLStateManager tests
+"""
+
+import json
+import sys
+
+import pytest
+
+import data_modules.sql_state_manager as sql_state_manager_module
+from data_modules.sql_state_manager import SQLStateManager, EntityData
+from data_modules.index_manager import EntityMeta
+
+
+@pytest.fixture
+def temp_project(tmp_path):
+    from data_modules.config import DataModulesConfig
+    cfg = DataModulesConfig.from_project_root(tmp_path)
+    cfg.ensure_dirs()
+    return cfg
+
+
+def test_sql_state_manager_entity_and_alias(temp_project):
+    manager = SQLStateManager(temp_project)
+    entity = EntityData(
+        id="xiaoyan",
+        type="角色",
+        name="萧炎",
+        tier="核心",
+        current={"realm": "斗师"},
+        aliases=["炎帝", "小炎子"],
+        is_protagonist=True,
+    )
+    assert manager.upsert_entity(entity) is True
+    assert manager.upsert_entity(entity) is False
+
+    fetched = manager.get_entity("xiaoyan")
+    assert "炎帝" in fetched["aliases"]
+
+    by_type = manager.get_entities_by_type("角色")
+    assert any(e["id"] == "xiaoyan" for e in by_type)
+
+    core = manager.get_core_entities()
+    assert any(e["id"] == "xiaoyan" for e in core)
+
+    protagonist = manager.get_protagonist()
+    assert protagonist["id"] == "xiaoyan"
+
+    resolved = manager.resolve_alias("炎帝")
+    assert any(r["id"] == "xiaoyan" for r in resolved)
+
+    assert manager.update_entity_current("xiaoyan", {"realm": "斗王"}) is True
+    updated = manager.get_entity("xiaoyan")
+    assert updated["current_json"]["realm"] == "斗王"
+
+
+def test_sql_state_manager_state_changes_and_relationships(temp_project):
+    manager = SQLStateManager(temp_project)
+    manager.upsert_entity(
+        EntityData(id="xiaoyan", type="角色", name="萧炎", current={})
+    )
+    change_id = manager.record_state_change(
+        entity_id="xiaoyan",
+        field="realm",
+        old_value="斗者",
+        new_value="斗师",
+        reason="突破",
+        chapter=2,
+    )
+    assert change_id > 0
+    assert len(manager.get_entity_state_changes("xiaoyan")) == 1
+    assert len(manager.get_recent_state_changes(limit=5)) == 1
+    assert len(manager.get_chapter_state_changes(2)) == 1
+
+    assert manager.upsert_relationship(
+        from_entity="xiaoyan",
+        to_entity="yaolao",
+        type="师徒",
+        description="收徒",
+        chapter=1,
+    )
+    rels = manager.get_entity_relationships("xiaoyan", direction="from")
+    assert len(rels) == 1
+    between = manager.get_relationship_between("xiaoyan", "yaolao")
+    assert len(between) == 1
+    assert len(manager.get_recent_relationships(limit=5)) >= 1
+
+
+def test_sql_state_manager_process_chapter_entities_and_exports(temp_project):
+    manager = SQLStateManager(temp_project)
+    stats = manager.process_chapter_entities(
+        chapter=10,
+        entities_appeared=[{"id": "xiaoyan", "mentions": ["萧炎"], "confidence": 0.9}],
+        entities_new=[
+            {"suggested_id": "yaolao", "name": "药老", "type": "角色", "tier": "重要"}
+        ],
+        state_changes=[
+            {"entity_id": "yaolao", "field": "status", "old": "", "new": "出场", "reason": "登场"}
+        ],
+        relationships_new=[
+            {"from": "xiaoyan", "to": "yaolao", "type": "师徒", "description": "收徒"}
+        ],
+    )
+    assert stats["entities_created"] >= 1
+    assert stats["relationships"] == 1
+
+    entities_v3 = manager.export_to_entities_v3_format()
+    assert "角色" in entities_v3
+
+    alias_index = manager.export_to_alias_index_format()
+    assert isinstance(alias_index, dict)
+
+
+def test_sql_state_manager_existing_entity_updates_and_stats(temp_project):
+    manager = SQLStateManager(temp_project)
+    manager.upsert_entity(
+        EntityData(id="xiaoyan", type="角色", name="萧炎", current={"hp": 5})
+    )
+
+    stats = manager.process_chapter_entities(
+        chapter=3,
+        entities_appeared=[{"id": "xiaoyan", "mentions": ["萧炎"], "confidence": 0.9}],
+        entities_new=[],
+        state_changes=[
+            {"entity_id": "xiaoyan", "field": "hp", "old": 5, "new": 0, "reason": "受伤"}
+        ],
+        relationships_new=[
+            {"from_entity": "xiaoyan", "to_entity": "yaolao", "type": "师徒", "description": "收徒"}
+        ],
+    )
+    assert stats["entities_updated"] >= 1
+    assert stats["state_changes"] == 1
+
+    updated = manager.get_entity("xiaoyan")
+    assert updated["current_json"]["hp"] == 0
+
+    rels = manager.get_entity_relationships("yaolao", direction="to")
+    assert rels
+
+    stats_summary = manager.get_stats()
+    assert "entities" in stats_summary
+
+    exported = manager.export_to_entities_v3_format()
+    assert exported["角色"]["xiaoyan"]["canonical_name"] == "萧炎"
+
+
+def test_sql_state_manager_process_chapter_skips_and_existing(temp_project):
+    manager = SQLStateManager(temp_project)
+    manager.upsert_entity(EntityData(id="xiaoyan", type="角色", name="萧炎"))
+
+    stats = manager.process_chapter_entities(
+        chapter=1,
+        entities_appeared=[{"mentions": ["无ID"]}, {"id": "xiaoyan", "mentions": ["萧炎"]}],
+        entities_new=[{"name": "无ID"}, {"suggested_id": "xiaoyan", "name": "萧炎"}],
+        state_changes=[{"field": "realm"}, {"entity_id": "xiaoyan", "field": "hp", "old": 1, "new": 1}],
+        relationships_new=[{"from": "xiaoyan", "to": ""}],
+    )
+    assert stats["entities_updated"] >= 1
+    assert stats["relationships"] == 0
+
+
+def test_sql_state_manager_export_protagonist_and_cli(temp_project, monkeypatch, capsys):
+    manager = SQLStateManager(temp_project)
+
+    def run_cli(args):
+        monkeypatch.setattr(sys, "argv", args)
+        sql_state_manager_module.main()
+        return capsys.readouterr().out
+
+    out = run_cli(["sql_state_manager", "--project-root", str(temp_project.project_root), "get-protagonist"])
+    assert "未设置主角" in out
+
+    manager.upsert_entity(
+        EntityData(id="xiaoyan", type="角色", name="萧炎", is_protagonist=True)
+    )
+    exported = manager.export_to_entities_v3_format()
+    assert exported["角色"]["xiaoyan"]["is_protagonist"] is True
+
+    out = run_cli(["sql_state_manager", "--project-root", str(temp_project.project_root), "get-protagonist"])
+    assert "萧炎" in out
+
+    out = run_cli(["sql_state_manager", "--project-root", str(temp_project.project_root), "stats"])
+    assert "entities" in out
+
+    out = run_cli(["sql_state_manager", "--project-root", str(temp_project.project_root), "get-core-entities"])
+    assert "萧炎" in out
+
+    out = run_cli(["sql_state_manager", "--project-root", str(temp_project.project_root), "export-entities-v3"])
+    assert "角色" in out
+
+    out = run_cli(["sql_state_manager", "--project-root", str(temp_project.project_root), "export-alias-index"])
+    assert isinstance(json.loads(out or "{}"), dict)
+
+    payload = json.dumps({"entities_appeared": [], "entities_new": [], "state_changes": [], "relationships_new": []})
+    out = run_cli([
+        "sql_state_manager",
+        "--project-root",
+        str(temp_project.project_root),
+        "process-chapter",
+        "--chapter",
+        "2",
+        "--data",
+        payload,
+    ])
+    assert "已处理第 2 章" in out

+ 538 - 0
.claude/scripts/data_modules/tests/test_state_manager_extra.py

@@ -0,0 +1,538 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+StateManager extra tests
+"""
+
+import json
+import sys
+
+import pytest
+
+from data_modules.state_manager import StateManager, EntityState
+from data_modules.index_manager import IndexManager, EntityMeta
+
+
+@pytest.fixture
+def temp_project(tmp_path):
+    from data_modules.config import DataModulesConfig
+    cfg = DataModulesConfig.from_project_root(tmp_path)
+    cfg.ensure_dirs()
+    return cfg
+
+
+def test_ensure_state_schema_and_progress(temp_project):
+    # relationships as list should be migrated to structured_relationships
+    state = {
+        "relationships": [
+            {"from_entity": "a", "to_entity": "b", "type": "师徒", "chapter": 1}
+        ],
+        "progress": {"current_chapter": "2", "total_words": "10"},
+    }
+    temp_project.state_file.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
+
+    manager = StateManager(temp_project, enable_sqlite_sync=False)
+    assert isinstance(manager._state.get("relationships"), dict)
+    assert isinstance(manager._state.get("structured_relationships"), list)
+    assert int(manager.get_current_chapter()) == 2
+
+    manager.update_progress(3)
+    assert manager.get_current_chapter() == 3
+
+
+def test_add_update_entities_and_alias(temp_project):
+    manager = StateManager(temp_project, enable_sqlite_sync=False)
+
+    entity = EntityState(id="xiaoyan", name="萧炎", type="角色", tier="核心", aliases=["炎帝"])
+    assert manager.add_entity(entity) is True
+    assert manager.add_entity(entity) is False
+
+    manager.update_entity("xiaoyan", {"current": {"realm": "斗师"}})
+    updated = manager.get_entity("xiaoyan")
+    assert updated["current"]["realm"] == "斗师"
+
+    assert manager.get_entity_type("xiaoyan") == "角色"
+    assert manager.get_entity_type("missing") is None
+
+    assert "xiaoyan" in manager.get_all_entities()
+    assert "xiaoyan" in manager.get_entities_by_type("角色")
+    assert "xiaoyan" in manager.get_entities_by_tier("核心")
+
+    # unknown type update
+    assert manager.update_entity("missing", {"current": {"realm": "斗者"}}, "角色") is False
+
+
+def test_update_entity_appearance_and_relationships(temp_project):
+    manager = StateManager(temp_project, enable_sqlite_sync=False)
+    manager.add_entity(EntityState(id="xiaoyan", name="萧炎", type="角色"))
+
+    manager.update_entity_appearance("xiaoyan", 5, "角色")
+    entity = manager.get_entity("xiaoyan")
+    assert entity.get("first_appearance") == 5
+    assert entity.get("last_appearance") == 5
+
+    # unknown entity should no-op
+    manager.update_entity_appearance("missing", 3, "角色")
+
+    manager.add_relationship("xiaoyan", "yaolao", "师徒", "收徒", 1)
+    rels = manager.get_relationships("xiaoyan")
+    assert len(rels) == 1
+
+
+def test_disambiguation_and_save_state(temp_project):
+    manager = StateManager(temp_project, enable_sqlite_sync=False)
+    warnings = manager._record_disambiguation(
+        1,
+        [
+            {
+                "mention": "宗主",
+                "candidates": ["zongzhu", "lintian"],
+                "suggested": "zongzhu",
+                "confidence": 0.4,
+            },
+            {
+                "mention": "萧炎",
+                "candidates": [{"type": "角色", "id": "xiaoyan"}],
+                "suggested": "xiaoyan",
+                "confidence": 0.6,
+            },
+        ],
+    )
+    assert any("需人工确认" in w for w in warnings)
+    assert any("消歧警告" in w for w in warnings)
+
+    manager.save_state()
+    assert temp_project.state_file.exists()
+
+
+def test_save_state_no_pending(temp_project):
+    manager = StateManager(temp_project, enable_sqlite_sync=False)
+    manager.save_state()
+    assert not temp_project.state_file.exists()
+
+
+def test_save_state_with_sqlite_sync_and_protagonist(temp_project):
+    manager = StateManager(temp_project)
+    manager.add_entity(EntityState(id="xiaoyan", name="萧炎", type="角色", tier="核心"))
+    manager.update_entity("xiaoyan", {"current": {"realm": "斗师", "location": "天云宗"}})
+    manager.update_progress(10, words=500)
+    manager.save_state()
+
+    state = json.loads(temp_project.state_file.read_text(encoding="utf-8"))
+    assert state.get("_migrated_to_sqlite") is True
+    assert state.get("progress", {}).get("current_chapter") == 10
+
+    # 标记为主角并同步
+    idx = IndexManager(temp_project)
+    idx.upsert_entity(
+        EntityMeta(
+            id="xiaoyan",
+            type="角色",
+            canonical_name="萧炎",
+            tier="核心",
+            current={"realm": "斗王", "location": "天云宗"},
+            first_appearance=1,
+            last_appearance=10,
+            is_protagonist=True,
+        ),
+        update_metadata=True,
+    )
+    manager.sync_protagonist_from_entity()
+    assert manager._state.get("protagonist_state", {}).get("power", {}).get("realm") == "斗王"
+
+    manager._state["protagonist_state"] = {
+        "power": {"realm": "斗皇", "layer": 2},
+        "location": {"current": "中州"},
+    }
+    manager._state.setdefault("entities_v3", {"角色": {}})
+    manager._state["entities_v3"]["角色"]["xiaoyan"] = {
+        "canonical_name": "萧炎",
+        "tier": "核心",
+        "desc": "",
+        "current": {"realm": "斗王", "location": "天云宗"},
+        "first_appearance": 1,
+        "last_appearance": 10,
+        "history": [],
+    }
+    manager.sync_protagonist_to_entity("xiaoyan")
+    manager.save_state()
+    updated = idx.get_entity("xiaoyan")
+    assert updated["current_json"]["realm"] == "斗皇"
+
+    # export context
+    exported = manager.export_for_context()
+    assert exported.get("alias_index") == {}
+
+
+def test_process_chapter_result_and_sqlite_sync(temp_project):
+    manager = StateManager(temp_project)
+    manager.add_entity(EntityState(id="xiaoyan", name="萧炎", type="角色", tier="核心"))
+
+    result = {
+        "entities_appeared": [
+            {"id": "xiaoyan", "type": "角色", "mentions": ["萧炎"], "confidence": 0.9}
+        ],
+        "entities_new": [
+            {
+                "suggested_id": "yaolao",
+                "name": "药老",
+                "type": "角色",
+                "tier": "重要",
+                "mentions": ["药老"],
+                "aliases": ["药老先生"],
+            }
+        ],
+        "state_changes": [
+            {"entity_id": "xiaoyan", "field": "realm", "old": "斗者", "new": "斗师", "reason": "突破"}
+        ],
+        "relationships_new": [
+            {"from": "xiaoyan", "to": "yaolao", "type": "师徒", "description": "收徒"}
+        ],
+        "uncertain": [
+            {"mention": "宗主", "candidates": ["zongzhu", "lintian"], "suggested": "zongzhu", "confidence": 0.2},
+            {
+                "mention": "萧炎",
+                "candidates": [{"type": "角色", "id": "xiaoyan"}],
+                "suggested": "xiaoyan",
+                "confidence": 0.8,
+                "adopted": True,
+            },
+        ],
+        "chapter_meta": {"hook": "test", "end": "ok"},
+    }
+    warnings = manager.process_chapter_result(12, result)
+    assert any("需人工确认" in w for w in warnings)
+    assert any("消歧警告" in w for w in warnings)
+
+    manager.save_state()
+
+    idx = IndexManager(temp_project)
+    assert idx.get_entity("yaolao") is not None
+    assert idx.get_relationship_between("xiaoyan", "yaolao")
+    assert idx.get_entity_state_changes("xiaoyan")
+
+    by_type = manager.get_entities_by_type("角色")
+    by_tier = manager.get_entities_by_tier("核心")
+    assert "xiaoyan" in by_type
+    assert "xiaoyan" in by_tier
+
+
+def test_export_context_and_protagonist_alias(temp_project):
+    manager = StateManager(temp_project, enable_sqlite_sync=False)
+    manager.add_entity(EntityState(id="xiaoyan", name="萧炎", type="角色", tier="核心"))
+    manager._state["disambiguation_warnings"] = [{"chapter": 1, "mention": "萧炎"}]
+    manager._state["disambiguation_pending"] = [{"chapter": 2, "mention": "宗主"}]
+
+    exported = manager.export_for_context()
+    assert "xiaoyan" in exported.get("entities", {})
+    assert exported["disambiguation"]["warnings"]
+    assert exported["disambiguation"]["pending"]
+
+    manager_sql = StateManager(temp_project)
+    idx = IndexManager(temp_project)
+    idx.upsert_entity(
+        EntityMeta(
+            id="xiaoyan",
+            type="角色",
+            canonical_name="萧炎",
+            tier="核心",
+            current={},
+            first_appearance=1,
+            last_appearance=1,
+            is_protagonist=False,
+        ),
+        update_metadata=True,
+    )
+    idx.register_alias("小炎子", "xiaoyan", "角色")
+    manager_sql._state["protagonist_state"] = {"name": "小炎子"}
+    assert manager_sql.get_protagonist_entity_id() == "xiaoyan"
+
+    idx.upsert_entity(
+        EntityMeta(
+            id="xiaoyan",
+            type="角色",
+            canonical_name="萧炎",
+            tier="核心",
+            current={},
+            first_appearance=1,
+            last_appearance=1,
+            is_protagonist=True,
+        ),
+        update_metadata=True,
+    )
+    assert manager_sql.get_protagonist_entity_id() == "xiaoyan"
+
+
+def test_sqlite_metadata_update_and_alias_sync(temp_project):
+    manager = StateManager(temp_project)
+    idx = IndexManager(temp_project)
+    idx.upsert_entity(
+        EntityMeta(
+            id="xiaoyan",
+            type="角色",
+            canonical_name="萧炎",
+            tier="核心",
+            current={"realm": "斗者"},
+            first_appearance=1,
+            last_appearance=1,
+            is_protagonist=False,
+        )
+    )
+
+    manager._state.setdefault("entities_v3", {"角色": {}})
+    manager._state["entities_v3"]["角色"]["xiaoyan"] = {
+        "canonical_name": "萧炎",
+        "tier": "核心",
+        "desc": "",
+        "current": {"realm": "斗者"},
+        "first_appearance": 1,
+        "last_appearance": 1,
+        "history": [],
+    }
+
+    manager.update_entity(
+        "xiaoyan",
+        {"canonical_name": "萧炎·新", "tier": "重要", "current": {"realm": "斗王"}},
+        "角色",
+    )
+    manager.update_entity("xiaoyan", {"location": "中州"}, "角色")
+    manager.update_entity_appearance("xiaoyan", 2, "角色")
+    manager._pending_alias_entries["小炎子"] = [{"type": "角色", "id": "xiaoyan"}]
+
+    manager.save_state()
+
+    updated = idx.get_entity("xiaoyan")
+    assert updated["canonical_name"] == "萧炎·新"
+    assert updated["current_json"]["realm"] == "斗王"
+    assert updated["current_json"]["location"] == "中州"
+    assert updated["last_appearance"] == 2
+
+    aliases = idx.get_entity_aliases("xiaoyan")
+    assert "萧炎·新" in aliases
+    assert "小炎子" in aliases
+
+
+def test_ensure_state_schema_invalid_inputs(temp_project):
+    manager = StateManager(temp_project, enable_sqlite_sync=False)
+    schema = manager._ensure_state_schema("bad")
+    assert isinstance(schema, dict)
+
+    schema2 = manager._ensure_state_schema({
+        "progress": "bad",
+        "relationships": "bad",
+        "disambiguation_warnings": "bad",
+        "disambiguation_pending": "bad",
+    })
+    assert isinstance(schema2["progress"], dict)
+    assert isinstance(schema2["relationships"], dict)
+    assert isinstance(schema2["disambiguation_warnings"], list)
+    assert isinstance(schema2["disambiguation_pending"], list)
+
+
+def test_save_state_progress_and_disambiguation_merge(temp_project):
+    state = {
+        "progress": {"current_chapter": "bad", "total_words": "bad"},
+        "disambiguation_warnings": "bad",
+        "disambiguation_pending": "bad",
+    }
+    temp_project.state_file.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
+
+    manager = StateManager(temp_project, enable_sqlite_sync=False)
+    manager.config.max_disambiguation_warnings = 1
+    manager.config.max_disambiguation_pending = 1
+    manager._pending_progress_chapter = 5
+    manager._pending_progress_words_delta = 10
+    manager._pending_disambiguation_warnings = [
+        {"chapter": 1, "mention": "a", "chosen_id": "x", "confidence": 0.5},
+        {"chapter": 1, "mention": "a", "chosen_id": "x", "confidence": 0.5},
+        "bad",
+    ]
+    manager._pending_disambiguation_pending = [
+        {"chapter": 2, "mention": "b", "suggested_id": "y", "confidence": 0.4},
+        {"chapter": 2, "mention": "b", "suggested_id": "y", "confidence": 0.4},
+        "bad",
+    ]
+    manager.save_state()
+
+    saved = json.loads(temp_project.state_file.read_text(encoding="utf-8"))
+    assert saved["progress"]["current_chapter"] == 5
+    assert saved["progress"]["total_words"] == 10
+    assert len(saved["disambiguation_warnings"]) == 1
+    assert len(saved["disambiguation_pending"]) == 1
+
+
+def test_sync_to_sqlite_exceptions_and_no_sql_manager(temp_project, monkeypatch):
+    manager = StateManager(temp_project)
+    manager._pending_progress_chapter = 1
+    manager._pending_sqlite_data["chapter"] = 1
+    manager._pending_alias_entries["alias"] = [{"type": "角色", "id": "xiaoyan"}]
+
+    def boom(*args, **kwargs):
+        raise RuntimeError("boom")
+
+    monkeypatch.setattr(manager._sql_state_manager, "process_chapter_entities", boom)
+    monkeypatch.setattr(manager._sql_state_manager, "register_alias", boom)
+
+    manager.save_state()
+
+    manager_no_sql = StateManager(temp_project, enable_sqlite_sync=False)
+    manager_no_sql._sync_pending_patches_to_sqlite()
+
+
+def test_entity_fallbacks_and_updates(temp_project):
+    manager = StateManager(temp_project, enable_sqlite_sync=False)
+
+    manager.add_entity(EntityState(id="hero", name="主角", type="未知", tier="核心"))
+    manager.add_entity(EntityState(id="place", name="乌坦城", type="地点", tier="重要"))
+
+    assert manager.get_entity("hero", "角色")["canonical_name"] == "主角"
+    assert manager.get_entity("place")["canonical_name"] == "乌坦城"
+    assert manager.get_entity_type("place") == "地点"
+
+    assert "hero" in manager.get_entities_by_type("角色")
+    assert "hero" in manager.get_entities_by_tier("核心")
+    assert "hero" in manager.get_all_entities()
+
+    assert manager.update_entity("missing", {"current": {"a": 1}}) is False
+
+    manager.update_entity("hero", {"attributes": {"hp": 1}}, "角色")
+    manager._state["entities_v3"]["角色"]["hero"].pop("current", None)
+    manager.update_entity("hero", {"current": {"mp": 2}}, "角色")
+    manager.update_entity("hero", {"tier": "重要"}, "角色")
+
+    manager._state["entities_v3"] = "bad"
+    manager.update_entity_appearance("hero", 1, "角色")
+    manager._state["entities_v3"]["角色"]["hero"] = {"first_appearance": 0, "last_appearance": 0}
+    manager.update_entity_appearance("hero", 1, "角色")
+    manager.update_entity_appearance("hero", 2, "角色")
+
+
+def test_register_alias_internal_and_get_all_entities_sqlite(temp_project):
+    manager = StateManager(temp_project)
+    manager._register_alias_internal("xiaoyan", "角色", "")
+    manager._register_alias_internal("xiaoyan", "角色", "萧炎")
+
+    idx = IndexManager(temp_project)
+    idx.upsert_entity(
+        EntityMeta(
+            id="xiaoyan",
+            type="角色",
+            canonical_name="萧炎",
+            tier="核心",
+            current={},
+            first_appearance=1,
+            last_appearance=1,
+            is_protagonist=False,
+        )
+    )
+    all_entities = manager.get_all_entities()
+    assert "xiaoyan" in all_entities
+
+
+def test_record_disambiguation_and_process_chapter_existing(temp_project):
+    manager = StateManager(temp_project, enable_sqlite_sync=False)
+    warnings = manager._record_disambiguation(
+        1,
+        [
+            "bad",
+            {"mention": "", "confidence": 0.1},
+            {"mention": "宗主", "confidence": "bad", "adopted": "zongzhu"},
+        ],
+    )
+    assert warnings
+
+    manager.add_entity(EntityState(id="xiaoyan", name="萧炎", type="角色"))
+    warnings = manager.process_chapter_result(2, {"entities_new": [{"id": "xiaoyan", "name": "萧炎"}]})
+    assert any("实体已存在" in w for w in warnings)
+
+
+def test_sync_protagonist_from_string_and_empty_updates(temp_project):
+    manager = StateManager(temp_project, enable_sqlite_sync=False)
+    manager._state.setdefault("entities_v3", {"角色": {}})
+    manager._state["entities_v3"]["角色"]["bad"] = {
+        "current": None,
+        "current_json": "not-json",
+    }
+    manager._state["entities_v3"]["角色"]["hero"] = {
+        "current": None,
+        "current_json": json.dumps({"realm": "斗师", "layer": 2, "location": "乌坦城", "last_chapter": 3}),
+    }
+    manager.sync_protagonist_from_entity("bad")
+    manager.sync_protagonist_from_entity("hero")
+    assert manager._state["protagonist_state"]["power"]["realm"] == "斗师"
+
+    manager._state["protagonist_state"] = {}
+    manager.sync_protagonist_to_entity()
+
+
+def test_state_manager_cli_commands(temp_project, monkeypatch, capsys):
+    idx = IndexManager(temp_project)
+    idx.upsert_entity(
+        EntityMeta(
+            id="xiaoyan",
+            type="角色",
+            canonical_name="萧炎",
+            tier="核心",
+            current={},
+            first_appearance=1,
+            last_appearance=1,
+            is_protagonist=False,
+        )
+    )
+
+    def run_cli(args):
+        monkeypatch.setattr(sys, "argv", args)
+        from data_modules import state_manager as sm
+
+        sm.main()
+        return capsys.readouterr().out
+
+    out = run_cli(["state_manager", "--project-root", str(temp_project.project_root), "get-progress"])
+    assert "current_chapter" in out
+
+    out = run_cli(["state_manager", "--project-root", str(temp_project.project_root), "get-entity", "--id", "missing"])
+    assert "未找到实体" in out
+
+    out = run_cli(["state_manager", "--project-root", str(temp_project.project_root), "get-entity", "--id", "xiaoyan"])
+    assert "xiaoyan" in out
+
+    out = run_cli(["state_manager", "--project-root", str(temp_project.project_root), "list-entities", "--type", "角色"])
+    assert "xiaoyan" in out
+
+    out = run_cli(["state_manager", "--project-root", str(temp_project.project_root), "list-entities", "--tier", "核心"])
+    assert "xiaoyan" in out
+
+    payload = json.dumps({"entities_appeared": [], "entities_new": [], "state_changes": [], "relationships_new": []})
+    out = run_cli([
+        "state_manager",
+        "--project-root",
+        str(temp_project.project_root),
+        "process-chapter",
+        "--chapter",
+        "1",
+        "--data",
+        payload,
+    ])
+    assert "已处理第 1 章" in out
+
+
+def test_save_state_timeout(monkeypatch, temp_project):
+    import filelock
+    from data_modules import state_manager as sm
+
+    manager = StateManager(temp_project, enable_sqlite_sync=False)
+    manager.update_progress(1)
+
+    class FakeLock:
+        def __init__(self, *args, **kwargs):
+            pass
+
+        def __enter__(self):
+            raise filelock.Timeout("timeout")
+
+        def __exit__(self, exc_type, exc, tb):
+            return False
+
+    monkeypatch.setattr(sm.filelock, "FileLock", FakeLock)
+    with pytest.raises(RuntimeError):
+        manager.save_state()

+ 91 - 0
.claude/scripts/data_modules/tests/test_style_sampler_cli.py

@@ -0,0 +1,91 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+StyleSampler extra tests + CLI
+"""
+
+import sys
+import json
+
+import pytest
+
+import data_modules.style_sampler as sampler_module
+from data_modules.style_sampler import StyleSampler, StyleSample, SceneType
+from data_modules.config import DataModulesConfig
+
+
+@pytest.fixture
+def temp_project(tmp_path):
+    cfg = DataModulesConfig.from_project_root(tmp_path)
+    cfg.ensure_dirs()
+    return cfg
+
+
+def test_style_sampler_more(temp_project):
+    sampler = StyleSampler(temp_project)
+
+    sample = StyleSample(
+        id="ch1_s1",
+        chapter=1,
+        scene_type=SceneType.BATTLE.value,
+        content="战斗描写很精彩",
+        score=0.9,
+        tags=["战斗"],
+    )
+    assert sampler.add_sample(sample) is True
+    assert sampler.add_sample(sample) is False
+
+    best = sampler.get_best_samples(limit=5)
+    assert len(best) == 1
+
+    stats = sampler.get_stats()
+    assert stats["total"] == 1
+
+    # scene type inference
+    assert sampler._infer_scene_types("一场战斗") == [SceneType.BATTLE.value]
+    assert sampler._infer_scene_types("对话和谈话") == [SceneType.DIALOGUE.value]
+    assert sampler._infer_scene_types("心理情感描写") == [SceneType.EMOTION.value]
+
+    # classify and tags
+    scene_type = sampler._classify_scene_type({"summary": "紧张", "content": ""})
+    assert scene_type == SceneType.TENSION.value
+
+    tags = sampler._extract_tags("战斗 修炼 对话 描写")
+    assert "战斗" in tags
+
+
+def test_style_sampler_cli(temp_project, monkeypatch, capsys):
+    root = str(temp_project.project_root)
+
+    def run_cli(args):
+        monkeypatch.setattr(sys, "argv", ["style_sampler"] + args)
+        sampler_module.main()
+
+    run_cli(["--project-root", root, "stats"])
+    run_cli(["--project-root", root, "list", "--limit", "5"])
+    run_cli(
+        [
+            "--project-root",
+            root,
+            "extract",
+            "--chapter",
+            "1",
+            "--score",
+            "90",
+            "--scenes",
+            json.dumps(
+                [
+                    {
+                        "index": 1,
+                        "summary": "战斗场景",
+                        "content": "战斗" + "a" * 300,
+                    }
+                ],
+                ensure_ascii=False,
+            ),
+        ]
+    )
+    run_cli(["--project-root", root, "list", "--type", "战斗", "--limit", "5"])
+    run_cli(["--project-root", root, "select", "--outline", "本章有一场战斗", "--max", "2"])
+
+    capsys.readouterr()

+ 7 - 0
.coveragerc

@@ -0,0 +1,7 @@
+[run]
+source = .claude/scripts/data_modules
+omit =
+    */tests/*
+
+[report]
+show_missing = True

+ 134 - 0
CLAUDE.md

@@ -0,0 +1,134 @@
+# CLAUDE.md - Webnovel Writer 项目指南
+
+> 本文档为 Claude Code 提供项目上下文,帮助 AI 理解项目结构和工作流程。
+
+## 项目概述
+
+**Webnovel Writer** 是基于 Claude Code 的长篇网文辅助创作系统(v5.3),解决 AI 写作中的"遗忘"和"幻觉"问题,支持 200 万字量级连载创作。
+
+## 核心理念
+
+### 防幻觉三定律
+1. **大纲即法律** - 遵循大纲,不擅自发挥
+2. **设定即物理** - 遵守设定,不自相矛盾
+3. **发明需识别** - 新实体必须入库管理
+
+### 追读力机制(v5.3 新增)
+- **Hard Invariants** - 不可违反的硬约束(可读性/承诺/节奏/冲突)
+- **Soft Guidance** - 可通过 Override Contract 违反的软建议
+- **Chase Debt** - 追读力债务追踪与利息机制
+
+## 关键目录
+
+```
+.claude/
+├── agents/                 # 9 个专职 Agent
+│   ├── context-agent.md    # 创作任务书生成器 (v5.3)
+│   ├── data-agent.md       # 数据链工程师
+│   ├── reader-pull-checker.md  # 追读力检查器 (v5.3)
+│   ├── high-point-checker.md   # 爽点检查器 (v5.3)
+│   └── ...
+├── skills/                 # 6 个核心 Skill
+│   ├── webnovel-init/
+│   ├── webnovel-plan/
+│   ├── webnovel-write/     # 主写作流程 (v5.3)
+│   └── ...
+├── scripts/                # Python 脚本
+│   └── data_modules/
+│       ├── index_manager.py  # SQLite 管理 (v5.3)
+│       └── ...
+├── references/             # 写作指南
+│   ├── reading-power-taxonomy.md  # 追读力分类标准 (v5.3)
+│   ├── genre-profiles.md          # 题材配置档案 (v5.3)
+│   └── ...
+└── templates/              # 题材模板
+```
+
+## 核心 Skill 命令
+
+| 命令 | 说明 |
+|------|------|
+| `/webnovel-init` | 初始化项目 |
+| `/webnovel-plan [卷号]` | 规划大纲 |
+| `/webnovel-write [章号]` | 创作章节 |
+| `/webnovel-review [范围]` | 质量审查 |
+| `/webnovel-query [关键词]` | 信息查询 |
+| `/webnovel-resume` | 恢复中断任务 |
+
+## v5.3 新增功能
+
+### 1. 追读力分类标准
+- **钩子类型扩展**:危机钩/悬念钩/情绪钩/选择钩/渴望钩
+- **爽点模式扩展**:8种模式(新增迪化误解/身份掉马)
+- **微兑现体系**:7种类型(信息/关系/能力/资源/认可/情绪/线索)
+
+### 2. 题材 Profile 配置
+8种内置题材,每种包含:
+- 偏好钩子类型
+- 偏好爽点模式
+- 微兑现要求
+- 节奏红线阈值
+- Override 允许规则
+
+### 3. SQLite 新表
+- `override_contracts` - Override Contract 记录
+- `chase_debt` - 追读力债务
+- `debt_events` - 债务事件日志
+- `chapter_reading_power` - 章节追读力元数据
+
+### 4. 约束分层机制
+- **Hard Invariants** (HARD-001 ~ HARD-004) - 必须修复
+- **Soft Guidance** - 可 Override,产生债务
+- **Override Contract** - 记录违反理由和偿还计划
+- **Chase Debt** - 债务追踪,含利息机制
+
+## 写作工作流 (Step 1-6)
+
+```
+Step 1: Context Agent 搜集上下文
+        ↓ (输出创作任务书,含追读力设计)
+Step 1.5: 章节设计(开头/钩子/爽点/微兑现)
+        ↓
+Step 2A: 生成粗稿
+Step 2B: 风格适配器
+        ↓
+Step 3: 6 Agent 并行审查(含 reader-pull-checker)
+        ↓
+Step 4: 网文化润色
+        ↓
+Step 5: Data Agent 处理数据链
+        ↓
+Step 6: Git 备份
+```
+
+## 常用 Python 命令
+
+```bash
+# 查询统计
+python -m data_modules.index_manager stats --project-root "."
+
+# 查询债务状态
+python -m data_modules.index_manager get-debt-summary --project-root "."
+
+# 查询追读力历史
+python -m data_modules.index_manager get-recent-reading-power --limit 10 --project-root "."
+
+# 查询模式使用统计
+python -m data_modules.index_manager get-pattern-usage-stats --last-n 20 --project-root "."
+```
+
+## 关键文件说明
+
+| 文件 | 说明 |
+|------|------|
+| `.webnovel/state.json` | 项目状态(精简版) |
+| `.webnovel/index.db` | SQLite 索引数据库 |
+| `.claude/references/reading-power-taxonomy.md` | 追读力分类标准 |
+| `.claude/references/genre-profiles.md` | 题材配置档案 |
+
+## 注意事项
+
+1. **不要直接修改 state.json 中的大量数据** - 大数据存 SQLite
+2. **Override Contract 需明确偿还计划** - 每个 Override 产生债务
+3. **债务有利息** - 每章累积 10%,逾期会影响后续章节
+4. **题材 Profile 可覆盖** - 在 state.json 中设置 genre_overrides

+ 4 - 0
pytest.ini

@@ -0,0 +1,4 @@
+[pytest]
+testpaths = .claude/scripts/data_modules/tests
+pythonpath = .claude/scripts
+addopts = -q --cov=.claude/scripts/data_modules --cov-report=term-missing --cov-fail-under=90