zizi 952485a506 框架: 前期创作流程代码——细纲统一合同+字段覆盖门禁+文风/范式注入+取数端统一
修五个硬伤,让"前期准备"从文档纪律变成机械门禁,并补齐写作链上游注入:
- 细纲合同合一:新建 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)。
2026-08-02 02:17:14 +08:00

182 lines
9.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""规划落库 —— 把 planner 产出的 schema 驱动结构化内容落 example_planning_section(shadow→confirmed)。
落库设计 §2.6:规划/细纲/设定的正式权威在库(planning_section),works/<书名>/ 文件退为人类可读留痕。
- write:落一条 shadow 规划(planner 产出结构化 payload,主会话调本脚本落库;planner 不碰库/git)。
- confirm:shadow→confirmed(用户确认后翻态,confirmed_at 落时间)——确认后的规划才进生成上下文。
- 已确认细纲取用见 planning SKILL.md 的 SQL。
payload = 对应 section_type 的 schema 字段结构化内容(JSON),由 planner/主会话按 meta/schemas 产出。
--dry-run 试跑:插入/翻态后回滚,校验但不落库。
"""
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))
from db import connect # noqa: E402
CREATOR = "planning"
SECTION_TYPES = ("setting", "outline", "state", "assembly", "fine_outline")
# 字段权威(git 侧设计稿):落库字段覆盖门禁按 schema_type 读这里的 <型>.yaml。
# persist_planning.py 在 .claude/skills/planning/scripts/ 下,仓库根是 parents[4]。
SCHEMAS_DIR = Path(__file__).resolve().parents[4] / "meta" / "schemas"
def _load_schema(schema_type):
"""从 meta/schemas/<schema_type>.yaml 读字段清单、必填集与门禁档位。
返回 (fields, required, enforce):
fields = {字段名: [别名...]}(特有字段全集,含推荐);
required= 必填字段名列表(只有这些参与失败关闭);
enforce = 该型是否「字段覆盖门禁: 强制」。
schema 文件不存在或非法 → (None, [], False),门禁跳过(该型暂未纳入覆盖校验,
如中文键的老 schema 与英文 payload 尚未对齐,不溯及强拦)。
"""
if not schema_type:
return None, [], False
path = SCHEMAS_DIR / f"{schema_type}.yaml"
if not path.exists():
return None, [], False
try:
doc = yaml.safe_load(path.read_text(encoding="utf-8"))
except yaml.YAMLError:
return None, [], False
if not isinstance(doc, dict):
return None, [], False
fields = {}
for item in doc.get("特有字段", []) or []:
if not isinstance(item, dict) or not item:
continue
if "key" in item: # 流式 - { key: X, 说明:..., aliases:[...] }
name = str(item["key"])
aliases = [str(a) for a in (item.get("aliases", []) or [])]
elif len(item) == 1: # 块式 - X:\n 说明:... (单键映射,键即字段名)
name = next(iter(item))
sub = item[name]
aliases = [str(a) for a in (sub.get("aliases", []) or [])] if isinstance(sub, dict) else []
else:
continue
fields[name] = aliases
required = [str(x) for x in (doc.get("必填字段", []) or [])]
enforce = str(doc.get("字段覆盖门禁", "")).strip() == "强制"
return fields, required, enforce
def check_field_coverage(schema_type, payload):
"""机械校验 payload 对 schema 字段的覆盖(「字段全覆盖」纪律的门禁化)。
每个字段算「已覆盖」当且仅当:字段名或某别名出现在 payload,或在 payload['字段存疑']
里被显式标注了非空原因(逃生口:确无依据的字段可声明存疑而非静默缺省)。
返回 {schema_type, enforced, required, covered, missing_required, missing_recommended, excused}。
enforced=True 且 missing_required 非空时,由 write_section 失败关闭。
"""
fields, required, enforce = _load_schema(schema_type)
if fields is None:
return {"schema_type": schema_type, "enforced": False, "required": [],
"covered": [], "missing_required": [], "missing_recommended": [], "excused": {}}
excused_raw = payload.get("字段存疑", {})
excused = excused_raw if isinstance(excused_raw, dict) else {}
def covered(name):
if any(key in payload for key in [name, *fields.get(name, [])]):
return True
return name in excused and str(excused[name]).strip() != ""
required_set = set(required)
missing_required = [name for name in required if not covered(name)]
missing_recommended = [name for name in fields
if name not in required_set and not covered(name)]
return {"schema_type": schema_type, "enforced": enforce, "required": required,
"covered": [name for name in fields if covered(name)],
"missing_required": missing_required,
"missing_recommended": missing_recommended,
"excused": dict(excused)}
def write_section(work_id, *, section_type, payload, target_chapter=None, schema_type=None,
version=1, state="shadow", creator=CREATOR, dry_run=False):
"""落一条规划(默认 shadow)。返回 {status, id, state}。"""
if section_type not in SECTION_TYPES:
raise ValueError(f"section_type 非法:{section_type}(应为 {SECTION_TYPES})")
if section_type == "fine_outline" and target_chapter is None:
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(
"INSERT INTO example_planning_section(work_id, target_chapter, section_type, schema_type, "
"payload, version, state, creator) VALUES (%s,%s,%s,%s,%s::jsonb,%s,%s,%s) RETURNING id, state",
(work_id, target_chapter, section_type, schema_type,
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": "试跑已回滚,未落库", "fieldCoverage": coverage}
conn.commit()
return {"status": "written", "id": row[0], "state": row[1], "fieldCoverage": coverage}
except Exception:
conn.rollback()
raise
def confirm_section(section_id, *, updater=CREATOR, dry_run=False):
"""确认规划:shadow→confirmed。返回 {status, id, state}。"""
with connect() as conn:
try:
row = conn.execute(
"UPDATE example_planning_section SET state='confirmed', confirmed_at=CURRENT_TIMESTAMP, "
"updater=%s WHERE id=%s AND state='shadow' AND deleted=false RETURNING id, state",
(updater, section_id)).fetchone()
if not row:
cur = conn.execute("SELECT state FROM example_planning_section WHERE id=%s", (section_id,)).fetchone()
raise ValueError(f"规划 {section_id} 不可确认(不存在或 state={cur[0] if cur else '无'},只有 shadow 可确认)")
if dry_run:
conn.rollback()
return {"status": "dry_run_ok", "id": row[0], "state": row[1], "note": "试跑已回滚,未落库"}
conn.commit()
return {"status": "confirmed", "id": row[0], "state": row[1]}
except Exception:
conn.rollback()
raise
if __name__ == "__main__":
import argparse
ap = argparse.ArgumentParser(description="规划落库(shadow→confirmed)")
sub = ap.add_subparsers(dest="cmd", required=True)
pw = sub.add_parser("write", help="落一条规划(默认 shadow)")
pw.add_argument("payload_json", help="payload JSON 文件路径(schema 字段结构化内容)")
pw.add_argument("--work-id", type=int, required=True)
pw.add_argument("--section-type", required=True, choices=SECTION_TYPES)
pw.add_argument("--target-chapter", type=int, default=None)
pw.add_argument("--schema-type", default=None)
pw.add_argument("--version", type=int, default=1)
pw.add_argument("--creator", default=CREATOR)
pw.add_argument("--dry-run", action="store_true")
pc = sub.add_parser("confirm", help="确认规划(shadow→confirmed)")
pc.add_argument("section_id", type=int)
pc.add_argument("--updater", default=CREATOR)
pc.add_argument("--dry-run", action="store_true")
args = ap.parse_args()
if args.cmd == "write":
payload = json.loads(Path(args.payload_json).read_text(encoding="utf-8"))
out = write_section(args.work_id, section_type=args.section_type, payload=payload,
target_chapter=args.target_chapter, schema_type=args.schema_type,
version=args.version, creator=args.creator, dry_run=args.dry_run)
else:
out = confirm_section(args.section_id, updater=args.updater, dry_run=args.dry_run)
print(json.dumps(out, ensure_ascii=False))