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

chore: align phase5 docs and pytest setup

lingfengQAQ 1 місяць тому
батько
коміт
fd803ab769

+ 6 - 0
.gitignore

@@ -1,4 +1,9 @@
 .claude/
+.agents/
+.codex/
+.gemini/
+.trellis/
+AGENTS.md
 
 # Python caches & virtualenv
 __pycache__/
@@ -45,6 +50,7 @@ _v52_*.json
 _v52_*.txt
 claude_inventory.csv
 *_utf8_sample.txt
+C*tool-results*.txt
 
 # Test / diagnostic files
 test_gemini.env

+ 1 - 1
docs/architecture/system-architecture.md

@@ -405,7 +405,7 @@ graph TB
 ```mermaid
 graph LR
     INIT_USER["用户选择题材(如'修仙')"]
-    STATE_GENRE["state.json<br/>project.genre='修仙'<br/>(唯一真源)"]
+    STATE_GENRE["state.json<br/>project.genre='修仙'<br/>(init 配置快照 / read-model)"]
     
     STORY_CLI["story-system CLI<br/>--genre '修仙'"]
     

+ 1 - 1
webnovel-writer/agents/context-agent.md

@@ -74,7 +74,7 @@ python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" extr
 
 1. `load-context --chapter {NNNN}` 获取基础包
 2. `Read` 章纲原文(load-context 的 outline 可能截断)
-3. 确定卷号(优先 state.json)
+3. 确定卷号(优先 runtime contracts / latest commit;必要时兼容读取 state.json 投影
 
 ### B:按需深查(只查基础包不足的)
 

+ 1 - 0
webnovel-writer/dashboard/requirements.txt

@@ -1,3 +1,4 @@
 fastapi>=0.115.0
+httpx>=0.27.0
 uvicorn[standard]>=0.32.0
 watchdog>=5.0.0

+ 57 - 0
webnovel-writer/scripts/data_modules/tests/test_event_log_store.py

@@ -60,6 +60,63 @@ def test_event_log_store_ignores_duplicate_event_id(tmp_path):
     assert count == 1
 
 
+def test_event_log_store_recent_and_health_use_sqlite_mirror(tmp_path):
+    store = EventLogStore(tmp_path)
+    store.write_events(
+        3,
+        [
+            {
+                "event_id": "evt-003",
+                "chapter": 3,
+                "event_type": "promise_created",
+                "subject": "救人承诺",
+                "payload": {"target": "小医仙"},
+            }
+        ],
+    )
+    store.write_events(
+        4,
+        [
+            {
+                "event_id": "evt-004",
+                "chapter": 4,
+                "event_type": "promise_paid_off",
+                "subject": "救人承诺",
+                "payload": {"target": "小医仙"},
+            }
+        ],
+    )
+
+    recent = store.list_recent(limit=10)
+    assert [item["event_id"] for item in recent] == ["evt-004", "evt-003"]
+    chapter_only = store.list_recent(chapter=3, limit=10)
+    assert chapter_only == [
+        {
+            "event_id": "evt-003",
+            "chapter": 3,
+            "event_type": "promise_created",
+            "subject": "救人承诺",
+            "payload": {"target": "小医仙"},
+        }
+    ]
+
+    health = store.health()
+    assert health["ok"] is True
+    assert health["sqlite_rows"] == 2
+    assert health["event_files"] == 2
+
+
+def test_event_log_store_recent_and_health_without_table(tmp_path):
+    store = EventLogStore(tmp_path)
+    (tmp_path / ".webnovel").mkdir(parents=True, exist_ok=True)
+    sqlite3.connect(tmp_path / ".webnovel" / "index.db").close()
+
+    assert store.list_recent() == []
+    health = store.health()
+    assert health["sqlite_rows"] == 0
+    assert health["event_files"] == 0
+
+
 def test_story_events_cli_reads_chapter_file(tmp_path, monkeypatch, capsys):
     _ensure_scripts_on_path()
     events_dir = tmp_path / ".story-system" / "events"

+ 14 - 0
webnovel-writer/scripts/data_modules/tests/test_event_projection_router.py

@@ -85,3 +85,17 @@ def test_required_writers_includes_vector_for_key_events():
     }
     writers = router.required_writers(payload)
     assert "vector" in writers
+
+
+def test_router_ignores_unknown_and_non_dict_events():
+    router = EventProjectionRouter()
+    assert router.route({"event_type": "unknown"}) == []
+    writers = router.required_writers(
+        {
+            "meta": {"status": "rejected"},
+            "accepted_events": ["not-a-dict", {"event_type": "unknown"}],
+            "entity_deltas": [],
+            "summary_text": "   ",
+        }
+    )
+    assert writers == []

+ 11 - 0
webnovel-writer/scripts/data_modules/tests/test_project_locator.py

@@ -4,6 +4,8 @@
 import sys
 from pathlib import Path
 
+import pytest
+
 
 def _ensure_scripts_on_path() -> None:
     scripts_dir = Path(__file__).resolve().parents[2]
@@ -11,11 +13,19 @@ def _ensure_scripts_on_path() -> None:
         sys.path.insert(0, str(scripts_dir))
 
 
+@pytest.fixture(autouse=True)
+def isolate_project_locator_environment(monkeypatch, tmp_path):
+    monkeypatch.delenv("WEBNOVEL_PROJECT_ROOT", raising=False)
+    monkeypatch.delenv("CLAUDE_PROJECT_DIR", raising=False)
+    monkeypatch.setenv("WEBNOVEL_CLAUDE_HOME", str(tmp_path / "empty-claude-home"))
+
+
 def test_resolve_project_root_prefers_cwd_project(tmp_path):
     _ensure_scripts_on_path()
 
     from project_locator import resolve_project_root
 
+    (tmp_path / ".git").mkdir(parents=True, exist_ok=True)
     project_root = tmp_path / "workspace"
     (project_root / ".webnovel").mkdir(parents=True, exist_ok=True)
     (project_root / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
@@ -91,6 +101,7 @@ def test_resolve_project_root_ignores_stale_pointer_and_fallbacks(tmp_path):
     from project_locator import resolve_project_root
 
     workspace = tmp_path / "workspace"
+    (workspace / ".git").mkdir(parents=True, exist_ok=True)
     (workspace / ".claude").mkdir(parents=True, exist_ok=True)
     # stale pointer
     (workspace / ".claude" / ".webnovel-current-project").write_text(

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

@@ -561,6 +561,11 @@ def test_state_manager_cli_commands(temp_project, monkeypatch, capsys):
 
 
 def test_state_manager_cli_rejects_invalid_project_root(monkeypatch, tmp_path, capsys):
+    monkeypatch.delenv("WEBNOVEL_PROJECT_ROOT", raising=False)
+    monkeypatch.delenv("CLAUDE_PROJECT_DIR", raising=False)
+    monkeypatch.setenv("WEBNOVEL_CLAUDE_HOME", str(tmp_path / "empty-claude-home"))
+
+    (tmp_path / ".git").mkdir(parents=True, exist_ok=True)
     invalid_root = tmp_path / "not-a-project"
     invalid_root.mkdir(parents=True, exist_ok=True)
 

+ 1 - 1
webnovel-writer/scripts/init_project.py

@@ -5,7 +5,7 @@
 
 目标:
 - 生成可运行的项目结构(webnovel-project)
-- 创建/更新 .webnovel/state.json(运行时真相
+- 创建/更新 .webnovel/state.json(初始化配置与兼容读模型
 - 生成基础设定集与大纲模板文件(供 /webnovel-plan 与 /webnovel-write 使用)
 
 说明:

+ 2 - 5
webnovel-writer/scripts/tests/test_reference_search.py

@@ -5,7 +5,6 @@ Tests for reference_search.py — BM25 keyword search over CSV reference files.
 """
 
 import json
-import shutil
 import subprocess
 import sys
 from pathlib import Path
@@ -166,11 +165,9 @@ class TestSkillAndGenreFiltering:
         ids = [r["编号"] for r in out["data"]["results"]]
         assert "GR-025" in ids
 
-    def test_legacy_comma_delimiters_remain_compatible(self):
+    def test_legacy_comma_delimiters_remain_compatible(self, tmp_path):
         """迁移过渡期仍兼容旧的逗号分隔技能与题材字段。"""
-        temp_dir = Path.home() / ".codex" / "memories" / "reference_search_compat"
-        if temp_dir.exists():
-            shutil.rmtree(temp_dir)
+        temp_dir = tmp_path / "reference_search_compat"
         temp_dir.mkdir(parents=True, exist_ok=True)
         csv_path = temp_dir / "兼容测试.csv"
         csv_path.write_text(

+ 2 - 5
webnovel-writer/scripts/tests/test_validate_csv.py

@@ -6,6 +6,7 @@ import subprocess
 import sys
 from pathlib import Path
 import csv
+import tempfile
 import uuid
 
 
@@ -14,11 +15,7 @@ CSV_DIR = str(Path(__file__).resolve().parents[2] / "references" / "csv")
 
 
 def _make_local_tmp_path() -> Path:
-    base_dir = Path.home() / ".codex" / "memories" / "validate_csv_cases"
-    base_dir.mkdir(parents=True, exist_ok=True)
-    tmp_dir = base_dir / f"case_{uuid.uuid4().hex}"
-    tmp_dir.mkdir()
-    return tmp_dir
+    return Path(tempfile.mkdtemp(prefix=f"validate_csv_cases_{uuid.uuid4().hex}_"))
 
 
 def run_validate(*args: str) -> subprocess.CompletedProcess:

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

@@ -56,7 +56,7 @@ export PROJECT_ROOT="$(python "${SCRIPTS_DIR}/webnovel.py" --project-root "${WOR
 若本次规划会直接落到具体章节,还必须先刷新 Story System runtime 合同:
 
 ```bash
