Przeglądaj źródła

Merge pull request #133 from lingfengQAQ/fix/issue-130-dashboard-aggregation

fix: open_loop 事件聚合进 plot_threads.foreshadowing
聆风 1 miesiąc temu
rodzic
commit
2041abad78

+ 2 - 2
webnovel-writer/scripts/data_modules/event_projection_router.py

@@ -14,8 +14,8 @@ class EventProjectionRouter:
         "relationship_changed": ["index", "vector"],
         "world_rule_revealed": ["memory", "vector"],
         "world_rule_broken": ["memory", "vector"],
-        "open_loop_created": ["memory"],
-        "open_loop_closed": ["memory"],
+        "open_loop_created": ["state", "memory"],
+        "open_loop_closed": ["state", "memory"],
         "promise_created": ["memory"],
         "promise_paid_off": ["memory"],
         "artifact_obtained": ["index", "vector"],

+ 84 - 0
webnovel-writer/scripts/data_modules/state_projection_writer.py

@@ -109,12 +109,14 @@ class StateProjectionWriter:
                     progress["last_updated"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
 
             strand_applied = self._apply_strand_tracker(state, chapter, commit_payload)
+            foreshadow_applied = self._apply_foreshadowing(state, chapter, commit_payload)
 
         return {
             "applied": applied_count > 0 or chapter > 0,
             "writer": "state",
             "applied_count": applied_count,
             "strand_tracker": strand_applied,
+            "foreshadowing": foreshadow_applied,
         }
 
     def _locked_state(self):
@@ -248,6 +250,88 @@ class StateProjectionWriter:
                 ids.add(eid)
         return ids
 
+    def _apply_foreshadowing(self, state: dict, chapter: int, commit_payload: dict) -> int:
+        """把 open_loop 事件聚合进 plot_threads.foreshadowing(issue #130)。
+
+        幂等:created 按 content 去重;closed 对已 resolved 条目不重复改写,
+        因此 projections replay 重放任意章节不会产生重复或抖动。
+        """
+        if chapter <= 0:
+            return 0
+        loop_events = [
+            event
+            for event in extraction_list(commit_payload, "accepted_events")
+            if isinstance(event, dict)
+            and str(event.get("event_type") or "").strip()
+            in ("open_loop_created", "open_loop_closed")
+        ]
+        if not loop_events:
+            return 0
+
+        plot_threads = state.get("plot_threads")
+        if not isinstance(plot_threads, dict):
+            plot_threads = {}
+            state["plot_threads"] = plot_threads
+        rows = plot_threads.get("foreshadowing")
+        if not isinstance(rows, list):
+            rows = []
+            plot_threads["foreshadowing"] = rows
+
+        applied = 0
+        for event in loop_events:
+            payload = event.get("payload") if isinstance(event.get("payload"), dict) else {}
+            content = str(
+                payload.get("content")
+                or payload.get("description")
+                or event.get("subject")
+                or ""
+            ).strip()
+            if not content:
+                continue
+            event_type = str(event.get("event_type") or "").strip()
+            row = next(
+                (
+                    r
+                    for r in rows
+                    if isinstance(r, dict) and str(r.get("content") or "").strip() == content
+                ),
+                None,
+            )
+            if event_type == "open_loop_created":
+                if row is not None:
+                    row.setdefault("planted_chapter", chapter)
+                    continue
+                new_row: dict[str, Any] = {
+                    "content": content,
+                    "status": "active",
+                    "planted_chapter": chapter,
+                }
+                target = self._safe_int(
+                    payload.get("target_chapter") or payload.get("due_chapter")
+                )
+                if target > 0:
+                    new_row["target_chapter"] = target
+                tier = str(payload.get("tier") or "").strip()
+                if tier:
+                    new_row["tier"] = tier
+                rows.append(new_row)
+                applied += 1
+            else:
+                if row is None:
+                    rows.append(
+                        {
+                            "content": content,
+                            "status": "resolved",
+                            "resolved_chapter": chapter,
+                        }
+                    )
+                    applied += 1
+                elif str(row.get("status") or "") != "resolved":
+                    row["status"] = "resolved"
+                    row["resolved_chapter"] = chapter
+                    applied += 1
+        return applied
+
     def _apply_strand_tracker(self, state: dict, chapter: int, commit_payload: dict) -> bool:
         strand = self._dominant_strand(commit_payload)
         if chapter <= 0 or not strand:

+ 91 - 0
webnovel-writer/scripts/data_modules/tests/test_projection_writers.py

@@ -610,3 +610,94 @@ def test_memory_projection_writer_maps_open_loop_event_into_scratchpad(tmp_path)
     loops = store.query(category="open_loop", status="active")
     assert result["applied"] is True
     assert any("三年之约" in x.subject for x in loops)
+
+
+def _loop_event(event_type, content, chapter=None, **payload_extra):
+    payload = {"content": content}
+    payload.update(payload_extra)
+    event = {"event_type": event_type, "subject": "narrator", "payload": payload}
+    if chapter is not None:
+        event["chapter"] = chapter
+    return event
+
+
+def _read_state(tmp_path):
+    return json.loads((tmp_path / ".webnovel" / "state.json").read_text(encoding="utf-8"))
+
+
+def test_state_writer_aggregates_foreshadowing_from_open_loop_events(tmp_path):
+    """issue #130:open_loop 事件必须聚合进 plot_threads.foreshadowing。"""
+    (tmp_path / ".webnovel").mkdir(parents=True, exist_ok=True)
+    (tmp_path / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
+    writer = StateProjectionWriter(tmp_path)
+
+    writer.apply(
+        _commit_payload(
+            chapter=5,
+            accepted_events=[
+                _loop_event("open_loop_created", "三年之约提及", target_chapter=30, tier="major")
+            ],
+        )
+    )
+    rows = _read_state(tmp_path)["plot_threads"]["foreshadowing"]
+    assert len(rows) == 1
+    row = rows[0]
+    assert row["content"] == "三年之约提及"
+    assert row["status"] == "active"
+    assert row["planted_chapter"] == 5
+    assert row["target_chapter"] == 30
+    assert row["tier"] == "major"
+
+    writer.apply(
+        _commit_payload(
+            chapter=28,
+            accepted_events=[_loop_event("open_loop_closed", "三年之约提及")],
+        )
+    )
+    rows = _read_state(tmp_path)["plot_threads"]["foreshadowing"]
+    assert len(rows) == 1
+    assert rows[0]["status"] == "resolved"
+    assert rows[0]["resolved_chapter"] == 28
+    assert rows[0]["planted_chapter"] == 5
+
+
+def test_state_writer_foreshadowing_replay_is_idempotent(tmp_path):
+    """projections replay 重放同章不得产生重复伏笔条目。"""
+    (tmp_path / ".webnovel").mkdir(parents=True, exist_ok=True)
+    (tmp_path / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
+    writer = StateProjectionWriter(tmp_path)
+    payload = _commit_payload(
+        chapter=7,
+        accepted_events=[_loop_event("open_loop_created", "黑色棺材的来历")],
+    )
+    writer.apply(payload)
+    writer.apply(payload)
+    rows = _read_state(tmp_path)["plot_threads"]["foreshadowing"]
+    assert len(rows) == 1
+    assert rows[0]["planted_chapter"] == 7
+
+
+def test_state_writer_foreshadowing_orphan_close_keeps_record(tmp_path):
+    """closed 事件找不到对应 active 条目时保留为 resolved 记录,不丢数据。"""
+    (tmp_path / ".webnovel").mkdir(parents=True, exist_ok=True)
+    (tmp_path / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
+    writer = StateProjectionWriter(tmp_path)
+    writer.apply(
+        _commit_payload(
+            chapter=9,
+            accepted_events=[_loop_event("open_loop_closed", "从未登记过的旧约")],
+        )
+    )
+    rows = _read_state(tmp_path)["plot_threads"]["foreshadowing"]
+    assert len(rows) == 1
+    assert rows[0]["status"] == "resolved"
+    assert rows[0]["resolved_chapter"] == 9
+
+
+def test_router_routes_open_loop_events_to_state(tmp_path):
+    """issue #130:open_loop 事件必须进 state 投影,否则伏笔无人聚合。"""
+    from data_modules.event_projection_router import EventProjectionRouter
+
+    router = EventProjectionRouter()
+    assert "state" in router.route({"event_type": "open_loop_created"})
+    assert "state" in router.route({"event_type": "open_loop_closed"})