Browse Source

fix: snapshot版本升级、context orchestrator守卫、state_manager CLI校验

- snapshot_manager: 版本升至1.3,配合context快照新增section校验
- context_manager: orchestrator仅在feature flag开启时调用,避免无效import
- context_manager: 快照兼容性检查增加required sections校验
- state_manager: CLI拒绝无效project_root并输出结构化错误
- context-agent.md: 移除已废弃的long_term_memory.json引用
- 补充测试: orchestrator禁用、旧版本快照失效、无效root拒绝
lingfengQAQ 2 months ago
parent
commit
1a270f8960

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

@@ -98,8 +98,7 @@ model: inherit
 - `index.db`:实体、别名、关系、状态变化、覆盖合同、追读力债务
 - `.webnovel/summaries/ch{NNNN}.md`:章节摘要
 - `.webnovel/context_snapshots/`:上下文快照,优先复用
-- `.webnovel/long_term_memory.json`:长期记忆主存储
-- `.webnovel/memory_scratchpad.json`:长期记忆暂存与待压缩事实
+- `.webnovel/memory_scratchpad.json`:当前长期记忆事实存储
 - `大纲/` 与 `设定集/`
 
 钩子数据说明:
@@ -168,7 +167,6 @@ cat "{project_root}/大纲/第{volume_id}卷-时间线.md"
 读取长期记忆:
 
 ```bash
-cat "{project_root}/.webnovel/long_term_memory.json"
 cat "{project_root}/.webnovel/memory_scratchpad.json"
 ```
 

+ 19 - 7
webnovel-writer/scripts/data_modules/context_manager.py

@@ -98,7 +98,18 @@ class ContextManager:
         if not isinstance(cached_template, str):
             return template == self.DEFAULT_TEMPLATE
 