-# genre 从 state.json 读取(唯一真源)
+# genre 从 state.json 的初始化配置快照读取;写前主链真源是 .story-system 合同树
 GENRE="$(python -X utf8 -c "import json,sys; s=json.load(open('${PROJECT_ROOT}/.webnovel/state.json',encoding='utf-8')); print(s.get('project',{}).get('genre',''))")"
 
 python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${WORKSPACE_ROOT}" \
@@ -102,13 +102,13 @@ python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${WORKSPACE_ROOT}" \
 **必须加载**:
 
 ```bash
-# 项目状态与题材
+# 项目配置/投影状态(兼容读取,不作为写后事实真源)
 cat "$PROJECT_ROOT/.webnovel/state.json"
 
 # 总纲(全局蓝图)
 cat "$PROJECT_ROOT/大纲/总纲.md"
 
-# 题材(唯一真源,后续 CSV 检索和裁决匹配依赖此值)
+# 题材(来自 init 配置快照,后续 CSV 检索和裁决匹配依赖此值)
 GENRE="$(python -X utf8 -c "import json; s=json.load(open('${PROJECT_ROOT}/.webnovel/state.json',encoding='utf-8')); print(s.get('project',{}).get('genre',''))")"
 ```
 

+ 6 - 6
webnovel-writer/skills/webnovel-review/SKILL.md

