From 952485a50645aabefbca26b713549cc7804de763 Mon Sep 17 00:00:00 2001 From: zizi Date: Sun, 2 Aug 2026 02:17:14 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A1=86=E6=9E=B6:=20=E5=89=8D=E6=9C=9F?= =?UTF-8?q?=E5=88=9B=E4=BD=9C=E6=B5=81=E7=A8=8B=E4=BB=A3=E7=A0=81=E2=80=94?= =?UTF-8?q?=E2=80=94=E7=BB=86=E7=BA=B2=E7=BB=9F=E4=B8=80=E5=90=88=E5=90=8C?= =?UTF-8?q?+=E5=AD=97=E6=AE=B5=E8=A6=86=E7=9B=96=E9=97=A8=E7=A6=81+?= =?UTF-8?q?=E6=96=87=E9=A3=8E/=E8=8C=83=E5=BC=8F=E6=B3=A8=E5=85=A5+?= =?UTF-8?q?=E5=8F=96=E6=95=B0=E7=AB=AF=E7=BB=9F=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修五个硬伤,让"前期准备"从文档纪律变成机械门禁,并补齐写作链上游注入: - 细纲合同合一:新建 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)。 --- .claude/skills/fine-outline/SKILL.md | 32 +- .../fine-outline/scripts/test_contract.py | 60 +++- .../planning/scripts/persist_planning.py | 88 +++++- .../planning/scripts/test_field_coverage.py | 103 +++++++ .../scripts/assemble_writer_context.py | 7 +- .../scripts/retrieve_writer_sources.py | 115 ++++++- .../scripts/test_assemble_writer_context.py | 29 ++ .../scripts/test_fine_outline_reader.py | 66 ++++ .../scripts/test_fine_outline_unification.py | 84 +++++ .../scripts/test_pattern_binding_reader.py | 74 +++++ .../read-context/scripts/test_style_loader.py | 72 +++++ .../read-context/scripts/writer_contract.py | 21 +- AGENTS.md | 1 + docs/write-chapter/step2_setup_chapter3.py | 110 +++++++ docs/write-chapter/step2_write_chapter.py | 289 ++++++++++++++++++ meta/schemas/fine_outline.yaml | 44 +++ 16 files changed, 1162 insertions(+), 33 deletions(-) create mode 100644 .claude/skills/planning/scripts/test_field_coverage.py create mode 100644 .claude/skills/read-context/scripts/test_fine_outline_reader.py create mode 100644 .claude/skills/read-context/scripts/test_fine_outline_unification.py create mode 100644 .claude/skills/read-context/scripts/test_pattern_binding_reader.py create mode 100644 .claude/skills/read-context/scripts/test_style_loader.py create mode 100644 docs/write-chapter/step2_setup_chapter3.py create mode 100644 docs/write-chapter/step2_write_chapter.py create mode 100644 meta/schemas/fine_outline.yaml diff --git a/.claude/skills/fine-outline/SKILL.md b/.claude/skills/fine-outline/SKILL.md index 071bc72..dfab5b2 100644 --- a/.claude/skills/fine-outline/SKILL.md +++ b/.claude/skills/fine-outline/SKILL.md @@ -30,11 +30,16 @@ disable-model-invocation: true ## 输出合同 -候选必须是一个结构化对象,字段完整且顺序稳定: +字段权威 = [`meta/schemas/fine_outline.yaml`](../../../meta/schemas/fine_outline.yaml);本节是该合同的产出侧说明,字段集以 yaml 为准。候选必须是一个结构化对象,字段完整且顺序稳定。 + +**必填**(writer 装配与机械门的硬依赖,缺一不得落库、不得装配): ```yaml targetChapter: 489 +sourceRef: { sourceId: "outline:volume_1", sourceVersion: "v1" } chapterGoal: "本章要完成的戏剧任务" +hardConstraints: + - "衔接上一章结尾的不可逆事实;设定约束;不可删除/反转/提前回收" keyEvents: - id: event-1 order: 1 @@ -42,22 +47,35 @@ keyEvents: participants: ["实体"] trigger: "触发因果" resultDirection: "结果方向" +adjustableBeats: + - "允许 writer 自由处理的次序与比重(不改硬约束)" +declaredNewFacts: + - factId: "fact-chN-1" + text: "本章宣告的新事实" + sourceRef: { sourceId: "outline:volume_1", sourceVersion: "v1" } +mustAppearEntities: ["实体名"] +chapterEndHook: "章末钩子" +``` + +**推荐**(规划表达力;落库字段覆盖门禁会检查,确无依据时显式标注「字段存疑:原因」): + +```yaml +foreshadowing: + - action: 推进 # 埋 | 推进 | 回收 | 不确定 + subject: "伏笔" + evidence: "来源 ID 或 unknown" entities: - name: "实体" type: character role: "本章作用" -foreshadowing: - - action: 推进 - subject: "伏笔" - evidence: "来源 ID 或 unknown" stateChanges: ["可观察的状态变化"] -hook: "章末钩子" unknowns: ["无法由当前资料证明的内容"] assumptions: ["为组织结构暂时采用的假设"] sourceRefs: ["冻结快照中的 sourceId"] +targetChars: 4000 # 可选,受 2000–10000 硬边界 ``` -`sourceRefs` 是可选的回溯字段,但存在时只能引用快照登记的 `sourceId`,不能粘贴原文。`keyEvents`、`entities`、`foreshadowing`、`stateChanges`、`unknowns`、`assumptions` 必须是数组;`chapterGoal` 和 `hook` 必须是字符串。 +字段纪律:`hardConstraints`、`adjustableBeats`、`keyEvents`、`declaredNewFacts`、`mustAppearEntities`、`foreshadowing`、`entities`、`stateChanges`、`unknowns`、`assumptions` 必须是数组;`chapterGoal` 和 `chapterEndHook` 必须是字符串;`sourceRef` 必须是含 `sourceId/sourceVersion` 的对象。`keyEvents` 规范形为因果对象(触发·参与者·行动·结果方向须互相解释),兼容纯字符串旧形,但新细纲一律用对象形。`declaredNewFacts` 是 `{factId,text,sourceRef}` 对象,不是纯字符串。`sourceRefs` 可选,存在时只能引用快照登记的 `sourceId`,不能粘贴原文。`entities`(带本章作用,供覆盖审计)与 `mustAppearEntities`(硬出场名单,供机械门)分工,不互相替代。 ## 回放专用红线 diff --git a/.claude/skills/fine-outline/scripts/test_contract.py b/.claude/skills/fine-outline/scripts/test_contract.py index 26d1224..24b9e6a 100644 --- a/.claude/skills/fine-outline/scripts/test_contract.py +++ b/.claude/skills/fine-outline/scripts/test_contract.py @@ -1,5 +1,9 @@ #!/usr/bin/env python3 -"""细纲功能合同的离线回归测试,不启动模型、不访问作品数据。""" +"""细纲功能合同的离线回归测试,不启动模型、不访问作品数据。 + +字段权威 = meta/schemas/fine_outline.yaml;本测试同时校验技能文档(产出侧)与 +该 schema(字段权威)声明的必填/推荐字段一致,防止两者再次漂移。 +""" from pathlib import Path import unittest @@ -9,6 +13,29 @@ ROOT = Path(__file__).resolve().parents[4] SKILL = (ROOT / ".claude/skills/fine-outline/SKILL.md").read_text(encoding="utf-8") PLANNER = (ROOT / ".claude/agents/planner.md").read_text(encoding="utf-8") CHAINS = (ROOT / "meta/chains/README.md").read_text(encoding="utf-8") +SCHEMA = (ROOT / "meta/schemas/fine_outline.yaml").read_text(encoding="utf-8") + +# 与 meta/schemas/fine_outline.yaml 的必填/推荐集保持一致。 +REQUIRED_FIELDS = ( + "targetChapter", + "sourceRef", + "chapterGoal", + "hardConstraints", + "keyEvents", + "adjustableBeats", + "declaredNewFacts", + "mustAppearEntities", + "chapterEndHook", +) +RECOMMENDED_FIELDS = ( + "foreshadowing", + "entities", + "stateChanges", + "unknowns", + "assumptions", + "sourceRefs", + "targetChars", +) class FineOutlineContractTest(unittest.TestCase): @@ -18,19 +45,24 @@ class FineOutlineContractTest(unittest.TestCase): self.assertIn("fine_outline", CHAINS) self.assertIn("规划→planner", CHAINS) - def test_candidate_fields_and_shadow_boundary_are_explicit(self): - for field in ( - "targetChapter", - "chapterGoal", - "keyEvents", - "entities", - "foreshadowing", - "stateChanges", - "hook", - "unknowns", - "assumptions", - ): - self.assertIn(field, SKILL) + def test_skill_cites_schema_as_field_authority(self): + # 技能文档必须把字段权威指向 schema,不再自持一套字段集。 + self.assertIn("meta/schemas/fine_outline.yaml", SKILL) + self.assertIn("字段权威", SCHEMA) + + def test_required_fields_present_in_skill_and_schema(self): + for field in REQUIRED_FIELDS: + self.assertIn(field, SKILL, f"技能文档缺必填字段 {field}") + self.assertIn(field, SCHEMA, f"schema 缺必填字段 {field}") + # hook 已统一为 chapterEndHook,不应再以裸 hook 作字段名出现于必填纪律。 + self.assertIn("chapterEndHook", SKILL) + + def test_recommended_fields_present_in_skill_and_schema(self): + for field in RECOMMENDED_FIELDS: + self.assertIn(field, SKILL, f"技能文档缺推荐字段 {field}") + self.assertIn(field, SCHEMA, f"schema 缺推荐字段 {field}") + + def test_shadow_boundary_and_red_lines_are_explicit(self): self.assertIn("不得输出正文", SKILL) self.assertIn("Shadow", SKILL) self.assertIn("目标章细纲作为生成输入", SKILL) diff --git a/.claude/skills/planning/scripts/persist_planning.py b/.claude/skills/planning/scripts/persist_planning.py index dea1dfb..9cdc4e3 100644 --- a/.claude/skills/planning/scripts/persist_planning.py +++ b/.claude/skills/planning/scripts/persist_planning.py @@ -13,6 +13,8 @@ import json import sys from pathlib import Path +import yaml + # 复用 db skill 锁死的 DSN(.claude/skills/db/scripts) DB_SCRIPTS = Path(__file__).resolve().parents[2] / "db" / "scripts" sys.path.insert(0, str(DB_SCRIPTS)) @@ -20,6 +22,80 @@ from db import connect # noqa: E402 CREATOR = "planning" SECTION_TYPES = ("setting", "outline", "state", "assembly", "fine_outline") +# 字段权威(git 侧设计稿):落库字段覆盖门禁按 schema_type 读这里的 <型>.yaml。 +# persist_planning.py 在 .claude/skills/planning/scripts/ 下,仓库根是 parents[4]。 +SCHEMAS_DIR = Path(__file__).resolve().parents[4] / "meta" / "schemas" + + +def _load_schema(schema_type): + """从 meta/schemas/.yaml 读字段清单、必填集与门禁档位。 + + 返回 (fields, required, enforce): + fields = {字段名: [别名...]}(特有字段全集,含推荐); + required= 必填字段名列表(只有这些参与失败关闭); + enforce = 该型是否「字段覆盖门禁: 强制」。 + schema 文件不存在或非法 → (None, [], False),门禁跳过(该型暂未纳入覆盖校验, + 如中文键的老 schema 与英文 payload 尚未对齐,不溯及强拦)。 + """ + if not schema_type: + return None, [], False + path = SCHEMAS_DIR / f"{schema_type}.yaml" + if not path.exists(): + return None, [], False + try: + doc = yaml.safe_load(path.read_text(encoding="utf-8")) + except yaml.YAMLError: + return None, [], False + if not isinstance(doc, dict): + return None, [], False + fields = {} + for item in doc.get("特有字段", []) or []: + if not isinstance(item, dict) or not item: + continue + if "key" in item: # 流式 - { key: X, 说明:..., aliases:[...] } + name = str(item["key"]) + aliases = [str(a) for a in (item.get("aliases", []) or [])] + elif len(item) == 1: # 块式 - X:\n 说明:... (单键映射,键即字段名) + name = next(iter(item)) + sub = item[name] + aliases = [str(a) for a in (sub.get("aliases", []) or [])] if isinstance(sub, dict) else [] + else: + continue + fields[name] = aliases + required = [str(x) for x in (doc.get("必填字段", []) or [])] + enforce = str(doc.get("字段覆盖门禁", "")).strip() == "强制" + return fields, required, enforce + + +def check_field_coverage(schema_type, payload): + """机械校验 payload 对 schema 字段的覆盖(「字段全覆盖」纪律的门禁化)。 + + 每个字段算「已覆盖」当且仅当:字段名或某别名出现在 payload,或在 payload['字段存疑'] + 里被显式标注了非空原因(逃生口:确无依据的字段可声明存疑而非静默缺省)。 + 返回 {schema_type, enforced, required, covered, missing_required, missing_recommended, excused}。 + enforced=True 且 missing_required 非空时,由 write_section 失败关闭。 + """ + fields, required, enforce = _load_schema(schema_type) + if fields is None: + return {"schema_type": schema_type, "enforced": False, "required": [], + "covered": [], "missing_required": [], "missing_recommended": [], "excused": {}} + excused_raw = payload.get("字段存疑", {}) + excused = excused_raw if isinstance(excused_raw, dict) else {} + + def covered(name): + if any(key in payload for key in [name, *fields.get(name, [])]): + return True + return name in excused and str(excused[name]).strip() != "" + + required_set = set(required) + missing_required = [name for name in required if not covered(name)] + missing_recommended = [name for name in fields + if name not in required_set and not covered(name)] + return {"schema_type": schema_type, "enforced": enforce, "required": required, + "covered": [name for name in fields if covered(name)], + "missing_required": missing_required, + "missing_recommended": missing_recommended, + "excused": dict(excused)} def write_section(work_id, *, section_type, payload, target_chapter=None, schema_type=None, @@ -31,6 +107,13 @@ def write_section(work_id, *, section_type, payload, target_chapter=None, schema raise ValueError("fine_outline 是章级 section,必须给 target_chapter") if not isinstance(payload, dict) or not payload: raise ValueError("payload 必须是非空 JSON 对象(schema 字段结构化内容)") + # 字段覆盖门禁:声明「强制」的 schema(如 fine_outline)缺必填字段即失败关闭; + # 推荐字段缺失只随返回值报告,不拦。未纳入校验的型(无 schema 或未标强制)跳过。 + coverage = check_field_coverage(schema_type, payload) + if coverage["enforced"] and coverage["missing_required"]: + raise ValueError( + f"字段覆盖门禁:{schema_type} 缺必填字段 {coverage['missing_required']};" + f"补齐后再落库,确无依据的字段在 payload['字段存疑'] 里逐条标注原因") with connect() as conn: try: row = conn.execute( @@ -40,9 +123,10 @@ def write_section(work_id, *, section_type, payload, target_chapter=None, schema json.dumps(payload, ensure_ascii=False), version, state, creator)).fetchone() if dry_run: conn.rollback() - return {"status": "dry_run_ok", "id": row[0], "state": row[1], "note": "试跑已回滚,未落库"} + return {"status": "dry_run_ok", "id": row[0], "state": row[1], + "note": "试跑已回滚,未落库", "fieldCoverage": coverage} conn.commit() - return {"status": "written", "id": row[0], "state": row[1]} + return {"status": "written", "id": row[0], "state": row[1], "fieldCoverage": coverage} except Exception: conn.rollback() raise diff --git a/.claude/skills/planning/scripts/test_field_coverage.py b/.claude/skills/planning/scripts/test_field_coverage.py new file mode 100644 index 0000000..7ee5fd7 --- /dev/null +++ b/.claude/skills/planning/scripts/test_field_coverage.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""落库字段覆盖门禁回归:把「字段全覆盖」从文档纪律变成机械门禁。 + +门禁读 meta/schemas/.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() diff --git a/.claude/skills/read-context/scripts/assemble_writer_context.py b/.claude/skills/read-context/scripts/assemble_writer_context.py index d3d5334..4a6d0c3 100644 --- a/.claude/skills/read-context/scripts/assemble_writer_context.py +++ b/.claude/skills/read-context/scripts/assemble_writer_context.py @@ -504,6 +504,7 @@ def assemble_context( output_contract: Mapping[str, Any], token_budget: Mapping[str, int], pattern_references: Sequence[Mapping[str, Any]] = (), + style_constraints: Sequence[str] = (), generated_at: str, evidence_strategy: str = "production_dual_evidence", ) -> dict[str, Any]: @@ -613,7 +614,9 @@ def assemble_context( def make_context() -> dict[str, Any]: """用当前选择集生成完整上下文,供预算试算与最终冻结。""" - + # 规划期选定的文风约束:归一化后仅在非空时入冻结上下文(空则省略整个键, + # 上下文逐字节不变,不破坏既有哈希与 A/C 单变量)。 + style_rules = [normalize_text(str(rule)) for rule in style_constraints if str(rule).strip()] ordered_prose = sorted( selected_prose, key=lambda item: ( @@ -664,6 +667,8 @@ def assemble_context( "omittedSources": sorted(copy.deepcopy(omitted), key=lambda item: (item["reason"], item["sourceId"])), "acceptanceEligible": mode == "production" and purpose == "production", } + if style_rules: + context["styleConstraints"] = list(style_rules) # usedContextChars 自身位数会影响 JSON 长度,迭代到数值稳定。 for _ in range(8): used = _context_size(context) diff --git a/.claude/skills/read-context/scripts/retrieve_writer_sources.py b/.claude/skills/read-context/scripts/retrieve_writer_sources.py index e6f2f7c..2f75d07 100644 --- a/.claude/skills/read-context/scripts/retrieve_writer_sources.py +++ b/.claude/skills/read-context/scripts/retrieve_writer_sources.py @@ -10,6 +10,7 @@ from __future__ import annotations import copy import hashlib +import json import pathlib import sys from typing import Any, Callable, Mapping, Protocol, Sequence @@ -63,6 +64,18 @@ def _chapter(value: Any, path: str) -> int: return value +def _freeze_chapter(value: Any, path: str) -> int: + """冻结线章号:允许 0(开篇前冻结线,第 1 章之前没有历史正文)。目标章仍必须正整数。""" + + if isinstance(value, bool): + raise RetrievalError(f"{path} 必须是非负整数章号") + if isinstance(value, str) and value.isdigit(): + value = int(value) + if not isinstance(value, int) or value < 0: + raise RetrievalError(f"{path} 必须是非负整数章号") + return value + + def _milestone_chapter(value: Mapping[str, Any]) -> int: """兼容实验库中英文里程碑章号字段,但不解析模糊文本。""" @@ -142,7 +155,7 @@ def build_retrieval_plan( """从已确认细纲确定查询集合,并在执行前冻结计划身份。""" target = _chapter(target_chapter, "targetChapter") - freeze = _chapter(as_of, "asOf") + freeze = _freeze_chapter(as_of, "asOf") if freeze >= target: raise RetrievalError("asOf 必须早于 targetChapter") if not isinstance(work_id, int) or isinstance(work_id, bool) or work_id <= 0: @@ -206,7 +219,7 @@ def _validate_source_ref(ref: Mapping[str, Any], *, as_of: int, path: str) -> di def freeze_card(card: Mapping[str, Any], *, as_of: int) -> dict[str, Any]: """仅用冻结线内里程碑重建卡状态,不透传终态摘要和未来字段。""" - freeze = _chapter(as_of, "asOf") + freeze = _freeze_chapter(as_of, "asOf") milestones = card.get("milestones") if not isinstance(milestones, list): raise RetrievalError(f"卡 {card.get('cardId')} 缺少历史里程碑") @@ -383,6 +396,99 @@ def _content_hash(text: str) -> str: return "sha256:" + hashlib.sha256(normalized.encode("utf-8")).hexdigest() +def load_confirmed_fine_outline(conn, *, work_id: int, target_chapter: int) -> dict[str, Any]: + """读取指定章最新一条已确认细纲——read-context 是已确认细纲的唯一消费点。 + + 取数合同与 planning SKILL 登记的 SQL 一致:section_type=fine_outline、state=confirmed、 + 未删除,按 version 倒序取最新。连接由调用方经 db skill 提供(本函数不自建连接、 + 不复制第二套权限语义)。缺已确认细纲或 payload 非法即失败关闭。 + """ + row = conn.execute( + "SELECT payload FROM example_planning_section WHERE work_id=%s AND " + "section_type='fine_outline' AND target_chapter=%s AND state='confirmed' AND " + "deleted=false ORDER BY version DESC LIMIT 1", + (work_id, target_chapter)).fetchone() + if not row: + raise RetrievalError( + f"无第{target_chapter}章已确认细纲(work={work_id});先落细纲并确认后再写正文") + payload = row[0] + if isinstance(payload, str): + payload = json.loads(payload) + if not isinstance(payload, dict) or not payload: + raise RetrievalError(f"第{target_chapter}章已确认细纲 payload 非法(须非空对象)") + return payload + + +def load_confirmed_pattern_bindings(conn, *, work_id: int) -> list[dict[str, Any]]: + """读取作品已确认的范式绑定——规划期选定、写作期只消费已绑定范式。 + + 实验仓承载(遵循「主仓表不改列、扩展进 example_*」口径):绑定即「最新一条已确认 + assembly 规划行」的 patternReferences;确认 assembly 即激活绑定,不另写作品行列。 + (主仓生产承载方向是 muse_content_work.pattern_bindings 列,见 03-范式领域 §6。) + 无已确认 assembly 或没有选中范式时诚实返空(新书/未选范式不报错)。 + """ + row = conn.execute( + "SELECT payload FROM example_planning_section WHERE work_id=%s AND " + "section_type='assembly' AND state='confirmed' AND deleted=false " + "ORDER BY version DESC LIMIT 1", (work_id,)).fetchone() + if not row: + return [] + payload = row[0] + if isinstance(payload, str): + payload = json.loads(payload) + if not isinstance(payload, dict): + return [] + references = payload.get("patternReferences", []) + return [dict(item) for item in references if isinstance(item, dict)] + + +def derive_style_constraints(style_payload: Any) -> list[str]: + """把作品已确认文风投影成 writer 可消费的约束字符串列表。 + + 兼容两种形状:结构化 style 画像(style.yaml 字段字典)按维度逐条投影;旧的一句话 + 文风 blob(书12 现状)整体作为一条约束。空/非法 → 空列表(不注入)。 + """ + if isinstance(style_payload, str): + text = style_payload.strip() + return [text] if text else [] + if isinstance(style_payload, dict): + rules = [] + for aspect, value in style_payload.items(): + text = str(value).strip() + if text: + rules.append(f"{aspect}:{text}") + return rules + return [] + + +def load_confirmed_style(conn, *, work_id: int) -> list[str]: + """读取作品已确认文风并投影为约束——read-context 的文风取数端(与细纲/范式取数端并排)。 + + 优先独立 style 规划行(schema_type='style'),回退设定行的 style 字段(书12 现状)。 + 无确认文风时诚实返空。结构化文风画像(书级 style 八字段)的抽取仍待建。 + """ + row = conn.execute( + "SELECT payload FROM example_planning_section WHERE work_id=%s AND " + "schema_type='style' AND state='confirmed' AND deleted=false " + "ORDER BY version DESC LIMIT 1", (work_id,)).fetchone() + if row: + payload = row[0] + if isinstance(payload, str): + payload = json.loads(payload) + return derive_style_constraints(payload) + setting = conn.execute( + "SELECT payload FROM example_planning_section WHERE work_id=%s AND " + "section_type='setting' AND state='confirmed' AND deleted=false " + "ORDER BY version DESC LIMIT 1", (work_id,)).fetchone() + if setting: + payload = setting[0] + if isinstance(payload, str): + payload = json.loads(payload) + if isinstance(payload, dict): + return derive_style_constraints(payload.get("style")) + return [] + + def _index_hint(card: Mapping[str, Any], *, as_of: int) -> dict[str, Any]: """把冻结卡投影成稳定索引提示,是否存在原文指针不影响卡进入索引。""" @@ -457,7 +563,7 @@ def retrieve_writer_sources( ) -> dict[str, Any]: """执行固定计划,展开卡来源并形成事实/原文两条证据线。""" - as_of = _chapter(plan.get("asOf"), "plan.asOf") + as_of = _freeze_chapter(plan.get("asOf"), "plan.asOf") work_id = plan.get("filters", {}).get("workId") if not isinstance(work_id, int) or isinstance(work_id, bool) or work_id <= 0: raise RetrievalError("plan.filters.workId 非法") @@ -566,5 +672,6 @@ def retrieve_writer_sources( __all__ = [ "RetrievalError", "CardIndexRepository", "ProseRepository", "ProductionCardIndexRepository", "ReplayCardIndexRepository", "FrozenProseRepository", "stable_sort_cards", "freeze_card", - "build_retrieval_plan", "retrieve_writer_sources", + "build_retrieval_plan", "retrieve_writer_sources", "load_confirmed_fine_outline", + "load_confirmed_pattern_bindings", "load_confirmed_style", "derive_style_constraints", ] diff --git a/.claude/skills/read-context/scripts/test_assemble_writer_context.py b/.claude/skills/read-context/scripts/test_assemble_writer_context.py index cbf7171..0be8d64 100644 --- a/.claude/skills/read-context/scripts/test_assemble_writer_context.py +++ b/.claude/skills/read-context/scripts/test_assemble_writer_context.py @@ -543,5 +543,34 @@ class AssembleWriterContextTest(unittest.TestCase): assemble_context(**kwargs) +class StyleInjectionTest(unittest.TestCase): + """style 真注入:规划期选定的文风约束进冻结上下文并投影给 writer。""" + + def test_no_style_means_context_unchanged_and_projection_empty(self): + result = assemble_context(**base_kwargs()) + # 未提供文风时整个键省略,上下文逐字节不变(不破坏既有冻结哈希)。 + self.assertNotIn("styleConstraints", result["context"]) + self.assertEqual(result["writerCreativeInput"]["styleConstraints"], []) + + def test_style_constraints_freeze_and_project_to_writer(self): + kwargs = base_kwargs() + kwargs["style_constraints"] = ["禁用 仿佛/似乎/不禁/眼眸", "句式偏短,节奏紧"] + result = assemble_context(**kwargs) + self.assertEqual( + result["context"]["styleConstraints"], + ["禁用 仿佛/似乎/不禁/眼眸", "句式偏短,节奏紧"], + ) + self.assertEqual( + result["writerCreativeInput"]["styleConstraints"], + ["禁用 仿佛/似乎/不禁/眼眸", "句式偏短,节奏紧"], + ) + + def test_blank_style_rules_are_dropped(self): + kwargs = base_kwargs() + kwargs["style_constraints"] = [" ", "", "冷峻克制"] + result = assemble_context(**kwargs) + self.assertEqual(result["context"]["styleConstraints"], ["冷峻克制"]) + + if __name__ == "__main__": unittest.main() diff --git a/.claude/skills/read-context/scripts/test_fine_outline_reader.py b/.claude/skills/read-context/scripts/test_fine_outline_reader.py new file mode 100644 index 0000000..d85d4c2 --- /dev/null +++ b/.claude/skills/read-context/scripts/test_fine_outline_reader.py @@ -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() diff --git a/.claude/skills/read-context/scripts/test_fine_outline_unification.py b/.claude/skills/read-context/scripts/test_fine_outline_unification.py new file mode 100644 index 0000000..d363139 --- /dev/null +++ b/.claude/skills/read-context/scripts/test_fine_outline_unification.py @@ -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() diff --git a/.claude/skills/read-context/scripts/test_pattern_binding_reader.py b/.claude/skills/read-context/scripts/test_pattern_binding_reader.py new file mode 100644 index 0000000..68b5827 --- /dev/null +++ b/.claude/skills/read-context/scripts/test_pattern_binding_reader.py @@ -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() diff --git a/.claude/skills/read-context/scripts/test_style_loader.py b/.claude/skills/read-context/scripts/test_style_loader.py new file mode 100644 index 0000000..28ce715 --- /dev/null +++ b/.claude/skills/read-context/scripts/test_style_loader.py @@ -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() diff --git a/.claude/skills/read-context/scripts/writer_contract.py b/.claude/skills/read-context/scripts/writer_contract.py index 57078e8..e2a6a26 100644 --- a/.claude/skills/read-context/scripts/writer_contract.py +++ b/.claude/skills/read-context/scripts/writer_contract.py @@ -376,7 +376,8 @@ def _validate_plan(value: Any, path: str) -> None: _hash(plan["planId"], f"{path}.planId") if not _RUN_ID_RE.fullmatch(_string(plan["runId"], f"{path}.runId")): raise ContractError(f"{path}.runId 格式非法") - _integer(plan["asOf"], f"{path}.asOf", minimum=1) + # asOf=0 表示开篇前冻结线(第 1 章之前):此刻只有设定/大纲/细纲,无历史正文 + _integer(plan["asOf"], f"{path}.asOf", minimum=0) for index, query in enumerate(_array(plan["queries"], f"{path}.queries")): item = _object(query, f"{path}.queries[{index}]", frozenset({"queryId", "text", "entityTypes", "purpose", "topK"})) _string(item["queryId"], f"{path}.queries[{index}].queryId") @@ -389,7 +390,7 @@ def _validate_plan(value: Any, path: str) -> None: _string(plan["proseIndexVersion"], f"{path}.proseIndexVersion") filters = _object(plan["filters"], f"{path}.filters", frozenset({"workId", "asOfChapter", "sourceStatus", "authorizationRequired"})) _integer(filters["workId"], f"{path}.filters.workId", minimum=1) - _integer(filters["asOfChapter"], f"{path}.filters.asOfChapter", minimum=1) + _integer(filters["asOfChapter"], f"{path}.filters.asOfChapter", minimum=0) _string(filters["sourceStatus"], f"{path}.filters.sourceStatus") _boolean(filters["authorizationRequired"], f"{path}.filters.authorizationRequired") if plan["tieBreak"] != TIE_BREAK: @@ -438,7 +439,7 @@ def validate_writer_context(value: Any) -> dict[str, Any]: value, "$", required, - frozenset({"evidenceStrategy", "indexHints"}), + frozenset({"evidenceStrategy", "indexHints", "styleConstraints"}), ) if context["schemaVersion"] != CONTEXT_VERSION: raise ContractError("$.schemaVersion 版本不支持") @@ -463,7 +464,8 @@ def validate_writer_context(value: Any) -> dict[str, Any]: _string(context["qualityPolicyVersion"], "$.qualityPolicyVersion") _integer(context["workId"], "$.workId", minimum=1) target = _integer(context["targetChapter"], "$.targetChapter", minimum=1) - as_of = _integer(context["asOf"], "$.asOf", minimum=1) + # asOf=0 合法:开篇前冻结线,正文基线必为空(任何历史正文都会被后续"超出冻结线"挡下) + as_of = _integer(context["asOf"], "$.asOf", minimum=0) if as_of >= target: raise ContractError("$.asOf 必须早于 targetChapter") @@ -600,6 +602,13 @@ def validate_writer_context(value: Any) -> dict[str, Any]: for index, reference in enumerate(_array(context["patternReferences"], "$.patternReferences")): # 范式引用走放宽校验:来源指针仍严格,另允许 name/summary/writingPoints 内容字段。 _pattern_source_ref(reference, f"$.patternReferences[{index}]") + if "styleConstraints" in context: + # 文风约束(规划期选定的 style 画像投影):非空字符串数组;缺省时整个键省略, + # 上下文逐字节不变(不破坏既有冻结哈希),build 端按空数组投影。 + style_rules = _array(context["styleConstraints"], "$.styleConstraints") + for index, rule in enumerate(style_rules): + _string(rule, f"$.styleConstraints[{index}]", nonempty=True) + context["styleConstraints"] = [str(rule) for rule in style_rules] for index, coverage in enumerate(_array(context["evidenceCoverage"], "$.evidenceCoverage")): item = _object(coverage, f"$.evidenceCoverage[{index}]", frozenset({"elementId", "elementType", "name", "status", "factEvidenceIds", "proseEvidenceIds", "gapReason"})) for field in ("elementId", "elementType", "name", "gapReason"): @@ -707,7 +716,9 @@ def build_writer_creative_input(value: Any) -> dict[str, Any]: "maxChars": output_contract["maxChars"], "frontmatterRequired": output_contract["frontmatterRequired"], }, - "styleConstraints": [], + # 文风约束从冻结上下文投影(规划期选定的 style 画像);上下文未带则为空, + # 不再写死恒空——style 真注入的出口。 + "styleConstraints": [str(rule) for rule in context.get("styleConstraints", [])], } return json.loads(canonical_json(creative_input)) diff --git a/AGENTS.md b/AGENTS.md index 37bae30..9c15100 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,6 +118,7 @@ agent-example/ - 创作候选默认不提交。用户明确选择接受或修改后合并,且实时审查通过后,才可经 `confirm` 流程进入正式事实;丢弃也必须针对用户明确指定的候选。 - 框架改动与创作内容分开审查、分开提交。不得把 agents、skills、meta、脚本改动和正文、规划、知识卡候选混在同一提交。 - 任何 `git add` 或 `git commit` 都必须先取得用户明确授权;获得授权后,提交信息使用 `作品(书名): 动作 摘要` 或 `框架: 摘要`。 +- **提交前必须形而上审查。** 取得授权后、执行 `git add` 前,必须起一个独立子代理,对即将提交的全部内容(先 `git diff` 出真实改动)从形而上层面审查四个维度:**逻辑完整性**(论点/合同/链路有无缺环、边界是否闭合)、**一致性**(与既有 SSOT、术语、字段合同、相邻文档有无矛盾)、**合理性**(设计取舍是否站得住、是否过度或不足)、**可行性**(是否真能落地运行、测试是否支撑结论)。审查只读、不改代码;四维给出明确结论(通过 / 带条件通过 / 不通过 + 具体问题),通过后才允许提交。框架改动与创作内容分别审查。 - 不覆盖、不还原、不暂存其他工作者或用户已有改动。执行前后都要核对真实 diff,只处理当前授权范围。 ## 10. 评测结论与验证入口 diff --git a/docs/write-chapter/step2_setup_chapter3.py b/docs/write-chapter/step2_setup_chapter3.py new file mode 100644 index 0000000..01db0a6 --- /dev/null +++ b/docs/write-chapter/step2_setup_chapter3.py @@ -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() diff --git a/docs/write-chapter/step2_write_chapter.py b/docs/write-chapter/step2_write_chapter.py new file mode 100644 index 0000000..611f938 --- /dev/null +++ b/docs/write-chapter/step2_write_chapter.py @@ -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() diff --git a/meta/schemas/fine_outline.yaml b/meta/schemas/fine_outline.yaml new file mode 100644 index 0000000..dfb29d4 --- /dev/null +++ b/meta/schemas/fine_outline.yaml @@ -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 的细纲为历史形状。