-        return cached_template == template
+        if cached_template != template:
+            return False
+
+        payload = cached.get("payload", cached)
+        if not isinstance(payload, dict):
+            return False
+        sections = payload.get("sections")
+        if not isinstance(sections, dict):
+            return False
+
+        required_sections = {"plot_structure", "long_term_memory"}
+        return required_sections.issubset(set(sections.keys()))
 
     def build_context(
         self,
@@ -195,13 +206,14 @@ class ContextManager:
         use_orchestrator = bool(getattr(self.config, "context_use_memory_orchestrator", False))
 
         orchestrator_pack: Dict[str, Any] = {}
-        try:
-            from .memory.orchestrator import MemoryOrchestrator
+        if use_orchestrator:
+            try:
+                from .memory.orchestrator import MemoryOrchestrator
 
-            orchestrator = MemoryOrchestrator(self.config)
-            orchestrator_pack = orchestrator.build_memory_pack(chapter)
-        except Exception as exc:
-            logger.warning("memory_orchestrator_failed: %s", exc)
+                orchestrator = MemoryOrchestrator(self.config)
+                orchestrator_pack = orchestrator.build_memory_pack(chapter)
+            except Exception as exc:
+                logger.warning("memory_orchestrator_failed: %s", exc)
 
         core = {
             "chapter_outline": self._load_outline(chapter),

+ 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.2"
+SNAPSHOT_VERSION = "1.3"
 
 
 class SnapshotVersionMismatch(RuntimeError):

+ 7 - 3
webnovel-writer/scripts/data_modules/state_manager.py

@@ -1273,9 +1273,13 @@ def main():
 
         try:
             resolved_root = resolve_project_root(args.project_root)
-        except FileNotFoundError:
-            # 兼容旧行为:显式目录无法被 locator 识别时,直接按传入路径初始化。
-            resolved_root = Path(args.project_root).expanduser().resolve()
+        except FileNotFoundError as exc:
+            print_error(
+                "INVALID_PROJECT_ROOT",
+                str(exc),
+                suggestion="请传入包含 .webnovel/state.json 的书项目根目录,或先通过 webnovel.py 解析 project_root。",
+            )
+            raise SystemExit(1) from exc
         config = DataModulesConfig.from_project_root(resolved_root)
 
     manager = StateManager(config)

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

@@ -141,6 +141,26 @@ def test_context_manager_uses_memory_orchestrator_for_working_when_enabled(temp_
     assert core["recent_summaries"] == [{"chapter": 0, "summary": "FAKE_SUMMARY"}]
 
 
+def test_context_manager_skips_memory_orchestrator_when_disabled(temp_project, monkeypatch):
+    state = {
+        "protagonist_state": {"name": "萧炎"},
+        "chapter_meta": {},
+        "disambiguation_warnings": [],
+        "disambiguation_pending": [],
+    }
+    temp_project.state_file.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
+    temp_project.context_use_memory_orchestrator = False
+
+    def _boom(self, chapter, task_type="write"):
+        raise AssertionError("context_use_memory_orchestrator=false 时不应调用 orchestrator")
+
+    monkeypatch.setattr("data_modules.memory.orchestrator.MemoryOrchestrator.build_memory_pack", _boom)
+    manager = ContextManager(temp_project)
+    payload = manager.build_context(1, use_snapshot=False, save_snapshot=False)
+
+    assert payload["sections"]["long_term_memory"]["content"] == {}
+
+
 def test_context_manager_loads_volume_outline_file(temp_project):
     state = {
         "progress": {
@@ -201,6 +221,60 @@ def test_context_snapshot_respects_template(temp_project):
     assert battle_payload.get("template") == "battle"
 
 
+def test_context_snapshot_invalidates_legacy_version(temp_project):
+    state = {
+        "project": {"genre": "xuanhuan"},
+        "protagonist_state": {"name": "萧炎"},
+        "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(
+        """### 第4章:试炼
+CBN:进入试炼场
+CPNs:
+- 观察规则
+CEN:决定将计就计
+""",
+        encoding="utf-8",
+    )
+
+    snapshot_path = temp_project.webnovel_dir / "context_snapshots" / "ch0004.json"
+    snapshot_path.parent.mkdir(parents=True, exist_ok=True)
+    snapshot_path.write_text(
+        json.dumps(
+            {
+                "version": "1.2",
+                "chapter": 4,
+                "saved_at": "2026-03-01T00:00:00+00:00",
+                "meta": {"template": "plot"},
+                "payload": {
+                    "meta": {"chapter": 4},
+                    "sections": {
+                        "core": {
+                            "content": {"chapter_outline": "旧快照"},
+                            "text": "{}",
+                            "budget": 1000,
+                        }
+                    },
+                    "template": "plot",
+                    "weights": {},
+                },
+            },
+            ensure_ascii=False,
+        ),
+        encoding="utf-8",
+    )
+
+    manager = ContextManager(temp_project)
+    payload = manager.build_context(4, template="plot", use_snapshot=True, save_snapshot=False)
+
+    assert payload["sections"]["core"]["content"]["chapter_outline"] != "旧快照"
+    assert payload["sections"]["plot_structure"]["content"]["cbn"] == "进入试炼场"
+
+
 def test_context_manager_applies_ranker_and_contract_meta(temp_project):
     state = {
         "protagonist_state": {"name": "萧炎"},

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

@@ -553,6 +553,26 @@ def test_state_manager_cli_commands(temp_project, monkeypatch, capsys):
     assert out["status"] == "success"
 
 
+def test_state_manager_cli_rejects_invalid_project_root(monkeypatch, tmp_path, capsys):
+    invalid_root = tmp_path / "not-a-project"
+    invalid_root.mkdir(parents=True, exist_ok=True)
+
+    monkeypatch.setattr(
+        sys,
+        "argv",
+        ["state_manager", "--project-root", str(invalid_root), "get-progress"],
+    )
+
+    from data_modules import state_manager as sm
+
+    with pytest.raises(SystemExit) as exc:
+        sm.main()
+    out = json.loads(capsys.readouterr().out)
+    assert int(exc.value.code or 0) == 1
+    assert out["status"] == "error"
+    assert out["error"]["code"] == "INVALID_PROJECT_ROOT"
+
+
 def test_save_state_timeout(monkeypatch, temp_project):
     import filelock
     from data_modules import state_manager as sm