|
@@ -0,0 +1,1324 @@
|
|
|
|
|
+# Story System Phase 1 Contract Seed Implementation Plan
|
|
|
|
|
+
|
|
|
|
|
+> **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:** 在不破坏现有 `reference_search.py`、`context_manager.py` 与写作主流程的前提下,落地 Story System Phase 1 合同种子层:`题材与调性推理.csv`、最小 `MASTER_SETTING` / `CHAPTER_BRIEF` / `anti_patterns` 持久化,以及 `context_manager` 的合同读取入口。
|
|
|
|
|
+
|
|
|
|
|
+**Architecture:** 采用“数据层 -> 合同聚合器 -> `.story-system/` 持久化 -> runtime 注入”的四段式。Phase 1 只建立最小合同真源,不引入 `VOLUME_BRIEF`、`REVIEW_CONTRACT`、`CHAPTER_COMMIT`,并继续允许 `genre-profiles.md` 作为回退源存在。
|
|
|
|
|
+
|
|
|
|
|
+**Tech Stack:** Python 3.13, argparse, pytest, CSV(UTF-8 with BOM), Markdown + JSON 合同文件, unified CLI `webnovel.py`
|
|
|
|
|
+
|
|
|
|
|
+**Spec:** `docs/superpowers/specs/2026-04-12-story-system-evolution-spec.md`
|
|
|
|
|
+
|
|
|
|
|
+**Companion Spec:** `docs/superpowers/specs/2026-04-12-story-system-pro-max-retrofit-spec.md`
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Scope Split
|
|
|
|
|
+
|
|
|
|
|
+这份 plan **只覆盖 evolution spec 的 Phase 1**,原因如下:
|
|
|
|
|
+
|
|
|
|
|
+- `Phase 2` 以后会引入 `VOLUME_BRIEF`、`REVIEW_CONTRACT`、大纲履约 diff、review blocking rules。
|
|
|
|
|
+- `Phase 3` 会新增 `CHAPTER_COMMIT` 与四类 projection writers。
|
|
|
|
|
+- `Phase 4` 会新增 canonical event log。
|
|
|
|
|
+- `Phase 5` 才能安全降级旧链路。
|
|
|
|
|
+
|
|
|
|
|
+把这些内容塞进一份实现计划会导致:
|
|
|
|
|
+
|
|
|
|
|
+- 文件责任边界失真
|
|
|
|
|
+- TDD 粒度失控
|
|
|
|
|
+- 文档与代码修改无法形成可验证的阶段产物
|
|
|
|
|
+
|
|
|
|
|
+因此本计划的退出标准固定为:
|
|
|
|
|
+
|
|
|
|
|
+1. `PROJECT_ROOT/.story-system/` 能生成最小 `MASTER_SETTING`、`chapter_XXX`、`anti_patterns`
|
|
|
|
|
+2. `context_manager` 能读取并注入 `story_contract` section
|
|
|
|
|
+3. 旧 `genre_profile` 仍可作为回退层保留
|
|
|
|
|
+4. 文档已明确 Phase 1 的路径语义、schema 与使用方式
|
|
|
|
|
+
|
|
|
|
|
+后续应另写三份计划:
|
|
|
|
|
+
|
|
|
|
|
+- `Phase 2 Contract-First Runtime`
|
|
|
|
|
+- `Phase 3 Chapter Commit Chain`
|
|
|
|
|
+- `Phase 4 Event Log + Override Ledger`
|
|
|
|
|
+
|
|
|
|
|
+文档边界也在本阶段定死:
|
|
|
|
|
+
|
|
|
|
|
+- `README.md` 只新增 `Story System` 一级段落与基础目录说明
|
|
|
|
|
+- 后续 Phase 2/3/4 只能在既有段落下追加,不重写整段结构
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## File Structure
|
|
|
|
|
+
|
|
|
|
|
+### 要创建的文件
|
|
|
|
|
+
|
|
|
|
|
+- `webnovel-writer/references/csv/题材与调性推理.csv`
|
|
|
|
|
+- `webnovel-writer/scripts/data_modules/story_contracts.py`
|
|
|
|
|
+- `webnovel-writer/scripts/data_modules/story_system_engine.py`
|
|
|
|
|
+- `webnovel-writer/scripts/data_modules/tests/test_story_contracts.py`
|
|
|
|
|
+- `webnovel-writer/scripts/data_modules/tests/test_story_system_engine.py`
|
|
|
|
|
+- `webnovel-writer/scripts/data_modules/tests/test_story_system_cli.py`
|
|
|
|
|
+- `webnovel-writer/scripts/story_system.py`
|
|
|
|
|
+- `docs/architecture/story-system-phase1.md`
|
|
|
|
|
+
|
|
|
|
|
+### 要修改的文件
|
|
|
|
|
+
|
|
|
|
|
+- `webnovel-writer/references/csv/README.md`
|
|
|
|
|
+- `webnovel-writer/references/csv/桥段套路.csv`
|
|
|
|
|
+- `webnovel-writer/scripts/data_modules/config.py`
|
|
|
|
|
+- `webnovel-writer/scripts/data_modules/context_manager.py`
|
|
|
|
|
+- `webnovel-writer/scripts/data_modules/tests/test_context_manager.py`
|
|
|
|
|
+- `webnovel-writer/scripts/extract_chapter_context.py`
|
|
|
|
|
+- `webnovel-writer/scripts/data_modules/tests/test_extract_chapter_context.py`
|
|
|
|
|
+- `webnovel-writer/scripts/data_modules/webnovel.py`
|
|
|
|
|
+- `webnovel-writer/scripts/data_modules/tests/test_webnovel_unified_cli.py`
|
|
|
|
|
+- `README.md`
|
|
|
|
|
+- `docs/architecture/overview.md`
|
|
|
|
|
+- `docs/guides/commands.md`
|
|
|
|
|
+- `docs/superpowers/README.md`
|
|
|
|
|
+
|
|
|
|
|
+### 文件职责
|
|
|
|
|
+
|
|
|
|
|
+- `story_contracts.py`:合同路径、merge 规则、JSON/Markdown 持久化、marker 安全更新
|
|
|
|
|
+- `story_system_engine.py`:题材路由、多表检索编排、anti-pattern 聚合、最小合同字典构造
|
|
|
|
|
+- `story_system.py`:CLI 入口,负责 `query -> build -> render -> persist`
|
|
|
|
|
+- `context_manager.py`:读取 `MASTER_SETTING` / `chapter_XXX` / `anti_patterns` 并注入 `story_contract` section
|
|
|
|
|
+- `extract_chapter_context.py`:把 `story_contract` 纳入可视化文本/JSON 提取结果
|
|
|
|
|
+- `docs/architecture/story-system-phase1.md`:Phase 1 合同 schema、目录结构、覆盖规则、迁移说明
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Task 1: 建立合同路径层与最小 merge 规则
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Create: `webnovel-writer/scripts/data_modules/story_contracts.py`
|
|
|
|
|
+- Create: `webnovel-writer/scripts/data_modules/tests/test_story_contracts.py`
|
|
|
|
|
+- Modify: `webnovel-writer/scripts/data_modules/config.py`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 先写 `story_contracts` 的失败测试**
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+# webnovel-writer/scripts/data_modules/tests/test_story_contracts.py
|
|
|
|
|
+#!/usr/bin/env python3
|
|
|
|
|
+# -*- coding: utf-8 -*-
|
|
|
|
|
+
|
|
|
|
|
+from data_modules.config import DataModulesConfig
|
|
|
|
|
+from data_modules.story_contracts import StoryContractPaths, merge_contract_layers, merge_anti_patterns
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_story_contract_paths_live_under_project_root(tmp_path):
|
|
|
|
|
+ cfg = DataModulesConfig.from_project_root(tmp_path)
|
|
|
|
|
+ paths = StoryContractPaths.from_project_root(cfg.project_root)
|
|
|
|
|
+
|
|
|
|
|
+ assert paths.root == tmp_path / ".story-system"
|
|
|
|
|
+ assert paths.master_json == tmp_path / ".story-system" / "MASTER_SETTING.json"
|
|
|
|
|
+ assert paths.anti_patterns_json == tmp_path / ".story-system" / "anti_patterns.json"
|
|
|
|
|
+ assert paths.chapter_json(1) == tmp_path / ".story-system" / "chapters" / "chapter_001.json"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_merge_contract_layers_respects_lock_categories():
|
|
|
|
|
+ master = {
|
|
|
|
|
+ "locked": {
|
|
|
|
|
+ "core_tone": "冷硬升级",
|
|
|
|
|
+ "golden_finger_limit": "每天只能触发一次",
|
|
|
|
|
+ },
|
|
|
|
|
+ "append_only": {
|
|
|
|
|
+ "anti_patterns": ["配角连续抢戏超过 300 字"],
|
|
|
|
|
+ },
|
|
|
|
|
+ "override_allowed": {
|
|
|
|
|
+ "scene_focus": "拍卖会打脸",
|
|
|
|
|
+ },
|
|
|
|
|
+ }
|
|
|
|
|
+ chapter = {
|
|
|
|
|
+ "locked": {
|
|
|
|
|
+ "core_tone": "轻喜日常",
|
|
|
|
|
+ },
|
|
|
|
|
+ "append_only": {
|
|
|
|
|
+ "anti_patterns": ["本章禁止解释性旁白"],
|
|
|
|
|
+ },
|
|
|
|
|
+ "override_allowed": {
|
|
|
|
|
+ "scene_focus": "退婚当场反杀",
|
|
|
|
|
+ },
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ merged = merge_contract_layers(master, chapter)
|
|
|
|
|
+
|
|
|
|
|
+ assert merged["locked"]["core_tone"] == "冷硬升级"
|
|
|
|
|
+ assert merged["locked"]["golden_finger_limit"] == "每天只能触发一次"
|
|
|
|
|
+ assert merged["append_only"]["anti_patterns"] == [
|
|
|
|
|
+ "配角连续抢戏超过 300 字",
|
|
|
|
|
+ "本章禁止解释性旁白",
|
|
|
|
|
+ ]
|
|
|
|
|
+ assert merged["override_allowed"]["scene_focus"] == "退婚当场反杀"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_merge_anti_patterns_deduplicates_by_text():
|
|
|
|
|
+ rows = merge_anti_patterns(
|
|
|
|
|
+ [{"text": "打脸节奏不能缺补刀", "source_table": "题材与调性推理", "source_id": "GR-001"}],
|
|
|
|
|
+ [{"text": "打脸节奏不能缺补刀", "source_table": "爽点与节奏", "source_id": "PA-002"}],
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ assert [item["text"] for item in rows] == ["打脸节奏不能缺补刀"]
|
|
|
|
|
+ assert rows[0]["source_table"] == "题材与调性推理"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: 运行测试,确认是正确的红灯**
|
|
|
|
|
+
|
|
|
|
|
+Run: `python -m pytest webnovel-writer/scripts/data_modules/tests/test_story_contracts.py -q --no-cov`
|
|
|
|
|
+
|
|
|
|
|
+Expected: `ModuleNotFoundError: No module named 'data_modules.story_contracts'`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: 在 `config.py` 增加 `.story-system` 路径属性**
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+# webnovel-writer/scripts/data_modules/config.py
|
|
|
|
|
+@property
|
|
|
|
|
+def story_system_dir(self) -> Path:
|
|
|
|
|
+ return self.project_root / ".story-system"
|
|
|
|
|
+
|
|
|
|
|
+@property
|
|
|
|
|
+def story_system_chapters_dir(self) -> Path:
|
|
|
|
|
+ return self.story_system_dir / "chapters"
|
|
|
|
|
+
|
|
|
|
|
+@property
|
|
|
|
|
+def story_system_master_json(self) -> Path:
|
|
|
|
|
+ return self.story_system_dir / "MASTER_SETTING.json"
|
|
|
|
|
+
|
|
|
|
|
+@property
|
|
|
|
|
+def story_system_anti_patterns_json(self) -> Path:
|
|
|
|
|
+ return self.story_system_dir / "anti_patterns.json"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: 实现 `StoryContractPaths`、merge 规则与 marker 更新工具**
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+# webnovel-writer/scripts/data_modules/story_contracts.py
|
|
|
|
|
+from __future__ import annotations
|
|
|
|
|
+
|
|
|
|
|
+import json
|
|
|
|
|
+from dataclasses import dataclass
|
|
|
|
|
+from pathlib import Path
|
|
|
|
|
+from typing import Any, Dict, Iterable, List
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+MARKER_BEGIN = "<!-- STORY-SYSTEM:BEGIN -->"
|
|
|
|
|
+MARKER_END = "<!-- STORY-SYSTEM:END -->"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@dataclass(frozen=True)
|
|
|
|
|
+class StoryContractPaths:
|
|
|
|
|
+ project_root: Path
|
|
|
|
|
+
|
|
|
|
|
+ @classmethod
|
|
|
|
|
+ def from_project_root(cls, project_root: str | Path) -> "StoryContractPaths":
|
|
|
|
|
+ return cls(Path(project_root).expanduser().resolve())
|
|
|
|
|
+
|
|
|
|
|
+ @property
|
|
|
|
|
+ def root(self) -> Path:
|
|
|
|
|
+ return self.project_root / ".story-system"
|
|
|
|
|
+
|
|
|
|
|
+ @property
|
|
|
|
|
+ def chapters_dir(self) -> Path:
|
|
|
|
|
+ return self.root / "chapters"
|
|
|
|
|
+
|
|
|
|
|
+ @property
|
|
|
|
|
+ def master_json(self) -> Path:
|
|
|
|
|
+ return self.root / "MASTER_SETTING.json"
|
|
|
|
|
+
|
|
|
|
|
+ @property
|
|
|
|
|
+ def anti_patterns_json(self) -> Path:
|
|
|
|
|
+ return self.root / "anti_patterns.json"
|
|
|
|
|
+
|
|
|
|
|
+ def chapter_json(self, chapter: int) -> Path:
|
|
|
|
|
+ return self.chapters_dir / f"chapter_{chapter:03d}.json"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def merge_contract_layers(master: Dict[str, Any], chapter: Dict[str, Any] | None) -> Dict[str, Any]:
|
|
|
|
|
+ chapter = chapter or {}
|
|
|
|
|
+ return {
|
|
|
|
|
+ "locked": dict(master.get("locked") or {}),
|
|
|
|
|
+ "append_only": _merge_append_only(master.get("append_only") or {}, chapter.get("append_only") or {}),
|
|
|
|
|
+ "override_allowed": {
|
|
|
|
|
+ **(master.get("override_allowed") or {}),
|
|
|
|
|
+ **(chapter.get("override_allowed") or {}),
|
|
|
|
|
+ },
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def merge_anti_patterns(*groups: Iterable[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
|
|
|
+ seen: set[str] = set()
|
|
|
|
|
+ merged: List[Dict[str, Any]] = []
|
|
|
|
|
+ for group in groups:
|
|
|
|
|
+ for row in group:
|
|
|
|
|
+ text = str(row.get("text") or "").strip()
|
|
|
|
|
+ if not text or text in seen:
|
|
|
|
|
+ continue
|
|
|
|
|
+ seen.add(text)
|
|
|
|
|
+ merged.append(dict(row))
|
|
|
|
|
+ return merged
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def read_json_if_exists(path: Path) -> Any | None:
|
|
|
|
|
+ if not path.is_file():
|
|
|
|
|
+ return None
|
|
|
|
|
+ try:
|
|
|
|
|
+ return json.loads(path.read_text(encoding="utf-8"))
|
|
|
|
|
+ except json.JSONDecodeError as exc:
|
|
|
|
|
+ raise ValueError(f"Bad JSON in {path}") from exc
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+同时补一个统一读取约定,供后续 Phase 2-4 复用:
|
|
|
|
|
+
|
|
|
|
|
+- `read_json_if_exists(path) -> dict | list | None`
|
|
|
|
|
+- 文件不存在时返回 `None`
|
|
|
|
|
+- JSON 格式错误时抛带路径的 `ValueError`
|
|
|
|
|
+- 本阶段先在 `story_contracts.py` 落这个 helper,后续 projection / runtime builder 不再各写一套吞错逻辑
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: 回跑测试,确认基础层转绿**
|
|
|
|
|
+
|
|
|
|
|
+Run: `python -m pytest webnovel-writer/scripts/data_modules/tests/test_story_contracts.py -q --no-cov`
|
|
|
|
|
+
|
|
|
|
|
+Expected: 通过
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 6: 提交**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add webnovel-writer/scripts/data_modules/config.py \
|
|
|
|
|
+ webnovel-writer/scripts/data_modules/story_contracts.py \
|
|
|
|
|
+ webnovel-writer/scripts/data_modules/tests/test_story_contracts.py
|
|
|
|
|
+git commit -m "feat: add story contract path helpers and merge rules"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Task 2: 落地题材路由表与合同聚合器
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Create: `webnovel-writer/references/csv/题材与调性推理.csv`
|
|
|
|
|
+- Create: `webnovel-writer/scripts/data_modules/story_system_engine.py`
|
|
|
|
|
+- Create: `webnovel-writer/scripts/data_modules/tests/test_story_system_engine.py`
|
|
|
|
|
+- Modify: `webnovel-writer/references/csv/README.md`
|
|
|
|
|
+- Modify: `webnovel-writer/references/csv/桥段套路.csv`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 先写 `story_system_engine` 的失败测试**
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+# webnovel-writer/scripts/data_modules/tests/test_story_system_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)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_story_system_routes_explicit_genre_and_collects_anti_patterns(tmp_path):
|
|
|
|
|
+ csv_dir = tmp_path / "csv"
|
|
|
|
|
+ csv_dir.mkdir()
|
|
|
|
|
+
|
|
|
|
|
+ _write_csv(
|
|
|
|
|
+ csv_dir / "题材与调性推理.csv",
|
|
|
|
|
+ [
|
|
|
|
|
+ "编号", "适用技能", "分类", "层级", "关键词", "意图与同义词", "适用题材",
|
|
|
|
|
+ "大模型指令", "核心摘要", "详细展开", "题材/流派", "题材别名", "核心调性",
|
|
|
|
|
+ "节奏策略", "强制禁忌/毒点", "推荐基础检索表", "推荐动态检索表", "默认查询词",
|
|
|
|
|
+ ],
|
|
|
|
|
+ [
|
|
|
|
|
+ {
|
|
|
|
|
+ "编号": "GR-001",
|
|
|
|
|
+ "适用技能": "write|plan",
|
|
|
|
|
+ "分类": "题材路由",
|
|
|
|
|
+ "层级": "知识补充",
|
|
|
|
|
+ "关键词": "玄幻退婚流|退婚打脸",
|
|
|
|
|
+ "意图与同义词": "退婚流|废材逆袭",
|
|
|
|
|
+ "适用题材": "玄幻",
|
|
|
|
|
+ "大模型指令": "先给压抑,再给爆发兑现。",
|
|
|
|
|
+ "核心摘要": "玄幻退婚流需要耻辱起手和强兑现。",
|
|
|
|
|
+ "详细展开": "",
|
|
|
|
|
+ "题材/流派": "玄幻退婚流",
|
|
|
|
|
+ "题材别名": "退婚流|废材逆袭",
|
|
|
|
|
+ "核心调性": "压抑蓄势后爆裂反击",
|
|
|
|
|
+ "节奏策略": "前压后爆,三章内必须首个反打",
|
|
|
|
|
+ "强制禁忌/毒点": "打脸节奏不能缺最后一拍补刀|配角不能压过主角兑现",
|
|
|
|
|
+ "推荐基础检索表": "命名规则|人设与关系|金手指与设定",
|
|
|
|
|
+ "推荐动态检索表": "桥段套路|爽点与节奏|场景写法",
|
|
|
|
|
+ "默认查询词": "退婚|打脸|废材逆袭",
|
|
|
|
|
+ }
|
|
|
|
|
+ ],
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ _write_csv(
|
|
|
|
|
+ csv_dir / "桥段套路.csv",
|
|
|
|
|
+ ["编号", "适用技能", "分类", "层级", "关键词", "适用题材", "核心摘要", "桥段名称", "忌讳写法"],
|
|
|
|
|
+ [
|
|
|
|
|
+ {
|
|
|
|
|
+ "编号": "TR-001",
|
|
|
|
|
+ "适用技能": "write",
|
|
|
|
|
+ "分类": "桥段",
|
|
|
|
|
+ "层级": "知识补充",
|
|
|
|
|
+ "关键词": "退婚|打脸",
|
|
|
|
|
+ "适用题材": "玄幻",
|
|
|
|
|
+ "核心摘要": "退婚现场要给足羞辱和反击空间",
|
|
|
|
|
+ "桥段名称": "退婚反击",
|
|
|
|
|
+ "忌讳写法": "主角还没反打就被配角替他出手",
|
|
|
|
|
+ }
|
|
|
|
|
+ ],
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ _write_csv(
|
|
|
|
|
+ csv_dir / "爽点与节奏.csv",
|
|
|
|
|
+ ["编号", "适用技能", "分类", "层级", "关键词", "适用题材", "核心摘要", "常见崩盘误区", "节奏类型"],
|
|
|
|
|
+ [
|
|
|
|
|
+ {
|
|
|
|
|
+ "编号": "PA-001",
|
|
|
|
|
+ "适用技能": "write",
|
|
|
|
|
+ "分类": "节奏",
|
|
|
|
|
+ "层级": "知识补充",
|
|
|
|
|
+ "关键词": "打脸|兑现",
|
|
|
|
|
+ "适用题材": "玄幻",
|
|
|
|
|
+ "核心摘要": "兑现必须补刀",
|
|
|
|
|
+ "常见崩盘误区": "打脸收尾太软,没有读者情绪补刀",
|
|
|
|
|
+ "节奏类型": "爆发期",
|
|
|
|
|
+ }
|
|
|
|
|
+ ],
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ engine = StorySystemEngine(csv_dir=csv_dir)
|
|
|
|
|
+ contract = engine.build(query="玄幻退婚流", genre=None, chapter=None)
|
|
|
|
|
+
|
|
|
|
|
+ assert contract["master_setting"]["route"]["primary_genre"] == "玄幻退婚流"
|
|
|
|
|
+ assert contract["master_setting"]["master_constraints"]["core_tone"] == "压抑蓄势后爆裂反击"
|
|
|
|
|
+ assert "命名规则" in contract["master_setting"]["route"]["recommended_base_tables"]
|
|
|
|
|
+ assert {
|
|
|
|
|
+ item["text"] for item in contract["anti_patterns"]
|
|
|
|
|
+ } >= {
|
|
|
|
|
+ "打脸节奏不能缺最后一拍补刀",
|
|
|
|
|
+ "主角还没反打就被配角替他出手",
|
|
|
|
|
+ "打脸收尾太软,没有读者情绪补刀",
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_story_system_falls_back_to_explicit_genre(tmp_path):
|
|
|
|
|
+ csv_dir = tmp_path / "csv"
|
|
|
|
|
+ csv_dir.mkdir()
|
|
|
|
|
+
|
|
|
|
|
+ _write_csv(
|
|
|
|
|
+ csv_dir / "题材与调性推理.csv",
|
|
|
|
|
+ [
|
|
|
|
|
+ "编号", "适用技能", "分类", "层级", "关键词", "意图与同义词", "适用题材",
|
|
|
|
|
+ "大模型指令", "核心摘要", "详细展开", "题材/流派", "题材别名", "核心调性",
|
|
|
|
|
+ "节奏策略", "强制禁忌/毒点", "推荐基础检索表", "推荐动态检索表", "默认查询词",
|
|
|
|
|
+ ],
|
|
|
|
|
+ [],
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ engine = StorySystemEngine(csv_dir=csv_dir)
|
|
|
|
|
+ contract = engine.build(query="压抑一点,后面爆", genre="现言", chapter=None)
|
|
|
|
|
+
|
|
|
|
|
+ assert contract["master_setting"]["route"]["primary_genre"] == "现言"
|
|
|
|
|
+ assert contract["master_setting"]["route"]["route_source"] == "explicit_genre_fallback"
|
|
|
|
|
+ assert contract["master_setting"]["route"]["recommended_dynamic_tables"] == ["桥段套路", "爽点与节奏", "场景写法"]
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: 跑红灯,确认缺的是聚合器而不是测试本身**
|
|
|
|
|
+
|
|
|
|
|
+Run: `python -m pytest webnovel-writer/scripts/data_modules/tests/test_story_system_engine.py -q --no-cov`
|
|
|
|
|
+
|
|
|
|
|
+Expected: `ModuleNotFoundError: No module named 'data_modules.story_system_engine'`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: 实现 `StorySystemEngine` 与 anti-pattern 归一化映射**
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+# webnovel-writer/scripts/data_modules/story_system_engine.py
|
|
|
|
|
+from __future__ import annotations
|
|
|
|
|
+
|
|
|
|
|
+import csv
|
|
|
|
|
+from pathlib import Path
|
|
|
|
|
+from typing import Any, Dict, List, Optional
|
|
|
|
|
+
|
|
|
|
|
+from reference_search import search as search_reference
|
|
|
|
|
+
|
|
|
|
|
+from .story_contracts import merge_anti_patterns
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+ANTI_PATTERN_SOURCE_FIELDS = {
|
|
|
|
|
+ "场景写法": ["反面写法"],
|
|
|
|
|
+ "写作技法": ["常见误区"],
|
|
|
|
|
+ "爽点与节奏": ["常见崩盘误区"],
|
|
|
|
|
+ "人设与关系": ["忌讳写法"],
|
|
|
|
|
+ "桥段套路": ["忌讳写法"],
|
|
|
|
|
+ "题材与调性推理": ["强制禁忌/毒点"],
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class StorySystemEngine:
|
|
|
|
|
+ def __init__(self, csv_dir: str | Path):
|
|
|
|
|
+ self.csv_dir = Path(csv_dir)
|
|
|
|
|
+
|
|
|
|
|
+ def build(self, query: str, genre: Optional[str], chapter: Optional[int]) -> Dict[str, Any]:
|
|
|
|
|
+ route = self._route(query=query, genre=genre)
|
|
|
|
|
+ base_context = self._collect_tables(query, route["recommended_base_tables"], genre=route["genre_filter"], top_k=1)
|
|
|
|
|
+ dynamic_context = self._collect_tables(query, route["recommended_dynamic_tables"], genre=route["genre_filter"], top_k=2)
|
|
|
|
|
+ source_trace = route["source_trace"] + self._build_source_trace(base_context, dynamic_context)
|
|
|
|
|
+ anti_patterns = merge_anti_patterns(
|
|
|
|
|
+ route["route_anti_patterns"],
|
|
|
|
|
+ self._extract_anti_patterns(base_context),
|
|
|
|
|
+ self._extract_anti_patterns(dynamic_context),
|
|
|
|
|
+ )
|
|
|
|
|
+ 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": base_context,
|
|
|
|
|
+ "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": dynamic_context,
|
|
|
|
|
+ "source_trace": source_trace,
|
|
|
|
|
+ }
|
|
|
|
|
+ if chapter is not None
|
|
|
|
|
+ else None
|
|
|
|
|
+ ),
|
|
|
|
|
+ "anti_patterns": anti_patterns,
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ def _route(self, query: str, genre: Optional[str]) -> Dict[str, Any]:
|
|
|
|
|
+ route_rows = self._load_csv_rows("题材与调性推理")
|
|
|
|
|
+ query_text = self._normalize_text(" ".join([query or "", genre or ""]))
|
|
|
|
|
+
|
|
|
|
|
+ # 命中顺序固定:关键词/同义词命中 -> 显式 genre 回退 -> 默认首行回退
|
|
|
|
|
+ matched = None
|
|
|
|
|
+ for row in route_rows:
|
|
|
|
|
+ aliases = self._split_multi_value(row.get("关键词")) + self._split_multi_value(row.get("意图与同义词")) + self._split_multi_value(row.get("题材别名"))
|
|
|
|
|
+ if any(alias and alias in query_text for alias in aliases):
|
|
|
|
|
+ matched = row
|
|
|
|
|
+ route_source = "keyword_or_alias_match"
|
|
|
|
|
+ break
|
|
|
|
|
+ if matched is None and genre:
|
|
|
|
|
+ matched = self._fallback_row_for_genre(route_rows, genre)
|
|
|
|
|
+ route_source = "explicit_genre_fallback"
|
|
|
|
|
+ if matched is None and route_rows:
|
|
|
|
|
+ matched = route_rows[0]
|
|
|
|
|
+ route_source = "default_seed_fallback"
|
|
|
|
|
+ if matched is None:
|
|
|
|
|
+ return self._empty_route(query=query, genre=genre)
|
|
|
|
|
+
|
|
|
|
|
+ primary_genre = str(matched.get("题材/流派") or genre or "").strip()
|
|
|
|
|
+ genre_filter = str(matched.get("适用题材") or genre or primary_genre).strip()
|
|
|
|
|
+ return {
|
|
|
|
|
+ "meta": {
|
|
|
|
|
+ "primary_genre": primary_genre,
|
|
|
|
|
+ "route_source": route_source,
|
|
|
|
|
+ "genre_filter": genre_filter,
|
|
|
|
|
+ "recommended_base_tables": self._split_multi_value(matched.get("推荐基础检索表")),
|
|
|
|
|
+ "recommended_dynamic_tables": self._split_multi_value(matched.get("推荐动态检索表")),
|
|
|
|
|
+ },
|
|
|
|
|
+ "core_tone": str(matched.get("核心调性") or "").strip(),
|
|
|
|
|
+ "pacing_strategy": str(matched.get("节奏策略") or "").strip(),
|
|
|
|
|
+ "route_anti_patterns": self._extract_route_anti_patterns(matched),
|
|
|
|
|
+ "recommended_base_tables": self._split_multi_value(matched.get("推荐基础检索表")),
|
|
|
|
|
+ "recommended_dynamic_tables": self._split_multi_value(matched.get("推荐动态检索表")),
|
|
|
|
|
+ "genre_filter": genre_filter,
|
|
|
|
|
+ "source_trace": [{"table": "题材与调性推理", "id": matched.get("编号", ""), "reason": route_source}],
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ def _collect_tables(self, query: str, tables: List[str], genre: str, top_k: int) -> List[Dict[str, Any]]:
|
|
|
|
|
+ rows: List[Dict[str, Any]] = []
|
|
|
|
|
+ for table_name in tables:
|
|
|
|
|
+ result = search_reference(
|
|
|
|
|
+ csv_dir=self.csv_dir,
|
|
|
|
|
+ skill="write",
|
|
|
|
|
+ query=query,
|
|
|
|
|
+ table=table_name,
|
|
|
|
|
+ genre=genre,
|
|
|
|
|
+ max_results=top_k,
|
|
|
|
|
+ )
|
|
|
|
|
+ rows.extend(result.get("data", {}).get("results", []))
|
|
|
|
|
+ return rows
|
|
|
|
|
+
|
|
|
|
|
+ def _extract_anti_patterns(self, rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
|
|
|
+ extracted: List[Dict[str, Any]] = []
|
|
|
|
|
+ for row in rows:
|
|
|
|
|
+ table_name = str(row.get("_table") or "")
|
|
|
|
|
+ for field_name in ANTI_PATTERN_SOURCE_FIELDS.get(table_name, []):
|
|
|
|
|
+ for text in self._split_multi_value(row.get(field_name)):
|
|
|
|
|
+ extracted.append({"text": text, "source_table": table_name, "source_id": row.get("编号", "")})
|
|
|
|
|
+ return extracted
|
|
|
|
|
+
|
|
|
|
|
+ def _suggest_chapter_focus(self, query: str, dynamic_rows: List[Dict[str, Any]]) -> str:
|
|
|
|
|
+ for row in dynamic_rows:
|
|
|
|
|
+ summary = str(row.get("核心摘要") or "").strip()
|
|
|
|
|
+ if summary:
|
|
|
|
|
+ return summary
|
|
|
|
|
+ return query
|
|
|
|
|
+
|
|
|
|
|
+ def _build_source_trace(self, *groups: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
|
|
|
+ trace: List[Dict[str, Any]] = []
|
|
|
|
|
+ for group in groups:
|
|
|
|
|
+ for row in group:
|
|
|
|
|
+ trace.append(
|
|
|
|
|
+ {
|
|
|
|
|
+ "table": row.get("_table", ""),
|
|
|
|
|
+ "id": row.get("编号", ""),
|
|
|
|
|
+ "summary": row.get("核心摘要", ""),
|
|
|
|
|
+ }
|
|
|
|
|
+ )
|
|
|
|
|
+ return trace
|
|
|
|
|
+
|
|
|
|
|
+ def _load_csv_rows(self, table_name: str) -> List[Dict[str, Any]]:
|
|
|
|
|
+ csv_path = self.csv_dir / f"{table_name}.csv"
|
|
|
|
|
+ if not csv_path.is_file():
|
|
|
|
|
+ return []
|
|
|
|
|
+ with csv_path.open("r", encoding="utf-8-sig", newline="") as f:
|
|
|
|
|
+ return list(csv.DictReader(f))
|
|
|
|
|
+
|
|
|
|
|
+ def _normalize_text(self, text: str) -> str:
|
|
|
|
|
+ return str(text or "").strip().lower()
|
|
|
|
|
+
|
|
|
|
|
+ def _split_multi_value(self, raw: Any) -> List[str]:
|
|
|
|
|
+ return [item.strip() for item in str(raw or "").split("|") if item.strip()]
|
|
|
|
|
+
|
|
|
|
|
+ def _fallback_row_for_genre(self, rows: List[Dict[str, Any]], genre: str) -> Dict[str, Any] | None:
|
|
|
|
|
+ genre = str(genre or "").strip()
|
|
|
|
|
+ for row in rows:
|
|
|
|
|
+ if genre and genre in self._split_multi_value(row.get("适用题材")):
|
|
|
|
|
+ return row
|
|
|
|
|
+ return None
|
|
|
|
|
+
|
|
|
|
|
+ def _extract_route_anti_patterns(self, row: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
|
|
|
+ return [
|
|
|
|
|
+ {"text": text, "source_table": "题材与调性推理", "source_id": row.get("编号", "")}
|
|
|
|
|
+ for text in self._split_multi_value(row.get("强制禁忌/毒点"))
|
|
|
|
|
+ ]
|
|
|
|
|
+
|
|
|
|
|
+ def _empty_route(self, query: str, genre: Optional[str]) -> Dict[str, Any]:
|
|
|
|
|
+ fallback_genre = str(genre or "未命中题材").strip()
|
|
|
|
|
+ return {
|
|
|
|
|
+ "meta": {
|
|
|
|
|
+ "primary_genre": fallback_genre,
|
|
|
|
|
+ "route_source": "empty_csv_fallback",
|
|
|
|
|
+ "genre_filter": fallback_genre,
|
|
|
|
|
+ "recommended_base_tables": ["命名规则", "人设与关系"],
|
|
|
|
|
+ "recommended_dynamic_tables": ["桥段套路", "爽点与节奏", "场景写法"],
|
|
|
|
|
+ },
|
|
|
|
|
+ "core_tone": "",
|
|
|
|
|
+ "pacing_strategy": "",
|
|
|
|
|
+ "route_anti_patterns": [],
|
|
|
|
|
+ "recommended_base_tables": ["命名规则", "人设与关系"],
|
|
|
|
|
+ "recommended_dynamic_tables": ["桥段套路", "爽点与节奏", "场景写法"],
|
|
|
|
|
+ "genre_filter": fallback_genre,
|
|
|
|
|
+ "source_trace": [{"table": "题材与调性推理", "id": "", "reason": f"empty_route_for:{query}"}],
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+这里显式约束测试策略:
|
|
|
|
|
+
|
|
|
|
|
+- 直接使用 `tmp_path / csv` 喂给 `reference_search.search()`
|
|
|
|
|
+- 不需要 monkeypatch 搜索函数
|
|
|
|
|
+- 如果后续 `reference_search.search()` 签名变化,优先同步这里的聚合器封装,不在测试层绕过真实接口
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: 落地真实 CSV 数据和字段文档**
|
|
|
|
|
+
|
|
|
|
|
+`题材与调性推理.csv` 至少先录入 3 条手工种子数据,覆盖:
|
|
|
|
|
+
|
|
|
|
|
+```csv
|
|
|
|
|
+编号,适用技能,分类,层级,关键词,意图与同义词,适用题材,大模型指令,核心摘要,详细展开,题材/流派,题材别名,核心调性,节奏策略,主冲突模板,必选爽点,强制禁忌/毒点,推荐基础检索表,推荐动态检索表,基础检索权重,动态检索权重,默认查询词
|
|
|
|
|
+GR-001,write|plan,题材路由,知识补充,玄幻退婚流|退婚打脸,退婚流|废材逆袭,玄幻,先压后爆,首个反打必须有羞辱反弹,玄幻退婚流需要耻辱起手和强兑现,,玄幻退婚流,退婚流|废材逆袭,压抑蓄势后爆裂反击,前压后爆,三章内必须首个反打,退婚羞辱→资源争夺→当场反杀,当众反打|身份翻盘,打脸节奏不能缺最后一拍补刀|配角不能压过主角兑现,命名规则|人设与关系|金手指与设定,桥段套路|爽点与节奏|场景写法,命名规则:1.0|人设与关系:0.9,桥段套路:1.0|爽点与节奏:0.9,退婚|打脸|废材逆袭
|
|
|
|
|
+GR-002,write|plan,题材路由,知识补充,规则动物园|怪谈副本,规则怪谈|动物园规则,悬疑|轻小说,规则先立死,代价必须兑现,规则动物园重在规则压迫与试错代价,,规则动物园,规则怪谈|动物园规则,诡异压迫与冷感观察,每章至少一个规则验证或误判后果,入园规则→试错牺牲→发现隐藏规则,规则反转|错误成本兑现,规则解释过量|系统提前剧透真相,命名规则|人设与关系,桥段套路|场景写法|写作技法,命名规则:0.7|人设与关系:0.8,场景写法:1.0|写作技法:0.9,规则|动物园|副本
|
|
|
|
|
+GR-003,write|plan,题材路由,知识补充,压抑后爆|后期翻盘,压抑一点后面爆|前面憋屈后面翻盘,现言|都市,压抑不能空耗,必须绑定后续兑现资产,压抑后爆路线需要持续累积反弹势能,,压抑后爆,前憋后爆|后期翻盘,持续压迫后的集中爆发,每 2-3 章必须补一个可见反抗信号,压迫累积→误判反扑→情绪总兑现,情绪爆点|身份反转,压抑没有收益|委屈全靠旁白硬说,命名规则|人设与关系|写作技法,爽点与节奏|场景写法|桥段套路,写作技法:0.9|人设与关系:0.8,爽点与节奏:1.0|场景写法:0.8,压抑|翻盘|反弹
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+同时给 `桥段套路.csv` 增加 `忌讳写法` 列,并在 `references/csv/README.md` 新增:
|
|
|
|
|
+
|
|
|
|
|
+```markdown
|
|
|
|
|
+### 题材与调性推理.csv
|
|
|
|
|
+
|
|
|
|
|
+| 列名 | 说明 |
|
|
|
|
|
+|------|------|
|
|
|
|
|
+| `题材/流派` | 路由主标签 |
|
|
|
|
|
+| `题材别名` | 同义词 / 平台黑话 |
|
|
|
|
|
+| `核心调性` | 全局情绪基调 |
|
|
|
|
|
+| `节奏策略` | 开局与兑现节奏 |
|
|
|
|
|
+| `强制禁忌/毒点` | 题材级绝对红线 |
|
|
|
|
|
+| `推荐基础检索表` | 默认基础检索表 |
|
|
|
|
|
+| `推荐动态检索表` | 默认动态检索表 |
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: 回跑测试,确认路由 + 聚合契约转绿**
|
|
|
|
|
+
|
|
|
|
|
+Run: `python -m pytest webnovel-writer/scripts/data_modules/tests/test_story_system_engine.py -q --no-cov`
|
|
|
|
|
+
|
|
|
|
|
+Expected: 通过
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 6: 提交**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add webnovel-writer/references/csv/题材与调性推理.csv \
|
|
|
|
|
+ webnovel-writer/references/csv/桥段套路.csv \
|
|
|
|
|
+ webnovel-writer/references/csv/README.md \
|
|
|
|
|
+ webnovel-writer/scripts/data_modules/story_system_engine.py \
|
|
|
|
|
+ webnovel-writer/scripts/data_modules/tests/test_story_system_engine.py
|
|
|
|
|
+git commit -m "feat: add genre routing csv and story system engine"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Task 3: 实现 `.story-system` 持久化与统一 CLI 接入
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Create: `webnovel-writer/scripts/story_system.py`
|
|
|
|
|
+- Create: `webnovel-writer/scripts/data_modules/tests/test_story_system_cli.py`
|
|
|
|
|
+- Modify: `webnovel-writer/scripts/data_modules/story_contracts.py`
|
|
|
|
|
+- Modify: `webnovel-writer/scripts/data_modules/webnovel.py`
|
|
|
|
|
+- Modify: `webnovel-writer/scripts/data_modules/tests/test_webnovel_unified_cli.py`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 先写 `--persist` 和统一 CLI 转发的失败测试**
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+# webnovel-writer/scripts/data_modules/tests/test_story_system_cli.py
|
|
|
|
|
+#!/usr/bin/env python3
|
|
|
|
|
+# -*- coding: utf-8 -*-
|
|
|
|
|
+
|
|
|
|
|
+import csv
|
|
|
|
|
+import json
|
|
|
|
|
+import sys
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+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)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_story_system_persist_writes_master_chapter_and_anti_patterns(tmp_path, monkeypatch):
|
|
|
|
|
+ project_root = tmp_path / "book"
|
|
|
|
|
+ (project_root / ".webnovel").mkdir(parents=True, exist_ok=True)
|
|
|
|
|
+ (project_root / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
|
|
|
|
|
+
|
|
|
|
|
+ csv_dir = tmp_path / "csv"
|
|
|
|
|
+ csv_dir.mkdir()
|
|
|
|
|
+ _write_csv(
|
|
|
|
|
+ csv_dir / "题材与调性推理.csv",
|
|
|
|
|
+ [
|
|
|
|
|
+ "编号", "适用技能", "分类", "层级", "关键词", "意图与同义词", "适用题材",
|
|
|
|
|
+ "大模型指令", "核心摘要", "详细展开", "题材/流派", "题材别名", "核心调性",
|
|
|
|
|
+ "节奏策略", "强制禁忌/毒点", "推荐基础检索表", "推荐动态检索表", "默认查询词",
|
|
|
|
|
+ ],
|
|
|
|
|
+ [
|
|
|
|
|
+ {
|
|
|
|
|
+ "编号": "GR-001",
|
|
|
|
|
+ "适用技能": "write",
|
|
|
|
|
+ "分类": "题材路由",
|
|
|
|
|
+ "层级": "知识补充",
|
|
|
|
|
+ "关键词": "玄幻退婚流",
|
|
|
|
|
+ "意图与同义词": "退婚流",
|
|
|
|
|
+ "适用题材": "玄幻",
|
|
|
|
|
+ "大模型指令": "先压后爆",
|
|
|
|
|
+ "核心摘要": "退婚起手",
|
|
|
|
|
+ "详细展开": "",
|
|
|
|
|
+ "题材/流派": "玄幻退婚流",
|
|
|
|
|
+ "题材别名": "退婚流",
|
|
|
|
|
+ "核心调性": "先压后爆",
|
|
|
|
|
+ "节奏策略": "三章内反打",
|
|
|
|
|
+ "强制禁忌/毒点": "打脸不能软收尾",
|
|
|
|
|
+ "推荐基础检索表": "命名规则",
|
|
|
|
|
+ "推荐动态检索表": "桥段套路",
|
|
|
|
|
+ "默认查询词": "退婚|打脸",
|
|
|
|
|
+ }
|
|
|
|
|
+ ],
|
|
|
|
|
+ )
|
|
|
|
|
+ _write_csv(csv_dir / "命名规则.csv", ["编号", "适用技能", "分类", "层级", "关键词", "适用题材", "核心摘要"], [])
|
|
|
|
|
+ _write_csv(csv_dir / "桥段套路.csv", ["编号", "适用技能", "分类", "层级", "关键词", "适用题材", "核心摘要", "忌讳写法"], [])
|
|
|
|
|
+
|
|
|
|
|
+ from story_system import main
|
|
|
|
|
+
|
|
|
|
|
+ monkeypatch.setattr(
|
|
|
|
|
+ sys,
|
|
|
|
|
+ "argv",
|
|
|
|
|
+ [
|
|
|
|
|
+ "story_system",
|
|
|
|
|
+ "玄幻退婚流",
|
|
|
|
|
+ "--project-root",
|
|
|
|
|
+ str(project_root),
|
|
|
|
|
+ "--chapter",
|
|
|
|
|
+ "1",
|
|
|
|
|
+ "--persist",
|
|
|
|
|
+ "--csv-dir",
|
|
|
|
|
+ str(csv_dir),
|
|
|
|
|
+ "--format",
|
|
|
|
|
+ "both",
|
|
|
|
|
+ ],
|
|
|
|
|
+ )
|
|
|
|
|
+ main()
|
|
|
|
|
+
|
|
|
|
|
+ story_root = project_root / ".story-system"
|
|
|
|
|
+ assert (story_root / "MASTER_SETTING.json").is_file()
|
|
|
|
|
+ assert (story_root / "MASTER_SETTING.md").is_file()
|
|
|
|
|
+ assert (story_root / "anti_patterns.json").is_file()
|
|
|
|
|
+ assert (story_root / "chapters" / "chapter_001.json").is_file()
|
|
|
|
|
+ assert (story_root / "chapters" / "chapter_001.md").is_file()
|
|
|
|
|
+
|
|
|
|
|
+ payload = json.loads((story_root / "MASTER_SETTING.json").read_text(encoding="utf-8"))
|
|
|
|
|
+ assert payload["route"]["primary_genre"] == "玄幻退婚流"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_markdown_writer_preserves_manual_notes_outside_markers(tmp_path):
|
|
|
|
|
+ from data_modules.story_contracts import write_marked_markdown
|
|
|
|
|
+
|
|
|
|
|
+ target = tmp_path / "MASTER_SETTING.md"
|
|
|
|
|
+ target.write_text(
|
|
|
|
|
+ "# 手工说明\n手工备注\n<!-- STORY-SYSTEM:BEGIN -->\n旧内容\n<!-- STORY-SYSTEM:END -->\n",
|
|
|
|
|
+ encoding="utf-8",
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ write_marked_markdown(target, "## Auto\n新内容\n")
|
|
|
|
|
+
|
|
|
|
|
+ text = target.read_text(encoding="utf-8")
|
|
|
|
|
+ assert "# 手工说明" in text
|
|
|
|
|
+ assert "手工备注" in text
|
|
|
|
|
+ assert "## Auto" in text
|
|
|
|
|
+ assert "旧内容" not in text
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+在 `test_webnovel_unified_cli.py` 增加:
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+def test_webnovel_story_system_forwards_with_resolved_project_root(monkeypatch, tmp_path):
|
|
|
|
|
+ from data_modules import webnovel as cli
|
|
|
|
|
+
|
|
|
|
|
+ project_root = tmp_path / "book"
|
|
|
|
|
+ (project_root / ".webnovel").mkdir(parents=True, exist_ok=True)
|
|
|
|
|
+ (project_root / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
|
|
|
|
|
+
|
|
|
|
|
+ called = {}
|
|
|
|
|
+
|
|
|
|
|
+ def _fake_run_script(script_name, argv):
|
|
|
|
|
+ called["script_name"] = script_name
|
|
|
|
|
+ called["argv"] = argv
|
|
|
|
|
+ return 0
|
|
|
|
|
+
|
|
|
|
|
+ monkeypatch.setattr(cli, "_run_script", _fake_run_script)
|
|
|
|
|
+ monkeypatch.setattr(sys, "argv", ["webnovel", "--project-root", str(project_root), "story-system", "玄幻退婚流"])
|
|
|
|
|
+
|
|
|
|
|
+ cli.main()
|
|
|
|
|
+
|
|
|
|
|
+ assert called["script_name"] == "story_system.py"
|
|
|
|
|
+ assert called["argv"][:2] == ["--project-root", str(project_root.resolve())]
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: 跑红灯**
|
|
|
|
|
+
|
|
|
|
|
+Run: `python -m pytest webnovel-writer/scripts/data_modules/tests/test_story_system_cli.py webnovel-writer/scripts/data_modules/tests/test_webnovel_unified_cli.py -q --no-cov`
|
|
|
|
|
+
|
|
|
|
|
+Expected: 失败于 `No module named 'story_system'` 或 `write_marked_markdown` 未实现
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: 实现持久化写入器与 `story_system.py` CLI**
|
|
|
|
|
+
|
|
|
|
|
+在 `story_contracts.py` 增补持久化函数。这里明确一个边界:**每个 `.md` 文件只允许一组 `<!-- STORY-SYSTEM:BEGIN/END -->` marker**;如果检测到多组 marker,直接抛 `ValueError`,避免 Phase 2 以后出现局部覆盖残留。
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+def write_marked_markdown(path: Path, generated_block: str) -> None:
|
|
|
|
|
+ wrapped = f"{MARKER_BEGIN}\n{generated_block.rstrip()}\n{MARKER_END}\n"
|
|
|
|
|
+ if path.exists():
|
|
|
|
|
+ current = path.read_text(encoding="utf-8")
|
|
|
|
|
+ if current.count(MARKER_BEGIN) > 1 or current.count(MARKER_END) > 1:
|
|
|
|
|
+ raise ValueError(f"{path} contains multiple STORY-SYSTEM markers")
|
|
|
|
|
+ if MARKER_BEGIN in current and MARKER_END in current:
|
|
|
|
|
+ before, _, rest = current.partition(MARKER_BEGIN)
|
|
|
|
|
+ _, _, after = rest.partition(MARKER_END)
|
|
|
|
|
+ path.write_text(f"{before}{wrapped}{after.lstrip()}", encoding="utf-8")
|
|
|
|
|
+ return
|
|
|
|
|
+ path.write_text(wrapped, encoding="utf-8")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def render_master_markdown(master_payload: dict) -> str:
|
|
|
|
|
+ route = master_payload.get("route") or {}
|
|
|
|
|
+ constraints = master_payload.get("master_constraints") or {}
|
|
|
|
|
+ return "\n".join(
|
|
|
|
|
+ [
|
|
|
|
|
+ "# MASTER_SETTING",
|
|
|
|
|
+ f"- 题材:{route.get('primary_genre', '')}",
|
|
|
|
|
+ f"- 调性:{constraints.get('core_tone', '')}",
|
|
|
|
|
+ f"- 节奏:{constraints.get('pacing_strategy', '')}",
|
|
|
|
|
+ ]
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def render_anti_patterns_markdown(anti_patterns: list[dict]) -> str:
|
|
|
|
|
+ lines = ["# ANTI_PATTERNS"]
|
|
|
|
|
+ for row in anti_patterns:
|
|
|
|
|
+ lines.append(f"- {row.get('text', '')}")
|
|
|
|
|
+ return "\n".join(lines)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def render_chapter_markdown(chapter_payload: dict) -> str:
|
|
|
|
|
+ focus = (chapter_payload.get("override_allowed") or {}).get("chapter_focus", "")
|
|
|
|
|
+ return "\n".join(
|
|
|
|
|
+ [
|
|
|
|
|
+ f"# CHAPTER_{int(chapter_payload['meta']['chapter']):03d}",
|
|
|
|
|
+ f"- 章节焦点:{focus}",
|
|
|
|
|
+ ]
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def persist_story_seed(project_root: Path, master_payload: dict, chapter_payload: dict | None, anti_patterns: list[dict]) -> None:
|
|
|
|
|
+ paths = StoryContractPaths.from_project_root(project_root)
|
|
|
|
|
+ paths.root.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
+ paths.chapters_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
+ write_json(paths.master_json, master_payload)
|
|
|
|
|
+ write_json(paths.anti_patterns_json, anti_patterns)
|
|
|
|
|
+ write_marked_markdown(paths.master_json.with_suffix(".md"), render_master_markdown(master_payload))
|
|
|
|
|
+ write_marked_markdown(paths.anti_patterns_json.with_suffix(".md"), render_anti_patterns_markdown(anti_patterns))
|
|
|
|
|
+ if chapter_payload is not None:
|
|
|
|
|
+ chapter_num = int(chapter_payload["meta"]["chapter"])
|
|
|
|
|
+ write_json(paths.chapter_json(chapter_num), chapter_payload)
|
|
|
|
|
+ write_marked_markdown(paths.chapter_json(chapter_num).with_suffix(".md"), render_chapter_markdown(chapter_payload))
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+新增 CLI 入口:
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+# webnovel-writer/scripts/story_system.py
|
|
|
|
|
+def main() -> None:
|
|
|
|
|
+ parser = argparse.ArgumentParser(description="Story System 聚合器")
|
|
|
|
|
+ parser.add_argument("query", help="题材描述或当前意图")
|
|
|
|
|
+ parser.add_argument("--project-root", help="书项目根目录或工作区根目录")
|
|
|
|
|
+ parser.add_argument("--genre", default="", help="显式题材")
|
|
|
|
|
+ parser.add_argument("--chapter", type=int, default=0, help="章节号")
|
|
|
|
|
+ parser.add_argument("--persist", action="store_true", help="写入 PROJECT_ROOT/.story-system/")
|
|
|
|
|
+ parser.add_argument("--format", choices=["json", "markdown", "both"], default="json")
|
|
|
|
|
+ parser.add_argument("--csv-dir", default="", help="测试时覆写 CSV 目录")
|
|
|
|
|
+ args = parser.parse_args()
|
|
|
|
|
+
|
|
|
|
|
+ project_root = resolve_project_root(args.project_root) if args.project_root else resolve_project_root()
|
|
|
|
|
+ csv_dir = Path(args.csv_dir) if args.csv_dir else Path(__file__).resolve().parent.parent / "references" / "csv"
|
|
|
|
|
+
|
|
|
|
|
+ engine = StorySystemEngine(csv_dir=csv_dir)
|
|
|
|
|
+ payload = engine.build(query=args.query, genre=args.genre or None, chapter=args.chapter or None)
|
|
|
|
|
+ if args.persist:
|
|
|
|
|
+ persist_story_seed(project_root, payload["master_setting"], payload.get("chapter_brief"), payload["anti_patterns"])
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: 在统一 CLI `webnovel.py` 中挂接 `story-system`**
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+# webnovel-writer/scripts/data_modules/webnovel.py
|
|
|
|
|
+p_story_system = sub.add_parser("story-system", help="转发到 story_system.py")
|
|
|
|
|
+p_story_system.add_argument("args", nargs=argparse.REMAINDER)
|
|
|
|
|
+
|
|
|
|
|
+# main() 路由分支
|
|
|
|
|
+elif args.tool == "story-system":
|
|
|
|
|
+ forward_args = ["--project-root", str(project_root), *(_strip_project_root_args(args.args))]
|
|
|
|
|
+ raise SystemExit(_run_script("story_system.py", forward_args))
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: 回跑测试,确认持久化与 CLI 契约转绿**
|
|
|
|
|
+
|
|
|
|
|
+Run: `python -m pytest webnovel-writer/scripts/data_modules/tests/test_story_system_cli.py webnovel-writer/scripts/data_modules/tests/test_webnovel_unified_cli.py -q --no-cov`
|
|
|
|
|
+
|
|
|
|
|
+Expected: 通过
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 6: 提交**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add webnovel-writer/scripts/story_system.py \
|
|
|
|
|
+ webnovel-writer/scripts/data_modules/story_contracts.py \
|
|
|
|
|
+ webnovel-writer/scripts/data_modules/webnovel.py \
|
|
|
|
|
+ webnovel-writer/scripts/data_modules/tests/test_story_system_cli.py \
|
|
|
|
|
+ webnovel-writer/scripts/data_modules/tests/test_webnovel_unified_cli.py
|
|
|
|
|
+git commit -m "feat: persist story system seed contracts and expose unified cli"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Task 4: 把合同种子接到 `context_manager` 与 `extract_chapter_context`
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Modify: `webnovel-writer/scripts/data_modules/context_manager.py`
|
|
|
|
|
+- Modify: `webnovel-writer/scripts/data_modules/tests/test_context_manager.py`
|
|
|
|
|
+- Modify: `webnovel-writer/scripts/extract_chapter_context.py`
|
|
|
|
|
+- Modify: `webnovel-writer/scripts/data_modules/tests/test_extract_chapter_context.py`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 先写合同读取入口的失败测试**
|
|
|
|
|
+
|
|
|
|
|
+在 `test_context_manager.py` 增加:
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+def test_context_manager_includes_story_contract_section_before_genre_profile(temp_project):
|
|
|
|
|
+ state = {
|
|
|
|
|
+ "genre": "玄幻",
|
|
|
|
|
+ "protagonist_state": {"name": "萧炎"},
|
|
|
|
|
+ "chapter_meta": {},
|
|
|
|
|
+ "disambiguation_warnings": [],
|
|
|
|
|
+ "disambiguation_pending": [],
|
|
|
|
|
+ }
|
|
|
|
|
+ temp_project.state_file.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
|
|
|
|
|
+
|
|
|
|
|
+ story_root = temp_project.project_root / ".story-system"
|
|
|
|
|
+ (story_root / "chapters").mkdir(parents=True, exist_ok=True)
|
|
|
|
|
+ (story_root / "MASTER_SETTING.json").write_text(
|
|
|
|
|
+ json.dumps(
|
|
|
|
|
+ {
|
|
|
|
|
+ "meta": {"schema_version": "story-system/v1", "contract_type": "MASTER_SETTING", "query": "玄幻退婚流"},
|
|
|
|
|
+ "route": {"primary_genre": "玄幻退婚流"},
|
|
|
|
|
+ "master_constraints": {"core_tone": "先压后爆"},
|
|
|
|
|
+ "base_context": [],
|
|
|
|
|
+ "source_trace": [],
|
|
|
|
|
+ "override_policy": {"locked": ["route.primary_genre"], "append_only": ["anti_patterns"], "override_allowed": []},
|
|
|
|
|
+ },
|
|
|
|
|
+ ensure_ascii=False,
|
|
|
|
|
+ ),
|
|
|
|
|
+ encoding="utf-8",
|
|
|
|
|
+ )
|
|
|
|
|
+ (story_root / "anti_patterns.json").write_text(
|
|
|
|
|
+ json.dumps([{"text": "打脸不能软收尾"}], ensure_ascii=False),
|
|
|
|
|
+ encoding="utf-8",
|
|
|
|
|
+ )
|
|
|
|
|
+ (story_root / "chapters" / "chapter_001.json").write_text(
|
|
|
|
|
+ json.dumps(
|
|
|
|
|
+ {
|
|
|
|
|
+ "meta": {"schema_version": "story-system/v1", "contract_type": "CHAPTER_BRIEF", "chapter": 1},
|
|
|
|
|
+ "override_allowed": {"chapter_focus": "退婚现场反打"},
|
|
|
|
|
+ },
|
|
|
|
|
+ ensure_ascii=False,
|
|
|
|
|
+ ),
|
|
|
|
|
+ encoding="utf-8",
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ payload = ContextManager(temp_project).build_context(1, use_snapshot=False, save_snapshot=False)
|
|
|
|
|
+
|
|
|
|
|
+ assert "story_contract" in payload["sections"]
|
|
|
|
|
+ assert payload["sections"]["story_contract"]["content"]["route"]["primary_genre"] == "玄幻退婚流"
|
|
|
|
|
+ assert payload["sections"]["story_contract"]["content"]["chapter_brief"]["override_allowed"]["chapter_focus"] == "退婚现场反打"
|
|
|
|
|
+ assert ContextManager.SECTION_ORDER.index("story_contract") < ContextManager.SECTION_ORDER.index("genre_profile")
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+在 `test_extract_chapter_context.py` 增加:
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+def test_build_chapter_context_payload_includes_story_contract(tmp_path):
|
|
|
|
|
+ from extract_chapter_context import build_chapter_context_payload
|
|
|
|
|
+
|
|
|
|
|
+ project_root = tmp_path
|
|
|
|
|
+ (project_root / ".webnovel").mkdir(parents=True, exist_ok=True)
|
|
|
|
|
+ (project_root / ".webnovel" / "state.json").write_text(
|
|
|
|
|
+ json.dumps({"protagonist_state": {}, "chapter_meta": {}}, ensure_ascii=False),
|
|
|
|
|
+ encoding="utf-8",
|
|
|
|
|
+ )
|
|
|
|
|
+ story_root = project_root / ".story-system"
|
|
|
|
|
+ (story_root / "chapters").mkdir(parents=True, exist_ok=True)
|
|
|
|
|
+ (story_root / "MASTER_SETTING.json").write_text(
|
|
|
|
|
+ json.dumps({"route": {"primary_genre": "规则动物园"}}, ensure_ascii=False),
|
|
|
|
|
+ encoding="utf-8",
|
|
|
|
|
+ )
|
|
|
|
|
+ (story_root / "anti_patterns.json").write_text(
|
|
|
|
|
+ json.dumps([{"text": "不要提前解释真相"}], ensure_ascii=False),
|
|
|
|
|
+ encoding="utf-8",
|
|
|
|
|
+ )
|
|
|
|
|
+ (story_root / "chapters" / "chapter_001.json").write_text(
|
|
|
|
|
+ json.dumps({"override_allowed": {"chapter_focus": "游客须知初次触发"}}, ensure_ascii=False),
|
|
|
|
|
+ encoding="utf-8",
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ payload = build_chapter_context_payload(project_root, 1)
|
|
|
|
|
+
|
|
|
|
|
+ assert payload["story_contract"]["route"]["primary_genre"] == "规则动物园"
|
|
|
|
|
+ assert payload["story_contract"]["anti_patterns"][0]["text"] == "不要提前解释真相"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: 跑红灯**
|
|
|
|
|
+
|
|
|
|
|
+Run: `python -m pytest webnovel-writer/scripts/data_modules/tests/test_context_manager.py webnovel-writer/scripts/data_modules/tests/test_extract_chapter_context.py -q --no-cov`
|
|
|
|
|
+
|
|
|
|
|
+Expected: `story_contract` section / payload 字段不存在
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: 在 `context_manager.py` 增加合同加载与 section 注入**
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+# webnovel-writer/scripts/data_modules/context_manager.py
|
|
|
|
|
+SECTION_ORDER = [
|
|
|
|
|
+ "core",
|
|
|
|
|
+ "story_contract",
|
|
|
|
|
+ "scene",
|
|
|
|
|
+ "global",
|
|
|
|
|
+ "reader_signal",
|
|
|
|
|
+ "genre_profile",
|
|
|
|
|
+ "writing_guidance",
|
|
|
|
|
+ "plot_structure",
|
|
|
|
|
+ "story_skeleton",
|
|
|
|
|
+ "memory",
|
|
|
|
|
+ "long_term_memory",
|
|
|
|
|
+ "preferences",
|
|
|
|
|
+ "alerts",
|
|
|
|
|
+]
|
|
|
|
|
+
|
|
|
|
|
+def _load_story_contract(self, chapter: int) -> Dict[str, Any]:
|
|
|
|
|
+ paths = StoryContractPaths.from_project_root(self.config.project_root)
|
|
|
|
|
+ master = read_json_if_exists(paths.master_json)
|
|
|
|
|
+ chapter_payload = read_json_if_exists(paths.chapter_json(chapter))
|
|
|
|
|
+ anti_patterns = read_json_if_exists(paths.anti_patterns_json) or []
|
|
|
|
|
+ if not master and not chapter_payload and not anti_patterns:
|
|
|
|
|
+ return {}
|
|
|
|
|
+ return {
|
|
|
|
|
+ "master_setting": master,
|
|
|
|
|
+ "chapter_brief": chapter_payload,
|
|
|
|
|
+ "route": (master or {}).get("route", {}),
|
|
|
|
|
+ "master_constraints": (master or {}).get("master_constraints", {}),
|
|
|
|
|
+ "anti_patterns": anti_patterns,
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+# 在 _build_pack() 组装 pack 时插入:
|
|
|
|
|
+story_contract = self._load_story_contract(chapter)
|
|
|
|
|
+pack = {
|
|
|
|
|
+ "meta": {"chapter": chapter},
|
|
|
|
|
+ "core": core,
|
|
|
|
|
+ "scene": scene,
|
|
|
|
|
+ "global": global_ctx,
|
|
|
|
|
+ "reader_signal": reader_signal,
|
|
|
|
|
+ "genre_profile": genre_profile,
|
|
|
|
|
+ "writing_guidance": writing_guidance,
|
|
|
|
|
+ "plot_structure": plot_structure,
|
|
|
|
|
+ "story_skeleton": story_skeleton,
|
|
|
|
|
+ "memory": memory_ctx,
|
|
|
|
|
+ "long_term_memory": long_term_memory,
|
|
|
|
|
+ "preferences": preferences,
|
|
|
|
|
+ "alerts": alerts,
|
|
|
|
|
+}
|
|
|
|
|
+if story_contract:
|
|
|
|
|
+ pack["story_contract"] = story_contract
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: 在 `extract_chapter_context.py` 中透出 `story_contract`**
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+# webnovel-writer/scripts/extract_chapter_context.py
|
|
|
|
|
+def _load_contract_context(project_root: Path, chapter_num: int) -> Dict[str, Any]:
|
|
|
|
|
+ manager = ContextManager(get_config(project_root))
|
|
|
|
|
+ payload = manager.build_context(chapter_num, use_snapshot=False, save_snapshot=False)
|
|
|
|
|
+ sections = payload.get("sections") or {}
|
|
|
|
|
+ return {
|
|
|
|
|
+ "reader_signal": (sections.get("reader_signal") or {}).get("content", {}),
|
|
|
|
|
+ "genre_profile": (sections.get("genre_profile") or {}).get("content", {}),
|
|
|
|
|
+ "story_contract": (sections.get("story_contract") or {}).get("content", {}),
|
|
|
|
|
+ "writing_guidance": (sections.get("writing_guidance") or {}).get("content", {}),
|
|
|
|
|
+ "plot_structure": (sections.get("plot_structure") or {}).get("content", {}),
|
|
|
|
|
+ "long_term_memory": (sections.get("long_term_memory") or {}).get("content", {}),
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+文本渲染新增一个紧凑章节:
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+story_contract = payload.get("story_contract") or {}
|
|
|
|
|
+if story_contract:
|
|
|
|
|
+ lines.append("## Story Contract")
|
|
|
|
|
+ route = story_contract.get("route") or {}
|
|
|
|
|
+ if route.get("primary_genre"):
|
|
|
|
|
+ lines.append(f"- 主路由题材: {route['primary_genre']}")
|
|
|
|
|
+ anti_patterns = story_contract.get("anti_patterns") or []
|
|
|
|
|
+ for row in anti_patterns[:5]:
|
|
|
|
|
+ lines.append(f"- 红线: {row.get('text')}")
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: 回跑测试**
|
|
|
|
|
+
|
|
|
|
|
+Run: `python -m pytest webnovel-writer/scripts/data_modules/tests/test_context_manager.py webnovel-writer/scripts/data_modules/tests/test_extract_chapter_context.py -q --no-cov`
|
|
|
|
|
+
|
|
|
|
|
+Expected: 通过
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 6: 提交**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add webnovel-writer/scripts/data_modules/context_manager.py \
|
|
|
|
|
+ webnovel-writer/scripts/data_modules/tests/test_context_manager.py \
|
|
|
|
|
+ webnovel-writer/scripts/extract_chapter_context.py \
|
|
|
|
|
+ webnovel-writer/scripts/data_modules/tests/test_extract_chapter_context.py
|
|
|
|
|
+git commit -m "feat: load story contract seed into context assembly"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Task 5: 更新架构文档、命令文档与回归验证
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Create: `docs/architecture/story-system-phase1.md`
|
|
|
|
|
+- Modify: `README.md`
|
|
|
|
|
+- Modify: `docs/architecture/overview.md`
|
|
|
|
|
+- Modify: `docs/guides/commands.md`
|
|
|
|
|
+- Modify: `docs/superpowers/README.md`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 新建 Phase 1 架构文档**
|
|
|
|
|
+
|
|
|
|
|
+`docs/architecture/story-system-phase1.md` 至少写清楚以下四段,避免 Phase 1 代码上线后又变成“隐式约定”:
|
|
|
|
|
+
|
|
|
|
|
+```markdown
|
|
|
|
|
+# Story System Phase 1
|
|
|
|
|
+
|
|
|
|
|
+## JSON 真源
|
|
|
|
|
+- `PROJECT_ROOT/.story-system/MASTER_SETTING.json`
|
|
|
|
|
+- `PROJECT_ROOT/.story-system/anti_patterns.json`
|
|
|
|
|
+- `PROJECT_ROOT/.story-system/chapters/chapter_XXX.json`
|
|
|
|
|
+
|
|
|
|
|
+## 覆盖规则
|
|
|
|
|
+- `locked`:chapter 不得覆盖
|
|
|
|
|
+- `append_only`:chapter 只能补充
|
|
|
|
|
+- `override_allowed`:chapter 可局部覆盖
|
|
|
|
|
+
|
|
|
|
|
+## 运行时读取顺序
|
|
|
|
|
+1. chapter brief
|
|
|
|
|
+2. master setting
|
|
|
|
|
+3. anti-patterns
|
|
|
|
|
+4. genre profile fallback
|
|
|
|
|
+
|
|
|
|
|
+## 迁移边界
|
|
|
|
|
+- Phase 1 不引入 `VOLUME_BRIEF`
|
|
|
|
|
+- Phase 1 不改写后回写主链
|
|
|
|
|
+- `genre-profiles.md` 继续保留为回退层
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: 更新命令与总览文档**
|
|
|
|
|
+
|
|
|
|
|
+在 `docs/guides/commands.md` 增加:
|
|
|
|
|
+
|
|
|
|
|
+````markdown
|
|
|
|
|
+## Story System(Phase 1)
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+python -X utf8 "<CLAUDE_PLUGIN_ROOT>/scripts/webnovel.py" \
|
|
|
|
|
+ --project-root "<WORKSPACE_ROOT>" \
|
|
|
|
|
+ story-system "玄幻退婚流" --chapter 1 --persist --format both
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+说明:
|
|
|
|
|
+- `--project-root` 允许传工作区根或书项目根
|
|
|
|
|
+- 真实落盘位置始终是 `PROJECT_ROOT/.story-system/`
|
|
|
|
|
+- `*.json` 为真源,`*.md` 为投影视图
|
|
|
|
|
+````
|
|
|
|
|
+
|
|
|
|
|
+在 `README.md` 与 `docs/architecture/overview.md` 补一条 Phase 1 说明:
|
|
|
|
|
+
|
|
|
|
|
+```markdown
|
|
|
|
|
+- Story System Phase 1:新增最小合同种子层(`MASTER_SETTING` / `chapter_XXX` / `anti_patterns`),
|
|
|
|
|
+ 作为 `context_manager` 的合同输入前置层。
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: 更新 `docs/superpowers/README.md` 导航**
|
|
|
|
|
+
|
|
|
|
|
+```markdown
|
|
|
|
|
+- [`plans/2026-04-12-story-system-phase1-contract-seed.md`](./plans/2026-04-12-story-system-phase1-contract-seed.md):Story System Phase 1 合同种子层实施计划
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: 跑 Phase 1 目标测试集**
|
|
|
|
|
+
|
|
|
|
|
+Run:
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+python -m pytest \
|
|
|
|
|
+ webnovel-writer/scripts/data_modules/tests/test_story_contracts.py \
|
|
|
|
|
+ webnovel-writer/scripts/data_modules/tests/test_story_system_engine.py \
|
|
|
|
|
+ webnovel-writer/scripts/data_modules/tests/test_story_system_cli.py \
|
|
|
|
|
+ webnovel-writer/scripts/data_modules/tests/test_context_manager.py \
|
|
|
|
|
+ webnovel-writer/scripts/data_modules/tests/test_extract_chapter_context.py \
|
|
|
|
|
+ webnovel-writer/scripts/data_modules/tests/test_webnovel_unified_cli.py \
|
|
|
|
|
+ -q --no-cov
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Expected: 全部通过
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: 跑 `reference_search.py` 回归,证明没有破坏底层 primitive**
|
|
|
|
|
+
|
|
|
|
|
+Run: `python -m pytest webnovel-writer/scripts/tests/test_reference_search.py -q --no-cov`
|
|
|
|
|
+
|
|
|
|
|
+Expected: 通过
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 6: 最终提交**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add README.md \
|
|
|
|
|
+ docs/architecture/story-system-phase1.md \
|
|
|
|
|
+ docs/architecture/overview.md \
|
|
|
|
|
+ docs/guides/commands.md \
|
|
|
|
|
+ docs/superpowers/README.md
|
|
|
|
|
+git commit -m "docs: document story system phase1 contract seed layer"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Spec Coverage Check
|
|
|
|
|
+
|
|
|
|
|
+本计划对 `2026-04-12-story-system-evolution-spec.md` 的覆盖关系如下:
|
|
|
|
|
+
|
|
|
|
|
+- `13.2 Phase 1:合同种子层`
|
|
|
|
|
+ - `题材与调性推理.csv`:Task 2
|
|
|
|
|
+ - 最小 `MASTER_SETTING`:Task 3
|
|
|
|
|
+ - 最小 `CHAPTER_BRIEF`:Task 3
|
|
|
|
|
+ - `anti_patterns.json`:Task 3
|
|
|
|
|
+ - `context_manager` 读取合同:Task 4
|
|
|
|
|
+
|
|
|
|
|
+- `14.1 / 14.1.1 路径解析约束`
|
|
|
|
|
+ - `PROJECT_ROOT/.story-system`:Task 1 / Task 3
|
|
|
|
|
+ - `resolve_project_root(args.project_root)` 经 unified CLI 注入:Task 3
|
|
|
|
|
+
|
|
|
|
|
+- `15.3 当前阶段结论`
|
|
|
|
|
+ - 保留 CSV + MD 双体系,不做自动迁移:Task 2
|
|
|
|
|
+
|
|
|
|
|
+- `17.1 文档更新要求`
|
|
|
|
|
+ - 合同 schema / 目录 / 运行流程 / 迁移说明文档:Task 5
|
|
|
|
|
+
|
|
|
|
|
+- `19. 实施建议`
|
|
|
|
|
+ - 明确先做合同种子层,不提前做 `CHAPTER_COMMIT` 或 event log:全计划范围
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Placeholder Scan
|
|
|
|
|
+
|
|
|
|
|
+已避免以下占位式写法:
|
|
|
|
|
+
|
|
|
|
|
+- 没有使用 “TODO / TBD / 后续补”
|
|
|
|
|
+- 没有把“写测试”写成空泛口号,均给出测试骨架
|
|
|
|
|
+- 没有把文档更新写成一句“同步更新文档”,而是明确到目标文件
|
|
|
|
|
+- 没有把 Phase 2-4 内容混进 Phase 1 实施任务
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Next Plan
|
|
|
|
|
+
|
|
|
|
|
+Phase 1 完成并稳定后,再进入下一份计划:
|
|
|
|
|
+
|
|
|
|
|
+1. `Phase 2 Contract-First Runtime`:`VOLUME_BRIEF`、`REVIEW_CONTRACT`、写前禁区、履约 diff
|
|
|
|
|
+2. `Phase 3 Chapter Commit Chain`:`CHAPTER_COMMIT`、accepted/rejected 语义、projection writers
|
|
|
|
|
+3. `Phase 4 Event Log + Override Ledger`:canonical event log、`contract_override` / `amend_proposal`
|