Browse Source

fix: unify chapter commit artifacts and projection gates

lingfengQAQ 2 weeks ago
parent
commit
fe84641afa
23 changed files with 415 additions and 179 deletions
  1. 4 6
      webnovel-writer/agents/data-agent.md
  2. 5 1
      webnovel-writer/agents/reviewer.md
  3. 40 12
      webnovel-writer/scripts/data_modules/artifact_validator.py
  4. 13 12
      webnovel-writer/scripts/data_modules/chapter_commit_service.py
  5. 51 0
      webnovel-writer/scripts/data_modules/commit_artifacts.py
  6. 5 3
      webnovel-writer/scripts/data_modules/event_projection_router.py
  7. 11 12
      webnovel-writer/scripts/data_modules/index_projection_writer.py
  8. 4 3
      webnovel-writer/scripts/data_modules/memory/writer.py
  9. 3 2
      webnovel-writer/scripts/data_modules/memory_contract_adapter.py
  10. 6 7
      webnovel-writer/scripts/data_modules/state_projection_writer.py
  11. 3 1
      webnovel-writer/scripts/data_modules/summary_projection_writer.py
  12. 39 0
      webnovel-writer/scripts/data_modules/tests/test_artifact_validator.py
  13. 20 12
      webnovel-writer/scripts/data_modules/tests/test_chapter_commit_service.py
  14. 38 0
      webnovel-writer/scripts/data_modules/tests/test_commit_artifacts.py
  15. 61 96
      webnovel-writer/scripts/data_modules/tests/test_projection_writers.py
  16. 9 0
      webnovel-writer/scripts/data_modules/tests/test_prompt_integrity.py
  17. 12 0
      webnovel-writer/scripts/data_modules/tests/test_webnovel_unified_cli.py
  18. 50 1
      webnovel-writer/scripts/data_modules/tests/test_write_gates.py
  19. 6 4
      webnovel-writer/scripts/data_modules/vector_projection_writer.py
  20. 25 3
      webnovel-writer/scripts/data_modules/write_gates/postcommit.py
  21. 7 1
      webnovel-writer/scripts/review_pipeline.py
  22. 2 2
      webnovel-writer/skills/webnovel-review/SKILL.md
  23. 1 1
      webnovel-writer/skills/webnovel-write/SKILL.md

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

