|
|
@@ -0,0 +1,1960 @@
|
|
|
+# Story System 最终收束实施计划
|
|
|
+
|
|
|
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
+
|
|
|
+**Goal:** 把 Story System 从"半成品并存"收束到"六层主链 + 消费端同步"的最终可用状态,覆盖 CSV_CONFIG 注册、裁决表、engine 改造、context_manager 瘦身、旧散写清理、projection 收束、消费端同步和向量索引增强共 9 个 section。
|
|
|
+
|
|
|
+**Architecture:** 自底向上串行推进。先在 `reference_search.py` 引入 per-table `CSV_CONFIG`,然后统一 CSV 毒点列名、新建裁决表、改造 `story_system_engine.py` 接入裁决层,接着瘦身 `context_manager.py`、清理旧散写路径、收束 projection 层,最后同步所有消费端 prompt 并增强向量索引。
|
|
|
+
|
|
|
+**Tech Stack:** Python 3.11+, pytest, CSV (UTF-8 BOM), SQLite FTS5, RAG embedding
|
|
|
+
|
|
|
+**Spec:** `docs/superpowers/specs/2026-04-14-story-system-final-convergence-spec.md`
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 文件结构总览
|
|
|
+
|
|
|
+### 新建文件
|
|
|
+
|
|
|
+| 文件 | 职责 |
|
|
|
+|------|------|
|
|
|
+| `webnovel-writer/references/csv/裁决规则.csv` | reasoning 层,key=题材,裁决命中条目的优先级和注入位置 |
|
|
|
+| `webnovel-writer/scripts/data_modules/knowledge_query.py` | 时序查询接口,entity_state_at_chapter / relationships_at_chapter |
|
|
|
+| `webnovel-writer/scripts/data_modules/vector_projection_writer.py` | commit 后把事件/entity_delta 写入向量库 |
|
|
|
+| `webnovel-writer/scripts/data_modules/tests/test_csv_config.py` | CSV_CONFIG 与 CSV 表头对齐校验 |
|
|
|
+| `webnovel-writer/scripts/data_modules/tests/test_reasoning_engine.py` | 裁决层单元测试 |
|
|
|
+| `webnovel-writer/scripts/data_modules/tests/test_knowledge_query.py` | 时序查询单元测试 |
|
|
|
+| `webnovel-writer/scripts/data_modules/tests/test_vector_projection_writer.py` | 向量投影写入测试 |
|
|
|
+
|
|
|
+### 主要修改文件
|
|
|
+
|
|
|
+| 文件 | 改动摘要 |
|
|
|
+|------|---------|
|
|
|
+| `webnovel-writer/scripts/reference_search.py` | 引入 `CSV_CONFIG`,`search()` 按表使用不同 `search_cols` |
|
|
|
+| `webnovel-writer/scripts/data_modules/story_system_engine.py` | 接入裁决表,新增 `_load_reasoning` / `_apply_reasoning` / `_rank_anti_patterns` / `_assemble_contract` |
|
|
|
+| `webnovel-writer/scripts/data_modules/context_manager.py` | 删 snapshot 逻辑、删 `_compact_json_text` / text 渲染相关,压到 400 行以下 |
|
|
|
+| `webnovel-writer/scripts/extract_chapter_context.py` | `_render_text()` 改为纯 JSON 序列化,text 渲染不再由代码层负责(context-agent 按示例写任务书) |
|
|
|
+| `webnovel-writer/scripts/data_modules/event_projection_router.py` | 给 6 种事件加 `"vector"` 路由 |
|
|
|
+| `webnovel-writer/scripts/data_modules/chapter_commit_service.py` | `apply_projections` 接入 `VectorProjectionWriter` |
|
|
|
+| `webnovel-writer/scripts/data_modules/state_projection_writer.py` | 统一由 projection 推进 `chapter_status` |
|
|
|
+| `webnovel-writer/skills/webnovel-write/SKILL.md` | 删 Step 2/4 的 `set-chapter-status`、删 `core-constraints` / `anti-ai-guide` 直读 |
|
|
|
+| `webnovel-writer/agents/context-agent.md` | 确认工具段落、research 数据源路径与代码一致 |
|
|
|
+| `webnovel-writer/agents/data-agent.md` | 确认不直写 state/index/memory |
|
|
|
+| `webnovel-writer/scripts/data_modules/tests/test_prompt_integrity.py` | 新增散写检测断言 |
|
|
|
+| `webnovel-writer/scripts/data_modules/tests/test_event_projection_router.py` | 补 vector 路由测试 |
|
|
|
+| `webnovel-writer/scripts/data_modules/tests/test_story_system_engine.py` | 补裁决层测试 |
|
|
|
+| `webnovel-writer/scripts/tests/test_reference_search.py` | 补 per-table search_cols 测试 |
|
|
|
+| `webnovel-writer/references/csv/*.csv` | 毒点列统一 rename |
|
|
|
+
|
|
|
+### 删除文件
|
|
|
+
|
|
|
+| 文件 | 理由 |
|
|
|
+|------|------|
|
|
|
+| `webnovel-writer/scripts/data_modules/snapshot_manager.py` | snapshot 逻辑随 context_manager 瘦身一起删除 |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 1: CSV_CONFIG 注册层
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `webnovel-writer/scripts/reference_search.py:90-191`
|
|
|
+- Create: `webnovel-writer/scripts/data_modules/tests/test_csv_config.py`
|
|
|
+- Modify: `webnovel-writer/scripts/tests/test_reference_search.py`
|
|
|
+
|
|
|
+- [ ] **Step 1: 在 `reference_search.py` 新增 `CSV_CONFIG` dict**
|
|
|
+
|
|
|
+在 `_TOKEN_SPLIT_RE` 定义之前(约第 89 行),插入 `CSV_CONFIG`:
|
|
|
+
|
|
|
+```python
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+# Per-table configuration
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+CSV_CONFIG: Dict[str, Dict[str, Any]] = {
|
|
|
+ "命名规则": {
|
|
|
+ "file": "命名规则.csv",
|
|
|
+ "search_cols": {"关键词": 3, "意图与同义词": 4, "核心摘要": 2},
|
|
|
+ "output_cols": ["编号", "命名对象", "核心摘要", "大模型指令", "详细展开"],
|
|
|
+ "poison_col": "毒点",
|
|
|
+ "role": "base",
|
|
|
+ },
|
|
|
+ "场景写法": {
|
|
|
+ "file": "场景写法.csv",
|
|
|
+ "search_cols": {"关键词": 3, "意图与同义词": 4, "核心摘要": 2},
|
|
|
+ "output_cols": ["编号", "模式名称", "核心摘要", "大模型指令", "详细展开"],
|
|
|
+ "poison_col": "毒点",
|
|
|
+ "role": "base",
|
|
|
+ },
|
|
|
+ "写作技法": {
|
|
|
+ "file": "写作技法.csv",
|
|
|
+ "search_cols": {"关键词": 3, "意图与同义词": 4, "核心摘要": 2},
|
|
|
+ "output_cols": ["编号", "技法名称", "核心摘要", "大模型指令", "详细展开"],
|
|
|
+ "poison_col": "毒点",
|
|
|
+ "role": "base",
|
|
|
+ },
|
|
|
+ "桥段套路": {
|
|
|
+ "file": "桥段套路.csv",
|
|
|
+ "search_cols": {"关键词": 3, "意图与同义词": 4, "核心摘要": 2},
|
|
|
+ "output_cols": ["编号", "桥段名称", "核心摘要", "大模型指令", "详细展开"],
|
|
|
+ "poison_col": "毒点",
|
|
|
+ "role": "dynamic",
|
|
|
+ },
|
|
|
+ "爽点与节奏": {
|
|
|
+ "file": "爽点与节奏.csv",
|
|
|
+ "search_cols": {"关键词": 3, "意图与同义词": 4, "核心摘要": 2},
|
|
|
+ "output_cols": ["编号", "节奏类型", "核心摘要", "大模型指令", "详细展开"],
|
|
|
+ "poison_col": "毒点",
|
|
|
+ "role": "dynamic",
|
|
|
+ },
|
|
|
+ "人设与关系": {
|
|
|
+ "file": "人设与关系.csv",
|
|
|
+ "search_cols": {"关键词": 3, "意图与同义词": 4, "核心摘要": 2},
|
|
|
+ "output_cols": ["编号", "人设类型", "核心摘要", "大模型指令", "详细展开"],
|
|
|
+ "poison_col": "毒点",
|
|
|
+ "role": "base",
|
|
|
+ },
|
|
|
+ "金手指与设定": {
|
|
|
+ "file": "金手指与设定.csv",
|
|
|
+ "search_cols": {"关键词": 3, "意图与同义词": 4, "核心摘要": 2},
|
|
|
+ "output_cols": ["编号", "设定类型", "核心摘要", "大模型指令", "详细展开"],
|
|
|
+ "poison_col": "毒点",
|
|
|
+ "role": "base",
|
|
|
+ },
|
|
|
+ "题材与调性推理": {
|
|
|
+ "file": "题材与调性推理.csv",
|
|
|
+ "search_cols": {"关键词": 3, "意图与同义词": 4, "题材别名": 3},
|
|
|
+ "output_cols": ["编号", "题材/流派", "核心调性", "推荐基础检索表", "推荐动态检索表"],
|
|
|
+ "poison_col": "毒点",
|
|
|
+ "role": "route",
|
|
|
+ },
|
|
|
+ "裁决规则": {
|
|
|
+ "file": "裁决规则.csv",
|
|
|
+ "search_cols": {"题材": 4},
|
|
|
+ "output_cols": [
|
|
|
+ "题材", "风格优先级", "爽点优先级", "节奏默认策略",
|
|
|
+ "毒点权重", "冲突裁决", "contract注入层", "反模式",
|
|
|
+ ],
|
|
|
+ "poison_col": "",
|
|
|
+ "role": "reasoning",
|
|
|
+ },
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 改造 `_build_doc_terms()` 使用 per-table `search_cols`**
|
|
|
+
|
|
|
+把旧的 `_SEARCH_FIELD_WEIGHTS` 全局 dict 替换为 per-table 参数:
|
|
|
+
|
|
|
+```python
|
|
|
+# 删除旧的全局常量
|
|
|
+# _SEARCH_FIELD_WEIGHTS = { ... } # 删除
|
|
|
+
|
|
|
+# 保留作为默认 fallback
|
|
|
+_DEFAULT_SEARCH_WEIGHTS: Dict[str, int] = {
|
|
|
+ "意图与同义词": 4,
|
|
|
+ "关键词": 3,
|
|
|
+ "核心摘要": 2,
|
|
|
+ "详细展开": 1,
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+def _build_doc_terms(row: Dict[str, str], search_weights: Dict[str, int] | None = None) -> List[str]:
|
|
|
+ """Build weighted BM25 terms from the configured search fields."""
|
|
|
+ weights = search_weights or _DEFAULT_SEARCH_WEIGHTS
|
|
|
+ terms: List[str] = []
|
|
|
+ for field, weight in weights.items():
|
|
|
+ field_terms = _tokenize(row.get(field, ""))
|
|
|
+ if not field_terms:
|
|
|
+ continue
|
|
|
+ terms.extend(field_terms * weight)
|
|
|
+ return terms
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 改造 `search()` 从 `CSV_CONFIG` 读取配置**
|
|
|
+
|
|
|
+在 `search()` 函数里,根据 `table` 参数查 `CSV_CONFIG`:
|
|
|
+
|
|
|
+```python
|
|
|
+def search(
|
|
|
+ csv_dir: Path,
|
|
|
+ skill: str,
|
|
|
+ query: str,
|
|
|
+ table: Optional[str] = None,
|
|
|
+ genre: Optional[str] = None,
|
|
|
+ max_results: int = 5,
|
|
|
+) -> Dict[str, Any]:
|
|
|
+ # ... (error check 不变)
|
|
|
+
|
|
|
+ tables = load_tables(csv_dir, table=table)
|
|
|
+ if not tables:
|
|
|
+ # ... (不变)
|
|
|
+
|
|
|
+ # 按表查 search_cols
|
|
|
+ table_config = CSV_CONFIG.get(table) if table else None
|
|
|
+ search_weights = (
|
|
|
+ dict(table_config["search_cols"]) if table_config else None
|
|
|
+ )
|
|
|
+
|
|
|
+ # 1) Collect filtered rows
|
|
|
+ candidates: List[tuple] = []
|
|
|
+ for tbl_name, rows in tables.items():
|
|
|
+ for row in rows:
|
|
|
+ if _skill_matches(row, skill) and _genre_matches(row, genre):
|
|
|
+ candidates.append((tbl_name, row))
|
|
|
+
|
|
|
+ if not candidates:
|
|
|
+ # ... (不变)
|
|
|
+
|
|
|
+ # 2) Tokenize - 对每条用其所在表的 search_cols
|
|
|
+ query_terms = _tokenize(query)
|
|
|
+ doc_terms_list = []
|
|
|
+ for tbl_name, row in candidates:
|
|
|
+ tbl_cfg = CSV_CONFIG.get(tbl_name)
|
|
|
+ weights = dict(tbl_cfg["search_cols"]) if tbl_cfg else search_weights
|
|
|
+ doc_terms_list.append(_build_doc_terms(row, weights))
|
|
|
+
|
|
|
+ # 3-4) 不变 ...
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 删除 `_SEARCH_FIELD_WEIGHTS` 和 `_CONTENT_COLUMNS`**
|
|
|
+
|
|
|
+删除第 90-95 行的 `_SEARCH_FIELD_WEIGHTS` 和第 180-190 行的 `_CONTENT_COLUMNS`。
|
|
|
+
|
|
|
+`_build_summary()` 改为:如果有 `CSV_CONFIG` 里的 `output_cols`,就按那个顺序取字段;否则用原来的 fallback 逻辑。
|
|
|
+
|
|
|
+```python
|
|
|
+def _build_summary(row: Dict[str, str], table_name: str | None = None) -> str:
|
|
|
+ core_summary = row.get("核心摘要", "").strip()
|
|
|
+ if core_summary:
|
|
|
+ return core_summary
|
|
|
+
|
|
|
+ # 优先用 CSV_CONFIG 的 output_cols
|
|
|
+ if table_name and table_name in CSV_CONFIG:
|
|
|
+ cols = CSV_CONFIG[table_name]["output_cols"]
|
|
|
+ else:
|
|
|
+ cols = [
|
|
|
+ "技法名称", "桥段名称", "人设类型", "节奏类型", "设定类型",
|
|
|
+ "规则", "说明", "模式名称", "命名对象", "场景类型",
|
|
|
+ ]
|
|
|
+
|
|
|
+ parts: List[str] = []
|
|
|
+ for col in cols:
|
|
|
+ val = row.get(col, "").strip()
|
|
|
+ if val and col not in ("编号", "大模型指令", "详细展开", "核心摘要"):
|
|
|
+ parts.append(val)
|
|
|
+ if parts:
|
|
|
+ return ";".join(parts)
|
|
|
+ return row.get("详细展开", "").strip()
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: 创建 CSV_CONFIG 对齐校验测试**
|
|
|
+
|
|
|
+```python
|
|
|
+# webnovel-writer/scripts/data_modules/tests/test_csv_config.py
|
|
|
+#!/usr/bin/env python3
|
|
|
+# -*- coding: utf-8 -*-
|
|
|
+"""CSV_CONFIG 与实际 CSV 表头对齐校验。"""
|
|
|
+import csv
|
|
|
+from pathlib import Path
|
|
|
+
|
|
|
+import pytest
|
|
|
+
|
|
|
+# reference_search.py 在 scripts/ 下,需要加 sys.path
|
|
|
+import sys
|
|
|
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
|
|
|
+
|
|
|
+from reference_search import CSV_CONFIG
|
|
|
+
|
|
|
+CSV_DIR = Path(__file__).resolve().parent.parent.parent.parent / "references" / "csv"
|
|
|
+
|
|
|
+
|
|
|
+@pytest.mark.parametrize("table_name,config", list(CSV_CONFIG.items()))
|
|
|
+def test_csv_config_columns_exist_in_csv_header(table_name: str, config: dict):
|
|
|
+ """CSV_CONFIG 里声明的所有列名都必须在 CSV 文件头中找到。"""
|
|
|
+ csv_path = CSV_DIR / config["file"]
|
|
|
+ if not csv_path.exists():
|
|
|
+ pytest.skip(f"{config['file']} not yet created")
|
|
|
+
|
|
|
+ with open(csv_path, "r", encoding="utf-8-sig", newline="") as f:
|
|
|
+ reader = csv.DictReader(f)
|
|
|
+ headers = set(reader.fieldnames or [])
|
|
|
+
|
|
|
+ all_cols = set()
|
|
|
+ for col in config.get("search_cols", {}):
|
|
|
+ all_cols.add(col)
|
|
|
+ for col in config.get("output_cols", []):
|
|
|
+ all_cols.add(col)
|
|
|
+ poison = config.get("poison_col", "")
|
|
|
+ if poison:
|
|
|
+ all_cols.add(poison)
|
|
|
+
|
|
|
+ missing = all_cols - headers
|
|
|
+ assert not missing, f"表 {table_name} 缺少列: {missing}"
|
|
|
+
|
|
|
+
|
|
|
+def test_csv_config_file_field_matches_filename():
|
|
|
+ """CSV_CONFIG 的 file 字段必须与 key + '.csv' 对应。"""
|
|
|
+ for name, config in CSV_CONFIG.items():
|
|
|
+ assert config["file"] == f"{name}.csv", f"{name}: file 应为 '{name}.csv',实际为 '{config['file']}'"
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 6: 运行测试验证**
|
|
|
+
|
|
|
+Run: `cd webnovel-writer && python -m pytest scripts/data_modules/tests/test_csv_config.py -v`
|
|
|
+
|
|
|
+预期:`裁决规则` 那条会 skip(文件还没创建),其余表全 pass。
|
|
|
+
|
|
|
+- [ ] **Step 7: 补充 per-table 检索测试**
|
|
|
+
|
|
|
+在 `webnovel-writer/scripts/tests/test_reference_search.py` 末尾新增:
|
|
|
+
|
|
|
+```python
|
|
|
+class TestPerTableSearchCols:
|
|
|
+ """CSV_CONFIG per-table search_cols 测试。"""
|
|
|
+
|
|
|
+ def test_different_tables_use_different_search_weights(self):
|
|
|
+ """确认不同表用不同的 search_cols 做检索。"""
|
|
|
+ # 命名规则和场景写法都应返回结果,但用各自表的 search_cols
|
|
|
+ out1 = run_search("--skill", "write", "--table", "命名规则", "--query", "角色命名")
|
|
|
+ out2 = run_search("--skill", "write", "--table", "场景写法", "--query", "战斗描写")
|
|
|
+ assert out1["status"] == "success"
|
|
|
+ assert out2["status"] == "success"
|
|
|
+ assert out1["data"]["total"] >= 1
|
|
|
+ assert out2["data"]["total"] >= 1
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 8: 运行全量 reference_search 测试**
|
|
|
+
|
|
|
+Run: `cd webnovel-writer && python -m pytest scripts/tests/test_reference_search.py -v`
|
|
|
+
|
|
|
+预期:全部 PASS。
|
|
|
+
|
|
|
+- [ ] **Step 9: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add webnovel-writer/scripts/reference_search.py webnovel-writer/scripts/data_modules/tests/test_csv_config.py webnovel-writer/scripts/tests/test_reference_search.py
|
|
|
+git commit -m "feat: introduce per-table CSV_CONFIG in reference_search"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 2: CSV 毒点列统一
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `webnovel-writer/references/csv/场景写法.csv` (header rename)
|
|
|
+- Modify: `webnovel-writer/references/csv/写作技法.csv` (header rename)
|
|
|
+- Modify: `webnovel-writer/references/csv/爽点与节奏.csv` (header rename)
|
|
|
+- Modify: `webnovel-writer/references/csv/人设与关系.csv` (header rename)
|
|
|
+- Modify: `webnovel-writer/references/csv/桥段套路.csv` (header rename)
|
|
|
+- Modify: `webnovel-writer/references/csv/题材与调性推理.csv` (header rename)
|
|
|
+- Modify: `webnovel-writer/scripts/data_modules/story_system_engine.py:15-22`
|
|
|
+
|
|
|
+- [ ] **Step 1: 统计当前各表的毒点列名**
|
|
|
+
|
|
|
+当前列名映射:
|
|
|
+- `场景写法.csv` → `反面写法`
|
|
|
+- `写作技法.csv` → `常见误区`
|
|
|
+- `爽点与节奏.csv` → `常见崩盘误区`
|
|
|
+- `人设与关系.csv` → `忌讳写法`
|
|
|
+- `桥段套路.csv` → 有 `忌讳写法` 列
|
|
|
+- `题材与调性推理.csv` → `强制禁忌/毒点`
|
|
|
+- `命名规则.csv` → 无毒点列(header 里有 `反例`,保留不动,新增 `毒点` 列)
|
|
|
+- `金手指与设定.csv` → 无毒点列(新增 `毒点` 列)
|
|
|
+
|
|
|
+- [ ] **Step 2: 批量 rename CSV 列头**
|
|
|
+
|
|
|
+用脚本执行(一次性,不入库):
|
|
|
+
|
|
|
+```python
|
|
|
+# 在 bash 里直接执行
|
|
|
+python3 -c "
|
|
|
+import csv, sys
|
|
|
+from pathlib import Path
|
|
|
+
|
|
|
+csv_dir = Path('webnovel-writer/references/csv')
|
|
|
+
|
|
|
+renames = {
|
|
|
+ '场景写法.csv': {'反面写法': '毒点'},
|
|
|
+ '写作技法.csv': {'常见误区': '毒点'},
|
|
|
+ '爽点与节奏.csv': {'常见崩盘误区': '毒点'},
|
|
|
+ '人设与关系.csv': {'忌讳写法': '毒点'},
|
|
|
+ '桥段套路.csv': {'忌讳写法': '毒点'},
|
|
|
+ '题材与调性推理.csv': {'强制禁忌/毒点': '毒点'},
|
|
|
+}
|
|
|
+
|
|
|
+for filename, mapping in renames.items():
|
|
|
+ path = csv_dir / filename
|
|
|
+ with open(path, 'r', encoding='utf-8-sig', newline='') as f:
|
|
|
+ reader = csv.DictReader(f)
|
|
|
+ rows = list(reader)
|
|
|
+ old_fields = list(reader.fieldnames)
|
|
|
+
|
|
|
+ new_fields = [mapping.get(f, f) for f in old_fields]
|
|
|
+
|
|
|
+ new_rows = []
|
|
|
+ for row in rows:
|
|
|
+ new_row = {}
|
|
|
+ for old_f, new_f in zip(old_fields, new_fields):
|
|
|
+ new_row[new_f] = row.get(old_f, '')
|
|
|
+ new_rows.append(new_row)
|
|
|
+
|
|
|
+ with open(path, 'w', encoding='utf-8-sig', newline='') as f:
|
|
|
+ writer = csv.DictWriter(f, fieldnames=new_fields)
|
|
|
+ writer.writeheader()
|
|
|
+ writer.writerows(new_rows)
|
|
|
+
|
|
|
+print('Done')
|
|
|
+"
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 给 `命名规则.csv` 和 `金手指与设定.csv` 新增空 `毒点` 列**
|
|
|
+
|
|
|
+```python
|
|
|
+python3 -c "
|
|
|
+import csv
|
|
|
+from pathlib import Path
|
|
|
+
|
|
|
+csv_dir = Path('webnovel-writer/references/csv')
|
|
|
+
|
|
|
+for filename in ['命名规则.csv', '金手指与设定.csv']:
|
|
|
+ path = csv_dir / filename
|
|
|
+ with open(path, 'r', encoding='utf-8-sig', newline='') as f:
|
|
|
+ reader = csv.DictReader(f)
|
|
|
+ rows = list(reader)
|
|
|
+ fields = list(reader.fieldnames)
|
|
|
+
|
|
|
+ if '毒点' not in fields:
|
|
|
+ fields.append('毒点')
|
|
|
+ for row in rows:
|
|
|
+ row['毒点'] = ''
|
|
|
+
|
|
|
+ with open(path, 'w', encoding='utf-8-sig', newline='') as f:
|
|
|
+ writer = csv.DictWriter(f, fieldnames=fields)
|
|
|
+ writer.writeheader()
|
|
|
+ writer.writerows(rows)
|
|
|
+
|
|
|
+print('Done')
|
|
|
+"
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 更新 `story_system_engine.py` 的 `ANTI_PATTERN_SOURCE_FIELDS`**
|
|
|
+
|
|
|
+把第 15-22 行的旧映射:
|
|
|
+
|
|
|
+```python
|
|
|
+ANTI_PATTERN_SOURCE_FIELDS = {
|
|
|
+ "场景写法": ["反面写法"],
|
|
|
+ "写作技法": ["常见误区"],
|
|
|
+ "爽点与节奏": ["常见崩盘误区"],
|
|
|
+ "人设与关系": ["忌讳写法"],
|
|
|
+ "桥段套路": ["忌讳写法"],
|
|
|
+ "题材与调性推理": ["强制禁忌/毒点"],
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+统一改为:
|
|
|
+
|
|
|
+```python
|
|
|
+ANTI_PATTERN_SOURCE_FIELDS = {
|
|
|
+ "场景写法": ["毒点"],
|
|
|
+ "写作技法": ["毒点"],
|
|
|
+ "爽点与节奏": ["毒点"],
|
|
|
+ "人设与关系": ["毒点"],
|
|
|
+ "桥段套路": ["毒点"],
|
|
|
+ "题材与调性推理": ["毒点"],
|
|
|
+ "命名规则": ["毒点"],
|
|
|
+ "金手指与设定": ["毒点"],
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: 运行测试**
|
|
|
+
|
|
|
+Run: `cd webnovel-writer && python -m pytest scripts/data_modules/tests/test_csv_config.py scripts/data_modules/tests/test_story_system_engine.py scripts/tests/test_reference_search.py -v`
|
|
|
+
|
|
|
+预期:test_story_system_engine 会因 fixture CSV 里用旧列名而失败。
|
|
|
+
|
|
|
+- [ ] **Step 6: 修复 `test_story_system_engine.py` fixture 列名**
|
|
|
+
|
|
|
+把 fixture CSV 里的 `忌讳写法` 和 `常见崩盘误区` 改为 `毒点`,`强制禁忌/毒点` 也改为 `毒点`。
|
|
|
+
|
|
|
+fixture 第 53 行的 `桥段套路.csv` headers 改为:
|
|
|
+```python
|
|
|
+["编号", "适用技能", "分类", "层级", "关键词", "适用题材", "核心摘要", "桥段名称", "毒点"],
|
|
|
+```
|
|
|
+对应行数据 key `忌讳写法` 改为 `毒点`。
|
|
|
+
|
|
|
+fixture 第 71 行的 `爽点与节奏.csv` headers 改为:
|
|
|
+```python
|
|
|
+["编号", "适用技能", "分类", "层级", "关键词", "适用题材", "核心摘要", "毒点", "节奏类型"],
|
|
|
+```
|
|
|
+对应行数据 key `常见崩盘误区` 改为 `毒点`。
|
|
|
+
|
|
|
+fixture 第 26 行的 `题材与调性推理.csv` headers 里 `强制禁忌/毒点` 改为 `毒点`,对应行数据 key 也改为 `毒点`。
|
|
|
+
|
|
|
+- [ ] **Step 7: 运行测试确认全 PASS**
|
|
|
+
|
|
|
+Run: `cd webnovel-writer && python -m pytest scripts/data_modules/tests/test_story_system_engine.py scripts/data_modules/tests/test_csv_config.py -v`
|
|
|
+
|
|
|
+预期:全部 PASS。
|
|
|
+
|
|
|
+- [ ] **Step 8: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add webnovel-writer/references/csv/*.csv webnovel-writer/scripts/data_modules/story_system_engine.py webnovel-writer/scripts/data_modules/tests/test_story_system_engine.py
|
|
|
+git commit -m "refactor: unify poison column name to 毒点 across all CSV tables"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 3: 新建裁决规则表
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `webnovel-writer/references/csv/裁决规则.csv`
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建裁决规则 CSV 文件**
|
|
|
+
|
|
|
+```csv
|
|
|
+编号,适用技能,分类,层级,关键词,意图与同义词,适用题材,大模型指令,核心摘要,详细展开,题材,风格优先级,爽点优先级,节奏默认策略,毒点权重,冲突裁决,contract注入层,反模式
|
|
|
+RS-001,write|plan,裁决,推理层,西方奇幻|奇幻,西方奇幻怎么写,西方奇幻,按冲突裁决排序命中条目,西方奇幻裁决规则,,西方奇幻,史诗感 > 冷硬算计 > 日常轻松,实力碾压 > 逆境翻盘 > 智谋博弈,快推慢收 对峙段拉长 过渡段压短,圣母病 > 情绪标签化 > 逻辑断裂,爽点与节奏 > 场景写法 > 写作技法,CHAPTER_BRIEF.writing_guidance,情绪标签化|角色行为无逻辑|战斗无代价
|
|
|
+RS-002,write|plan,裁决,推理层,东方仙侠|仙侠,仙侠怎么写,东方仙侠,按冲突裁决排序命中条目,东方仙侠裁决规则,,东方仙侠,冷硬算计 > 超然物外 > 热血冲突,境界碾压 > 底牌揭晓 > 因果兑现,慢蓄快爆 修炼段精简 斗法段拉满,修炼水字数 > 圣母病 > 逻辑断裂,爽点与节奏 > 桥段套路 > 场景写法,CHAPTER_BRIEF.writing_guidance,修炼变流水账|境界突破无代价|感悟靠顿悟标签
|
|
|
+RS-003,write|plan,裁决,推理层,科幻末世|末世|科幻,科幻末世怎么写,科幻末世,按冲突裁决排序命中条目,科幻末世裁决规则,,科幻末世,高压克制 > 冷硬算计 > 绝境反击,绝境生存 > 资源碾压 > 智谋博弈,紧凑推进 危机不断 喘息极短,主角无敌 > 科技无代价 > 末世无压迫感,场景写法 > 爽点与节奏 > 写作技法,CHAPTER_BRIEF.writing_guidance,末世没有生存压力|科技万能|角色行为无逻辑
|
|
|
+RS-004,write|plan,裁决,推理层,都市日常|都市,都市日常怎么写,都市日常,按冲突裁决排序命中条目,都市日常裁决规则,,都市日常,日常轻松 > 温情治愈 > 微妙张力,情感共鸣 > 生活逆袭 > 社交碾压,慢节奏 情感铺垫长 冲突柔和,假大空说教 > 情绪标签化 > 逻辑断裂,写作技法 > 人设与关系 > 场景写法,CHAPTER_BRIEF.writing_guidance,情感靠标签|日常无冲突|角色千人一面
|
|
|
+RS-005,write|plan,裁决,推理层,都市修真|修真|现代修真,都市修真怎么写,都市修真,按冲突裁决排序命中条目,都市修真裁决规则,,都市修真,隐秘低调 > 冷硬算计 > 热血爆发,身份反差 > 境界碾压 > 底牌揭晓,快慢交替 日常短 修真爆发长,修真体系与现代割裂 > 圣母病 > 装逼无代价,爽点与节奏 > 场景写法 > 桥段套路,CHAPTER_BRIEF.writing_guidance,修真体系照搬古代|现代元素没有影响|身份暴露无后果
|
|
|
+RS-006,write|plan,裁决,推理层,都市高武|高武|都市异能,都市高武怎么写,都市高武,按冲突裁决排序命中条目,都市高武裁决规则,,都市高武,热血冲突 > 冷硬算计 > 力量美学,实力碾压 > 以弱胜强 > 排名跃升,快节奏 战斗密集 过渡极短,战力崩盘 > 圣母病 > 无脑开挂,爽点与节奏 > 场景写法 > 桥段套路,CHAPTER_BRIEF.writing_guidance,战力体系自相矛盾|升级无代价|打斗无策略
|
|
|
+RS-007,write|plan,裁决,推理层,历史古代|历史|古代,历史古代怎么写,历史古代,按冲突裁决排序命中条目,历史古代裁决规则,,历史古代,沉稳厚重 > 权谋算计 > 家国情怀,权谋碾压 > 历史转折 > 身份反转,慢铺快收 权谋段拉长 战争段紧凑,现代价值观强加古人 > 逻辑断裂 > 历史常识错误,写作技法 > 人设与关系 > 场景写法,CHAPTER_BRIEF.writing_guidance,用现代口语写古代|权谋无逻辑|历史事件随意篡改
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 运行 CSV_CONFIG 校验确认新表列头对齐**
|
|
|
+
|
|
|
+Run: `cd webnovel-writer && python -m pytest scripts/data_modules/tests/test_csv_config.py -v`
|
|
|
+
|
|
|
+预期:`裁决规则` 现在有文件了,应该 PASS。
|
|
|
+
|
|
|
+- [ ] **Step 3: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add webnovel-writer/references/csv/裁决规则.csv
|
|
|
+git commit -m "feat: add 裁决规则.csv reasoning table for 7 genres"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 4: engine 接入裁决表
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `webnovel-writer/scripts/data_modules/story_system_engine.py`
|
|
|
+- Create: `webnovel-writer/scripts/data_modules/tests/test_reasoning_engine.py`
|
|
|
+- Modify: `webnovel-writer/scripts/data_modules/tests/test_story_system_engine.py`
|
|
|
+
|
|
|
+- [ ] **Step 1: 写裁决层测试**
|
|
|
+
|
|
|
+```python
|
|
|
+# webnovel-writer/scripts/data_modules/tests/test_reasoning_engine.py
|
|
|
+#!/usr/bin/env python3
|
|
|
+# -*- coding: utf-8 -*-
|
|
|
+"""裁决层集成测试。"""
|
|
|
+import csv
|
|
|
+
|
|
|
+from data_modules.story_system_engine import StorySystemEngine
|
|
|
+
|
|
|
+
|
|
|
+def _write_csv(path, headers, rows):
|
|
|
+ with open(path, "w", encoding="utf-8-sig", newline="") as f:
|
|
|
+ writer = csv.DictWriter(f, fieldnames=headers)
|
|
|
+ writer.writeheader()
|
|
|
+ writer.writerows(rows)
|
|
|
+
|
|
|
+
|
|
|
+ROUTE_HEADERS = [
|
|
|
+ "编号", "适用技能", "分类", "层级", "关键词", "意图与同义词", "适用题材",
|
|
|
+ "大模型指令", "核心摘要", "详细展开", "题材/流派", "题材别名", "核心调性",
|
|
|
+ "节奏策略", "毒点", "推荐基础检索表", "推荐动态检索表", "默认查询词",
|
|
|
+]
|
|
|
+
|
|
|
+REASONING_HEADERS = [
|
|
|
+ "编号", "适用技能", "分类", "层级", "关键词", "意图与同义词", "适用题材",
|
|
|
+ "大模型指令", "核心摘要", "详细展开",
|
|
|
+ "题材", "风格优先级", "爽点优先级", "节奏默认策略",
|
|
|
+ "毒点权重", "冲突裁决", "contract注入层", "反模式",
|
|
|
+]
|
|
|
+
|
|
|
+
|
|
|
+def _setup_csvs(csv_dir):
|
|
|
+ _write_csv(csv_dir / "题材与调性推理.csv", ROUTE_HEADERS, [{
|
|
|
+ "编号": "GR-001", "适用技能": "write|plan", "分类": "题材路由",
|
|
|
+ "层级": "知识补充", "关键词": "玄幻", "意图与同义词": "玄幻|仙侠",
|
|
|
+ "适用题材": "玄幻", "大模型指令": "", "核心摘要": "", "详细展开": "",
|
|
|
+ "题材/流派": "玄幻", "题材别名": "玄幻", "核心调性": "热血冲突",
|
|
|
+ "节奏策略": "快推慢收", "毒点": "圣母病",
|
|
|
+ "推荐基础检索表": "命名规则|人设与关系",
|
|
|
+ "推荐动态检索表": "桥段套路|爽点与节奏",
|
|
|
+ "默认查询词": "玄幻",
|
|
|
+ }])
|
|
|
+
|
|
|
+ _write_csv(csv_dir / "裁决规则.csv", REASONING_HEADERS, [{
|
|
|
+ "编号": "RS-001", "适用技能": "write|plan", "分类": "裁决",
|
|
|
+ "层级": "推理层", "关键词": "玄幻", "意图与同义词": "玄幻",
|
|
|
+ "适用题材": "玄幻", "大模型指令": "", "核心摘要": "", "详细展开": "",
|
|
|
+ "题材": "玄幻",
|
|
|
+ "风格优先级": "热血冲突 > 冷硬算计",
|
|
|
+ "爽点优先级": "实力碾压 > 逆境翻盘",
|
|
|
+ "节奏默认策略": "快推慢收",
|
|
|
+ "毒点权重": "圣母病 > 情绪标签化",
|
|
|
+ "冲突裁决": "爽点与节奏 > 场景写法 > 写作技法",
|
|
|
+ "contract注入层": "CHAPTER_BRIEF.writing_guidance",
|
|
|
+ "反模式": "情绪标签化|战斗无代价",
|
|
|
+ }])
|
|
|
+
|
|
|
+ _write_csv(csv_dir / "桥段套路.csv",
|
|
|
+ ["编号", "适用技能", "分类", "层级", "关键词", "适用题材", "核心摘要", "桥段名称", "毒点"],
|
|
|
+ [{"编号": "TR-001", "适用技能": "write", "分类": "桥段", "层级": "知识补充",
|
|
|
+ "关键词": "退婚", "适用题材": "玄幻", "核心摘要": "退婚反击",
|
|
|
+ "桥段名称": "退婚反击", "毒点": "配角代打"}])
|
|
|
+
|
|
|
+ _write_csv(csv_dir / "爽点与节奏.csv",
|
|
|
+ ["编号", "适用技能", "分类", "层级", "关键词", "适用题材", "核心摘要", "毒点", "节奏类型"],
|
|
|
+ [{"编号": "PA-001", "适用技能": "write", "分类": "节奏", "层级": "知识补充",
|
|
|
+ "关键词": "打脸", "适用题材": "玄幻", "核心摘要": "兑现必须补刀",
|
|
|
+ "毒点": "打脸软收尾", "节奏类型": "爆发期"}])
|
|
|
+
|
|
|
+
|
|
|
+def test_build_with_reasoning_includes_reasoning_rule_in_source_trace(tmp_path):
|
|
|
+ csv_dir = tmp_path / "csv"
|
|
|
+ csv_dir.mkdir()
|
|
|
+ _setup_csvs(csv_dir)
|
|
|
+
|
|
|
+ engine = StorySystemEngine(csv_dir=csv_dir)
|
|
|
+ contract = engine.build(query="玄幻", genre=None, chapter=5)
|
|
|
+
|
|
|
+ traces = contract["master_setting"]["source_trace"]
|
|
|
+ reasoning_traces = [t for t in traces if t.get("reasoning_rule")]
|
|
|
+ assert len(reasoning_traces) >= 1
|
|
|
+ assert reasoning_traces[0]["reasoning_rule"] == "玄幻"
|
|
|
+
|
|
|
+
|
|
|
+def test_reasoning_anti_patterns_sorted_by_weight(tmp_path):
|
|
|
+ csv_dir = tmp_path / "csv"
|
|
|
+ csv_dir.mkdir()
|
|
|
+ _setup_csvs(csv_dir)
|
|
|
+
|
|
|
+ engine = StorySystemEngine(csv_dir=csv_dir)
|
|
|
+ contract = engine.build(query="玄幻", genre=None, chapter=5)
|
|
|
+
|
|
|
+ anti = contract["anti_patterns"]
|
|
|
+ assert len(anti) >= 1
|
|
|
+
|
|
|
+
|
|
|
+def test_reasoning_not_found_falls_back_gracefully(tmp_path):
|
|
|
+ csv_dir = tmp_path / "csv"
|
|
|
+ csv_dir.mkdir()
|
|
|
+ _setup_csvs(csv_dir)
|
|
|
+
|
|
|
+ engine = StorySystemEngine(csv_dir=csv_dir)
|
|
|
+ contract = engine.build(query="末日生存", genre="末日", chapter=1)
|
|
|
+
|
|
|
+ # 没有裁决规则也不应报错
|
|
|
+ assert "master_setting" in contract
|
|
|
+ assert "anti_patterns" in contract
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 运行测试确认失败**
|
|
|
+
|
|
|
+Run: `cd webnovel-writer && python -m pytest scripts/data_modules/tests/test_reasoning_engine.py -v`
|
|
|
+
|
|
|
+预期:FAIL,因为 `_load_reasoning` 等方法还不存在。
|
|
|
+
|
|
|
+- [ ] **Step 3: 在 `story_system_engine.py` 新增裁决方法**
|
|
|
+
|
|
|
+在 `StorySystemEngine` 类末尾新增:
|
|
|
+
|
|
|
+```python
|
|
|
+def _load_reasoning(self, genre: str) -> Dict[str, Any]:
|
|
|
+ """从裁决表按题材查一行,返回裁决规则 dict。"""
|
|
|
+ rows = self._load_csv_rows("裁决规则")
|
|
|
+ genre_text = self._normalize_text(genre)
|
|
|
+ for row in rows:
|
|
|
+ row_genre = self._normalize_text(row.get("题材", ""))
|
|
|
+ if row_genre == genre_text:
|
|
|
+ return row
|
|
|
+ aliases = self._split_multi_value(row.get("关键词")) + self._split_multi_value(row.get("意图与同义词"))
|
|
|
+ if any(self._normalize_text(a) == genre_text for a in aliases):
|
|
|
+ return row
|
|
|
+ return {}
|
|
|
+
|
|
|
+def _apply_reasoning(
|
|
|
+ self,
|
|
|
+ reasoning: Dict[str, Any],
|
|
|
+ base_context: List[Dict[str, Any]],
|
|
|
+ dynamic_context: List[Dict[str, Any]],
|
|
|
+) -> List[Dict[str, Any]]:
|
|
|
+ """用冲突裁决字段对命中条目做优先级排序。"""
|
|
|
+ if not reasoning:
|
|
|
+ return base_context + dynamic_context
|
|
|
+
|
|
|
+ priority_order = [
|
|
|
+ t.strip() for t in str(reasoning.get("冲突裁决", "")).split(">") if t.strip()
|
|
|
+ ]
|
|
|
+ priority_map = {name: idx for idx, name in enumerate(priority_order)}
|
|
|
+
|
|
|
+ all_rows = base_context + dynamic_context
|
|
|
+ for row in all_rows:
|
|
|
+ table_name = str(row.get("_table", "")).strip()
|
|
|
+ row["_priority_rank"] = priority_map.get(table_name, len(priority_order))
|
|
|
+ row["_reasoning_rule"] = str(reasoning.get("题材", "")).strip()
|
|
|
+
|
|
|
+ all_rows.sort(key=lambda r: r.get("_priority_rank", 999))
|
|
|
+ return all_rows
|
|
|
+
|
|
|
+def _rank_anti_patterns(
|
|
|
+ self,
|
|
|
+ reasoning: Dict[str, Any],
|
|
|
+ anti_patterns: List[Dict[str, Any]],
|
|
|
+) -> List[Dict[str, Any]]:
|
|
|
+ """用毒点权重字段对毒点排序。"""
|
|
|
+ if not reasoning:
|
|
|
+ return anti_patterns
|
|
|
+
|
|
|
+ weight_order = [
|
|
|
+ t.strip() for t in str(reasoning.get("毒点权重", "")).split(">") if t.strip()
|
|
|
+ ]
|
|
|
+
|
|
|
+ def sort_key(item):
|
|
|
+ text = str(item.get("text", "")).strip()
|
|
|
+ for idx, keyword in enumerate(weight_order):
|
|
|
+ if keyword in text:
|
|
|
+ return idx
|
|
|
+ return len(weight_order)
|
|
|
+
|
|
|
+ anti_patterns.sort(key=sort_key)
|
|
|
+
|
|
|
+ # 追加裁决表自带的反模式
|
|
|
+ for text in self._split_multi_value(reasoning.get("反模式")):
|
|
|
+ anti_patterns.append({
|
|
|
+ "text": text,
|
|
|
+ "source_table": "裁决规则",
|
|
|
+ "source_id": reasoning.get("编号", ""),
|
|
|
+ })
|
|
|
+
|
|
|
+ return anti_patterns
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 改造 `build()` 方法接入裁决层**
|
|
|
+
|
|
|
+把 `build()` 方法(第 29-90 行)改为:
|
|
|
+
|
|
|
+```python
|
|
|
+def build(self, query: str, genre: Optional[str], chapter: Optional[int]) -> Dict[str, Any]:
|
|
|
+ route = self._route(query=query, genre=genre)
|
|
|
+ search_query = self._expand_query(query, route.get("default_query", ""))
|
|
|
+ base_context = self._collect_tables(
|
|
|
+ search_query,
|
|
|
+ route["recommended_base_tables"],
|
|
|
+ genre=route["genre_filter"],
|
|
|
+ top_k=1,
|
|
|
+ )
|
|
|
+ dynamic_context = self._collect_tables(
|
|
|
+ search_query,
|
|
|
+ route["recommended_dynamic_tables"],
|
|
|
+ genre=route["genre_filter"],
|
|
|
+ top_k=2,
|
|
|
+ )
|
|
|
+
|
|
|
+ # --- 裁决层 ---
|
|
|
+ primary_genre = str(
|
|
|
+ route.get("meta", {}).get("primary_genre", "") or genre or ""
|
|
|
+ ).strip()
|
|
|
+ reasoning = self._load_reasoning(primary_genre)
|
|
|
+ ranked = self._apply_reasoning(reasoning, base_context, dynamic_context)
|
|
|
+
|
|
|
+ source_trace = route["source_trace"] + self._build_source_trace_with_reasoning(ranked, reasoning)
|
|
|
+
|
|
|
+ raw_anti = merge_anti_patterns(
|
|
|
+ route["route_anti_patterns"],
|
|
|
+ self._extract_anti_patterns(base_context),
|
|
|
+ self._extract_anti_patterns(dynamic_context),
|
|
|
+ )
|
|
|
+ anti_patterns = self._rank_anti_patterns(reasoning, raw_anti)
|
|
|
+
|
|
|
+ return {
|
|
|
+ "meta": {"query": query, "chapter": chapter, "explicit_genre": genre or ""},
|
|
|
+ "master_setting": {
|
|
|
+ "meta": {
|
|
|
+ "schema_version": "story-system/v1",
|
|
|
+ "contract_type": "MASTER_SETTING",
|
|
|
+ "generator_version": "phase1",
|
|
|
+ "query": query,
|
|
|
+ },
|
|
|
+ "route": route["meta"],
|
|
|
+ "master_constraints": {
|
|
|
+ "core_tone": route["core_tone"],
|
|
|
+ "pacing_strategy": route["pacing_strategy"],
|
|
|
+ },
|
|
|
+ "base_context": [r for r in ranked if r.get("_priority_rank", 999) < 999],
|
|
|
+ "source_trace": source_trace,
|
|
|
+ "override_policy": {
|
|
|
+ "locked": ["route.primary_genre", "master_constraints.core_tone"],
|
|
|
+ "append_only": ["anti_patterns"],
|
|
|
+ "override_allowed": [],
|
|
|
+ },
|
|
|
+ },
|
|
|
+ "chapter_brief": (
|
|
|
+ {
|
|
|
+ "meta": {
|
|
|
+ "schema_version": "story-system/v1",
|
|
|
+ "contract_type": "CHAPTER_BRIEF",
|
|
|
+ "generator_version": "phase1",
|
|
|
+ "chapter": chapter,
|
|
|
+ },
|
|
|
+ "override_allowed": {
|
|
|
+ "chapter_focus": self._suggest_chapter_focus(query, dynamic_context),
|
|
|
+ },
|
|
|
+ "dynamic_context": ranked,
|
|
|
+ "source_trace": source_trace,
|
|
|
+ "reasoning": {
|
|
|
+ "genre": reasoning.get("题材", ""),
|
|
|
+ "inject_target": reasoning.get("contract注入层", ""),
|
|
|
+ "style_priority": reasoning.get("风格优先级", ""),
|
|
|
+ "pacing_strategy": reasoning.get("节奏默认策略", ""),
|
|
|
+ } if reasoning else {},
|
|
|
+ }
|
|
|
+ if chapter is not None
|
|
|
+ else None
|
|
|
+ ),
|
|
|
+ "anti_patterns": anti_patterns,
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: 新增 `_build_source_trace_with_reasoning` 方法**
|
|
|
+
|
|
|
+```python
|
|
|
+def _build_source_trace_with_reasoning(
|
|
|
+ self, ranked: List[Dict[str, Any]], reasoning: Dict[str, Any]
|
|
|
+) -> List[Dict[str, Any]]:
|
|
|
+ trace: List[Dict[str, Any]] = []
|
|
|
+ reasoning_rule = str(reasoning.get("题材", "")).strip() if reasoning else ""
|
|
|
+ for row in ranked:
|
|
|
+ trace.append({
|
|
|
+ "table": row.get("_table", ""),
|
|
|
+ "id": row.get("编号", ""),
|
|
|
+ "summary": row.get("核心摘要", ""),
|
|
|
+ "reasoning_rule": row.get("_reasoning_rule", reasoning_rule),
|
|
|
+ "priority_rank": row.get("_priority_rank", 999),
|
|
|
+ "inject_target": str(reasoning.get("contract注入层", "")).strip() if reasoning else "",
|
|
|
+ })
|
|
|
+ return trace
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 6: 运行裁决层测试**
|
|
|
+
|
|
|
+Run: `cd webnovel-writer && python -m pytest scripts/data_modules/tests/test_reasoning_engine.py -v`
|
|
|
+
|
|
|
+预期:全部 PASS。
|
|
|
+
|
|
|
+- [ ] **Step 7: 运行现有 engine 测试确认不破坏**
|
|
|
+
|
|
|
+Run: `cd webnovel-writer && python -m pytest scripts/data_modules/tests/test_story_system_engine.py -v`
|
|
|
+
|
|
|
+预期:全部 PASS(无裁决表时 graceful fallback)。
|
|
|
+
|
|
|
+- [ ] **Step 8: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add webnovel-writer/scripts/data_modules/story_system_engine.py webnovel-writer/scripts/data_modules/tests/test_reasoning_engine.py
|
|
|
+git commit -m "feat: integrate reasoning table into story_system_engine build pipeline"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 5: context_manager 瘦身
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `webnovel-writer/scripts/data_modules/context_manager.py`
|
|
|
+- Delete: `webnovel-writer/scripts/data_modules/snapshot_manager.py`
|
|
|
+- Modify: `webnovel-writer/scripts/data_modules/tests/test_context_manager.py`
|
|
|
+
|
|
|
+- [ ] **Step 1: 从 `context_manager.py` 删除 snapshot 相关代码**
|
|
|
+
|
|
|
+1. 删除 import:`from .snapshot_manager import SnapshotManager, SnapshotVersionMismatch`(第 33 行)
|
|
|
+2. 删除 `__init__` 中 `self.snapshot_manager` 赋值(第 101 行)
|
|
|
+3. 删除 `_is_snapshot_compatible` 方法(第 105-146 行)
|
|
|
+4. 删除 `build_context` 中 snapshot 加载和保存逻辑(第 162-169 行和第 176-181 行)
|
|
|
+5. 删除 `_story_contract_signature` 方法(第 794-817 行)
|
|
|
+6. 删除 `_payload_signature` 方法(第 819-823 行)
|
|
|
+7. 删除 `build_context` 的 `use_snapshot` 和 `save_snapshot` 参数
|
|
|
+
|
|
|
+- [ ] **Step 2: 简化 `build_context` 为纯 JSON 返回**
|
|
|
+
|
|
|
+改造后的 `build_context`:
|
|
|
+
|
|
|
+```python
|
|
|
+def build_context(
|
|
|
+ self,
|
|
|
+ chapter: int,
|
|
|
+ template: str | None = None,
|
|
|
+ max_chars: Optional[int] = None,
|
|
|
+) -> Dict[str, Any]:
|
|
|
+ template = template or self.DEFAULT_TEMPLATE
|
|
|
+ self._active_template = template
|
|
|
+ if template not in self.TEMPLATE_WEIGHTS:
|
|
|
+ template = self.DEFAULT_TEMPLATE
|
|
|
+ self._active_template = template
|
|
|
+
|
|
|
+ pack = self._build_pack(chapter)
|
|
|
+ if getattr(self.config, "context_ranker_enabled", True):
|
|
|
+ pack = self.context_ranker.rank_pack(pack, chapter)
|
|
|
+
|
|
|
+ return self._assemble_json_payload(pack, template=template, max_chars=max_chars)
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 把 `assemble_context` 重写为 `_assemble_json_payload`**
|
|
|
+
|
|
|
+直接返回 dict,不做 text 渲染:
|
|
|
+
|
|
|
+```python
|
|
|
+def _assemble_json_payload(
|
|
|
+ self,
|
|
|
+ pack: Dict[str, Any],
|
|
|
+ template: str = DEFAULT_TEMPLATE,
|
|
|
+ max_chars: Optional[int] = None,
|
|
|
+) -> Dict[str, Any]:
|
|
|
+ chapter = int((pack.get("meta") or {}).get("chapter") or 0)
|
|
|
+ weights = self._resolve_template_weights(template=template, chapter=chapter)
|
|
|
+
|
|
|
+ payload: Dict[str, Any] = {
|
|
|
+ "meta": {
|
|
|
+ **(pack.get("meta") or {}),
|
|
|
+ "context_contract_version": "v3",
|
|
|
+ },
|
|
|
+ }
|
|
|
+
|
|
|
+ for section_name in self.SECTION_ORDER:
|
|
|
+ if section_name in pack and section_name != "global":
|
|
|
+ content = pack[section_name]
|
|
|
+ weight = weights.get(section_name, 0.0)
|
|
|
+ if weight > 0 or section_name in self.EXTRA_SECTIONS:
|
|
|
+ payload[section_name] = content
|
|
|
+
|
|
|
+ if chapter > 0:
|
|
|
+ payload["meta"]["context_weight_stage"] = self._resolve_context_stage(chapter)
|
|
|
+
|
|
|
+ return payload
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 删除 `_compact_json_text` 方法**
|
|
|
+
|
|
|
+删除第 749-764 行。
|
|
|
+
|
|
|
+- [ ] **Step 5: 删除 `assemble_context` 旧方法**
|
|
|
+
|
|
|
+删除第 185-217 行的 `assemble_context`。
|
|
|
+
|
|
|
+- [ ] **Step 6: 更新 `__init__` 签名**
|
|
|
+
|
|
|
+```python
|
|
|
+def __init__(self, config=None):
|
|
|
+ self.config = config or get_config()
|
|
|
+ self.index_manager = IndexManager(self.config)
|
|
|
+ self.context_ranker = ContextRanker(self.config)
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 7: 删除 `snapshot_manager.py`**
|
|
|
+
|
|
|
+```bash
|
|
|
+git rm webnovel-writer/scripts/data_modules/snapshot_manager.py
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 8: 更新 `extract_chapter_context.py` 的 `_load_contract_context`**
|
|
|
+
|
|
|
+`_load_contract_context`(第 294-325 行)改为:
|
|
|
+
|
|
|
+```python
|
|
|
+def _load_contract_context(project_root: Path, chapter_num: int) -> Dict[str, Any]:
|
|
|
+ """Build context via ContextManager and return payload directly."""
|
|
|
+ _ensure_scripts_path()
|
|
|
+ from data_modules.config import DataModulesConfig
|
|
|
+ from data_modules.context_manager import ContextManager
|
|
|
+
|
|
|
+ config = DataModulesConfig.from_project_root(project_root)
|
|
|
+ manager = ContextManager(config)
|
|
|
+ payload = manager.build_context(chapter=chapter_num, template="plot")
|
|
|
+
|
|
|
+ return {
|
|
|
+ "context_contract_version": (payload.get("meta") or {}).get("context_contract_version"),
|
|
|
+ "context_weight_stage": (payload.get("meta") or {}).get("context_weight_stage"),
|
|
|
+ "story_contract": payload.get("story_contract", {}),
|
|
|
+ "runtime_status": payload.get("runtime_status", {}),
|
|
|
+ "latest_commit": payload.get("latest_commit", {}),
|
|
|
+ "prewrite_validation": payload.get("prewrite_validation", {}),
|
|
|
+ "reader_signal": payload.get("reader_signal", {}),
|
|
|
+ "genre_profile": payload.get("genre_profile", {}),
|
|
|
+ "writing_guidance": payload.get("writing_guidance", {}),
|
|
|
+ "plot_structure": payload.get("plot_structure", {}),
|
|
|
+ "long_term_memory": payload.get("long_term_memory", {}),
|
|
|
+ "scene": payload.get("scene", {}),
|
|
|
+ "core": payload.get("core", {}),
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 9: 把 `_render_text()` 改为纯 JSON 序列化**
|
|
|
+
|
|
|
+当前 `_render_text()`(第 364-601 行)是一个 240 行的审计式文本渲染函数。按 spec 终局,text 渲染不再由代码层负责——context-agent 拿 JSON payload 按示例写任务书。
|
|
|
+
|
|
|
+把整个 `_render_text()` 替换为:
|
|
|
+
|
|
|
+```python
|
|
|
+def _render_text(payload: Dict[str, Any]) -> str:
|
|
|
+ """JSON 序列化输出,text 渲染由 context-agent 负责。"""
|
|
|
+ return json.dumps(payload, ensure_ascii=False, indent=2)
|
|
|
+```
|
|
|
+
|
|
|
+这意味着 `--format text` 和 `--format json` 现在输出相同内容。如果后续要区分,可以在 context-agent 侧处理,但代码层不再做 markdown 拼接。
|
|
|
+
|
|
|
+- [ ] **Step 10: 修复受影响的测试**
|
|
|
+
|
|
|
+在 `test_context_manager.py` 中:
|
|
|
+- 删除所有 `snapshot_manager` 相关的 mock 和 fixture
|
|
|
+- 删除 snapshot 相关的测试用例
|
|
|
+- 更新 `build_context` 调用移除 `use_snapshot` / `save_snapshot` 参数
|
|
|
+- 更新断言适配新的 payload 结构(直接 `payload["story_contract"]` 而不是 `payload["sections"]["story_contract"]["content"]`)
|
|
|
+
|
|
|
+在 `test_extract_chapter_context.py` 中:
|
|
|
+- 更新任何依赖旧 markdown 渲染输出的断言(如 `"## 本章大纲"` 等 markdown 标题检查改为 JSON key 检查)
|
|
|
+
|
|
|
+- [ ] **Step 11: 运行测试**
|
|
|
+
|
|
|
+Run: `cd webnovel-writer && python -m pytest scripts/data_modules/tests/test_context_manager.py scripts/data_modules/tests/test_extract_chapter_context.py -v`
|
|
|
+
|
|
|
+预期:全部 PASS。
|
|
|
+
|
|
|
+- [ ] **Step 12: 确认行数**
|
|
|
+
|
|
|
+Run: `wc -l webnovel-writer/scripts/data_modules/context_manager.py`
|
|
|
+
|
|
|
+预期:400 行以下。
|
|
|
+
|
|
|
+- [ ] **Step 13: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add webnovel-writer/scripts/data_modules/context_manager.py webnovel-writer/scripts/extract_chapter_context.py webnovel-writer/scripts/data_modules/tests/test_context_manager.py
|
|
|
+git rm webnovel-writer/scripts/data_modules/snapshot_manager.py
|
|
|
+git commit -m "refactor: slim context_manager to pure JSON assembler, remove snapshot"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 6: 旧散写路径清理
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `webnovel-writer/skills/webnovel-write/SKILL.md:184,254,323`
|
|
|
+- Modify: `webnovel-writer/scripts/data_modules/tests/test_prompt_integrity.py`
|
|
|
+
|
|
|
+- [ ] **Step 1: 删除 SKILL.md 中 Step 2 的 `set-chapter-status`**
|
|
|
+
|
|
|
+删除第 182-184 行:
|
|
|
+
|
|
|
+```markdown
|
|
|
+状态推进:
|
|
|
+
|
|
|
+```bash
|
|
|
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" state set-chapter-status --chapter {chapter_num} --status chapter_drafted
|
|
|
+```
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 删除 SKILL.md 中 Step 4 的 `set-chapter-status`**
|
|
|
+
|
|
|
+删除第 250-254 行的 `状态推进(--minimal 除外):` 段和对应的 bash 块。
|
|
|
+
|
|
|
+- [ ] **Step 3: 删除 SKILL.md 中 Step 5 末尾的 `set-chapter-status`**
|
|
|
+
|
|
|
+删除第 320-323 行的状态推进 bash 块。Step 5 的状态推进现在由 `state_projection_writer.py` 在 commit accepted 时自动完成。
|
|
|
+
|
|
|
+在 Step 5.3 验证投影状态段落中补充说明:
|
|
|
+
|
|
|
+```markdown
|
|
|
+**chapter_status 推进**:
|
|
|
+- accepted commit → `state_projection_writer` 自动推进到 `chapter_committed`
|
|
|
+- rejected commit → `state_projection_writer` 自动推进到 `chapter_rejected`
|
|
|
+- 不再由 skill 手动调用 `set-chapter-status`
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 更新充分性闸门**
|
|
|
+
|
|
|
+把第 338-346 行的闸门条件中:
|
|
|
+- 删除 "2. `chapter_status` 已推进到 `chapter_drafted`(Step 2 完成)"
|
|
|
+- 把 "5. ... `chapter_status` 已推进到 `chapter_reviewed`" 中状态检查改为仅由投影确认
|
|
|
+- 把 "6. ... `chapter_status` 已推进到 `chapter_committed`" 改为 "6. ... projection_status 四项全部 done/skipped"
|
|
|
+
|
|
|
+改为:
|
|
|
+
|
|
|
+```markdown
|
|
|
+## 充分性闸门
|
|
|
+
|
|
|
+未满足以下条件前,不得结束流程:
|
|
|
+
|
|
|
+1. 章节正文文件存在且非空。
|
|
|
+2. Step 3 已产出审查结果并落库(`--minimal` 除外)。
|
|
|
+3. 若存在 `blocking=true` 的 issue,流程必须停在 Step 3。
|
|
|
+4. Step 4 的 `anti_ai_force_check=pass`(`--minimal` 除外)。
|
|
|
+5. Step 5 已生成 accepted `CHAPTER_COMMIT`,`projection_status` 四项全部为 `done` 或 `skipped`。
|
|
|
+6. `chapter_status` 为 `chapter_committed`(由 projection writer 自动推进,不手动写入)。
|
|
|
+7. 若启用观测,已读取最新 timing 记录并给出结论。
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: 新增 prompt integrity 测试断言**
|
|
|
+
|
|
|
+在 `test_prompt_integrity.py` 末尾新增:
|
|
|
+
|
|
|
+```python
|
|
|
+def test_no_direct_state_writes_in_write_skill():
|
|
|
+ """webnovel-write SKILL.md 中不应有 set-chapter-status 调用(由 projection writer 统一推进)。"""
|
|
|
+ text = (SKILLS_DIR / "webnovel-write" / "SKILL.md").read_text(encoding="utf-8")
|
|
|
+ assert "state set-chapter-status" not in text, (
|
|
|
+ "webnovel-write 中不应直接调用 state set-chapter-status,"
|
|
|
+ "chapter_status 由 state_projection_writer 在 commit 时自动推进"
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def test_no_direct_state_writes_in_agents():
|
|
|
+ """agents 目录中不应有直接写 state/index 的指令。"""
|
|
|
+ for agent_file in AGENT_FILES:
|
|
|
+ text = _read_text(agent_file)
|
|
|
+ assert "state set-chapter-status" not in text, (
|
|
|
+ f"{agent_file.name}: 不应直接调用 state set-chapter-status"
|
|
|
+ )
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 6: 运行测试**
|
|
|
+
|
|
|
+Run: `cd webnovel-writer && python -m pytest scripts/data_modules/tests/test_prompt_integrity.py -v`
|
|
|
+
|
|
|
+预期:全部 PASS。
|
|
|
+
|
|
|
+- [ ] **Step 7: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add webnovel-writer/skills/webnovel-write/SKILL.md webnovel-writer/scripts/data_modules/tests/test_prompt_integrity.py
|
|
|
+git commit -m "refactor: remove direct set-chapter-status calls from write skill"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 7: projection 层收束
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `webnovel-writer/scripts/data_modules/event_projection_router.py`
|
|
|
+- Modify: `webnovel-writer/scripts/data_modules/state_projection_writer.py`
|
|
|
+- Modify: `webnovel-writer/scripts/data_modules/chapter_commit_service.py`
|
|
|
+- Modify: `webnovel-writer/scripts/data_modules/tests/test_event_projection_router.py`
|
|
|
+
|
|
|
+- [ ] **Step 1: 写 router vector 路由测试**
|
|
|
+
|
|
|
+在 `test_event_projection_router.py` 末尾新增:
|
|
|
+
|
|
|
+```python
|
|
|
+def test_router_maps_power_breakthrough_to_state_memory_vector():
|
|
|
+ router = EventProjectionRouter()
|
|
|
+ targets = router.route(
|
|
|
+ {"event_type": "power_breakthrough", "subject": "xiaoyan", "payload": {}}
|
|
|
+ )
|
|
|
+ assert "vector" in targets
|
|
|
+ assert "state" in targets
|
|
|
+ assert "memory" in targets
|
|
|
+
|
|
|
+
|
|
|
+def test_router_maps_relationship_changed_to_index_and_vector():
|
|
|
+ router = EventProjectionRouter()
|
|
|
+ targets = router.route(
|
|
|
+ {"event_type": "relationship_changed", "subject": "xiaoyan", "payload": {}}
|
|
|
+ )
|
|
|
+ assert "index" in targets
|
|
|
+ assert "vector" in targets
|
|
|
+
|
|
|
+
|
|
|
+def test_required_writers_includes_vector_for_key_events():
|
|
|
+ router = EventProjectionRouter()
|
|
|
+ payload = {
|
|
|
+ "meta": {"status": "accepted", "chapter": 5},
|
|
|
+ "accepted_events": [
|
|
|
+ {"event_type": "power_breakthrough", "subject": "xiaoyan", "payload": {}},
|
|
|
+ ],
|
|
|
+ "entity_deltas": [],
|
|
|
+ "summary_text": "摘要",
|
|
|
+ }
|
|
|
+ writers = router.required_writers(payload)
|
|
|
+ assert "vector" in writers
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 运行测试确认失败**
|
|
|
+
|
|
|
+Run: `cd webnovel-writer && python -m pytest scripts/data_modules/tests/test_event_projection_router.py -v`
|
|
|
+
|
|
|
+预期:新增的 3 个测试 FAIL。
|
|
|
+
|
|
|
+- [ ] **Step 3: 更新 `EventProjectionRouter.TABLE`**
|
|
|
+
|
|
|
+```python
|
|
|
+class EventProjectionRouter:
|
|
|
+ TABLE = {
|
|
|
+ "character_state_changed": ["state", "memory", "vector"],
|
|
|
+ "power_breakthrough": ["state", "memory", "vector"],
|
|
|
+ "relationship_changed": ["index", "vector"],
|
|
|
+ "world_rule_revealed": ["memory", "vector"],
|
|
|
+ "world_rule_broken": ["memory", "vector"],
|
|
|
+ "open_loop_created": ["memory"],
|
|
|
+ "open_loop_closed": ["memory"],
|
|
|
+ "promise_created": ["memory"],
|
|
|
+ "promise_paid_off": ["memory"],
|
|
|
+ "artifact_obtained": ["index", "vector"],
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 确认 `state_projection_writer` 已处理 `chapter_status`**
|
|
|
+
|
|
|
+当前 `state_projection_writer.py:34-35` 已有:
|
|
|
+
|
|
|
+```python
|
|
|
+if chapter > 0:
|
|
|
+ chapter_status[str(chapter)] = "chapter_committed"
|
|
|
+```
|
|
|
+
|
|
|
+这已经满足 Section 6/7 的要求(accepted commit 时自动推进到 `chapter_committed`)。
|
|
|
+
|
|
|
+确认 rejected commit 时不推进——当前第 15 行检查了 `status != "accepted"` 直接返回,不写状态。这是正确的。
|
|
|
+
|
|
|
+但 spec 要求 rejected 推进到 `chapter_rejected`。在 `apply` 方法开头加 rejected 处理:
|
|
|
+
|
|
|
+```python
|
|
|
+def apply(self, commit_payload: dict) -> dict:
|
|
|
+ chapter = int(commit_payload.get("meta", {}).get("chapter") or 0)
|
|
|
+ status = commit_payload["meta"]["status"]
|
|
|
+
|
|
|
+ if status == "rejected":
|
|
|
+ if chapter > 0:
|
|
|
+ state_path = self.project_root / ".webnovel" / "state.json"
|
|
|
+ state = read_json_if_exists(state_path) or {}
|
|
|
+ progress = state.setdefault("progress", {})
|
|
|
+ chapter_status = progress.setdefault("chapter_status", {})
|
|
|
+ chapter_status[str(chapter)] = "chapter_rejected"
|
|
|
+ write_json(state_path, state)
|
|
|
+ return {"applied": True, "writer": "state", "reason": "commit_rejected_status_updated"}
|
|
|
+
|
|
|
+ # ... rest of accepted logic
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: 确认 `chapter_commit_service.apply_projections` 的失败隔离**
|
|
|
+
|
|
|
+当前第 115-119 行已有 try/except 隔离:
|
|
|
+
|
|
|
+```python
|
|
|
+try:
|
|
|
+ result = writer.apply(payload)
|
|
|
+ payload["projection_status"][name] = "done" if result.get("applied") else "skipped"
|
|
|
+except Exception as exc:
|
|
|
+ payload["projection_status"][name] = f"failed:{exc}"
|
|
|
+```
|
|
|
+
|
|
|
+并且第 120 行 `self.persist_commit(payload)` 在所有 writer 执行完后才写入——确保 `projection_status` 已更新。
|
|
|
+
|
|
|
+这已满足 spec 要求。不需要额外改动。
|
|
|
+
|
|
|
+- [ ] **Step 6: 运行测试**
|
|
|
+
|
|
|
+Run: `cd webnovel-writer && python -m pytest scripts/data_modules/tests/test_event_projection_router.py -v`
|
|
|
+
|
|
|
+预期:全部 PASS。
|
|
|
+
|
|
|
+- [ ] **Step 7: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add webnovel-writer/scripts/data_modules/event_projection_router.py webnovel-writer/scripts/data_modules/state_projection_writer.py webnovel-writer/scripts/data_modules/tests/test_event_projection_router.py
|
|
|
+git commit -m "feat: add vector route to projection router, handle rejected status"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 8: 消费端同步
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `webnovel-writer/agents/context-agent.md`
|
|
|
+- Modify: `webnovel-writer/agents/data-agent.md`
|
|
|
+- Modify: `webnovel-writer/skills/webnovel-write/SKILL.md`
|
|
|
+- Modify: `webnovel-writer/scripts/data_modules/tests/test_prompt_integrity.py`
|
|
|
+
|
|
|
+- [ ] **Step 1: 更新 `context-agent.md` 删除旧引用**
|
|
|
+
|
|
|
+1. Section 2 工具段落中,确认 `extract-context` 命令标注为"备选"(已是,不动)
|
|
|
+2. 删除对 snapshot 的引用(grep 确认是否有)
|
|
|
+3. 确认 Section 8 的输出格式中有写作任务书示例(已在上一次改造中完成)
|
|
|
+
|
|
|
+- [ ] **Step 2: 确认 `data-agent.md` 不直写**
|
|
|
+
|
|
|
+当前 `data-agent.md` 已明确标注:
|
|
|
+- "你不直接写入这些文件" (第 111 行)
|
|
|
+- "不直接写入 `index.db` 和 `state.json`" (第 146 行)
|
|
|
+
|
|
|
+确认不需要改动。
|
|
|
+
|
|
|
+- [ ] **Step 3: 更新 `SKILL.md` 中 Step 5 简化描述**
|
|
|
+
|
|
|
+把 Step 5.4 的失败隔离表格中增加 vector 相关条目(如果还没有的话)。
|
|
|
+
|
|
|
+确认 Step 1 的写作任务书流程描述和当前代码对齐(已在上一次改造中完成)。
|
|
|
+
|
|
|
+- [ ] **Step 4: 在 `test_prompt_integrity.py` 中更新 `KNOWN_DELETED_FILES`**
|
|
|
+
|
|
|
+新增 `snapshot_manager.py` 到已删文件列表:
|
|
|
+
|
|
|
+```python
|
|
|
+KNOWN_DELETED_FILES = [
|
|
|
+ "step-1.5-contract.md",
|
|
|
+ "step-3-review-gate.md",
|
|
|
+ "step-5-debt-switch.md",
|
|
|
+ "workflow-details.md",
|
|
|
+ "checker-output-schema.md",
|
|
|
+ "workflow_manager.py",
|
|
|
+ "webnovel-resume",
|
|
|
+ "golden_three_checker.py",
|
|
|
+ "snapshot_manager.py",
|
|
|
+]
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: 运行全量 prompt integrity 测试**
|
|
|
+
|
|
|
+Run: `cd webnovel-writer && python -m pytest scripts/data_modules/tests/test_prompt_integrity.py -v`
|
|
|
+
|
|
|
+预期:全部 PASS。
|
|
|
+
|
|
|
+- [ ] **Step 6: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add webnovel-writer/agents/context-agent.md webnovel-writer/agents/data-agent.md webnovel-writer/skills/webnovel-write/SKILL.md webnovel-writer/scripts/data_modules/tests/test_prompt_integrity.py
|
|
|
+git commit -m "refactor: sync consumer prompts with new mainline"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 9: 向量投影 Writer
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `webnovel-writer/scripts/data_modules/vector_projection_writer.py`
|
|
|
+- Modify: `webnovel-writer/scripts/data_modules/chapter_commit_service.py`
|
|
|
+- Create: `webnovel-writer/scripts/data_modules/tests/test_vector_projection_writer.py`
|
|
|
+
|
|
|
+- [ ] **Step 1: 写测试**
|
|
|
+
|
|
|
+```python
|
|
|
+# webnovel-writer/scripts/data_modules/tests/test_vector_projection_writer.py
|
|
|
+#!/usr/bin/env python3
|
|
|
+# -*- coding: utf-8 -*-
|
|
|
+"""VectorProjectionWriter 单元测试。"""
|
|
|
+from data_modules.vector_projection_writer import VectorProjectionWriter
|
|
|
+
|
|
|
+
|
|
|
+def test_event_to_text_formats_power_breakthrough():
|
|
|
+ writer = VectorProjectionWriter.__new__(VectorProjectionWriter)
|
|
|
+ event = {
|
|
|
+ "event_type": "power_breakthrough",
|
|
|
+ "chapter": 47,
|
|
|
+ "subject": "韩立",
|
|
|
+ "payload": {"field": "realm", "new": "筑基初期"},
|
|
|
+ }
|
|
|
+ text = writer._event_to_text(event)
|
|
|
+ assert "第47章" in text
|
|
|
+ assert "韩立" in text
|
|
|
+ assert "筑基初期" in text
|
|
|
+
|
|
|
+
|
|
|
+def test_delta_to_text_formats_relationship():
|
|
|
+ writer = VectorProjectionWriter.__new__(VectorProjectionWriter)
|
|
|
+ delta = {
|
|
|
+ "from_entity": "韩立",
|
|
|
+ "to_entity": "陈巧倩",
|
|
|
+ "relationship_type": "合作",
|
|
|
+ "chapter": 47,
|
|
|
+ }
|
|
|
+ text = writer._delta_to_text(delta)
|
|
|
+ assert "第47章" in text
|
|
|
+ assert "韩立" in text
|
|
|
+ assert "陈巧倩" in text
|
|
|
+ assert "合作" in text
|
|
|
+
|
|
|
+
|
|
|
+def test_collect_chunks_from_commit():
|
|
|
+ writer = VectorProjectionWriter.__new__(VectorProjectionWriter)
|
|
|
+ payload = {
|
|
|
+ "meta": {"chapter": 47, "status": "accepted"},
|
|
|
+ "accepted_events": [
|
|
|
+ {
|
|
|
+ "event_type": "power_breakthrough",
|
|
|
+ "chapter": 47,
|
|
|
+ "subject": "韩立",
|
|
|
+ "payload": {"field": "realm", "new": "筑基初期"},
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ "entity_deltas": [
|
|
|
+ {
|
|
|
+ "from_entity": "韩立",
|
|
|
+ "to_entity": "陈巧倩",
|
|
|
+ "relationship_type": "合作",
|
|
|
+ "chapter": 47,
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ }
|
|
|
+ chunks = writer._collect_chunks(payload)
|
|
|
+ assert len(chunks) == 2
|
|
|
+ assert chunks[0]["chunk_type"] == "event"
|
|
|
+ assert chunks[1]["chunk_type"] == "entity_delta"
|
|
|
+
|
|
|
+
|
|
|
+def test_rejected_commit_returns_not_applied():
|
|
|
+ writer = VectorProjectionWriter.__new__(VectorProjectionWriter)
|
|
|
+ writer.project_root = None # won't be used
|
|
|
+ result = writer.apply({"meta": {"status": "rejected", "chapter": 1}})
|
|
|
+ assert result["applied"] is False
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 运行测试确认失败**
|
|
|
+
|
|
|
+Run: `cd webnovel-writer && python -m pytest scripts/data_modules/tests/test_vector_projection_writer.py -v`
|
|
|
+
|
|
|
+预期:FAIL(模块不存在)。
|
|
|
+
|
|
|
+- [ ] **Step 3: 实现 `vector_projection_writer.py`**
|
|
|
+
|
|
|
+```python
|
|
|
+#!/usr/bin/env python3
|
|
|
+# -*- coding: utf-8 -*-
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+import asyncio
|
|
|
+import logging
|
|
|
+from pathlib import Path
|
|
|
+from typing import Any, Dict, List
|
|
|
+
|
|
|
+logger = logging.getLogger(__name__)
|
|
|
+
|
|
|
+
|
|
|
+class VectorProjectionWriter:
|
|
|
+ def __init__(self, project_root: Path):
|
|
|
+ self.project_root = Path(project_root)
|
|
|
+
|
|
|
+ def apply(self, commit_payload: dict) -> dict:
|
|
|
+ if commit_payload["meta"]["status"] != "accepted":
|
|
|
+ return {"applied": False, "writer": "vector", "reason": "commit_rejected"}
|
|
|
+
|
|
|
+ chunks = self._collect_chunks(commit_payload)
|
|
|
+ if not chunks:
|
|
|
+ return {"applied": False, "writer": "vector", "reason": "no_chunks"}
|
|
|
+
|
|
|
+ try:
|
|
|
+ stored = self._store_chunks(chunks)
|
|
|
+ return {"applied": stored > 0, "writer": "vector", "stored": stored}
|
|
|
+ except Exception as exc:
|
|
|
+ logger.warning("vector_projection_failed: %s", exc)
|
|
|
+ return {"applied": False, "writer": "vector", "reason": f"error:{exc}"}
|
|
|
+
|
|
|
+ def _collect_chunks(self, commit_payload: dict) -> List[Dict[str, Any]]:
|
|
|
+ chunks: List[Dict[str, Any]] = []
|
|
|
+ chapter = int(commit_payload.get("meta", {}).get("chapter") or 0)
|
|
|
+
|
|
|
+ for event in commit_payload.get("accepted_events") or []:
|
|
|
+ if not isinstance(event, dict):
|
|
|
+ continue
|
|
|
+ text = self._event_to_text(event)
|
|
|
+ if text:
|
|
|
+ evt_chapter = int(event.get("chapter") or chapter)
|
|
|
+ chunks.append({
|
|
|
+ "chapter": evt_chapter,
|
|
|
+ "scene_index": 0,
|
|
|
+ "content": text,
|
|
|
+ "chunk_type": "event",
|
|
|
+ "parent_chunk_id": f"ch{evt_chapter:04d}_summary",
|
|
|
+ "source_file": f"commit:chapter_{evt_chapter:03d}",
|
|
|
+ })
|
|
|
+
|
|
|
+ for delta in commit_payload.get("entity_deltas") or []:
|
|
|
+ if not isinstance(delta, dict):
|
|
|
+ continue
|
|
|
+ text = self._delta_to_text(delta)
|
|
|
+ if text:
|
|
|
+ d_chapter = int(delta.get("chapter") or chapter)
|
|
|
+ chunks.append({
|
|
|
+ "chapter": d_chapter,
|
|
|
+ "scene_index": 0,
|
|
|
+ "content": text,
|
|
|
+ "chunk_type": "entity_delta",
|
|
|
+ "parent_chunk_id": f"ch{d_chapter:04d}_summary",
|
|
|
+ "source_file": f"commit:chapter_{d_chapter:03d}",
|
|
|
+ })
|
|
|
+
|
|
|
+ return chunks
|
|
|
+
|
|
|
+ def _event_to_text(self, event: dict) -> str:
|
|
|
+ chapter = int(event.get("chapter") or 0)
|
|
|
+ subject = str(event.get("subject") or "").strip()
|
|
|
+ event_type = str(event.get("event_type") or "").strip()
|
|
|
+ payload = event.get("payload") or {}
|
|
|
+
|
|
|
+ if event_type == "power_breakthrough":
|
|
|
+ new_val = str(payload.get("new") or payload.get("to") or "").strip()
|
|
|
+ return f"第{chapter}章:{subject}突破至{new_val}" if new_val else ""
|
|
|
+ elif event_type == "character_state_changed":
|
|
|
+ field = str(payload.get("field") or "").strip()
|
|
|
+ new_val = str(payload.get("new") or payload.get("to") or "").strip()
|
|
|
+ return f"第{chapter}章:{subject}的{field}变为{new_val}" if field and new_val else ""
|
|
|
+ elif event_type == "relationship_changed":
|
|
|
+ to_entity = str(payload.get("to_entity") or payload.get("to") or "").strip()
|
|
|
+ rel_type = str(
|
|
|
+ payload.get("relationship_type") or payload.get("type") or ""
|
|
|
+ ).strip()
|
|
|
+ return f"第{chapter}章:{subject}与{to_entity}关系变为{rel_type}" if to_entity else ""
|
|
|
+ elif event_type in ("world_rule_revealed", "world_rule_broken"):
|
|
|
+ desc = str(payload.get("description") or payload.get("rule") or "").strip()
|
|
|
+ action = "揭示" if "revealed" in event_type else "打破"
|
|
|
+ return f"第{chapter}章:{action}世界规则——{desc}" if desc else ""
|
|
|
+ elif event_type == "artifact_obtained":
|
|
|
+ name = str(payload.get("name") or subject or "").strip()
|
|
|
+ owner = str(payload.get("owner") or payload.get("holder") or "").strip()
|
|
|
+ return f"第{chapter}章:{owner}获得{name}" if owner else f"第{chapter}章:获得{name}"
|
|
|
+ return ""
|
|
|
+
|
|
|
+ def _delta_to_text(self, delta: dict) -> str:
|
|
|
+ chapter = int(delta.get("chapter") or 0)
|
|
|
+ from_e = str(delta.get("from_entity") or "").strip()
|
|
|
+ to_e = str(delta.get("to_entity") or "").strip()
|
|
|
+ rel = str(delta.get("relationship_type") or "").strip()
|
|
|
+
|
|
|
+ if from_e and to_e and rel:
|
|
|
+ return f"第{chapter}章:{from_e}与{to_e}关系变为{rel}"
|
|
|
+
|
|
|
+ entity_id = str(delta.get("entity_id") or "").strip()
|
|
|
+ canonical = str(delta.get("canonical_name") or entity_id).strip()
|
|
|
+ if entity_id:
|
|
|
+ return f"第{chapter}章:实体变更——{canonical}"
|
|
|
+ return ""
|
|
|
+
|
|
|
+ def _store_chunks(self, chunks: List[Dict[str, Any]]) -> int:
|
|
|
+ from .config import DataModulesConfig
|
|
|
+ from .rag_adapter import RAGAdapter
|
|
|
+
|
|
|
+ config = DataModulesConfig.from_project_root(self.project_root)
|
|
|
+ adapter = RAGAdapter(config)
|
|
|
+ try:
|
|
|
+ stored = asyncio.run(adapter.store_chunks(chunks))
|
|
|
+ return stored
|
|
|
+ except Exception as exc:
|
|
|
+ logger.warning("vector_store_failed: %s", exc)
|
|
|
+ return 0
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 在 `chapter_commit_service.py` 注册 vector writer**
|
|
|
+
|
|
|
+在 `apply_projections` 方法中(第 104-109 行),加入 vector writer:
|
|
|
+
|
|
|
+```python
|
|
|
+from .vector_projection_writer import VectorProjectionWriter
|
|
|
+
|
|
|
+writers = {
|
|
|
+ "state": StateProjectionWriter(self.project_root),
|
|
|
+ "index": IndexProjectionWriter(self.project_root),
|
|
|
+ "summary": SummaryProjectionWriter(self.project_root),
|
|
|
+ "memory": MemoryProjectionWriter(self.project_root),
|
|
|
+ "vector": VectorProjectionWriter(self.project_root),
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+同时在 `build_commit` 的 `projection_status` 中加 `"vector": "pending"`:
|
|
|
+
|
|
|
+```python
|
|
|
+"projection_status": {
|
|
|
+ "state": "pending",
|
|
|
+ "index": "pending",
|
|
|
+ "summary": "pending",
|
|
|
+ "memory": "pending",
|
|
|
+ "vector": "pending",
|
|
|
+},
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: 运行测试**
|
|
|
+
|
|
|
+Run: `cd webnovel-writer && python -m pytest scripts/data_modules/tests/test_vector_projection_writer.py -v`
|
|
|
+
|
|
|
+预期:全部 PASS。
|
|
|
+
|
|
|
+- [ ] **Step 6: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add webnovel-writer/scripts/data_modules/vector_projection_writer.py webnovel-writer/scripts/data_modules/chapter_commit_service.py webnovel-writer/scripts/data_modules/tests/test_vector_projection_writer.py
|
|
|
+git commit -m "feat: add vector_projection_writer for event/entity embedding"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 10: 时序查询接口
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `webnovel-writer/scripts/data_modules/knowledge_query.py`
|
|
|
+- Create: `webnovel-writer/scripts/data_modules/tests/test_knowledge_query.py`
|
|
|
+- Modify: `webnovel-writer/scripts/data_modules/webnovel.py` (register CLI subcommand)
|
|
|
+
|
|
|
+- [ ] **Step 1: 写测试**
|
|
|
+
|
|
|
+```python
|
|
|
+# webnovel-writer/scripts/data_modules/tests/test_knowledge_query.py
|
|
|
+#!/usr/bin/env python3
|
|
|
+# -*- coding: utf-8 -*-
|
|
|
+"""KnowledgeQuery 时序查询测试。"""
|
|
|
+import json
|
|
|
+import sqlite3
|
|
|
+from pathlib import Path
|
|
|
+
|
|
|
+import pytest
|
|
|
+
|
|
|
+from data_modules.knowledge_query import KnowledgeQuery
|
|
|
+
|
|
|
+
|
|
|
+@pytest.fixture
|
|
|
+def setup_db(tmp_path):
|
|
|
+ """创建带 state_changes 和 relationship_events 表的测试 DB。"""
|
|
|
+ db_path = tmp_path / ".webnovel" / "index.db"
|
|
|
+ db_path.parent.mkdir(parents=True)
|
|
|
+
|
|
|
+ conn = sqlite3.connect(str(db_path))
|
|
|
+ conn.execute("""
|
|
|
+ CREATE TABLE IF NOT EXISTS entities (
|
|
|
+ id TEXT PRIMARY KEY,
|
|
|
+ canonical_name TEXT,
|
|
|
+ type TEXT DEFAULT '角色',
|
|
|
+ current_json TEXT DEFAULT '{}',
|
|
|
+ created_at TEXT,
|
|
|
+ updated_at TEXT
|
|
|
+ )
|
|
|
+ """)
|
|
|
+ conn.execute("""
|
|
|
+ CREATE TABLE IF NOT EXISTS state_changes (
|
|
|
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
+ entity_id TEXT,
|
|
|
+ field TEXT,
|
|
|
+ old_value TEXT,
|
|
|
+ new_value TEXT,
|
|
|
+ chapter INTEGER,
|
|
|
+ created_at TEXT
|
|
|
+ )
|
|
|
+ """)
|
|
|
+ conn.execute("""
|
|
|
+ CREATE TABLE IF NOT EXISTS relationship_events (
|
|
|
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
+ from_entity TEXT,
|
|
|
+ to_entity TEXT,
|
|
|
+ relationship_type TEXT,
|
|
|
+ description TEXT,
|
|
|
+ chapter INTEGER,
|
|
|
+ created_at TEXT
|
|
|
+ )
|
|
|
+ """)
|
|
|
+
|
|
|
+ # 插入测试数据
|
|
|
+ conn.execute(
|
|
|
+ "INSERT INTO entities (id, canonical_name, current_json) VALUES (?, ?, ?)",
|
|
|
+ ("hanli", "韩立", json.dumps({"realm": "筑基中期", "location": "乱星海"})),
|
|
|
+ )
|
|
|
+ conn.execute(
|
|
|
+ "INSERT INTO state_changes (entity_id, field, old_value, new_value, chapter) VALUES (?, ?, ?, ?, ?)",
|
|
|
+ ("hanli", "realm", "练气圆满", "筑基初期", 30),
|
|
|
+ )
|
|
|
+ conn.execute(
|
|
|
+ "INSERT INTO state_changes (entity_id, field, old_value, new_value, chapter) VALUES (?, ?, ?, ?, ?)",
|
|
|
+ ("hanli", "realm", "筑基初期", "筑基中期", 50),
|
|
|
+ )
|
|
|
+ conn.execute(
|
|
|
+ "INSERT INTO relationship_events (from_entity, to_entity, relationship_type, chapter) VALUES (?, ?, ?, ?)",
|
|
|
+ ("hanli", "陈巧倩", "同门", 20),
|
|
|
+ )
|
|
|
+ conn.execute(
|
|
|
+ "INSERT INTO relationship_events (from_entity, to_entity, relationship_type, chapter) VALUES (?, ?, ?, ?)",
|
|
|
+ ("hanli", "陈巧倩", "合作", 45),
|
|
|
+ )
|
|
|
+ conn.commit()
|
|
|
+ conn.close()
|
|
|
+
|
|
|
+ return tmp_path
|
|
|
+
|
|
|
+
|
|
|
+def test_entity_state_at_chapter_before_first_change(setup_db):
|
|
|
+ kq = KnowledgeQuery(setup_db)
|
|
|
+ result = kq.entity_state_at_chapter("hanli", 10)
|
|
|
+ # 第10章在第一次 state_change 之前,应返回空变更
|
|
|
+ assert result["entity_id"] == "hanli"
|
|
|
+ assert result["state_at_chapter"] == {}
|
|
|
+
|
|
|
+
|
|
|
+def test_entity_state_at_chapter_after_first_breakthrough(setup_db):
|
|
|
+ kq = KnowledgeQuery(setup_db)
|
|
|
+ result = kq.entity_state_at_chapter("hanli", 35)
|
|
|
+ assert result["state_at_chapter"]["realm"] == "筑基初期"
|
|
|
+
|
|
|
+
|
|
|
+def test_entity_state_at_chapter_after_second_breakthrough(setup_db):
|
|
|
+ kq = KnowledgeQuery(setup_db)
|
|
|
+ result = kq.entity_state_at_chapter("hanli", 60)
|
|
|
+ assert result["state_at_chapter"]["realm"] == "筑基中期"
|
|
|
+
|
|
|
+
|
|
|
+def test_relationships_at_chapter_before_any(setup_db):
|
|
|
+ kq = KnowledgeQuery(setup_db)
|
|
|
+ result = kq.entity_relationships_at_chapter("hanli", 10)
|
|
|
+ assert result["relationships"] == []
|
|
|
+
|
|
|
+
|
|
|
+def test_relationships_at_chapter_after_first(setup_db):
|
|
|
+ kq = KnowledgeQuery(setup_db)
|
|
|
+ result = kq.entity_relationships_at_chapter("hanli", 25)
|
|
|
+ assert len(result["relationships"]) == 1
|
|
|
+ assert result["relationships"][0]["to_entity"] == "陈巧倩"
|
|
|
+ assert result["relationships"][0]["relationship_type"] == "同门"
|
|
|
+
|
|
|
+
|
|
|
+def test_relationships_at_chapter_after_update(setup_db):
|
|
|
+ kq = KnowledgeQuery(setup_db)
|
|
|
+ result = kq.entity_relationships_at_chapter("hanli", 50)
|
|
|
+ rels = result["relationships"]
|
|
|
+ assert len(rels) == 1
|
|
|
+ assert rels[0]["relationship_type"] == "合作"
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 运行测试确认失败**
|
|
|
+
|
|
|
+Run: `cd webnovel-writer && python -m pytest scripts/data_modules/tests/test_knowledge_query.py -v`
|
|
|
+
|
|
|
+预期:FAIL。
|
|
|
+
|
|
|
+- [ ] **Step 3: 实现 `knowledge_query.py`**
|
|
|
+
|
|
|
+```python
|
|
|
+#!/usr/bin/env python3
|
|
|
+# -*- coding: utf-8 -*-
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+import sqlite3
|
|
|
+from pathlib import Path
|
|
|
+from typing import Any, Dict, List
|
|
|
+
|
|
|
+
|
|
|
+class KnowledgeQuery:
|
|
|
+ def __init__(self, project_root: Path):
|
|
|
+ self.project_root = Path(project_root)
|
|
|
+ self._db_path = self.project_root / ".webnovel" / "index.db"
|
|
|
+
|
|
|
+ def entity_state_at_chapter(self, entity_id: str, chapter: int) -> Dict[str, Any]:
|
|
|
+ """查询实体在指定章节时的状态(从 state_changes 反推)。"""
|
|
|
+ conn = sqlite3.connect(str(self._db_path))
|
|
|
+ conn.row_factory = sqlite3.Row
|
|
|
+ try:
|
|
|
+ rows = conn.execute(
|
|
|
+ """
|
|
|
+ SELECT field, new_value
|
|
|
+ FROM state_changes
|
|
|
+ WHERE entity_id = ? AND chapter <= ?
|
|
|
+ ORDER BY chapter ASC, id ASC
|
|
|
+ """,
|
|
|
+ (entity_id, chapter),
|
|
|
+ ).fetchall()
|
|
|
+
|
|
|
+ state: Dict[str, str] = {}
|
|
|
+ for row in rows:
|
|
|
+ field = str(row["field"] or "").strip()
|
|
|
+ if field:
|
|
|
+ state[field] = str(row["new_value"] or "").strip()
|
|
|
+
|
|
|
+ return {
|
|
|
+ "entity_id": entity_id,
|
|
|
+ "at_chapter": chapter,
|
|
|
+ "state_at_chapter": state,
|
|
|
+ }
|
|
|
+ finally:
|
|
|
+ conn.close()
|
|
|
+
|
|
|
+ def entity_relationships_at_chapter(self, entity_id: str, chapter: int) -> Dict[str, Any]:
|
|
|
+ """查询实体在指定章节时的所有关系(从 relationship_events 计算快照)。"""
|
|
|
+ conn = sqlite3.connect(str(self._db_path))
|
|
|
+ conn.row_factory = sqlite3.Row
|
|
|
+ try:
|
|
|
+ rows = conn.execute(
|
|
|
+ """
|
|
|
+ SELECT from_entity, to_entity, relationship_type, description, chapter
|
|
|
+ FROM relationship_events
|
|
|
+ WHERE (from_entity = ? OR to_entity = ?) AND chapter <= ?
|
|
|
+ ORDER BY chapter ASC, id ASC
|
|
|
+ """,
|
|
|
+ (entity_id, entity_id, chapter),
|
|
|
+ ).fetchall()
|
|
|
+
|
|
|
+ # 用最新的关系覆盖旧关系(按 pair 去重,保留最新)
|
|
|
+ latest: Dict[str, Dict[str, Any]] = {}
|
|
|
+ for row in rows:
|
|
|
+ from_e = str(row["from_entity"] or "").strip()
|
|
|
+ to_e = str(row["to_entity"] or "").strip()
|
|
|
+ pair_key = tuple(sorted([from_e, to_e]))
|
|
|
+ latest[str(pair_key)] = {
|
|
|
+ "from_entity": from_e,
|
|
|
+ "to_entity": to_e,
|
|
|
+ "relationship_type": str(row["relationship_type"] or "").strip(),
|
|
|
+ "description": str(row["description"] or "").strip(),
|
|
|
+ "since_chapter": int(row["chapter"] or 0),
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ "entity_id": entity_id,
|
|
|
+ "at_chapter": chapter,
|
|
|
+ "relationships": list(latest.values()),
|
|
|
+ }
|
|
|
+ finally:
|
|
|
+ conn.close()
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 运行测试**
|
|
|
+
|
|
|
+Run: `cd webnovel-writer && python -m pytest scripts/data_modules/tests/test_knowledge_query.py -v`
|
|
|
+
|
|
|
+预期:全部 PASS。
|
|
|
+
|
|
|
+- [ ] **Step 5: 注册 `knowledge` CLI 子命令**
|
|
|
+
|
|
|
+在 `webnovel-writer/scripts/data_modules/webnovel.py` 中注册 `knowledge` 子命令。找到 subparser 注册区域(grep `add_parser`),新增:
|
|
|
+
|
|
|
+```python
|
|
|
+# knowledge 子命令
|
|
|
+knowledge_parser = subparsers.add_parser("knowledge", help="时序知识查询")
|
|
|
+knowledge_sub = knowledge_parser.add_subparsers(dest="knowledge_action")
|
|
|
+
|
|
|
+qs_parser = knowledge_sub.add_parser("query-entity-state", help="查询实体在指定章节的状态")
|
|
|
+qs_parser.add_argument("--entity", required=True, help="实体 ID")
|
|
|
+qs_parser.add_argument("--at-chapter", type=int, required=True, help="目标章节号")
|
|
|
+
|
|
|
+qr_parser = knowledge_sub.add_parser("query-relationships", help="查询实体在指定章节的关系")
|
|
|
+qr_parser.add_argument("--entity", required=True, help="实体 ID")
|
|
|
+qr_parser.add_argument("--at-chapter", type=int, required=True, help="目标章节号")
|
|
|
+```
|
|
|
+
|
|
|
+在命令分发区域新增 handler:
|
|
|
+
|
|
|
+```python
|
|
|
+if args.command == "knowledge":
|
|
|
+ from .knowledge_query import KnowledgeQuery
|
|
|
+ kq = KnowledgeQuery(project_root)
|
|
|
+ if args.knowledge_action == "query-entity-state":
|
|
|
+ result = kq.entity_state_at_chapter(args.entity, args.at_chapter)
|
|
|
+ print_success(result, message="entity_state_at_chapter")
|
|
|
+ elif args.knowledge_action == "query-relationships":
|
|
|
+ result = kq.entity_relationships_at_chapter(args.entity, args.at_chapter)
|
|
|
+ print_success(result, message="entity_relationships_at_chapter")
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 6: 同步 `REGISTERED_CLI_SUBCOMMANDS` 和 `context-agent.md`**
|
|
|
+
|
|
|
+在 `test_prompt_integrity.py` 的 `REGISTERED_CLI_SUBCOMMANDS`(第 32-38 行)中新增 `"knowledge"`:
|
|
|
+
|
|
|
+```python
|
|
|
+REGISTERED_CLI_SUBCOMMANDS = {
|
|
|
+ "where", "preflight", "use",
|
|
|
+ "index", "state", "rag", "style", "entity", "context", "memory",
|
|
|
+ "migrate", "status", "update-state", "backup", "archive",
|
|
|
+ "init", "extract-context", "memory-contract", "review-pipeline",
|
|
|
+ "story-system", "chapter-commit", "story-events",
|
|
|
+ "knowledge",
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+在 `context-agent.md` Section 2 的"补充命令"段落中新增:
|
|
|
+
|
|
|
+```bash
|
|
|
+# 时序知识查询(查询某实体在指定章节时的状态和关系)
|
|
|
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" knowledge query-entity-state --entity "{entity_id}" --at-chapter {N}
|
|
|
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" knowledge query-relationships --entity "{entity_id}" --at-chapter {N}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 7: 运行全量测试**
|
|
|
+
|
|
|
+Run: `cd webnovel-writer && python -m pytest scripts/data_modules/tests/ scripts/tests/ -v --timeout=60`
|
|
|
+
|
|
|
+预期:全部 PASS。
|
|
|
+
|
|
|
+- [ ] **Step 8: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add webnovel-writer/scripts/data_modules/knowledge_query.py webnovel-writer/scripts/data_modules/tests/test_knowledge_query.py webnovel-writer/scripts/data_modules/webnovel.py webnovel-writer/agents/context-agent.md webnovel-writer/scripts/data_modules/tests/test_prompt_integrity.py
|
|
|
+git commit -m "feat: add knowledge_query temporal API with CLI and prompt sync"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 11: 最终集成验证
|
|
|
+
|
|
|
+**Files:** (read-only verification)
|
|
|
+
|
|
|
+- [ ] **Step 1: 运行全量测试套件**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd webnovel-writer && python -m pytest scripts/data_modules/tests/ scripts/tests/ -v --timeout=120
|
|
|
+```
|
|
|
+
|
|
|
+预期:全部 PASS,0 FAIL。
|
|
|
+
|
|
|
+- [ ] **Step 2: grep 确认无残留散写**
|
|
|
+
|
|
|
+```bash
|
|
|
+grep -rn "state set-chapter-status" webnovel-writer/skills/ webnovel-writer/agents/ || echo "CLEAN"
|
|
|
+grep -rn "index process-chapter" webnovel-writer/skills/ webnovel-writer/agents/ || echo "CLEAN"
|
|
|
+```
|
|
|
+
|
|
|
+预期:两条都输出 `CLEAN`。
|
|
|
+
|
|
|
+- [ ] **Step 3: 确认 context_manager.py 行数**
|
|
|
+
|
|
|
+```bash
|
|
|
+wc -l webnovel-writer/scripts/data_modules/context_manager.py
|
|
|
+```
|
|
|
+
|
|
|
+预期:< 400 行。
|
|
|
+
|
|
|
+- [ ] **Step 4: 确认 snapshot_manager.py 已删除**
|
|
|
+
|
|
|
+```bash
|
|
|
+test -f webnovel-writer/scripts/data_modules/snapshot_manager.py && echo "STILL EXISTS" || echo "DELETED"
|
|
|
+```
|
|
|
+
|
|
|
+预期:`DELETED`。
|
|
|
+
|
|
|
+- [ ] **Step 5: 确认裁决表覆盖 7 个题材**
|
|
|
+
|
|
|
+```bash
|
|
|
+python3 -c "
|
|
|
+import csv
|
|
|
+from pathlib import Path
|
|
|
+path = Path('webnovel-writer/references/csv/裁决规则.csv')
|
|
|
+with open(path, 'r', encoding='utf-8-sig') as f:
|
|
|
+ rows = list(csv.DictReader(f))
|
|
|
+genres = [r['题材'] for r in rows]
|
|
|
+print(f'题材数: {len(genres)}')
|
|
|
+print(f'题材: {genres}')
|
|
|
+assert len(genres) == 7
|
|
|
+"
|
|
|
+```
|
|
|
+
|
|
|
+预期:输出 7 个题材。
|
|
|
+
|
|
|
+- [ ] **Step 6: 确认 CSV_CONFIG 对齐**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd webnovel-writer && python -m pytest scripts/data_modules/tests/test_csv_config.py -v
|
|
|
+```
|
|
|
+
|
|
|
+预期:全部 PASS。
|
|
|
+
|
|
|
+- [ ] **Step 7: Commit final**
|
|
|
+
|
|
|
+如果有任何 fix,commit:
|
|
|
+
|
|
|
+```bash
|
|
|
+git add -A
|
|
|
+git commit -m "chore: final integration fixes for story system convergence"
|
|
|
+```
|