框架: 前期创作流程代码——细纲统一合同+字段覆盖门禁+文风/范式注入+取数端统一

修五个硬伤,让"前期准备"从文档纪律变成机械门禁,并补齐写作链上游注入:
- 细纲合同合一:新建 meta/schemas/fine_outline.yaml 作唯一字段权威(必填9+推荐7),
  统一此前 planning 产出形与 writer 装配消费形两套不相交字段;fine-outline 技能对齐。
- 落库字段覆盖门禁:persist_planning 按 schema 校验必填字段,缺必填失败关闭,
  「字段存疑」逃生口;只对声明「字段覆盖门禁:强制」的型生效(fine_outline 已强制)。
- 文风真注入:WriterContext 增可选 styleConstraints(空则省键、上下文逐字节不变),
  装配投影给 writer,不再写死为空。
- 范式规划期绑定:read-context load_confirmed_pattern_bindings 只读已确认 assembly 绑定
  (实验仓承载,确认即绑定,不改主仓表);生产脚本注入已绑定范式。
- 取数端统一:read-context 三个一等取数端(细纲/范式/文风),生产编排 step2 接线,
  已确认细纲不再人肉读库传参。
- harness:AGENTS.md 第9节新增「提交前必须独立子代理形而上四维审查」硬规则。
(writer_contract/retrieve 含上一会话 asOf=0 开篇冻结线改动,同属创作链修复,随本笔提交。)

测试:9 套全绿(合同6/门禁8/装配17/writer_contract21/检索11/统一3/细纲读取4/范式读取5/文风读取6)。
This commit is contained in:
zizi 2026-08-02 02:17:14 +08:00
parent 93415a0930
commit 952485a506
16 changed files with 1162 additions and 33 deletions

View File

@ -30,11 +30,16 @@ disable-model-invocation: true
## 输出合同
候选必须是一个结构化对象,字段完整且顺序稳定:
字段权威 = [`meta/schemas/fine_outline.yaml`](../../../meta/schemas/fine_outline.yaml);本节是该合同的产出侧说明,字段集以 yaml 为准。候选必须是一个结构化对象,字段完整且顺序稳定。
**必填**(writer 装配与机械门的硬依赖,缺一不得落库、不得装配):
```yaml
targetChapter: 489
sourceRef: { sourceId: "outline:volume_1", sourceVersion: "v1" }
chapterGoal: "本章要完成的戏剧任务"
hardConstraints:
- "衔接上一章结尾的不可逆事实;设定约束;不可删除/反转/提前回收"
keyEvents:
- id: event-1
order: 1
@ -42,22 +47,35 @@ keyEvents:
participants: ["实体"]
trigger: "触发因果"
resultDirection: "结果方向"
adjustableBeats:
- "允许 writer 自由处理的次序与比重(不改硬约束)"
declaredNewFacts:
- factId: "fact-chN-1"
text: "本章宣告的新事实"
sourceRef: { sourceId: "outline:volume_1", sourceVersion: "v1" }
mustAppearEntities: ["实体名"]
chapterEndHook: "章末钩子"
```
**推荐**(规划表达力;落库字段覆盖门禁会检查,确无依据时显式标注「字段存疑:原因」):
```yaml
foreshadowing:
- action: 推进 # 埋 | 推进 | 回收 | 不确定
subject: "伏笔"
evidence: "来源 ID 或 unknown"
entities:
- name: "实体"
type: character
role: "本章作用"
foreshadowing:
- action: 推进
subject: "伏笔"
evidence: "来源 ID 或 unknown"
stateChanges: ["可观察的状态变化"]
hook: "章末钩子"
unknowns: ["无法由当前资料证明的内容"]
assumptions: ["为组织结构暂时采用的假设"]
sourceRefs: ["冻结快照中的 sourceId"]
targetChars: 4000 # 可选,受 2000–10000 硬边界
```
`sourceRefs` 是可选的回溯字段,但存在时只能引用快照登记的 `sourceId`,不能粘贴原文。`keyEvents`、`entities`、`foreshadowing`、`stateChanges`、`unknowns`、`assumptions` 必须是数组;`chapterGoal` 和 `hook` 必须是字符串。
字段纪律:`hardConstraints`、`adjustableBeats`、`keyEvents`、`declaredNewFacts`、`mustAppearEntities`、`foreshadowing`、`entities`、`stateChanges`、`unknowns`、`assumptions` 必须是数组;`chapterGoal` 和 `chapterEndHook` 必须是字符串;`sourceRef` 必须是含 `sourceId/sourceVersion` 的对象。`keyEvents` 规范形为因果对象(触发·参与者·行动·结果方向须互相解释),兼容纯字符串旧形,但新细纲一律用对象形。`declaredNewFacts` 是 `{factId,text,sourceRef}` 对象,不是纯字符串。`sourceRefs` 可选,存在时只能引用快照登记的 `sourceId`,不能粘贴原文。`entities`(带本章作用,供覆盖审计)与 `mustAppearEntities`(硬出场名单,供机械门)分工,不互相替代。
## 回放专用红线

View File

@ -1,5 +1,9 @@
#!/usr/bin/env python3
"""细纲功能合同的离线回归测试,不启动模型、不访问作品数据。"""
"""细纲功能合同的离线回归测试,不启动模型、不访问作品数据。
字段权威 = meta/schemas/fine_outline.yaml;本测试同时校验技能文档(产出侧)与
该 schema(字段权威)声明的必填/推荐字段一致,防止两者再次漂移。
"""
from pathlib import Path
import unittest
@ -9,6 +13,29 @@ ROOT = Path(__file__).resolve().parents[4]
SKILL = (ROOT / ".claude/skills/fine-outline/SKILL.md").read_text(encoding="utf-8")
PLANNER = (ROOT / ".claude/agents/planner.md").read_text(encoding="utf-8")
CHAINS = (ROOT / "meta/chains/README.md").read_text(encoding="utf-8")
SCHEMA = (ROOT / "meta/schemas/fine_outline.yaml").read_text(encoding="utf-8")
# 与 meta/schemas/fine_outline.yaml 的必填/推荐集保持一致。
REQUIRED_FIELDS = (
"targetChapter",
"sourceRef",
"chapterGoal",
"hardConstraints",
"keyEvents",
"adjustableBeats",
"declaredNewFacts",
"mustAppearEntities",
"chapterEndHook",
)
RECOMMENDED_FIELDS = (
"foreshadowing",
"entities",
"stateChanges",
"unknowns",
"assumptions",
"sourceRefs",
"targetChars",
)
class FineOutlineContractTest(unittest.TestCase):
@ -18,19 +45,24 @@ class FineOutlineContractTest(unittest.TestCase):
self.assertIn("fine_outline", CHAINS)
self.assertIn("规划→planner", CHAINS)
def test_candidate_fields_and_shadow_boundary_are_explicit(self):
for field in (
"targetChapter",
"chapterGoal",
"keyEvents",
"entities",
"foreshadowing",
"stateChanges",
"hook",
"unknowns",
"assumptions",
):
self.assertIn(field, SKILL)
def test_skill_cites_schema_as_field_authority(self):
# 技能文档必须把字段权威指向 schema,不再自持一套字段集。
self.assertIn("meta/schemas/fine_outline.yaml", SKILL)
self.assertIn("字段权威", SCHEMA)
def test_required_fields_present_in_skill_and_schema(self):
for field in REQUIRED_FIELDS:
self.assertIn(field, SKILL, f"技能文档缺必填字段 {field}")
self.assertIn(field, SCHEMA, f"schema 缺必填字段 {field}")
# hook 已统一为 chapterEndHook,不应再以裸 hook 作字段名出现于必填纪律。
self.assertIn("chapterEndHook", SKILL)
def test_recommended_fields_present_in_skill_and_schema(self):
for field in RECOMMENDED_FIELDS:
self.assertIn(field, SKILL, f"技能文档缺推荐字段 {field}")
self.assertIn(field, SCHEMA, f"schema 缺推荐字段 {field}")
def test_shadow_boundary_and_red_lines_are_explicit(self):
self.assertIn("不得输出正文", SKILL)
self.assertIn("Shadow", SKILL)
self.assertIn("目标章细纲作为生成输入", SKILL)

View File

