263 lines
10 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
"""生产写手的框架派发桥(阶段 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, label: str = "writer") -> tuple[str, Path]:
"""一章一个写作智能体会话:会话 ID 与目录按作品/章稳定,跨运行复用。
label 区分会话用途:生成阶段用默认 "writer"(跨版本积累写作连续性),
两阶段写手的探索阶段用 "writer-explore"(探索历史不混入生成上下文)。
"""
session_id = f"{label}-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,
task_prompt: str | None = None,
creative_input: Mapping[str, Any] | None = None,
enable_read_tools: bool = True,
) -> dict[str, Any]:
"""装配可移植任务包:冻结创作输入原样进 input,不掺框架字段。
task_prompt / creative_input 为覆盖位(缺省保持单阶段派发行为);
两阶段写手的生成阶段用它们注入探索整理的输入并关闭工具。
"""
instruction = (human_instruction or "").strip() or "按冻结创作输入续写本章完整正文。"
default_task_prompt = (
f"为第{target_chapter}章写正文候选(候选版本 {candidate_version})。"
f"人的创作指令:{instruction}"
"先用授权只读工具补齐动笔所需的细纲、人物状态与前文衔接,再写整章;"
"正文中的事实必须来自你实际读到的资料。"
)
return {
"specVersion": "agent-task-v1",
"role": "writer",
"taskPrompt": task_prompt or default_task_prompt,
"input": {
"workId": context.get("workId"),
"targetChapter": target_chapter,
"candidateVersion": candidate_version,
"creativeInput": (
dict(creative_input) if creative_input is not None
else build_writer_creative_input(context)
),
},
"outputSchema": WRITER_DISPATCH_OUTPUT_SCHEMA,
"outputSchemaId": "writer-candidate-body-v1",
"toolAllowlist": list(READ_TOOL_ALLOWLIST) if enable_read_tools else [],
"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 = "",
task_prompt: str | None = None,
creative_input: Mapping[str, Any] | None = None,
enable_read_tools: bool = True,
session_label: str = "writer",
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(失败关闭);不产生半绑定候选。
生成阶段(两阶段写手)传 enable_read_tools=False:不带工具单次成稿。
"""
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,
task_prompt=task_prompt,
creative_input=creative_input,
enable_read_tools=enable_read_tools,
)
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, label=session_label)
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=enable_read_tools,
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",
]