Ver Fonte

fix: 安全写入项目经验记忆

lingfengQAQ há 1 mês atrás
pai
commit
d2571d294f

+ 1 - 1
webnovel-writer/scripts/data_modules/tests/test_prompt_integrity.py

@@ -33,7 +33,7 @@ REGISTERED_CLI_SUBCOMMANDS = {
     "where", "preflight", "use",
     "index", "state", "rag", "style", "entity", "context", "memory",
     "migrate", "status", "update-state", "backup", "archive",
-    "init", "extract-context", "memory-contract", "review-pipeline",
+    "init", "extract-context", "memory-contract", "project-memory", "review-pipeline",
     "story-system", "chapter-commit", "story-events", "knowledge",
 }
 

+ 78 - 0
webnovel-writer/scripts/data_modules/tests/test_webnovel_unified_cli.py

@@ -432,6 +432,84 @@ def test_review_pipeline_forwards_with_resolved_project_root(monkeypatch, tmp_pa
     ]
 
 
+def test_project_memory_forwards_with_resolved_project_root(monkeypatch, tmp_path):
+    module = _load_webnovel_module()
+
+    book_root = (tmp_path / "book").resolve()
+    called = {}
+
+    def _fake_resolve(explicit_project_root=None):
+        return book_root
+
+    def _fake_run_script(script_name, argv):
+        called["script_name"] = script_name
+        called["argv"] = list(argv)
+        return 0
+
+    monkeypatch.setattr(module, "_resolve_root", _fake_resolve)
+    monkeypatch.setattr(module, "_run_script", _fake_run_script)
+    monkeypatch.setattr(
+        sys,
+        "argv",
+        [
+            "webnovel",
+            "--project-root",
+            str(tmp_path),
+            "project-memory",
+            "add-pattern",
+            "--pattern-type",
+            "format",
+            "--description",
+            '内心独白使用双引号""',
+        ],
+    )
+
+    with pytest.raises(SystemExit) as exc:
+        module.main()
+
+    assert int(exc.value.code or 0) == 0
+    assert called["script_name"] == "project_memory.py"
+    assert called["argv"] == [
+        "--project-root",
+        str(book_root),
+        "add-pattern",
+        "--pattern-type",
+        "format",
+        "--description",
+        '内心独白使用双引号""',
+    ]
+
+
+def test_project_memory_add_pattern_escapes_quotes(tmp_path):
+    _ensure_scripts_on_path()
+    import project_memory as project_memory_module
+
+    project_root = (tmp_path / "book").resolve()
+    (project_root / ".webnovel").mkdir(parents=True, exist_ok=True)
+    (project_root / ".webnovel" / "state.json").write_text(
+        json.dumps({"progress": {"current_chapter": 3}}, ensure_ascii=False),
+        encoding="utf-8",
+    )
+
+    description = "正文格式规范:内心独白使用双引号\"\",系统界面保留方括号[]"
+    result = project_memory_module.add_pattern(
+        project_root,
+        pattern_type="format",
+        description=description,
+        category="写作规范",
+        importance="high",
+    )
+
+    memory_path = project_root / ".webnovel" / "project_memory.json"
+    raw_text = memory_path.read_text(encoding="utf-8")
+    payload = json.loads(raw_text)
+
+    assert result["status"] == "success"
+    assert '\\"\\"' in raw_text
+    assert payload["patterns"][0]["description"] == description
+    assert payload["patterns"][0]["source_chapter"] == 3
+
+
 def test_review_pipeline_main_creates_output_directories(tmp_path):
     _ensure_scripts_on_path()
     import review_pipeline as review_pipeline_module

+ 5 - 0
webnovel-writer/scripts/data_modules/webnovel.py

@@ -284,6 +284,9 @@ def main() -> None:
     p_memory_contract = sub.add_parser("memory-contract", help="转发到 memory_cli.py")
     p_memory_contract.add_argument("args", nargs=argparse.REMAINDER)
 
+    p_project_memory = sub.add_parser("project-memory", help="转发到 project_memory.py")
+    p_project_memory.add_argument("args", nargs=argparse.REMAINDER)
+
     p_review_pipeline = sub.add_parser("review-pipeline", help="转发到 review_pipeline.py")
     p_review_pipeline.add_argument("--chapter", type=int, required=True, help="目标章节号")
     p_review_pipeline.add_argument("--review-results", required=True, help="reviewer 原始结果 JSON 文件")
@@ -378,6 +381,8 @@ def main() -> None:
         raise SystemExit(_run_script("chapter_commit.py", return_args))
     if tool == "memory-contract":
         raise SystemExit(_run_script("memory_cli.py", [*forward_args, *rest]))
