Bläddra i källkod

fix: 修复post-merge review的4个important项

1. data-agent.md Step 6: 补充memory update CLI命令模板
2. _key_for去重: 提取为schema.memory_item_key()共享函数,
   store和compactor均委托调用,消除维护风险
3. compactor timeline summary: 匹配改为仅比较subject,
   不再比较field,避免不同field值导致summary累积
4. 补充测试: timeline summary替换验证、resolved open_loop
   集成测试、memory_item_key一致性验证
lingfengQAQ 2 månader sedan
förälder
incheckning
18213b62d8

+ 11 - 1
webnovel-writer/agents/data-agent.md

@@ -178,9 +178,19 @@ hook_strength: "strong"
 约束:
 - 不新增额外 LLM 调用。
 - 不创建独立 extractor Agent。
-- 只提炼可跨章复用”的长期事实,不混入临时工作记忆。
+- 只提炼可跨章复用”的长期事实,不混入临时工作记忆。
 - 提取结果必须交由 `memory/writer.py` 写入 `.webnovel/memory_scratchpad.json`。
 
+写入命令:
+
+```bash
+python -X utf8 “${SCRIPTS_DIR}/webnovel.py” --project-root “{project_root}” memory update \
+  --chapter {chapter} \
+  --data '@{tmp_dir}/chapter_result.json'
+```
+
+> `chapter_result.json` 是 Step 4 + Step 6 的完整结构化输出,包含 `state_changes`、`entities_new`、`relationships_new`、`chapter_meta`、`memory_facts` 等字段。
+
 ### Step 7:执行场景切片
 
 - 按地点、时间、视角切分场景

+ 3 - 6
webnovel-writer/scripts/data_modules/memory/compactor.py

@@ -7,14 +7,11 @@ from __future__ import annotations
 
 from typing import Dict, List, Tuple
 
-from .schema import CATEGORY_KEY_RULES, CATEGORY_TO_BUCKET, MemoryItem, ScratchpadData, now_iso
+from .schema import CATEGORY_KEY_RULES, CATEGORY_TO_BUCKET, MemoryItem, ScratchpadData, memory_item_key, now_iso
 
 
 def _key_for(item: MemoryItem) -> Tuple:
-    fields = CATEGORY_KEY_RULES.get(item.category)
-    if not fields:
-        return (item.id,)
-    return tuple(getattr(item, f, None) for f in fields)
+    return memory_item_key(item)
 
 
 def _is_resolved_open_loop(item: MemoryItem) -> bool:
@@ -79,7 +76,7 @@ def compact_scratchpad(data: ScratchpadData, max_items: int = 500) -> Scratchpad
             )
             replaced = False
             for i, row in enumerate(list(data.story_facts)):
-                if row.subject == summary_item.subject and row.field == summary_item.field:
+                if row.subject == summary_item.subject and row.subject == "timeline_summary":
                     data.story_facts[i] = summary_item
                     replaced = True
                     break

+ 8 - 0
webnovel-writer/scripts/data_modules/memory/schema.py

@@ -35,6 +35,14 @@ CATEGORY_KEY_RULES: Dict[str, tuple[str, ...]] = {
 }
 
 
+def memory_item_key(item: "MemoryItem") -> tuple:
+    """根据 category 规则计算 MemoryItem 的去重 key。供 store/compactor 共用。"""
+    fields = CATEGORY_KEY_RULES.get(item.category)
+    if not fields:
+        return (item.id,)
+    return tuple(getattr(item, f, None) for f in fields)
+
+
 def now_iso() -> str:
     return datetime.now().isoformat(timespec="seconds")
 

+ 2 - 7
webnovel-writer/scripts/data_modules/memory/store.py

@@ -19,6 +19,7 @@ from .schema import (
     CATEGORY_TO_BUCKET,
     MemoryItem,
     ScratchpadData,
+    memory_item_key,
     now_iso,
 )
 
@@ -59,13 +60,7 @@ class ScratchpadManager:
         atomic_write_json(self.path, payload, use_lock=_use_lock, backup=True)
 
     def _key_for(self, item: MemoryItem) -> tuple[Any, ...]:
-        rule = CATEGORY_KEY_RULES.get(item.category)
-        if not rule:
-            return (item.id,)
-        values: list[Any] = []
-        for key in rule:
-            values.append(getattr(item, key, None))
-        return tuple(values)
+        return memory_item_key(item)
 
     def upsert_item(self, item: MemoryItem) -> Dict[str, int]:
         normalized = item.normalized()

