Переглянути джерело

fix: sync chapter titles into filenames and bump plugin version

lingfengQAQ 3 місяців тому
батько
коміт
e8a0efac48

+ 1 - 1
README.md

@@ -111,7 +111,7 @@ model: sonnet
 
 | 版本 | 说明 |
 |------|------|
-| **v5.5.2 (当前)** | fix: support volume outline snapshots and bump plugin version |
+| **v5.5.2 (当前)** | 支持将详细大纲中的章节名同步到正文文件名;修复 workflow_manager 在无参 find_project_root monkeypatch 下的兼容性问题。 |
 | **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` 单路径,透传命令统一 `--`) |

+ 76 - 5
webnovel-writer/scripts/chapter_paths.py

@@ -17,6 +17,8 @@ from typing import Optional
 
 
 _CHAPTER_NUM_RE = re.compile(r"第(?P<num>\d+)章")
+_OUTLINE_HEADING_RE = re.compile(r"^#{1,6}\s*第\s*(?P<num>\d+)\s*章[::]\s*(?P<title>.+?)\s*$", re.MULTILINE)
+_SPLIT_OUTLINE_FILENAME_RE = re.compile(r"^第0*(?P<num>\d+)章[-—_ ]+(?P<title>.+?)\.md$")
 
 
 def volume_num_for_chapter(chapter_num: int, *, chapters_per_volume: int = 50) -> int:
@@ -35,6 +37,75 @@ def extract_chapter_num_from_filename(filename: str) -> Optional[int]:
         return None
 
 
+def _safe_title_for_filename(title: str) -> str:
+    cleaned = title.strip()
+    if not cleaned:
+        return ""
+
+    try:
+        from security_utils import sanitize_filename
+    except ImportError:  # pragma: no cover
+        from scripts.security_utils import sanitize_filename
+
+    safe_title = sanitize_filename(cleaned, max_length=60)
+    return "" if safe_title == "unnamed_entity" else safe_title
+
+
+def _extract_title_from_outline_text(outline_text: str, chapter_num: int) -> str:
+    for match in _OUTLINE_HEADING_RE.finditer(outline_text):
+        if int(match.group("num")) != chapter_num:
+            continue
+        return _safe_title_for_filename(match.group("title"))
+    return ""
+
+
+def _extract_title_from_split_outline_filename(outline_dir: Path, chapter_num: int) -> str:
+    patterns = [
+        f"第{chapter_num}章*.md",
+        f"第{chapter_num:02d}章*.md",
+        f"第{chapter_num:03d}章*.md",
+        f"第{chapter_num:04d}章*.md",
+    ]
+    for pattern in patterns:
+        for path in sorted(outline_dir.glob(pattern)):
+            match = _SPLIT_OUTLINE_FILENAME_RE.match(path.name)
+            if not match:
+                continue
+            if int(match.group("num")) != chapter_num:
+                continue
+            title = _safe_title_for_filename(match.group("title"))
+            if title:
+                return title
+    return ""
+
+
+def extract_chapter_title(project_root: Path, chapter_num: int) -> str:
+    """从详细大纲提取章节标题,用于生成更直观的章节文件名。"""
+    try:
+        from chapter_outline_loader import load_chapter_outline
+    except ImportError:  # pragma: no cover
+        from scripts.chapter_outline_loader import load_chapter_outline
+
+    outline_text = load_chapter_outline(project_root, chapter_num, max_chars=None)
+    if not outline_text.startswith("⚠️"):
+        title = _extract_title_from_outline_text(outline_text, chapter_num)
+        if title:
+            return title
+
+    outline_dir = project_root / "大纲"
+    if outline_dir.exists():
+        return _extract_title_from_split_outline_filename(outline_dir, chapter_num)
+    return ""
+
+
+def _build_chapter_filename(project_root: Path, chapter_num: int, *, use_volume_layout: bool) -> str:
+    padded = f"{chapter_num:03d}" if use_volume_layout else f"{chapter_num:04d}"
+    title = extract_chapter_title(project_root, chapter_num)
+    if title:
+        return f"第{padded}章-{title}.md"
+    return f"第{padded}章.md"
+
+
 def find_chapter_file(project_root: Path, chapter_num: int) -> Optional[Path]:
     """
     Find an existing chapter file for chapter_num under project_root/正文.
@@ -71,14 +142,14 @@ def default_chapter_draft_path(project_root: Path, chapter_num: int, *, use_volu
     Args:
         project_root: 项目根目录
         chapter_num: 章节号
-        use_volume_layout: True 使用卷布局 (正文/第N卷/第NNN章.md),False 使用平坦布局 (正文/第NNNN章.md)
+        use_volume_layout: True 使用卷布局 (正文/第N卷/第NNN章-章节标题.md),False 使用平坦布局 (正文/第NNNN章-章节标题.md)
 
-    Default is flat layout to match SKILL.md documentation.
+    Default is flat layout. If the detailed outline already has a chapter title,
+    append it to the filename for better discoverability.
     """
     if use_volume_layout:
         vol_dir = project_root / "正文" / f"第{volume_num_for_chapter(chapter_num)}卷"
-        return vol_dir / f"第{chapter_num:03d}章.md"
+        return vol_dir / _build_chapter_filename(project_root, chapter_num, use_volume_layout=True)
     else:
-        # Flat layout: 正文/第NNNN章.md (matches SKILL.md)
-        return project_root / "正文" / f"第{chapter_num:04d}章.md"
+        return project_root / "正文" / _build_chapter_filename(project_root, chapter_num, use_volume_layout=False)
 

+ 51 - 0
webnovel-writer/scripts/data_modules/tests/test_chapter_paths.py

@@ -0,0 +1,51 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+import sys
+from pathlib import Path
+
+
+def _load_module():
+    scripts_dir = Path(__file__).resolve().parents[2]
+    if str(scripts_dir) not in sys.path:
+        sys.path.insert(0, str(scripts_dir))
+
+    import chapter_paths
+
+    return chapter_paths
+
+
+def test_default_chapter_draft_path_uses_outline_heading_title(tmp_path):
+    module = _load_module()
+
+    outline_dir = tmp_path / "大纲"
+    outline_dir.mkdir(parents=True, exist_ok=True)
+    (outline_dir / "第1卷-详细大纲.md").write_text("### 第1章:测试标题\n测试大纲", encoding="utf-8")
+
+    draft_path = module.default_chapter_draft_path(tmp_path, 1)
+
+    assert draft_path.name == "第0001章-测试标题.md"
+
+
+def test_default_chapter_draft_path_falls_back_to_split_outline_filename(tmp_path):
+    module = _load_module()
+
+    outline_dir = tmp_path / "大纲"
+    outline_dir.mkdir(parents=True, exist_ok=True)
+    (outline_dir / "第0002章-标题 文件.md").write_text("无章节标题 heading", encoding="utf-8")
+
+    draft_path = module.default_chapter_draft_path(tmp_path, 2)
+
+    assert draft_path.name == "第0002章-标题_文件.md"
+
+
+def test_find_chapter_file_supports_titled_flat_filename(tmp_path):
+    module = _load_module()
+
+    chapter_path = tmp_path / "正文" / "第0003章-山雨欲来.md"
+    chapter_path.parent.mkdir(parents=True, exist_ok=True)
+    chapter_path.write_text("正文", encoding="utf-8")
+
+    found = module.find_chapter_file(tmp_path, 3)
+
+    assert found == chapter_path

+ 9 - 0
webnovel-writer/scripts/data_modules/tests/test_workflow_manager.py

@@ -118,6 +118,15 @@ def test_safe_append_call_trace_logs_failure(monkeypatch, caplog):
     assert "unit_test_event" in message_text
 
 
+def test_get_workflow_paths_support_zero_arg_find_project_root(tmp_path, monkeypatch):
+    module = _load_module()
+    monkeypatch.setattr(module, "_cli_project_root", None)
+    monkeypatch.setattr(module, "find_project_root", lambda: tmp_path)
+
+    assert module.get_workflow_state_path() == tmp_path / ".webnovel" / "workflow_state.json"
+    assert module.get_call_trace_path() == tmp_path / ".webnovel" / "observability" / "call_trace.jsonl"
+
+
 def test_workflow_reentry_does_not_duplicate_history(tmp_path, monkeypatch):
     module = _load_module()
     monkeypatch.setattr(module, "find_project_root", lambda: tmp_path)

+ 9 - 2
webnovel-writer/scripts/workflow_manager.py

@@ -63,14 +63,21 @@ def find_project_root(override: Optional[Path] = None) -> Path:
 _cli_project_root: Optional[Path] = None
 
 
+def _get_active_project_root() -> Path:
+    """Resolve workflow paths while兼容测试中无参 monkeypatch。"""
+    if _cli_project_root is not None:
+        return find_project_root(_cli_project_root)
+    return find_project_root()
+
+
 def get_workflow_state_path() -> Path:
     """Absolute path to workflow_state.json."""
-    project_root = find_project_root(_cli_project_root)
+    project_root = _get_active_project_root()
     return project_root / ".webnovel" / "workflow_state.json"
 
 
 def get_call_trace_path() -> Path:
-    project_root = find_project_root(_cli_project_root)
+    project_root = _get_active_project_root()
     return project_root / ".webnovel" / "observability" / "call_trace.jsonl"
 
 

+ 3 - 3
webnovel-writer/skills/webnovel-write/SKILL.md

@@ -189,7 +189,7 @@ cat "${SKILL_ROOT}/../../references/shared/core-constraints.md"
 ```
 
 硬要求:
-- 只输出纯正文到 `正文/第{chapter_padded}章.md`。
+- 只输出纯正文到章节正文文件;若详细大纲已有章节名,优先使用 `正文/第{chapter_padded}章-{title_safe}.md`,否则回退为 `正文/第{chapter_padded}章.md`。
 - 默认按 2000-2500 字执行;若大纲为关键战斗章/高潮章/卷末章或用户明确指定,则按大纲/用户优先。
 - 禁止占位符正文(如 `[TODO]`、`[待补充]`)。
 - 保留承接关系:若上章有明确钩子,本章必须回应(可部分兑现)。
@@ -269,7 +269,7 @@ cat "${SKILL_ROOT}/references/writing/typesetting.md"
 
 使用 Task 调用 `data-agent`,参数:
 - `chapter`
-- `chapter_file="正文/第{chapter_padded}章.md"`
+- `chapter_file` 必须传入实际章节文件路径;若详细大纲已有章节名,优先传 `正文/第{chapter_padded}章-{title_safe}.md`,否则传 `正文/第{chapter_padded}章.md`
 - `review_score=Step 3 overall_score`
 - `project_root`
 - `storage_path=.webnovel/`
@@ -302,7 +302,7 @@ git commit -m "Ch{chapter_num}: {title}"
 
 未满足以下条件前,不得结束流程:
 
-1. 章节正文文件存在且非空:`正文/第{chapter_padded}章.md`
+1. 章节正文文件存在且非空:`正文/第{chapter_padded}章-{title_safe}.md` 或 `正文/第{chapter_padded}章.md`
 2. Step 3 已产出 `overall_score` 且 `review_metrics` 成功落库
 3. Step 4 已处理全部 `critical`,`high` 未修项有 deviation 记录
 4. Step 4 的 `anti_ai_force_check=pass`(基于全文检查;fail 时不得进入 Step 5)