#!/usr/bin/env python3 """通过统一治理 runtime 运行正文写手并绑定候选身份。 执行器是 muse_role 的固定 Opus HTTP 策略:身份提示与中心角色合同作系统提示词,冻结输入和 JSON Schema 进入同一次调用,输出校验后生成回执。不依赖模型 CLI 或宿主装载机制, 模型不可用时失败关闭,不降级到内容模型链。 """ from __future__ import annotations import pathlib 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" if str(READ_CONTEXT_DIR) not in sys.path: sys.path.insert(0, str(READ_CONTEXT_DIR)) from muse_role_contract import load_role_contract_catalog # noqa: E402 from muse_role import ( # noqa: E402 FIXED_OPUS_MODEL_ID, FIXED_OPUS_POLICY_ALIAS, FIXED_OPUS_POLICY_VERSION, RoleExecutionProfile, compose_role_system_prompt, RoleExecutionReceipt, RoleRuntimeError, run_role, sha256_json, sha256_text, verify_role_profile, ) _ROLE_CATALOG = load_role_contract_catalog(SCRIPT_DIR.parents[3]) WRITER_ROLE_PROMPT = ( (SCRIPT_DIR.parents[2] / "agents" / "writer.md").read_text(encoding="utf-8").rstrip() + "\n\n--- 角色合同(唯一事实源) ---\n" + _ROLE_CATALOG.for_role("writer").contract_prompt ) 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( *, max_budget_usd_per_call: Decimal, timeout_seconds: float, max_context_chars: int, system_prompt: str, system_prompt_id: str = "writer-system-prompt-v2", ) -> RoleExecutionProfile: """从预注册字段构造 writer 的完整冻结 RoleExecutionProfile。 本函数不提供预算与上下文默认值,调用方必须显式传入全部冻结参数,避免真实 运行随本机默认配置漂移。模型由 muse_llm 治理策略决定,不在 profile 绑定具体模型。 """ composed_prompt = compose_role_system_prompt(WRITER_ROLE_PROMPT, system_prompt) return RoleExecutionProfile( profile_version="role-writer-v4", adapter_role="writer", model_alias=FIXED_OPUS_POLICY_ALIAS, model_policy_version=FIXED_OPUS_POLICY_VERSION, resolved_model_id=FIXED_OPUS_MODEL_ID, 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=system_prompt_id, system_prompt=composed_prompt, system_prompt_sha256=sha256_text(composed_prompt), ) 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_production_length_contracts( dynamic_contract: Mapping[str, Any], *, generation_min_chars: int = 4000, generation_target_floor_chars: int = 7000, generation_max_chars: int = 7000, acceptance_min_chars: int = 3001, acceptance_max_chars: int = 10000, ) -> tuple[dict[str, Any], dict[str, Any]]: """把写手目标区间与机械接受底线分开,并保证两层合同不矛盾。""" values = { "generation_min_chars": generation_min_chars, "generation_target_floor_chars": generation_target_floor_chars, "generation_max_chars": generation_max_chars, "acceptance_min_chars": acceptance_min_chars, "acceptance_max_chars": acceptance_max_chars, } if any(isinstance(value, bool) or not isinstance(value, int) for value in values.values()): raise WriterAdapterError("length_contract_invalid", "生产篇幅合同参数必须是整数") dynamic_target = dynamic_contract.get("targetChars") frontmatter_required = dynamic_contract.get("frontmatterRequired") if isinstance(dynamic_target, bool) or not isinstance(dynamic_target, int): raise WriterAdapterError("length_contract_invalid", "动态篇幅合同缺少整数 targetChars") if not isinstance(frontmatter_required, bool): raise WriterAdapterError("length_contract_invalid", "动态篇幅合同缺少布尔 frontmatterRequired") target = min(generation_max_chars, max(generation_target_floor_chars, dynamic_target)) if not ( 0 < acceptance_min_chars <= generation_min_chars <= target <= generation_max_chars <= acceptance_max_chars ): raise WriterAdapterError("length_contract_invalid", "生成篇幅区间必须完整落在机械接受区间内") acceptance_contract = { "targetChars": target, "minChars": acceptance_min_chars, "maxChars": acceptance_max_chars, "frontmatterRequired": frontmatter_required, } generation_contract = { "targetChars": target, "minChars": generation_min_chars, "maxChars": generation_max_chars, "frontmatterRequired": frontmatter_required, } return acceptance_contract, generation_contract 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: RoleExecutionProfile | None, candidate_version: int = 1, governed_chat: Callable[..., Any] | None = None, binding_verifier: Callable[[RoleExecutionProfile], None] = verify_role_profile, run_id: str | None = None, caller: str | None = None, persist_call: Callable[[Mapping[str, Any]], Any] | None = None, ) -> tuple[dict[str, Any], RoleExecutionReceipt]: """调用 writer,由可信 adapter 绑定候选并返回执行回执。 生产记账:run_id 默认取上下文的 runId;生产编排必须显式传入 record-run-evidence 的 persist_call,把模型输入/输出原文和调用明细原子落库(example_raw_content / example_llm_call)。候选记录器 persist_writer_execution 以这些调用明细为前置证据, 缺了拒绝写候选。离线测试注入假 governed_chat,可不提供持久化回调。 """ 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_role( profile, creative_input, 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, governed_chat=governed_chat, ) except RoleRuntimeError as exc: # runtime 只提供受控原因和回执;这里不拼接底层错误详情。 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: RoleExecutionProfile | None = None, candidate_version: int = 1, governed_chat: Callable[..., Any] | None = None, binding_verifier: Callable[[RoleExecutionProfile], None] = verify_role_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, governed_chat=governed_chat, 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_execution_profile", "build_production_length_contracts", "calculate_dynamic_output_contract", "run_writer", "run_writer_with_receipt", ]