+ 46 - 9
webnovel-writer/scripts/data_modules/tests/test_coverage_boost.py

@@ -12,7 +12,7 @@ from pathlib import Path
 import pytest
 
 from data_modules.config import DataModulesConfig
-from data_modules.memory.schema import MemoryItem, ScratchpadData
+from data_modules.memory.schema import MemoryItem, ScratchpadData, memory_item_key
 from data_modules.memory.store import ScratchpadManager
 from data_modules.memory.compactor import compact_scratchpad, _key_for, _is_resolved_open_loop
 from data_modules.cli_args import normalize_global_project_root, load_json_arg, _extract_flag_value
@@ -144,8 +144,9 @@ def test_compactor_cleans_resolved_open_loops():
 
 
 def test_compactor_replaces_existing_timeline_summary():
+    """步骤3的timeline summary只保留一条,即使field值随章节变化。"""
     data = ScratchpadData.empty()
-    # pre-existing summary
+    # pre-existing summary with old field value
     data.story_facts = [
         _make_item("sf-old", category="story_fact", subject="timeline_summary", field="<=ch5", value="旧摘要"),
     ]
@@ -154,15 +155,51 @@ def test_compactor_replaces_existing_timeline_summary():
         data.timeline.append(_make_item(f"t-old-{i}", category="timeline", subject=f"旧事件{i}", field="event", value=f"旧事件{i}", chapter=i+1))
     # fresh timeline
     data.timeline.append(_make_item("t-fresh", category="timeline", subject="新事件", field="event", value="新事件", chapter=60))
-    # pad to exceed max
-    for i in range(5):
-        data.world_rules.append(_make_item(f"wr{i}", category="world_rule", subject=f"rule{i}", field=f"f{i}", value=f"v{i}", chapter=60))
 
-    result = compact_scratchpad(data, max_items=6)
+    # max_items 设足够大,让 step 4 不截断,只测 step 3 的替换逻辑
+    result = compact_scratchpad(data, max_items=4)
     summaries = [r for r in result.story_facts if r.subject == "timeline_summary"]
-    assert len(summaries) <= 1
-    if summaries:
-        assert "旧事件" in summaries[0].value
+    # 旧 summary (field="<=ch5") 应被新 summary (field="<=ch3") 替换,而非共存
+    assert len(summaries) == 1
+    assert "旧事件" in summaries[0].value
+    assert summaries[0].field != "<=ch5"  # 旧field已被覆盖
+
+
+def test_compactor_resolved_open_loop_integration(tmp_path):
+    """集成测试: compactor通过store.save()触发时正确清除resolved open_loop。"""
+    cfg = _cfg(tmp_path)
+    cfg.memory_compactor_enabled = True
+    cfg.memory_compactor_threshold = 3
+    manager = ScratchpadManager(cfg)
+
+    # 插入一个resolved open_loop
+    manager.upsert_item(_make_item("ol-resolved", category="open_loop", subject="伏笔已解",
+                                   field="status", value="已解伏笔", payload={"status": "resolved"}))
+    # 插入一个active open_loop
+    manager.upsert_item(_make_item("ol-active", category="open_loop", subject="伏笔未解",
+                                   field="status", value="未解伏笔", payload={"status": "active"}))
+    # 再插入几条让总数超过threshold触发compaction
+    manager.upsert_item(_make_item("w1", category="world_rule", subject="r1", field="f1", value="v1"))
+    manager.upsert_item(_make_item("w2", category="world_rule", subject="r2", field="f2", value="v2"))
+
+    data = manager.load()
+    loop_subjects = [r.subject for r in data.open_loops]
+    assert "伏笔已解" not in loop_subjects
+    assert "伏笔未解" in loop_subjects
+
+
+def test_memory_item_key_shared_function():
+    """验证 schema.memory_item_key 与 compactor._key_for / store._key_for 一致。"""
+    item = _make_item("x1", category="character_state", subject="hero", field="realm")
+    assert memory_item_key(item) == ("hero", "realm")
+    assert _key_for(item) == memory_item_key(item)
+
+    mgr_key = ScratchpadManager.__new__(ScratchpadManager)
+    # _key_for is instance method, call directly
+    assert ScratchpadManager._key_for(mgr_key, item) == memory_item_key(item)
+
+    unknown = _make_item("u1", category="unknown_category")
+    assert memory_item_key(unknown) == ("u1",)
 
 
 # ═══════════════════════════════════════════════════════════════════════