浏览代码

refactor(data-agent): compact schema prose (sole source); add precommit negative test for missing required fields

lingfengQAQ 2 周之前
父节点
当前提交
338a94a33e

+ 35 - 39
webnovel-writer/agents/data-agent.md

@@ -10,7 +10,7 @@ color: green
 
 ## 1. 身份
 
-从章节正文提取结构化信息,生成 chapter-commit 所需 artifacts。不直接写 state/index/summaries/memory——这些由 commit 投影链完成
+从章节正文提取结构化信息,生成 chapter-commit 所需 artifacts。本文件是这三份 artifact 的 schema 唯一真源
 
 ## 2. 工具
 
@@ -30,16 +30,11 @@ python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" chap
 
 ## 3. 流程
 
-**A 加载**:project_root 由调用方传入(已过 preflight),直接 Read 正文 + 查实体和出场
+**A 加载**:project_root 由调用方传入(已过 preflight),Read 正文 + 查实体索引和别名
 
 **B 提取与消歧**:同一轮完成,不额外调 LLM。置信度>0.8 自动采用,0.5-0.8 采用+warning,<0.5 标记待人工。
 
-**C 生成 artifacts**:
-
-产出三份 JSON 到 `.webnovel/tmp/`:
-- `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`
+**C 生成 artifacts**:产出三份 JSON 到 `.webnovel/tmp/`,顶层结构见 §7。
 
 **D 摘要**:100-150 字,含钩子类型。格式:
 
@@ -61,11 +56,9 @@ hook_strength: "strong"
 {30字}
 ```
 
-长期记忆只提炼"可跨章复用"的事实,转成 events/deltas 写入 extraction_result。
-
-摘要 `## 伏笔` 中每条 `[埋设]` 必须同步写一条 `accepted_events[].event_type == "open_loop_created"`;不要只写在摘要里。伏笔已回收则用 `promise_paid_off` 或对应闭合事件表达。
+长期记忆只提炼"可跨章复用"的事实,转成 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。
+**E 索引与观测**:`scenes` 写入 50-100 字/场景的结构化切片(index/start_line/end_line/location/summary/characters/content);RAG 向量索引 → review_score≥80 时提取风格样本 → 记录耗时到 observability。
 
 ## 4. 输入
 
@@ -75,45 +68,48 @@ hook_strength: "strong"
 
 ## 5. 边界
 
-- 不额外调 LLM
-- 置信度<0.5 不自动写入
-- 不回滚上游步骤
-- 不直接写 state/index/summaries/memory
+- 不额外调 LLM;置信度<0.5 不自动写入;不回滚上游步骤。
+- 只生成三份 tmp artifact;不直接写 state/index/summaries/memory/vectors/projection(这些由 chapter-commit 投影链完成)。
 
 ## 6. 校验清单
 
-实体识别完整、extraction_result 已生成、commit artifacts 齐全、projection 已触发、摘要已生成、场景索引已写入、观测日志有效。
+实体识别完整、三份 artifact 已生成且 schema 合格、摘要已生成、场景索引已写入、观测日志有效。
+
+## 7. 输出 schema(唯一真源)
+
+三份 artifact 的顶层结构如下。投影器只认规范字段名,必须严格遵守。
+
+- `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`、`entities_new`。
+
+### 7.1 字段命名
+
+- **state_deltas 子项**:`entity_id` + `field` + `old` + `new`。简单字段直接写(`realm`),嵌套用点号(`power.realm`、`location.current`),投影器自动展开。
+- **entity_deltas 子项**:`entity_id` + `action` + `entity_type`(值为 `角色|组织|地点|物品|势力`,非默认 `"角色"`)+ `payload`;`is_protagonist: true` 标主角(同步到 `state.protagonist_state`)。
+- **accepted_events 子项**:每条必含 `event_id`(章内稳定 ID 如 `evt-ch100-001`)+ `chapter`(当前章号)+ `event_type`(枚举见下)+ `subject`(主体 entity_id,非中文名)+ `payload`。
+- **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`。
+- **各 event_type payload 必备字段**:
+  - `character_state_changed`:`field` + `old` + `new`(与 state_deltas 一致)。
+  - `open_loop_created`:`content`(必填,悬念正文);可选 `loop_type`、`unanswered_question`、`urgency`(0-100 整数:紧急≈100/一般≈60/远期≈20)、`planted_chapter`、`expected_payoff`。
+  - `world_rule_revealed`:`rule_content`;可选 `rule_category`、`scope`。
+  - `relationship_changed`:`to_entity` + `relationship_type`。
+  - `artifact_obtained`:`artifact_id` + `name` + `owner`。
 
-## 7. 输出
+### 7.2 最小示例
 
 ```json
 {
-  "entities_appeared": [{"id": "xiaoyan", "type": "角色", "mentions": ["萧炎"], "confidence": 0.95}],
-  "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_id": "evt-ch100-001", "chapter": 100, "event_type": "open_loop_created", "subject": "three_year_promise", "payload": {"content": "三年之约提及"}}],
-  "summary_text": "摘要",
+  "state_deltas": [{"entity_id": "xiaoyan", "field": "realm", "old": "斗者", "new": "斗师"}],
+  "entity_deltas": [{"entity_id": "hongyi_girl", "action": "upsert", "entity_type": "角色", "payload": {"name": "红衣女子"}}],
+  "entities_appeared": [{"id": "xiaoyan", "type": "角色", "mentions": ["萧炎"], "confidence": 0.95}],
   "scenes": [{"index": 1, "start_line": 1, "end_line": 30, "location": "萧炎房间", "summary": "药老提醒三年之约", "characters": ["xiaoyan", "yaolao"]}],
