Explorar el Código

fix: support volume outline snapshots and bump plugin version

lingfengQAQ hace 3 meses
padre
commit
81f2031c7e

+ 1 - 1
.claude-plugin/marketplace.json

@@ -11,7 +11,7 @@
     {
       "name": "webnovel-writer",
       "description": "长篇网文创作系统(skills + agents + data chain + RAG)",
-      "version": "5.5.0",
+      "version": "5.5.1",
       "author": {
         "name": "lingfengQAQ"
       },

+ 2 - 2
README.md

@@ -111,13 +111,13 @@ model: sonnet
 
 | 版本 | 说明 |
 |------|------|
-| **v5.5.0 (当前)** | 新增只读可视化 Dashboard Skill(`/webnovel-dashboard`)与实时刷新能力;支持插件目录启动与预构建前端分发 |
+| **v5.5.1 (当前)** | 修复卷级单文件大纲在上下文快照中的章节提取问题;补齐命令文档中遗漏的 `/webnovel-dashboard` 与 `/webnovel-learn`。 |
+| **v5.5.0** | 新增只读可视化 Dashboard Skill(`/webnovel-dashboard`)与实时刷新能力;支持插件目录启动与预构建前端分发 |
 | **v5.4.4** | 引入官方 Plugin Marketplace 安装机制;统一修复 Skills/Agents/References 的 CLI 调用(`CLAUDE_PLUGIN_ROOT` 单路径,透传命令统一 `--`) |
 | **v5.4.3** | 增强智能 RAG 上下文辅助(`auto/graph_hybrid` 回退 BM25) |
 | **v5.3** | 引入追读力系统(Hook / Cool-point / 微兑现 / 债务追踪) |
 
 ## 开源协议
-
 本项目使用 `GPL v3` 协议,详见 `LICENSE`。
 
 ## Star 历史

+ 31 - 2
docs/commands.md

@@ -12,7 +12,7 @@
 
 ## `/webnovel-plan [卷号]`
 
-用途:生成卷级规划与节拍
+用途:生成卷级规划与章节大纲
 
 示例:
 
@@ -23,7 +23,7 @@
 
 ## `/webnovel-write [章号]`
 
-用途:执行完整章节创作流水线(上下文 → 草稿 → 审查 → 数据落盘)。
+用途:执行完整章节创作流程(上下文 → 草稿 → 审查 → 润色 → 数据落盘)。
 
 示例:
 
@@ -70,3 +70,32 @@
 ```bash
 /webnovel-resume
 ```
+
+## `/webnovel-dashboard`
+
+用途:启动只读可视化面板,查看项目状态、实体关系、章节与大纲内容。
+
+示例:
+
+```bash
+/webnovel-dashboard
+```
+
+说明:
+
+- 默认只读,不会修改项目文件
+- 适合排查上下文、实体关系和章节进度
+
+## `/webnovel-learn [内容]`
+
+用途:从当前会话或用户输入中提取可复用写作模式,并写入项目记忆。
+
+示例:
+
+```bash
+/webnovel-learn "本章的危机钩设计很有效,悬念拉满"
+```
+
+产出:
+
+- `.webnovel/project_memory.json`

+ 129 - 0
webnovel-writer/scripts/chapter_outline_loader.py

@@ -0,0 +1,129 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+from __future__ import annotations
+
+import json
+import re
+from pathlib import Path
+
+try:
+    from chapter_paths import volume_num_for_chapter
+except ImportError:  # pragma: no cover
+    from scripts.chapter_paths import volume_num_for_chapter
+
+
+_CHAPTER_RANGE_RE = re.compile(r"^\s*(\d+)\s*-\s*(\d+)\s*$")
+
+
+def _parse_chapters_range(value: object) -> tuple[int, int] | None:
+    if not isinstance(value, str):
+        return None
+    match = _CHAPTER_RANGE_RE.match(value)
+    if not match:
+        return None
+    try:
+        start = int(match.group(1))
+        end = int(match.group(2))
+    except ValueError:
+        return None
+    if start <= 0 or end <= 0 or start > end:
+        return None
+    return start, end
+
+
+def volume_num_for_chapter_from_state(project_root: Path, chapter_num: int) -> int | None:
+    state_path = project_root / ".webnovel" / "state.json"
+    if not state_path.exists():
+        return None
+
+    try:
+        state = json.loads(state_path.read_text(encoding="utf-8"))
+    except Exception:
+        return None
+
+    if not isinstance(state, dict):
+        return None
+
+    progress = state.get("progress")
+    if not isinstance(progress, dict):
+        return None
+
+    volumes_planned = progress.get("volumes_planned")
+    if not isinstance(volumes_planned, list):
+        return None
+
+    best: tuple[int, int] | None = None
+    for item in volumes_planned:
+        if not isinstance(item, dict):
+            continue
+        volume = item.get("volume")
+        if not isinstance(volume, int) or volume <= 0:
+            continue
+        parsed = _parse_chapters_range(item.get("chapters_range"))
+        if not parsed:
+            continue
+        start, end = parsed
+        if start <= chapter_num <= end:
+            candidate = (start, volume)
+            if best is None or candidate[0] > best[0] or (candidate[0] == best[0] and candidate[1] < best[1]):
+                best = candidate
+
+    return best[1] if best else None
+
+
+def _find_split_outline_file(outline_dir: Path, chapter_num: int) -> Path | None:
+    patterns = [
+        f"第{chapter_num}章*.md",
+        f"第{chapter_num:02d}章*.md",
+        f"第{chapter_num:03d}章*.md",
+        f"第{chapter_num:04d}章*.md",
+    ]
+    for pattern in patterns:
+        matches = sorted(outline_dir.glob(pattern))
+        if matches:
+            return matches[0]
+    return None
+
+
+def _find_volume_outline_file(project_root: Path, chapter_num: int) -> Path | None:
+    outline_dir = project_root / "大纲"
+    volume_num = volume_num_for_chapter_from_state(project_root, chapter_num) or volume_num_for_chapter(chapter_num)
+    candidates = [
+        outline_dir / f"第{volume_num}卷-详细大纲.md",
+        outline_dir / f"第{volume_num}卷 - 详细大纲.md",
+        outline_dir / f"第{volume_num}卷 详细大纲.md",
+    ]
+    return next((path for path in candidates if path.exists()), None)
+
+
+def _extract_outline_section(content: str, chapter_num: int) -> str | None:
+    patterns = [
+        rf"###\s*第\s*{chapter_num}\s*章[::]\s*(.+?)(?=###\s*第\s*\d+\s*章|##\s|$)",
+        rf"###\s*第{chapter_num}章[::]\s*(.+?)(?=###\s*第\d+章|##\s|$)",
+    ]
+    for pattern in patterns:
+        match = re.search(pattern, content, re.DOTALL)
+        if match:
+            return match.group(0).strip()
+    return None
+
+
+def load_chapter_outline(project_root: Path, chapter_num: int, max_chars: int | None = 1500) -> str:
+    outline_dir = project_root / "大纲"
+
+    split_outline = _find_split_outline_file(outline_dir, chapter_num)
+    if split_outline is not None:
+        return split_outline.read_text(encoding="utf-8")
+
+    volume_outline = _find_volume_outline_file(project_root, chapter_num)
+    if volume_outline is None:
+        return f"⚠️ 大纲文件不存在:第 {chapter_num} 章"
+
+    outline = _extract_outline_section(volume_outline.read_text(encoding="utf-8"), chapter_num)
+    if outline is None:
+        return f"⚠️ 未找到第 {chapter_num} 章的大纲"
+
+    if max_chars and len(outline) > max_chars:
+        return outline[:max_chars] + "\n...(已截断)"
+    return outline

+ 6 - 12
webnovel-writer/scripts/data_modules/context_manager.py

@@ -14,6 +14,11 @@ from pathlib import Path
 from runtime_compat import enable_windows_utf8_stdio
 from typing import Any, Dict, List, Optional
 
+try:
+    from chapter_outline_loader import load_chapter_outline
+except ImportError:  # pragma: no cover
+    from scripts.chapter_outline_loader import load_chapter_outline
+
 from .config import get_config
 from .index_manager import IndexManager, WritingChecklistScoreMeta
 from .context_ranker import ContextRanker
@@ -633,18 +638,7 @@ class ContextManager:
         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}章]"
