Kaynağa Gözat

feat(v5.4): webnovel-learn skill 和 ContextManager

- webnovel-learn: 从会话提取成功模式写入 project_memory.json
- context_manager: Token 预算管理(40%关键/35%重要/25%可选)
- snapshot_manager: 快照版本管理与兼容性检查
- query_router: 查询路由骨架(entity/plot/scene/setting)
- 添加 context-engineering-upgrade-plan-v1.2.md 设计文档

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
lingfengQAQ 4 ay önce
ebeveyn
işleme
349747a52a

+ 410 - 0
.claude/references/context-engineering-upgrade-plan-v1.2.md

@@ -0,0 +1,410 @@
+# Webnovel-Writer 上下文工程升级方案 v1.2
+
+> **状态**: 设计确认,待实施
+> **参与者**: Claude (Opus 4.5) + Codex + 用户确认
+> **日期**: 2026-02-02
+> **v1.2 变更**: 修正向量存储路径、/learn 路径、依赖清单
+> **兼容性**: 不考虑向前兼容(允许重建 `vectors.db` 与重跑索引)
+
+---
+
+## 一、关键决策确认
+
+| 决策项 | 最终决定 | 理由 |
+|--------|---------|------|
+| 向量存储 | **保留 SQLite vectors.db** | 现有 rag_adapter 完整可用,重写成本高 |
+| 父子文档 | **在 vectors 表增加 parent_chunk_id** | 无需新建 FAISS/scenes 目录 |
+| 向前兼容 | **不考虑** | 允许重建 vectors.db 与父子索引 |
+| /webnovel-learn 位置 | **skills/webnovel-learn/SKILL.md** | 遵循现有扩展体系,统一前缀 |
+| 日志表位置 | **index.db** | 统一管理 |
+| scenes/ 目录 | **不新建** | 场景数据存 scenes 表 + vectors 表 |
+
+---
+
+## 二、闭环确认清单(修正版)
+
+| 模块 | 写入方 | 读取方 | 存储位置 | 状态 |
+|------|--------|--------|---------|------|
+| ContextManager | - | context-agent 调用 | 工具类(无持久化) | 待实现 |
+| context_snapshots/ | context-agent | context-agent(下章) | .webnovel/context_snapshots/ | 待实现 |
+| cli_output.py | - | 所有 CLI 输出 | 工具类 | 待实现 |
+| schemas.py | - | data-agent 校验 | 工具类 | 待实现 |
+| invalid_facts 表 | 用户+checker | context-agent | index.db | 待实现 |
+| mark-invalid 命令 | 用户 | invalid_facts | index_manager.py | 待实现 |
+| 父子向量索引 | data-agent | rag_adapter | vectors.db(重建+新增字段) | 待实现 |
+| query_router.py | - | context-agent | 工具类 | 待实现 |
+| 来源标注 | context-agent | 人读 | 任务书文本 | 待实现 |
+| rag_query_log | rag_adapter | 手动SQL | index.db | 待实现 |
+| tool_call_stats | CLI工具 | 手动SQL | index.db | 待实现 |
+| preferences.json | 用户/init | context-agent | .webnovel/ | 待实现 |
+| project_memory.json | /webnovel-learn | context-agent | .webnovel/ | 待实现 |
+
+---
+
+## 三、修正:向量存储方案
+
+### 3.1 现有结构(保留)
+
+```python
+# rag_adapter.py 使用 SQLite 存储向量
+# 表结构:
+CREATE TABLE vectors (
+    chunk_id TEXT PRIMARY KEY,
+    chapter INTEGER,
+    scene_index INTEGER,
+    content TEXT,
+    embedding BLOB,
+    created_at TIMESTAMP
+);
+```
+
+### 3.2 扩展方案(父子文档)
+
+```sql
+-- 不考虑向前兼容:重建 vectors 表(旧向量不保留)
+DROP TABLE IF EXISTS vectors;
+CREATE TABLE vectors (
+    chunk_id TEXT PRIMARY KEY,
+    chapter INTEGER,
+    scene_index INTEGER,
+    content TEXT,
+    embedding BLOB,
+    parent_chunk_id TEXT,
+    chunk_type TEXT DEFAULT 'scene',  -- 'summary' | 'scene'
+    source_file TEXT,                -- 来源文件路径
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX idx_vectors_chapter ON vectors(chapter);
+CREATE INDEX idx_vectors_parent ON vectors(parent_chunk_id);
+CREATE INDEX idx_vectors_type ON vectors(chunk_type);
+```
+
+**父子关系**:
+- 父块 (summary): chunk_type='summary', parent_chunk_id=NULL
+- 子块 (scene): chunk_type='scene', parent_chunk_id='ch0100_summary'
+
+**source_file 规范**:
+- summary: `summaries/chNNNN.md`
+- scene: `正文/第NNNN章.md#scene_{scene_index}`
+
+**检索 + 回溯**:
+```python
+def search_with_backtrack(self, query: str, top_k: int = 5) -> list:
+    # 1. 检索子块
+    child_results = self._vector_search(query, chunk_type='scene', top_k=top_k*2)
+
+    # 2. 收集父块 ID
+    parent_ids = set(r.parent_chunk_id for r in child_results if r.parent_chunk_id)
+
+    # 3. 查询父块上下文
+    parent_contexts = self._get_chunks_by_ids(parent_ids)
+
+    # 4. 合并返回
+    return self._merge_results(parent_contexts, child_results[:top_k])
+```
+
+**实施方式**:
+- 删除 `.webnovel/vectors.db` 后由 `rag_adapter` 重建
+- 或在初始化阶段执行 `DROP TABLE IF EXISTS vectors` 并重建
+
+---
+
+## 四、修正:/learn 位置
+
+### 4.1 目录结构
+
+```
+.claude/
+├── skills/
+│   ├── webnovel-learn/               # 【新增】学习命令
+│   │   └── SKILL.md
+│   ├── webnovel-init/
+│   ├── webnovel-write/
+│   └── ...
+```
+
+### 4.2 skills/webnovel-learn/SKILL.md
+
+```markdown
+---
+name: webnovel-learn
+description: 从当前会话中提取成功的写作模式并持久化到 project_memory.json
+allowed-tools: Read Write Bash
+---
+
+# /webnovel-learn 命令
+
+## 触发条件
+- 用户主动调用 /webnovel-learn
+- 章节审查得分 > 85 时提示用户调用
+
+## 执行流程
+1. 分析当前会话中的成功模式
+2. 提取可复用的技巧(钩子设计、节奏控制、对话技巧等)
+3. 写入 `.webnovel/project_memory.json`
+
+## 输入
+```bash
+/webnovel-learn "本章的危机钩设计很有效,悬念拉满"
+```
+
+## 输出
+```json
+{
+  "status": "success",
+  "learned": {
+    "pattern_type": "hook",
+    "description": "危机钩设计:悬念拉满",
+    "source_chapter": 100,
+    "learned_at": "2026-02-02T12:00:00Z"
+  }
+}
+```
+```
+
+---
+
+## 五、修正:依赖清单
+
+### 5.1 requirements.txt 更新
+
+```txt
+# Webnovel Writer - Python Dependencies
+# Python >= 3.8 required
+
+# 核心依赖
+aiohttp>=3.8.0          # 异步 HTTP 客户端(API 调用)
+filelock>=3.0.0         # 文件锁(状态文件并发控制)
+pydantic>=2.0.0         # 【新增】Schema 校验
+
+# 可选依赖(开发/测试)
+pytest>=7.0.0           # 单元测试
+pytest-cov>=4.1.0       # 覆盖率统计
+```
+
+---
+
+## 六、invalid_facts 详细设计(保持不变)
+
+### 6.1 表结构
+
+```sql
+CREATE TABLE invalid_facts (
+    id INTEGER PRIMARY KEY,
+    source_type TEXT NOT NULL,      -- entity/relationship/state_change
+    source_id TEXT NOT NULL,
+    reason TEXT NOT NULL,
+    status TEXT DEFAULT 'pending',  -- pending/confirmed
+    marked_by TEXT NOT NULL,        -- user/consistency-checker
+    marked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+    confirmed_at TIMESTAMP,
+    chapter_discovered INTEGER
+);
+
+CREATE INDEX idx_invalid_status ON invalid_facts(status);
+CREATE INDEX idx_invalid_source ON invalid_facts(source_type, source_id);
+```
+
+### 6.2 CLI 命令
+
+```bash
+# 标记无效
+python -m data_modules.index_manager mark-invalid \
+    --source-type entity \
+    --source-id 123 \
+    --reason "境界描述与第50章矛盾" \
+    --chapter 75 \
+    --project-root "."
+
+# 确认无效
+python -m data_modules.index_manager resolve-invalid \
+    --id 1 --action confirm --project-root "."
+
+# 查看待确认
+python -m data_modules.index_manager list-invalid \
+    --status pending --project-root "."
+```
+
+### 6.3 consistency-checker 集成
+
+需要在 `consistency-checker.md` 的 Step 4 后增加:
+
+```markdown
+### Step 5: 标记无效事实(新增)
+
+对于发现的 CRITICAL 级别问题,自动标记到 invalid_facts:
+
+```bash
+python -m data_modules.index_manager mark-invalid \
+    --source-type entity \
+    --source-id {entity_id} \
+    --reason "{问题描述}" \
+    --marked-by consistency-checker \
+    --chapter {current_chapter} \
+    --project-root "."
+```
+
+**注意**: 自动标记的状态为 `pending`,需用户确认后才生效。
+```
+
+---
+
+## 七、日志表设计
+
+### 7.1 rag_query_log(在 index.db)
+
+```sql
+CREATE TABLE rag_query_log (
+    id INTEGER PRIMARY KEY,
+    query TEXT,
+    query_type TEXT,           -- entity/plot/scene/setting
+    results_count INTEGER,
+    hit_sources TEXT,          -- JSON: {"summary": 2, "scene": 3}
+    latency_ms INTEGER,
+    chapter INTEGER,           -- 发起查询的章节
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX idx_rag_query_type ON rag_query_log(query_type);
+CREATE INDEX idx_rag_query_chapter ON rag_query_log(chapter);
+```
+
+### 7.2 tool_call_stats(在 index.db)
+
+```sql
+CREATE TABLE tool_call_stats (
+    id INTEGER PRIMARY KEY,
+    tool_name TEXT,
+    success BOOLEAN,
+    retry_count INTEGER DEFAULT 0,
+    error_code TEXT,
+    error_message TEXT,
+    chapter INTEGER,
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX idx_tool_stats_name ON tool_call_stats(tool_name);
+CREATE INDEX idx_tool_stats_chapter ON tool_call_stats(chapter);
+```
+
+---
+
+## 八、目录结构(修正版)
+
+```
+.claude/
+├── scripts/
+│   └── data_modules/
+│       ├── context_manager.py      # 【新增】上下文管理器
+│       ├── cli_output.py           # 【新增】统一输出格式
+│       ├── schemas.py              # 【新增】Pydantic Schema
+│       ├── query_router.py         # 【新增】查询路由器 (阶段2)
+│       ├── snapshot_manager.py     # 【新增】快照版本管理
+│       ├── index_manager.py        # 【修改】增加 invalid_facts + 日志表
+│       └── rag_adapter.py          # 【修改】增加父子索引 + 回溯
+├── skills/
+│   └── webnovel-learn/                 # 【新增】阶段3
+│       └── SKILL.md
+├── agents/
+│   ├── context-agent.md            # 【修改】集成 ContextManager + 来源标注
+│   └── consistency-checker.md      # 【修改】集成 invalid_facts 写入
+└── references/
+    └── context-engineering-upgrade-plan-v1.2.md  # 本文档
+
+.webnovel/  # (在用户的小说项目中)
+├── context_snapshots/              # 【新增】上下文快照
+│   └── ch0100.json
+├── index.db                        # 现有(增加新表)
+├── vectors.db                      # 现有(增加字段)
+├── state.json                      # 现有
+├── summaries/                      # 现有
+├── preferences.json                # 【新增】阶段3
+└── project_memory.json             # 【新增】阶段3
+```
+
+**注意**: 删除了原计划中的:
+- `.webnovel/scenes/` 目录(场景数据存表)
+- `.webnovel/vectors/*.faiss` 文件(保留 SQLite)
+
+---
+
+## 九、阶段划分(修正版)
+
+### 阶段1:上下文控制与结构化输出
+
+| 任务 | 文件 | 变更类型 | 依赖 |
+|------|------|---------|------|
+| 1. requirements.txt 更新 | requirements.txt | 修改 | 无 |
+| 2. invalid_facts 表 | index_manager.py | 新增表 | 无 |
+| 3. mark-invalid 命令 | index_manager.py | 新增命令 | 任务2 |
+| 4. resolve-invalid 命令 | index_manager.py | 新增命令 | 任务2 |
+| 5. list-invalid 命令 | index_manager.py | 新增命令 | 任务2 |
+| 6. cli_output.py | 新文件 | 新增 | 无 |
+| 7. schemas.py | 新文件 | 新增 | 任务1 |
+| 8. snapshot_manager.py | 新文件 | 新增 | 任务6 |
+| 9. ContextManager | 新文件 | 新增 | 任务6,7,8 |
+| 10. consistency-checker 集成 | agents/*.md | 修改 | 任务3 |
+| 11. context-agent 集成 | agents/*.md | 修改 | 任务9 |
+
+### 阶段2:RAG 增强
+
+| 任务 | 文件 | 变更类型 | 依赖 |
+|------|------|---------|------|
+| 1. vectors 表重建(不兼容) | rag_adapter.py | 修改表结构 | 无 |
+| 2. 父子索引构建 | rag_adapter.py | 新增方法 | 任务1 |
+| 3. 回溯检索 | rag_adapter.py | 新增方法 | 任务2 |
+| 4. query_router.py | 新文件 | 新增 | 无 |
+| 5. rag_query_log 表 | index_manager.py | 新增表 | 无 |
+| 6. tool_call_stats 表 | index_manager.py | 新增表 | 无 |
+| 7. 来源标注 | context-agent.md | 修改输出 | 任务3 |
+| 8. data-agent 集成 | data-agent.md | 修改 | 任务1,2 |
+
+### 阶段3:记忆与评估
+
+| 任务 | 文件 | 变更类型 | 依赖 |
+|------|------|---------|------|
+| 1. preferences.json 设计 | 文档 | 设计 | 无 |
+| 2. project_memory.json 设计 | 文档 | 设计 | 无 |
+| 3. skills/webnovel-learn/SKILL.md | 新文件 | 新增 | 任务2 |
+| 4. 置信度过滤 | context_manager.py | 新增方法 | 阶段1 |
+| 5. context-agent 读取记忆 | context-agent.md | 修改 | 任务1,2 |
+
+---
+
+## 十、验收标准(修正版)
+
+### 阶段1 验收
+
+- [ ] requirements.txt 包含 pydantic>=2.0.0
+- [ ] index.db 包含 invalid_facts 表
+- [ ] mark-invalid / resolve-invalid / list-invalid 命令可用
+- [ ] consistency-checker 可自动写入 pending 状态
+- [ ] context-agent 过滤 confirmed,提示 pending
+- [ ] cli_output.py 提供统一的 success/error 格式
+- [ ] schemas.py 提供 DataAgentOutput 等 Schema
+- [ ] snapshot_manager.py 可保存/加载快照
+- [ ] ContextManager 可按优先级组装上下文
+
+### 阶段2 验收
+
+- [ ] vectors.db 已重建(旧向量不保留)
+- [ ] vectors.db 包含 parent_chunk_id, chunk_type 字段
+- [ ] data-agent 写入时设置正确的父子关系
+- [ ] rag_adapter 支持 search_with_backtrack
+- [ ] query_router 可按问题类型分流
+- [ ] index.db 包含 rag_query_log, tool_call_stats 表
+- [ ] 任务书包含来源标注
+
+### 阶段3 验收
+
+- [ ] preferences.json 可被 context-agent 读取
+- [ ] project_memory.json 可被 context-agent 读取
+- [ ] /webnovel-learn 命令可写入 project_memory.json
+- [ ] 置信度过滤生效
+
+---
+
+*文档版本: 1.2*
+*状态: 设计确认,待实施*
+*日期: 2026-02-02*

+ 259 - 0
.claude/scripts/data_modules/context_manager.py

@@ -0,0 +1,259 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+ContextManager - assemble context packs with weighted priorities.
+"""
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+from .config import get_config
+from .index_manager import IndexManager
+from .snapshot_manager import SnapshotManager, SnapshotVersionMismatch
+
+
+class ContextManager:
+    DEFAULT_TEMPLATE = "plot"
+    TEMPLATE_WEIGHTS = {
+        "plot": {"core": 0.40, "scene": 0.35, "global": 0.25},
+        "battle": {"core": 0.35, "scene": 0.45, "global": 0.20},
+        "emotion": {"core": 0.45, "scene": 0.35, "global": 0.20},
+        "transition": {"core": 0.50, "scene": 0.25, "global": 0.25},
+    }
+
+    def __init__(self, config=None, snapshot_manager: Optional[SnapshotManager] = None):
+        self.config = config or get_config()
+        self.snapshot_manager = snapshot_manager or SnapshotManager(self.config)
+        self.index_manager = IndexManager(self.config)
+
+    def build_context(
+        self,
+        chapter: int,
+        template: str | None = None,
+        use_snapshot: bool = True,
+        save_snapshot: bool = True,
+        max_chars: Optional[int] = None,
+    ) -> Dict[str, Any]:
+        template = template or self.DEFAULT_TEMPLATE
+        if template not in self.TEMPLATE_WEIGHTS:
+            template = self.DEFAULT_TEMPLATE
+
+        if use_snapshot:
+            try:
+                cached = self.snapshot_manager.load_snapshot(chapter)
+                if cached:
+                    return cached.get("payload", cached)
+            except SnapshotVersionMismatch:
+                # Snapshot incompatible; rebuild below.
+                pass
+
+        pack = self._build_pack(chapter)
+        assembled = self.assemble_context(pack, template=template, max_chars=max_chars)
+
+        if save_snapshot:
+            meta = {"template": template}
+            self.snapshot_manager.save_snapshot(chapter, assembled, meta=meta)
+
+        return assembled
+
+    def assemble_context(
+        self,
+        pack: Dict[str, Any],
+        template: str = DEFAULT_TEMPLATE,
+        max_chars: Optional[int] = None,
+    ) -> Dict[str, Any]:
+        weights = self.TEMPLATE_WEIGHTS.get(template, self.TEMPLATE_WEIGHTS[self.DEFAULT_TEMPLATE])
+        max_chars = max_chars or 8000
+
+        sections = {}
+        for section_name in ["core", "scene", "global", "memory", "preferences", "alerts"]:
+            if section_name in pack:
+                sections[section_name] = pack[section_name]
+
+        assembled: Dict[str, Any] = {"meta": pack.get("meta", {}), "sections": {}}
+        for name, content in sections.items():
+            weight = weights.get(name, 0.0)
+            budget = int(max_chars * weight) if weight > 0 else None
+            text = json.dumps(content, ensure_ascii=False)
+            if budget is not None and len(text) > budget:
+                text = text[:budget]
+            assembled["sections"][name] = {"content": content, "text": text, "budget": budget}
+
+        assembled["template"] = template
+        assembled["weights"] = weights
+        return assembled
+
+    def filter_invalid_items(self, items: List[Dict[str, Any]], source_type: str, id_key: str) -> List[Dict[str, Any]]:
+        confirmed = self.index_manager.get_invalid_ids(source_type, status="confirmed")
+        pending = self.index_manager.get_invalid_ids(source_type, status="pending")
+        result = []
+        for item in items:
+            item_id = str(item.get(id_key, ""))
+            if item_id in confirmed:
+                continue
+            if item_id in pending:
+                item = dict(item)
+                item["warning"] = "pending_invalid"
+            result.append(item)
+        return result
+
+    def apply_confidence_filter(self, items: List[Dict[str, Any]], min_confidence: float) -> List[Dict[str, Any]]:
+        filtered: List[Dict[str, Any]] = []
+        for item in items:
+            conf = item.get("confidence")
+            if conf is None or conf >= min_confidence:
+                filtered.append(item)
+        return filtered
+
+    def _build_pack(self, chapter: int) -> Dict[str, Any]:
+        state = self._load_state()
+        core = {
+            "chapter_outline": self._load_outline(chapter),
+            "protagonist_snapshot": state.get("protagonist_state", {}),
+            "recent_summaries": self._load_recent_summaries(chapter, window=3),
+            "recent_meta": self._load_recent_meta(state, chapter, window=3),
+        }
+
+        scene = {
+            "location_context": state.get("protagonist_state", {}).get("location", {}),
+            "appearing_characters": self._load_recent_appearances(),
+        }
+        scene["appearing_characters"] = self.filter_invalid_items(
+            scene["appearing_characters"], source_type="entity", id_key="entity_id"
+        )
+
+        global_ctx = {
+            "worldview_skeleton": self._load_setting("世界观"),
+            "power_system_skeleton": self._load_setting("力量体系"),
+            "style_contract_ref": self._load_setting("风格契约"),
+        }
+
+        preferences = self._load_json_optional(self.config.webnovel_dir / "preferences.json")
+        memory = self._load_json_optional(self.config.webnovel_dir / "project_memory.json")
+
+        return {
+            "meta": {"chapter": chapter},
+            "core": core,
+            "scene": scene,
+            "global": global_ctx,
+            "preferences": preferences,
+            "memory": memory,
+            "alerts": {
+                "disambiguation_warnings": state.get("disambiguation_warnings", [])[-10:],
+                "disambiguation_pending": state.get("disambiguation_pending", [])[-10:],
+            },
+        }
+
+    def _load_state(self) -> Dict[str, Any]:
+        path = self.config.state_file
+        if not path.exists():
+            return {}
+        return json.loads(path.read_text(encoding="utf-8"))
+
+    def _load_outline(self, chapter: int) -> str:
+        outline_dir = self.config.outline_dir
+        patterns = [
+            f"第{chapter}章*.md",
+            f"第{chapter:02d}章*.md",
+            f"第{chapter:03d}章*.md",
+            f"第{chapter:04d}章*.md",
+        ]
+        for pattern in patterns:
+            matches = list(outline_dir.glob(pattern))
+            if matches:
+                return matches[0].read_text(encoding="utf-8")
+        return f"[大纲未找到: 第{chapter}章]"
+
+    def _load_recent_summaries(self, chapter: int, window: int = 3) -> List[Dict[str, Any]]:
+        summaries = []
+        summaries_dir = self.config.webnovel_dir / "summaries"
+        for ch in range(max(1, chapter - window), chapter):
+            path = summaries_dir / f"ch{ch:04d}.md"
+            if path.exists():
+                summaries.append({"chapter": ch, "summary": path.read_text(encoding="utf-8")})
+        return summaries
+
+    def _load_recent_meta(self, state: Dict[str, Any], chapter: int, window: int = 3) -> List[Dict[str, Any]]:
+        meta = state.get("chapter_meta", {}) or {}
+        results = []
+        for ch in range(max(1, chapter - window), chapter):
+            for key in (f"{ch:04d}", str(ch)):
+                if key in meta:
+                    results.append({"chapter": ch, **meta.get(key, {})})
+                    break
+        return results
+
+    def _load_recent_appearances(self) -> List[Dict[str, Any]]:
+        appearances = self.index_manager.get_recent_appearances()
+        return appearances or []
+
+    def _load_setting(self, keyword: str) -> str:
+        settings_dir = self.config.settings_dir
+        candidates = [
+            settings_dir / f"{keyword}.md",
+        ]
+        for path in candidates:
+            if path.exists():
+                return path.read_text(encoding="utf-8")
+        # fallback: any file containing keyword
+        matches = list(settings_dir.glob(f"*{keyword}*.md"))
+        if matches:
+            return matches[0].read_text(encoding="utf-8")
+        return f"[{keyword}设定未找到]"
+
+    def _load_json_optional(self, path: Path) -> Dict[str, Any]:
+        if not path.exists():
+            return {}
+        try:
+            return json.loads(path.read_text(encoding="utf-8"))
+        except json.JSONDecodeError:
+            return {}
+
+
+def main():
+    import argparse
+    from .cli_output import print_success, print_error
+
+    parser = argparse.ArgumentParser(description="Context Manager CLI")
+    parser.add_argument("--project-root", type=str, help="项目根目录")
+    parser.add_argument("--chapter", type=int, required=True)
+    parser.add_argument("--template", type=str, default=ContextManager.DEFAULT_TEMPLATE)
+    parser.add_argument("--no-snapshot", action="store_true")
+    parser.add_argument("--max-chars", type=int, default=8000)
+
+    args = parser.parse_args()
+
+    config = None
+    if args.project_root:
+        from .config import DataModulesConfig
+
+        config = DataModulesConfig.from_project_root(args.project_root)
+
+    manager = ContextManager(config)
+    try:
+        payload = manager.build_context(
+            chapter=args.chapter,
+            template=args.template,
+            use_snapshot=not args.no_snapshot,
+            save_snapshot=True,
+            max_chars=args.max_chars,
+        )
+        print_success(payload, message="context_built")
+        try:
+            manager.index_manager.log_tool_call("context_manager:build", True, chapter=args.chapter)
+        except Exception:
+            pass
+    except Exception as exc:
+        print_error("CONTEXT_BUILD_FAILED", str(exc), suggestion="请检查项目结构与依赖文件")
+        try:
+            manager.index_manager.log_tool_call(
+                "context_manager:build", False, error_code="CONTEXT_BUILD_FAILED", error_message=str(exc), chapter=args.chapter
+            )
+        except Exception:
+            pass
+
+
+if __name__ == "__main__":
+    main()

+ 28 - 0
.claude/scripts/data_modules/query_router.py

@@ -0,0 +1,28 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""Query router for RAG requests."""
+from __future__ import annotations
+
+import re
+from typing import List
+
+
+class QueryRouter:
+    def __init__(self):
+        self.patterns = {
+            "entity": [r"人物", r"角色", r"谁", r"身份", r"别名"],
+            "scene": [r"地点", r"场景", r"哪里", r"位置"],
+            "setting": [r"设定", r"规则", r"体系", r"世界观"],
+            "plot": [r"剧情", r"发生", r"事件", r"经过"],
+        }
+
+    def route(self, query: str) -> str:
+        for qtype, patterns in self.patterns.items():
+            for pat in patterns:
+                if re.search(pat, query):
+                    return qtype
+        return "plot"
+
+    def split(self, query: str) -> List[str]:
+        parts = re.split(r"[,,;;以及和]\s*", query)
+        return [p.strip() for p in parts if p.strip()]

+ 75 - 0
.claude/scripts/data_modules/snapshot_manager.py

@@ -0,0 +1,75 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+Context snapshot manager.
+"""
+from __future__ import annotations
+
+import json
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any, Dict, Optional
+
+from .config import get_config
+
+SNAPSHOT_VERSION = "1.0"
+
+
+class SnapshotVersionMismatch(RuntimeError):
+    def __init__(self, expected: str, actual: str) -> None:
+        super().__init__(f"snapshot version mismatch: expected {expected}, got {actual}")
+        self.expected = expected
+        self.actual = actual
+
+
+@dataclass
+class SnapshotMeta:
+    chapter: int
+    version: str
+    saved_at: str
+
+
+class SnapshotManager:
+    def __init__(self, config=None, version: str = SNAPSHOT_VERSION):
+        self.config = config or get_config()
+        self.version = version
+        self.snapshot_dir = self.config.webnovel_dir / "context_snapshots"
+        self.snapshot_dir.mkdir(parents=True, exist_ok=True)
+
+    def _snapshot_path(self, chapter: int) -> Path:
+        return self.snapshot_dir / f"ch{chapter:04d}.json"
+
+    def save_snapshot(self, chapter: int, payload: Dict[str, Any], meta: Optional[Dict[str, Any]] = None) -> Path:
+        data: Dict[str, Any] = {
+            "version": self.version,
+            "chapter": chapter,
+            "saved_at": datetime.now(timezone.utc).isoformat(),
+            "payload": payload,
+        }
+        if meta:
+            data["meta"] = meta
+
+        path = self._snapshot_path(chapter)
+        path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
+        return path
+
+    def load_snapshot(self, chapter: int) -> Optional[Dict[str, Any]]:
+        path = self._snapshot_path(chapter)
+        if not path.exists():
+            return None
+        data = json.loads(path.read_text(encoding="utf-8"))
+        version = str(data.get("version", ""))
+        if version != self.version:
+            raise SnapshotVersionMismatch(self.version, version)
+        return data
+
+    def delete_snapshot(self, chapter: int) -> bool:
+        path = self._snapshot_path(chapter)
+        if path.exists():
+            path.unlink()
+            return True
+        return False
+
+    def list_snapshots(self) -> list[str]:
+        return sorted(p.name for p in self.snapshot_dir.glob("ch*.json"))

+ 90 - 0
.claude/scripts/data_modules/tests/test_context_manager.py

@@ -0,0 +1,90 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+ContextManager and SnapshotManager tests
+"""
+
+import json
+
+import pytest
+
+from data_modules.config import DataModulesConfig
+from data_modules.index_manager import IndexManager, EntityMeta
+from data_modules.context_manager import ContextManager
+from data_modules.snapshot_manager import SnapshotManager, SnapshotVersionMismatch
+from data_modules.query_router import QueryRouter
+
+
+@pytest.fixture
+def temp_project(tmp_path):
+    cfg = DataModulesConfig.from_project_root(tmp_path)
+    cfg.ensure_dirs()
+    return cfg
+
+
+def test_snapshot_manager_roundtrip(temp_project):
+    manager = SnapshotManager(temp_project)
+    payload = {"hello": "world"}
+    manager.save_snapshot(1, payload)
+    loaded = manager.load_snapshot(1)
+    assert loaded["payload"] == payload
+
+
+def test_snapshot_version_mismatch(temp_project):
+    manager = SnapshotManager(temp_project, version="1.0")
+    manager.save_snapshot(1, {"a": 1})
+    other = SnapshotManager(temp_project, version="2.0")
+    with pytest.raises(SnapshotVersionMismatch):
+        other.load_snapshot(1)
+
+
+def test_context_manager_build_and_filter(temp_project):
+    state = {
+        "protagonist_state": {"name": "萧炎", "location": {"current": "天云宗"}},
+        "chapter_meta": {"0001": {"hook": "测试"}},
+    }
+    temp_project.state_file.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
+
+    # preferences and memory
+    (temp_project.webnovel_dir / "preferences.json").write_text(json.dumps({"tone": "热血"}, ensure_ascii=False), encoding="utf-8")
+    (temp_project.webnovel_dir / "project_memory.json").write_text(json.dumps({"patterns": []}, ensure_ascii=False), encoding="utf-8")
+
+    idx = IndexManager(temp_project)
+    idx.upsert_entity(
+        EntityMeta(
+            id="xiaoyan",
+            type="角色",
+            canonical_name="萧炎",
+            current={},
+            first_appearance=1,
+            last_appearance=1,
+        )
+    )
+    idx.upsert_entity(
+        EntityMeta(
+            id="bad",
+            type="角色",
+            canonical_name="坏人",
+            current={},
+            first_appearance=1,
+            last_appearance=1,
+        )
+    )
+    idx.record_appearance("xiaoyan", 1, ["萧炎"], 1.0)
+    idx.record_appearance("bad", 1, ["坏人"], 1.0)
+    invalid_id = idx.mark_invalid_fact("entity", "bad", "错误")
+    idx.resolve_invalid_fact(invalid_id, "confirm")
+
+    manager = ContextManager(temp_project)
+    payload = manager.build_context(1, use_snapshot=False, save_snapshot=False)
+    characters = payload["sections"]["scene"]["content"]["appearing_characters"]
+    assert any(c.get("entity_id") == "xiaoyan" for c in characters)
+    assert not any(c.get("entity_id") == "bad" for c in characters)
+    assert payload["sections"]["preferences"]["content"].get("tone") == "热血"
+
+
+def test_query_router():
+    router = QueryRouter()
+    assert router.route("角色是谁") == "entity"
+    assert router.route("发生了什么剧情") == "plot"
+    assert "A" in router.split("A, B;C")

+ 39 - 0
.claude/skills/webnovel-learn/SKILL.md

@@ -0,0 +1,39 @@
+---
+name: webnovel-learn
+description: 从当前会话提取成功模式并写入 project_memory.json
+allowed-tools: Read Write Bash
+---
+
+# /webnovel-learn
+
+## 目标
+- 提取可复用的写作模式(钩子/节奏/对话/微兑现等)
+- 追加到 `.webnovel/project_memory.json`
+
+## 输入
+```bash
+/webnovel-learn "本章的危机钩设计很有效,悬念拉满"
+```
+
+## 输出
+```json
+{
+  "status": "success",
+  "learned": {
+    "pattern_type": "hook",
+    "description": "危机钩设计:悬念拉满",
+    "source_chapter": 100,
+    "learned_at": "2026-02-02T12:00:00Z"
+  }
+}
+```
+
+## 执行流程
+1. 读取 `.webnovel/state.json`,获取当前章节号(progress.current_chapter)
+2. 读取 `.webnovel/project_memory.json`,若不存在则初始化 `{"patterns": []}`
+3. 解析用户输入,归类 pattern_type(hook/pacing/dialogue/payoff/emotion)
+4. 追加记录并写回文件
+
+## 约束
+- 不删除旧记录,仅追加
+- 避免完全重复的 description(可去重)