@@ -10,7 +10,7 @@ allowed-tools: Read Grep Write Edit Bash Agent AskUserQuestion
 
 - 解析真实书项目根目录,按统一流程完成章节审查。
 - 调用统一 `reviewer` 生成结构化问题列表与审查报告。
-- 把审查指标写入 `index.db`,并把审查记录写回 `state.json`
+- 把审查指标写入 `index.db`,并把审查记录写入 `.webnovel/state.json` 兼容投影,主链事实仍以 review contract 与 accepted `CHAPTER_COMMIT` 为准
 - 审查时优先依据 `.story-system/reviews/chapter_{NNN}.review.json` 与 latest accepted `CHAPTER_COMMIT` 判断主链事实。
 - 若存在关键问题,明确交给用户决定是否立即返工。
 
@@ -79,7 +79,7 @@ python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${WORKSPACE_ROOT}" \
 | ai_flavor issue ≥ 3 | `../../skills/webnovel-write/references/anti-ai-guide.md` |
 | blocking issue 需用户决策 (Step 6) | `../../references/review/blocking-override-guidelines.md` |
 
-### Step 3:加载项目状态与待审正文
+### Step 3:加载项目投影状态与待审正文
 
 ```bash
 cat "${PROJECT_ROOT}/.webnovel/state.json"
@@ -87,7 +87,7 @@ cat "${PROJECT_ROOT}/.webnovel/state.json"
 
 要求:
 - 明确当前章节号与对应正文文件
-- 若缺少正文或状态文件,立即阻断
+- 若缺少正文或兼容状态文件,立即阻断
 
 ### Step 4:调用统一审查 Agent
 
@@ -142,9 +142,9 @@ python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" ind
 - `review-pipeline` 生成的 `review_metrics.json` 必须可直接写入 `review_metrics` 表
 - 阻断判断以 reviewer 原始结果中的 `blocking=true` 为准
 
-### Step 6:写审查记录并处理阻断
+### Step 6:写入兼容审查记录并处理阻断
 
-先写回审查记录
+先写入兼容审查记录(read-model/projection,不是写后事实真源)
 
 ```bash
 python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" update-state -- --add-review "{chapter_num}-{chapter_num}" "审查报告/第{chapter_num}章审查报告.md"
