355 lines
14 KiB
Python
355 lines
14 KiB
Python
#!/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",
|
||
]
|