+        return load_chapter_outline(self.config.project_root, chapter, max_chars=1500)
 
     def _load_recent_summaries(self, chapter: int, window: int = 3) -> List[Dict[str, Any]]:
         summaries = []

+ 1 - 1
webnovel-writer/scripts/data_modules/snapshot_manager.py

@@ -21,7 +21,7 @@ except ImportError:  # pragma: no cover
     # 当以 python -m scripts.data_modules... 形式运行
     from scripts.security_utils import atomic_write_json
 
-SNAPSHOT_VERSION = "1.1"
+SNAPSHOT_VERSION = "1.2"
 
 
 class SnapshotVersionMismatch(RuntimeError):

+ 27 - 0
webnovel-writer/scripts/data_modules/tests/test_context_manager.py

@@ -97,6 +97,33 @@ def test_context_manager_build_and_filter(temp_project):
     assert payload["sections"]["preferences"]["content"].get("tone") == "热血"
 
 
+def test_context_manager_loads_volume_outline_file(temp_project):
+    state = {
+        "progress": {
+            "volumes_planned": [
+                {"volume": 1, "chapters_range": "1-10"},
+            ]
+        },
+        "protagonist_state": {},
+        "chapter_meta": {},
+        "disambiguation_warnings": [],
+        "disambiguation_pending": [],
+    }
+    temp_project.state_file.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
+    temp_project.outline_dir.mkdir(parents=True, exist_ok=True)
+    (temp_project.outline_dir / "第1卷-详细大纲.md").write_text(
+        "### 第2章:测试标题\n测试大纲\n\n### 第3章:下一章",
+        encoding="utf-8",
+    )
+
+    manager = ContextManager(temp_project)
+    payload = manager.build_context(2, use_snapshot=False, save_snapshot=False)
+
+    outline = payload["sections"]["core"]["content"]["chapter_outline"]
+    assert "### 第2章:测试标题" in outline
+    assert "测试大纲" in outline
+
+
 def test_query_router():
     router = QueryRouter()
     assert router.route("角色是谁") == "entity"

