框架: 前期创作流程代码——细纲统一合同+字段覆盖门禁+文风/范式注入+取数端统一
修五个硬伤,让"前期准备"从文档纪律变成机械门禁,并补齐写作链上游注入: - 细纲合同合一:新建 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:
parent
93415a0930
commit
952485a506
@ -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`(硬出场名单,供机械门)分工,不互相替代。
|
||||||
|
|
||||||
## 回放专用红线
|
## 回放专用红线
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
103
.claude/skills/planning/scripts/test_field_coverage.py
Normal file
103
.claude/skills/planning/scripts/test_field_coverage.py
Normal 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()
|
||||||
@ -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)
|
||||||
|
|||||||
@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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()
|
||||||
@ -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()
|
||||||
@ -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()
|
||||||
72
.claude/skills/read-context/scripts/test_style_loader.py
Normal file
72
.claude/skills/read-context/scripts/test_style_loader.py
Normal 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()
|
||||||
@ -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))
|
||||||
|
|
||||||
|
|||||||
@ -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. 评测结论与验证入口
|
||||||
|
|||||||
110
docs/write-chapter/step2_setup_chapter3.py
Normal file
110
docs/write-chapter/step2_setup_chapter3.py
Normal 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()
|
||||||
289
docs/write-chapter/step2_write_chapter.py
Normal file
289
docs/write-chapter/step2_write_chapter.py
Normal 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()
|
||||||
44
meta/schemas/fine_outline.yaml
Normal file
44
meta/schemas/fine_outline.yaml
Normal 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 的细纲为历史形状。
|
||||||
Loading…
x
Reference in New Issue
Block a user