修五个硬伤,让"前期准备"从文档纪律变成机械门禁,并补齐写作链上游注入: - 细纲合同合一:新建 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)。
290 lines
15 KiB
Python
290 lines
15 KiB
Python
#!/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()
|