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
这份 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 才能安全降级旧链路。把这些内容塞进一份实现计划会导致:
因此本计划的退出标准固定为:
PROJECT_ROOT/.story-system/ 能生成最小 MASTER_SETTING、chapter_XXX、anti_patternscontext_manager 能读取并注入 story_contract sectiongenre_profile 仍可作为回退层保留后续应另写三份计划:
Phase 2 Contract-First RuntimePhase 3 Chapter Commit ChainPhase 4 Event Log + Override Ledger文档边界也在本阶段定死:
README.md 只新增 Story System 一级段落与基础目录说明webnovel-writer/references/csv/题材与调性推理.csvwebnovel-writer/scripts/data_modules/story_contracts.pywebnovel-writer/scripts/data_modules/story_system_engine.pywebnovel-writer/scripts/data_modules/tests/test_story_contracts.pywebnovel-writer/scripts/data_modules/tests/test_story_system_engine.pywebnovel-writer/scripts/data_modules/tests/test_story_system_cli.pywebnovel-writer/scripts/story_system.pydocs/architecture/story-system-phase1.mdwebnovel-writer/references/csv/README.mdwebnovel-writer/references/csv/桥段套路.csvwebnovel-writer/scripts/data_modules/config.pywebnovel-writer/scripts/data_modules/context_manager.pywebnovel-writer/scripts/data_modules/tests/test_context_manager.pywebnovel-writer/scripts/extract_chapter_context.pywebnovel-writer/scripts/data_modules/tests/test_extract_chapter_context.pywebnovel-writer/scripts/data_modules/webnovel.pywebnovel-writer/scripts/data_modules/tests/test_webnovel_unified_cli.pyREADME.mddocs/architecture/overview.mddocs/guides/commands.mddocs/superpowers/README.mdstory_contracts.py:合同路径、merge 规则、JSON/Markdown 持久化、marker 安全更新story_system_engine.py:题材路由、多表检索编排、anti-pattern 聚合、最小合同字典构造story_system.py:CLI 入口,负责 query -> build -> render -> persistcontext_manager.py:读取 MASTER_SETTING / chapter_XXX / anti_patterns 并注入 story_contract sectionextract_chapter_context.py:把 story_contract 纳入可视化文本/JSON 提取结果docs/architecture/story-system-phase1.md:Phase 1 合同 schema、目录结构、覆盖规则、迁移说明Files:
webnovel-writer/scripts/data_modules/story_contracts.pywebnovel-writer/scripts/data_modules/tests/test_story_contracts.pyModify: webnovel-writer/scripts/data_modules/config.py
[ ] Step 1: 先写 story_contracts 的失败测试
# 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 路径属性
# 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 更新工具
# 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 | NoneNoneValueError本阶段先在 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: 提交
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"
Files:
webnovel-writer/references/csv/题材与调性推理.csvwebnovel-writer/scripts/data_modules/story_system_engine.pywebnovel-writer/scripts/data_modules/tests/test_story_system_engine.pywebnovel-writer/references/csv/README.mdModify: webnovel-writer/references/csv/桥段套路.csv
[ ] Step 1: 先写 story_system_engine 的失败测试
# 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 归一化映射
# 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()如果后续 reference_search.search() 签名变化,优先同步这里的聚合器封装,不在测试层绕过真实接口
[ ] Step 4: 落地真实 CSV 数据和字段文档
题材与调性推理.csv 至少先录入 3 条手工种子数据,覆盖:
编号,适用技能,分类,层级,关键词,意图与同义词,适用题材,大模型指令,核心摘要,详细展开,题材/流派,题材别名,核心调性,节奏策略,主冲突模板,必选爽点,强制禁忌/毒点,推荐基础检索表,推荐动态检索表,基础检索权重,动态检索权重,默认查询词
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 新增:
### 题材与调性推理.csv
| 列名 | 说明 |
|------|------|
| `题材/流派` | 路由主标签 |
| `题材别名` | 同义词 / 平台黑话 |
| `核心调性` | 全局情绪基调 |
| `节奏策略` | 开局与兑现节奏 |
| `强制禁忌/毒点` | 题材级绝对红线 |
| `推荐基础检索表` | 默认基础检索表 |
| `推荐动态检索表` | 默认动态检索表 |
Run: python -m pytest webnovel-writer/scripts/data_modules/tests/test_story_system_engine.py -q --no-cov
Expected: 通过
[ ] Step 6: 提交
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"
.story-system 持久化与统一 CLI 接入Files:
webnovel-writer/scripts/story_system.pywebnovel-writer/scripts/data_modules/tests/test_story_system_cli.pywebnovel-writer/scripts/data_modules/story_contracts.pywebnovel-writer/scripts/data_modules/webnovel.pyModify: webnovel-writer/scripts/data_modules/tests/test_webnovel_unified_cli.py
[ ] Step 1: 先写 --persist 和统一 CLI 转发的失败测试
# 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 增加:
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())]
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 未实现
story_system.py CLI在 story_contracts.py 增补持久化函数。这里明确一个边界:每个 .md 文件只允许一组 <!-- STORY-SYSTEM:BEGIN/END --> marker;如果检测到多组 marker,直接抛 ValueError,避免 Phase 2 以后出现局部覆盖残留。
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 入口:
# 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
# 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: 提交
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"
context_manager 与 extract_chapter_contextFiles:
webnovel-writer/scripts/data_modules/context_manager.pywebnovel-writer/scripts/data_modules/tests/test_context_manager.pywebnovel-writer/scripts/extract_chapter_context.pyModify: webnovel-writer/scripts/data_modules/tests/test_extract_chapter_context.py
[ ] Step 1: 先写合同读取入口的失败测试
在 test_context_manager.py 增加:
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 增加:
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"] == "不要提前解释真相"
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 注入
# 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
# 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", {}),
}
文本渲染新增一个紧凑章节:
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')}")
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: 提交
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"
Files:
docs/architecture/story-system-phase1.mdREADME.mddocs/architecture/overview.mddocs/guides/commands.mdModify: docs/superpowers/README.md
[ ] Step 1: 新建 Phase 1 架构文档
docs/architecture/story-system-phase1.md 至少写清楚以下四段,避免 Phase 1 代码上线后又变成“隐式约定”:
# 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` 继续保留为回退层
在 docs/guides/commands.md 增加:
## 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 说明:
- Story System Phase 1:新增最小合同种子层(`MASTER_SETTING` / `chapter_XXX` / `anti_patterns`),
作为 `context_manager` 的合同输入前置层。
[ ] Step 3: 更新 docs/superpowers/README.md 导航
- [`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:
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: 全部通过
reference_search.py 回归,证明没有破坏底层 primitiveRun: python -m pytest webnovel-writer/scripts/tests/test_reference_search.py -q --no-cov
Expected: 通过
[ ] Step 6: 最终提交
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"
本计划对 2026-04-12-story-system-evolution-spec.md 的覆盖关系如下:
13.2 Phase 1:合同种子层
题材与调性推理.csv:Task 2MASTER_SETTING:Task 3CHAPTER_BRIEF:Task 3anti_patterns.json:Task 3context_manager 读取合同:Task 414.1 / 14.1.1 路径解析约束
PROJECT_ROOT/.story-system:Task 1 / Task 3resolve_project_root(args.project_root) 经 unified CLI 注入:Task 315.3 当前阶段结论
17.1 文档更新要求
19. 实施建议
CHAPTER_COMMIT 或 event log:全计划范围已避免以下占位式写法:
Phase 1 完成并稳定后,再进入下一份计划:
Phase 2 Contract-First Runtime:VOLUME_BRIEF、REVIEW_CONTRACT、写前禁区、履约 diffPhase 3 Chapter Commit Chain:CHAPTER_COMMIT、accepted/rejected 语义、projection writersPhase 4 Event Log + Override Ledger:canonical event log、contract_override / amend_proposal