Ver Fonte

fix: project chapter commits into index state

lingfengQAQ há 1 mês atrás
pai
commit
9ba7f4548a

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

@@ -75,6 +75,7 @@ python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" extr
 1. `load-context --chapter {NNNN}` 获取基础包
 2. `Read` 章纲原文(load-context 的 outline 可能截断)
 3. 确定卷号(优先 runtime contracts / latest commit;必要时兼容读取 state.json 投影)
+4. 读取项目级写作 DNA(若存在):`{project_root}/P20_WRITING_DNA.md`、`{project_root}/WRITING_DNA.md`、`{project_root}/.claude/rules/P20_*.md`。只消费规则,不在任务书暴露文件名。
 
 ### B:按需深查(只查基础包不足的)
 
@@ -93,7 +94,7 @@ python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" extr
 ### D:组装
 
 1. 推断:动机 = 目标+处境+钩子压力;情绪底色 = 上章结尾+走向;可用能力 = 境界+设定禁用
-2. 从 `story_contracts` 取 `reasoning`(style_priority/pacing_strategy)+ `anti_patterns`
+2. 从 `story_contracts` 取 `reasoning`(style_priority/pacing_strategy)+ `anti_patterns`,并合并项目级写作 DNA
 3. 组装五段任务书
 4. 红线校验
 

+ 8 - 4
webnovel-writer/agents/data-agent.md

@@ -38,7 +38,7 @@ python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" chap
 产出三份 JSON 到 `.webnovel/tmp/`:
 - `fulfillment_result.json`:大纲履约(覆盖/遗漏节点)
 - `disambiguation_result.json`:消歧状态
-- `extraction_result.json`:必须包含 `accepted_events`、`state_deltas`、`entity_deltas`、`summary_text`
+- `extraction_result.json`:必须包含 `accepted_events`、`state_deltas`、`entity_deltas`、`entities_appeared`、`scenes`、`summary_text`;能判断主导情节线时写 `dominant_strand`
 
 **D 摘要**:100-150 字,含钩子类型。格式:
 
@@ -62,7 +62,9 @@ hook_strength: "strong"
 
 长期记忆只提炼"可跨章复用"的事实,转成 events/deltas 写入 extraction_result。
 
-**E 索引与观测**:场景切片(50-100 字/场景)→ RAG 向量索引 → review_score≥80 时提取风格样本 → 记录耗时到 observability。
+摘要 `## 伏笔` 中每条 `[埋设]` 必须同步写一条 `accepted_events[].event_type == "open_loop_created"`;不要只写在摘要里。伏笔已回收则用 `promise_paid_off` 或对应闭合事件表达。
+
+**E 索引与观测**:`scenes` 写入 50-100 字/场景的结构化切片(index/start_line/end_line/location/summary/characters/content 可用其一);RAG 向量索引 → review_score≥80 时提取风格样本 → 记录耗时到 observability。
 
 ## 4. 输入
 
@@ -89,9 +91,11 @@ hook_strength: "strong"
   "entities_new": [{"suggested_id": "hongyi_girl", "name": "红衣女子", "type": "角色", "tier": "装饰"}],
   "state_deltas": [{"entity_id": "xiaoyan", "field": "realm", "old": "斗者", "new": "斗师"}],
   "entity_deltas": [{"entity_id": "hongyi_girl", "action": "upsert", "entity_type": "角色", "tier": "装饰", "payload": {"name": "红衣女子"}}],
-  "accepted_events": [],
+  "accepted_events": [{"event_type": "open_loop_created", "subject": "three_year_promise", "payload": {"content": "三年之约提及"}}],
   "summary_text": "摘要",
+  "scenes": [{"index": 1, "start_line": 1, "end_line": 30, "location": "萧炎房间", "summary": "药老提醒三年之约", "characters": ["xiaoyan", "yaolao"]}],
   "scenes_chunked": 4,
+  "dominant_strand": "quest",
   "timing_ms": {},
   "bottlenecks_top3": []
 }
