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

313 lines
12 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
"""通过统一离线 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",
]