240 lines
9.4 KiB
Python
240 lines
9.4 KiB
Python
#!/usr/bin/env python3
|
||
"""生产写手的框架派发桥(阶段 E 第二部分)。
|
||
|
||
职责分界(边界合同):智能体框架负责模型事件、raw、逐回合调用,记在派发运行下;
|
||
生产编排负责候选、CAS、质量结果与业务回执,记在生产运行下。两者以显式
|
||
`writer_raw_ref`(调用账 id + raw 内容 id)关联,禁止同一模型回合两套记账。
|
||
|
||
桥只做三件事:
|
||
1. 把 WriterContext 装配成可移植任务包(角色=writer、冻结创作输入、输出 Schema);
|
||
2. 经 dispatch-agent-task 公共入口派发(显式 provider/model、会话复用、只读探索工具);
|
||
3. 把派发产出绑定为可信候选信封(身份、哈希、版本由本桥绑定,模型只产正文)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import sys
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
from typing import Any, Callable, Mapping
|
||
|
||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||
DISPATCH_SCRIPTS = SCRIPT_DIR.parents[1] / "dispatch-agent-task" / "scripts"
|
||
ASSEMBLE_SCRIPTS = SCRIPT_DIR.parents[1] / "assemble-context" / "scripts"
|
||
for _path in (DISPATCH_SCRIPTS, ASSEMBLE_SCRIPTS):
|
||
if str(_path) not in sys.path:
|
||
sys.path.insert(0, str(_path))
|
||
|
||
from writer_contract import build_candidate_envelope, build_writer_creative_input # noqa: E402
|
||
from dispatch_agent_task import run_dispatch # noqa: E402
|
||
from pi_runner import ExecutionPolicy # noqa: E402
|
||
from read_tools import TOOL_REGISTRY # noqa: E402
|
||
|
||
# writer 探索白名单:工具 server 登记表的全部只读工具(登记表是唯一事实源)。
|
||
READ_TOOL_ALLOWLIST = tuple(sorted(TOOL_REGISTRY))
|
||
|
||
# writer 只产正文;篇幅合同由机械门与可信适配层校验,不在 Schema 里重复定义语义。
|
||
WRITER_DISPATCH_OUTPUT_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}},
|
||
}
|
||
|
||
DEFAULT_MAX_DURATION_SECONDS = 2400
|
||
SESSION_ROOT = Path("/tmp/muse-agent-runs/writer-sessions")
|
||
|
||
|
||
class DispatchWriterError(RuntimeError):
|
||
"""派发桥失败:携带稳定错误码,编排方按失败关闭处理。"""
|
||
|
||
def __init__(self, code: str, message: str, *, details: Mapping[str, Any] | None = None):
|
||
super().__init__(message)
|
||
self.code = code
|
||
self.details = dict(details or {})
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class DispatchWriterReceipt:
|
||
"""派发回执的生产账本适配:persist_writer_execution 以属性方式读取。"""
|
||
|
||
requested_model_id: str
|
||
actual_model_id: str
|
||
model_match: bool
|
||
usage: Mapping[str, Any]
|
||
total_cost_usd: float | None
|
||
effort: str | None
|
||
stop_reason: str
|
||
terminal_reason: str
|
||
is_error: bool
|
||
dispatch_run_id: str
|
||
|
||
|
||
def writer_session_paths(work_id: int, target_chapter: int) -> tuple[str, Path]:
|
||
"""一章一个写作智能体会话:会话 ID 与目录按作品/章稳定,跨运行复用。"""
|
||
|
||
session_id = f"writer-work{work_id}-ch{target_chapter}"
|
||
session_dir = SESSION_ROOT / session_id
|
||
return session_id, session_dir
|
||
|
||
|
||
def build_writer_dispatch_spec(
|
||
context: Mapping[str, Any],
|
||
*,
|
||
target_chapter: int,
|
||
human_instruction: str,
|
||
candidate_version: int,
|
||
) -> dict[str, Any]:
|
||
"""装配可移植任务包:冻结创作输入原样进 input,不掺框架字段。"""
|
||
|
||
instruction = (human_instruction or "").strip() or "按冻结创作输入续写本章完整正文。"
|
||
return {
|
||
"specVersion": "agent-task-v1",
|
||
"role": "writer",
|
||
"taskPrompt": (
|
||
f"为第{target_chapter}章写正文候选(候选版本 {candidate_version})。"
|
||
f"人的创作指令:{instruction}"
|
||
"先用授权只读工具补齐动笔所需的细纲、人物状态与前文衔接,再写整章;"
|
||
"正文中的事实必须来自你实际读到的资料。"
|
||
),
|
||
"input": {
|
||
"workId": context.get("workId"),
|
||
"targetChapter": target_chapter,
|
||
"candidateVersion": candidate_version,
|
||
"creativeInput": build_writer_creative_input(context),
|
||
},
|
||
"outputSchema": WRITER_DISPATCH_OUTPUT_SCHEMA,
|
||
"outputSchemaId": "writer-candidate-body-v1",
|
||
"toolAllowlist": list(READ_TOOL_ALLOWLIST),
|
||
"maxDurationSeconds": DEFAULT_MAX_DURATION_SECONDS,
|
||
}
|
||
|
||
|
||
def _writer_raw_ref(dispatch_run_id: str, connect_factory: Callable[..., Any] | None) -> tuple[Any, Any]:
|
||
"""从派发运行的模型调用账取最新一条带 raw 的成功调用;缺则失败关闭。"""
|
||
|
||
factory = connect_factory
|
||
if factory is None:
|
||
import muse_db
|
||
|
||
factory = lambda: muse_db.connect(readonly=True) # noqa: E731
|
||
with factory() as conn:
|
||
row = conn.execute(
|
||
"SELECT id, raw_content_id FROM example_llm_call "
|
||
"WHERE run_id=%s AND out_tokens>0 AND raw_content_id IS NOT NULL "
|
||
"ORDER BY id DESC LIMIT 1",
|
||
(dispatch_run_id,),
|
||
).fetchone()
|
||
if not row:
|
||
raise DispatchWriterError(
|
||
"DISPATCH_EVIDENCE_MISSING",
|
||
f"派发运行缺少带 raw 的成功模型调用账:{dispatch_run_id}",
|
||
)
|
||
return row[0], row[1]
|
||
|
||
|
||
def run_writer_via_dispatch(
|
||
context: Mapping[str, Any],
|
||
*,
|
||
candidate_version: int,
|
||
repo_root: str | Path,
|
||
provider: str,
|
||
model: str,
|
||
thinking: str | None = None,
|
||
human_instruction: str = "",
|
||
spec_path: str | Path | None = None,
|
||
launcher: Callable[..., Any] | None = None,
|
||
connect_factory: Callable[..., Any] | None = None,
|
||
) -> tuple[dict[str, Any], DispatchWriterReceipt, tuple[Any, Any]]:
|
||
"""派发一次写作智能体并绑定候选信封;返回(信封、回执适配、证据引用)。
|
||
|
||
任何失败抛 DispatchWriterError(失败关闭);不产生半绑定候选。
|
||
"""
|
||
|
||
run_id = str(context.get("runId") or "")
|
||
work_id = context.get("workId")
|
||
target_chapter = context.get("targetChapter")
|
||
if not run_id or not isinstance(work_id, int) or not isinstance(target_chapter, int):
|
||
raise DispatchWriterError("DISPATCH_CONTEXT_INVALID", "WriterContext 缺 runId/workId/targetChapter")
|
||
|
||
spec = build_writer_dispatch_spec(
|
||
context,
|
||
target_chapter=target_chapter,
|
||
human_instruction=human_instruction,
|
||
candidate_version=candidate_version,
|
||
)
|
||
spec_file = Path(spec_path) if spec_path is not None else SCRIPT_DIR / f"{run_id}-writer-task-v{candidate_version}.json"
|
||
spec_file.parent.mkdir(parents=True, exist_ok=True)
|
||
spec_file.write_text(json.dumps(spec, ensure_ascii=False, indent=1), encoding="utf-8")
|
||
|
||
session_id, session_dir = writer_session_paths(work_id, target_chapter)
|
||
session_dir.mkdir(parents=True, mode=0o700, exist_ok=True)
|
||
dispatch_run_id = f"{run_id}-writer-v{candidate_version}"
|
||
|
||
policy = ExecutionPolicy(provider=provider, model=model, thinking=thinking)
|
||
receipt, code = run_dispatch(
|
||
spec_file,
|
||
repo_root=repo_root,
|
||
policy=policy,
|
||
run_id=dispatch_run_id,
|
||
trigger_source="user",
|
||
trigger_detail={"stage": "writer-dispatch", "productionRunId": run_id},
|
||
session_id=session_id,
|
||
session_dir=session_dir,
|
||
enable_read_tools=True,
|
||
launcher=launcher,
|
||
connect_factory=connect_factory,
|
||
)
|
||
if code != 0 or receipt.get("status") != "completed":
|
||
raise DispatchWriterError(
|
||
str(receipt.get("errorCode") or "DISPATCH_FAILED"),
|
||
f"写作智能体派发未成功:{receipt.get('error') or receipt.get('errorCode')}",
|
||
details={"dispatchRunId": dispatch_run_id, "exitCode": code},
|
||
)
|
||
|
||
# 结构化输出与事件账本同源:回读派发运行目录的 output.json,不另造权威。
|
||
body = None
|
||
output_file = Path(str(receipt.get("runDir") or "")) / "output.json"
|
||
try:
|
||
loaded = json.loads(output_file.read_text(encoding="utf-8"))
|
||
if isinstance(loaded, Mapping):
|
||
body = loaded.get("candidateBody")
|
||
except (OSError, ValueError):
|
||
body = None
|
||
if not isinstance(body, str) or not body.strip():
|
||
raise DispatchWriterError(
|
||
"DISPATCH_OUTPUT_INVALID", "写作智能体未返回可用正文",
|
||
details={"dispatchRunId": dispatch_run_id},
|
||
)
|
||
|
||
envelope = build_candidate_envelope(context, {"candidateBody": body}, candidate_version=candidate_version)
|
||
raw_ref = _writer_raw_ref(dispatch_run_id, connect_factory)
|
||
|
||
model_ids = receipt.get("actualModelIds") or []
|
||
adapter = DispatchWriterReceipt(
|
||
requested_model_id=str(receipt.get("requestedModelId") or f"{provider}/{model}"),
|
||
actual_model_id=str(model_ids[-1]) if model_ids else "",
|
||
model_match=bool(model_ids) and all(mid == receipt.get("requestedModelId") for mid in model_ids),
|
||
usage=dict(receipt.get("usage") or {}),
|
||
total_cost_usd=receipt.get("totalCostUsd"),
|
||
effort=thinking,
|
||
stop_reason="completed",
|
||
terminal_reason="writer-dispatch",
|
||
is_error=False,
|
||
dispatch_run_id=dispatch_run_id,
|
||
)
|
||
return envelope, adapter, raw_ref
|
||
|
||
|
||
__all__ = [
|
||
"DEFAULT_MAX_DURATION_SECONDS",
|
||
"DispatchWriterError",
|
||
"DispatchWriterReceipt",
|
||
"READ_TOOL_ALLOWLIST",
|
||
"WRITER_DISPATCH_OUTPUT_SCHEMA",
|
||
"build_writer_dispatch_spec",
|
||
"run_writer_via_dispatch",
|
||
"writer_session_paths",
|
||
]
|