@@ -101,7 +105,7 @@ hook_strength: "strong"
 
 - **state_deltas 子项**:必须用 `field`(不是 `field_path`),`new`(不是 `new_value`),`old`(不是 `old_value`)。简单字段名直接写(如 `realm`),嵌套路径用点号(如 `power.realm`、`location.current`)。投影器会自动展开嵌套字典。
 - **entity_deltas 子项**:必须用 `entity_type`(不是 `type`),值为 `角色|组织|地点|物品|势力` 等,不是默认填 `"角色"`。`is_protagonist: true` 用于标记主角,主角字段会同步到 `state.protagonist_state`。
-- **accepted_events 通用**:`event_type` 用枚举值(`character_state_changed|power_breakthrough|relationship_changed|world_rule_revealed|world_rule_broken|open_loop_created|promise_created|promise_paid_off|artifact_obtained`)。`subject` 是事件主体的 entity_id(不是中文名)。
+- **accepted_events 通用**:`event_type` 用枚举值(`character_state_changed|power_breakthrough|relationship_changed|world_rule_revealed|world_rule_broken|open_loop_created|open_loop_closed|promise_created|promise_paid_off|artifact_obtained`)。`subject` 是事件主体的 entity_id(不是中文名)。
 - **character_state_changed.payload**:用 `field`(或 `field_path`)+ `new`(或 `new_state`/`new_value`)+ `old`(或 `previous_state`/`old_value`)。建议直接用 `field` + `new` + `old` 与 state_deltas 保持一致。
 - **open_loop_created.payload**:必须有 `content`(悬念正文),可选 `loop_type`(悬念类型)、`unanswered_question`(核心疑问)、`urgency`、`planted_chapter`、`expected_payoff`/`loop_deadline`。投影器会从 content > unanswered_question > description 取值,不要省略 content。
 - **world_rule_revealed.payload**:必须有 `rule_content`(或 `rule`、`description`),可选 `rule_category` / `domain`、`scope`。

+ 3 - 0
webnovel-writer/agents/reviewer.md

@@ -19,6 +19,8 @@ model: inherit
 - `Grep`:在正文中搜索关键词
 - `Bash`:调用记忆模块查询
 
