一、技能重组(动作-对象命名) - 旧目录 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)按"框架与创作分开"未入本提交。
313 lines
12 KiB
Python
313 lines
12 KiB
Python
#!/usr/bin/env python3
|
||
"""通过统一离线 runtime 运行正文写手并绑定候选身份。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import pathlib
|
||
import subprocess
|
||
import sys
|
||
from decimal import Decimal, ROUND_HALF_UP
|
||
from typing import Any, Callable, Mapping, Sequence
|
||
|
||
SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
|
||
READ_CONTEXT_DIR = SCRIPT_DIR.parents[1] / "assemble-context" / "scripts"
|
||
EXECUTION_DIR = SCRIPT_DIR.parents[1] / "execute-claude-task" / "scripts"
|
||
for import_path in (READ_CONTEXT_DIR, EXECUTION_DIR):
|
||
if str(import_path) not in sys.path:
|
||
sys.path.insert(0, str(import_path))
|
||
|
||
from claude_runtime import ( # noqa: E402
|
||
ClaudeRuntimeError,
|
||
ExecutionProfile,
|
||
ExecutionReceipt,
|
||
build_sandbox_command,
|
||
run_claude,
|
||
sha256_json,
|
||
sha256_text,
|
||
verify_execution_profile,
|
||
)
|
||
|
||
from writer_contract import ( # noqa: E402
|
||
ContractError,
|
||
build_candidate_envelope,
|
||
build_writer_creative_input,
|
||
calculate_target_chars,
|
||
han_count,
|
||
validate_writer_context,
|
||
validate_writer_draft,
|
||
)
|
||
|
||
|
||
# writer 模型只产生正文。运行身份、哈希和候选版本由 adapter 绑定。
|
||
WRITER_DRAFT_JSON_SCHEMA: dict[str, Any] = {
|
||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||
"type": "object",
|
||
"additionalProperties": False,
|
||
"required": ["candidateBody"],
|
||
"properties": {
|
||
"candidateBody": {"type": "string", "minLength": 1},
|
||
},
|
||
}
|
||
WRITER_OUTPUT_JSON_SCHEMA = WRITER_DRAFT_JSON_SCHEMA
|
||
|
||
|
||
def build_writer_execution_profile(
|
||
*,
|
||
claude_executable_path: str,
|
||
claude_executable_sha256: str,
|
||
claude_cli_version: str,
|
||
resolved_model_id: str,
|
||
effort: str,
|
||
max_budget_usd_per_call: Decimal,
|
||
timeout_seconds: float,
|
||
max_context_chars: int,
|
||
system_prompt: str,
|
||
) -> ExecutionProfile:
|
||
"""从预注册字段构造 writer 的完整冻结 ExecutionProfile。
|
||
|
||
本函数不提供 PATH、`opus` 或预算默认值,调用方必须显式传入已探测的完整模型
|
||
ID 和全部冻结参数,避免真实运行随本机默认配置漂移。
|
||
"""
|
||
|
||
return ExecutionProfile(
|
||
profile_version="claude-offline-writer-v2",
|
||
adapter_role="writer",
|
||
claude_executable_path=claude_executable_path,
|
||
claude_executable_sha256=claude_executable_sha256,
|
||
claude_cli_version=claude_cli_version,
|
||
model_alias="opus",
|
||
resolved_model_id=resolved_model_id,
|
||
effort=effort,
|
||
max_budget_usd_per_call=max_budget_usd_per_call,
|
||
timeout_seconds=timeout_seconds,
|
||
max_context_chars=max_context_chars,
|
||
json_schema_id="writer-draft-v2",
|
||
json_schema=WRITER_DRAFT_JSON_SCHEMA,
|
||
json_schema_sha256=sha256_json(WRITER_DRAFT_JSON_SCHEMA),
|
||
system_prompt_id="writer-system-prompt-v2",
|
||
system_prompt=system_prompt,
|
||
system_prompt_sha256=sha256_text(system_prompt),
|
||
# Claude Code 2.1.211 的 --print envelope 在正常结束时给 terminal_reason="completed"
|
||
# (subtype 才是 "success")。replay 冻结 config 的 normalTerminalReasons 同样登记
|
||
# ["completed"];旧值 ("success",) 会把每一次正常调用误判成 RECEIPT_INVALID。
|
||
normal_terminal_reasons=("completed",),
|
||
)
|
||
|
||
|
||
class WriterAdapterError(RuntimeError):
|
||
"""携带稳定失败码的写手 adapter 错误,所有错误都不可接受。"""
|
||
|
||
def __init__(self, code: str, message: str, *, details: Mapping[str, Any] | None = None):
|
||
super().__init__(message)
|
||
self.code = code
|
||
self.details = dict(details or {})
|
||
self.acceptance_eligible = False
|
||
|
||
|
||
def _array_length(value: Any, field: str) -> int:
|
||
"""读取细纲数组长度;错误类型失败关闭,避免密度被静默低估。"""
|
||
|
||
if value is None:
|
||
return 0
|
||
if not isinstance(value, list) or any(not isinstance(item, (str, Mapping)) for item in value):
|
||
raise WriterAdapterError("dynamic_length_input_invalid", f"{field} 必须是字符串或对象数组")
|
||
return len(value)
|
||
|
||
|
||
def _round_half_up(value: Decimal) -> int:
|
||
"""以十进制半入规则计算篇幅区间端点。"""
|
||
|
||
return int(value.quantize(Decimal("1"), rounding=ROUND_HALF_UP))
|
||
|
||
|
||
def calculate_dynamic_output_contract(
|
||
*,
|
||
fine_outline: Mapping[str, Any],
|
||
recent_chapter_bodies: Sequence[str],
|
||
default_target_chars: int = 4000,
|
||
hard_min_chars: int = 2000,
|
||
hard_max_chars: int = 10000,
|
||
) -> dict[str, Any]:
|
||
"""按细纲密度和冻结历史中位章长计算确定性输出篇幅合同。
|
||
|
||
目标值复用 WriterContext 合同的唯一算法。允许区间固定为目标值上下 30%,
|
||
端点按十进制半入取整后再受 2000-10000 的硬边界限制。
|
||
"""
|
||
|
||
if not isinstance(fine_outline, Mapping):
|
||
raise WriterAdapterError("dynamic_length_input_invalid", "fine_outline 必须是对象")
|
||
if isinstance(recent_chapter_bodies, (str, bytes)):
|
||
raise WriterAdapterError("dynamic_length_input_invalid", "recent_chapter_bodies 必须是正文数组")
|
||
counts: list[int] = []
|
||
for index, body in enumerate(recent_chapter_bodies):
|
||
if not isinstance(body, str):
|
||
raise WriterAdapterError(
|
||
"dynamic_length_input_invalid",
|
||
f"recent_chapter_bodies[{index}] 必须是字符串",
|
||
)
|
||
count = han_count(body)
|
||
# 只有达到合同定义的有效章节才进入历史中位数,短章不会污染基线。
|
||
if count >= 500:
|
||
counts.append(count)
|
||
explicit_target = fine_outline.get("targetChars")
|
||
try:
|
||
target = calculate_target_chars(
|
||
explicit_target_chars=explicit_target,
|
||
recent_chapter_han_counts=counts,
|
||
default_target_chars=default_target_chars,
|
||
hard_event_count=_array_length(
|
||
fine_outline.get("hardEvents", fine_outline.get("hardConstraints", [])),
|
||
"fine_outline.hardEvents",
|
||
),
|
||
foreshadowing_action_count=_array_length(
|
||
fine_outline.get("foreshadowingActions", []),
|
||
"fine_outline.foreshadowingActions",
|
||
),
|
||
required_scene_count=_array_length(
|
||
fine_outline.get("requiredScenes", []),
|
||
"fine_outline.requiredScenes",
|
||
),
|
||
min_chars=hard_min_chars,
|
||
max_chars=hard_max_chars,
|
||
)
|
||
except ContractError as exc:
|
||
raise WriterAdapterError("dynamic_length_input_invalid", str(exc)) from exc
|
||
lower = max(hard_min_chars, _round_half_up(Decimal(target) * Decimal("0.70")))
|
||
upper = min(hard_max_chars, _round_half_up(Decimal(target) * Decimal("1.30")))
|
||
return {
|
||
"targetChars": target,
|
||
"minChars": lower,
|
||
"maxChars": upper,
|
||
"frontmatterRequired": False,
|
||
}
|
||
|
||
|
||
def build_writer_command(profile: ExecutionProfile, isolation_directory: pathlib.Path) -> list[str]:
|
||
"""为只读检查暴露统一 runtime 的固定 sandbox 命令构造结果。"""
|
||
|
||
if profile.adapter_role != "writer":
|
||
raise WriterAdapterError("WRITER_PROFILE_INVALID", "writer 只能使用 writer profile")
|
||
return build_sandbox_command(profile, isolation_directory)
|
||
|
||
|
||
def _validate_candidate_semantics(context: Mapping[str, Any], output: Mapping[str, Any]) -> None:
|
||
"""校验 adapter 绑定后候选的动态篇幅。"""
|
||
|
||
contract = context["outputContract"]
|
||
actual_han_chars = han_count(output["candidateBody"])
|
||
if not contract["minChars"] <= actual_han_chars <= contract["maxChars"]:
|
||
raise WriterAdapterError(
|
||
"candidate_length_out_of_range",
|
||
"候选正文汉字数超出动态篇幅区间",
|
||
details={
|
||
"actualHanChars": actual_han_chars,
|
||
"minChars": contract["minChars"],
|
||
"maxChars": contract["maxChars"],
|
||
"targetChars": contract["targetChars"],
|
||
},
|
||
)
|
||
|
||
|
||
def run_writer_with_receipt(
|
||
context: Mapping[str, Any],
|
||
*,
|
||
profile: ExecutionProfile | None,
|
||
candidate_version: int = 1,
|
||
runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
|
||
binding_verifier: Callable[[ExecutionProfile], None] = verify_execution_profile,
|
||
run_id: str | None = None,
|
||
caller: str | None = None,
|
||
persist_call: Callable[[Mapping[str, Any]], Any] | None = None,
|
||
) -> tuple[dict[str, Any], ExecutionReceipt]:
|
||
"""调用 writer,由可信 adapter 绑定候选并返回执行回执。
|
||
|
||
生产记账:run_id 默认取上下文的 runId。带着 run_id 走默认 runner 时,runtime 会把
|
||
本次模型调用的输入/输出原文和调用明细原子落库(example_raw_content / example_llm_call);
|
||
候选记录器 persist_writer_execution 以这些调用明细为前置证据,缺了拒绝写候选。
|
||
离线 fake runner 不触发默认落库;需要自定义 runner 又要在库留证时,显式传 persist_call。
|
||
"""
|
||
|
||
try:
|
||
normalized_context = validate_writer_context(context)
|
||
except ContractError as exc:
|
||
raise WriterAdapterError("writer_context_contract_invalid", str(exc)) from exc
|
||
if profile is None:
|
||
raise WriterAdapterError("WRITER_PROFILE_REQUIRED", "真实 writer 调用必须显式传入冻结 profile")
|
||
if profile.adapter_role != "writer":
|
||
raise WriterAdapterError("WRITER_PROFILE_INVALID", "writer 只能使用 writer profile")
|
||
creative_input = build_writer_creative_input(normalized_context)
|
||
try:
|
||
invocation = run_claude(
|
||
profile,
|
||
creative_input,
|
||
runner=runner,
|
||
binding_verifier=binding_verifier,
|
||
business_validator=validate_writer_draft,
|
||
run_id=run_id or normalized_context.get("runId"),
|
||
caller=caller or "writer",
|
||
persist_call=persist_call,
|
||
)
|
||
except ClaudeRuntimeError as exc:
|
||
# runtime 只提供受控原因和回执;这里不拼接 subprocess stderr 或 stdout。
|
||
details: dict[str, Any] = {"causes": list(exc.causes), **exc.details}
|
||
if exc.receipt is not None:
|
||
details["executionReceipt"] = exc.receipt.as_dict()
|
||
raise WriterAdapterError(
|
||
exc.primary_code,
|
||
"正文写手运行未满足联合成功条件",
|
||
details=details,
|
||
) from exc
|
||
try:
|
||
candidate = build_candidate_envelope(
|
||
normalized_context,
|
||
invocation.structured_output,
|
||
candidate_version=candidate_version,
|
||
)
|
||
_validate_candidate_semantics(normalized_context, candidate)
|
||
except ContractError as exc:
|
||
error = WriterAdapterError("candidate_envelope_invalid", str(exc))
|
||
error.details["executionReceipt"] = invocation.receipt.as_dict()
|
||
raise error from exc
|
||
except WriterAdapterError as exc:
|
||
exc.details.setdefault("executionReceipt", invocation.receipt.as_dict())
|
||
raise
|
||
return candidate, invocation.receipt
|
||
|
||
|
||
def run_writer(
|
||
context: Mapping[str, Any],
|
||
*,
|
||
profile: ExecutionProfile | None = None,
|
||
candidate_version: int = 1,
|
||
runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
|
||
binding_verifier: Callable[[ExecutionProfile], None] = verify_execution_profile,
|
||
run_id: str | None = None,
|
||
caller: str | None = None,
|
||
persist_call: Callable[[Mapping[str, Any]], Any] | None = None,
|
||
) -> dict[str, Any]:
|
||
"""返回由可信 adapter 生成的 CandidateEnvelope v2。"""
|
||
|
||
output, _receipt = run_writer_with_receipt(
|
||
context,
|
||
profile=profile,
|
||
candidate_version=candidate_version,
|
||
runner=runner,
|
||
binding_verifier=binding_verifier,
|
||
run_id=run_id,
|
||
caller=caller,
|
||
persist_call=persist_call,
|
||
)
|
||
return output
|
||
|
||
|
||
__all__ = [
|
||
"WriterAdapterError",
|
||
"WRITER_DRAFT_JSON_SCHEMA",
|
||
"WRITER_OUTPUT_JSON_SCHEMA",
|
||
"build_writer_command",
|
||
"build_writer_execution_profile",
|
||
"calculate_dynamic_output_contract",
|
||
"run_writer",
|
||
"run_writer_with_receipt",
|
||
]
|