@ -13,6 +13,8 @@ import json
import sys
from pathlib import Path
import yaml
# 复用 db skill 锁死的 DSN(.claude/skills/db/scripts)
DB_SCRIPTS = Path(__file__).resolve().parents[2] / "db" / "scripts"
sys.path.insert(0, str(DB_SCRIPTS))
@ -20,6 +22,80 @@ from db import connect # noqa: E402
CREATOR = "planning"
SECTION_TYPES = ("setting", "outline", "state", "assembly", "fine_outline")
# 字段权威(git 侧设计稿):落库字段覆盖门禁按 schema_type 读这里的 <型>.yaml。
# persist_planning.py 在 .claude/skills/planning/scripts/ 下,仓库根是 parents[4]。
SCHEMAS_DIR = Path(__file__).resolve().parents[4] / "meta" / "schemas"
def _load_schema(schema_type):
"""从 meta/schemas/<schema_type>.yaml 读字段清单、必填集与门禁档位。
返回 (fields, required, enforce):
fields = {字段名: [别名...]}(特有字段全集,含推荐);
required= 必填字段名列表(只有这些参与失败关闭);
enforce = 该型是否「字段覆盖门禁: 强制」。
schema 文件不存在或非法 → (None, [], False),门禁跳过(该型暂未纳入覆盖校验,
如中文键的老 schema 与英文 payload 尚未对齐,不溯及强拦)。
"""
if not schema_type:
return None, [], False
path = SCHEMAS_DIR / f"{schema_type}.yaml"
if not path.exists():
return None, [], False
try:
doc = yaml.safe_load(path.read_text(encoding="utf-8"))
except yaml.YAMLError:
return None, [], False
if not isinstance(doc, dict):
return None, [], False
fields = {}
for item in doc.get("特有字段", []) or []:
if not isinstance(item, dict) or not item:
continue
if "key" in item: # 流式 - { key: X, 说明:..., aliases:[...] }
name = str(item["key"])
aliases = [str(a) for a in (item.get("aliases", []) or [])]
elif len(item) == 1: # 块式 - X:\n 说明:... (单键映射,键即字段名)
name = next(iter(item))
sub = item[name]
aliases = [str(a) for a in (sub.get("aliases", []) or [])] if isinstance(sub, dict) else []
else:
continue
fields[name] = aliases
required = [str(x) for x in (doc.get("必填字段", []) or [])]
enforce = str(doc.get("字段覆盖门禁", "")).strip() == "强制"
return fields, required, enforce
def check_field_coverage(schema_type, payload):
"""机械校验 payload 对 schema 字段的覆盖(「字段全覆盖」纪律的门禁化)。
每个字段算「已覆盖」当且仅当:字段名或某别名出现在 payload,或在 payload['字段存疑']
里被显式标注了非空原因(逃生口:确无依据的字段可声明存疑而非静默缺省)。
返回 {schema_type, enforced, required, covered, missing_required, missing_recommended, excused}。
enforced=True 且 missing_required 非空时,由 write_section 失败关闭。
"""
fields, required, enforce = _load_schema(schema_type)
if fields is None:
return {"schema_type": schema_type, "enforced": False, "required": [],
"covered": [], "missing_required": [], "missing_recommended": [], "excused": {}}
excused_raw = payload.get("字段存疑", {})
excused = excused_raw if isinstance(excused_raw, dict) else {}
def covered(name):
if any(key in payload for key in [name, *fields.get(name, [])]):
return True
return name in excused and str(excused[name]).strip() != ""
required_set = set(required)
missing_required = [name for name in required if not covered(name)]
missing_recommended = [name for name in fields
if name not in required_set and not covered(name)]
return {"schema_type": schema_type, "enforced": enforce, "required": required,
"covered": [name for name in fields if covered(name)],
"missing_required": missing_required,
"missing_recommended": missing_recommended,
"excused": dict(excused)}
def write_section(work_id, *, section_type, payload, target_chapter=None, schema_type=None,
@ -31,6 +107,13 @@ def write_section(work_id, *, section_type, payload, target_chapter=None, schema
raise ValueError("fine_outline 是章级 section,必须给 target_chapter")
if not isinstance(payload, dict) or not payload:
raise ValueError("payload 必须是非空 JSON 对象(schema 字段结构化内容)")
# 字段覆盖门禁:声明「强制」的 schema(如 fine_outline)缺必填字段即失败关闭;
# 推荐字段缺失只随返回值报告,不拦。未纳入校验的型(无 schema 或未标强制)跳过。
coverage = check_field_coverage(schema_type, payload)
if coverage["enforced"] and coverage["missing_required"]:
raise ValueError(
f"字段覆盖门禁:{schema_type} 缺必填字段 {coverage['missing_required']};"
f"补齐后再落库,确无依据的字段在 payload['字段存疑'] 里逐条标注原因")
with connect() as conn:
try:
row = conn.execute(
@ -40,9 +123,10 @@ def write_section(work_id, *, section_type, payload, target_chapter=None, schema
json.dumps(payload, ensure_ascii=False), version, state, creator)).fetchone()
if dry_run:
conn.rollback()
return {"status": "dry_run_ok", "id": row[0], "state": row[1], "note": "试跑已回滚,未落库"}
return {"status": "dry_run_ok", "id": row[0], "state": row[1],
"note": "试跑已回滚,未落库", "fieldCoverage": coverage}
conn.commit()
return {"status": "written", "id": row[0], "state": row[1]}
return {"status": "written", "id": row[0], "state": row[1], "fieldCoverage": coverage}
except Exception:
conn.rollback()
raise

View File

@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""落库字段覆盖门禁回归:把「字段全覆盖」从文档纪律变成机械门禁。
门禁读 meta/schemas/<schema_type>.yaml:只对该型声明的「必填字段」失败关闭,
推荐字段缺失只报告不拦;确无依据的字段可在 payload['字段存疑'] 显式标注原因放行。
未标「字段覆盖门禁: 强制」的型(如中文键的老 schema)不溯及强拦。
"""
import pathlib
import sys
import unittest
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "db" / "scripts"))
from persist_planning import check_field_coverage, write_section, _load_schema # noqa: E402
REQUIRED = ["targetChapter", "sourceRef", "chapterGoal", "hardConstraints", "keyEvents",
"adjustableBeats", "declaredNewFacts", "mustAppearEntities", "chapterEndHook"]
def full_payload():
return {
"targetChapter": 3,
"sourceRef": {"sourceId": "outline:volume_1", "sourceVersion": "v1"},
"chapterGoal": "本章戏剧任务",
"hardConstraints": ["衔接上一章结尾"],
"keyEvents": [{"id": "e1", "order": 1, "event": "事件", "participants": ["林深"],
"trigger": "触发", "resultDirection": "结果方向"}],
"adjustableBeats": ["次序可调整"],
"declaredNewFacts": [{"factId": "f1", "text": "新事实",
"sourceRef": {"sourceId": "outline:volume_1", "sourceVersion": "v1"}}],
"mustAppearEntities": ["林深"],
"chapterEndHook": "章末钩子",
# 推荐字段
"foreshadowing": [{"action": "推进", "subject": "伏笔", "evidence": "unknown"}],
"entities": [{"name": "林深", "type": "character", "role": "主角"}],
"stateChanges": ["状态变化"],
"unknowns": [],
"assumptions": [],
}
class FieldCoverageTest(unittest.TestCase):
def test_schema_loads_with_required_and_enforce(self):
fields, required, enforce = _load_schema("fine_outline")
self.assertTrue(enforce)
self.assertEqual(required, REQUIRED)
self.assertIn("keyEvents", fields) # 块式字段被解析
self.assertIn("foreshadowing", fields) # 推荐字段被解析
def test_full_payload_covers_all(self):
r = check_field_coverage("fine_outline", full_payload())
self.assertTrue(r["enforced"])
self.assertEqual(r["missing_required"], [])
self.assertEqual(r["missing_recommended"], ["sourceRefs", "targetChars"])
def test_only_required_is_passable_recommended_reported_not_blocking(self):
payload = {k: v for k, v in full_payload().items() if k in REQUIRED}
r = check_field_coverage("fine_outline", payload)
self.assertEqual(r["missing_required"], []) # 不会被拦
self.assertEqual(len(r["missing_recommended"]), 7) # 只报告
def test_missing_required_is_caught(self):
for missing in REQUIRED:
payload = {k: v for k, v in full_payload().items() if k != missing}
r = check_field_coverage("fine_outline", payload)
self.assertIn(missing, r["missing_required"], f"缺 {missing} 应进 missing_required")
def test_excuse_hatch_releases_field(self):
payload = {k: v for k, v in full_payload().items() if k in REQUIRED}
payload["字段存疑"] = {"foreshadowing": "本章无伏笔动作", "entities": "出场已在 mustAppearEntities"}
r = check_field_coverage("fine_outline", payload)
self.assertNotIn("foreshadowing", r["missing_recommended"])
self.assertNotIn("entities", r["missing_recommended"])
# 必填也能用存疑放行(确无依据时显式声明,而非静默缺省)
payload2 = {k: v for k, v in payload.items() if k != "chapterEndHook"}
payload2["字段存疑"] = {"chapterEndHook": "本章无钩子,审查认可"}
self.assertEqual(check_field_coverage("fine_outline", payload2)["missing_required"], [])
def test_non_enforced_schema_not_blocked(self):
# outline 是中文键老 schema,未标强制:enforce=False,不溯及强拦。
_, _, enforce = _load_schema("outline")
self.assertFalse(enforce)
r = check_field_coverage("outline", {"title": "x"})
self.assertFalse(r["enforced"])
def test_unknown_schema_skipped(self):
r = check_field_coverage("no_such_type", {"anything": 1})
self.assertFalse(r["enforced"])
self.assertEqual(r["missing_required"], [])
def test_write_section_rejects_missing_required_before_db(self):
# 门禁在连库之前失败关闭:缺必填的 fine_outline 落库直接 ValueError。
payload = {k: v for k, v in full_payload().items() if k != "hardConstraints"}
with self.assertRaises(ValueError) as ctx:
write_section(12, section_type="fine_outline", schema_type="fine_outline",
target_chapter=99, payload=payload)
self.assertIn("字段覆盖门禁", str(ctx.exception))
if __name__ == "__main__":
unittest.main()

View File

@ -504,6 +504,7 @@ def assemble_context(
output_contract: Mapping[str, Any],
token_budget: Mapping[str, int],
pattern_references: Sequence[Mapping[str, Any]] = (),
style_constraints: Sequence[str] = (),
generated_at: str,
evidence_strategy: str = "production_dual_evidence",
) -> dict[str, Any]:
@ -613,7 +614,9 @@ def assemble_context(
def make_context() -> dict[str, Any]:
"""用当前选择集生成完整上下文,供预算试算与最终冻结。"""
# 规划期选定的文风约束:归一化后仅在非空时入冻结上下文(空则省略整个键,
# 上下文逐字节不变,不破坏既有哈希与 A/C 单变量)。
style_rules = [normalize_text(str(rule)) for rule in style_constraints if str(rule).strip()]
ordered_prose = sorted(
selected_prose,
key=lambda item: (
@ -664,6 +667,8 @@ def assemble_context(
"omittedSources": sorted(copy.deepcopy(omitted), key=lambda item: (item["reason"], item["sourceId"])),
"acceptanceEligible": mode == "production" and purpose == "production",
}
if style_rules:
context["styleConstraints"] = list(style_rules)
# usedContextChars 自身位数会影响 JSON 长度,迭代到数值稳定。
for _ in range(8):
used = _context_size(context)

View File

@ -10,6 +10,7 @@ from __future__ import annotations
import copy
import hashlib
import json
import pathlib
import sys
from typing import Any, Callable, Mapping, Protocol, Sequence
@ -63,6 +64,18 @@ def _chapter(value: Any, path: str) -> int:
return value
def _freeze_chapter(value: Any, path: str) -> int:
"""冻结线章号:允许 0(开篇前冻结线,第 1 章之前没有历史正文)。目标章仍必须正整数。"""
if isinstance(value, bool):
raise RetrievalError(f"{path} 必须是非负整数章号")
if isinstance(value, str) and value.isdigit():
value = int(value)
if not isinstance(value, int) or value < 0:
raise RetrievalError(f"{path} 必须是非负整数章号")
return value
def _milestone_chapter(value: Mapping[str, Any]) -> int:
"""兼容实验库中英文里程碑章号字段,但不解析模糊文本。"""
@ -142,7 +155,7 @@ def build_retrieval_plan(
"""从已确认细纲确定查询集合,并在执行前冻结计划身份。"""
target = _chapter(target_chapter, "targetChapter")
freeze = _chapter(as_of, "asOf")
freeze = _freeze_chapter(as_of, "asOf")
if freeze >= target:
raise RetrievalError("asOf 必须早于 targetChapter")
if not isinstance(work_id, int) or isinstance(work_id, bool) or work_id <= 0:
@ -206,7 +219,7 @@ def _validate_source_ref(ref: Mapping[str, Any], *, as_of: int, path: str) -> di
def freeze_card(card: Mapping[str, Any], *, as_of: int) -> dict[str, Any]:
"""仅用冻结线内里程碑重建卡状态,不透传终态摘要和未来字段。"""
freeze = _chapter(as_of, "asOf")
freeze = _freeze_chapter(as_of, "asOf")
milestones = card.get("milestones")
if not isinstance(milestones, list):
raise RetrievalError(f"卡 {card.get('cardId')} 缺少历史里程碑")
@ -383,6 +396,99 @@ def _content_hash(text: str) -> str:
return "sha256:" + hashlib.sha256(normalized.encode("utf-8")).hexdigest()
def load_confirmed_fine_outline(conn, *, work_id: int, target_chapter: int) -> dict[str, Any]:
"""读取指定章最新一条已确认细纲——read-context 是已确认细纲的唯一消费点。
取数合同与 planning SKILL 登记的 SQL 一致:section_type=fine_outline、state=confirmed、
未删除,按 version 倒序取最新。连接由调用方经 db skill 提供(本函数不自建连接、
不复制第二套权限语义)。缺已确认细纲或 payload 非法即失败关闭。
"""
row = conn.execute(
"SELECT payload FROM example_planning_section WHERE work_id=%s AND "
"section_type='fine_outline' AND target_chapter=%s AND state='confirmed' AND "
"deleted=false ORDER BY version DESC LIMIT 1",
(work_id, target_chapter)).fetchone()
if not row:
raise RetrievalError(
f"无第{target_chapter}章已确认细纲(work={work_id});先落细纲并确认后再写正文")
payload = row[0]
if isinstance(payload, str):
payload = json.loads(payload)
if not isinstance(payload, dict) or not payload:
raise RetrievalError(f"第{target_chapter}章已确认细纲 payload 非法(须非空对象)")
return payload
def load_confirmed_pattern_bindings(conn, *, work_id: int) -> list[dict[str, Any]]:
"""读取作品已确认的范式绑定——规划期选定、写作期只消费已绑定范式。
实验仓承载(遵循「主仓表不改列、扩展进 example_*」口径):绑定即「最新一条已确认
assembly 规划行」的 patternReferences;确认 assembly 即激活绑定,不另写作品行列。
(主仓生产承载方向是 muse_content_work.pattern_bindings 列,见 03-范式领域 §6。)
无已确认 assembly 或没有选中范式时诚实返空(新书/未选范式不报错)。
"""
row = conn.execute(
"SELECT payload FROM example_planning_section WHERE work_id=%s AND "
"section_type='assembly' AND state='confirmed' AND deleted=false "
"ORDER BY version DESC LIMIT 1", (work_id,)).fetchone()
if not row:
return []
payload = row[0]
if isinstance(payload, str):
payload = json.loads(payload)
if not isinstance(payload, dict):
return []
references = payload.get("patternReferences", [])
return [dict(item) for item in references if isinstance(item, dict)]
def derive_style_constraints(style_payload: Any) -> list[str]:
"""把作品已确认文风投影成 writer 可消费的约束字符串列表。
兼容两种形状:结构化 style 画像(style.yaml 字段字典)按维度逐条投影;旧的一句话
文风 blob(书12 现状)整体作为一条约束。空/非法 → 空列表(不注入)。
"""
if isinstance(style_payload, str):
text = style_payload.strip()
return [text] if text else []
if isinstance(style_payload, dict):
rules = []
for aspect, value in style_payload.items():
text = str(value).strip()
if text:
rules.append(f"{aspect}:{text}")
return rules
return []
def load_confirmed_style(conn, *, work_id: int) -> list[str]:
"""读取作品已确认文风并投影为约束——read-context 的文风取数端(与细纲/范式取数端并排)。
优先独立 style 规划行(schema_type='style'),回退设定行的 style 字段(书12 现状)。
无确认文风时诚实返空。结构化文风画像(书级 style 八字段)的抽取仍待建。
"""
row = conn.execute(
"SELECT payload FROM example_planning_section WHERE work_id=%s AND "
"schema_type='style' AND state='confirmed' AND deleted=false "
"ORDER BY version DESC LIMIT 1", (work_id,)).fetchone()
if row:
payload = row[0]
if isinstance(payload, str):
payload = json.loads(payload)
return derive_style_constraints(payload)
setting = conn.execute(
"SELECT payload FROM example_planning_section WHERE work_id=%s AND "
"section_type='setting' AND state='confirmed' AND deleted=false "
"ORDER BY version DESC LIMIT 1", (work_id,)).fetchone()
if setting:
payload = setting[0]
if isinstance(payload, str):
payload = json.loads(payload)
if isinstance(payload, dict):
return derive_style_constraints(payload.get("style"))
return []
def _index_hint(card: Mapping[str, Any], *, as_of: int) -> dict[str, Any]:
"""把冻结卡投影成稳定索引提示,是否存在原文指针不影响卡进入索引。"""
@ -457,7 +563,7 @@ def retrieve_writer_sources(
) -> dict[str, Any]:
"""执行固定计划,展开卡来源并形成事实/原文两条证据线。"""
as_of = _chapter(plan.get("asOf"), "plan.asOf")
as_of = _freeze_chapter(plan.get("asOf"), "plan.asOf")
work_id = plan.get("filters", {}).get("workId")
if not isinstance(work_id, int) or isinstance(work_id, bool) or work_id <= 0:
raise RetrievalError("plan.filters.workId 非法")
@ -566,5 +672,6 @@ def retrieve_writer_sources(
__all__ = [
"RetrievalError", "CardIndexRepository", "ProseRepository", "ProductionCardIndexRepository",
"ReplayCardIndexRepository", "FrozenProseRepository", "stable_sort_cards", "freeze_card",
"build_retrieval_plan", "retrieve_writer_sources",
"build_retrieval_plan", "retrieve_writer_sources", "load_confirmed_fine_outline",
"load_confirmed_pattern_bindings", "load_confirmed_style", "derive_style_constraints",
]

View File

@ -543,5 +543,34 @@ class AssembleWriterContextTest(unittest.TestCase):
assemble_context(**kwargs)
class StyleInjectionTest(unittest.TestCase):
"""style 真注入:规划期选定的文风约束进冻结上下文并投影给 writer。"""
def test_no_style_means_context_unchanged_and_projection_empty(self):
result = assemble_context(**base_kwargs())
# 未提供文风时整个键省略,上下文逐字节不变(不破坏既有冻结哈希)。
self.assertNotIn("styleConstraints", result["context"])
self.assertEqual(result["writerCreativeInput"]["styleConstraints"], [])
def test_style_constraints_freeze_and_project_to_writer(self):
kwargs = base_kwargs()
kwargs["style_constraints"] = ["禁用 仿佛/似乎/不禁/眼眸", "句式偏短,节奏紧"]
result = assemble_context(**kwargs)
self.assertEqual(
result["context"]["styleConstraints"],
["禁用 仿佛/似乎/不禁/眼眸", "句式偏短,节奏紧"],
)
self.assertEqual(
result["writerCreativeInput"]["styleConstraints"],
["禁用 仿佛/似乎/不禁/眼眸", "句式偏短,节奏紧"],
)
def test_blank_style_rules_are_dropped(self):
kwargs = base_kwargs()
kwargs["style_constraints"] = [" ", "", "冷峻克制"]
result = assemble_context(**kwargs)
self.assertEqual(result["context"]["styleConstraints"], ["冷峻克制"])
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""细纲库读统一消费点回归:load_confirmed_fine_outline 的取数合同(不连真库,用桩连接)。"""
import pathlib
import sys
import unittest
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
from retrieve_writer_sources import RetrievalError, load_confirmed_fine_outline # noqa: E402
class FakeCursor:
def __init__(self, row):
self._row = row
def fetchone(self):
return self._row
class FakeConn:
"""记录 SQL 与参数,按预设行返回,模拟 db skill 连接的 execute/fetchone 接口。"""
def __init__(self, row):
self._row = row
self.sql = None
self.params = None
def execute(self, sql, params=None):
self.sql = sql
self.params = params
return FakeCursor(self._row)
class FineOutlineReaderTest(unittest.TestCase):
def test_returns_dict_payload_and_uses_confirmed_latest_contract(self):
conn = FakeConn(({"targetChapter": 3, "chapterGoal": "x"},))
result = load_confirmed_fine_outline(conn, work_id=12, target_chapter=3)
self.assertEqual(result["targetChapter"], 3)
# 取数合同:fine_outline + confirmed + 未删 + version 倒序取最新。
self.assertIn("section_type='fine_outline'", conn.sql)
self.assertIn("state='confirmed'", conn.sql)
self.assertIn("deleted=false", conn.sql)
self.assertIn("ORDER BY version DESC LIMIT 1", conn.sql)
self.assertEqual(conn.params, (12, 3))
def test_parses_jsonb_returned_as_string(self):
conn = FakeConn(('{"targetChapter": 5, "chapterGoal": "y"}',))
result = load_confirmed_fine_outline(conn, work_id=12, target_chapter=5)
self.assertEqual(result, {"targetChapter": 5, "chapterGoal": "y"})
def test_missing_confirmed_outline_fails_closed(self):
conn = FakeConn(None)
with self.assertRaises(RetrievalError) as ctx:
load_confirmed_fine_outline(conn, work_id=12, target_chapter=9)
self.assertIn("已确认细纲", str(ctx.exception))
def test_illegal_payload_fails_closed(self):
for bad in (None, "{}", "[]", '"str"'):
conn = FakeConn((bad,))
with self.assertRaises(RetrievalError):
load_confirmed_fine_outline(conn, work_id=12, target_chapter=3)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""细纲统一合同回归:锁住「技能产出形」与「装配消费形」合一后的装配行为。
历史 bug:planning 技能曾只产 chapterGoal/keyEvents/entities/foreshadowing/... 这套
表达形字段,缺 writer 装配硬依赖的 sourceRef/hardConstraints/adjustableBeats/
declaredNewFacts,导致 _outline_contract 直接 AssemblyError——planner 产出装配不了。
统一合同(meta/schemas/fine_outline.yaml)把必填集定为两者并集后,按统一形状产出
必须能装配;缺任一 writer 必填字段必须失败关闭。
"""
from __future__ import annotations
import pathlib
import sys
import unittest
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
from assemble_writer_context import AssemblyError, _outline_contract # noqa: E402
def unified_outline() -> dict:
"""按 meta/schemas/fine_outline.yaml 必填集构造的统一形状细纲。"""
return {
"targetChapter": 3,
"sourceRef": {"sourceId": "outline:volume_1", "sourceVersion": "v1"},
"chapterGoal": "茧出击后首次实战,吞食更强异种核心完成升级",
"hardConstraints": ["衔接第2章结尾:茧自行撕开舱门出击"],
"keyEvents": [
{"id": "event-1", "order": 1, "event": "搏杀中被压制",
"participants": ["林深"], "trigger": "锁定更强异种",
"resultDirection": "暴露升级前不足"},
],
"adjustableBeats": ["搏杀战术细节可调整"],
"declaredNewFacts": [
{"factId": "fact-ch3-1", "text": "茧吞食更强核心即升级",
"sourceRef": {"sourceId": "outline:volume_1", "sourceVersion": "v1"}},
],
"mustAppearEntities": ["林深"],
"chapterEndHook": "深渊更深处回应了这次进食",
# 推荐字段也在,但不影响装配子集
"foreshadowing": [{"action": "推进", "subject": "脑内声音", "evidence": "unknown"}],
"unknowns": [],
"assumptions": [],
}
class FineOutlineUnificationTest(unittest.TestCase):
def test_unified_shape_assembles_and_extracts_writer_subset(self):
result = _outline_contract(unified_outline())
# 装配只抽 writer 需要的四字段,表达形字段不倾倒给写手。
self.assertEqual(
set(result),
{"sourceRef", "hardConstraints", "adjustableBeats", "declaredNewFacts"},
)
self.assertEqual(result["hardConstraints"], ["衔接第2章结尾:茧自行撕开舱门出击"])
def test_legacy_skill_only_shape_fails_closed(self):
# 旧技能形:只有表达形字段,缺 writer 四必填——必须装配失败(复现原 bug)。
legacy = {
"targetChapter": 3,
"chapterGoal": "本章戏剧任务",
"keyEvents": [{"id": "e1", "order": 1, "event": "事件"}],
"entities": [{"name": "林深", "type": "character", "role": "主角"}],
"foreshadowing": [{"action": "埋", "subject": "伏笔", "evidence": "unknown"}],
"stateChanges": ["状态变化"],
"chapterEndHook": "钩子",
"unknowns": [],
"assumptions": [],
}
with self.assertRaises(AssemblyError):
_outline_contract(legacy)
def test_missing_any_writer_required_field_fails_closed(self):
for missing in ("sourceRef", "hardConstraints", "adjustableBeats", "declaredNewFacts"):
outline = unified_outline()
del outline[missing]
with self.assertRaises(AssemblyError, msg=f"缺 {missing} 应装配失败"):
_outline_contract(outline)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""范式规划期绑定读取回归:load_confirmed_pattern_bindings(桩连接,不连真库)。
实验仓承载:绑定 = 最新一条已确认 assembly 规划行的 patternReferences。
确认 assembly 即激活绑定;无已确认 assembly 或没选范式时诚实返空(不报错)。
"""
import pathlib
import sys
import unittest
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
from retrieve_writer_sources import load_confirmed_pattern_bindings # noqa: E402
class FakeCursor:
def __init__(self, row):
self._row = row
def fetchone(self):
return self._row
class FakeConn:
def __init__(self, row):
self._row = row
self.sql = None
self.params = None
def execute(self, sql, params=None):
self.sql = sql
self.params = params
return FakeCursor(self._row)
def assembly_payload(refs):
return {"schemaVersion": "assembly-v1", "workId": 12,
"patternReferences": refs, "knowledgeBindings": []}
class PatternBindingReaderTest(unittest.TestCase):
def test_returns_confirmed_assembly_pattern_references(self):
refs = [{"sourceId": "card:1", "sourceVersion": "v1", "name": "锁空坠杀"},
{"sourceId": "card:2", "sourceVersion": "v1", "name": "碎颅"}]
conn = FakeConn((assembly_payload(refs),))
result = load_confirmed_pattern_bindings(conn, work_id=12)
self.assertEqual(result, refs)
self.assertIn("section_type='assembly'", conn.sql)
self.assertIn("state='confirmed'", conn.sql)
self.assertIn("ORDER BY version DESC LIMIT 1", conn.sql)
self.assertEqual(conn.params, (12,))
def test_no_confirmed_assembly_returns_empty(self):
conn = FakeConn(None)
self.assertEqual(load_confirmed_pattern_bindings(conn, work_id=12), [])
def test_jsonb_as_string_is_parsed(self):
conn = FakeConn(('{"patternReferences": [{"sourceId": "card:9", "name": "x"}]}',))
result = load_confirmed_pattern_bindings(conn, work_id=12)
self.assertEqual(result, [{"sourceId": "card:9", "name": "x"}])
def test_empty_selection_returns_empty(self):
conn = FakeConn((assembly_payload([]),))
self.assertEqual(load_confirmed_pattern_bindings(conn, work_id=12), [])
def test_non_dict_references_are_filtered(self):
conn = FakeConn((assembly_payload([{"sourceId": "card:1"}, "bad", 42, None]),))
self.assertEqual(load_confirmed_pattern_bindings(conn, work_id=12),
[{"sourceId": "card:1"}])
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""文风取数端回归:load_confirmed_style / derive_style_constraints(桩连接,不连真库)。
read-context 的文风取数端,与细纲/范式取数端并排成一等公民。优先独立 style 规划行,
回退设定行的 style 字段(书12 现状为一句话 blob)。结构化画像抽取仍待建。
"""
import pathlib
import sys
import unittest
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
from retrieve_writer_sources import derive_style_constraints, load_confirmed_style # noqa: E402
class FakeCursor:
def __init__(self, row):
self._row = row
def fetchone(self):
return self._row
class FakeConn:
"""按 SQL 区分两次查询:schema_type='style' 的独立文风行 / section_type='setting' 的设定行。"""
def __init__(self, style_row=None, setting_row=None):
self.style_row = style_row
self.setting_row = setting_row
def execute(self, sql, params=None):
if "schema_type='style'" in sql:
return FakeCursor(self.style_row)
if "section_type='setting'" in sql:
return FakeCursor(self.setting_row)
return FakeCursor(None)
class DeriveStyleTest(unittest.TestCase):
def test_blob_becomes_single_constraint(self):
self.assertEqual(derive_style_constraints("冷峻、克制、微恐。"), ["冷峻、克制、微恐。"])
def test_structured_dict_projects_per_aspect(self):
self.assertEqual(
derive_style_constraints({"叙事人称": "第三人称限制视角", "AI味黑名单": "禁 仿佛/似乎", "达标样张": ""}),
["叙事人称:第三人称限制视角", "AI味黑名单:禁 仿佛/似乎"],
)
def test_blank_and_illegal_return_empty(self):
self.assertEqual(derive_style_constraints(" "), [])
self.assertEqual(derive_style_constraints(None), [])
self.assertEqual(derive_style_constraints(42), [])
class LoadConfirmedStyleTest(unittest.TestCase):
def test_prefers_dedicated_style_section(self):
conn = FakeConn(style_row=('{"叙事人称": "第三人称"}',),
setting_row=('{"style": "不该用到我"}',))
self.assertEqual(load_confirmed_style(conn, work_id=12), ["叙事人称:第三人称"])
def test_falls_back_to_setting_style_field(self):
conn = FakeConn(style_row=None, setting_row=('{"title": "深渊机神", "style": "冷峻克制"}',))
self.assertEqual(load_confirmed_style(conn, work_id=12), ["冷峻克制"])
def test_no_confirmed_style_returns_empty(self):
conn = FakeConn(style_row=None, setting_row=None)
self.assertEqual(load_confirmed_style(conn, work_id=12), [])
if __name__ == "__main__":
unittest.main()

View File

@ -376,7 +376,8 @@ def _validate_plan(value: Any, path: str) -> None:
_hash(plan["planId"], f"{path}.planId")
if not _RUN_ID_RE.fullmatch(_string(plan["runId"], f"{path}.runId")):
raise ContractError(f"{path}.runId 格式非法")
_integer(plan["asOf"], f"{path}.asOf", minimum=1)
# asOf=0 表示开篇前冻结线(第 1 章之前):此刻只有设定/大纲/细纲,无历史正文
_integer(plan["asOf"], f"{path}.asOf", minimum=0)
for index, query in enumerate(_array(plan["queries"], f"{path}.queries")):
item = _object(query, f"{path}.queries[{index}]", frozenset({"queryId", "text", "entityTypes", "purpose", "topK"}))
_string(item["queryId"], f"{path}.queries[{index}].queryId")
@ -389,7 +390,7 @@ def _validate_plan(value: Any, path: str) -> None:
_string(plan["proseIndexVersion"], f"{path}.proseIndexVersion")
filters = _object(plan["filters"], f"{path}.filters", frozenset({"workId", "asOfChapter", "sourceStatus", "authorizationRequired"}))
_integer(filters["workId"], f"{path}.filters.workId", minimum=1)
_integer(filters["asOfChapter"], f"{path}.filters.asOfChapter", minimum=1)
_integer(filters["asOfChapter"], f"{path}.filters.asOfChapter", minimum=0)
_string(filters["sourceStatus"], f"{path}.filters.sourceStatus")
_boolean(filters["authorizationRequired"], f"{path}.filters.authorizationRequired")
if plan["tieBreak"] != TIE_BREAK:
@ -438,7 +439,7 @@ def validate_writer_context(value: Any) -> dict[str, Any]:
value,
"$",
required,
frozenset({"evidenceStrategy", "indexHints"}),
frozenset({"evidenceStrategy", "indexHints", "styleConstraints"}),
)
if context["schemaVersion"] != CONTEXT_VERSION:
raise ContractError("$.schemaVersion 版本不支持")
@ -463,7 +464,8 @@ def validate_writer_context(value: Any) -> dict[str, Any]:
_string(context["qualityPolicyVersion"], "$.qualityPolicyVersion")
_integer(context["workId"], "$.workId", minimum=1)
target = _integer(context["targetChapter"], "$.targetChapter", minimum=1)
as_of = _integer(context["asOf"], "$.asOf", minimum=1)
# asOf=0 合法:开篇前冻结线,正文基线必为空(任何历史正文都会被后续"超出冻结线"挡下)
as_of = _integer(context["asOf"], "$.asOf", minimum=0)
if as_of >= target:
raise ContractError("$.asOf 必须早于 targetChapter")
@ -600,6 +602,13 @@ def validate_writer_context(value: Any) -> dict[str, Any]:
for index, reference in enumerate(_array(context["patternReferences"], "$.patternReferences")):
# 范式引用走放宽校验:来源指针仍严格,另允许 name/summary/writingPoints 内容字段。
_pattern_source_ref(reference, f"$.patternReferences[{index}]")
if "styleConstraints" in context:
# 文风约束(规划期选定的 style 画像投影):非空字符串数组;缺省时整个键省略,
# 上下文逐字节不变(不破坏既有冻结哈希),build 端按空数组投影。
style_rules = _array(context["styleConstraints"], "$.styleConstraints")
for index, rule in enumerate(style_rules):
_string(rule, f"$.styleConstraints[{index}]", nonempty=True)
context["styleConstraints"] = [str(rule) for rule in style_rules]
for index, coverage in enumerate(_array(context["evidenceCoverage"], "$.evidenceCoverage")):
item = _object(coverage, f"$.evidenceCoverage[{index}]", frozenset({"elementId", "elementType", "name", "status", "factEvidenceIds", "proseEvidenceIds", "gapReason"}))
for field in ("elementId", "elementType", "name", "gapReason"):
@ -707,7 +716,9 @@ def build_writer_creative_input(value: Any) -> dict[str, Any]:
"maxChars": output_contract["maxChars"],
"frontmatterRequired": output_contract["frontmatterRequired"],
},
"styleConstraints": [],
# 文风约束从冻结上下文投影(规划期选定的 style 画像);上下文未带则为空,
# 不再写死恒空——style 真注入的出口。
"styleConstraints": [str(rule) for rule in context.get("styleConstraints", [])],
}
return json.loads(canonical_json(creative_input))

View File

@ -118,6 +118,7 @@ agent-example/
- 创作候选默认不提交。用户明确选择接受或修改后合并,且实时审查通过后,才可经 `confirm` 流程进入正式事实;丢弃也必须针对用户明确指定的候选。
- 框架改动与创作内容分开审查、分开提交。不得把 agents、skills、meta、脚本改动和正文、规划、知识卡候选混在同一提交。
- 任何 `git add` 或 `git commit` 都必须先取得用户明确授权;获得授权后,提交信息使用 `作品(书名): 动作 摘要` 或 `框架: 摘要`。
- **提交前必须形而上审查。** 取得授权后、执行 `git add` 前,必须起一个独立子代理,对即将提交的全部内容(先 `git diff` 出真实改动)从形而上层面审查四个维度:**逻辑完整性**(论点/合同/链路有无缺环、边界是否闭合)、**一致性**(与既有 SSOT、术语、字段合同、相邻文档有无矛盾)、**合理性**(设计取舍是否站得住、是否过度或不足)、**可行性**(是否真能落地运行、测试是否支撑结论)。审查只读、不改代码;四维给出明确结论(通过 / 带条件通过 / 不通过 + 具体问题),通过后才允许提交。框架改动与创作内容分别审查。
- 不覆盖、不还原、不暂存其他工作者或用户已有改动。执行前后都要核对真实 diff,只处理当前授权范围。
## 10. 评测结论与验证入口

View File

@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""真写一章 · 阶段二前置:建第 3 章 + 章级细纲(规划落库)。
镜像 step2_setup_chapter2.py 的建章 + persist_planning 模式。第 3 章细纲接第 2 章
结尾硬钩子(「茧」自己撕开舱门出击、裴朔下达出击授权、它要吃掉更强的核心),按
总纲「机甲养成升级线 + 污染不可逆」展开:首次实战、吞食更强异种核心完成第一次
升级、链接加深、污染同步恶化、脑内声音与林深自我边界开始模糊。
细纲 payload 形状与第 2 章一致(合同形状):
- 合同四件套(sourceRef/hardConstraints/adjustableBeats/declaredNewFacts)满足
assemble 的 _outline_contract 与 WriterContext 严格校验;declaredNewFacts 是
{factId,text,sourceRef} 对象。
- chapterGoal/keyEvents/mustAppearEntities/chapterEndHook 是规划侧字段,assemble 会裁剪,
留给人工审查与机械门的锚点映射。
- entities 供 build_retrieval_plan 生成检索查询(新书无卡,检索会诚实返空)。
"""
import sys
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
DB_SCRIPTS = SCRIPT_DIR.parents[1] / ".claude" / "skills" / "db" / "scripts"
PLANNING_SCRIPTS = SCRIPT_DIR.parents[1] / ".claude" / "skills" / "planning" / "scripts"
for p in (str(DB_SCRIPTS), str(PLANNING_SCRIPTS)):
if p not in sys.path:
sys.path.insert(0, p)
from db import connect # noqa: E402
from persist_planning import write_section, confirm_section # noqa: E402
WORK_ID = 12
CHAPTER3_TITLE = "进食"
FINE_OUTLINE_PAYLOAD = {
"targetChapter": 3,
"targetChars": 4000,
"chapterGoal": ("「茧」撕开舱门出击后的首次实战。林深驾驶茧拦截一头更强的异种,"
"在近乎被压制的搏杀中,茧吞食了它的异种核心,完成第一次升级;"
"链接随之加深,林深获得跨层感知(能直接'读'深渊的波纹),"
"代价是污染不可逆地恶化——左眼黑纹继续扩散,脑内的声音越来越像"
"他自己,人与机甲、与异种之间的边界开始模糊。"),
"hardConstraints": [
"衔接第2章结尾:茧未经弹射自行撕开实验舰外舱门出击,裴朔已下出击授权,机甲饥饿直接、没有谎言,目标是吃掉一个更强的核心。",
"首次实战对象是一个比第1章杂兵更强的异种单位;搏杀有真实的压制与险情,不是碾压。",
"茧通过吞食更强异种核心完成第一次升级,链接加深,林深获得新的感知或力量(升级线落地,不只是变强数字)。",
"污染随这次吞食不可逆加深:左眼黑纹扩散,脑内声音与林深的自我边界模糊(他开始分不清哪个念头是自己的)。",
"舰队在后方通过链路监控这次出击,裴朔/何岚至少一方观察到茧的异常成长与林深的污染读数。",
"章末钩子:升级后的茧/林深引来了深渊更深处的注意,或核心指向下一个更强的猎物——饥饿没有止境。",
],
"keyEvents": [
{"id": "event-1", "order": 1, "event": "茧冲入战场锁定更强异种,交手即被压制",
"participants": ["林深", "生物机甲茧"], "trigger": "茧循饥饿扑向更强核心",
"resultDirection": "暴露升级前的不足,陷入险境"},
{"id": "event-2", "order": 2, "event": "链接加深,林深首次主动读到深渊波纹找到破局点",
"participants": ["林深", "生物机甲茧"], "trigger": "搏杀逼迫人机链接深入",
"resultDirection": "扭转战局,污染随之加深"},
{"id": "event-3", "order": 3, "event": "茧吞噬异种核心完成第一次升级",
"participants": ["生物机甲茧"], "trigger": "异种被击败、核心暴露",
"resultDirection": "机体与感知同时蜕变,升级线落地"},
{"id": "event-4", "order": 4, "event": "污染恶化,脑内声音与林深边界模糊",
"participants": ["林深"], "trigger": "吞食更强核心使链接与污染同步加深",
"resultDirection": "林深分不清是谁在渴望,不可逆"},
{"id": "event-5", "order": 5, "event": "舰队侧观察到异常成长与污染读数",
"participants": ["裴朔"], "trigger": "链路监控回传升级与污染数据",
"resultDirection": "埋下后续裁决伏笔"},
],
"adjustableBeats": [
"搏杀的具体战术与险情设计可调整(须有真实压制,不许写成碾压)。",
"升级获得的具体能力可调整(须是'感知/链接'向,服务于深渊设定,不凭空给战力)。",
"舰队侧观察的视角(裴朔或何岚)与比重可调整。",
],
"declaredNewFacts": [
{"factId": "fact-ch3-1",
"text": "茧吞食更强异种核心即可升级:机体蜕变、链接加深,林深获得跨层感知,能直接读取深渊波纹。",
"sourceRef": {"sourceId": "outline:volume_1", "sourceVersion": "v1"}},
{"factId": "fact-ch3-2",
"text": "污染随每次吞食不可逆加深:左眼黑纹扩散,脑内声音与林深自我边界模糊,人与机甲、异种的界限开始溶解。",
"sourceRef": {"sourceId": "outline:volume_1", "sourceVersion": "v1"}},
],
"mustAppearEntities": ["林深", "生物机甲茧", "裴朔"],
"chapterEndHook": ("升级完成的茧在深空中安静下来,饥饿却没有消失。林深'听'见深渊"
"更深处有什么东西回应了这次的进食——更大的核心,更深的召唤。"
"它要的从来不止这一顿。"),
"sourceRef": {"sourceId": "outline:volume_1", "sourceVersion": "v1"},
"entities": [
{"id": "entity-ch3-char-linshen", "type": "character", "name": "林深"},
{"id": "entity-ch3-char-peishuo", "type": "character", "name": "裴朔"},
{"id": "entity-ch3-item-cocoon", "type": "item", "name": "生物机甲茧"},
{"id": "entity-ch3-loc-deepspace", "type": "location", "name": "深空战场"},
],
}
def main():
with connect() as conn:
chapter_id = conn.execute(
"INSERT INTO muse_content_chapter(work_id, title, order_no, status, revision, "
"creator, updater) VALUES (%s,%s,3,'draft',1,'setup','setup') RETURNING id",
(WORK_ID, CHAPTER3_TITLE)).fetchone()[0]
print(f"建第 3 章: id={chapter_id} 《{CHAPTER3_TITLE}》")
conn.commit()
fine = write_section(WORK_ID, section_type="fine_outline", schema_type="fine_outline",
target_chapter=3, payload=FINE_OUTLINE_PAYLOAD)
confirm_section(fine["id"])
print(f"第3章细纲: section id={fine['id']} → confirmed")
print(f"\nCHAPTER3_ID={chapter_id}\nFINE_SECTION_ID={fine['id']}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,289 @@
#!/usr/bin/env python3
"""真写一章 · 阶段二:走完整生产链写下一章(默认写目标作品的下一章)。
生产链(与 persist_writer_run 文档登记的顺序一致):
读已确认细纲 + 前章正文基线
→ build_retrieval_plan + retrieve_writer_sources(生产仓储;新书无卡诚实返空)
→ assemble_context 冻结 WriterContext v1
→ run_writer_with_receipt(sandbox 隔离 + 冻结 profile + 运行号随上下文 →
runtime 自动把模型输入/输出原文与调用明细原子落库)
→ 篇幅越界自动重抽(最多三遍)
→ check_writer_candidate 机械门(锚点合同按章登记)
→ persist_writer_execution(上下文冻结 + 运行注册 + 候选 + 回执 + 质量评判一次落库)
→ confirm.accept 接受为正式正文
用法:.venv/bin/python docs/write-chapter/step2_write_chapter.py [目标章号]
缺省写下一章(库内最大章序 +1)。前置:该章已建且有 confirmed 细纲(镜像
step2_setup_chapter2.py 建章 + 落细纲),且门锚合同 GATE_ANCHORS 已登记该章。
"""
import hashlib
import json
import subprocess
import sys
import uuid
from datetime import datetime, timezone
from decimal import Decimal
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
AGENT_ROOT = SCRIPT_DIR.parents[1]
SKILLS = AGENT_ROOT / ".claude" / "skills"
for sub in ("read-context/scripts", "continuation/scripts", "runtime/scripts",
"db/scripts", "detect/scripts", "confirm/scripts"):
p = str(SKILLS / sub)
if p not in sys.path:
sys.path.insert(0, p)
from db import connect, DSN # noqa: E402
from assemble_writer_context import assemble_context # noqa: E402
from retrieve_writer_sources import ( # noqa: E402
ProductionCardIndexRepository, FrozenProseRepository, RetrievalError,
build_retrieval_plan, retrieve_writer_sources, load_confirmed_fine_outline,
load_confirmed_pattern_bindings, load_confirmed_style,
)
from run_writer import ( # noqa: E402
WriterAdapterError, build_writer_execution_profile,
calculate_dynamic_output_contract, run_writer_with_receipt,
)
from persist_llm_call import persist_call as persist_llm_event # noqa: E402
from persist_writer_run import persist_writer_execution # noqa: E402
from check_writer_candidate import check_writer_candidate # noqa: E402
from write_canonical import accept # noqa: E402
WORK_ID = 12
CLAUDE_BIN = "/Users/qingse/.nvm/versions/node/v24.15.0/bin/claude"
CLAUDE_SHA256 = "5a728a76198b6eca7f3c7cdbff43bab44b77b48c2108f7a3107d889773382629"
CLAUDE_VERSION = "2.1.211"
RESOLVED_MODEL_ID = "claude-opus-4-8[1m]"
MAX_ATTEMPTS = 3 # 篇幅越界重抽上限(编排层响应,对齐 replay 自纠环语义)
GATE_A_CONFIG = json.loads((SKILLS / "replay-eval" / "configs" /
"writer-gate-a-deep-space-v1.json").read_text(encoding="utf-8"))
GATE_A_WRITER = GATE_A_CONFIG["executionProfiles"]["writer"]
SYSTEM_PROMPT = GATE_A_WRITER["systemPrompt"]
SYSTEM_PROMPT_ID = GATE_A_WRITER["systemPromptId"]
SYSTEM_PROMPT_SHA256 = "sha256:" + hashlib.sha256(SYSTEM_PROMPT.encode("utf-8")).hexdigest()
assert SYSTEM_PROMPT_SHA256 == GATE_A_WRITER["systemPromptSha256"], "冻结提示词漂移,停止"
ARTIFACTS = SCRIPT_DIR / "artifacts"
# 门锚合同按章登记:锚点是章级创作判断,any-hit 子串匹配。新章必须先登记再跑。
GATE_ANCHORS = {
2: {
"requiredEvents": [
{"requirementId": "event-1-isolation",
"anchors": ["隔离", "收押", "禁闭", "关押", "封锁"]},
{"requirementId": "event-2-interrogation",
"anchors": ["审讯", "审问", "询问", "盘问", "讯问"]},
{"requirementId": "event-3-conceal",
"anchors": ["隐瞒", "没有告诉", "没说", "没有说", "咽了回去", "沉默", "闭上嘴"]},
{"requirementId": "event-4-hunger",
"anchors": ["饥饿", "渴望", "吞噬", "进食", "吃", "贪"]},
],
"requiredCharacters": ["林深", "何岚"],
"foreshadowingActions": [
{"requirementId": "foreshadow-upgrade",
"anchors": ["异种核心", "融合", "升级"]},
],
"chapterEndHook": {
"requirementId": "hook-ch2",
"anchors": ["调令", "实战", "出击", "部署", "任务", "出征", "离不开", "不愿离开"],
"maxDistanceFromEnd": 900,
},
},
3: {
# 接第2章结尾硬钩子(茧撕开舱门出击、要吃掉更强核心)。锚点 any-hit 子串匹配;
# requiredCharacters 只硬约束主角(避免过度约束触发 costly 重抽),其余靠事件锚点。
"requiredEvents": [
{"requirementId": "event-1-sortie",
"anchors": ["出击", "实战", "战斗", "交火", "搏杀", "拦截", "扑向", "战场"]},
{"requirementId": "event-2-devour",
"anchors": ["吞噬", "吞食", "吃掉", "进食", "撕碎", "吸收", "吞下", "吞"]},
{"requirementId": "event-3-upgrade",
"anchors": ["升级", "蜕变", "进化", "变强", "增强", "新的力量", "蜕变"]},
{"requirementId": "event-4-pollution",
"anchors": ["黑纹", "污染", "扩散", "蔓延", "加深", "恶化"]},
],
"requiredCharacters": ["林深"],
"foreshadowingActions": [
{"requirementId": "foreshadow-voice-merge",
"anchors": ["分不清", "像他自己", "脑内的声音", "低语", "渴望", "哪个念头", "另一个"]},
],
"chapterEndHook": {
"requirementId": "hook-ch3",
"anchors": ["深渊", "更深", "回应", "召唤", "更大", "下一", "不止", "饥饿", "注视", "凝视"],
"maxDistanceFromEnd": 900,
},
},
}
def make_logging_runner(log_path: Path):
"""透传 subprocess.run,只把 sandbox 调用现场原样留档(审计用,不影响落库)。"""
def runner(command, *, input, text, capture_output, timeout, check, cwd, env,
start_new_session):
record = {"command": list(command), "envKeys": sorted(env.keys()), "cwd": cwd,
"inputChars": len(input), "startedAt": datetime.now().isoformat()}
completed = subprocess.run(list(command), input=input, text=text,
capture_output=capture_output, timeout=timeout,
check=check, cwd=cwd, env=dict(env),
start_new_session=start_new_session)
record.update({"returncode": completed.returncode,
"stdout": completed.stdout or "", "stderr": completed.stderr or ""})
log_path.write_text(json.dumps(record, ensure_ascii=False, indent=1), encoding="utf-8")
return completed
return runner
def resolve_target_chapter(requested: int | None) -> int:
with connect(readonly=True) as conn:
if requested is not None:
return requested
row = conn.execute(
"SELECT COALESCE(MAX(order_no),0)+1 FROM muse_content_chapter "
"WHERE work_id=%s AND deleted=false", (WORK_ID,)).fetchone()
return int(row[0])
def main():
target = resolve_target_chapter(int(sys.argv[1]) if len(sys.argv) > 1 else None)
as_of = target - 1
run_id = f"run-prod-work12-ch{target}-{uuid.uuid4().hex[:8]}"
generated_at = datetime.now(timezone.utc).isoformat()
ARTIFACTS.mkdir(exist_ok=True)
if target not in GATE_ANCHORS:
raise SystemExit(f"第{target}章门锚合同未登记(GATE_ANCHORS),先登记锚点再跑。")
# 1) 已确认细纲(read-context 统一消费点)+ 前章全文基线(asOf 起连续四章;不足四章从第1章起)
with connect(readonly=True) as conn:
try:
fine_outline = load_confirmed_fine_outline(conn, work_id=WORK_ID, target_chapter=target)
except RetrievalError as exc:
raise SystemExit(f"{exc}(镜像 step2_setup_chapter2.py 建章并落细纲)")
first = max(1, as_of - 3)
recent_rows = conn.execute(
"SELECT c.order_no, b.id, b.revision, b.content_text FROM muse_content_chapter c "
"JOIN muse_content_block b ON b.chapter_id=c.id AND b.deleted=false "
"WHERE c.work_id=%s AND c.deleted=false AND c.order_no BETWEEN %s AND %s "
"ORDER BY c.order_no", (WORK_ID, first, as_of)).fetchall()
style_constraints = load_confirmed_style(conn, work_id=WORK_ID)
pattern_references = load_confirmed_pattern_bindings(conn, work_id=WORK_ID)
recent_chapters = [{
"chapter": order_no,
"sourceRef": {"sourceId": f"content-block:{block_id}",
"sourceVersion": f"rev{revision}",
"blockId": int(block_id), "chapter": order_no,
"startCodePoint": 0, "endCodePoint": len(body)},
"text": body,
} for order_no, block_id, revision, body in recent_rows]
expected = list(range(first, as_of + 1))
got = [item["chapter"] for item in recent_chapters]
if got != expected:
raise SystemExit(f"连续前章基线缺章:期望 {expected},实际 {got}。")
print(f"目标第{target}章(asOf={as_of});细纲已读,基线 {got},"
f"基线总字数 {sum(len(item['text']) for item in recent_chapters)}")
# 2) 检索计划 + 执行(生产仓储;新书无卡诚实返空)
token_budget = {"maxContextChars": 200000}
plan = build_retrieval_plan(
run_id=run_id, work_id=WORK_ID, target_chapter=target, as_of=as_of,
fine_outline=fine_outline, card_index_version="knowledge-index-v1",
prose_index_version="content-block-v1", token_budget=token_budget)
retrieval = retrieve_writer_sources(
plan=plan, card_repository=ProductionCardIndexRepository(),
prose_repository=FrozenProseRepository(dsn=DSN, tenant_id=0))
(ARTIFACTS / f"{run_id}-retrieval.json").write_text(json.dumps(
{"plan": plan, "resultCounts": {k: len(v) for k, v in retrieval.items()
if isinstance(v, list)}},
ensure_ascii=False, indent=1), encoding="utf-8")
print(f"检索: 卡={len(retrieval['cards'])} 事实={len(retrieval['factEvidence'])} "
f"原文={len(retrieval['proseEvidence'])}")
# 3) 组装冻结 WriterContext v1
output_contract = calculate_dynamic_output_contract(
fine_outline=fine_outline,
recent_chapter_bodies=[item["text"] for item in recent_chapters])
narrative_state = {
"time": f"第{as_of}章结束后",
"location": "承接上一章结尾的场景",
"characterPositions": {},
"immediateSituation": "按细纲 chapterGoal 展开(上一章结尾状态见基线正文)。",
}
authorization_snapshot = {"snapshotId": "auth-work12-production-v1",
"allowedPurpose": "production_generation",
"verifiedAt": generated_at, "sourceVersion": "v1"}
assembled = assemble_context(
run_id=run_id, attempt=1, mode="production", purpose="production",
quality_policy_version="writer-production-v1", work_id=WORK_ID,
target_chapter=target, as_of=as_of, source_version="outline@v1",
authorization_snapshot=authorization_snapshot, source_status="active",
retrieval_plan=plan, retrieval_result=retrieval, fine_outline=fine_outline,
narrative_state=narrative_state, recent_chapters=recent_chapters,
output_contract=output_contract, token_budget=token_budget,
pattern_references=pattern_references, style_constraints=style_constraints,
generated_at=generated_at,
evidence_strategy="production_dual_evidence")
writer_context = assembled["context"]
(ARTIFACTS / f"{run_id}-writer-context.json").write_text(
assembled["contextJson"], encoding="utf-8")
print(f"上下文冻结: contextSha256={writer_context['contextSnapshot']['contextSha256'][:24]}... "
f"篇幅合同 {output_contract['minChars']}-{output_contract['maxChars']}"
f"(目标 {output_contract['targetChars']}),范式绑定 {len(pattern_references)} 张,"
f"文风约束 {len(style_constraints)} 条")
# 4) writer 真调(运行号随上下文 → runtime 自动落模型输入输出与调用明细);越界重抽
profile = build_writer_execution_profile(
claude_executable_path=CLAUDE_BIN, claude_executable_sha256=CLAUDE_SHA256,
claude_cli_version=CLAUDE_VERSION, resolved_model_id=RESOLVED_MODEL_ID,
effort="high", max_budget_usd_per_call=Decimal("5.0"), timeout_seconds=1200,
max_context_chars=token_budget["maxContextChars"], system_prompt=SYSTEM_PROMPT)
candidate = receipt = None
for attempt in range(1, MAX_ATTEMPTS + 1):
try:
candidate, receipt = run_writer_with_receipt(
writer_context, profile=profile, candidate_version=1,
runner=make_logging_runner(ARTIFACTS / f"{run_id}-sandbox-call-{attempt}.json"),
persist_call=persist_llm_event)
break
except WriterAdapterError as exc:
if exc.code != "candidate_length_out_of_range" or attempt == MAX_ATTEMPTS:
raise
print(f" 第{attempt}遍越界({exc.details.get('actualHanChars')} 字),重抽……")
# 5) 机械门
gate = check_writer_candidate(writer_context, candidate, GATE_ANCHORS[target])
print(f"writer 产出: sha256={candidate['candidateSha256'][:24]}..., "
f"实际模型={receipt.actual_model_id}, 成本=${receipt.total_cost_usd}")
print(f"机械门: passed={gate['passed']} "
f"blocking={[f['code'] for f in gate.get('blockingFailures', [])]}")
(ARTIFACTS / f"{run_id}-gate-report.json").write_text(
json.dumps(gate, ensure_ascii=False, indent=1), encoding="utf-8")
# 6) 一次落库:冻结 + 运行注册 + 候选 + 回执 + 质量评判(persist_writer_execution 单通道)
persisted = persist_writer_execution(
writer_context, candidate, receipt, gate, assemble_result=assembled)
cand_id = persisted["candidate_id"]
print(f"落库: candidate_id={cand_id}, receipt_id={persisted['receipt_id']}, "
f"raw_content_id={persisted['raw_content_id']}, state={persisted['state']}")
if not gate["passed"]:
print("[停止] 机械门未通过,候选留库 rejected,不接受为正文。复核 artifacts/"
f"{run_id}-gate-report.json 后决定下一步。")
print(f"\nCANDIDATE_ID={cand_id}\nRUN_ID={run_id}")
raise SystemExit(1)
# 7) 接受为正式正文(confirm 唯一通道)——新章无正文块,expected_revision=0
accept_result = accept(
cand_id, decided_by="1",
rationale=(f"第{target}章生产路径:冻结上下文(asOf={as_of}) → writer sandbox 真调 → "
"机械门锚点检查通过。语义 detector 未跑(本轮链路到机械门为止)。"),
basis_ref=f"细纲@v1 (第{target}章 confirmed) + 前章正文基线",
expected_revision=0, command_id=f"accept-work12-ch{target}-{run_id}")
print(f"接受结果: {json.dumps(accept_result, ensure_ascii=False)}")
print(f"\nCANDIDATE_ID={cand_id}\nRUN_ID={run_id}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,44 @@
target_type: fine_outline
中文名: 章级细纲
domain: content
scope: chapter
本体分组: 作品骨架
状态: 启用
判据: 单章的结构骨架(续写的直接依据) ‖ 全书/卷层计划→outline;戏内已定事实→event;跨作品写法公式→trope
实例落点: example_planning_section(section_type=fine_outline;schema_type=fine_outline;必须带 target_chapter;shadow→confirmed 才进生成上下文)
# 本文件是章级细纲 payload 的唯一字段权威。planning 技能(fine-outline SKILL)按此形状产出;
# persist_planning 落库时按此做字段覆盖门禁;assemble_writer_context 从中抽取 writer 需要的子集。
# 三处同一合同;结束此前「技能产出形」与「装配消费形」字段不相交的漂移。
# 字段说明文字一律避开 ASCII 逗号与花括号(流式值里它们是 YAML 结构符);用中文顿号/分号/圆括号。
字段覆盖门禁: 强制 # persist_planning 落库时按 必填字段 失败关闭;推荐字段只报告不拦
必填字段: [targetChapter, sourceRef, chapterGoal, hardConstraints, keyEvents, adjustableBeats, declaredNewFacts, mustAppearEntities, chapterEndHook]
特有字段:
# ── 必填:writer 装配与机械门的硬依赖 ──
- { key: targetChapter, 说明: 目标章号(正整数), aiContext: true }
- { key: sourceRef, 说明: 不可变来源引用(含 sourceId 与 sourceVersion);指向本细纲依据的已确认大纲版本, aiContext: true }
- { key: chapterGoal, 说明: 本章要完成的戏剧任务(字符串), aiContext: true }
- { key: hardConstraints, 说明: 硬约束字符串数组;衔接与设定与不可逆事实;机械门硬事件锚点的来源;不可删除·反转·提前回收, aiContext: true }
- keyEvents:
说明: 关键事件数组;规范形为因果对象(含 id·order·event·participants·trigger·resultDirection;触发·参与者·行动·结果方向须互相解释);兼容纯字符串旧形(书12 ch1);新细纲用对象形
aiContext: true
- { key: adjustableBeats, 说明: 可调整节拍字符串数组;允许 writer 在不变硬约束下自由处理的次序与比重, aiContext: true }
- declaredNewFacts:
说明: 本章宣告的新事实数组(每项含 factId·text·sourceRef);合同形状(非纯字符串);落库为 fine_outline_declared_new 事实证据
aiContext: true
- { key: mustAppearEntities, 说明: 必须出场实体数组(字符串或 id/type/name 对象);出场校验与覆盖审计的依据;门锚收敛目标(现按章登记于生产脚本 GATE_ANCHORS), aiContext: true }
- { key: chapterEndHook, 说明: 章末钩子(字符串);章末锚点匹配的依据;门锚收敛目标(现按章登记于生产脚本 GATE_ANCHORS), aiContext: true }
# ── 推荐:规划表达力;字段覆盖门禁会检查;允许显式「字段存疑」标注后落库 ──
- foreshadowing:
说明: 伏笔动作数组(每项含 action·subject·evidence;action 取 埋/推进/回收/不确定;evidence 为来源ID或 unknown);不得把未证明的结果静默写成确定事实
aiContext: true
- { key: entities, 说明: 出场实体数组(含 name·type·role);带本章作用;供覆盖审计;与 mustAppearEntities 分工(本字段给作用;前者给硬出场名单), aiContext: true }
- { key: stateChanges, 说明: 可观察的状态变化字符串数组, aiContext: true }
- { key: unknowns, 说明: 无法由当前资料证明的内容字符串数组;审查据此区分资料缺失与规划错误, aiContext: [planning, detection] }
- { key: assumptions, 说明: 为组织结构暂时采用的假设字符串数组, aiContext: [planning, detection] }
- { key: sourceRefs, 说明: 可选回溯;冻结快照中的 sourceId 数组;只能引用 sourceId 不能粘原文, aiContext: false }
- { key: targetChars, 说明: 可选本章目标汉字数;未提供时按目标章前有效章长中位数×细纲密度确定性计算;仍受 2000–10000 硬边界, aiContext: [generation] }
信息量纪律:
- 章细纲字数≈章正文 3–5%(3000 字章→100–150 字);细纲是结构骨架不是缩写;超比例=退回重做(创始人已拍板@2026-07-09)
设计发现:
- 2026-08-02 统一合同——此前章级细纲有两套字段集不相交的合同:planning 技能产出形(chapterGoal/keyEvents因果对象/entities/foreshadowing埋推收/stateChanges/hook/unknowns/assumptions/sourceRefs)与 writer 装配消费形(sourceRef/hardConstraints/adjustableBeats/declaredNewFacts;见 assemble_writer_context._outline_contract)。planner 按技能形产出则装配直接 AssemblyError。本文件取两者并集为唯一权威:必填集满足装配与机械门;推荐集保留规划表达力;hook 统一为 chapterEndHook。书12 已确认细纲(旧字符串 keyEvents;schema_type=outline)为冻结历史;不溯及重校验。
- 同日——schema_type 归属:新细纲用 schema_type=fine_outline 落库以触发字段覆盖门禁;书12 存量 schema_type=outline 的细纲为历史形状。