@@ -31,7 +31,7 @@ chapter-commit 由写章主流程运行,data-agent 不在此执行(见 §5 
 
 **C 生成 artifacts**:产出三份 JSON 到 `.webnovel/tmp/`,顶层结构见 §7。
 
-**D 摘要**:100-150 字,含钩子类型。格式:
+**D 摘要与场景切片**:写入 `extraction_result.json` 的 `summary_text` 与 `scenes` 字段。摘要 100-150 字,场景切片 50-100 字/场景,字段为 `index/start_line/end_line/location/summary/characters/content`。
 
 ```markdown
 ---
@@ -51,9 +51,7 @@ hook_strength: "strong"
 {30字}
 ```
 
-长期记忆只提炼"可跨章复用"的事实,转成 events/deltas 写入 extraction_result。摘要 `## 伏笔` 中每条 `[埋设]` 必须同步写一条 `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。
+长期记忆只提炼"可跨章复用"的事实,转成 events/deltas 写入 extraction_result。摘要中的每条埋设伏笔必须同步写一条 `accepted_events[].event_type == "open_loop_created"`;已回收则用 `promise_paid_off` 或对应闭合事件。
 
 ## 4. 输入
 
@@ -68,7 +66,7 @@ hook_strength: "strong"
 
 ## 6. 校验清单
 
-实体识别完整、三份 artifact 已生成且 schema 合格、摘要已生成、场景索引已写入、观测日志有效
+实体识别完整、三份 artifact 已生成且 schema 合格、`summary_text` 已填写、`scenes` 已作为 artifact 字段填写
 
 ## 7. 输出 schema(唯一真源)
 
@@ -108,4 +106,4 @@ hook_strength: "strong"
 
 ## 8. 错误处理
 
-artifacts 失败→重跑 C/D。commit 失败→修复 JSON 后补提。索引失败→只补跑 E。耗时>30s→附原因。
+artifacts 失败→重跑 C/D。commit 失败→修复三份 JSON 后补提。projection 失败不由 data-agent 修复,由主流程补跑 `projections retry`。耗时>30s→附原因。

+ 5 - 1
webnovel-writer/agents/reviewer.md

@@ -92,10 +92,11 @@ python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" ind
 
 ## 7. 输出格式
 
-严格按以下 JSON 格式输出(无其他文本)
+严格按以下 JSON 格式输出(无其他文本)。`issues_count`、`blocking_count`、`has_blocking` 必须与 `issues` 一致;review-pipeline 会复核并覆盖写回标准 artifact。
 
 ```json
 {
+  "chapter": 100,
   "issues": [
     {
       "severity": "critical | high | medium | low",
@@ -107,6 +108,9 @@ python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" ind
       "blocking": true
     }
   ],
+  "issues_count": 1,
+  "blocking_count": 1,
+  "has_blocking": true,
   "dimension_results": [
     {"dimension": "setting", "conclusion": "pass"},
     {"dimension": "timeline", "conclusion": "发现1个问题:上章黄昏→本章晨光,无时间流逝交代"},

+ 40 - 12
webnovel-writer/scripts/data_modules/artifact_validator.py

@@ -24,6 +24,10 @@ ERROR_BLOCKING_REVIEW = "blocking_review"
 ERROR_MISSED_OUTLINE_NODE = "missed_outline_node"
 ERROR_PENDING_DISAMBIGUATION = "pending_disambiguation"
 ERROR_PROJECTION_FAILURE = "projection_failure"
+ERROR_PROJECTION_INCOMPLETE = "projection_incomplete"
+
+REQUIRED_PROJECTION_WRITERS = ("state", "index", "summary", "memory", "vector")
+OK_PROJECTION_STATUSES = {"done", "skipped"}
 
 ARTIFACT_SCHEMAS = {
     "review_result": ReviewResult,
@@ -275,19 +279,43 @@ def validate_chapter_commit(path: str | Path) -> dict[str, Any]:
         nested_reports.append(validate_artifact_payload(artifact, payload.get(artifact), path=str(commit_path)))
 
     projection_status = payload.get("projection_status") or {}
-    if isinstance(projection_status, dict):
-        for writer, status in projection_status.items():
-            if str(status).startswith("failed:"):
-                report["errors"].append(
-                    _issue(
-                        ERROR_PROJECTION_FAILURE,
-                        message=f"projection {writer} failed: {status}",
-                        path=str(commit_path),
-                        field=f"projection_status.{writer}",
-                        impact="提交事实已生成,但 read-model 投影不完整。",
-                        repair="修复失败原因后补跑 projection retry/replay。",
-                    )
+    if not isinstance(projection_status, dict):
+        projection_status = {}
+    for writer in REQUIRED_PROJECTION_WRITERS:
+        status = str(projection_status.get(writer) or "").strip()
+        if not status:
+            report["errors"].append(
+                _issue(
+                    ERROR_PROJECTION_INCOMPLETE,
+                    message=f"projection {writer} status missing",
+                    path=str(commit_path),
+                    field=f"projection_status.{writer}",
+                    impact="postcommit 必须确认 state/index/summary/memory/vector 五项投影状态。",
+                    repair="重新执行 chapter-commit 或补跑 projections retry/replay。",
                 )
+            )
+        elif status.startswith("failed:"):
+            report["errors"].append(
+                _issue(
+                    ERROR_PROJECTION_FAILURE,
+                    message=f"projection {writer} failed: {status}",
+                    path=str(commit_path),
+                    field=f"projection_status.{writer}",
+                    impact="提交事实已生成,但 read-model 投影不完整。",
+                    repair="修复失败原因后补跑 projection retry/replay。",
+                )
+            )
+        elif status not in OK_PROJECTION_STATUSES:
+            report["errors"].append(
+                _issue(
+                    ERROR_PROJECTION_INCOMPLETE,
+                    message=f"projection {writer} status is {status}",
+                    path=str(commit_path),
+                    field=f"projection_status.{writer}",
+                    impact="postcommit 只接受 projection 状态 done 或 skipped。",
+                    repair="等待投影完成或补跑 projections retry/replay。",
+                )
+            )
 
     merged = merge_reports(nested_reports, artifact="chapter_commit_nested")
     report["errors"].extend(merged["errors"])

+ 13 - 12
webnovel-writer/scripts/data_modules/chapter_commit_service.py

@@ -13,6 +13,7 @@ from .chapter_commit_schema import (
     FulfillmentResult,
     ReviewResult,
 )
+from .commit_artifacts import extraction_list
 from .config import DataModulesConfig
 from .event_log_store import EventLogStore
 from .event_projection_router import EventProjectionRouter
@@ -49,6 +50,8 @@ class ChapterCommitService:
         accepted_events = EventLogStore(self.project_root).normalize_events(
             chapter, extraction.accepted_events
         )
+        extraction_payload = extraction.model_dump()
+        extraction_payload["accepted_events"] = accepted_events
         return {
             "meta": {
                 "schema_version": "story-system/v1",
@@ -75,14 +78,7 @@ class ChapterCommitService:
             "review_result": review.model_dump(),
             "fulfillment_result": fulfillment.model_dump(),
             "disambiguation_result": disambiguation.model_dump(),
-            "accepted_events": accepted_events,
-            "state_deltas": extraction.state_deltas,
-            "entity_deltas": extraction.entity_deltas,
-            "entities_appeared": extraction.entities_appeared,
-            "scenes": extraction.scenes,
-            "chapter_meta": extraction.chapter_meta,
-            "dominant_strand": extraction.dominant_strand,
-            "summary_text": extraction.summary_text,
+            "extraction_result": extraction_payload,
             "projection_status": {
                 "state": "pending",
                 "index": "pending",
@@ -173,12 +169,17 @@ class ChapterCommitService:
         if status == "accepted":
             chapter = int((payload.get("meta") or {}).get("chapter") or 0)
             event_store = EventLogStore(self.project_root)
-            payload["accepted_events"] = event_store.normalize_events(
-                chapter, payload.get("accepted_events", [])
+            accepted_events = extraction_list(payload, "accepted_events")
+            extraction = payload.setdefault("extraction_result", {})
+            if not isinstance(extraction, dict):
+                extraction = {}
+                payload["extraction_result"] = extraction
+            extraction["accepted_events"] = event_store.normalize_events(
+                chapter, accepted_events
             )
-            event_store.write_events(chapter, payload["accepted_events"])
+            event_store.write_events(chapter, extraction["accepted_events"])
 
-            proposals = AmendProposalTrigger().check(chapter, payload.get("accepted_events", []))
+            proposals = AmendProposalTrigger().check(chapter, extraction["accepted_events"])
             if proposals:
                 manager = IndexManager(DataModulesConfig.from_project_root(self.project_root))
                 with manager._get_conn() as conn:

+ 51 - 0
webnovel-writer/scripts/data_modules/commit_artifacts.py

@@ -0,0 +1,51 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from __future__ import annotations
+
+from typing import Any
+
+
+EXTRACTION_FIELDS = (
+    "accepted_events",
+    "state_deltas",
+    "entity_deltas",
+    "entities_appeared",
+    "scenes",
+    "chapter_meta",
+    "dominant_strand",
+    "summary_text",
+)
+
+
+def extraction_result_from_commit(commit_payload: dict[str, Any]) -> dict[str, Any]:
+    """Return the canonical extraction artifact from a commit.
+
+    New commits store the extraction snapshot under ``extraction_result``.
+    Older commits stored these fields at top level, so this helper keeps
+    projections readable without preserving two write shapes. If the
+    canonical nested artifact exists, it is the only source of truth.
+    """
+    nested = commit_payload.get("extraction_result")
+    if isinstance(nested, dict):
+        return dict(nested)
+
+    result: dict[str, Any] = {}
+    for field in EXTRACTION_FIELDS:
+        if field in commit_payload:
+            result[field] = commit_payload.get(field)
+    return result
+
+
+def extraction_list(commit_payload: dict[str, Any], field: str) -> list[Any]:
+    value = extraction_result_from_commit(commit_payload).get(field)
+    return value if isinstance(value, list) else []
+
+
+def extraction_dict(commit_payload: dict[str, Any], field: str) -> dict[str, Any]:
+    value = extraction_result_from_commit(commit_payload).get(field)
+    return value if isinstance(value, dict) else {}
+
+
+def extraction_text(commit_payload: dict[str, Any], field: str) -> str:
+    value = extraction_result_from_commit(commit_payload).get(field)
+    return str(value or "").strip()

+ 5 - 3
webnovel-writer/scripts/data_modules/event_projection_router.py

@@ -4,6 +4,8 @@ from __future__ import annotations
 
 from typing import Dict, List, Set
 
+from .commit_artifacts import extraction_list, extraction_text
+
 
 class EventProjectionRouter:
     TABLE = {
@@ -31,11 +33,11 @@ class EventProjectionRouter:
         if status == "accepted":
             writers.add("state")
             writers.add("index")
-        if commit_payload.get("entity_deltas"):
+        if extraction_list(commit_payload, "entity_deltas"):
             writers.add("index")
-        if str(commit_payload.get("summary_text") or "").strip():
+        if extraction_text(commit_payload, "summary_text"):
             writers.add("summary")
-        for event in commit_payload.get("accepted_events") or []:
+        for event in extraction_list(commit_payload, "accepted_events"):
             if not isinstance(event, dict):
                 continue
             writers.update(self.route(event))

+ 11 - 12
webnovel-writer/scripts/data_modules/index_projection_writer.py

@@ -7,6 +7,7 @@ import re
 from pathlib import Path
 from typing import Any
 
+from .commit_artifacts import extraction_dict, extraction_list, extraction_text
 from .config import DataModulesConfig
 from .index_manager import ChapterMeta, IndexManager, SceneMeta, StateChangeMeta
 
@@ -61,9 +62,7 @@ class IndexProjectionWriter:
         if chapter <= 0:
             return False
 
-        meta = commit_payload.get("chapter_meta") or {}
-        if not isinstance(meta, dict):
-            meta = {}
+        meta = extraction_dict(commit_payload, "chapter_meta")
 
         title = str(
             meta.get("title")
@@ -72,7 +71,7 @@ class IndexProjectionWriter:
             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()
+        summary = str(extraction_text(commit_payload, "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)
@@ -95,7 +94,7 @@ class IndexProjectionWriter:
 
     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 []
+        scenes = extraction_list(commit_payload, "scenes")
         if chapter <= 0 or not isinstance(scenes, list) or not scenes:
             return 0
 
@@ -125,7 +124,7 @@ class IndexProjectionWriter:
 
     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 []
+        entities = extraction_list(commit_payload, "entities_appeared")
         if chapter <= 0 or not isinstance(entities, list):
             return 0
 
@@ -179,7 +178,7 @@ class IndexProjectionWriter:
     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 [])
+            for delta in extraction_list(commit_payload, "state_deltas")
             if isinstance(delta, dict)
         ]
         seen = {
@@ -191,7 +190,7 @@ class IndexProjectionWriter:
             for delta in deltas
         }
 
-        for event in commit_payload.get("accepted_events") or []:
+        for event in extraction_list(commit_payload, "accepted_events"):
             if not isinstance(event, dict):
                 continue
             event_type = str(event.get("event_type") or "").strip()
@@ -275,13 +274,13 @@ class IndexProjectionWriter:
 
     def _collect_character_ids(self, commit_payload: dict) -> list[str]:
         ids: list[str] = []
-        for entity in commit_payload.get("entities_appeared") or []:
+        for entity in extraction_list(commit_payload, "entities_appeared"):
             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 []:
+        for delta in extraction_list(commit_payload, "entity_deltas"):
             if not isinstance(delta, dict):
                 continue
             entity_id = str(delta.get("entity_id") or delta.get("id") or "").strip()
@@ -331,8 +330,8 @@ class IndexProjectionWriter:
             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 []:
+        deltas = [dict(delta) for delta in extraction_list(commit_payload, "entity_deltas") if isinstance(delta, dict)]
+        for event in extraction_list(commit_payload, "accepted_events"):
             if not isinstance(event, dict):
                 continue
             event_type = str(event.get("event_type") or "").strip()

+ 4 - 3
webnovel-writer/scripts/data_modules/memory/writer.py

@@ -8,6 +8,7 @@ from __future__ import annotations
 import hashlib
 from typing import Any, Dict, List
 
+from ..commit_artifacts import extraction_list
 from ..config import DataModulesConfig, get_config
 from ..urgency_utils import coerce_urgency
 from .schema import MemoryItem
@@ -271,8 +272,8 @@ class MemoryWriter:
 
     def apply_commit_projection(self, commit_payload: Dict[str, Any]) -> Dict[str, Any]:
         chapter = int((commit_payload.get("meta") or {}).get("chapter") or 0)
-        entity_deltas = list(commit_payload.get("entity_deltas") or [])
-        accepted_events = list(commit_payload.get("accepted_events") or [])
+        entity_deltas = list(extraction_list(commit_payload, "entity_deltas"))
+        accepted_events = list(extraction_list(commit_payload, "accepted_events"))
 
         memory_facts: Dict[str, Any] = {
             "timeline_events": [],
@@ -358,7 +359,7 @@ class MemoryWriter:
                 and str(row.get("entity_id") or row.get("id") or "").strip()
                 and not (row.get("from_entity") or row.get("from"))
             ],
-            "state_changes": list(commit_payload.get("state_deltas") or []),
+            "state_changes": list(extraction_list(commit_payload, "state_deltas")),
             "relationships_new": [
                 {
                     "from": row.get("from_entity") or row.get("from"),

+ 3 - 2
webnovel-writer/scripts/data_modules/memory_contract_adapter.py

@@ -12,6 +12,7 @@ from pathlib import Path
 from typing import Any, Dict, List, Optional
 
 from .chapter_commit_service import ChapterCommitService
+from .commit_artifacts import extraction_list
 from .config import DataModulesConfig, get_config
 from .memory_contract import (
     CommitResult,
@@ -134,9 +135,9 @@ class MemoryContractAdapter:
         summary_file = self.config.webnovel_dir / "summaries" / f"ch{chapter:04d}.md"
         return CommitResult(
             chapter=chapter,
-            entities_added=len(payload.get("entity_deltas") or []),
+            entities_added=len(extraction_list(payload, "entity_deltas")),
             entities_updated=0,
-            state_changes_recorded=len(payload.get("state_deltas") or []),
+            state_changes_recorded=len(extraction_list(payload, "state_deltas")),
             relationships_added=0,
             memory_items_added=0,
             summary_path=str(summary_file) if summary_file.exists() else "",

+ 6 - 7
webnovel-writer/scripts/data_modules/state_projection_writer.py

@@ -9,6 +9,7 @@ from typing import Any
 
 import filelock
 
+from .commit_artifacts import extraction_dict, extraction_list, extraction_text
 from .story_contracts import read_json_if_exists
 
 try:
@@ -122,7 +123,7 @@ class StateProjectionWriter:
     def _collect_state_deltas(self, commit_payload: dict) -> list[dict]:
         deltas = [
             self._normalize_state_delta(delta)
-            for delta in (commit_payload.get("state_deltas") or [])
+            for delta in extraction_list(commit_payload, "state_deltas")
             if isinstance(delta, dict)
         ]
         seen = {
@@ -130,7 +131,7 @@ class StateProjectionWriter:
             for delta in deltas
         }
 
-        for event in commit_payload.get("accepted_events") or []:
+        for event in extraction_list(commit_payload, "accepted_events"):
             if not isinstance(event, dict):
                 continue
             event_type = str(event.get("event_type") or "").strip()
@@ -227,7 +228,7 @@ class StateProjectionWriter:
             ids.add(existing_eid)
         protagonist_name = str(protagonist_state.get("name") or "").strip()
 
-        for delta in commit_payload.get("entity_deltas") or []:
+        for delta in extraction_list(commit_payload, "entity_deltas"):
             if not isinstance(delta, dict):
                 continue
             eid = str(delta.get("entity_id") or delta.get("id") or "").strip()
@@ -306,11 +307,9 @@ class StateProjectionWriter:
         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 = {}
+        chapter_meta = extraction_dict(commit_payload, "chapter_meta")
         raw = (
-            commit_payload.get("dominant_strand")
+            extraction_text(commit_payload, "dominant_strand")
             or commit_payload.get("strand")
             or chapter_meta.get("dominant_strand")
             or chapter_meta.get("strand")

+ 3 - 1
webnovel-writer/scripts/data_modules/summary_projection_writer.py

@@ -4,10 +4,12 @@ from __future__ import annotations
 
 from pathlib import Path
 
+from .commit_artifacts import extraction_text
+
 
 def append_summary_projection(project_root: Path, commit_payload: dict) -> dict:
     chapter = int(commit_payload.get("meta", {}).get("chapter") or 0)
-    summary_text = str(commit_payload.get("summary_text") or "").strip()
+    summary_text = extraction_text(commit_payload, "summary_text")
     if chapter <= 0 or not summary_text:
         return {"applied": False, "writer": "summary", "reason": "missing_summary"}
 

+ 39 - 0
webnovel-writer/scripts/data_modules/tests/test_artifact_validator.py

@@ -21,6 +21,7 @@ from data_modules.artifact_validator import (  # noqa: E402
     ERROR_MISSING,
     ERROR_PENDING_DISAMBIGUATION,
     ERROR_PROJECTION_FAILURE,
+    ERROR_PROJECTION_INCOMPLETE,
     ERROR_SCHEMA,
     validate_chapter_commit,
     validate_commit_artifact_files,
@@ -161,6 +162,44 @@ def test_validate_chapter_commit_reports_projection_failure(tmp_path):
     assert any(item["type"] == ERROR_PROJECTION_FAILURE for item in report["errors"])
 
 
+def test_validate_chapter_commit_requires_all_projection_writers(tmp_path):
+    commit = _write_json(
+        tmp_path / "chapter_001.commit.json",
+        {
+            "meta": {"chapter": 1, "status": "accepted"},
+            "review_result": {"blocking_count": 0},
+            "fulfillment_result": {
+                "planned_nodes": [],
+                "covered_nodes": [],
+                "missed_nodes": [],
+                "extra_nodes": [],
+            },
+            "disambiguation_result": {"pending": []},
+            "extraction_result": {
+                "accepted_events": [],
+                "state_deltas": [],
+                "entity_deltas": [],
+                "summary_text": "摘要",
+            },
+            "projection_status": {
+                "state": "done",
+                "index": "done",
+                "summary": "done",
+                "memory": "skipped",
+            },
+        },
+    )
+
+    report = validate_chapter_commit(commit)
+
+    assert report["ok"] is False
+    incomplete = [
+        item for item in report["errors"] if item["type"] == ERROR_PROJECTION_INCOMPLETE
+    ]
+    assert incomplete
+    assert any("vector" in item["message"] for item in incomplete)
+
+
 def test_artifact_validator_rejects_missing_required_top_level_fields(tmp_path):
     """precommit 负向用例:缺关键顶层字段时 runtime validator 必须拦截。
 

+ 20 - 12
webnovel-writer/scripts/data_modules/tests/test_chapter_commit_service.py

@@ -42,6 +42,10 @@ def test_commit_service_accepts_when_all_checks_pass(tmp_path):
     assert payload["contract_refs"]["volume"] == "volume_001.json"
     assert payload["contract_refs"]["chapter"] == "chapter_003.json"
     assert payload["outline_snapshot"]["covered_nodes"] == ["发现陷阱"]
+    assert payload["extraction_result"]["accepted_events"] == []
+    assert "accepted_events" not in payload
+    assert "state_deltas" not in payload
+    assert "entity_deltas" not in payload
 
 
 def test_commit_service_includes_volume_ref_and_write_fact_provenance(tmp_path):
@@ -236,11 +240,12 @@ def test_commit_service_normalizes_accepted_events_before_projection(tmp_path):
         },
     )
 
-    event = payload["accepted_events"][0]
+    event = payload["extraction_result"]["accepted_events"][0]
     assert event["event_id"].startswith("evt-ch076-001-")
     assert event["chapter"] == 76
     assert event["event_type"] == "open_loop_created"
     assert event["subject"] == "xiaoyan"
+    assert "accepted_events" not in payload
 
 
 def test_apply_projections_normalizes_events_before_router_inspection(
@@ -250,7 +255,7 @@ def test_apply_projections_normalizes_events_before_router_inspection(
 
     class SpyRouter:
         def required_writers(self, payload):
-            captured["events"] = list(payload.get("accepted_events") or [])
+            captured["events"] = list(payload.get("extraction_result", {}).get("accepted_events") or [])
             return []
 
     monkeypatch.setattr(
@@ -261,15 +266,18 @@ def test_apply_projections_normalizes_events_before_router_inspection(
     service = ChapterCommitService(tmp_path)
     payload = {
         "meta": {"status": "accepted", "chapter": 76},
-        "accepted_events": [
-            {
-                "type": "scene_open",
-                "characters": ["xiaoyan"],
-                "payload": {"content": "萧炎推开石门,新的悬念出现"},
-            }
-        ],
-        "entity_deltas": [],
-        "summary_text": "",
+        "extraction_result": {
+            "accepted_events": [
+                {
+                    "type": "scene_open",
+                    "characters": ["xiaoyan"],
+                    "payload": {"content": "萧炎推开石门,新的悬念出现"},
+                }
+            ],
+            "state_deltas": [],
+            "entity_deltas": [],
+            "summary_text": "",
+        },
         "projection_status": {
             "state": "pending",
             "index": "pending",
@@ -286,7 +294,7 @@ def test_apply_projections_normalizes_events_before_router_inspection(
     assert event["chapter"] == 76
     assert event["event_type"] == "open_loop_created"
     assert event["subject"] == "xiaoyan"
-    assert payload["accepted_events"] == captured["events"]
+    assert payload["extraction_result"]["accepted_events"] == captured["events"]
 
 
 def test_apply_projections_updates_state_for_rejected_commit(tmp_path):

+ 38 - 0
webnovel-writer/scripts/data_modules/tests/test_commit_artifacts.py

@@ -0,0 +1,38 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+from data_modules.commit_artifacts import (
+    extraction_list,
+    extraction_result_from_commit,
+    extraction_text,
+)
+
+
+def test_extraction_result_prefers_canonical_nested_payload():
+    payload = {
+        "extraction_result": {
+            "accepted_events": [{"event_id": "nested"}],
+            "summary_text": "nested summary",
+        },
+        "accepted_events": [{"event_id": "legacy"}],
+        "summary_text": "legacy summary",
+    }
+
+    extraction = extraction_result_from_commit(payload)
+
+    assert extraction["accepted_events"] == [{"event_id": "nested"}]
+    assert extraction["summary_text"] == "nested summary"
+    assert extraction_list(payload, "accepted_events") == [{"event_id": "nested"}]
+    assert extraction_text(payload, "summary_text") == "nested summary"
+
+
+def test_extraction_result_keeps_read_compatibility_for_legacy_commit_payload():
+    payload = {
+        "accepted_events": [{"event_id": "legacy"}],
+        "summary_text": "legacy summary",
+    }
+
+    extraction = extraction_result_from_commit(payload)
+
+    assert extraction["accepted_events"] == [{"event_id": "legacy"}]
+    assert extraction["summary_text"] == "legacy summary"

+ 61 - 96
webnovel-writer/scripts/data_modules/tests/test_projection_writers.py

@@ -15,11 +15,29 @@ from data_modules.summary_projection_writer import SummaryProjectionWriter
 from data_modules.vector_projection_writer import VectorProjectionWriter
 
 
+def _commit_payload(*, chapter=3, status="accepted", **extraction):
+    extraction_payload = {
+        "accepted_events": [],
+        "state_deltas": [],
+        "entity_deltas": [],
+        "entities_appeared": [],
+        "scenes": [],
+        "chapter_meta": {},
+        "dominant_strand": "",
+        "summary_text": "",
+    }
+    extraction_payload.update(extraction)
+    return {
+        "meta": {"status": status, "chapter": chapter},
+        "extraction_result": extraction_payload,
+    }
+
+
 def test_state_projection_writer_handles_rejected_commit(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)
-    result = writer.apply({"meta": {"status": "rejected", "chapter": 3}, "state_deltas": []})
+    result = writer.apply(_commit_payload(status="rejected"))
     assert result["applied"] is True
     state = json.loads((tmp_path / ".webnovel" / "state.json").read_text(encoding="utf-8"))
     assert state["progress"]["chapter_status"]["3"] == "chapter_rejected"
@@ -30,10 +48,7 @@ def test_state_projection_writer_applies_accepted_commit(tmp_path):
     (tmp_path / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
     writer = StateProjectionWriter(tmp_path)
     result = writer.apply(
-        {
-            "meta": {"status": "accepted", "chapter": 3},
-            "state_deltas": [{"entity_id": "x", "field": "realm", "new": "斗者"}],
-        }
+        _commit_payload(state_deltas=[{"entity_id": "x", "field": "realm", "new": "斗者"}])
     )
     assert result["applied"] is True
     payload = json.loads((tmp_path / ".webnovel" / "state.json").read_text(encoding="utf-8"))
@@ -89,11 +104,7 @@ def test_reapplying_accepted_chapter_commit_does_not_double_count_words(tmp_path
     chapters_dir.mkdir(parents=True, exist_ok=True)
     (chapters_dir / "第0001章.md").write_text("第一章正文内容", encoding="utf-8")
 
-    payload = {
-        "meta": {"status": "accepted", "chapter": 1},
-        "state_deltas": [],
-        "accepted_events": [],
-    }
+    payload = _commit_payload(chapter=1)
     writer = StateProjectionWriter(tmp_path)
     writer.apply(payload)
     first_state = json.loads((tmp_path / ".webnovel" / "state.json").read_text(encoding="utf-8"))
@@ -111,10 +122,8 @@ def test_state_projection_writer_derives_delta_from_power_breakthrough_event(tmp
     (tmp_path / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
     writer = StateProjectionWriter(tmp_path)
     result = writer.apply(
-        {
-            "meta": {"status": "accepted", "chapter": 3},
-            "state_deltas": [],
-            "accepted_events": [
+        _commit_payload(
+            accepted_events=[
                 {
                     "event_id": "evt-001",
                     "chapter": 3,
@@ -123,7 +132,7 @@ def test_state_projection_writer_derives_delta_from_power_breakthrough_event(tmp
                     "payload": {"from": "斗者", "to": "斗师"},
                 }
             ],
-        }
+        )
     )
 
     payload = json.loads((tmp_path / ".webnovel" / "state.json").read_text(encoding="utf-8"))
@@ -137,20 +146,10 @@ def test_state_projection_writer_updates_strand_tracker(tmp_path):
     writer = StateProjectionWriter(tmp_path)
 
     writer.apply(
-        {
-            "meta": {"status": "accepted", "chapter": 3},
-            "state_deltas": [],
-            "accepted_events": [],
-            "dominant_strand": "quest",
-        }
+        _commit_payload(chapter=3, dominant_strand="quest")
     )
     writer.apply(
-        {
-            "meta": {"status": "accepted", "chapter": 4},
-            "state_deltas": [],
-            "accepted_events": [],
-            "dominant_strand": "quest",
-        }
+        _commit_payload(chapter=4, dominant_strand="quest")
     )
 
     payload = json.loads((tmp_path / ".webnovel" / "state.json").read_text(encoding="utf-8"))
@@ -167,20 +166,10 @@ def test_state_projection_writer_reapplying_chapter_replaces_strand(tmp_path):
     writer = StateProjectionWriter(tmp_path)
 
     writer.apply(
-        {
-            "meta": {"status": "accepted", "chapter": 3},
-            "state_deltas": [],
-            "accepted_events": [],
-            "dominant_strand": "quest",
-        }
+        _commit_payload(chapter=3, dominant_strand="quest")
     )
     writer.apply(
-        {
-            "meta": {"status": "accepted", "chapter": 3},
-            "state_deltas": [],
-            "accepted_events": [],
-            "dominant_strand": "fire",
-        }
+        _commit_payload(chapter=3, dominant_strand="fire")
     )
 
     payload = json.loads((tmp_path / ".webnovel" / "state.json").read_text(encoding="utf-8"))
@@ -215,9 +204,8 @@ def test_index_projection_writer_applies_entity_delta(tmp_path):
     writer = IndexProjectionWriter(tmp_path)
 
     result = writer.apply(
-        {
-            "meta": {"status": "accepted", "chapter": 3},
-            "entity_deltas": [
+        _commit_payload(
+            entity_deltas=[
                 {
                     "entity_id": "xiaoyan",
                     "canonical_name": "萧炎",
@@ -226,7 +214,7 @@ def test_index_projection_writer_applies_entity_delta(tmp_path):
                     "chapter": 3,
                 }
             ],
-        }
+        )
     )
 
     entity = IndexManager(cfg).get_entity("xiaoyan")
@@ -241,9 +229,9 @@ def test_index_projection_writer_registers_stable_protagonist_aliases(tmp_path):
     writer = IndexProjectionWriter(tmp_path)
 
     result = writer.apply(
-        {
-            "meta": {"status": "accepted", "chapter": 1},
-            "entity_deltas": [
+        _commit_payload(
+            chapter=1,
+            entity_deltas=[
                 {
                     "entity_id": "lu_ming",
                     "canonical_name": "陆鸣",
@@ -253,7 +241,7 @@ def test_index_projection_writer_registers_stable_protagonist_aliases(tmp_path):
                     "is_protagonist": True,
                 }
             ],
-        }
+        )
     )
 
     manager = IndexManager(cfg)
@@ -302,10 +290,8 @@ def test_index_projection_writer_derives_relationship_from_event(tmp_path):
     writer = IndexProjectionWriter(tmp_path)
 
     result = writer.apply(
-        {
-            "meta": {"status": "accepted", "chapter": 3},
-            "entity_deltas": [],
-            "accepted_events": [
+        _commit_payload(
+            accepted_events=[
                 {
                     "event_id": "evt-001",
                     "chapter": 3,
@@ -318,7 +304,7 @@ def test_index_projection_writer_derives_relationship_from_event(tmp_path):
                     },
                 }
             ],
-        }
+        )
     )
 
     rels = IndexManager(cfg).get_relationship_between("xiaoyan", "yaolao")
@@ -332,10 +318,8 @@ def test_index_projection_writer_derives_artifact_entity_from_event(tmp_path):
     writer = IndexProjectionWriter(tmp_path)
 
     result = writer.apply(
-        {
-            "meta": {"status": "accepted", "chapter": 3},
-            "entity_deltas": [],
-            "accepted_events": [
+        _commit_payload(
+            accepted_events=[
                 {
                     "event_id": "evt-002",
                     "chapter": 3,
@@ -348,7 +332,7 @@ def test_index_projection_writer_derives_artifact_entity_from_event(tmp_path):
                     },
                 }
             ],
-        }
+        )
     )
 
     entity = IndexManager(cfg).get_entity("black_ring")
@@ -461,11 +445,8 @@ def test_index_projection_writer_records_state_change_from_event(tmp_path):
     writer = IndexProjectionWriter(tmp_path)
 
     result = writer.apply(
-        {
-            "meta": {"status": "accepted", "chapter": 3},
-            "state_deltas": [],
-            "entity_deltas": [],
-            "accepted_events": [
+        _commit_payload(
+            accepted_events=[
                 {
                     "event_id": "evt-001",
                     "chapter": 3,
@@ -474,7 +455,7 @@ def test_index_projection_writer_records_state_change_from_event(tmp_path):
                     "payload": {"field": "mood", "old": "躁动", "new": "冷静"},
                 }
             ],
-        }
+        )
     )
 
     changes = IndexManager(cfg).get_chapter_state_changes(3)
@@ -490,10 +471,7 @@ def test_summary_projection_writer_writes_summary_markdown(tmp_path):
     writer = SummaryProjectionWriter(tmp_path)
 
     result = writer.apply(
-        {
-            "meta": {"status": "accepted", "chapter": 3},
-            "summary_text": "本章主角发现陷阱并决定隐忍。",
-        }
+        _commit_payload(summary_text="本章主角发现陷阱并决定隐忍。")
     )
 
     summary_path = tmp_path / ".webnovel" / "summaries" / "ch0003.md"
@@ -506,10 +484,7 @@ def test_summary_projection_writer_replay_overwrites_not_appends(tmp_path):
     cfg = DataModulesConfig.from_project_root(tmp_path)
     cfg.ensure_dirs()
     writer = SummaryProjectionWriter(tmp_path)
-    payload = {
-        "meta": {"status": "accepted", "chapter": 3},
-        "summary_text": "本章主角发现陷阱并决定隐忍。",
-    }
+    payload = _commit_payload(summary_text="本章主角发现陷阱并决定隐忍。")
 
     writer.apply(payload)
     writer.apply(payload)
@@ -525,14 +500,11 @@ def test_memory_projection_writer_maps_commit_into_scratchpad(tmp_path):
     writer = MemoryProjectionWriter(tmp_path)
 
     result = writer.apply(
-        {
-            "meta": {"status": "accepted", "chapter": 3},
-            "state_deltas": [
+        _commit_payload(
+            state_deltas=[
                 {"entity_id": "xiaoyan", "field": "realm", "old": "斗者", "new": "斗师"}
             ],
-            "entity_deltas": [],
-            "accepted_events": [],
-        }
+        )
     )
 
     store = ScratchpadManager(cfg)
@@ -545,14 +517,11 @@ def test_memory_projection_writer_is_idempotent_for_replay(tmp_path):
     cfg = DataModulesConfig.from_project_root(tmp_path)
     cfg.ensure_dirs()
     writer = MemoryProjectionWriter(tmp_path)
-    payload = {
-        "meta": {"status": "accepted", "chapter": 3},
-        "state_deltas": [
+    payload = _commit_payload(
+        state_deltas=[
             {"entity_id": "xiaoyan", "field": "realm", "old": "斗者", "new": "斗师"}
         ],
-        "entity_deltas": [],
-        "accepted_events": [],
-    }
+    )
 
     writer.apply(payload)
     writer.apply(payload)
@@ -577,10 +546,9 @@ def test_vector_projection_writer_is_idempotent_for_replay(tmp_path, monkeypatch
     cfg = DataModulesConfig.from_project_root(tmp_path)
     cfg.ensure_dirs()
     writer = VectorProjectionWriter(tmp_path)
-    payload = {
-        "meta": {"status": "accepted", "chapter": 3},
-        "summary_text": "本章主角发现陷阱并决定隐忍。",
-        "entity_deltas": [
+    payload = _commit_payload(
+        summary_text="本章主角发现陷阱并决定隐忍。",
+        entity_deltas=[
             {
                 "entity_id": "xiaoyan",
                 "canonical_name": "萧炎",
@@ -588,7 +556,7 @@ def test_vector_projection_writer_is_idempotent_for_replay(tmp_path, monkeypatch
                 "chapter": 3,
             }
         ],
-        "accepted_events": [
+        accepted_events=[
             {
                 "event_id": "evt-power-3",
                 "chapter": 3,
@@ -597,14 +565,14 @@ def test_vector_projection_writer_is_idempotent_for_replay(tmp_path, monkeypatch
                 "payload": {"to": "斗师"},
             }
         ],
-        "scenes": [
+        scenes=[
             {
                 "index": 1,
                 "location": "山门",
                 "summary": "萧炎完成突破",
             }
         ],
-    }
+    )
 
     writer.apply(payload)
     writer.apply(payload)
@@ -625,11 +593,8 @@ def test_memory_projection_writer_maps_open_loop_event_into_scratchpad(tmp_path)
     writer = MemoryProjectionWriter(tmp_path)
 
     result = writer.apply(
-        {
-            "meta": {"status": "accepted", "chapter": 3},
-            "state_deltas": [],
-            "entity_deltas": [],
-            "accepted_events": [
+        _commit_payload(
+            accepted_events=[
                 {
                     "event_id": "evt-001",
                     "chapter": 3,
@@ -638,7 +603,7 @@ def test_memory_projection_writer_maps_open_loop_event_into_scratchpad(tmp_path)
                     "payload": {"content": "三年之约"},
                 }
             ],
-        }
+        )
     )
 
     store = ScratchpadManager(cfg)

+ 9 - 0
webnovel-writer/scripts/data_modules/tests/test_prompt_integrity.py

@@ -203,6 +203,8 @@ def test_review_schema_consistency():
     assert schema_fields, "无法从 review_schema.py 提取字段"
     extra = issue_fields_in_prompt - schema_fields
     assert not extra, f"reviewer.md 中有字段不在 review_schema.py 中: {extra}"
+    assert "blocking_count" in reviewer_text
+    assert "issues_count" in reviewer_text
 
 
 # ---------------------------------------------------------------------------
@@ -341,6 +343,13 @@ def test_data_agent_is_described_as_extraction_only_not_direct_write_mainline():
     assert "event_type" in text
     assert "subject" in text
     assert "直接写入 index.db 和 state.json" not in text
+    for forbidden in (
+        "RAG 向量索引",
+        "observability",
+        "场景索引已写入",
+        "索引失败",
+    ):
+        assert forbidden not in text, f"data-agent.md 不应保留 projection 写入语义: {forbidden}"
     # data-agent 不得携带可运行的 chapter-commit 命令(commit 是主流程的事实提交入口,data-agent 只产 artifact)
     assert not re.search(r"webnovel\.py[^\n]+chapter-commit", text), (
         "data-agent.md 不应出现可运行的 webnovel.py ... chapter-commit 命令"

+ 12 - 0
webnovel-writer/scripts/data_modules/tests/test_webnovel_unified_cli.py

@@ -667,6 +667,12 @@ def test_review_pipeline_builds_artifacts(tmp_path):
     assert payload["metrics"]["overall_score"] < 100
     assert payload["metrics"]["report_file"] == "审查报告/第20章.md"
 
+    persisted_review = json.loads(review_results_path.read_text(encoding="utf-8"))
+    assert persisted_review["chapter"] == 20
+    assert persisted_review["issues_count"] == 2
+    assert persisted_review["blocking_count"] == 1
+    assert persisted_review["has_blocking"] is True
+
 
 def test_review_pipeline_forwards_with_resolved_project_root(monkeypatch, tmp_path):
     module = _load_webnovel_module()
@@ -860,6 +866,12 @@ def test_review_pipeline_main_creates_output_directories(tmp_path):
     assert "小问题" in report_text
     assert "## 其他问题" in report_text
 
+    persisted_review = json.loads(review_results_path.read_text(encoding="utf-8"))
+    assert persisted_review["chapter"] == 9
+    assert persisted_review["issues_count"] == 1
+    assert persisted_review["blocking_count"] == 0
+    assert persisted_review["has_blocking"] is False
+
     import sqlite3
 
     with sqlite3.connect(project_root / ".webnovel" / "index.db") as conn:

+ 50 - 1
webnovel-writer/scripts/data_modules/tests/test_write_gates.py

@@ -215,6 +215,49 @@ def test_postcommit_gate_prefers_projection_log_failure(tmp_path):
     assert report["details"]["projection_source"] == "projection_log"
 
 
+def test_postcommit_gate_requires_five_projection_statuses_from_projection_log(tmp_path):
+    _make_init_ready(tmp_path)
+    commit_payload = {
+        "meta": {"chapter": 1, "status": "accepted"},
+        "review_result": {"blocking_count": 0},
+        "fulfillment_result": {
+            "planned_nodes": [],
+            "covered_nodes": [],
+            "missed_nodes": [],
+            "extra_nodes": [],
+        },
+        "disambiguation_result": {"pending": []},
+        "extraction_result": {
+            "accepted_events": [],
+            "state_deltas": [],
+            "entity_deltas": [],
+            "summary_text": "摘要",
+        },
+        "projection_status": {
+            "state": "done",
+            "index": "done",
+            "summary": "skipped",
+            "memory": "skipped",
+            "vector": "done",
+        },
+    }
+    commit_path = tmp_path / ".story-system" / "commits" / "chapter_001.commit.json"
+    _write_json(commit_path, commit_payload)
+    append_projection_run(
+        tmp_path,
+        commit_payload,
+        {"vector": {"status": "done"}},
+        commit_path=commit_path,
+    )
+
+    report = run_write_gate(tmp_path, chapter=1, stage="postcommit")
+
+    assert report["ok"] is False
+    assert report["details"]["projection_source"] == "projection_log"
+    assert any(item["code"] == "projection_status_missing" for item in report["errors"])
+    assert any("state" in item["message"] for item in report["errors"])
+
+
 def test_postcommit_gate_accepts_done_or_skipped_projection(tmp_path):
     _make_init_ready(tmp_path)
     _write_json(
@@ -235,7 +278,13 @@ def test_postcommit_gate_accepts_done_or_skipped_projection(tmp_path):
                 "entity_deltas": [],
                 "summary_text": "摘要",
             },
-            "projection_status": {"state": "done", "index": "skipped", "summary": "skipped", "memory": "skipped"},
+            "projection_status": {
+                "state": "done",
+                "index": "skipped",
+                "summary": "skipped",
+                "memory": "skipped",
+                "vector": "skipped",
+            },
         },
     )
 

+ 6 - 4
webnovel-writer/scripts/data_modules/vector_projection_writer.py

@@ -10,6 +10,8 @@ from collections.abc import Coroutine
 from pathlib import Path
 from typing import Any, Dict, List
 
+from .commit_artifacts import extraction_list, extraction_text
+
 logger = logging.getLogger(__name__)
 
 
@@ -45,7 +47,7 @@ class VectorProjectionWriter:
 
         chunk_counts: Dict[str, int] = {}
 
-        summary_text = str(commit_payload.get("summary_text") or "").strip()
+        summary_text = extraction_text(commit_payload, "summary_text")
         summary_chunk_id = f"ch{chapter:04d}_summary" if chapter > 0 else ""
         if chapter > 0 and summary_text:
             chunks.append({
@@ -58,7 +60,7 @@ class VectorProjectionWriter:
                 "source_file": f"commit:chapter_{chapter:03d}",
             })
 
-        for event in commit_payload.get("accepted_events") or []:
+        for event in extraction_list(commit_payload, "accepted_events"):
             if not isinstance(event, dict):
                 continue
             text = self._event_to_text(event)
@@ -76,7 +78,7 @@ class VectorProjectionWriter:
                     "source_file": f"commit:chapter_{evt_chapter:03d}",
                 })
 
-        for delta in commit_payload.get("entity_deltas") or []:
+        for delta in extraction_list(commit_payload, "entity_deltas"):
             if not isinstance(delta, dict):
                 continue
             text = self._delta_to_text(delta)
@@ -94,7 +96,7 @@ class VectorProjectionWriter:
                     "source_file": f"commit:chapter_{d_chapter:03d}",
                 })
 
-        for idx, scene in enumerate(commit_payload.get("scenes") or [], start=1):
+        for idx, scene in enumerate(extraction_list(commit_payload, "scenes"), start=1):
             if not isinstance(scene, dict):
                 continue
             scene_index = int(scene.get("scene_index") or scene.get("index") or idx)

+ 25 - 3
webnovel-writer/scripts/data_modules/write_gates/postcommit.py

@@ -5,6 +5,7 @@ from __future__ import annotations
 from pathlib import Path
 
 from ..artifact_validator import validate_chapter_commit
+from ..artifact_validator import OK_PROJECTION_STATUSES, REQUIRED_PROJECTION_WRITERS
 from ..config import DataModulesConfig
 from ..project_phase import resolve_project_phase
 from ..projection_log import latest_projection_run, projection_status_from_run
@@ -74,9 +75,20 @@ def run_postcommit_gate(project_root: Path, chapter: int) -> dict:
         payload,
     )
     if isinstance(projection_status, dict):
-        for writer, writer_status in projection_status.items():
-            status_text = str(writer_status)
-            if projection_source == "projection_log" and status_text.startswith("failed"):
+        for writer in REQUIRED_PROJECTION_WRITERS:
+            status_text = str(projection_status.get(writer) or "").strip()
+            if not status_text:
+                errors.append(
+                    issue(
+                        "projection_status_missing",
+                        message=f"projection {writer} status missing",
+                        path=str(commit_path),
+                        impact="postcommit 必须确认 state/index/summary/memory/vector 五项投影状态。",
+                        repair="重新执行 chapter-commit 或补跑 projection retry/replay。",
+                        details={"source": projection_source},
+                    )
+                )
+            elif status_text.startswith("failed"):
                 errors.append(
                     issue(
                         "projection_failure",
@@ -97,6 +109,16 @@ def run_postcommit_gate(project_root: Path, chapter: int) -> dict:
                         repair="重新运行 chapter-commit 或后续 projection retry/replay。",
                     )
                 )
+            elif status_text not in OK_PROJECTION_STATUSES:
+                errors.append(
+                    issue(
+                        "projection_status_invalid",
+                        message=f"projection {writer} status is {status_text}",
+                        path=str(commit_path),
+                        impact="postcommit 只接受 projection 状态 done 或 skipped。",
+                        repair="等待投影完成或补跑 projection retry/replay。",
+                    )
+                )
 
     cfg = DataModulesConfig.from_project_root(project_root)
     if isinstance(projection_status, dict) and projection_status.get("summary") == "done":

+ 7 - 1
webnovel-writer/scripts/review_pipeline.py

@@ -151,10 +151,16 @@ def build_review_artifacts(
     result = parse_review_output(chapter=chapter, raw=raw)
     anti_patterns_added = append_ai_flavor_anti_patterns(project_root, result)
     metrics = result.to_metrics_dict(report_file=report_file)
+    normalized_review = result.to_dict()
+    review_results_path.parent.mkdir(parents=True, exist_ok=True)
+    review_results_path.write_text(
+        json.dumps(normalized_review, ensure_ascii=False, indent=2),
+        encoding="utf-8",
+    )
 
     return {
         "chapter": chapter,
-        "review_result": result.to_dict(),
+        "review_result": normalized_review,
         "metrics": metrics,
         "anti_patterns_added": anti_patterns_added,
     }

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

@@ -16,7 +16,7 @@ argument-hint: "[章号或范围,如 5 或 1-5]"
 ## 红线
 
 - 必须通过 `Agent` 工具调用 `reviewer`,禁止主流程伪造结论或口头总结代替 subagent 输出。
-- reviewer 只返回严格 JSON;主流程负责把返回值写入 `${PROJECT_ROOT}/.webnovel/tmp/review_results.json`。
+- reviewer 只返回严格 JSON;主流程负责把返回值写入 `${PROJECT_ROOT}/.webnovel/tmp/review_results.json`,随后由 `review-pipeline` 覆盖为标准 review_result artifact
 - 报告与 metrics 只由 `review-pipeline --save-metrics` 产出;主流程不伪造 `overall_score`。
 - 项目根不合法 / 缺 `.webnovel/state.json` / 缺待审正文 → 阻断。
 
@@ -71,7 +71,7 @@ Use the Agent tool to run `webnovel-writer:reviewer`.
 Prompt: chapter={chapter_num}; chapter_file={chapter_file}; project_root=${PROJECT_ROOT}; scripts_dir=${SCRIPTS_DIR}。严格输出 reviewer schema JSON,不评分,不口头总结。
 ```
 
-reviewer 返回后,主流程把严格 JSON 写入 `${PROJECT_ROOT}/.webnovel/tmp/review_results.json`(reviewer 不持 Write,是这份 artifact 的非写入方)。
+reviewer 返回后,主流程把严格 JSON 写入 `${PROJECT_ROOT}/.webnovel/tmp/review_results.json`(reviewer 不持 Write,是这份 artifact 的非写入方)。`review-pipeline` 必须把同一路径覆盖为标准 review_result artifact(含 `blocking_count`)。
 
 ### Step 6:生成报告并落库
 

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

@@ -114,7 +114,7 @@ Task:
 - 只返回严格的 reviewer schema JSON,不写任何文件。
 - 不评分、不口头总结。
 
-reviewer 只返回 JSON;主流程负责用 `Write` 把返回的 JSON 写入 `${PROJECT_ROOT}/.webnovel/tmp/review_results.json`(reviewer 不持 Write,是这份 artifact 的非写入方),随后运行 review-pipeline
+reviewer 只返回 JSON;主流程负责用 `Write` 把返回的 JSON 写入 `${PROJECT_ROOT}/.webnovel/tmp/review_results.json`(reviewer 不持 Write,是这份 artifact 的非写入方)。随后必须运行 review-pipeline;review-pipeline 会把同一路径覆盖为标准 review_result artifact(含 `blocking_count`),供 precommit gate 与后续提交命令使用
 
 ```bash
 python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" review-pipeline \