@@ -167,5 +167,5 @@ python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" update-stat
 2. 已通过 `reviewer` 输出结构化问题 JSON。
 3. 审查报告已生成。
 4. `review_metrics` 已写入 `index.db`。
-5. 审查记录已写回 `state.json`
+5. 审查记录已写入 `.webnovel/state.json` 兼容投影
 6. 如存在阻断问题,用户已明确选择处理策略。

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

@@ -53,7 +53,7 @@ export PROJECT_ROOT="$(python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-roo
 
 ### 准备:刷新合同树
 
-genre 从 state.json 读取(唯一真源),query 填章纲目标(用于 CSV 检索)。
+genre 从 `.webnovel/state.json` 的初始化配置快照读取,用于刷新合同树;写前主链真源仍是 `.story-system/` 合同,query 填章纲目标(用于 CSV 检索)。
 
 ```bash
 GENRE="$(python -X utf8 -c "import json,sys; s=json.load(open('${PROJECT_ROOT}/.webnovel/state.json',encoding='utf-8')); print(s.get('project',{}).get('genre',''))")"
@@ -73,7 +73,7 @@ python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${WORKSPACE_ROOT}" \
 ```text
 Agent(
   subagent_type: "webnovel-writer:context-agent",
-  prompt: "chapter={chapter_num}; project_root=${PROJECT_ROOT}; scripts_dir=${SCRIPTS_DIR}; storage_path=${PROJECT_ROOT}/.webnovel; state_file=${PROJECT_ROOT}/.webnovel/state.json。先 research,再输出五段写作任务书。"
+  prompt: "chapter={chapter_num}; project_root=${PROJECT_ROOT}; scripts_dir=${SCRIPTS_DIR}; storage_path=${PROJECT_ROOT}/.webnovel; state_file=${PROJECT_ROOT}/.webnovel/state.json(projection/read-model,仅兼容读取)。先 research,再输出五段写作任务书。"
 )
 ```
 

+ 1 - 1
webnovel-writer/skills/webnovel-write/references/writing/typesetting.md

@@ -34,7 +34,7 @@
 
 - **少用长串省略号/感叹号**:`……!!!` 这类组合一章内别反复出现。
 - **解释句别太“论文味”**:避免“首先/其次/总之/由此可见”式结构化套话。
-- **专有名词统一写法**:人名/势力/境界/技能名,统一一个版本(写入 `设定集/` 并在 `state.json` 追踪)。
+- **专有名词统一写法**:人名/势力/境界/技能名,统一一个版本(写入 `设定集/`,后续由 accepted `CHAPTER_COMMIT` 投影到 index/state/memory)。
 
 ---