+ 6 - 90
webnovel-writer/scripts/extract_chapter_context.py

@@ -20,12 +20,14 @@ import sys
 from pathlib import Path
 from typing import Any, Dict, List
 
+from chapter_outline_loader import load_chapter_outline
+
 from runtime_compat import enable_windows_utf8_stdio
 
 try:
-    from chapter_paths import find_chapter_file, volume_num_for_chapter
+    from chapter_paths import find_chapter_file
 except ImportError:  # pragma: no cover
-    from scripts.chapter_paths import find_chapter_file, volume_num_for_chapter
+    from scripts.chapter_paths import find_chapter_file
 
 
 def _ensure_scripts_path():
@@ -34,7 +36,6 @@ def _ensure_scripts_path():
         sys.path.insert(0, str(scripts_dir))
 
 
-_CHAPTER_RANGE_RE = re.compile(r"^\s*(\d+)\s*-\s*(\d+)\s*$")
 _RAG_TRIGGER_KEYWORDS = (
     "关系",
     "恩怨",
@@ -53,66 +54,6 @@ _RAG_TRIGGER_KEYWORDS = (
 )
 
 
-def _parse_chapters_range(value: Any) -> tuple[int, int] | None:
-    if not isinstance(value, str):
-        return None
-    m = _CHAPTER_RANGE_RE.match(value)
-    if not m:
-        return None
-    try:
-        start = int(m.group(1))
-        end = int(m.group(2))
-    except ValueError:
-        return None
-    if start <= 0 or end <= 0 or start > end:
-        return None
-    return start, end
-
-
-def _volume_num_for_chapter_from_state(project_root: Path, chapter_num: int) -> int | None:
-    """
-    Prefer `.webnovel/state.json.progress.volumes_planned[].chapters_range` mapping.
-
-    Fallback is handled by caller (typically 50 chapters per volume).
-    """
-    state_path = project_root / ".webnovel" / "state.json"
-    if not state_path.exists():
-        return None
-    try:
-        state = json.loads(state_path.read_text(encoding="utf-8"))
-    except Exception:
-        return None
-
-    if not isinstance(state, dict):
-        return None
-
-    progress = state.get("progress")
-    if not isinstance(progress, dict):
-        return None
-
-    volumes_planned = progress.get("volumes_planned")
-    if not isinstance(volumes_planned, list):
-        return None
-
-    best: tuple[int, int] | None = None  # (start, volume) - prefer the latest start if overlaps exist
-    for item in volumes_planned:
-        if not isinstance(item, dict):
-            continue
-        volume = item.get("volume")
-        if not isinstance(volume, int) or volume <= 0:
-            continue
-        parsed = _parse_chapters_range(item.get("chapters_range"))
-        if not parsed:
-            continue
-        start, end = parsed
-        if start <= chapter_num <= end:
-            cand = (start, volume)
-            if best is None or cand[0] > best[0] or (cand[0] == best[0] and cand[1] < best[1]):
-                best = cand
-
-    return best[1] if best else None
-
-
 def find_project_root(start_path: Path | None = None) -> Path:
     """解析真实书项目根(包含 `.webnovel/state.json` 的目录)。"""
     from project_locator import resolve_project_root
@@ -124,33 +65,7 @@ def find_project_root(start_path: Path | None = None) -> Path:
 
 def extract_chapter_outline(project_root: Path, chapter_num: int) -> str:
     """Extract chapter outline segment from volume outline file."""
-    volume_num = _volume_num_for_chapter_from_state(project_root, chapter_num) or volume_num_for_chapter(chapter_num)
-    outline_candidates = [
-        project_root / "大纲" / f"第{volume_num}卷-详细大纲.md",
-        project_root / "大纲" / f"第{volume_num}卷 详细大纲.md",
-        project_root / "大纲" / f"第{volume_num}卷详细大纲.md",
-    ]
-    outline_file = next((p for p in outline_candidates if p.exists()), None)
-
-    if outline_file is None:
-        tried = " / ".join(str(p) for p in outline_candidates)
-        return f"⚠️ 大纲文件不存在,已尝试: {tried}"
-
-    content = outline_file.read_text(encoding="utf-8")
-
-    pattern = rf"###\s*第\s*{chapter_num}\s*章[::]\s*(.+?)(?=###\s*第\s*\d+\s*章|##\s|$)"
-    match = re.search(pattern, content, re.DOTALL)
-    if not match:
-        pattern2 = rf"###\s*第{chapter_num}章[::]\s*(.+?)(?=###\s*第\d+章|##\s|$)"
-        match = re.search(pattern2, content, re.DOTALL)
-
-    if match:
-        outline = match.group(0).strip()
-        if len(outline) > 1500:
-            outline = outline[:1500] + "\n...(已截断)"
-        return outline
-
-    return f"⚠️ 未找到第 {chapter_num} 章的大纲"
+    return load_chapter_outline(project_root, chapter_num, max_chars=1500)
 
 
 def _load_summary_file(project_root: Path, chapter_num: int) -> str:
@@ -618,3 +533,4 @@ if __name__ == "__main__":
     if sys.platform == "win32":
         enable_windows_utf8_stdio()
     main()
+