#!/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()