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

修五个硬伤,让"前期准备"从文档纪律变成机械门禁,并补齐写作链上游注入:
- 细纲合同合一:新建 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 ```yaml
targetChapter: 489 targetChapter: 489
sourceRef: { sourceId: "outline:volume_1", sourceVersion: "v1" }
chapterGoal: "本章要完成的戏剧任务" chapterGoal: "本章要完成的戏剧任务"
hardConstraints:
- "衔接上一章结尾的不可逆事实;设定约束;不可删除/反转/提前回收"
keyEvents: keyEvents:
- id: event-1 - id: event-1
order: 1 order: 1
@ -42,22 +47,35 @@ keyEvents:
participants: ["实体"] participants: ["实体"]
trigger: "触发因果" trigger: "触发因果"
resultDirection: "结果方向" 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: entities:
- name: "实体" - name: "实体"
type: character type: character
role: "本章作用" role: "本章作用"
foreshadowing:
- action: 推进
subject: "伏笔"
evidence: "来源 ID 或 unknown"
stateChanges: ["可观察的状态变化"] stateChanges: ["可观察的状态变化"]
hook: "章末钩子"
unknowns: ["无法由当前资料证明的内容"] unknowns: ["无法由当前资料证明的内容"]
assumptions: ["为组织结构暂时采用的假设"] assumptions: ["为组织结构暂时采用的假设"]
sourceRefs: ["冻结快照中的 sourceId"] 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 #!/usr/bin/env python3
"""细纲功能合同的离线回归测试,不启动模型、不访问作品数据。""" """细纲功能合同的离线回归测试,不启动模型、不访问作品数据。
字段权威 = meta/schemas/fine_outline.yaml;本测试同时校验技能文档(产出侧)与
该 schema(字段权威)声明的必填/推荐字段一致,防止两者再次漂移。
"""
from pathlib import Path from pathlib import Path
import unittest 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") SKILL = (ROOT / ".claude/skills/fine-outline/SKILL.md").read_text(encoding="utf-8")
PLANNER = (ROOT / ".claude/agents/planner.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") 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): class FineOutlineContractTest(unittest.TestCase):
@ -18,19 +45,24 @@ class FineOutlineContractTest(unittest.TestCase):
self.assertIn("fine_outline", CHAINS) self.assertIn("fine_outline", CHAINS)
self.assertIn("规划→planner", CHAINS) self.assertIn("规划→planner", CHAINS)
def test_candidate_fields_and_shadow_boundary_are_explicit(self): def test_skill_cites_schema_as_field_authority(self):
for field in ( # 技能文档必须把字段权威指向 schema,不再自持一套字段集。
"targetChapter", self.assertIn("meta/schemas/fine_outline.yaml", SKILL)
"chapterGoal", self.assertIn("字段权威", SCHEMA)
"keyEvents",
"entities", def test_required_fields_present_in_skill_and_schema(self):
"foreshadowing", for field in REQUIRED_FIELDS:
"stateChanges", self.assertIn(field, SKILL, f"技能文档缺必填字段 {field}")
"hook", self.assertIn(field, SCHEMA, f"schema 缺必填字段 {field}")
"unknowns", # hook 已统一为 chapterEndHook,不应再以裸 hook 作字段名出现于必填纪律。
"assumptions", self.assertIn("chapterEndHook", SKILL)
):
self.assertIn(field, 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("不得输出正文", SKILL)
self.assertIn("Shadow", SKILL) self.assertIn("Shadow", SKILL)
self.assertIn("目标章细纲作为生成输入", SKILL) self.assertIn("目标章细纲作为生成输入", SKILL)

View File

@ -13,6 +13,8 @@ import json
import sys import sys
from pathlib import Path from pathlib import Path
import yaml
# 复用 db skill 锁死的 DSN(.claude/skills/db/scripts) # 复用 db skill 锁死的 DSN(.claude/skills/db/scripts)
DB_SCRIPTS = Path(__file__).resolve().parents[2] / "db" / "scripts" DB_SCRIPTS = Path(__file__).resolve().parents[2] / "db" / "scripts"
sys.path.insert(0, str(DB_SCRIPTS)) sys.path.insert(0, str(DB_SCRIPTS))
@ -20,6 +22,80 @@ from db import connect # noqa: E402
CREATOR = "planning" CREATOR = "planning"
SECTION_TYPES = ("setting", "outline", "state", "assembly", "fine_outline") 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, 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") raise ValueError("fine_outline 是章级 section,必须给 target_chapter")
if not isinstance(payload, dict) or not payload: if not isinstance(payload, dict) or not payload:
raise ValueError("payload 必须是非空 JSON 对象(schema 字段结构化内容)") 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: with connect() as conn:
try: try:
row = conn.execute( 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() json.dumps(payload, ensure_ascii=False), version, state, creator)).fetchone()
if dry_run: if dry_run:
conn.rollback() 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() conn.commit()
return {"status": "written", "id": row[0], "state": row[1]} return {"status": "written", "id": row[0], "state": row[1], "fieldCoverage": coverage}
except Exception: except Exception:
conn.rollback() conn.rollback()
raise 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], output_contract: Mapping[str, Any],
token_budget: Mapping[str, int], token_budget: Mapping[str, int],
pattern_references: Sequence[Mapping[str, Any]] = (), pattern_references: Sequence[Mapping[str, Any]] = (),
style_constraints: Sequence[str] = (),
generated_at: str, generated_at: str,
evidence_strategy: str = "production_dual_evidence", evidence_strategy: str = "production_dual_evidence",
) -> dict[str, Any]: ) -> dict[str, Any]:
@ -613,7 +614,9 @@ def assemble_context(
def make_context() -> dict[str, Any]: 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( ordered_prose = sorted(
selected_prose, selected_prose,
key=lambda item: ( key=lambda item: (
@ -664,6 +667,8 @@ def assemble_context(
"omittedSources": sorted(copy.deepcopy(omitted), key=lambda item: (item["reason"], item["sourceId"])), "omittedSources": sorted(copy.deepcopy(omitted), key=lambda item: (item["reason"], item["sourceId"])),
"acceptanceEligible": mode == "production" and purpose == "production", "acceptanceEligible": mode == "production" and purpose == "production",
} }
if style_rules:
context["styleConstraints"] = list(style_rules)
# usedContextChars 自身位数会影响 JSON 长度,迭代到数值稳定。 # usedContextChars 自身位数会影响 JSON 长度,迭代到数值稳定。
for _ in range(8): for _ in range(8):
used = _context_size(context) used = _context_size(context)

View File

@ -10,6 +10,7 @@ from __future__ import annotations
import copy import copy
import hashlib import hashlib
import json
import pathlib import pathlib
import sys import sys
from typing import Any, Callable, Mapping, Protocol, Sequence from typing import Any, Callable, Mapping, Protocol, Sequence
@ -63,6 +64,18 @@ def _chapter(value: Any, path: str) -> int:
return value 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: def _milestone_chapter(value: Mapping[str, Any]) -> int:
"""兼容实验库中英文里程碑章号字段,但不解析模糊文本。""" """兼容实验库中英文里程碑章号字段,但不解析模糊文本。"""
@ -142,7 +155,7 @@ def build_retrieval_plan(
"""从已确认细纲确定查询集合,并在执行前冻结计划身份。""" """从已确认细纲确定查询集合,并在执行前冻结计划身份。"""
target = _chapter(target_chapter, "targetChapter") target = _chapter(target_chapter, "targetChapter")
freeze = _chapter(as_of, "asOf") freeze = _freeze_chapter(as_of, "asOf")
if freeze >= target: if freeze >= target:
raise RetrievalError("asOf 必须早于 targetChapter") raise RetrievalError("asOf 必须早于 targetChapter")
if not isinstance(work_id, int) or isinstance(work_id, bool) or work_id <= 0: 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]: 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") milestones = card.get("milestones")
if not isinstance(milestones, list): if not isinstance(milestones, list):
raise RetrievalError(f"卡 {card.get('cardId')} 缺少历史里程碑") 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() 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]: def _index_hint(card: Mapping[str, Any], *, as_of: int) -> dict[str, Any]:
"""把冻结卡投影成稳定索引提示,是否存在原文指针不影响卡进入索引。""" """把冻结卡投影成稳定索引提示,是否存在原文指针不影响卡进入索引。"""
@ -457,7 +563,7 @@ def retrieve_writer_sources(
) -> dict[str, Any]: ) -> 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") work_id = plan.get("filters", {}).get("workId")
if not isinstance(work_id, int) or isinstance(work_id, bool) or work_id <= 0: if not isinstance(work_id, int) or isinstance(work_id, bool) or work_id <= 0:
raise RetrievalError("plan.filters.workId 非法") raise RetrievalError("plan.filters.workId 非法")
@ -566,5 +672,6 @@ def retrieve_writer_sources(
__all__ = [ __all__ = [
"RetrievalError", "CardIndexRepository", "ProseRepository", "ProductionCardIndexRepository", "RetrievalError", "CardIndexRepository", "ProseRepository", "ProductionCardIndexRepository",
"ReplayCardIndexRepository", "FrozenProseRepository", "stable_sort_cards", "freeze_card", "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) 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__": if __name__ == "__main__":
unittest.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") _hash(plan["planId"], f"{path}.planId")
if not _RUN_ID_RE.fullmatch(_string(plan["runId"], f"{path}.runId")): if not _RUN_ID_RE.fullmatch(_string(plan["runId"], f"{path}.runId")):
raise ContractError(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")): for index, query in enumerate(_array(plan["queries"], f"{path}.queries")):
item = _object(query, f"{path}.queries[{index}]", frozenset({"queryId", "text", "entityTypes", "purpose", "topK"})) item = _object(query, f"{path}.queries[{index}]", frozenset({"queryId", "text", "entityTypes", "purpose", "topK"}))
_string(item["queryId"], f"{path}.queries[{index}].queryId") _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") _string(plan["proseIndexVersion"], f"{path}.proseIndexVersion")
filters = _object(plan["filters"], f"{path}.filters", frozenset({"workId", "asOfChapter", "sourceStatus", "authorizationRequired"})) filters = _object(plan["filters"], f"{path}.filters", frozenset({"workId", "asOfChapter", "sourceStatus", "authorizationRequired"}))
_integer(filters["workId"], f"{path}.filters.workId", minimum=1) _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") _string(filters["sourceStatus"], f"{path}.filters.sourceStatus")
_boolean(filters["authorizationRequired"], f"{path}.filters.authorizationRequired") _boolean(filters["authorizationRequired"], f"{path}.filters.authorizationRequired")
if plan["tieBreak"] != TIE_BREAK: if plan["tieBreak"] != TIE_BREAK:
@ -438,7 +439,7 @@ def validate_writer_context(value: Any) -> dict[str, Any]:
value, value,
"$", "$",
required, required,
frozenset({"evidenceStrategy", "indexHints"}), frozenset({"evidenceStrategy", "indexHints", "styleConstraints"}),
) )
if context["schemaVersion"] != CONTEXT_VERSION: if context["schemaVersion"] != CONTEXT_VERSION:
raise ContractError("$.schemaVersion 版本不支持") raise ContractError("$.schemaVersion 版本不支持")
@ -463,7 +464,8 @@ def validate_writer_context(value: Any) -> dict[str, Any]:
_string(context["qualityPolicyVersion"], "$.qualityPolicyVersion") _string(context["qualityPolicyVersion"], "$.qualityPolicyVersion")
_integer(context["workId"], "$.workId", minimum=1) _integer(context["workId"], "$.workId", minimum=1)
target = _integer(context["targetChapter"], "$.targetChapter", 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: if as_of >= target:
raise ContractError("$.asOf 必须早于 targetChapter") 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")): for index, reference in enumerate(_array(context["patternReferences"], "$.patternReferences")):
# 范式引用走放宽校验:来源指针仍严格,另允许 name/summary/writingPoints 内容字段。 # 范式引用走放宽校验:来源指针仍严格,另允许 name/summary/writingPoints 内容字段。
_pattern_source_ref(reference, f"$.patternReferences[{index}]") _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")): for index, coverage in enumerate(_array(context["evidenceCoverage"], "$.evidenceCoverage")):
item = _object(coverage, f"$.evidenceCoverage[{index}]", frozenset({"elementId", "elementType", "name", "status", "factEvidenceIds", "proseEvidenceIds", "gapReason"})) item = _object(coverage, f"$.evidenceCoverage[{index}]", frozenset({"elementId", "elementType", "name", "status", "factEvidenceIds", "proseEvidenceIds", "gapReason"}))
for field in ("elementId", "elementType", "name", "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"], "maxChars": output_contract["maxChars"],
"frontmatterRequired": output_contract["frontmatterRequired"], "frontmatterRequired": output_contract["frontmatterRequired"],
}, },
"styleConstraints": [], # 文风约束从冻结上下文投影(规划期选定的 style 画像);上下文未带则为空,
# 不再写死恒空——style 真注入的出口。
"styleConstraints": [str(rule) for rule in context.get("styleConstraints", [])],
} }
return json.loads(canonical_json(creative_input)) return json.loads(canonical_json(creative_input))

View File

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