Просмотр исходного кода

fix: validate data-agent commit artifacts

lingfengQAQ 1 месяц назад
Родитель
Сommit
dee4c256a7

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

@@ -36,8 +36,8 @@ python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" chap
 **C 生成 artifacts**:
 
 产出三份 JSON 到 `.webnovel/tmp/`:
-- `fulfillment_result.json`:大纲履约(覆盖/遗漏节点)
-- `disambiguation_result.json`:消歧状态
+- `fulfillment_result.json`:顶层必须包含 `planned_nodes`、`covered_nodes`、`missed_nodes`、`extra_nodes` 四个数组
+- `disambiguation_result.json`:顶层必须包含 `pending` 数组
 - `extraction_result.json`:必须包含 `accepted_events`、`state_deltas`、`entity_deltas`、`entities_appeared`、`scenes`、`summary_text`;能判断主导情节线时写 `dominant_strand`
 
 **D 摘要**:100-150 字,含钩子类型。格式:
@@ -91,7 +91,7 @@ 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": [{"event_type": "open_loop_created", "subject": "three_year_promise", "payload": {"content": "三年之约提及"}}],
+  "accepted_events": [{"event_id": "evt-ch100-001", "chapter": 100, "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,
@@ -105,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|open_loop_closed|promise_created|promise_paid_off|artifact_obtained`)`subject` 是事件主体的 entity_id(不是中文名)。
+- **accepted_events 通用**:每条必须包含 `event_id`、`chapter`、`event_type`、`subject`、`payload`。`event_id` 用章节内稳定 ID(如 `evt-ch100-001`);`chapter` 写当前章号;`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`。

+ 278 - 0
webnovel-writer/scripts/data_modules/chapter_commit_schema.py

@@ -0,0 +1,278 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from __future__ import annotations
+
+import hashlib
+import json
+from typing import Any, ClassVar
+
+from pydantic import (
+    BaseModel,
+    ConfigDict,
+    Field,
+    ValidationInfo,
+    field_validator,
+    model_validator,
+)
+
+from .story_event_schema import StoryEvent
+
+EXTRACTION_CORE_FIELDS = ("accepted_events", "state_deltas", "entity_deltas")
+EXTRACTION_LIST_FIELDS = (
+    "accepted_events",
+    "state_deltas",
+    "entity_deltas",
+    "entities_appeared",
+    "scenes",
+)
+FULFILLMENT_LIST_FIELDS = (
+    "planned_nodes",
+    "covered_nodes",
+    "missed_nodes",
+    "extra_nodes",
+)
+
+EVENT_TYPE_ALIASES = {
+    "character_state": "character_state_changed",
+    "character_state_change": "character_state_changed",
+    "state_changed": "character_state_changed",
+    "relationship_change": "relationship_changed",
+    "relation_changed": "relationship_changed",
+    "world_rule": "world_rule_revealed",
+    "rule_revealed": "world_rule_revealed",
+    "rule_broken": "world_rule_broken",
+    "breakthrough": "power_breakthrough",
+    "power_up": "power_breakthrough",
+    "artifact": "artifact_obtained",
+    "item_obtained": "artifact_obtained",
+    "promise": "promise_created",
+    "promise_resolved": "promise_paid_off",
+    "promise_fulfilled": "promise_paid_off",
+    "mystery_introduction": "open_loop_created",
+    "mystery_introduced": "open_loop_created",
+    "unresolved_thread": "open_loop_created",
+    "scene_open": "open_loop_created",
+    "open_loop": "open_loop_created",
+    "loop_closed": "open_loop_closed",
+}
+
+
+class CommitArtifactModel(BaseModel):
+    model_config = ConfigDict(extra="allow")
+
+    artifact_name: ClassVar[str]
+    wrapper_key: ClassVar[str | None] = None
+    required_top_level_fields: ClassVar[tuple[str, ...]] = ()
+
+    @model_validator(mode="before")
+    @classmethod
+    def validate_top_level_shape(cls, value: Any) -> Any:
+        if not isinstance(value, dict):
+            raise ValueError(f"{cls.artifact_name} must be a JSON object")
+
+        wrapper_key = cls.wrapper_key
+        if wrapper_key and wrapper_key in value:
+            if cls.artifact_name == "extraction_result":
+                raise ValueError(
+                    "extraction_result must expose accepted_events/state_deltas/entity_deltas "
+                    "as top-level fields, not nested under extraction"
+                )
+            raise ValueError(
+                f"{cls.artifact_name} fields must be top-level, not nested under {wrapper_key}"
+            )
+
+        missing = [
+            field for field in cls.required_top_level_fields if field not in value
+        ]
+        if missing:
+            raise ValueError(
+                f"{cls.artifact_name} missing required top-level fields: "
+                + ", ".join(missing)
+            )
+        return value
+
+
+def _ensure_list(artifact_name: str, field_name: str, value: Any) -> Any:
+    if not isinstance(value, list):
+        raise ValueError(f"{artifact_name}.{field_name} must be a list")
+    return value
+
+
+def _ensure_object_list(artifact_name: str, field_name: str, value: Any) -> Any:
+    _ensure_list(artifact_name, field_name, value)
+    for index, item in enumerate(value):
+        if not isinstance(item, dict):
+            raise ValueError(f"{artifact_name}.{field_name}[{index}] must be a JSON object")
+    return value
+
+
+class ReviewResult(CommitArtifactModel):
+    artifact_name: ClassVar[str] = "review_result"
+    wrapper_key: ClassVar[str | None] = "review"
+    required_top_level_fields: ClassVar[tuple[str, ...]] = ("blocking_count",)
+
+    blocking_count: int = Field(ge=0, strict=True)
+
+
+class FulfillmentResult(CommitArtifactModel):
+    artifact_name: ClassVar[str] = "fulfillment_result"
+    wrapper_key: ClassVar[str | None] = "fulfillment"
+    required_top_level_fields: ClassVar[tuple[str, ...]] = FULFILLMENT_LIST_FIELDS
+
+    planned_nodes: list[Any]
+    covered_nodes: list[Any]
+    missed_nodes: list[Any]
+    extra_nodes: list[Any]
+
+    @field_validator(*FULFILLMENT_LIST_FIELDS, mode="before")
+    @classmethod
+    def validate_list_fields(cls, value: Any, info: ValidationInfo) -> Any:
+        return _ensure_list(cls.artifact_name, info.field_name, value)
+
+
+class DisambiguationResult(CommitArtifactModel):
+    artifact_name: ClassVar[str] = "disambiguation_result"
+    wrapper_key: ClassVar[str | None] = "disambiguation"
+    required_top_level_fields: ClassVar[tuple[str, ...]] = ("pending",)
+
+    pending: list[Any]
+
+    @field_validator("pending", mode="before")
+    @classmethod
+    def validate_pending(cls, value: Any, info: ValidationInfo) -> Any:
+        return _ensure_list(cls.artifact_name, info.field_name, value)
+
+
+class ExtractionResult(CommitArtifactModel):
+    artifact_name: ClassVar[str] = "extraction_result"
+    wrapper_key: ClassVar[str | None] = "extraction"
+    required_top_level_fields: ClassVar[tuple[str, ...]] = EXTRACTION_CORE_FIELDS
+
+    accepted_events: list[dict[str, Any]]
+    state_deltas: list[dict[str, Any]]
+    entity_deltas: list[dict[str, Any]]
+    entities_appeared: list[dict[str, Any]] = Field(default_factory=list)
+    scenes: list[dict[str, Any]] = Field(default_factory=list)
+    chapter_meta: Any = Field(default_factory=dict)
+    dominant_strand: Any = ""
+    summary_text: str = ""
+
+    @field_validator(*EXTRACTION_LIST_FIELDS, mode="before")
+    @classmethod
+    def validate_object_list_fields(cls, value: Any, info: ValidationInfo) -> Any:
+        return _ensure_object_list(cls.artifact_name, info.field_name, value)
+
+    @field_validator("summary_text", mode="before")
+    @classmethod
+    def validate_summary_text(cls, value: Any) -> Any:
+        if not isinstance(value, str):
+            raise ValueError("extraction_result.summary_text must be a string")
+        return value
+
+
+class AcceptedEventInput(BaseModel):
+    model_config = ConfigDict(extra="allow")
+
+    event_id: str
+    chapter: int = Field(ge=1)
+    event_type: str
+    subject: str
+    payload: dict[str, Any] = Field(default_factory=dict)
+
+    @model_validator(mode="before")
+    @classmethod
+    def normalize_aliases(cls, value: Any, info: ValidationInfo) -> Any:
+        if not isinstance(value, dict):
+            index = _event_context_index(info)
+            raise ValueError(f"accepted_events[{index}] must be a JSON object")
+
+        payload = dict(value)
+        context = info.context or {}
+        chapter = int(payload.get("chapter") or context.get("chapter") or 0)
+        payload["chapter"] = chapter
+
+        event_type = str(payload.get("event_type") or payload.get("type") or "").strip()
+        if event_type:
+            normalized_type = event_type.lower().replace("-", "_")
+            payload["event_type"] = EVENT_TYPE_ALIASES.get(normalized_type, normalized_type)
+
+        subject = _event_subject(payload)
+        if not subject:
+            index = _event_context_index(info)
+            raise ValueError(
+                f"accepted_events[{index}].subject must be a non-empty string"
+            )
+        payload["subject"] = subject
+
+        if not str(payload.get("event_id") or "").strip():
+            index = _event_context_index(info)
+            payload["event_id"] = _generated_event_id(chapter, index + 1, payload)
+
+        return payload
+
+
+class AcceptedEventsInput(BaseModel):
+    accepted_events: list[Any]
+
+    @field_validator("accepted_events", mode="before")
+    @classmethod
+    def validate_events_list(cls, value: Any) -> Any:
+        if not isinstance(value, list):
+            raise ValueError("accepted_events must be a list")
+        return value
+
+    def normalize(self, chapter: int) -> list[dict[str, Any]]:
+        normalized: list[dict[str, Any]] = []
+        for index, event in enumerate(self.accepted_events):
+            if not isinstance(event, dict):
+                raise ValueError(f"accepted_events[{index}] must be a JSON object")
+            payload = AcceptedEventInput.model_validate(
+                event,
+                context={"chapter": chapter, "index": index},
+            ).model_dump()
+            normalized.append(StoryEvent.model_validate(payload).model_dump())
+        return normalized
+
+
+def normalize_accepted_events(chapter: int, events: Any) -> list[dict[str, Any]]:
+    accepted_events = AcceptedEventsInput.model_validate({"accepted_events": events})
+    return accepted_events.normalize(chapter)
+
+
+def _event_context_index(info: ValidationInfo) -> int:
+    context = info.context or {}
+    return int(context.get("index") or 0)
+
+
+def _event_subject(payload: dict[str, Any]) -> str:
+    for key in ("subject", "entity_id", "from_entity", "to_entity"):
+        value = payload.get(key)
+        if isinstance(value, str) and value.strip():
+            return value.strip()
+
+    characters = payload.get("characters")
+    if isinstance(characters, str) and characters.strip():
+        return characters.strip()
+    if isinstance(characters, list):
+        for character in characters:
+            if isinstance(character, str) and character.strip():
+                return character.strip()
+
+    event_payload = payload.get("payload") or {}
+    if isinstance(event_payload, dict):
+        for key in ("subject", "entity_id", "owner", "holder", "artifact_id", "name"):
+            value = event_payload.get(key)
+            if isinstance(value, str) and value.strip():
+                return value.strip()
+    return ""
+
+
+def _generated_event_id(chapter: int, index: int, payload: dict[str, Any]) -> str:
+    stable_payload = {
+        key: value
+        for key, value in payload.items()
+        if key not in {"event_id", "chapter"}
+    }
+    raw = json.dumps(stable_payload, ensure_ascii=False, sort_keys=True)
+    digest = hashlib.sha1(raw.encode("utf-8")).hexdigest()[:10]
+    return f"evt-ch{chapter:03d}-{index:03d}-{digest}"

+ 36 - 19
webnovel-writer/scripts/data_modules/chapter_commit_service.py

@@ -7,6 +7,12 @@ from typing import Any, Dict
 
 from chapter_outline_loader import volume_num_for_chapter_from_state
 
+from .chapter_commit_schema import (
+    DisambiguationResult,
+    ExtractionResult,
+    FulfillmentResult,
+    ReviewResult,
+)
 from .config import DataModulesConfig
 from .event_log_store import EventLogStore
 from .event_projection_router import EventProjectionRouter
@@ -31,11 +37,18 @@ class ChapterCommitService:
         disambiguation_result: Dict[str, Any],
         extraction_result: Dict[str, Any],
     ) -> Dict[str, Any]:
-        rejected = bool(review_result.get("blocking_count")) or bool(
-            fulfillment_result.get("missed_nodes")
-        ) or bool(disambiguation_result.get("pending"))
+        review = ReviewResult.model_validate(review_result)
+        fulfillment = FulfillmentResult.model_validate(fulfillment_result)
+        disambiguation = DisambiguationResult.model_validate(disambiguation_result)
+        extraction = ExtractionResult.model_validate(extraction_result)
+        rejected = bool(review.blocking_count) or bool(
+            fulfillment.missed_nodes
+        ) or bool(disambiguation.pending)
         status = "rejected" if rejected else "accepted"
         volume = volume_num_for_chapter_from_state(self.project_root, chapter) or 1
+        accepted_events = EventLogStore(self.project_root).normalize_events(
+            chapter, extraction.accepted_events
+        )
         return {
             "meta": {
                 "schema_version": "story-system/v1",
@@ -54,22 +67,22 @@ class ChapterCommitService:
                 "legacy_state_role": "projection_only",
             },
             "outline_snapshot": {
-                "planned_nodes": fulfillment_result.get("planned_nodes", []),
-                "covered_nodes": fulfillment_result.get("covered_nodes", []),
-                "missed_nodes": fulfillment_result.get("missed_nodes", []),
-                "extra_nodes": fulfillment_result.get("extra_nodes", []),
+                "planned_nodes": fulfillment.planned_nodes,
+                "covered_nodes": fulfillment.covered_nodes,
+                "missed_nodes": fulfillment.missed_nodes,
+                "extra_nodes": fulfillment.extra_nodes,
             },
-            "review_result": review_result,
-            "fulfillment_result": fulfillment_result,
-            "disambiguation_result": disambiguation_result,
-            "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", ""),
+            "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,
             "projection_status": {
                 "state": "pending",
                 "index": "pending",
@@ -91,7 +104,11 @@ class ChapterCommitService:
             return payload
 
         chapter = int((payload.get("meta") or {}).get("chapter") or 0)
-        EventLogStore(self.project_root).write_events(chapter, payload.get("accepted_events", []))
+        event_store = EventLogStore(self.project_root)
+        payload["accepted_events"] = event_store.normalize_events(
+            chapter, payload.get("accepted_events", [])
+        )
+        event_store.write_events(chapter, payload["accepted_events"])
 
         proposals = AmendProposalTrigger().check(chapter, payload.get("accepted_events", []))
         if proposals:

+ 5 - 12
webnovel-writer/scripts/data_modules/event_log_store.py

@@ -9,8 +9,8 @@ from contextlib import contextmanager
 from pathlib import Path
 from typing import Any, Dict, Iterator, List
 
+from .chapter_commit_schema import normalize_accepted_events
 from .story_contracts import StoryContractPaths, read_json_if_exists, write_json
-from .story_event_schema import StoryEvent
 
 
 class EventLogStore:
@@ -31,8 +31,8 @@ class EventLogStore:
         finally:
             conn.close()
 
-    def write_events(self, chapter: int, events: List[dict]) -> Path:
-        normalized = self._normalize_events(chapter, events)
+    def write_events(self, chapter: int, events: Any) -> Path:
+        normalized = self.normalize_events(chapter, events)
         path = self.paths.event_json(chapter)
         write_json(path, normalized)
         self._write_sqlite_mirror(normalized)
@@ -103,15 +103,8 @@ class EventLogStore:
                     sqlite_rows = 0
         return {"ok": True, "sqlite_rows": sqlite_rows, "event_files": file_count}
 
-    def _normalize_events(self, chapter: int, events: List[dict]) -> List[Dict[str, Any]]:
-        normalized: List[Dict[str, Any]] = []
-        for event in events or []:
-            if not isinstance(event, dict):
-                continue
-            payload = dict(event)
-            payload["chapter"] = int(payload.get("chapter") or chapter)
-            normalized.append(StoryEvent.model_validate(payload).model_dump())
-        return normalized
+    def normalize_events(self, chapter: int, events: Any) -> List[Dict[str, Any]]:
+        return normalize_accepted_events(chapter, events)
 
     def _write_sqlite_mirror(self, events: List[Dict[str, Any]]) -> None:
         with self._connect() as conn:

+ 114 - 0
webnovel-writer/scripts/data_modules/tests/test_chapter_commit_schema.py

@@ -0,0 +1,114 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+import pytest
+
+from data_modules.chapter_commit_schema import (
+    DisambiguationResult,
+    ExtractionResult,
+    FulfillmentResult,
+    ReviewResult,
+    normalize_accepted_events,
+)
+
+
+def test_artifact_models_preserve_valid_top_level_payloads():
+    review = ReviewResult.model_validate(
+        {"blocking_count": 0, "issues_count": 2, "has_blocking": False}
+    )
+    fulfillment = FulfillmentResult.model_validate(
+        {
+            "planned_nodes": ["find trap"],
+            "covered_nodes": ["find trap"],
+            "missed_nodes": [],
+            "extra_nodes": [],
+        }
+    )
+    disambiguation = DisambiguationResult.model_validate({"pending": []})
+    extraction = ExtractionResult.model_validate(
+        {
+            "accepted_events": [],
+            "state_deltas": [{"entity_id": "xiaoyan", "field": "realm", "new": "fighter"}],
+            "entity_deltas": [],
+            "summary_text": "summary",
+        }
+    )
+
+    assert review.model_dump()["issues_count"] == 2
+    assert fulfillment.covered_nodes == ["find trap"]
+    assert disambiguation.pending == []
+    assert extraction.state_deltas[0]["entity_id"] == "xiaoyan"
+
+
+def test_artifact_models_reject_nested_wrappers_and_missing_core_fields():
+    with pytest.raises(ValueError, match="nested under fulfillment"):
+        FulfillmentResult.model_validate({"fulfillment": {"missed_nodes": []}})
+
+    with pytest.raises(ValueError, match="nested under disambiguation"):
+        DisambiguationResult.model_validate({"disambiguation": {"pending": []}})
+
+    with pytest.raises(ValueError, match="nested under extraction"):
+        ExtractionResult.model_validate(
+            {
+                "accepted_events": [],
+                "state_deltas": [],
+                "entity_deltas": [],
+                "extraction": {"summary_text": "wrapped"},
+            }
+        )
+
+    with pytest.raises(ValueError, match="accepted_events"):
+        ExtractionResult.model_validate({"state_deltas": [], "entity_deltas": []})
+
+
+def test_accepted_event_model_normalizes_aliases_before_story_event_validation():
+    events = normalize_accepted_events(
+        76,
+        [
+            {
+                "type": "scene_open",
+                "characters": ["xiaoyan"],
+                "payload": {"content": "new mystery"},
+            }
+        ],
+    )
+
+    assert events[0]["event_id"].startswith("evt-ch076-001-")
+    assert events[0]["chapter"] == 76
+    assert events[0]["event_type"] == "open_loop_created"
+    assert events[0]["subject"] == "xiaoyan"
+
+
+def test_accepted_event_model_rejects_malformed_event_collections():
+    with pytest.raises(ValueError, match="accepted_events must be a list"):
+        normalize_accepted_events(3, {"event_type": "open_loop_created"})
+
+    with pytest.raises(ValueError, match=r"accepted_events\[0\]"):
+        normalize_accepted_events(3, ["not-a-json-object"])
+
+
+def test_accepted_event_model_rejects_blank_subject_and_unknown_type():
+    with pytest.raises(ValueError, match="subject"):
+        normalize_accepted_events(
+            3,
+            [
+                {
+                    "event_type": "open_loop_created",
+                    "subject": "   ",
+                    "payload": {"content": "三年之约提及"},
+                }
+            ],
+        )
+
+    with pytest.raises(ValueError, match="event_type"):
+        normalize_accepted_events(
+            3,
+            [
+                {
+                    "event_id": "evt-unknown",
+                    "event_type": "not_a_story_event",
+                    "subject": "xiaoyan",
+                    "payload": {},
+                }
+            ],
+        )

+ 238 - 1
webnovel-writer/scripts/data_modules/tests/test_chapter_commit_service.py

@@ -4,6 +4,8 @@
 import sys
 from pathlib import Path
 
+import pytest
+
 from data_modules.chapter_commit_service import ChapterCommitService
 from data_modules.config import DataModulesConfig
 from data_modules.index_manager import IndexManager
@@ -14,7 +16,12 @@ def test_commit_service_rejects_when_missed_nodes_exist(tmp_path):
     payload = service.build_commit(
         chapter=3,
         review_result={"blocking_count": 0},
-        fulfillment_result={"planned_nodes": ["发现陷阱"], "missed_nodes": ["发现陷阱"]},
+        fulfillment_result={
+            "planned_nodes": ["发现陷阱"],
+            "covered_nodes": [],
+            "missed_nodes": ["发现陷阱"],
+            "extra_nodes": [],
+        },
         disambiguation_result={"pending": []},
         extraction_result={"state_deltas": [], "entity_deltas": [], "accepted_events": []},
     )
@@ -52,6 +59,236 @@ def test_commit_service_includes_volume_ref_and_write_fact_provenance(tmp_path):
     assert payload["provenance"]["projection_role"] == "derived_read_models"
 
 
+def test_commit_service_rejects_malformed_gate_artifacts(tmp_path):
+    service = ChapterCommitService(tmp_path)
+    valid_fulfillment = {
+        "planned_nodes": [],
+        "covered_nodes": [],
+        "missed_nodes": [],
+        "extra_nodes": [],
+    }
+    valid_disambiguation = {"pending": []}
+    valid_extraction = {"state_deltas": [], "entity_deltas": [], "accepted_events": []}
+
+    with pytest.raises(ValueError, match="blocking_count"):
+        service.build_commit(
+            chapter=3,
+            review_result={},
+            fulfillment_result=valid_fulfillment,
+            disambiguation_result=valid_disambiguation,
+            extraction_result=valid_extraction,
+        )
+
+    with pytest.raises(ValueError, match="fulfillment_result"):
+        service.build_commit(
+            chapter=3,
+            review_result={"blocking_count": 0},
+            fulfillment_result={"fulfillment": {"missed_nodes": ["遗漏节点"]}},
+            disambiguation_result=valid_disambiguation,
+            extraction_result=valid_extraction,
+        )
+
+    with pytest.raises(ValueError, match="disambiguation_result"):
+        service.build_commit(
+            chapter=3,
+            review_result={"blocking_count": 0},
+            fulfillment_result=valid_fulfillment,
+            disambiguation_result={"disambiguation": {"pending": ["宗主"]}},
+            extraction_result=valid_extraction,
+        )
+
+
+def test_commit_service_rejects_nested_extraction_result_shape(tmp_path):
+    service = ChapterCommitService(tmp_path)
+
+    with pytest.raises(ValueError, match="top-level"):
+        service.build_commit(
+            chapter=76,
+            review_result={"blocking_count": 0},
+            fulfillment_result={
+                "planned_nodes": [],
+                "covered_nodes": [],
+                "missed_nodes": [],
+                "extra_nodes": [],
+            },
+            disambiguation_result={"pending": []},
+            extraction_result={
+                "chapter": 76,
+                "extraction": {
+                    "scenes": [{"summary": "场景切分"}],
+                    "unresolved_threads": ["未解线索"],
+                },
+            },
+        )
+
+
+def test_commit_service_rejects_extraction_wrapper_even_with_empty_core_fields(tmp_path):
+    service = ChapterCommitService(tmp_path)
+
+    with pytest.raises(ValueError, match="nested under extraction"):
+        service.build_commit(
+            chapter=76,
+            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": [],
+                "extraction": {
+                    "scenes": [{"summary": "真实场景却被包错层"}],
+                    "summary_text": "真实摘要却被包错层",
+                },
+            },
+        )
+
+
+def test_commit_service_rejects_extraction_result_missing_core_fields(tmp_path):
+    service = ChapterCommitService(tmp_path)
+
+    with pytest.raises(ValueError, match="accepted_events"):
+        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": "摘要"},
+        )
+
+
+def test_commit_service_rejects_non_object_extraction_items(tmp_path):
+    service = ChapterCommitService(tmp_path)
+
+    with pytest.raises(ValueError, match=r"state_deltas\[0\]"):
+        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={
+                "accepted_events": [],
+                "state_deltas": ["realm changed"],
+                "entity_deltas": [],
+            },
+        )
+
+
+def test_commit_service_rejects_non_object_accepted_event_items(tmp_path):
+    service = ChapterCommitService(tmp_path)
+
+    with pytest.raises(ValueError, match=r"accepted_events\[0\]"):
+        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={
+                "accepted_events": ["not-a-json-object"],
+                "state_deltas": [],
+                "entity_deltas": [],
+            },
+        )
+
+
+def test_commit_service_normalizes_accepted_events_before_projection(tmp_path):
+    service = ChapterCommitService(tmp_path)
+
+    payload = service.build_commit(
+        chapter=76,
+        review_result={"blocking_count": 0},
+        fulfillment_result={
+            "planned_nodes": [],
+            "covered_nodes": [],
+            "missed_nodes": [],
+            "extra_nodes": [],
+        },
+        disambiguation_result={"pending": []},
+        extraction_result={
+            "state_deltas": [],
+            "entity_deltas": [],
+            "accepted_events": [
+                {
+                    "type": "mystery_introduction",
+                    "characters": ["xiaoyan"],
+                    "payload": {"content": "萧炎发现石门背后的新疑点"},
+                }
+            ],
+        },
+    )
+
+    event = payload["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"
+
+
+def test_apply_projections_normalizes_events_before_router_inspection(
+    tmp_path, monkeypatch
+):
+    captured = {}
+
+    class SpyRouter:
+        def required_writers(self, payload):
+            captured["events"] = list(payload.get("accepted_events") or [])
+            return []
+
+    monkeypatch.setattr(
+        "data_modules.chapter_commit_service.EventProjectionRouter",
+        lambda: SpyRouter(),
+    )
+
+    service = ChapterCommitService(tmp_path)
+    payload = {
+        "meta": {"status": "accepted", "chapter": 76},
+        "accepted_events": [
+            {
+                "type": "scene_open",
+                "characters": ["xiaoyan"],
+                "payload": {"content": "萧炎推开石门,新的悬念出现"},
+            }
+        ],
+        "entity_deltas": [],
+        "summary_text": "",
+        "projection_status": {
+            "state": "pending",
+            "index": "pending",
+            "summary": "pending",
+            "memory": "pending",
+            "vector": "pending",
+        },
+    }
+
+    service.apply_projections(payload)
+
+    event = captured["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 payload["accepted_events"] == captured["events"]
+
+
 def test_chapter_commit_cli_builds_and_persists_commit(tmp_path, monkeypatch):
     review_path = tmp_path / "review.json"
     fulfillment_path = tmp_path / "fulfillment.json"

+ 97 - 0
webnovel-writer/scripts/data_modules/tests/test_event_log_store.py

@@ -5,6 +5,8 @@ import sqlite3
 import sys
 from pathlib import Path
 
+import pytest
+
 from data_modules.event_log_store import EventLogStore
 
 
@@ -40,6 +42,101 @@ def test_event_log_store_writes_per_chapter_file_and_sqlite_mirror(tmp_path):
     assert row == ("evt-001", 3, "open_loop_created")
 
 
+def test_event_log_store_generates_missing_event_id_and_chapter(tmp_path):
+    store = EventLogStore(tmp_path)
+    store.write_events(
+        3,
+        [
+            {
+                "event_type": "open_loop_created",
+                "subject": "three_year_promise",
+                "payload": {"content": "三年之约提及"},
+            }
+        ],
+    )
+
+    events = store.read_events(3)
+    assert len(events) == 1
+    assert events[0]["event_id"].startswith("evt-ch003-001-")
+    assert events[0]["chapter"] == 3
+    assert events[0]["event_type"] == "open_loop_created"
+    assert events[0]["subject"] == "three_year_promise"
+
+
+def test_event_log_store_normalizes_llm_alias_event_shape(tmp_path):
+    store = EventLogStore(tmp_path)
+    store.write_events(
+        76,
+        [
+            {
+                "type": "scene_open",
+                "characters": ["xiaoyan"],
+                "payload": {"content": "萧炎推开石门,新的悬念出现"},
+            }
+        ],
+    )
+
+    events = store.read_events(76)
+    assert events[0]["event_type"] == "open_loop_created"
+    assert events[0]["subject"] == "xiaoyan"
+    assert events[0]["chapter"] == 76
+    assert events[0]["event_id"].startswith("evt-ch076-001-")
+
+
+def test_event_log_store_rejects_unknown_event_type_after_normalization(tmp_path):
+    store = EventLogStore(tmp_path)
+
+    with pytest.raises(ValueError, match="event_type"):
+        store.write_events(
+            3,
+            [
+                {
+                    "event_id": "evt-unknown",
+                    "event_type": "not_a_story_event",
+                    "subject": "xiaoyan",
+                    "payload": {},
+                }
+            ],
+        )
+
+
+def test_event_log_store_rejects_non_list_event_collection(tmp_path):
+    store = EventLogStore(tmp_path)
+
+    with pytest.raises(ValueError, match="accepted_events must be a list"):
+        store.write_events(
+            3,
+            {
+                "event_type": "open_loop_created",
+                "subject": "three_year_promise",
+                "payload": {},
+            },
+        )
+
+
+def test_event_log_store_rejects_non_object_event_items(tmp_path):
+    store = EventLogStore(tmp_path)
+
+    with pytest.raises(ValueError, match=r"accepted_events\[0\]"):
+        store.write_events(3, ["not-a-json-object"])
+
+
+def test_event_log_store_rejects_blank_event_subject(tmp_path):
+    store = EventLogStore(tmp_path)
+
+    with pytest.raises(ValueError, match="subject"):
+        store.write_events(
+            3,
+            [
+                {
+                    "event_type": "open_loop_created",
+                    "subject": "   ",
+                    "payload": {"content": "三年之约提及"},
+                }
+            ],
+        )
+
+
 def test_event_log_store_ignores_duplicate_event_id(tmp_path):
     store = EventLogStore(tmp_path)
     event = {

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

@@ -338,9 +338,27 @@ def test_data_agent_is_described_as_extraction_only_not_direct_write_mainline():
     text = (AGENTS_DIR / "data-agent.md").read_text(encoding="utf-8")
     assert "chapter-commit" in text
     assert "extraction_result.json" in text
+    assert "planned_nodes" in text
+    assert "missed_nodes" in text
+    assert "pending" in text
+    assert "event_id" in text
+    assert "event_type" in text
+    assert "subject" in text
     assert "直接写入 index.db 和 state.json" not in text
 
 
+def test_webnovel_write_data_agent_prompt_requires_extraction_schema():
+    text = (SKILLS_DIR / "webnovel-write" / "SKILL.md").read_text(encoding="utf-8")
+    assert "webnovel-writer:data-agent" in text
+    assert "fulfillment_result.json 必须顶层" in text
+    assert "planned_nodes/covered_nodes/missed_nodes/extra_nodes" in text
+    assert "disambiguation_result.json 必须顶层包含 pending" in text
+    assert "extraction_result.json 必须严格" in text
+    assert "accepted_events/state_deltas/entity_deltas" in text
+    assert "禁止包在 chapter/fulfillment/disambiguation/extraction" in text
+    assert "event_id/chapter/event_type/subject/payload" in text
+
+
 def test_dashboard_and_plan_skills_surface_story_runtime_mainline():
     dashboard_text = (SKILLS_DIR / "webnovel-dashboard" / "SKILL.md").read_text(encoding="utf-8")
     plan_text = (SKILLS_DIR / "webnovel-plan" / "SKILL.md").read_text(encoding="utf-8")

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

@@ -131,7 +131,7 @@ blocking=true → 修复后重审,不进 Step 4。`--fast` 只检查 setting/t
 ```text
 Agent(
   subagent_type: "webnovel-writer:data-agent",
-  prompt: "chapter={chapter_num}; chapter_file=${CHAPTER_FILE}; project_root=${PROJECT_ROOT}; scripts_dir=${SCRIPTS_DIR}。从正文提取事实,生成 .webnovel/tmp/ 下的 fulfillment_result.json、disambiguation_result.json、extraction_result.json;不直接写 state/index/summaries/memory。"
+  prompt: "chapter={chapter_num}; chapter_file=${CHAPTER_FILE}; project_root=${PROJECT_ROOT}; scripts_dir=${SCRIPTS_DIR}。从正文提取事实,生成 .webnovel/tmp/ 下的 fulfillment_result.json、disambiguation_result.json、extraction_result.json;fulfillment_result.json 必须顶层包含 planned_nodes/covered_nodes/missed_nodes/extra_nodes;disambiguation_result.json 必须顶层包含 pending;extraction_result.json 必须严格按你的第7节格式输出顶层字段 accepted_events/state_deltas/entity_deltas/entities_appeared/scenes/summary_text,禁止包在 chapter/fulfillment/disambiguation/extraction 等外层对象里;accepted_events 子项必须包含 event_id/chapter/event_type/subject/payload;不直接写 state/index/summaries/memory。"
 )
 ```