muse-agent-example/docs/write-chapter/step2a_write_chapter1.py
zizi b0bc7a8745 框架: 技能按动作-对象重组 + 先审后入创作闭环
一、技能重组(动作-对象命名)
- 旧目录 clean/confirm/continuation/db/detect/embed/… 重组为
  clean-book-text/decide-candidate/write-next-chapter/access-database/
  check-content-consistency/embed-knowledge/…(git 识别为 rename,内容保持)
- agents/*.md、AGENTS.md/CLAUDE.md 收编、example_skill 登记表同步新名

二、先审后入创作闭环(本次核心)
正文接受从"机械门一过就写正典"改为"机械门+语义审查双通过+用户批准+单事务原子提交",
DB 级兜底,编排层跳步即被硬拒。
- candidate_cas.py + example_candidate_cas(109):持久化 CAS 状态链
- fact_delta.py + example_fact_delta/example_fact_ledger(106):结构化事实增量,
  模型只提六型闭集增量+正文证据引文,仅用户批准的增量随正文同事务入账本
- projection_registry.py + example_projection_run(107):投影登记与恢复
- acceptance_state.py:接受前置实时状态重读
- lesson_registry.py + example_lesson(108):经验升格链,禁止自动升格
- DDL 105:example_candidate 增 semantic_status/semantic_report_sha256
- write_canonical.accept:语义兜底+同事务合并增量+登记投影;
  run_writer_pipeline/persist_writer_run/run_writer_semantic_detector/step2 接入全链
- claude_runtime:兼容新 CLI modelUsage 信息字段

三、审查修复(独立子代理四维审查后)
- 事实增量 propose→approve 翻态正道,不撞唯一键
- 冻结配置探针重刷(CLI 2.1.211→2.1.231 漂移),profileSha256/adapterVersion 再登记
- 可视化合同悬空路径/五六空间矛盾、 SoT 旧技能名漂移、行尾空白清理

测试:离线 65 套 + 真实库集成 5 套(CAS/接受故障注入/事实增量/投影/经验升格)+ 回放 79 项全绿。
创作内容(docs/design、生成正文 artifacts)按"框架与创作分开"未入本提交。
2026-08-14 10:24:08 +08:00

265 lines
14 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
"""真写一章 · 阶段二a:writer 真子进程写第 1 章正文(基线章)。
为什么第 1 章单独走这条路(而不是生产组装):
- read-context 合同要求 asOf>=1 且 asOf<targetChapter,第 1 章没有合法 asOf;
- validate_writer_context / check_writer_candidate 都以完整 WriterContext v1 为前提。
所以第 1 章是基线章:writer 真调(claude_runtime.run_claude:sandbox 隔离 + 冻结 profile
+ 联合回执校验),但不走 assemble_context 与机械/语义门(门不适用于无前文的章),落库后
经 confirm 的 accept 唯一通道转正式正文,为第 2 章的生产流程(asOf=1)提供基线。
⚠ 先审后入兜底说明(2026-08-14):`write_canonical.accept` 现要求候选 `semantic_status='passed'`
才放行。本脚本造的第 1 章基线候选不跑语义门(semantic_status 为空),**重跑会在 accept 处被
DB 级兜底拒绝**。第 1 章基线已在本脚本首跑时落库;如未来确需重写基线章,须先就"无前文基线章
的接受豁免"做显式治理决策,不得绕过兜底直接写块。
本脚本修复的两处真 bug(上一会话遗留):
1. system prompt:上一版用 .claude/agents/writer.md(元定义,声称"合同由派发指令加载"),
writer 拿不到功能合同只回 3 字占位 "..."。本版用 replay-eval config 里冻结且跑出过
完整章节的 writer 提示词(systemPromptId=writer-gate-a-system-v2),从配置文件读取,
不复制粘贴(sha256 断言核对)。
2. 落库:上一版 example_run insert 缺 finished_at,撞 check 约束
chk_example_run_terminal_needs_finish 整段回滚。本版补 started_at/finished_at,
并把 ExecutionReceipt 的 usage/stop_reason/terminal_reason/is_error 落回执。
另:上一会话"绕过 sandbox"的临时手段已撤销——探针(probe_sandbox.py)证明 sandbox 本身
完好,当时失败的真因是 build_writer_execution_profile 把 normal_terminal_reasons 冻结成
("success",),而 CLI 2.1.211 正常结束给 "completed"(已在 run_writer.py 修复并补测试)。
本版恢复 sandbox 隔离,走 run_claude 官方路径。
创作输入严格按 build_writer_creative_input 的生产投影形状构造(见 run_writer.py);
文风约束从设定包(planning section id=3)的 style 字段投影——第 1 章无历史原文摘录,
文风画像是唯一的文风锚点。
"""
import hashlib
import json
import subprocess
import sys
import uuid
from datetime import datetime
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 (
"write-next-chapter/scripts",
"execute-claude-task/scripts",
"access-database/scripts",
"decide-candidate/scripts",
"assemble-context/scripts",
):
p = str(SKILLS / sub)
if p not in sys.path:
sys.path.insert(0, p)
from db import connect # noqa: E402
from claude_runtime import run_claude # noqa: E402
from run_writer import build_writer_execution_profile # noqa: E402
from writer_contract import han_count, normalize_text, validate_writer_draft # noqa: E402
from write_canonical import accept # noqa: E402
WORK_ID = 12
TARGET_CHAPTER = 1
CLAUDE_BIN = "/Users/qingse/.nvm/versions/node/v24.15.0/bin/claude"
CLAUDE_SHA256 = "ba790279cab6ef77b713864d4bf5f764fcea87d3a3eb7591a41f741e45212b5c"
CLAUDE_VERSION = "2.1.231"
RESOLVED_MODEL_ID = "claude-opus-4-8[1m]"
# 冻结 writer 提示词:从 replay-eval config 读(不复制粘贴,sha256 可与配置核对)
GATE_A_CONFIG_PATH = (
SKILLS
/ "evaluate-frozen-replay"
/ "configs"
/ "writer-gate-a-deep-space-v1.json"
)
GATE_A_WRITER_PROFILE = json.loads(GATE_A_CONFIG_PATH.read_text(encoding="utf-8"))[
"executionProfiles"]["writer"]
SYSTEM_PROMPT = GATE_A_WRITER_PROFILE["systemPrompt"]
SYSTEM_PROMPT_ID = GATE_A_WRITER_PROFILE["systemPromptId"]
SYSTEM_PROMPT_SHA256 = "sha256:" + hashlib.sha256(SYSTEM_PROMPT.encode("utf-8")).hexdigest()
assert SYSTEM_PROMPT_SHA256 == GATE_A_WRITER_PROFILE["systemPromptSha256"], \
"config 里的 systemPrompt 与其冻结 sha256 不一致,停止(事实来源已漂移)"
ARTIFACTS_DIR = SCRIPT_DIR / "artifacts"
# 第 1 章细纲硬骨架(与库内 planning section id=5 confirmed payload 同源)
HARD_CONSTRAINTS = [
"主角是天才机师,初次驾驶生物机甲。",
"深渊异族(克苏鲁式)首次出现,营造微恐/精神压迫。",
"建立黑暗森林/舰队/巨舰的世界观基调。",
"章末钩子:主角觉醒生物机甲的代价(精神污染的预兆)。",
]
ADJUSTABLE_BEATS = [
"开场节奏(侦察船日常 → 异常信号 → 异族现身)可调整。",
"精神压迫的描写强度可调整(微恐基调)。",
]
DECLARED_NEW_FACTS = [
{"factId": "fact-ch1-1", "text": "主角林深:天才机师。"},
{"factId": "fact-ch1-2", "text": "生物机甲:以异种核心驱动,机师与机甲精神链接。"},
{"factId": "fact-ch1-3", "text": "深渊异族:克苏鲁式,造成精神污染。"},
]
# 篇幅合同:细纲显式 targetChars=4000,区间按生产算法 = 目标值 ±30%(硬边界 2000-10000)
LENGTH_CONTRACT = {"targetChars": 4000, "minChars": 2800, "maxChars": 5200,
"frontmatterRequired": False}
# 文风约束:投影自设定包 style 字段(planning section id=3)。第 1 章无历史原文摘录,
# 文风画像是唯一文风锚点;生产投影从第 2 章起有 proseEvidence,本字段可为空。
STYLE_TEXT = ("冷峻、克制、微恐。战斗场面宏大(舰队/巨舰/机甲),心理描写细腻"
"(克苏鲁式精神压迫)。句式偏短,节奏紧。")
# 创作输入——严格按 build_writer_creative_input 的生产投影形状
CREATIVE_INPUT = {
"fineOutline": {
"hardConstraints": HARD_CONSTRAINTS,
"adjustableBeats": ADJUSTABLE_BEATS,
"declaredNewFacts": DECLARED_NEW_FACTS,
},
"narrativeState": {
"time": "故事开篇",
"location": "深渊星域,人类舰队侦察船",
"characterPositions": {"林深": "侦察船"},
"immediateSituation": "侦察船在深渊星域捕获异常信号,未知存在逼近。",
},
"factConstraints": [],
"proseExcerpts": [],
"patternReferences": [],
"lengthContract": LENGTH_CONTRACT,
"styleConstraints": [{"styleId": "style-baseline-v1", "text": STYLE_TEXT}],
}
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": str(datetime.now())}
try:
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)
except subprocess.TimeoutExpired as exc:
record["exception"] = f"TimeoutExpired: {exc}"
log_path.write_text(json.dumps(record, ensure_ascii=False, indent=1),
encoding="utf-8")
raise
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 main():
run_id = f"run-prod-work12-ch1-{uuid.uuid4().hex[:8]}"
ARTIFACTS_DIR.mkdir(exist_ok=True)
(ARTIFACTS_DIR / f"{run_id}-creative-input.json").write_text(
json.dumps(CREATIVE_INPUT, ensure_ascii=False, indent=1), encoding="utf-8")
envelope_log = ARTIFACTS_DIR / f"{run_id}-sandbox-call.json"
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=200000, system_prompt=SYSTEM_PROMPT)
print(f"writer 真调开始: run_id={run_id}, 模型={RESOLVED_MODEL_ID}, "
f"提示词={SYSTEM_PROMPT_ID}, sandbox=on")
started = datetime.now()
invocation = run_claude(profile, CREATIVE_INPUT,
runner=make_logging_runner(envelope_log),
business_validator=validate_writer_draft)
finished = datetime.now()
receipt = invocation.receipt
body = normalize_text(invocation.structured_output["candidateBody"])
actual_han = han_count(body)
candidate_sha256 = hashlib.sha256(body.encode("utf-8")).hexdigest()
print("── 回执 ──")
print(f" actual_model_id: {receipt.actual_model_id} model_match: {receipt.model_match}")
print(f" terminal_reason: {receipt.terminal_reason} stop_reason: {receipt.stop_reason} "
f"is_error: {receipt.is_error} exit_code: {receipt.exit_code}")
print(f" total_cost_usd: {receipt.total_cost_usd} usage: "
f"{json.dumps(dict(receipt.usage or {}), ensure_ascii=False)[:160]}")
print(f"── 候选 ──\n 汉字数: {actual_han}(合同 {LENGTH_CONTRACT['minChars']}"
f"-{LENGTH_CONTRACT['maxChars']},目标 {LENGTH_CONTRACT['targetChars']})")
print(f" sha256: {candidate_sha256[:16]}...")
print(f" 正文前 120 字: {body[:120]}")
# 落库前硬门:篇幅不出界才落库(3 字级垃圾不许入库污染看板)
if not LENGTH_CONTRACT["minChars"] <= actual_han <= LENGTH_CONTRACT["maxChars"]:
print(f"[拒绝落库] 汉字数 {actual_han} 超出篇幅合同,候选只留档 artifacts 不入库")
raise SystemExit(1)
if not receipt.model_match:
print(f"[拒绝落库] 实际模型与冻结模型 {RESOLVED_MODEL_ID} 不一致")
raise SystemExit(1)
# ── 落库:run + 候选 + 回执 + 实跑长度检测(同事务)──
with connect() as conn:
conn.execute(
"INSERT INTO example_run(run_id, work_id, target_chapter, trigger_source, "
"trigger_detail, started_at, finished_at, terminal_state, creator) "
"VALUES (%s,%s,%s,'user',%s,%s,%s,'completed','writer')",
(run_id, WORK_ID, TARGET_CHAPTER,
json.dumps({"note": "第1章基线:writer 真子进程(sandbox);asOf 合同不适用,"
"未走生产组装与门",
"systemPromptId": SYSTEM_PROMPT_ID,
"systemPromptSha256": SYSTEM_PROMPT_SHA256,
"executionProfileSha256": receipt.execution_profile_sha256,
"artifact": envelope_log.name}, ensure_ascii=False),
started, finished))
cand_id = conn.execute(
"INSERT INTO example_candidate(work_id, target_chapter, run_id, attempt, run_type, "
"candidate_version, candidate_sha256, candidate_body, state, mode, source_role, "
"acceptance_eligible, creator) "
"VALUES (%s,%s,%s,1,'production','1',%s,%s,'passed','continuation','writer',true,"
"'writer') RETURNING id",
(WORK_ID, TARGET_CHAPTER, run_id, candidate_sha256, body)).fetchone()[0]
receipt_id = conn.execute(
"INSERT INTO example_run_receipt(run_id, sample_id, revision, adapter_role, "
"stage_kind, attempt, candidate_version, candidate_sha256, requested_model_id, "
"actual_model_id, model_match, effort, total_cost_usd, usage, stop_reason, "
"terminal_reason, is_error, creator) "
"VALUES (%s,'writer-ch1',1,'writer','generation',1,'1',%s,%s,%s,%s,'high',%s,"
"%s::jsonb,%s,%s,%s,'writer') RETURNING id",
(run_id, candidate_sha256, receipt.requested_model_id, receipt.actual_model_id,
receipt.model_match, str(receipt.total_cost_usd),
json.dumps(dict(receipt.usage or {}), ensure_ascii=False),
receipt.stop_reason, receipt.terminal_reason,
bool(receipt.is_error))).fetchone()[0]
# 长度检测是脚本真跑过的机械检查,如实落账;语义门对基线章不适用,不伪造通过。
conn.execute(
"INSERT INTO example_quality_result(run_id, receipt_id, candidate_sha256, "
"judge_kind, dimension, scale_version, conclusion, detail, creator) "
"VALUES (%s,%s,%s,'detection','length','writer-production-v1','pass',%s::jsonb,"
"'writer')",
(run_id, receipt_id, candidate_sha256,
json.dumps({"actualHanChars": actual_han, **LENGTH_CONTRACT,
"note": "基线章:仅篇幅机械检查;无 WriterContext,语义门不适用"},
ensure_ascii=False)))
conn.commit()
print(f"候选落库: candidate_id={cand_id}, run_id={run_id}")
# 接受为正式正文(confirm 唯一通道)——第 1 章无正文块,expected_revision=0
accept_result = accept(
cand_id, decided_by="1",
rationale=("开篇基线章:writer 真子进程产出(sandbox 隔离),联合回执校验与篇幅"
"机械检查通过。第 1 章无前文,asOf 合同无解,生产组装与机械/语义门不适用;"
"文风锚定设定包 style。"),
basis_ref="细纲@v1 (planning_section id=5)",
expected_revision=0,
command_id=f"accept-work12-ch1-{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()