+    if tool == "project-memory":
+        raise SystemExit(_run_script("project_memory.py", [*forward_args, *rest]))
     if tool == "review-pipeline":
         return_args = [
             *forward_args,

+ 130 - 0
webnovel-writer/scripts/project_memory.py

@@ -0,0 +1,130 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""Project memory writer for /webnovel-learn."""
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any, Dict, Optional
+
+from runtime_compat import enable_windows_utf8_stdio
+from security_utils import atomic_write_json
+
+
+def _utc_now_iso() -> str:
+    return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
+
+
+def _load_json_required(path: Path) -> Dict[str, Any]:
+    if not path.exists():
+        return {}
+    try:
+        data = json.loads(path.read_text(encoding="utf-8"))
+    except json.JSONDecodeError as exc:
+        raise ValueError(f"JSON 解析失败: {path}: {exc}") from exc
+    if not isinstance(data, dict):
+        raise ValueError(f"JSON 顶层必须是 object: {path}")
+    return data
+
+
+def _current_chapter(project_root: Path) -> Optional[int]:
+    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 json.JSONDecodeError:
+        return None
+    progress = state.get("progress") if isinstance(state, dict) else {}
+    chapter = progress.get("current_chapter") if isinstance(progress, dict) else None
+    try:
+        return int(chapter) if chapter is not None else None
+    except (TypeError, ValueError):
+        return None
+
+
+def add_pattern(
+    project_root: Path,
+    *,
+    pattern_type: str,
+    description: str,
+    category: str = "",
+    importance: str = "medium",
+    source_chapter: Optional[int] = None,
+) -> Dict[str, Any]:
+    project_root = project_root.expanduser().resolve()
+    memory_path = project_root / ".webnovel" / "project_memory.json"
+    payload = _load_json_required(memory_path)
+    patterns = payload.setdefault("patterns", [])
+    if not isinstance(patterns, list):
+        raise ValueError(f"patterns 必须是数组: {memory_path}")
+
+    pattern_type = (pattern_type or "other").strip() or "other"
+    description = (description or "").strip()
+    if not description:
+        raise ValueError("description 不能为空")
+
+    for item in patterns:
+        if not isinstance(item, dict):
+            continue
+        if item.get("pattern_type") == pattern_type and item.get("description") == description:
+            return {"status": "skipped", "reason": "duplicate", "learned": item}
+
+    now = _utc_now_iso()
+    chapter = source_chapter if source_chapter is not None else _current_chapter(project_root)
+    learned: Dict[str, Any] = {
+        "pattern_type": pattern_type,
+        "description": description,
+        "source_chapter": chapter,
+        "learned_at": now,
+        "updated_at": now,
+    }
+    if category:
+        learned["category"] = category
+    if importance:
+        learned["importance"] = importance
+
+    patterns.append(learned)
+    atomic_write_json(memory_path, payload, use_lock=True, backup=True)
+    return {"status": "success", "learned": learned, "path": str(memory_path)}
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="Write .webnovel/project_memory.json safely")
+    parser.add_argument("--project-root", required=True)
+    sub = parser.add_subparsers(dest="command", required=True)
+
+    add = sub.add_parser("add-pattern", help="追加一条项目经验记忆")
+    add.add_argument("--pattern-type", default="other")
+    add.add_argument("--description", required=True)
+    add.add_argument("--category", default="")
+    add.add_argument("--importance", default="medium")
+    add.add_argument("--source-chapter", type=int)
+
+    args = parser.parse_args()
+    try:
+        if args.command == "add-pattern":
+            result = add_pattern(
+                Path(args.project_root),
+                pattern_type=args.pattern_type,
+                description=args.description,
+                category=args.category,
+                importance=args.importance,
+                source_chapter=args.source_chapter,
+            )
+            print(json.dumps(result, ensure_ascii=False, indent=2))
+            return
+    except ValueError as exc:
+        print(json.dumps({"status": "error", "error": str(exc)}, ensure_ascii=False), file=sys.stderr)
+        raise SystemExit(1)
+
+    raise SystemExit(2)
+
+
+if __name__ == "__main__":
+    if sys.platform == "win32":
+        enable_windows_utf8_stdio()
+    main()

+ 21 - 6
webnovel-writer/skills/webnovel-learn/SKILL.md

@@ -1,7 +1,7 @@
 ---
 name: webnovel-learn
 description: 从当前会话提取成功模式并写入 project_memory.json
-allowed-tools: Read Write Bash
+allowed-tools: Read Bash
 ---
 
 # /webnovel-learn
@@ -9,8 +9,13 @@ allowed-tools: Read Write Bash
 ## Project Root Guard(必须先确认)
 
 - 必须在项目根目录执行(需存在 `.webnovel/state.json`)
-- 若当前目录不存在该文件,先询问用户项目路径并 `cd` 进入
-- 进入后设置变量:`$PROJECT_ROOT = (Resolve-Path ".").Path`
+- 使用统一入口解析项目根,避免写错目录:
+
+```bash
+export WORKSPACE_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
+export SCRIPTS_DIR="${CLAUDE_PLUGIN_ROOT:?}/scripts"
+export PROJECT_ROOT="$(python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${WORKSPACE_ROOT}" where)"
+```
 
 ## 目标
 - 提取可复用的写作模式(钩子/节奏/对话/微兑现等)
@@ -36,13 +41,23 @@ allowed-tools: Read Write Bash
 
 ## 执行流程
 1. 读取 `"$PROJECT_ROOT/.webnovel/state.json"`,获取当前章节号(progress.current_chapter)
-2. 读取 `"$PROJECT_ROOT/.webnovel/project_memory.json"`,若不存在则初始化 `{"patterns": []}`
-3. 解析用户输入,归类 pattern_type(hook/pacing/dialogue/payoff/emotion)
-4. 追加记录并写回文件
+2. 解析用户输入,归类 pattern_type(hook/pacing/dialogue/payoff/emotion/format/other)
+3. 必须调用脚本写入,不得手写或拼接 JSON:
+
+```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" project-memory add-pattern \
+  --pattern-type "{pattern_type}" \
+  --description "{用户输入或提炼后的完整描述}" \
+  --category "{分类,可空}" \
+  --importance "{high|medium|low}"
+```
+
+脚本会自动读取/初始化 `.webnovel/project_memory.json`,并用 JSON 序列化写回,自动转义英文双引号、换行等字符。
 
 ## 约束
 - 不删除旧记录,仅追加
 - 避免完全重复的 description(可去重)
+- 禁止使用 `Write` 或手工编辑 `.webnovel/project_memory.json`
 
 ## 去重规则