muse-agent-example/docs/write-chapter/step2_write_chapter.py
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

290 lines
15 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
"""真写一章 · 阶段二:走完整生产链写下一章(默认写目标作品的下一章)。
生产链(与 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()