+若项目存在 `{project_root}/P20_WRITING_DNA.md`、`{project_root}/WRITING_DNA.md`、`{project_root}/.claude/rules/P20_*.md`,必须先读取并把其中的私有文风/反 AI 味规则纳入检查;输出 issue 时不暴露文件路径。
+
 ```bash
 # 查询角色当前状态
 python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" state get-entity --id "{entity_id}"
@@ -84,6 +86,7 @@ python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" ind
 - 是否存在连续同构句(≥3 句主谓宾结构一致)
 - 是否每段都以总结句收尾("他终于明白了""由此可见")
 - 是否存在同一信息用不同句式重复说 2-3 遍
+- 是否存在比较状语或抽象判断先行,随后用正文补解释,导致句子像在替读者下结论
 - severity: `high`
 
 #### 6.3 叙事层

+ 4 - 0
webnovel-writer/scripts/data_modules/chapter_commit_service.py

@@ -65,6 +65,10 @@ class ChapterCommitService:
             "accepted_events": extraction_result.get("accepted_events", []),
             "state_deltas": extraction_result.get("state_deltas", []),
             "entity_deltas": extraction_result.get("entity_deltas", []),
+            "entities_appeared": extraction_result.get("entities_appeared", []),
+            "scenes": extraction_result.get("scenes", []),
+            "chapter_meta": extraction_result.get("chapter_meta", {}),
+            "dominant_strand": extraction_result.get("dominant_strand", ""),
             "summary_text": extraction_result.get("summary_text", ""),
             "projection_status": {
                 "state": "pending",

+ 1 - 0
webnovel-writer/scripts/data_modules/event_projection_router.py

@@ -26,6 +26,7 @@ class EventProjectionRouter:
         writers: Set[str] = set()
         if str((commit_payload.get("meta") or {}).get("status") or "") == "accepted":
             writers.add("state")
+            writers.add("index")
         if commit_payload.get("entity_deltas"):
             writers.add("index")
         if str(commit_payload.get("summary_text") or "").strip():

+ 303 - 1
webnovel-writer/scripts/data_modules/index_projection_writer.py

@@ -2,10 +2,18 @@
 # -*- coding: utf-8 -*-
 from __future__ import annotations
 
+import json
+import re
 from pathlib import Path
+from typing import Any
 
 from .config import DataModulesConfig
-from .index_manager import IndexManager
+from .index_manager import ChapterMeta, IndexManager, SceneMeta, StateChangeMeta
+
+try:
+    from chapter_paths import find_chapter_file
+except ImportError:  # pragma: no cover
+    from scripts.chapter_paths import find_chapter_file
 
 
 class IndexProjectionWriter:
@@ -18,16 +26,310 @@ class IndexProjectionWriter:
 
         manager = IndexManager(DataModulesConfig.from_project_root(self.project_root))
         applied_count = 0
+        chapter_applied = self._upsert_chapter(manager, commit_payload)
+        if chapter_applied:
+            applied_count += 1
+
+        scenes_count = self._apply_scenes(manager, commit_payload)
+        applied_count += scenes_count
+
+        appearances_count = self._apply_appearances(manager, commit_payload)
+        applied_count += appearances_count
+
+        state_changes_count = self._apply_state_changes(manager, commit_payload)
+        applied_count += state_changes_count
+
+        entity_delta_count = 0
         for delta in self._collect_entity_deltas(commit_payload):
             result = manager.apply_entity_delta(delta)
             if result:
+                entity_delta_count += 1
                 applied_count += 1
         return {
             "applied": applied_count > 0,
             "writer": "index",
             "applied_count": applied_count,
+            "chapters": 1 if chapter_applied else 0,
+            "scenes": scenes_count,
+            "appearances": appearances_count,
+            "state_changes": state_changes_count,
+            "entity_deltas": entity_delta_count,
         }
 
+    def _upsert_chapter(self, manager: IndexManager, commit_payload: dict) -> bool:
+        chapter = int(commit_payload.get("meta", {}).get("chapter") or 0)
+        if chapter <= 0:
+            return False
+
+        meta = commit_payload.get("chapter_meta") or {}
+        if not isinstance(meta, dict):
+            meta = {}
+
+        title = str(
+            meta.get("title")
+            or commit_payload.get("chapter_title")
+            or self._title_from_chapter_file(chapter)
+            or ""
+        ).strip()
+        location = str(meta.get("location") or commit_payload.get("location") or "").strip()
+        summary = str(commit_payload.get("summary_text") or meta.get("summary") or "").strip()
+        word_count = self._safe_int(meta.get("word_count") or commit_payload.get("word_count"))
+        if word_count <= 0:
+            word_count = self._chapter_word_count(chapter)
+
+        characters = meta.get("characters") or self._collect_character_ids(commit_payload)
+        if not isinstance(characters, list):
+            characters = []
+
+        manager.add_chapter(
+            ChapterMeta(
+                chapter=chapter,
+                title=title,
+                location=location,
+                word_count=word_count,
+                characters=[str(c) for c in characters if str(c).strip()],
+                summary=summary,
+            )
+        )
+        return True
+
+    def _apply_scenes(self, manager: IndexManager, commit_payload: dict) -> int:
+        chapter = int(commit_payload.get("meta", {}).get("chapter") or 0)
+        scenes = commit_payload.get("scenes") or []
+        if chapter <= 0 or not isinstance(scenes, list) or not scenes:
+            return 0
+
+        scene_metas: list[SceneMeta] = []
+        for idx, scene in enumerate(scenes, start=1):
+            if not isinstance(scene, dict):
+                continue
+            scene_index = self._safe_int(scene.get("scene_index") or scene.get("index") or idx)
+            characters = scene.get("characters") or scene.get("character_ids") or []
+            if not isinstance(characters, list):
+                characters = []
+            scene_metas.append(
+                SceneMeta(
+                    chapter=chapter,
+                    scene_index=scene_index,
+                    start_line=self._safe_int(scene.get("start_line")),
+                    end_line=self._safe_int(scene.get("end_line")),
+                    location=str(scene.get("location") or "").strip(),
+                    summary=str(scene.get("summary") or scene.get("content") or "").strip(),
+                    characters=[str(c) for c in characters if str(c).strip()],
+                )
+            )
+        if not scene_metas:
+            return 0
+        manager.add_scenes(chapter, scene_metas)
+        return len(scene_metas)
+
+    def _apply_appearances(self, manager: IndexManager, commit_payload: dict) -> int:
+        chapter = int(commit_payload.get("meta", {}).get("chapter") or 0)
+        entities = commit_payload.get("entities_appeared") or []
+        if chapter <= 0 or not isinstance(entities, list):
+            return 0
+
+        applied = 0
+        for entity in entities:
+            if not isinstance(entity, dict):
+                continue
+            entity_id = str(entity.get("id") or entity.get("entity_id") or "").strip()
+            if not entity_id or entity_id == "NEW":
+                continue
+            mentions = entity.get("mentions") or []
+            if isinstance(mentions, str):
+                mentions = [mentions]
+            if not isinstance(mentions, list):
+                mentions = []
+            manager.record_appearance(
+                entity_id=entity_id,
+                chapter=chapter,
+                mentions=[str(m) for m in mentions if str(m).strip()],
+                confidence=self._safe_float(entity.get("confidence"), 1.0),
+            )
+            applied += 1
+        return applied
+
+    def _apply_state_changes(self, manager: IndexManager, commit_payload: dict) -> int:
+        applied = 0
+        for change in self._collect_state_changes(commit_payload):
+            entity_id = str(change.get("entity_id") or "").strip()
+            field = str(change.get("field") or "").strip()
+            chapter = self._safe_int(change.get("chapter") or commit_payload.get("meta", {}).get("chapter"))
+            if not entity_id or not field or chapter <= 0:
+                continue
+            old_value = self._stringify(change.get("old"))
+            new_value = self._stringify(change.get("new"))
+            reason = str(change.get("reason") or "").strip()
+            if self._state_change_exists(manager, entity_id, field, old_value, new_value, reason, chapter):
+                continue
+            manager.record_state_change(
+                StateChangeMeta(
+                    entity_id=entity_id,
+                    field=field,
+                    old_value=old_value,
+                    new_value=new_value,
+                    reason=reason,
+                    chapter=chapter,
+                )
+            )
+            applied += 1
+        return applied
+
+    def _collect_state_changes(self, commit_payload: dict) -> list[dict]:
+        deltas = [
+            self._normalize_state_delta(delta)
+            for delta in (commit_payload.get("state_deltas") or [])
+            if isinstance(delta, dict)
+        ]
+        seen = {
+            (
+                str(delta.get("entity_id") or "").strip(),
+                str(delta.get("field") or "").strip(),
+                self._safe_int(delta.get("chapter") or commit_payload.get("meta", {}).get("chapter")),
+            )
+            for delta in deltas
+        }
+
+        for event in commit_payload.get("accepted_events") or []:
+            if not isinstance(event, dict):
+                continue
+            event_type = str(event.get("event_type") or "").strip()
+            payload = dict(event.get("payload") or {})
+            if event_type == "power_breakthrough":
+                field = str(payload.get("field") or payload.get("field_path") or "realm").strip()
+            elif event_type == "character_state_changed":
+                field = str(payload.get("field") or payload.get("field_path") or "").strip()
+            else:
+                continue
+            entity_id = str(payload.get("entity_id") or event.get("subject") or "").strip()
+            chapter = self._safe_int(event.get("chapter") or commit_payload.get("meta", {}).get("chapter"))
+            key = (entity_id, field, chapter)
+            if not entity_id or not field or key in seen:
+                continue
+            seen.add(key)
+            deltas.append(
+                {
+                    "entity_id": entity_id,
+                    "field": field,
+                    "old": (
+                        payload.get("old")
+                        if "old" in payload
+                        else payload.get("from")
+                        if "from" in payload
+                        else payload.get("old_value")
+                        if "old_value" in payload
+                        else payload.get("previous_state")
+                    ),
+                    "new": (
+                        payload.get("new")
+                        if "new" in payload
+                        else payload.get("to")
+                        if "to" in payload
+                        else payload.get("new_value")
+                        if "new_value" in payload
+                        else payload.get("new_state")
+                    ),
+                    "reason": event_type,
+                    "chapter": chapter,
+                }
+            )
+        return deltas
+
+    def _normalize_state_delta(self, delta: dict) -> dict:
+        result = dict(delta)
+        if "field" not in result and "field_path" in result:
+            result["field"] = result["field_path"]
+        if "new" not in result and "new_value" in result:
+            result["new"] = result["new_value"]
+        if "old" not in result and "old_value" in result:
+            result["old"] = result["old_value"]
+        return result
+
+    def _state_change_exists(
+        self,
+        manager: IndexManager,
+        entity_id: str,
+        field: str,
+        old_value: str,
+        new_value: str,
+        reason: str,
+        chapter: int,
+    ) -> bool:
+        with manager._get_conn() as conn:
+            cursor = conn.cursor()
+            cursor.execute(
+                """
+                SELECT 1 FROM state_changes
+                WHERE entity_id = ?
+                  AND field = ?
+                  AND chapter = ?
+                  AND COALESCE(old_value, '') = ?
+                  AND COALESCE(new_value, '') = ?
+                  AND COALESCE(reason, '') = ?
+                LIMIT 1
+                """,
+                (entity_id, field, chapter, old_value, new_value, reason),
+            )
+            return cursor.fetchone() is not None
+
+    def _collect_character_ids(self, commit_payload: dict) -> list[str]:
+        ids: list[str] = []
+        for entity in commit_payload.get("entities_appeared") or []:
+            if not isinstance(entity, dict):
+                continue
+            entity_id = str(entity.get("id") or entity.get("entity_id") or "").strip()
+            if entity_id and entity_id != "NEW":
+                ids.append(entity_id)
+        for delta in commit_payload.get("entity_deltas") or []:
+            if not isinstance(delta, dict):
+                continue
+            entity_id = str(delta.get("entity_id") or delta.get("id") or "").strip()
+            entity_type = str(delta.get("type") or delta.get("entity_type") or "").strip()
+            if entity_id and (not entity_type or entity_type == "角色"):
+                ids.append(entity_id)
+        return list(dict.fromkeys(ids))
+
+    def _title_from_chapter_file(self, chapter: int) -> str:
+        path = find_chapter_file(self.project_root, chapter)
+        if path is None:
+            return ""
+        stem = path.stem
+        match = re.match(r"第0*\d+章[-_ ]+(.+)$", stem)
+        return match.group(1).strip() if match else ""
+
+    def _chapter_word_count(self, chapter: int) -> int:
+        path = find_chapter_file(self.project_root, chapter)
+        if path is None:
+            return 0
+        try:
+            text = path.read_text(encoding="utf-8")
+        except OSError:
+            return 0
+        text = re.sub(r"```[\s\S]*?```", "", text)
+        text = re.sub(r"^#+ .*$", "", text, flags=re.MULTILINE)
+        text = re.sub(r"---", "", text)
+        return len(text.strip())
+
+    def _stringify(self, value: Any) -> str:
+        if value is None:
+            return ""
+        if isinstance(value, (dict, list)):
+            return json.dumps(value, ensure_ascii=False, sort_keys=True)
+        return str(value)
+
+    def _safe_int(self, value: object) -> int:
+        try:
+            return int(value or 0)
+        except (TypeError, ValueError):
+            return 0
+
+    def _safe_float(self, value: object, default: float) -> float:
+        try:
+            return float(value)
+        except (TypeError, ValueError):
+            return default
+
     def _collect_entity_deltas(self, commit_payload: dict) -> list[dict]:
         deltas = [dict(delta) for delta in (commit_payload.get("entity_deltas") or []) if isinstance(delta, dict)]
         for event in commit_payload.get("accepted_events") or []:

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

@@ -78,11 +78,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)
+
         write_json(state_path, state)
         return {
             "applied": applied_count > 0 or chapter > 0,
             "writer": "state",
             "applied_count": applied_count,
+            "strand_tracker": strand_applied,
         }
 
     def _collect_state_deltas(self, commit_payload: dict) -> list[dict]:
@@ -213,6 +216,78 @@ class StateProjectionWriter:
                 ids.add(eid)
         return ids
 
+    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:
+            return False
+
+        tracker = state.get("strand_tracker")
+        if not isinstance(tracker, dict):
+            tracker = {}
+            state["strand_tracker"] = tracker
+
+        valid = ("quest", "fire", "constellation")
+        for name in valid:
+            tracker.setdefault(f"last_{name}_chapter", 0)
+        tracker.setdefault("current_dominant", None)
+        tracker.setdefault("chapters_since_switch", 0)
+
+        history = tracker.get("history")
+        if not isinstance(history, list):
+            history = []
+
+        replaced_strands = set()
+        cleaned = []
+        for row in history:
+            if not isinstance(row, dict):
+                continue
+            row_chapter = self._safe_int(row.get("chapter"))
+            row_strand = str(row.get("dominant") or "").strip().lower()
+            if row_chapter <= 0 or row_strand not in valid:
+                continue
+            if row_chapter == chapter:
+                replaced_strands.add(row_strand)
+                continue
+            cleaned.append({"chapter": row_chapter, "dominant": row_strand})
+        cleaned.append({"chapter": chapter, "dominant": strand})
+        cleaned.sort(key=lambda row: row["chapter"])
+        if len(cleaned) > 50:
+            cleaned = cleaned[-50:]
+        tracker["history"] = cleaned
+
+        for name in valid:
+            history_last = max((row["chapter"] for row in cleaned if row["dominant"] == name), default=0)
+            existing_last = 0 if name in replaced_strands else self._safe_int(tracker.get(f"last_{name}_chapter"))
+            tracker[f"last_{name}_chapter"] = max(
+                existing_last,
+                history_last,
+            )
+
+        latest = cleaned[-1]
+        current = latest["dominant"]
+        tracker["current_dominant"] = current
+        streak = 0
+        for row in reversed(cleaned):
+            if row["dominant"] != current:
+                break
+            streak += 1
+        tracker["chapters_since_switch"] = streak
+        return True
+
+    def _dominant_strand(self, commit_payload: dict) -> str:
+        chapter_meta = commit_payload.get("chapter_meta") or {}
+        if not isinstance(chapter_meta, dict):
+            chapter_meta = {}
+        raw = (
+            commit_payload.get("dominant_strand")
+            or commit_payload.get("strand")
+            or chapter_meta.get("dominant_strand")
+            or chapter_meta.get("strand")
+            or ""
+        )
+        strand = str(raw or "").strip().lower()
+        return strand if strand in {"quest", "fire", "constellation"} else ""
+
     def _project_total_words(self, chapter_status: dict) -> int:
         total = 0
         for raw_chapter, raw_status in chapter_status.items():

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

@@ -87,6 +87,19 @@ def test_required_writers_includes_vector_for_key_events():
     assert "vector" in writers
 
 
+def test_required_writers_includes_index_for_accepted_commit():
+    router = EventProjectionRouter()
+    writers = router.required_writers(
+        {
+            "meta": {"status": "accepted", "chapter": 5},
+            "accepted_events": [],
+            "entity_deltas": [],
+            "summary_text": "",
+        }
+    )
+    assert "index" in writers
+
+
 def test_router_ignores_unknown_and_non_dict_events():
     router = EventProjectionRouter()
     assert router.route({"event_type": "unknown"}) == []

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

@@ -129,6 +129,66 @@ def test_state_projection_writer_derives_delta_from_power_breakthrough_event(tmp
     assert payload["entity_state"]["xiaoyan"]["realm"] == "斗师"
 
 
+def test_state_projection_writer_updates_strand_tracker(tmp_path):
+    (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(
+        {
+            "meta": {"status": "accepted", "chapter": 3},
+            "state_deltas": [],
+            "accepted_events": [],
+            "dominant_strand": "quest",
+        }
+    )
+    writer.apply(
+        {
+            "meta": {"status": "accepted", "chapter": 4},
+            "state_deltas": [],
+            "accepted_events": [],
+            "dominant_strand": "quest",
+        }
+    )
+
+    payload = json.loads((tmp_path / ".webnovel" / "state.json").read_text(encoding="utf-8"))
+    tracker = payload["strand_tracker"]
+    assert tracker["current_dominant"] == "quest"
+    assert tracker["last_quest_chapter"] == 4
+    assert tracker["chapters_since_switch"] == 2
+    assert len(tracker["history"]) == 2
+
+
+def test_state_projection_writer_reapplying_chapter_replaces_strand(tmp_path):
+    (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(
+        {
+            "meta": {"status": "accepted", "chapter": 3},
+            "state_deltas": [],
+            "accepted_events": [],
+            "dominant_strand": "quest",
+        }
+    )
+    writer.apply(
+        {
+            "meta": {"status": "accepted", "chapter": 3},
+            "state_deltas": [],
+            "accepted_events": [],
+            "dominant_strand": "fire",
+        }
+    )
+
+    payload = json.loads((tmp_path / ".webnovel" / "state.json").read_text(encoding="utf-8"))
+    tracker = payload["strand_tracker"]
+    assert tracker["current_dominant"] == "fire"
+    assert tracker["last_quest_chapter"] == 0
+    assert tracker["last_fire_chapter"] == 3
+    assert tracker["history"] == [{"chapter": 3, "dominant": "fire"}]
+
+
 def test_accepted_commit_updates_state_json_end_to_end(tmp_path):
     (tmp_path / ".webnovel").mkdir(parents=True, exist_ok=True)
     (tmp_path / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
@@ -295,6 +355,81 @@ def test_index_projection_writer_derives_artifact_entity_from_event(tmp_path):
     assert entity["current_json"]["holder"] == "xiaoyan"
 
 
+def test_accepted_commit_writes_chapter_index_tables(tmp_path):
+    cfg = DataModulesConfig.from_project_root(tmp_path)
+    cfg.ensure_dirs()
+    (tmp_path / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
+    chapters_dir = tmp_path / "正文"
+    chapters_dir.mkdir(parents=True, exist_ok=True)
+    (chapters_dir / "第0003章.md").write_text("第三章正文内容", encoding="utf-8")
+
+    service = ChapterCommitService(tmp_path)
+    payload = service.build_commit(
+        chapter=3,
+        review_result={"blocking_count": 0},
+        fulfillment_result={"planned_nodes": [], "covered_nodes": [], "missed_nodes": [], "extra_nodes": []},
+        disambiguation_result={"pending": []},
+        extraction_result={
+            "summary_text": "本章摘要",
+            "state_deltas": [{"entity_id": "xiaoyan", "field": "realm", "old": "斗者", "new": "斗师"}],
+            "entity_deltas": [],
+            "entities_appeared": [{"id": "xiaoyan", "mentions": ["萧炎"], "confidence": 0.95}],
+            "scenes": [
+                {
+                    "index": 1,
+                    "start_line": 1,
+                    "end_line": 12,
+                    "location": "山门",
+                    "summary": "萧炎完成突破",
+                    "characters": ["xiaoyan"],
+                }
+            ],
+            "accepted_events": [],
+        },
+    )
+
+    result = service.apply_projections(payload)
+    manager = IndexManager(cfg)
+
+    assert result["projection_status"]["index"] == "done"
+    assert manager.get_chapter(3)["summary"] == "本章摘要"
+    assert manager.get_chapter_appearances(3)[0]["entity_id"] == "xiaoyan"
+    assert manager.get_scenes(3)[0]["location"] == "山门"
+    changes = manager.get_chapter_state_changes(3)
+    assert len(changes) == 1
+    assert changes[0]["entity_id"] == "xiaoyan"
+    assert changes[0]["field"] == "realm"
+
+
+def test_index_projection_writer_records_state_change_from_event(tmp_path):
+    cfg = DataModulesConfig.from_project_root(tmp_path)
+    cfg.ensure_dirs()
+    writer = IndexProjectionWriter(tmp_path)
+
+    result = writer.apply(
+        {
+            "meta": {"status": "accepted", "chapter": 3},
+            "state_deltas": [],
+            "entity_deltas": [],
+            "accepted_events": [
+                {
+                    "event_id": "evt-001",
+                    "chapter": 3,
+                    "event_type": "character_state_changed",
+                    "subject": "xiaoyan",
+                    "payload": {"field": "mood", "old": "躁动", "new": "冷静"},
+                }
+            ],
+        }
+    )
+
+    changes = IndexManager(cfg).get_chapter_state_changes(3)
+    assert result["state_changes"] == 1
+    assert len(changes) == 1
+    assert changes[0]["entity_id"] == "xiaoyan"
+    assert changes[0]["field"] == "mood"
+
+
 def test_summary_projection_writer_writes_summary_markdown(tmp_path):
     cfg = DataModulesConfig.from_project_root(tmp_path)
     cfg.ensure_dirs()