-  "scenes_chunked": 4,
-  "dominant_strand": "quest",
-  "timing_ms": {},
-  "bottlenecks_top3": []
+  "summary_text": "摘要"
 }
 ```
 
-### 7.1 字段命名硬性约定(投影器读不到不同义词,必须严格遵守)
-
-- **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_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`(**0-100 整数**;惯例:紧急≈100、一般≈60、远期≈20。若误传字符串 `"high"`/`"medium"`/`"low"`,消费端会兜底转换,但**首选数字**)、`planted_chapter`、`expected_payoff`/`loop_deadline`。投影器会从 content > unanswered_question > description 取值,不要省略 content。
-- **world_rule_revealed.payload**:必须有 `rule_content`(或 `rule`、`description`),可选 `rule_category` / `domain`、`scope`。
-- **relationship_changed.payload**:必须有 `to_entity` 和 `relationship_type`(不是 `type`)。
-- **artifact_obtained.payload**:必须有 `artifact_id`、`name`、`owner`(或 `holder`)。
-
-注:旧字段名(`field_path`、`new_value`、`type`、`description` 等)作为兼容输入也能被正确投影,但首选清单中列出的规范名。
+旧字段名(`field_path`、`new_value`、`type`、`description` 等)作为兼容输入仍可被投影,但首选上述规范名。
 
 ## 8. 错误处理
 

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

@@ -159,3 +159,37 @@ def test_validate_chapter_commit_reports_projection_failure(tmp_path):
 
     assert report["ok"] is False
     assert any(item["type"] == ERROR_PROJECTION_FAILURE for item in report["errors"])
+
+
+def test_artifact_validator_rejects_missing_required_top_level_fields(tmp_path):
+    """precommit 负向用例:缺关键顶层字段时 runtime validator 必须拦截。
+
+    取代已退役的 test_webnovel_write_data_agent_prompt_requires_extraction_schema
+    (plan §12.2):字段保障由 runtime schema 强制,而非主 Skill 文案锚定。
+    """
+    # fulfillment_result 缺 missed_nodes
+    fulfillment = _write_json(
+        tmp_path / "fulfillment_result.json",
+        {"planned_nodes": [], "covered_nodes": [], "extra_nodes": []},
+    )
+    report = validate_fulfillment_result(fulfillment)
+    assert report["ok"] is False
+    assert report["errors"][0]["type"] == ERROR_SCHEMA
+    assert "missed_nodes" in report["errors"][0]["message"]
+
+    # disambiguation_result 缺 pending
+    disambiguation = _write_json(tmp_path / "disambiguation_result.json", {})
+    report = validate_disambiguation_result(disambiguation)
+    assert report["ok"] is False
+    assert report["errors"][0]["type"] == ERROR_SCHEMA
+    assert "pending" in report["errors"][0]["message"]
+
+    # extraction_result 缺核心字段 accepted_events
+    extraction = _write_json(
+        tmp_path / "extraction_result.json",
+        {"state_deltas": [], "entity_deltas": []},
+    )
+    report = validate_extraction_result(extraction)
+    assert report["ok"] is False
+    assert report["errors"][0]["type"] == ERROR_SCHEMA
+    assert "accepted_events" in report["errors"][0]["message"]