mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-02 05:17:23 +08:00
Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
This commit is contained in:
@@ -1 +1,3 @@
|
||||
"""离线 Agent 评测,业务模拟、判定器与执行入口各自独立。"""
|
||||
"""
|
||||
离线 Agent 评测,业务模拟、判定器与执行入口各自独立。
|
||||
"""
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"""分别提供离线轨迹回放与显式真实模型评测;业务操作只进入假世界。"""
|
||||
"""
|
||||
分别提供离线轨迹回放与显式真实模型评测;业务操作只进入假世界。
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
@@ -17,7 +19,9 @@ _CALL_FIELDS = frozenset({"operation_id", "path_params", "query", "body"})
|
||||
|
||||
|
||||
def _source_digest(root: Path) -> str:
|
||||
"""按路径和内容记录生产运行时或内置技能,不把源码正文送进模型或报告。"""
|
||||
"""
|
||||
按路径和内容记录生产运行时或内置技能,不把源码正文送进模型或报告。
|
||||
"""
|
||||
digest = hashlib.sha256()
|
||||
for path in sorted(root.rglob("*")):
|
||||
if path.is_file() and "__pycache__" not in path.parts and path.suffix != ".pyc":
|
||||
@@ -26,7 +30,9 @@ def _source_digest(root: Path) -> str:
|
||||
|
||||
|
||||
def _provenance(world: EvaluationWorld) -> dict[str, Any]:
|
||||
"""记录实际场景与判定代码指纹,避免未提交或修改后的轨迹被混为同一基线。"""
|
||||
"""
|
||||
记录实际场景与判定代码指纹,避免未提交或修改后的轨迹被混为同一基线。
|
||||
"""
|
||||
fixture = json.dumps({"scenario": asdict(world.scenario), "initial": world.initial_snapshot()},
|
||||
ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
digest = hashlib.sha256()
|
||||
@@ -40,7 +46,9 @@ def _provenance(world: EvaluationWorld) -> dict[str, Any]:
|
||||
|
||||
|
||||
def replay(scenario_id: str, payload: Any) -> dict[str, Any]:
|
||||
"""只接受调用与最终报告,轨迹不能提供初态、账本或预期答案。"""
|
||||
"""
|
||||
只接受调用与最终报告,轨迹不能提供初态、账本或预期答案。
|
||||
"""
|
||||
if not isinstance(payload, dict) or set(payload) != {"calls", "final"}:
|
||||
raise ValueError("回放必须且只能包含 calls 与 final")
|
||||
calls = payload["calls"]
|
||||
@@ -56,7 +64,9 @@ def replay(scenario_id: str, payload: Any) -> dict[str, Any]:
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""显式区分回放与真实调用,并以非零退出码表达验收未通过。"""
|
||||
"""
|
||||
显式区分回放与真实调用,并以非零退出码表达验收未通过。
|
||||
"""
|
||||
parser = argparse.ArgumentParser(description="MoviePilot Agent 隔离任务评测;--live/--native 才会调用真实模型")
|
||||
mode = parser.add_mutually_exclusive_group(required=True)
|
||||
mode.add_argument("--list", action="store_true", help="列出公开场景,不输出初态或答案")
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"""让原生 Codex CLI 操作同一假世界,模型循环与独立评分器不共用状态。"""
|
||||
"""
|
||||
让原生 Codex CLI 操作同一假世界,模型循环与独立评分器不共用状态。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
@@ -34,12 +36,16 @@ NATIVE_STEERING_MESSAGE_ID = "moviepilot-evaluation-steering"
|
||||
|
||||
|
||||
def _native_steering_message_id(index: int, count: int) -> str:
|
||||
"""为原生多条 steering 事件生成稳定且可审计的消息标识。"""
|
||||
"""
|
||||
为原生多条 steering 事件生成稳定且可审计的消息标识。
|
||||
"""
|
||||
return NATIVE_STEERING_MESSAGE_ID if count == 1 else f"{NATIVE_STEERING_MESSAGE_ID}-{index + 1}"
|
||||
|
||||
|
||||
def _browser_runtime_configuration(executable: str, instruction_dir: Path) -> dict[str, Any] | None:
|
||||
"""发现本机已安装的浏览器插件运行时,缺少任一受信文件就保持原生边界关闭。"""
|
||||
"""
|
||||
发现本机已安装的浏览器插件运行时,缺少任一受信文件就保持原生边界关闭。
|
||||
"""
|
||||
codex_home = Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex")
|
||||
plugin_root = Path(os.environ.get(
|
||||
"MOVIEPILOT_EVAL_BROWSER_PLUGIN_ROOT",
|
||||
@@ -91,9 +97,8 @@ def _browser_runtime_configuration(executable: str, instruction_dir: Path) -> di
|
||||
"BROWSER_USE_BROWSER_CLIENT_BUILD": browser_service_path.parent.parent.name,
|
||||
"CODEX_CLI_PATH": executable,
|
||||
}
|
||||
# The installed skill intentionally uses the portable ``<plugin root>`` token.
|
||||
# A standalone CLI run has no desktop plugin context to expand it, so create
|
||||
# a private copy with the same instructions and the verified absolute root.
|
||||
# 安装后的 Skill 使用可移植的 <plugin root> 标记;独立命令行没有桌面插件上下文,
|
||||
# 因此复制一份相同指令,并用已验证的绝对路径展开该标记。
|
||||
skill = plugin_root / "skills" / "control-in-app-browser" / "SKILL.md"
|
||||
expanded_skill = instruction_dir / "browser-control-skill.md"
|
||||
expanded_skill.write_text(skill.read_text(encoding="utf-8").replace("<plugin root>", str(plugin_root)), encoding="utf-8")
|
||||
@@ -111,14 +116,20 @@ def _browser_runtime_configuration(executable: str, instruction_dir: Path) -> di
|
||||
|
||||
@contextmanager
|
||||
def _native_browser_fixture() -> Any:
|
||||
"""启动只绑定回环地址的动态测试页,并记录真实浏览器的点击回调。"""
|
||||
"""
|
||||
启动只绑定回环地址的动态测试页,并记录真实浏览器的点击回调。
|
||||
"""
|
||||
state = {"clicked": False}
|
||||
|
||||
class _Handler(BaseHTTPRequestHandler):
|
||||
"""提供最小页面和点击回调,不记录请求正文或客户端环境。"""
|
||||
"""
|
||||
提供最小页面和点击回调,不记录请求正文或客户端环境。
|
||||
"""
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802 - 标准库处理器方法名
|
||||
"""返回测试页面或确认按钮回调,其他路径返回 404。"""
|
||||
"""
|
||||
返回测试页面或确认按钮回调,其他路径返回 404。
|
||||
"""
|
||||
if self.path == "/fixture":
|
||||
body = (
|
||||
"<!doctype html><html><head><meta charset='utf-8'><title>MoviePilot Browser Fixture</title></head>"
|
||||
@@ -147,7 +158,9 @@ def _native_browser_fixture() -> Any:
|
||||
self.send_error(404)
|
||||
|
||||
def log_message(self, _format: str, *_args: Any) -> None:
|
||||
"""禁止标准库把本地测试请求写入 stderr。"""
|
||||
"""
|
||||
禁止标准库把本地测试请求写入 stderr。
|
||||
"""
|
||||
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), _Handler)
|
||||
thread = Thread(target=server.serve_forever, name="moviepilot-native-browser", daemon=True)
|
||||
@@ -161,7 +174,9 @@ def _native_browser_fixture() -> Any:
|
||||
|
||||
|
||||
def _catalog(payload: Any, model: str) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""仅修改实际二进制自带模型的工具模式,原生提示词及其他模型字段逐字保留。"""
|
||||
"""
|
||||
仅修改实际二进制自带模型的工具模式,原生提示词及其他模型字段逐字保留。
|
||||
"""
|
||||
if not isinstance(payload, dict) or not isinstance(payload.get("models"), list):
|
||||
raise ValueError("原生模型目录结构无效")
|
||||
matches = [row for row in payload["models"] if isinstance(row, dict) and row.get("slug") == model]
|
||||
@@ -172,7 +187,9 @@ def _catalog(payload: Any, model: str) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
projected["tool_mode"] = "direct"
|
||||
|
||||
def digest(value: Any) -> str:
|
||||
"""对相同序列化规则取指纹,便于复验单字段投影。"""
|
||||
"""
|
||||
对相同序列化规则取指纹,便于复验单字段投影。
|
||||
"""
|
||||
return hashlib.sha256(json.dumps(value, ensure_ascii=False, sort_keys=True).encode()).hexdigest()
|
||||
|
||||
metadata = {"source": "codex_debug_models_bundled", "model": model,
|
||||
@@ -184,14 +201,18 @@ def _catalog(payload: Any, model: str) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
|
||||
|
||||
def _toml(value: Any) -> str:
|
||||
"""按 TOML 表达配置值,直接传 argv,不经过 shell 或其变量展开。"""
|
||||
"""
|
||||
按 TOML 表达配置值,直接传 argv,不经过 shell 或其变量展开。
|
||||
"""
|
||||
if isinstance(value, dict):
|
||||
return "{ " + ", ".join(f"{json.dumps(key)} = {_toml(item)}" for key, item in value.items()) + " }"
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
|
||||
|
||||
def _redact(value: Any, tokens: tuple[str, ...]) -> Any:
|
||||
"""在全部事件和最终 JSON 解码后移除局部令牌,Unicode 转义不能绕过。"""
|
||||
"""
|
||||
在全部事件和最终 JSON 解码后移除局部令牌,Unicode 转义不能绕过。
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
for token in tokens:
|
||||
value = value.replace(token, "[local evaluation token]")
|
||||
@@ -206,7 +227,9 @@ def _redact(value: Any, tokens: tuple[str, ...]) -> Any:
|
||||
def _configuration(settings: ModelSettings, proxy: EvaluationModelProxy, server: EvaluationMcpServer,
|
||||
control_dir: Path, *, scenario_id: str = "",
|
||||
browser_runtime: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""显式隔离原生客户端的外部能力,仍保留其默认提示词、计划及子代理循环。"""
|
||||
"""
|
||||
显式隔离原生客户端的外部能力,仍保留其默认提示词、计划及子代理循环。
|
||||
"""
|
||||
disabled = [
|
||||
"apps", "browser_use", "browser_use_external", "browser_use_full_cdp_access", "computer_use",
|
||||
"in_app_browser", "in_app_chat", "image_generation", "view_image", "hooks", "memories", "plugins",
|
||||
@@ -269,15 +292,16 @@ def _configuration(settings: ModelSettings, proxy: EvaluationModelProxy, server:
|
||||
"marketplaces.openai-bundled": browser_runtime["marketplace"],
|
||||
"plugins.browser@openai-bundled": browser_runtime["plugins"]["browser@openai-bundled"],
|
||||
"plugins.chrome@openai-bundled": browser_runtime["plugins"]["chrome@openai-bundled"],
|
||||
# Standalone exec does not inherit the desktop's selected-plugin context;
|
||||
# feed the installed Browser skill through the native instructions hook.
|
||||
# 独立执行不继承桌面端已选插件上下文,需通过原生指令入口加载浏览器 Skill。
|
||||
"model_instructions_file": browser_runtime["skill"],
|
||||
})
|
||||
return configuration
|
||||
|
||||
|
||||
async def _read_output(stream: asyncio.StreamReader, result: bytearray) -> None:
|
||||
"""持续排空子进程管道,输出超限时让控制器终止整组进程。"""
|
||||
"""
|
||||
持续排空子进程管道,输出超限时让控制器终止整组进程。
|
||||
"""
|
||||
while chunk := await stream.read(65536):
|
||||
remaining = MAX_PROCESS_OUTPUT_BYTES - len(result)
|
||||
result.extend(chunk[:remaining])
|
||||
@@ -286,7 +310,9 @@ async def _read_output(stream: asyncio.StreamReader, result: bytearray) -> None:
|
||||
|
||||
|
||||
async def _stop_process(process: asyncio.subprocess.Process) -> None:
|
||||
"""父任务取消或超时时回收原生客户端及继承进程组,不遗留后台评测。"""
|
||||
"""
|
||||
父任务取消或超时时回收原生客户端及继承进程组,不遗留后台评测。
|
||||
"""
|
||||
if os.name == "posix":
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
@@ -299,7 +325,9 @@ async def _stop_process(process: asyncio.subprocess.Process) -> None:
|
||||
|
||||
async def _execute(command: list[str], prompt: str, environment: dict[str, str],
|
||||
work_dir: Path, timeout: int) -> dict[str, Any]:
|
||||
"""只向原生 stdin 发送公开任务,结构化事件和退出状态由控制器采集。"""
|
||||
"""
|
||||
只向原生 stdin 发送公开任务,结构化事件和退出状态由控制器采集。
|
||||
"""
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*command, cwd=work_dir, env=environment, stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, start_new_session=os.name == "posix",
|
||||
@@ -333,7 +361,9 @@ async def _execute(command: list[str], prompt: str, environment: dict[str, str],
|
||||
|
||||
|
||||
def _normalize_app_server_item(item: Any) -> dict[str, Any]:
|
||||
"""把 app-server 的 camelCase item 转成评测器共用的安全事件字段。"""
|
||||
"""
|
||||
把 app-server 的 camelCase item 转成评测器共用的安全事件字段。
|
||||
"""
|
||||
if not isinstance(item, dict):
|
||||
return {"type": "unknown"}
|
||||
item_type = item.get("type")
|
||||
@@ -397,7 +427,9 @@ def _normalize_app_server_item(item: Any) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _normalize_app_server_notification(method: str, params: Any) -> dict[str, Any] | None:
|
||||
"""保留终端、消息和轮次终态,把其他 app-server 通知降为可审计摘要。"""
|
||||
"""
|
||||
保留终端、消息和轮次终态,把其他 app-server 通知降为可审计摘要。
|
||||
"""
|
||||
payload = params if isinstance(params, dict) else {}
|
||||
if method == "turn/started":
|
||||
turn = payload.get("turn") if isinstance(payload.get("turn"), dict) else {}
|
||||
@@ -439,7 +471,9 @@ def _normalize_app_server_notification(method: str, params: Any) -> dict[str, An
|
||||
|
||||
|
||||
def _append_thread_history_events(events: list[dict[str, Any]], thread: Any) -> None:
|
||||
"""把 thread/read 返回的子代理历史转换为带 scope 的完成事件。"""
|
||||
"""
|
||||
把 thread/read 返回的子代理历史转换为带 scope 的完成事件。
|
||||
"""
|
||||
if not isinstance(thread, dict):
|
||||
return
|
||||
thread_id = thread.get("id")
|
||||
@@ -475,7 +509,9 @@ async def _execute_app_server(
|
||||
*, model: str, reasoning_effort: str, steering_message: str = "",
|
||||
steering_plan: tuple[tuple[int, str], ...] = (),
|
||||
) -> dict[str, Any]:
|
||||
"""通过 Codex app-server 的 JSON-RPC 驱动终端和多条中途补充消息。"""
|
||||
"""
|
||||
通过 Codex app-server 的 JSON-RPC 驱动终端和多条中途补充消息。
|
||||
"""
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*command, cwd=work_dir, env=environment, stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, start_new_session=os.name == "posix",
|
||||
@@ -504,7 +540,9 @@ async def _execute_app_server(
|
||||
child_thread_read_requested: set[str] = set()
|
||||
|
||||
async def send_request(method: str, params: dict[str, Any]) -> int:
|
||||
"""发送带序号的 JSON-RPC 请求,并记录响应所属方法。"""
|
||||
"""
|
||||
发送带序号的 JSON-RPC 请求,并记录响应所属方法。
|
||||
"""
|
||||
nonlocal next_request_id
|
||||
request_id = next_request_id
|
||||
next_request_id += 1
|
||||
@@ -516,12 +554,16 @@ async def _execute_app_server(
|
||||
return request_id
|
||||
|
||||
async def send_notification(method: str) -> None:
|
||||
"""发送不等待响应的 JSON-RPC 通知。"""
|
||||
"""
|
||||
发送不等待响应的 JSON-RPC 通知。
|
||||
"""
|
||||
process.stdin.write((json.dumps({"jsonrpc": "2.0", "method": method}) + "\n").encode())
|
||||
await process.stdin.drain()
|
||||
|
||||
async def send_server_error(request_id: Any) -> None:
|
||||
"""拒绝意外的原生服务端请求,避免评测控制器代替用户授权。"""
|
||||
"""
|
||||
拒绝意外的原生服务端请求,避免评测控制器代替用户授权。
|
||||
"""
|
||||
process.stdin.write(
|
||||
(json.dumps({
|
||||
"jsonrpc": "2.0", "id": request_id,
|
||||
@@ -531,7 +573,9 @@ async def _execute_app_server(
|
||||
await process.stdin.drain()
|
||||
|
||||
async def send_child_thread_reads_if_ready() -> None:
|
||||
"""在父轮次结束后读取已完成子代理历史,补齐可能遗漏的终端 item。"""
|
||||
"""
|
||||
在父轮次结束后读取已完成子代理历史,补齐可能遗漏的终端 item。
|
||||
"""
|
||||
if not turn_completed:
|
||||
return
|
||||
for child_id in sorted(child_thread_ids - child_thread_read_requested):
|
||||
@@ -540,7 +584,9 @@ async def _execute_app_server(
|
||||
child_thread_read_requested.add(child_id)
|
||||
|
||||
async def send_next_steering_if_ready() -> None:
|
||||
"""在达到计划回执边界后逐条发送 steering,并保留请求身份。"""
|
||||
"""
|
||||
在达到计划回执边界后逐条发送 steering,并保留请求身份。
|
||||
"""
|
||||
nonlocal failure, steering_index, steering_request_pending
|
||||
if steering_request_pending or steering_index >= len(schedule):
|
||||
return
|
||||
@@ -571,7 +617,9 @@ async def _execute_app_server(
|
||||
steering_request_pending = True
|
||||
|
||||
async def handle_response(payload: dict[str, Any]) -> None:
|
||||
"""按 initialize、thread/start、turn/start 的顺序推进原生轮次。"""
|
||||
"""
|
||||
按 initialize、thread/start、turn/start 的顺序推进原生轮次。
|
||||
"""
|
||||
nonlocal thread_id, failure, steering_request_pending
|
||||
request_id = payload.get("id")
|
||||
method = pending.pop(request_id, None) if type(request_id) is int else None
|
||||
@@ -714,7 +762,9 @@ async def _execute_app_server(
|
||||
|
||||
|
||||
def _events(text: str) -> tuple[list[dict[str, Any]], str, bool]:
|
||||
"""保留 JSONL 原生事件;只有完成事件之后的有效最终报告才由 oracle 判断。"""
|
||||
"""
|
||||
保留 JSONL 原生事件;只有完成事件之后的有效最终报告才由 oracle 判断。
|
||||
"""
|
||||
events, final_text, completed, malformed = [], "", False, False
|
||||
for line in text.splitlines():
|
||||
try:
|
||||
@@ -739,7 +789,9 @@ def _events(text: str) -> tuple[list[dict[str, Any]], str, bool]:
|
||||
|
||||
|
||||
def _record_native_command_events(world: EvaluationWorld, events: list[dict[str, Any]]) -> None:
|
||||
"""把 CLI 的已完成命令事件映射为同一场景账本,避免只看模型文字。"""
|
||||
"""
|
||||
把 CLI 的已完成命令事件映射为同一场景账本,避免只看模型文字。
|
||||
"""
|
||||
seen: set[str] = set()
|
||||
streamed_output: dict[str, str] = {}
|
||||
streamed_input: set[str] = set()
|
||||
@@ -805,7 +857,9 @@ def _record_native_command_events(world: EvaluationWorld, events: list[dict[str,
|
||||
|
||||
|
||||
def _probe_ready(usage: dict[str, Any], server_stats: dict[str, Any]) -> bool:
|
||||
"""探针必须到达实际业务工具目录且没有真实模型或业务调用,仅有计划工具不算就绪。"""
|
||||
"""
|
||||
探针必须到达实际业务工具目录且没有真实模型或业务调用,仅有计划工具不算就绪。
|
||||
"""
|
||||
retained = {name for record in usage.get("model_requests", []) for name in record.get("retained_tools", [])}
|
||||
fixture = {f"mcp__evaluation.{name}" for name in ("moviepilot_api", "read_skill", "read_tool_result")}
|
||||
return bool(usage.get("probe_requests") == 1 and not usage.get("model_calls")
|
||||
@@ -814,7 +868,9 @@ def _probe_ready(usage: dict[str, Any], server_stats: dict[str, Any]) -> bool:
|
||||
|
||||
|
||||
async def _run_codex(scenario_id: str, settings: ModelSettings, executable: str, *, probe_only: bool) -> dict[str, Any]:
|
||||
"""每次运行创建新目录、新模型预算和新世界,源码与 oracle 从不进入模型工作目录。"""
|
||||
"""
|
||||
每次运行创建新目录、新模型预算和新世界,源码与 oracle 从不进入模型工作目录。
|
||||
"""
|
||||
from scripts.evaluation.__main__ import _provenance
|
||||
|
||||
world = EvaluationWorld(scenario_id)
|
||||
@@ -891,8 +947,7 @@ async def _run_codex(scenario_id: str, settings: ModelSettings, executable: str,
|
||||
server_stats = server.stats
|
||||
skill_sha256 = server.skill_sha256
|
||||
if fixture is not None and fixture[1].get("clicked"):
|
||||
# The native browser plugin bypasses the evaluation MCP server; the
|
||||
# loopback callback is the independent proof that the button click ran.
|
||||
# 原生浏览器插件绕过评测 MCP 服务;回环回调独立证明按钮点击已执行。
|
||||
world.record_browser("native_browser", {
|
||||
"success": True, "execution_outcome": "succeeded", "rendered": "BROWSER_OK",
|
||||
})
|
||||
@@ -948,7 +1003,9 @@ async def _run_codex(scenario_id: str, settings: ModelSettings, executable: str,
|
||||
|
||||
|
||||
def run_codex(scenario_id: str, settings: ModelSettings, *, executable: str = "codex", probe_only: bool = False) -> dict[str, Any]:
|
||||
"""仅运行已核对的原生版本,探针不会向真实供应商发送请求。"""
|
||||
"""
|
||||
仅运行已核对的原生版本,探针不会向真实供应商发送请求。
|
||||
"""
|
||||
if settings.wire_api != "responses":
|
||||
raise RuntimeError("原生 Codex 对照目前要求 Responses provider;当前配置是 Chat Completions")
|
||||
binary = shutil.which(executable)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"""校验 MoviePilot 与原生 Codex 的同条件评测报告并生成成对摘要。"""
|
||||
"""
|
||||
校验 MoviePilot 与原生 Codex 的同条件评测报告并生成成对摘要。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -18,7 +20,9 @@ _EVIDENCE_KINDS = {
|
||||
|
||||
|
||||
def _read_report(path: Path) -> dict[str, Any]:
|
||||
"""读取有限大小的 JSON 报告,拒绝目录、超限或非对象正文。"""
|
||||
"""
|
||||
读取有限大小的 JSON 报告,拒绝目录、超限或非对象正文。
|
||||
"""
|
||||
if not path.is_file():
|
||||
raise ValueError(f"评测报告不存在:{path}")
|
||||
if path.stat().st_size > MAX_REPORT_BYTES:
|
||||
@@ -33,7 +37,9 @@ def _read_report(path: Path) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _conditions(report: dict[str, Any]) -> dict[str, Any]:
|
||||
"""提取必须一致的模型条件,不把令牌或完整工具正文带入比较结果。"""
|
||||
"""
|
||||
提取必须一致的模型条件,不把令牌或完整工具正文带入比较结果。
|
||||
"""
|
||||
model = report.get("model")
|
||||
if not isinstance(model, dict):
|
||||
raise ValueError("评测报告缺少模型条件")
|
||||
@@ -41,7 +47,9 @@ def _conditions(report: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _metric(report: dict[str, Any], key: str) -> Any:
|
||||
"""读取报告中的公开轨迹指标,未知用量保持 None 而不是补零。"""
|
||||
"""
|
||||
读取报告中的公开轨迹指标,未知用量保持 None 而不是补零。
|
||||
"""
|
||||
if key == "total_tokens":
|
||||
tokens = report.get("tokens")
|
||||
return tokens.get("total_tokens") if isinstance(tokens, dict) else None
|
||||
@@ -49,7 +57,9 @@ def _metric(report: dict[str, Any], key: str) -> Any:
|
||||
|
||||
|
||||
def _side(report: dict[str, Any], role: str) -> dict[str, Any]:
|
||||
"""生成不含最终正文、工具参数和凭据的单侧摘要。"""
|
||||
"""
|
||||
生成不含最终正文、工具参数和凭据的单侧摘要。
|
||||
"""
|
||||
return {
|
||||
"role": role,
|
||||
"passed": report.get("passed"),
|
||||
@@ -68,7 +78,9 @@ def _side(report: dict[str, Any], role: str) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _delta(moviepilot: dict[str, Any], codex: dict[str, Any], *, usage_comparable: bool = True) -> dict[str, Any]:
|
||||
"""计算两个方向的数值差;任一侧未知时保留 unknown。"""
|
||||
"""
|
||||
计算两个方向的数值差;任一侧未知时保留 unknown。
|
||||
"""
|
||||
result: dict[str, Any] = {}
|
||||
for key in ("model_calls", "completed_model_calls", "failed_tool_calls", "tool_calls",
|
||||
"duplicate_attempts", "side_effects", "elapsed_seconds", "total_tokens"):
|
||||
@@ -81,7 +93,9 @@ def _delta(moviepilot: dict[str, Any], codex: dict[str, Any], *, usage_comparabl
|
||||
|
||||
|
||||
def _has_cancel_action(report: dict[str, Any]) -> bool:
|
||||
"""确认取消场景确实发出了子代理取消动作,而不是凭场景名放宽用量门禁。"""
|
||||
"""
|
||||
确认取消场景确实发出了子代理取消动作,而不是凭场景名放宽用量门禁。
|
||||
"""
|
||||
trace = report.get("agent_trace") if report.get("evidence_kind") == "moviepilot_live_model" else report.get("native_events")
|
||||
if not isinstance(trace, list):
|
||||
return False
|
||||
@@ -110,7 +124,9 @@ def _has_cancel_action(report: dict[str, Any]) -> bool:
|
||||
|
||||
|
||||
def _intentional_cancel_usage(report: dict[str, Any]) -> bool:
|
||||
"""确认取消场景的执行质量,并约束未完整用量只能来自一个取消请求。"""
|
||||
"""
|
||||
确认取消场景的执行质量,并约束未完整用量只能来自一个取消请求。
|
||||
"""
|
||||
if report.get("scenario_id") != "subagent_cancel_recovery":
|
||||
return False
|
||||
calls = report.get("model_calls")
|
||||
@@ -134,7 +150,9 @@ def _intentional_cancel_usage(report: dict[str, Any]) -> bool:
|
||||
|
||||
|
||||
def compare_reports(moviepilot: dict[str, Any], codex: dict[str, Any]) -> dict[str, Any]:
|
||||
"""仅在同场景、同源码和同模型预算成立时生成 Codex 成对比较摘要。"""
|
||||
"""
|
||||
仅在同场景、同源码和同模型预算成立时生成 Codex 成对比较摘要。
|
||||
"""
|
||||
roles = {}
|
||||
for report in (moviepilot, codex):
|
||||
evidence_kind = report.get("evidence_kind")
|
||||
@@ -187,5 +205,7 @@ def compare_reports(moviepilot: dict[str, Any], codex: dict[str, Any]) -> dict[s
|
||||
|
||||
|
||||
def compare_report_files(moviepilot_path: Path, codex_path: Path) -> dict[str, Any]:
|
||||
"""从两个路径读取报告并执行严格成对校验。"""
|
||||
"""
|
||||
从两个路径读取报告并执行严格成对校验。
|
||||
"""
|
||||
return compare_reports(_read_report(moviepilot_path), _read_report(codex_path))
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"""真实模型评测的独立进程边界,业务调用仍只进入内存假世界。"""
|
||||
"""
|
||||
真实模型评测的独立进程边界,业务调用仍只进入内存假世界。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
@@ -18,7 +20,9 @@ from scripts.evaluation.models import ModelSettings, ModelUsageTracker
|
||||
|
||||
|
||||
def _build_model(settings: ModelSettings, tracker: ModelUsageTracker) -> tuple[Any, str]:
|
||||
"""按显式 provider 选择评测模型,并让官方 Gemini 走原生工具协议。"""
|
||||
"""
|
||||
按显式 provider 选择评测模型,并让官方 Gemini 走原生工具协议。
|
||||
"""
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
if settings.auth_mode == "codex_oauth":
|
||||
@@ -105,7 +109,9 @@ def _build_model(settings: ModelSettings, tracker: ModelUsageTracker) -> tuple[A
|
||||
|
||||
|
||||
async def _close_model_clients(model: Any) -> list[str]:
|
||||
"""关闭不同 LangChain provider 暴露的客户端,保留清理失败类型。"""
|
||||
"""
|
||||
关闭不同 LangChain provider 暴露的客户端,保留清理失败类型。
|
||||
"""
|
||||
cleanup_errors: list[str] = []
|
||||
for attribute in ("root_async_client", "root_client"):
|
||||
try:
|
||||
@@ -125,7 +131,9 @@ async def _close_model_clients(model: Any) -> list[str]:
|
||||
|
||||
|
||||
def _worker_environment() -> dict[str, str]:
|
||||
"""只传递解释器所需平台环境;业务设置、代理和其他服务凭据不进入评测。"""
|
||||
"""
|
||||
只传递解释器所需平台环境;业务设置、代理和其他服务凭据不进入评测。
|
||||
"""
|
||||
allowed = {"PATH", "SYSTEMROOT", "WINDIR", "LANG", "TMPDIR", "TMP", "TEMP"}
|
||||
environment = {name: value for name, value in os.environ.items() if name in allowed or name.startswith("LC_")}
|
||||
environment.update(PYTHONNOUSERSITE="1", PYTHONUTF8="1")
|
||||
@@ -133,7 +141,9 @@ def _worker_environment() -> dict[str, str]:
|
||||
|
||||
|
||||
def _validate_report(report: Any, credential: str, *additional_credentials: str | None) -> dict[str, Any]:
|
||||
"""对解码后的键和值检查凭据,JSON 转义不能绕过输出边界。"""
|
||||
"""
|
||||
对解码后的键和值检查凭据,JSON 转义不能绕过输出边界。
|
||||
"""
|
||||
credentials = tuple(value for value in (credential, *additional_credentials) if isinstance(value, str) and value)
|
||||
if not isinstance(report, dict):
|
||||
raise RuntimeError("隔离模型评测报告未通过输出校验")
|
||||
@@ -151,7 +161,9 @@ def _validate_report(report: Any, credential: str, *additional_credentials: str
|
||||
|
||||
|
||||
def run_live(scenario_id: str, settings: ModelSettings) -> dict[str, Any]:
|
||||
"""经标准输入传递连接凭据,独立进程避免复用调用方已经加载的真实配置。"""
|
||||
"""
|
||||
经标准输入传递连接凭据,独立进程避免复用调用方已经加载的真实配置。
|
||||
"""
|
||||
payload = json.dumps({"scenario_id": scenario_id, "settings": asdict(settings)})
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
@@ -172,7 +184,9 @@ def run_live(scenario_id: str, settings: ModelSettings) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _parse_final(text: str) -> Any:
|
||||
"""接受普通 JSON 或唯一 JSON 围栏,允许模型在围栏外补充说明。"""
|
||||
"""
|
||||
接受普通 JSON 或唯一 JSON 围栏,允许模型在围栏外补充说明。
|
||||
"""
|
||||
value = text.strip()
|
||||
fenced = re.findall(r"```(?P<language>[A-Za-z0-9_-]*)\s*\n(?P<body>.*?)```", value, re.DOTALL)
|
||||
if fenced:
|
||||
@@ -186,7 +200,9 @@ def _parse_final(text: str) -> Any:
|
||||
|
||||
|
||||
def _trace_metrics(messages: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""补充业务账本之外的工具调用;压缩后仅统计保留的父图消息,不冒充全部子图历史。"""
|
||||
"""
|
||||
补充业务账本之外的工具调用;压缩后仅统计保留的父图消息,不冒充全部子图历史。
|
||||
"""
|
||||
requests = sum(len(message["data"].get("tool_calls", [])) for message in messages if message["type"] == "ai")
|
||||
results = [message["data"] for message in messages if message["type"] == "tool"]
|
||||
return {"scope": "retained_parent_graph", "requested": requests, "results": len(results),
|
||||
@@ -194,7 +210,9 @@ def _trace_metrics(messages: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
|
||||
|
||||
async def _run_worker(scenario_id: str, settings: ModelSettings) -> dict[str, Any]:
|
||||
"""创建临时宿主环境,直到此处才导入业务运行时和建立真实模型连接。"""
|
||||
"""
|
||||
创建临时宿主环境,直到此处才导入业务运行时和建立真实模型连接。
|
||||
"""
|
||||
if "app.runtime.config" in sys.modules:
|
||||
raise RuntimeError("真实评测 worker 必须在业务导入前隔离配置")
|
||||
with tempfile.TemporaryDirectory(prefix="moviepilot-model-evaluation-") as config_dir:
|
||||
@@ -258,7 +276,9 @@ async def _run_worker(scenario_id: str, settings: ModelSettings) -> dict[str, An
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""worker 只从私有 stdin 接收配置,stdout 只输出无凭据评测报告。"""
|
||||
"""
|
||||
worker 只从私有 stdin 接收配置,stdout 只输出无凭据评测报告。
|
||||
"""
|
||||
if sys.argv[1:] != ["--worker"]:
|
||||
raise SystemExit("请通过 python -m scripts.evaluation --live 启动")
|
||||
payload = json.loads(sys.stdin.read(256 * 1024))
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"""读取明确配置的模型连接,并为真实评测统计所有模型调用。"""
|
||||
"""
|
||||
读取明确配置的模型连接,并为真实评测统计所有模型调用。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
@@ -19,7 +21,21 @@ _CODEX_OAUTH_BASE_URL = "https://chatgpt.com/backend-api/codex"
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModelSettings:
|
||||
"""评测连接只在控制进程使用,凭据不进入 repr 或公开报告。"""
|
||||
"""
|
||||
评测连接只在控制进程使用,凭据不进入 repr 或公开报告。
|
||||
|
||||
:param model: 显式选择的模型名称
|
||||
:param base_url: 模型服务地址,不允许内嵌凭据、查询参数或片段
|
||||
:param api_key: 控制进程使用的评测凭据,不进入公开报告
|
||||
:param reasoning_effort: 推理预算等级,默认 high
|
||||
:param max_model_calls: 单次评测最多允许的模型调用次数
|
||||
:param max_output_tokens: 单次模型调用的最大输出令牌数
|
||||
:param timeout_seconds: 单次评测的超时秒数
|
||||
:param context_window: 评测使用的上下文窗口令牌数
|
||||
:param wire_api: 模型请求协议,默认 responses
|
||||
:param auth_mode: 凭据来源模式,默认显式提供
|
||||
:param account_id: 可选的账户标识,不进入公开报告
|
||||
"""
|
||||
|
||||
model: str
|
||||
base_url: str = field(repr=False)
|
||||
@@ -34,7 +50,9 @@ class ModelSettings:
|
||||
account_id: str | None = field(default=None, repr=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""配置文件和 worker 输入共享校验,不能绕过限额或夹带 URL 凭据。"""
|
||||
"""
|
||||
配置文件和 worker 输入共享校验,不能绕过限额或夹带 URL 凭据。
|
||||
"""
|
||||
if not isinstance(self.base_url, str):
|
||||
raise ValueError("模型 endpoint 无效")
|
||||
endpoint = urlsplit(self.base_url)
|
||||
@@ -67,7 +85,9 @@ class ModelSettings:
|
||||
raise ValueError("评测预算超出允许范围")
|
||||
|
||||
def public_metadata(self) -> dict[str, Any]:
|
||||
"""公开模型与评测预算,省略凭据、完整连接路径和用户配置文件。"""
|
||||
"""
|
||||
公开模型与评测预算,省略凭据、完整连接路径和用户配置文件。
|
||||
"""
|
||||
return {
|
||||
"requested_model": self.model, "provider_host": urlsplit(self.base_url).hostname,
|
||||
"wire_api": self.wire_api,
|
||||
@@ -83,7 +103,9 @@ def load_codex_model_settings(
|
||||
max_model_calls: int = 12, max_output_tokens: int = 8192, timeout_seconds: int = 180,
|
||||
probe_only: bool = False, use_codex_auth: bool = False,
|
||||
) -> ModelSettings:
|
||||
"""读取选中 provider;真实运行只接受显式凭据或用户明确选择的 Codex OAuth。"""
|
||||
"""
|
||||
读取选中 provider;真实运行只接受显式凭据或用户明确选择的 Codex OAuth。
|
||||
"""
|
||||
config = tomllib.loads(path.read_text(encoding="utf-8"))
|
||||
provider = config.get("model_providers", {}).get(config.get("model_provider"), {})
|
||||
wire_api = provider.get("wire_api")
|
||||
@@ -124,25 +146,33 @@ def load_codex_model_settings(
|
||||
|
||||
|
||||
class ModelCallLimitError(RuntimeError):
|
||||
"""在下一次真实请求之前拒绝超出评测预算的模型调用。"""
|
||||
"""
|
||||
在下一次真实请求之前拒绝超出评测预算的模型调用。
|
||||
"""
|
||||
|
||||
|
||||
# strict mypy 的 follow_imports=skip 不展开第三方回调基类。
|
||||
class ModelUsageTracker(BaseCallbackHandler): # type: ignore[misc]
|
||||
"""合计主模型、筛选、摘要与子代理的请求,不把缺失 usage 当成零消耗。"""
|
||||
"""
|
||||
合计主模型、筛选、摘要与子代理的请求,不把缺失 usage 当成零消耗。
|
||||
"""
|
||||
|
||||
raise_error = True
|
||||
run_inline = True
|
||||
|
||||
def __init__(self, limit: int) -> None:
|
||||
"""建立单次评测共享的并发安全计数器。"""
|
||||
"""
|
||||
建立单次评测共享的并发安全计数器。
|
||||
"""
|
||||
self.limit = limit
|
||||
self._lock = threading.Lock()
|
||||
self._runs: dict[str, dict[str, Any]] = {}
|
||||
self.blocked_calls = 0
|
||||
|
||||
def on_chat_model_start(self, serialized: dict[str, Any], messages: list[Any], **kwargs: Any) -> None:
|
||||
"""原子执行预算判断,既不记录提示词,也不允许并行调用越过上限。"""
|
||||
"""
|
||||
原子执行预算判断,既不记录提示词,也不允许并行调用越过上限。
|
||||
"""
|
||||
run_id = str(kwargs["run_id"])
|
||||
with self._lock:
|
||||
if run_id in self._runs:
|
||||
@@ -153,7 +183,9 @@ class ModelUsageTracker(BaseCallbackHandler): # type: ignore[misc]
|
||||
self._runs[run_id] = {"started": time.monotonic(), "completed": False, "usage": None, "model": None}
|
||||
|
||||
def on_llm_end(self, response: Any, **kwargs: Any) -> None:
|
||||
"""只提取用量与模型标识,不把原始响应或凭据带入统计。"""
|
||||
"""
|
||||
只提取用量与模型标识,不把原始响应或凭据带入统计。
|
||||
"""
|
||||
generation = next((item for group in getattr(response, "generations", []) for item in group), None)
|
||||
message = getattr(generation, "message", None)
|
||||
usage = getattr(message, "usage_metadata", None)
|
||||
@@ -171,7 +203,9 @@ class ModelUsageTracker(BaseCallbackHandler): # type: ignore[misc]
|
||||
record.update(completed=True, usage=usage, model=metadata.get("model_name") or metadata.get("model"))
|
||||
|
||||
def on_llm_error(self, error: BaseException, **kwargs: Any) -> None:
|
||||
"""错误请求保留为用量未知,不保存可能包含私有响应的异常文本。"""
|
||||
"""
|
||||
错误请求保留为用量未知,不保存可能包含私有响应的异常文本。
|
||||
"""
|
||||
with self._lock:
|
||||
record = self._runs.get(str(kwargs["run_id"]))
|
||||
if record is not None:
|
||||
@@ -180,7 +214,9 @@ class ModelUsageTracker(BaseCallbackHandler): # type: ignore[misc]
|
||||
record["http_status"] = status if isinstance(status, int) else None
|
||||
|
||||
def snapshot(self) -> dict[str, Any]:
|
||||
"""返回可审计的已知消耗及覆盖度;部分失败时数字仅为已知下界。"""
|
||||
"""
|
||||
返回可审计的已知消耗及覆盖度;部分失败时数字仅为已知下界。
|
||||
"""
|
||||
with self._lock:
|
||||
records = list(self._runs.values())
|
||||
known = [record["usage"] for record in records if isinstance(record["usage"], dict)]
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"""固定只读聚合程序与独立假世界验收;不把任意模型代码带入真实宿主。"""
|
||||
"""
|
||||
固定只读聚合程序与独立假世界验收;不把任意模型代码带入真实宿主。
|
||||
"""
|
||||
|
||||
import json
|
||||
from collections import Counter
|
||||
@@ -35,7 +37,9 @@ print(json.dumps({"status": "completed", "subscription_count": len(subscriptions
|
||||
|
||||
|
||||
def seed_code_state(state: dict[str, Any]) -> None:
|
||||
"""45条长订阅强制跨页,站点资源只来自已缓存搜索,不调用有副作用的站点抓取。"""
|
||||
"""
|
||||
45条长订阅强制跨页,站点资源只来自已缓存搜索,不调用有副作用的站点抓取。
|
||||
"""
|
||||
state['subscriptions'] = [{'id': index + 1, 'name': f'订阅 {index}', 'media_source': 'themoviedb',
|
||||
'media_id': str(8000 + index), 'description': '分页原文证据' * 2000}
|
||||
for index in range(45)]
|
||||
@@ -46,7 +50,9 @@ def seed_code_state(state: dict[str, Any]) -> None:
|
||||
|
||||
|
||||
def expected_code_report(state: dict[str, Any]) -> dict[str, Any]:
|
||||
"""验收器从隐藏终态独立计算事实,不能用模型输出或程序 stdout 生成答案。"""
|
||||
"""
|
||||
验收器从隐藏终态独立计算事实,不能用模型输出或程序 stdout 生成答案。
|
||||
"""
|
||||
resources = [entry['torrent_info'] for entry in state['resources']]
|
||||
free = [row for row in resources if row['downloadvolumefactor'] == min(item['downloadvolumefactor'] for item in resources)]
|
||||
winner = sorted(free, key=lambda row: (-row['seeders'], row['site']))[0]
|
||||
@@ -58,7 +64,9 @@ def expected_code_report(state: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
|
||||
def check_code_report(state: dict[str, Any], report: dict[str, Any], ledger: list[dict[str, Any]], trace: Any) -> list[str]:
|
||||
"""完整读取证据、生产 Python 成功回执和最终答案必须同时成立。"""
|
||||
"""
|
||||
完整读取证据、生产 Python 成功回执和最终答案必须同时成立。
|
||||
"""
|
||||
errors = []
|
||||
expected = expected_code_report(state)
|
||||
if report != expected:
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"""给原生客户端提供有调用上限的模型连接,凭据和业务答案留在控制器。"""
|
||||
"""
|
||||
给原生客户端提供有调用上限的模型连接,凭据和业务答案留在控制器。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
@@ -46,7 +48,9 @@ FIXTURE_TOOLS = frozenset({"moviepilot_api", "read_skill", "read_tool_result"})
|
||||
|
||||
|
||||
def _allowed_mcp_tool(normalized: str, extra_native_tools: frozenset[str]) -> bool:
|
||||
"""只放行评测夹具或当前浏览器场景明确授予的原生 MCP 工具。"""
|
||||
"""
|
||||
只放行评测夹具或当前浏览器场景明确授予的原生 MCP 工具。
|
||||
"""
|
||||
if normalized == "mcp__evaluation" or normalized.startswith("mcp__evaluation__"):
|
||||
return normalized == "mcp__evaluation" or normalized.removeprefix("mcp__evaluation__") in FIXTURE_TOOLS
|
||||
if normalized == "mcp__node_repl":
|
||||
@@ -55,7 +59,9 @@ def _allowed_mcp_tool(normalized: str, extra_native_tools: frozenset[str]) -> bo
|
||||
|
||||
|
||||
def allowed_tool(name: str, extra_native_tools: frozenset[str] = frozenset()) -> bool:
|
||||
"""只允许原生控制、本地固定 MCP,以及当前场景明确授予的能力。"""
|
||||
"""
|
||||
只允许原生控制、本地固定 MCP,以及当前场景明确授予的能力。
|
||||
"""
|
||||
normalized = name.removeprefix("functions.").replace(".", "__")
|
||||
return (normalized in NATIVE_CONTROL_TOOLS
|
||||
or normalized in extra_native_tools
|
||||
@@ -68,7 +74,9 @@ def allowed_tool(name: str, extra_native_tools: frozenset[str] = frozenset()) ->
|
||||
def project_tools(
|
||||
tools: Any, prefix: str = "", extra_native_tools: frozenset[str] = frozenset(),
|
||||
) -> tuple[list[dict[str, Any]], list[str], list[str]]:
|
||||
"""保留允许工具的原始 schema;外部 MCP 配置污染必须中止,不能静默投影成合格评测。"""
|
||||
"""
|
||||
保留允许工具的原始 schema;外部 MCP 配置污染必须中止,不能静默投影成合格评测。
|
||||
"""
|
||||
if not isinstance(tools, list) or prefix.count(".") > 8:
|
||||
raise ValueError("工具目录结构无效")
|
||||
projected, retained, removed = [], [], []
|
||||
@@ -120,11 +128,15 @@ def project_tools(
|
||||
def _project_catalogs(
|
||||
payload: dict[str, Any], extra_native_tools: frozenset[str] = frozenset(),
|
||||
) -> tuple[list[str], list[str], list[dict[str, Any]]]:
|
||||
"""同时约束传统顶层目录和原生 additional_tools 输入目录,保持各自 wire 位置。"""
|
||||
"""
|
||||
同时约束传统顶层目录和原生 additional_tools 输入目录,保持各自 wire 位置。
|
||||
"""
|
||||
retained, removed, catalogs = [], [], []
|
||||
|
||||
def project(value: dict[str, Any], location: str) -> None:
|
||||
"""替换已存在的目录并记录位置,不能凭空创建第二份工具定义。"""
|
||||
"""
|
||||
替换已存在的目录并记录位置,不能凭空创建第二份工具定义。
|
||||
"""
|
||||
projected, kept, dropped = project_tools(value["tools"], extra_native_tools=extra_native_tools)
|
||||
value["tools"] = projected
|
||||
retained.extend(kept)
|
||||
@@ -132,7 +144,9 @@ def _project_catalogs(
|
||||
catalogs.append({"location": location, "retained_tools": kept, "removed_tools": dropped})
|
||||
|
||||
def visit(value: Any, location: str) -> None:
|
||||
"""递归检查输入及子代理历史,未知额外目录封装必须拒绝。"""
|
||||
"""
|
||||
递归检查输入及子代理历史,未知额外目录封装必须拒绝。
|
||||
"""
|
||||
if isinstance(value, dict):
|
||||
kind = value.get("type")
|
||||
if isinstance(kind, str) and kind.startswith("additional_tools"):
|
||||
@@ -158,14 +172,18 @@ def _project_catalogs(
|
||||
|
||||
|
||||
def project_host_instructions(payload: dict[str, Any]) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
||||
"""只去除原生独立 AGENTS 用户说明块,保留系统提示及同消息内的业务文本。"""
|
||||
"""
|
||||
只去除原生独立 AGENTS 用户说明块,保留系统提示及同消息内的业务文本。
|
||||
"""
|
||||
removed: list[dict[str, Any]] = []
|
||||
envelope = re.compile(r"\A# AGENTS\.md instructions(?: for [^\r\n]+)?\n\n<INSTRUCTIONS>\n[\s\S]*\n</INSTRUCTIONS>\Z")
|
||||
marker = "# AGENTS.md instructions"
|
||||
omitted = object()
|
||||
|
||||
def visit(value: Any, *, top_level: bool = False) -> Any:
|
||||
"""递归处理子代理历史,未知封装不能靠关键词删除后继续发送。"""
|
||||
"""
|
||||
递归处理子代理历史,未知封装不能靠关键词删除后继续发送。
|
||||
"""
|
||||
if isinstance(value, dict):
|
||||
result = {}
|
||||
removed_content = False
|
||||
@@ -203,7 +221,9 @@ def project_host_instructions(payload: dict[str, Any]) -> tuple[dict[str, Any],
|
||||
|
||||
|
||||
def _input_metadata(payload: Any) -> list[dict[str, Any]]:
|
||||
"""仅审计主输入文本块的角色、类型和指纹,不保存宿主偏好或用户任务正文。"""
|
||||
"""
|
||||
仅审计主输入文本块的角色、类型和指纹,不保存宿主偏好或用户任务正文。
|
||||
"""
|
||||
if not isinstance(payload, dict):
|
||||
return []
|
||||
result = []
|
||||
@@ -224,7 +244,9 @@ def _input_metadata(payload: Any) -> list[dict[str, Any]]:
|
||||
|
||||
|
||||
def _strings(payload: Any) -> Iterator[str]:
|
||||
"""遍历解析后的字段和值,转义后的凭据也不能从响应或审计元数据泄露。"""
|
||||
"""
|
||||
遍历解析后的字段和值,转义后的凭据也不能从响应或审计元数据泄露。
|
||||
"""
|
||||
if isinstance(payload, str):
|
||||
yield payload
|
||||
elif isinstance(payload, dict):
|
||||
@@ -237,7 +259,9 @@ def _strings(payload: Any) -> Iterator[str]:
|
||||
|
||||
|
||||
def _validate_search_call(payload: dict[str, Any], *, partial: bool = False) -> None:
|
||||
"""只接受原生客户端的查询参数,服务端搜索或自选来源不能进入受控运行。"""
|
||||
"""
|
||||
只接受原生客户端的查询参数,服务端搜索或自选来源不能进入受控运行。
|
||||
"""
|
||||
if payload.get("execution") != "client" or not isinstance(payload.get("call_id"), str) or not payload["call_id"]:
|
||||
raise ValueError("原生工具搜索调用边界无效")
|
||||
arguments = payload.get("arguments")
|
||||
@@ -255,7 +279,9 @@ def _validate_search_call(payload: dict[str, Any], *, partial: bool = False) ->
|
||||
def _validate_search_output(
|
||||
payload: dict[str, Any], *, extra_native_tools: frozenset[str] = frozenset(),
|
||||
) -> None:
|
||||
"""延迟发现返回的定义只能属于三个评测工具,不能借搜索结果再引入原生危险工具。"""
|
||||
"""
|
||||
延迟发现返回的定义只能属于三个评测工具,不能借搜索结果再引入原生危险工具。
|
||||
"""
|
||||
if (payload.get("execution") != "client" or payload.get("status") != "completed"
|
||||
or not isinstance(payload.get("call_id"), str) or not payload["call_id"]):
|
||||
raise ValueError("原生工具搜索结果边界无效")
|
||||
@@ -268,7 +294,9 @@ def _validate_search_output(
|
||||
def _reject_unsafe_calls(
|
||||
payload: Any, *, partial: bool = False, extra_native_tools: frozenset[str] = frozenset(),
|
||||
) -> None:
|
||||
"""校验实际调用;猜出被隐藏的函数名或内置调用类型也不能交给原生执行器。"""
|
||||
"""
|
||||
校验实际调用;猜出被隐藏的函数名或内置调用类型也不能交给原生执行器。
|
||||
"""
|
||||
if isinstance(payload, dict):
|
||||
kind = payload.get("type")
|
||||
if kind == "tool_search_call":
|
||||
@@ -294,24 +322,34 @@ def _reject_unsafe_calls(
|
||||
|
||||
|
||||
class _LocalServer(uvicorn.Server):
|
||||
"""回环服务的取消由评测控制器统一管理,不占用宿主进程的信号处理器。"""
|
||||
"""
|
||||
回环服务的取消由评测控制器统一管理,不占用宿主进程的信号处理器。
|
||||
"""
|
||||
|
||||
@contextmanager
|
||||
def capture_signals(self) -> Iterator[None]:
|
||||
"""控制器并行管理 MCP 和模型连接,服务不得互相覆盖全局信号处理。"""
|
||||
"""
|
||||
控制器并行管理 MCP 和模型连接,服务不得互相覆盖全局信号处理。
|
||||
"""
|
||||
yield
|
||||
|
||||
|
||||
class _ModelStreamingResponse(StreamingResponse):
|
||||
"""客户端断开时显式等待生成器收尾,确保连接和审计结果不依赖垃圾回收。"""
|
||||
"""
|
||||
客户端断开时显式等待生成器收尾,确保连接和审计结果不依赖垃圾回收。
|
||||
"""
|
||||
|
||||
def __init__(self, body: AsyncGenerator[str, None]) -> None:
|
||||
"""保留本次响应独占的生成器,后续关闭不会影响其他模型请求。"""
|
||||
"""
|
||||
保留本次响应独占的生成器,后续关闭不会影响其他模型请求。
|
||||
"""
|
||||
super().__init__(body, media_type="text/event-stream")
|
||||
self._body = body
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
"""流式发送被取消也要完成局部清理,之后才让控制器读取终态。"""
|
||||
"""
|
||||
流式发送被取消也要完成局部清理,之后才让控制器读取终态。
|
||||
"""
|
||||
try:
|
||||
await super().__call__(scope, receive, send)
|
||||
finally:
|
||||
@@ -320,12 +358,16 @@ class _ModelStreamingResponse(StreamingResponse):
|
||||
|
||||
|
||||
class EvaluationModelProxy:
|
||||
"""原生 Codex 使用本地随机令牌,真正供应商凭据只由此固定目标代理持有。"""
|
||||
"""
|
||||
原生 Codex 使用本地随机令牌,真正供应商凭据只由此固定目标代理持有。
|
||||
"""
|
||||
|
||||
def __init__(self, settings: ModelSettings, *, probe_only: bool = False,
|
||||
transport: Optional[httpx.AsyncBaseTransport] = None,
|
||||
extra_native_tools: frozenset[str] = frozenset()) -> None:
|
||||
"""创建独立计数和目录记录;probe_only 只记录目录,绝不真正出站。"""
|
||||
"""
|
||||
创建独立计数和目录记录;probe_only 只记录目录,绝不真正出站。
|
||||
"""
|
||||
self.settings = settings
|
||||
self.probe_only = probe_only
|
||||
self._extra_native_tools = frozenset(extra_native_tools)
|
||||
@@ -343,16 +385,22 @@ class EvaluationModelProxy:
|
||||
self.app = Starlette(routes=[Route("/v1/responses", self._respond, methods=["POST"])])
|
||||
|
||||
async def __aenter__(self) -> "EvaluationModelProxy":
|
||||
"""显式启动本地 HTTP 服务,不访问真实模型。"""
|
||||
"""
|
||||
显式启动本地 HTTP 服务,不访问真实模型。
|
||||
"""
|
||||
await self.start()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_args: Any) -> None:
|
||||
"""退出时回收服务和连接,不把运行任务交给解释器退出。"""
|
||||
"""
|
||||
退出时回收服务和连接,不把运行任务交给解释器退出。
|
||||
"""
|
||||
await self.close()
|
||||
|
||||
async def start(self) -> None:
|
||||
"""绑定随机回环端口;启动失败或取消也必须关闭连接、监听和子任务。"""
|
||||
"""
|
||||
绑定随机回环端口;启动失败或取消也必须关闭连接、监听和子任务。
|
||||
"""
|
||||
if self._task is not None or self._closed:
|
||||
raise RuntimeError("模型代理已经启动或关闭")
|
||||
try:
|
||||
@@ -372,7 +420,9 @@ class EvaluationModelProxy:
|
||||
raise
|
||||
|
||||
async def close(self) -> None:
|
||||
"""有界等待退出,幂等回收;子任务异常也不能跳过后续资源关闭。"""
|
||||
"""
|
||||
有界等待退出,幂等回收;子任务异常也不能跳过后续资源关闭。
|
||||
"""
|
||||
if self._server is not None:
|
||||
self._server.should_exit = True
|
||||
try:
|
||||
@@ -398,11 +448,15 @@ class EvaluationModelProxy:
|
||||
|
||||
@staticmethod
|
||||
def _error(message: str, status: int = 400) -> JSONResponse:
|
||||
"""只返回固定控制错误,不回显原始供应商响应、密钥或提示词。"""
|
||||
"""
|
||||
只返回固定控制错误,不回显原始供应商响应、密钥或提示词。
|
||||
"""
|
||||
return JSONResponse({"error": {"message": message, "type": "evaluation_error"}}, status_code=status)
|
||||
|
||||
def _validate_payload(self, payload: Any) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""请求边界同时约束模型、预算和目录,子模型不能静默替换对照条件。"""
|
||||
"""
|
||||
请求边界同时约束模型、预算和目录,子模型不能静默替换对照条件。
|
||||
"""
|
||||
if not isinstance(payload, dict) or payload.get("model") != self.settings.model:
|
||||
raise ValueError("模型与评测配置不符")
|
||||
if self.settings.api_key in "".join(_strings(payload)):
|
||||
@@ -438,7 +492,9 @@ class EvaluationModelProxy:
|
||||
return payload, record
|
||||
|
||||
async def _respond(self, request: Request) -> Response:
|
||||
"""认证与原子预算判断先于真正出站,探测请求单独计数。"""
|
||||
"""
|
||||
认证与原子预算判断先于真正出站,探测请求单独计数。
|
||||
"""
|
||||
if request.headers.get("authorization") != f"Bearer {self.bearer_token}":
|
||||
return self._error("Local evaluation authentication failed", 401)
|
||||
if self._closed:
|
||||
@@ -493,7 +549,9 @@ class EvaluationModelProxy:
|
||||
return await self._response(upstream, record)
|
||||
|
||||
async def _response(self, upstream: httpx.Response, record: dict[str, Any]) -> Response:
|
||||
"""非流式响应同样有大小和工具边界,错误正文始终留在代理内部。"""
|
||||
"""
|
||||
非流式响应同样有大小和工具边界,错误正文始终留在代理内部。
|
||||
"""
|
||||
body = bytearray()
|
||||
try:
|
||||
async for chunk in upstream.aiter_bytes():
|
||||
@@ -511,14 +569,18 @@ class EvaluationModelProxy:
|
||||
await upstream.aclose()
|
||||
|
||||
def _validate_response(self, value: Any) -> None:
|
||||
"""在任何正文交给原生客户端之前拒绝凭据、非法调用与非对象响应。"""
|
||||
"""
|
||||
在任何正文交给原生客户端之前拒绝凭据、非法调用与非对象响应。
|
||||
"""
|
||||
if not isinstance(value, dict) or self.settings.api_key in "".join(_strings(value)):
|
||||
raise ValueError("响应格式或凭据边界无效")
|
||||
_reject_unsafe_calls(value, extra_native_tools=self._extra_native_tools)
|
||||
|
||||
@staticmethod
|
||||
def _capture(response: dict[str, Any], record: dict[str, Any]) -> None:
|
||||
"""只记录完整非负用量;响应声明完成与流后续失败分别保留。"""
|
||||
"""
|
||||
只记录完整非负用量;响应声明完成与流后续失败分别保留。
|
||||
"""
|
||||
if not isinstance(response, dict):
|
||||
raise ValueError("响应完成对象无效")
|
||||
record["completed"] = response.get("status") == "completed"
|
||||
@@ -530,7 +592,9 @@ class EvaluationModelProxy:
|
||||
record["response_model"] = model if isinstance(model, str) else None
|
||||
|
||||
def _event(self, raw: bytes, record: dict[str, Any], tails: dict[str, str]) -> str:
|
||||
"""校验完整 SSE 帧以及跨帧文本片段,非法调用所在帧不向客户端发送。"""
|
||||
"""
|
||||
校验完整 SSE 帧以及跨帧文本片段,非法调用所在帧不向客户端发送。
|
||||
"""
|
||||
text = raw.decode("utf-8")
|
||||
data = "\n".join(line[5:].removeprefix(" ") for line in text.splitlines() if line.startswith("data:"))
|
||||
if self.settings.api_key in text:
|
||||
@@ -554,7 +618,9 @@ class EvaluationModelProxy:
|
||||
return text + "\n\n"
|
||||
|
||||
async def _stream(self, upstream: httpx.Response, record: dict[str, Any]) -> AsyncGenerator[str, None]:
|
||||
"""完整事件校验后转发,终态交付后即关闭上游,不再等待客户端通常忽略的尾帧。"""
|
||||
"""
|
||||
完整事件校验后转发,终态交付后即关闭上游,不再等待客户端通常忽略的尾帧。
|
||||
"""
|
||||
pending = bytearray()
|
||||
tails: dict[str, str] = {}
|
||||
received = 0
|
||||
@@ -599,7 +665,9 @@ class EvaluationModelProxy:
|
||||
|
||||
@staticmethod
|
||||
def _record_disconnect(record: dict[str, Any]) -> None:
|
||||
"""客户端收到终态后关闭属于正常完成,终态前断开则保留未完成事实。"""
|
||||
"""
|
||||
客户端收到终态后关闭属于正常完成,终态前断开则保留未完成事实。
|
||||
"""
|
||||
if "terminal_event" in record:
|
||||
record["client_closed_after_terminal"] = True
|
||||
else:
|
||||
@@ -607,7 +675,9 @@ class EvaluationModelProxy:
|
||||
record["completed"] = False
|
||||
|
||||
def snapshot(self) -> dict[str, Any]:
|
||||
"""探测不计真实消耗;缺失用量保持未知,已知总量是可审计下界。"""
|
||||
"""
|
||||
探测不计真实消耗;缺失用量保持未知,已知总量是可审计下界。
|
||||
"""
|
||||
records = copy.deepcopy(self.requests)
|
||||
forwarded = [record for record in records if record["forwarded"]]
|
||||
known = [record["usage"] for record in forwarded if isinstance(record["usage"], dict)]
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"""用完整生产 Agent 驱动隔离业务世界;工具目录受控,不代表真实部署配置。"""
|
||||
"""
|
||||
用完整生产 Agent 驱动隔离业务世界;工具目录受控,不代表真实部署配置。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
@@ -37,7 +39,9 @@ _OPERATIONS = (
|
||||
|
||||
|
||||
def _config_path() -> Path:
|
||||
"""拒绝未隔离或导入后才换目录的调用,不主动导入可能读取真实配置的模块。"""
|
||||
"""
|
||||
拒绝未隔离或导入后才换目录的调用,不主动导入可能读取真实配置的模块。
|
||||
"""
|
||||
configured = os.environ.get("CONFIG_DIR")
|
||||
bootstrap = sys.modules.get("app.testing.bootstrap")
|
||||
config = sys.modules.get("app.runtime.config")
|
||||
@@ -52,25 +56,35 @@ def _config_path() -> Path:
|
||||
|
||||
|
||||
class _Response:
|
||||
"""内存响应保留执行器真实的读取和关闭协议,不产生网络请求。"""
|
||||
"""
|
||||
内存响应保留执行器真实的读取和关闭协议,不产生网络请求。
|
||||
"""
|
||||
|
||||
status_code = 200
|
||||
headers: dict[str, str] = {}
|
||||
|
||||
def __init__(self, payload: dict[str, Any]) -> None:
|
||||
"""保存世界返回的成功、失败或未知结果,不提升为成功。"""
|
||||
"""
|
||||
保存世界返回的成功、失败或未知结果,不提升为成功。
|
||||
"""
|
||||
self._payload = payload
|
||||
|
||||
def json(self) -> dict[str, Any]:
|
||||
"""仅返回此次 API 执行得到的可见响应。"""
|
||||
"""
|
||||
仅返回此次 API 执行得到的可见响应。
|
||||
"""
|
||||
return self._payload
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""满足生产响应的显式关闭协议。"""
|
||||
"""
|
||||
满足生产响应的显式关闭协议。
|
||||
"""
|
||||
|
||||
|
||||
class _Transport:
|
||||
"""按生产固定路由反向匹配 operation,无法向任意主机发送请求。"""
|
||||
"""
|
||||
按生产固定路由反向匹配 operation,无法向任意主机发送请求。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -79,7 +93,9 @@ class _Transport:
|
||||
after_operation: Optional[Callable[[str, dict[str, Any]], Awaitable[None]]] = None,
|
||||
**_kwargs: Any,
|
||||
) -> None:
|
||||
"""绑定当前世界;执行器传来的认证头不记录、不使用。"""
|
||||
"""
|
||||
绑定当前世界;执行器传来的认证头不记录、不使用。
|
||||
"""
|
||||
from app.agent.policy.api import resolve_api_route
|
||||
|
||||
self._world = world
|
||||
@@ -96,7 +112,9 @@ class _Transport:
|
||||
async def request(
|
||||
self, *, method: str, url: str, params: Optional[dict[str, Any]], json: Any, raise_exception: bool,
|
||||
) -> _Response:
|
||||
"""解释执行器生成的 HTTP 请求,未纳入场景的真实 API 同样拒绝。"""
|
||||
"""
|
||||
解释执行器生成的 HTTP 请求,未纳入场景的真实 API 同样拒绝。
|
||||
"""
|
||||
del raise_exception
|
||||
parsed = urlsplit(url)
|
||||
if parsed.netloc != "evaluation.invalid" or parsed.scheme != "http" or parsed.query or parsed.fragment:
|
||||
@@ -129,44 +147,62 @@ class _Transport:
|
||||
|
||||
|
||||
class _MemoryPort:
|
||||
"""为真实 MemoryManager 提供一次运行独占的会话读取与持久化边界。"""
|
||||
"""
|
||||
为真实 MemoryManager 提供一次运行独占的会话读取与持久化边界。
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""存储仅属于当前运行的消息快照。"""
|
||||
"""
|
||||
存储仅属于当前运行的消息快照。
|
||||
"""
|
||||
self.messages: list[dict[str, Any]] = []
|
||||
|
||||
async def get(self, **_kwargs: Any) -> None:
|
||||
"""新评测会话没有历史消息,不读取宿主会话表。"""
|
||||
"""
|
||||
新评测会话没有历史消息,不读取宿主会话表。
|
||||
"""
|
||||
|
||||
async def async_save_agent_messages(self, *, messages: list[dict[str, Any]], **_kwargs: Any) -> None:
|
||||
"""接收 MemoryManager 的序列化结果,不访问宿主全局持久化服务。"""
|
||||
"""
|
||||
接收 MemoryManager 的序列化结果,不访问宿主全局持久化服务。
|
||||
"""
|
||||
self.messages = messages
|
||||
|
||||
|
||||
class _EvaluationReadFileTool(ReadFileTool):
|
||||
"""评测专用文件读取工具,只允许访问本次运行的临时 Agent 根目录。"""
|
||||
"""
|
||||
评测专用文件读取工具,只允许访问本次运行的临时 Agent 根目录。
|
||||
"""
|
||||
|
||||
_evaluation_allowed_root: Path = PrivateAttr()
|
||||
|
||||
def __init__(self, *, allowed_root: Path, **kwargs: Any) -> None:
|
||||
"""绑定隔离根目录后复用生产文件读取、大小限制和行范围合同。"""
|
||||
"""
|
||||
绑定隔离根目录后复用生产文件读取、大小限制和行范围合同。
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self._evaluation_allowed_root = allowed_root.resolve()
|
||||
|
||||
def _get_non_admin_local_file_roots(self) -> list[Path]:
|
||||
"""返回评测临时 Agent 根,避免回退到宿主 CONFIG_PATH/agent。"""
|
||||
"""
|
||||
返回评测临时 Agent 根,避免回退到宿主 CONFIG_PATH/agent。
|
||||
"""
|
||||
return [self._evaluation_allowed_root]
|
||||
|
||||
|
||||
class _EvaluationExecuteCommandTool(ExecuteCommandTool):
|
||||
"""在临时工作目录执行命令或交互终端并保留生产工具的真实回执合同。"""
|
||||
"""
|
||||
在临时工作目录执行命令或交互终端并保留生产工具的真实回执合同。
|
||||
"""
|
||||
|
||||
_evaluation_world: Any = PrivateAttr()
|
||||
_evaluation_allowed_root: Path = PrivateAttr()
|
||||
_evaluation_terminal_session_id: Optional[str] = PrivateAttr(default=None)
|
||||
|
||||
def __init__(self, *, world: EvaluationWorld, allowed_root: Path, **kwargs: Any) -> None:
|
||||
"""绑定假世界与临时目录,拒绝环境覆盖和目录逃逸。"""
|
||||
"""
|
||||
绑定假世界与临时目录,拒绝环境覆盖和目录逃逸。
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self._evaluation_world = world
|
||||
self._evaluation_allowed_root = allowed_root.resolve()
|
||||
@@ -180,7 +216,9 @@ class _EvaluationExecuteCommandTool(ExecuteCommandTool):
|
||||
session_id: Optional[str] = None,
|
||||
input_text: Optional[str] = None,
|
||||
) -> None:
|
||||
"""把当前宿主终端作用域附加到隔离账本,区分父任务和子任务读取。"""
|
||||
"""
|
||||
把当前宿主终端作用域附加到隔离账本,区分父任务和子任务读取。
|
||||
"""
|
||||
scope = current_terminal_scope()
|
||||
self._evaluation_world.record_command(
|
||||
command,
|
||||
@@ -201,7 +239,9 @@ class _EvaluationExecuteCommandTool(ExecuteCommandTool):
|
||||
session_id: Optional[str] = None,
|
||||
input_text: Optional[str] = None,
|
||||
) -> str:
|
||||
"""返回带纠正提示的受控失败,供模型按评测合同修正输入。"""
|
||||
"""
|
||||
返回带纠正提示的受控失败,供模型按评测合同修正输入。
|
||||
"""
|
||||
payload: dict[str, Any] = {
|
||||
"action": action, "success": False, "execution_outcome": "failed", "status": "error",
|
||||
"exit_code": None, "timed_out": False, "command": command,
|
||||
@@ -215,7 +255,9 @@ class _EvaluationExecuteCommandTool(ExecuteCommandTool):
|
||||
|
||||
@staticmethod
|
||||
def _payload(result: str) -> dict[str, Any]:
|
||||
"""解码生产命令工具的 JSON 回执,异常文本仍作为失败证据保留。"""
|
||||
"""
|
||||
解码生产命令工具的 JSON 回执,异常文本仍作为失败证据保留。
|
||||
"""
|
||||
try:
|
||||
decoded = json.loads(result)
|
||||
except (TypeError, ValueError):
|
||||
@@ -231,7 +273,9 @@ class _EvaluationExecuteCommandTool(ExecuteCommandTool):
|
||||
env: Optional[dict[str, Any]],
|
||||
kwargs: dict[str, Any],
|
||||
) -> str:
|
||||
"""按固定顺序代理启动、读取、写入和等待动作,验证真实会话句柄不会被替换。"""
|
||||
"""
|
||||
按固定顺序代理启动、读取、写入和等待动作,验证真实会话句柄不会被替换。
|
||||
"""
|
||||
command_text = (command or "").strip()
|
||||
session_id = kwargs.get("session_id")
|
||||
input_text = kwargs.get("input_text")
|
||||
@@ -329,7 +373,9 @@ class _EvaluationExecuteCommandTool(ExecuteCommandTool):
|
||||
|
||||
async def run(self, action: Optional[str] = "run", command: Optional[str] = None, cwd: Optional[str] = None,
|
||||
env: Optional[dict[str, Any]] = None, **kwargs: Any) -> str:
|
||||
"""仅允许场景给定的短命令,执行路径仍经过生产命令安全、超时和作用域清理。"""
|
||||
"""
|
||||
仅允许场景给定的短命令,执行路径仍经过生产命令安全、超时和作用域清理。
|
||||
"""
|
||||
scenario = self._evaluation_world.scenario
|
||||
normalized_action = (action or ("start" if scenario.kind == "terminal" else "run")).strip().lower()
|
||||
if scenario.kind == "terminal":
|
||||
@@ -365,18 +411,24 @@ class _EvaluationExecuteCommandTool(ExecuteCommandTool):
|
||||
|
||||
|
||||
class _EvaluationBrowseWebpageTool(BrowseWebpageTool):
|
||||
"""在本地固定页面上复用生产 Playwright 工具,拒绝外部 URL 和任意脚本。"""
|
||||
"""
|
||||
在本地固定页面上复用生产 Playwright 工具,拒绝外部 URL 和任意脚本。
|
||||
"""
|
||||
|
||||
_evaluation_world: Any = PrivateAttr()
|
||||
|
||||
def __init__(self, *, world: EvaluationWorld, **kwargs: Any) -> None:
|
||||
"""绑定本轮世界;浏览器会话归属仍由生产 TerminalScope 管理。"""
|
||||
"""
|
||||
绑定本轮世界;浏览器会话归属仍由生产 TerminalScope 管理。
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self._evaluation_world = world
|
||||
|
||||
@staticmethod
|
||||
def _failure(action: str, message: str) -> str:
|
||||
"""返回告诉模型正确动作或输入的结构化失败。"""
|
||||
"""
|
||||
返回告诉模型正确动作或输入的结构化失败。
|
||||
"""
|
||||
return json.dumps({
|
||||
"action": action, "success": False, "execution_outcome": "failed",
|
||||
"error": "evaluation_browser_rejected", "message": message,
|
||||
@@ -388,7 +440,9 @@ class _EvaluationBrowseWebpageTool(BrowseWebpageTool):
|
||||
cookies: Optional[str] = None, user_agent: Optional[str] = None,
|
||||
session_key: Optional[str] = None, tab_index: Optional[int] = None,
|
||||
allow_private_network: bool = False, **kwargs: Any) -> Any:
|
||||
"""仅允许页面读取与按快照 ref 点击,执行仍走生产浏览器实现。"""
|
||||
"""
|
||||
仅允许页面读取与按快照 ref 点击,执行仍走生产浏览器实现。
|
||||
"""
|
||||
allowed_actions = {"goto", "snapshot", "click_ref", "get_content", "close_session"}
|
||||
normalized_action = str(action or "").strip().lower()
|
||||
browser_url = self._evaluation_world.browser_url
|
||||
@@ -429,12 +483,18 @@ class _EvaluationBrowseWebpageTool(BrowseWebpageTool):
|
||||
|
||||
@contextmanager
|
||||
def _browser_fixture() -> Iterator[str]:
|
||||
"""启动只绑定回环地址的动态测试页,并在评测结束后等待线程退出。"""
|
||||
"""
|
||||
启动只绑定回环地址的动态测试页,并在评测结束后等待线程退出。
|
||||
"""
|
||||
class _Handler(BaseHTTPRequestHandler):
|
||||
"""返回固定页面,不记录请求正文或客户端环境。"""
|
||||
"""
|
||||
返回固定页面,不记录请求正文或客户端环境。
|
||||
"""
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802 - 标准库处理器方法名
|
||||
"""仅提供固定路径和最小 HTML,其他请求返回 404。"""
|
||||
"""
|
||||
仅提供固定路径和最小 HTML,其他请求返回 404。
|
||||
"""
|
||||
if self.path != "/fixture":
|
||||
self.send_error(404)
|
||||
return
|
||||
@@ -454,7 +514,9 @@ def _browser_fixture() -> Iterator[str]:
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, _format: str, *_args: Any) -> None:
|
||||
"""禁止标准库把本地测试请求写入 stderr。"""
|
||||
"""
|
||||
禁止标准库把本地测试请求写入 stderr。
|
||||
"""
|
||||
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), _Handler)
|
||||
thread = Thread(target=server.serve_forever, name="moviepilot-evaluation-browser", daemon=True)
|
||||
@@ -468,31 +530,43 @@ def _browser_fixture() -> Iterator[str]:
|
||||
|
||||
|
||||
class _McpDirectory:
|
||||
"""受控目录不配置任何外部 MCP,禁止发现阶段启动服务或联网。"""
|
||||
"""
|
||||
受控目录不配置任何外部 MCP,禁止发现阶段启动服务或联网。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def config_signature() -> str:
|
||||
"""提供明确区别于真实部署的空目录签名。"""
|
||||
"""
|
||||
提供明确区别于真实部署的空目录签名。
|
||||
"""
|
||||
return "evaluation-empty-mcp"
|
||||
|
||||
@staticmethod
|
||||
async def list_enabled_tool_specs() -> list[Any]:
|
||||
"""无须读取 SystemConfig 即可返回空外部工具目录。"""
|
||||
"""
|
||||
无须读取 SystemConfig 即可返回空外部工具目录。
|
||||
"""
|
||||
return []
|
||||
|
||||
|
||||
def _agent_type() -> type:
|
||||
"""延迟加载宿主;只替换模型、目录和展示边界,继承完整生产执行图。"""
|
||||
"""
|
||||
延迟加载宿主;只替换模型、目录和展示边界,继承完整生产执行图。
|
||||
"""
|
||||
from app.agent.middleware.selection import ToolSelectorMiddleware
|
||||
from app.agent.orchestrator import MoviePilotAgent
|
||||
from app.agent.tools.catalog import ToolCatalogSnapshot
|
||||
|
||||
# 脚本单独严格检查时 follow_imports=skip 不展开宿主类型;运行合同由生产图测试验证。
|
||||
class EvaluationAgent(MoviePilotAgent): # type: ignore[misc]
|
||||
"""在生产策略、中间件和生命周期内运行受控工具及外部注入模型。"""
|
||||
"""
|
||||
在生产策略、中间件和生命周期内运行受控工具及外部注入模型。
|
||||
"""
|
||||
|
||||
def __init__(self, *, model: Any, model_name: str, context_window: int, max_iterations: int, **kwargs: Any) -> None:
|
||||
"""隔离模型身份和预算,不解析宿主 LLM 设置或供应商选择事件。"""
|
||||
"""
|
||||
隔离模型身份和预算,不解析宿主 LLM 设置或供应商选择事件。
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self.evaluation_model = model
|
||||
self.evaluation_model_name = model_name
|
||||
@@ -506,32 +580,44 @@ def _agent_type() -> type:
|
||||
self.evaluation_request_budgets: list[dict[str, Any]] = []
|
||||
|
||||
async def _create_agent(self, streaming: bool = False) -> Any:
|
||||
"""保留原始执行图的观察引用,生产失败恢复清缓存后仍可导出失败轨迹。"""
|
||||
"""
|
||||
保留原始执行图的观察引用,生产失败恢复清缓存后仍可导出失败轨迹。
|
||||
"""
|
||||
graph = await super()._create_agent(streaming=streaming)
|
||||
self.evaluation_bundle = self._compiled_agent_bundle
|
||||
return graph
|
||||
|
||||
async def _initialize_llm(self, streaming: bool = False) -> Any:
|
||||
"""直接使用调用方模型;内部总结和子代理仍由生产代码调用同一模型。"""
|
||||
"""
|
||||
直接使用调用方模型;内部总结和子代理仍由生产代码调用同一模型。
|
||||
"""
|
||||
del streaming
|
||||
return self.evaluation_model
|
||||
|
||||
async def _resolve_llm_runtime_config(self) -> dict[str, Any]:
|
||||
"""返回不含凭据的稳定模型描述,不加载供应商配置。"""
|
||||
"""
|
||||
返回不含凭据的稳定模型描述,不加载供应商配置。
|
||||
"""
|
||||
return {"provider": "openai", "model": self.evaluation_model_name, "web_search_mode": "disabled"}
|
||||
|
||||
def _sync_model_profile(self, model: Any) -> None:
|
||||
"""记录显式传入的模型预算,避免脚本模型缺失 profile 时丢失上下文上限。"""
|
||||
"""
|
||||
记录显式传入的模型预算,避免脚本模型缺失 profile 时丢失上下文上限。
|
||||
"""
|
||||
super()._sync_model_profile(model)
|
||||
self._session_usage.model = self.evaluation_model_name
|
||||
self._session_usage.context_window_tokens = self.evaluation_context_window
|
||||
|
||||
def _get_recursion_limit(self) -> int:
|
||||
"""将显式评测迭代预算交给真实 LangGraph 执行器。"""
|
||||
"""
|
||||
将显式评测迭代预算交给真实 LangGraph 执行器。
|
||||
"""
|
||||
return self.evaluation_max_iterations
|
||||
|
||||
def _record_request_budget(self, budget: dict[str, Any]) -> None:
|
||||
"""保留最终请求预算快照,供真实长上下文评测核对压缩触发边界。"""
|
||||
"""
|
||||
保留最终请求预算快照,供真实长上下文评测核对压缩触发边界。
|
||||
"""
|
||||
super()._record_request_budget(budget)
|
||||
self.evaluation_request_budgets.append({
|
||||
key: budget.get(key) for key in (
|
||||
@@ -542,36 +628,50 @@ def _agent_type() -> type:
|
||||
})
|
||||
|
||||
def _should_stream(self) -> bool:
|
||||
"""使用生产非流式分支,模型输出只进入评测捕获。"""
|
||||
"""
|
||||
使用生产非流式分支,模型输出只进入评测捕获。
|
||||
"""
|
||||
return False
|
||||
|
||||
def _initialize_local_tool_catalogs(self) -> tuple[Any, Any]:
|
||||
"""主图和子图使用不同工具实例,保持完整生产目录校验和子图只读策略。"""
|
||||
"""
|
||||
主图和子图使用不同工具实例,保持完整生产目录校验和子图只读策略。
|
||||
"""
|
||||
return tuple(ToolCatalogSnapshot.from_tools(
|
||||
tools, plugin_revision=0, factory_revision="evaluation-controlled-api",
|
||||
).require_unique() for tools in (self.evaluation_tools, self.evaluation_child_tools))
|
||||
|
||||
@staticmethod
|
||||
def _initialize_tool_selector(tools: list[Any], internal_tools: list[Any], model: Any) -> Any:
|
||||
"""保留生产发现中间件,同时避免加载实际插件工具工厂。"""
|
||||
"""
|
||||
保留生产发现中间件,同时避免加载实际插件工具工厂。
|
||||
"""
|
||||
return ToolSelectorMiddleware(
|
||||
model=model, selection_tools=[*tools, *internal_tools], max_tools=20,
|
||||
always_include=[tool.name for tool in [*tools, *internal_tools]], enable_discovery=True,
|
||||
)
|
||||
|
||||
async def prepare_chat_title(self, message: str) -> None:
|
||||
"""评测不额外生成会话标题,业务推理与总结调用仍按生产路径计费。"""
|
||||
"""
|
||||
评测不额外生成会话标题,业务推理与总结调用仍按生产路径计费。
|
||||
"""
|
||||
|
||||
async def _save_display_history_messages(self, messages: list[dict[str, Any]]) -> None:
|
||||
"""捕获真实 process 的展示历史,不注册通知或宿主会话服务。"""
|
||||
"""
|
||||
捕获真实 process 的展示历史,不注册通知或宿主会话服务。
|
||||
"""
|
||||
self.display_messages.extend(messages)
|
||||
|
||||
async def send_agent_message(self, message: str, title: str = "") -> None:
|
||||
"""意外进入发送边界时直接拒绝,不能把评测内容发送到真实渠道。"""
|
||||
"""
|
||||
意外进入发送边界时直接拒绝,不能把评测内容发送到真实渠道。
|
||||
"""
|
||||
raise RuntimeError("评测禁止发送通知")
|
||||
|
||||
def _send_agent_tokens_usage_event(self, *, success: bool, error: Optional[str] = None) -> None:
|
||||
"""只记录成功标志,不广播可被插件消费的真实运行事件。"""
|
||||
"""
|
||||
只记录成功标志,不广播可被插件消费的真实运行事件。
|
||||
"""
|
||||
self.execution_success = success
|
||||
|
||||
return EvaluationAgent
|
||||
@@ -584,7 +684,9 @@ def _runtime_scope(
|
||||
*,
|
||||
after_operation: Optional[Callable[[str, dict[str, Any]], Awaitable[None]]] = None,
|
||||
) -> Iterator[Any]:
|
||||
"""在独立 worker 中临时替换明确的外部边界,退出时恢复全部全局引用。"""
|
||||
"""
|
||||
在独立 worker 中临时替换明确的外部边界,退出时恢复全部全局引用。
|
||||
"""
|
||||
from app.agent.api.executor import MoviePilotApiExecutor
|
||||
from app.agent.runtime import AgentRuntimeManager
|
||||
|
||||
@@ -602,7 +704,9 @@ def _runtime_scope(
|
||||
|
||||
|
||||
def _code_tool_scope(stack: ExitStack, directory: Path, agent: Any, world: EvaluationWorld) -> Any:
|
||||
"""只准入任务给定程序并使用临时工作目录,仍保留原生工具类型和完整生产 RPC 链。"""
|
||||
"""
|
||||
只准入任务给定程序并使用临时工作目录,仍保留原生工具类型和完整生产 RPC 链。
|
||||
"""
|
||||
from app.agent.code.manager import CodeSessionManager
|
||||
from app.agent.tools.impl.execute_code import ExecuteCodeTool
|
||||
|
||||
@@ -610,7 +714,9 @@ def _code_tool_scope(stack: ExitStack, directory: Path, agent: Any, world: Evalu
|
||||
original_run = ExecuteCodeTool.run
|
||||
|
||||
async def run(tool: Any, **kwargs: Any) -> str:
|
||||
"""评测拒绝程序改写,不能用拥有宿主权限的任意代码绕开假世界。"""
|
||||
"""
|
||||
评测拒绝程序改写,不能用拥有宿主权限的任意代码绕开假世界。
|
||||
"""
|
||||
if str(kwargs.get('code') or '').strip() != world.scenario.command.strip():
|
||||
return json.dumps({'success': False, 'error': '评测仅允许原样执行任务给定的 Python 程序。'})
|
||||
return str(await original_run(tool, **kwargs))
|
||||
@@ -630,7 +736,9 @@ async def run_moviepilot(
|
||||
world: EvaluationWorld, model: "BaseChatModel", *, model_name: str, context_window: int, max_iterations: int,
|
||||
invocation_repository: Optional[Any] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""调用真实 process 并捕获最终图;每个 live trial 应使用独立进程和 CONFIG_DIR。"""
|
||||
"""
|
||||
调用真实 process 并捕获最终图;每个 live trial 应使用独立进程和 CONFIG_DIR。
|
||||
"""
|
||||
config_path = _config_path()
|
||||
if context_window < 1 or max_iterations < 1:
|
||||
raise ValueError("context_window 和 max_iterations 必须为正整数")
|
||||
@@ -650,7 +758,9 @@ async def _run_isolated(
|
||||
world: EvaluationWorld, model: Any, *, directory: Path, model_name: str, context_window: int,
|
||||
max_iterations: int, invocation_repository: Optional[Any],
|
||||
) -> dict[str, Any]:
|
||||
"""装配真实 API 执行器、独立回执库和会话端口,并在回收前保存执行证据。"""
|
||||
"""
|
||||
装配真实 API 执行器、独立回执库和会话端口,并在回收前保存执行证据。
|
||||
"""
|
||||
from langchain_core.messages import messages_to_dict
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
@@ -681,7 +791,9 @@ async def _run_isolated(
|
||||
steering_sent: set[int] = set()
|
||||
|
||||
async def after_operation(operation_id: str, response: dict[str, Any]) -> None:
|
||||
"""在计划的真实业务回执边界注入运行中补充消息。"""
|
||||
"""
|
||||
在计划的真实业务回执边界注入运行中补充消息。
|
||||
"""
|
||||
nonlocal steering_operation_count
|
||||
if not steering_schedule:
|
||||
return
|
||||
@@ -703,13 +815,14 @@ async def _run_isolated(
|
||||
})
|
||||
|
||||
def steering_status_callback(message: Any, status: str) -> None:
|
||||
"""记录 inbox 在模型边界真正消费补充消息的状态。"""
|
||||
"""
|
||||
记录 inbox 在模型边界真正消费补充消息的状态。
|
||||
"""
|
||||
steering_events.append({
|
||||
"status": status,
|
||||
"message_id": message.message_id,
|
||||
# SteeringMiddleware consumes the message immediately before the
|
||||
# model handler; this marks a real model-boundary injection even
|
||||
# when later context compaction removes the HumanMessage snapshot.
|
||||
# 补充消息在模型处理器运行前被消费;即使后续上下文压缩移除消息快照,
|
||||
# 这里仍记录其确实进入模型边界。
|
||||
"model_boundary": status == "applied",
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"""只向被测 Agent 公开用户任务与已知媒体、资源输入。"""
|
||||
"""
|
||||
只向被测 Agent 公开用户任务与已知媒体、资源输入。
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
@@ -18,7 +20,23 @@ _PUBLIC_INSTRUCTIONS = (
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Scenario:
|
||||
"""场景的公开输入,不包含业务初态、故障配置或验收答案。"""
|
||||
"""
|
||||
场景的公开输入,不包含业务初态、故障配置或验收答案。
|
||||
|
||||
:param scenario_id: 仅供评测器识别场景的稳定代号
|
||||
:param task: 发送给被测 Agent 的用户任务
|
||||
:param media_source: 已知媒体来源,非媒体任务可为空
|
||||
:param media_id: 来源内的媒体标识,非媒体任务可为空
|
||||
:param title: 已知媒体名称,非媒体任务可为空
|
||||
:param magnet: 已知磁力链接,非下载任务可为空
|
||||
:param infohash: 已知资源摘要,非下载任务可为空
|
||||
:param kind: 场景交互类型,默认通过 API 完成
|
||||
:param command: 代码或终端场景提供的完整命令
|
||||
:param browser_url: 浏览器场景提供的目标页面地址
|
||||
:param terminal_use_pty: 终端场景是否使用伪终端;None 表示沿用默认设置
|
||||
:param steering_message: 单次运行中追加给 Agent 的补充消息
|
||||
:param steering_plan: 按业务工具回执次数安排的补充消息序列
|
||||
"""
|
||||
|
||||
scenario_id: str
|
||||
task: str
|
||||
@@ -35,13 +53,17 @@ class Scenario:
|
||||
steering_plan: tuple[tuple[int, str], ...] = ()
|
||||
|
||||
def steering_schedule(self) -> tuple[tuple[int, str], ...]:
|
||||
"""返回按业务工具回执次数触发的补充消息计划。"""
|
||||
"""
|
||||
返回按业务工具回执次数触发的补充消息计划。
|
||||
"""
|
||||
if self.steering_plan:
|
||||
return self.steering_plan
|
||||
return ((1, self.steering_message),) if self.steering_message else ()
|
||||
|
||||
def model_input(self) -> str:
|
||||
"""提供完整已知输入,但不把场景代号及隐藏故障布置传给模型。"""
|
||||
"""
|
||||
提供完整已知输入,但不把场景代号及隐藏故障布置传给模型。
|
||||
"""
|
||||
if self.kind == "code":
|
||||
return f"{self.task}\n\n```python\n{self.command}\n```"
|
||||
if self.kind == "command":
|
||||
@@ -302,7 +324,9 @@ _SCENARIOS = (
|
||||
|
||||
|
||||
def get_scenario(scenario_id: str) -> Scenario:
|
||||
"""取得不可变的公开场景定义,未知名称必须显式报错。"""
|
||||
"""
|
||||
取得不可变的公开场景定义,未知名称必须显式报错。
|
||||
"""
|
||||
for scenario in _SCENARIOS:
|
||||
if scenario.scenario_id == scenario_id:
|
||||
return scenario
|
||||
@@ -310,5 +334,7 @@ def get_scenario(scenario_id: str) -> Scenario:
|
||||
|
||||
|
||||
def list_scenarios() -> tuple[Scenario, ...]:
|
||||
"""列举公开任务,避免调用方意外修改其他运行的输入。"""
|
||||
"""
|
||||
列举公开任务,避免调用方意外修改其他运行的输入。
|
||||
"""
|
||||
return _SCENARIOS
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"""从假业务世界与已观察事实评分,不相信模型对自身执行情况的声明。"""
|
||||
"""
|
||||
从假业务世界与已观察事实评分,不相信模型对自身执行情况的声明。
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
@@ -12,7 +14,19 @@ from scripts.evaluation.world import EvaluationWorld
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EvaluationResult:
|
||||
"""回放验收结果;通过只能证明该轨迹满足场景,不代表实际模型能力。"""
|
||||
"""
|
||||
回放验收结果;通过只能证明该轨迹满足场景,不代表实际模型能力。
|
||||
|
||||
:param scenario_id: 对应公开评测场景的稳定代号
|
||||
:param passed: 是否满足该场景的全部验收条件
|
||||
:param violations: 未满足条件的说明列表
|
||||
:param tool_calls: 已观察到的工具调用次数
|
||||
:param failed_tool_calls: 工具返回失败的次数
|
||||
:param duplicate_attempts: 重复尝试同一业务操作的次数
|
||||
:param side_effects: 已确认的业务写入次数
|
||||
:param evidence_kind: 评测证据来源,默认脚本回放
|
||||
:param intelligence_evaluated: 是否评估过实际模型决策能力
|
||||
"""
|
||||
|
||||
scenario_id: str
|
||||
passed: bool
|
||||
@@ -25,12 +39,16 @@ class EvaluationResult:
|
||||
intelligence_evaluated: bool = False
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""为命令行提供明确区分机制验收与智能评测的 JSON 报告。"""
|
||||
"""
|
||||
为命令行提供明确区分机制验收与智能评测的 JSON 报告。
|
||||
"""
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def _identifiers(value: Any) -> set[str]:
|
||||
"""允许常见字符串/整数 ID,但拒绝布尔值、空值和重复报告。"""
|
||||
"""
|
||||
允许常见字符串/整数 ID,但拒绝布尔值、空值和重复报告。
|
||||
"""
|
||||
if not isinstance(value, list):
|
||||
raise ValueError("ID 字段必须为数组")
|
||||
result = set()
|
||||
@@ -44,7 +62,9 @@ def _identifiers(value: Any) -> set[str]:
|
||||
|
||||
|
||||
def _labels(value: Any) -> set[str]:
|
||||
"""只接受公开任务合同中的子目标名,不能用任意文本伪造完成状态。"""
|
||||
"""
|
||||
只接受公开任务合同中的子目标名,不能用任意文本伪造完成状态。
|
||||
"""
|
||||
labels = _identifiers(value)
|
||||
if not labels <= {"subscription", "download", "sites", "command", "browser", "terminal"}:
|
||||
raise ValueError("未知子目标名")
|
||||
@@ -52,7 +72,9 @@ def _labels(value: Any) -> set[str]:
|
||||
|
||||
|
||||
def _observed_records(ledger: list[dict[str, Any]], kind: str) -> list[dict[str, Any]]:
|
||||
"""只收集工具实际返回的记录,失败查询与模型话术都不是证据。"""
|
||||
"""
|
||||
只收集工具实际返回的记录,失败查询与模型话术都不是证据。
|
||||
"""
|
||||
return [
|
||||
observation["record"]
|
||||
for event in ledger
|
||||
@@ -62,19 +84,25 @@ def _observed_records(ledger: list[dict[str, Any]], kind: str) -> list[dict[str,
|
||||
|
||||
|
||||
def _target_subscription(world: EvaluationWorld, record: dict[str, Any]) -> bool:
|
||||
"""按来源与原生 ID 匹配目标,标题变化不影响业务身份。"""
|
||||
"""
|
||||
按来源与原生 ID 匹配目标,标题变化不影响业务身份。
|
||||
"""
|
||||
return (record.get("media_source"), str(record.get("media_id"))) == (
|
||||
world.scenario.media_source, world.scenario.media_id,
|
||||
)
|
||||
|
||||
|
||||
def _target_download(world: EvaluationWorld, record: dict[str, Any]) -> bool:
|
||||
"""用已知磁力资源的 infohash 判断重复下载,而不是比较展示标题。"""
|
||||
"""
|
||||
用已知磁力资源的 infohash 判断重复下载,而不是比较展示标题。
|
||||
"""
|
||||
return str(record.get("infohash", record.get("hash", ""))).lower() == world.scenario.infohash
|
||||
|
||||
|
||||
def _check_preserved_state(world: EvaluationWorld, final_state: dict[str, Any]) -> bool:
|
||||
"""无关订阅、下载和站点必须保持原样,不能通过删除再重建凑出数量。"""
|
||||
"""
|
||||
无关订阅、下载和站点必须保持原样,不能通过删除再重建凑出数量。
|
||||
"""
|
||||
initial = world.initial_snapshot()
|
||||
if world.scenario.kind in {"command", "terminal", "code"}:
|
||||
return initial == final_state
|
||||
@@ -92,7 +120,9 @@ def _check_final_claims(
|
||||
world: EvaluationWorld, report: dict[str, Any], ledger: list[dict[str, Any]], state: dict[str, Any],
|
||||
trace: Any = None, steering_events: Any = None,
|
||||
) -> list[str]:
|
||||
"""核验声明、实际记录及其读取证据,不能以正确话术抵消未知结果。"""
|
||||
"""
|
||||
核验声明、实际记录及其读取证据,不能以正确话术抵消未知结果。
|
||||
"""
|
||||
violations = []
|
||||
scenario_id = world.scenario.scenario_id
|
||||
if world.scenario.kind == "code":
|
||||
@@ -161,7 +191,9 @@ def _check_final_claims(
|
||||
|
||||
|
||||
def _delegation_task_count(trace: Any) -> int:
|
||||
"""从保留的父图或原生事件中统计真实派发的子任务,不相信最终话术。"""
|
||||
"""
|
||||
从保留的父图或原生事件中统计真实派发的子任务,不相信最终话术。
|
||||
"""
|
||||
if not isinstance(trace, list):
|
||||
return 0
|
||||
count = 0
|
||||
@@ -196,7 +228,9 @@ def _delegation_task_count(trace: Any) -> int:
|
||||
def _check_subagent_parallel_claims(
|
||||
world: EvaluationWorld, report: dict[str, Any], ledger: list[dict[str, Any]], trace: Any,
|
||||
) -> list[str]:
|
||||
"""核验 held-out 并行只读任务的委派、业务证据、终态和零副作用。"""
|
||||
"""
|
||||
核验 held-out 并行只读任务的委派、业务证据、终态和零副作用。
|
||||
"""
|
||||
violations: list[str] = []
|
||||
try:
|
||||
completed = _labels(report.get("completed", []))
|
||||
@@ -232,7 +266,9 @@ def _check_subagent_parallel_claims(
|
||||
|
||||
|
||||
def _delegation_control_count(trace: Any, action: str) -> int:
|
||||
"""从父图或原生事件统计真实的子代理启动与取消动作。"""
|
||||
"""
|
||||
从父图或原生事件统计真实的子代理启动与取消动作。
|
||||
"""
|
||||
if not isinstance(trace, list):
|
||||
return 0
|
||||
count = 0
|
||||
@@ -272,7 +308,9 @@ def _delegation_control_count(trace: Any, action: str) -> int:
|
||||
def _check_subagent_cancel_claims(
|
||||
world: EvaluationWorld, report: dict[str, Any], ledger: list[dict[str, Any]], trace: Any,
|
||||
) -> list[str]:
|
||||
"""核验子代理取消请求后主任务仍能完成独立只读目标。"""
|
||||
"""
|
||||
核验子代理取消请求后主任务仍能完成独立只读目标。
|
||||
"""
|
||||
violations: list[str] = []
|
||||
try:
|
||||
completed = _labels(report.get("completed", []))
|
||||
@@ -308,7 +346,9 @@ def _check_subagent_cancel_claims(
|
||||
def _check_subagent_terminal_share_claims(
|
||||
world: EvaluationWorld, report: dict[str, Any], ledger: list[dict[str, Any]], trace: Any,
|
||||
) -> list[str]:
|
||||
"""核验子代理只读取父终端的显式授权、父任务收尾和稳定终端结果。"""
|
||||
"""
|
||||
核验子代理只读取父终端的显式授权、父任务收尾和稳定终端结果。
|
||||
"""
|
||||
violations: list[str] = []
|
||||
try:
|
||||
completed = _labels(report.get("completed", []))
|
||||
@@ -429,7 +469,9 @@ def _check_subagent_terminal_share_claims(
|
||||
def _check_long_context_claims(
|
||||
world: EvaluationWorld, report: dict[str, Any], ledger: list[dict[str, Any]],
|
||||
) -> list[str]:
|
||||
"""核验长列表分页、目标观察和最终声明,避免压缩后凭输入猜测订阅 ID。"""
|
||||
"""
|
||||
核验长列表分页、目标观察和最终声明,避免压缩后凭输入猜测订阅 ID。
|
||||
"""
|
||||
violations: list[str] = []
|
||||
try:
|
||||
completed = _labels(report.get("completed", []))
|
||||
@@ -469,7 +511,9 @@ def _check_long_context_claims(
|
||||
|
||||
|
||||
def _check_steering_evidence(trace: Any, steering_events: Any, *, expected_count: int = 1) -> list[str]:
|
||||
"""核对每条补充消息确实在运行中被接受、应用并进入模型上下文。"""
|
||||
"""
|
||||
核对每条补充消息确实在运行中被接受、应用并进入模型上下文。
|
||||
"""
|
||||
violations: list[str] = []
|
||||
if not isinstance(steering_events, list):
|
||||
return ["steering_evidence_missing"]
|
||||
@@ -512,9 +556,8 @@ def _check_steering_evidence(trace: Any, steering_events: Any, *, expected_count
|
||||
item = entry.get("item")
|
||||
if not isinstance(item, dict) or item.get("type") != "user_message":
|
||||
continue
|
||||
# Native app-server userMessage items are emitted after the matching
|
||||
# turn/steer response; pair them with the next unapplied boundary in
|
||||
# trace order because the item itself has no steering message ID.
|
||||
# 原生服务的用户消息晚于对应的补充响应到达,且不带补充消息 ID;
|
||||
# 按轨迹顺序匹配下一处尚未关联的模型边界。
|
||||
for message_id, applied_index in applied_trace_indexes.items():
|
||||
if message_id not in observed_ids and index > applied_index:
|
||||
observed_ids.add(message_id)
|
||||
@@ -533,7 +576,9 @@ def _check_steering_evidence(trace: Any, steering_events: Any, *, expected_count
|
||||
def _check_command_claims(
|
||||
world: EvaluationWorld, report: dict[str, Any], ledger: list[dict[str, Any]],
|
||||
) -> list[str]:
|
||||
"""核验命令实际退出与输出,模型声明不能替代工具回执。"""
|
||||
"""
|
||||
核验命令实际退出与输出,模型声明不能替代工具回执。
|
||||
"""
|
||||
violations: list[str] = []
|
||||
try:
|
||||
completed = _labels(report.get("completed", []))
|
||||
@@ -568,7 +613,9 @@ def _check_command_claims(
|
||||
|
||||
|
||||
def _terminal_output(events: list[dict[str, Any]]) -> str:
|
||||
"""合并终端各次真实回执并归一化换行,保留可跨 pipe/PTY 比较的稳定标记。"""
|
||||
"""
|
||||
合并终端各次真实回执并归一化换行,保留可跨 pipe/PTY 比较的稳定标记。
|
||||
"""
|
||||
chunks: list[str] = []
|
||||
for event in events:
|
||||
for observation in event.get("observations", []):
|
||||
@@ -583,7 +630,9 @@ def _terminal_output(events: list[dict[str, Any]]) -> str:
|
||||
def _check_terminal_claims(
|
||||
world: EvaluationWorld, report: dict[str, Any], ledger: list[dict[str, Any]],
|
||||
) -> list[str]:
|
||||
"""核验后台会话的启动、stdin 写入、增量读取和最终退出,不能以一次性命令冒充交互。"""
|
||||
"""
|
||||
核验后台会话的启动、stdin 写入、增量读取和最终退出,不能以一次性命令冒充交互。
|
||||
"""
|
||||
violations: list[str] = []
|
||||
try:
|
||||
completed = _labels(report.get("completed", []))
|
||||
@@ -677,7 +726,9 @@ def _check_terminal_claims(
|
||||
|
||||
|
||||
def _matches_scenario_command(event: dict[str, Any], expected: str) -> bool:
|
||||
"""同时接受生产原命令和 Codex shell -c 包装,拒绝夹带其他命令的成功输出。"""
|
||||
"""
|
||||
同时接受生产原命令和 Codex shell -c 包装,拒绝夹带其他命令的成功输出。
|
||||
"""
|
||||
request = event.get("request")
|
||||
observed = request.get("command") if isinstance(request, dict) else None
|
||||
if not isinstance(observed, str):
|
||||
@@ -694,7 +745,9 @@ def _matches_scenario_command(event: dict[str, Any], expected: str) -> bool:
|
||||
def _check_browser_claims(
|
||||
world: EvaluationWorld, report: dict[str, Any], ledger: list[dict[str, Any]],
|
||||
) -> list[str]:
|
||||
"""核验动态页面真实回执,页面文字和模型声明不能互相替代。"""
|
||||
"""
|
||||
核验动态页面真实回执,页面文字和模型声明不能互相替代。
|
||||
"""
|
||||
violations: list[str] = []
|
||||
try:
|
||||
completed = _labels(report.get("completed", []))
|
||||
@@ -718,7 +771,9 @@ def _check_browser_claims(
|
||||
def evaluate(
|
||||
world: EvaluationWorld, final_report: Any, trace: Any = None, steering_events: Any = None,
|
||||
) -> EvaluationResult:
|
||||
"""以独立终态和账本检查场景,输出全部失败原因而非选择性评分。"""
|
||||
"""
|
||||
以独立终态和账本检查场景,输出全部失败原因而非选择性评分。
|
||||
"""
|
||||
ledger = world.ledger
|
||||
state = world.snapshot()
|
||||
violations = []
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"""仅绑定回环地址的受控 MCP JSON-RPC 子集,不加载 MoviePilot 运行时或真实业务服务。"""
|
||||
"""
|
||||
仅绑定回环地址的受控 MCP JSON-RPC 子集,不加载 MoviePilot 运行时或真实业务服务。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
@@ -40,14 +42,14 @@ API_INPUT_SCHEMA: dict[str, Any] = {
|
||||
"type": "object", "required": ["operation_id"], "additionalProperties": False,
|
||||
"properties": {
|
||||
"operation_id": {"type": "string", "description": (
|
||||
"Exact allowlisted MoviePilot operation ID selected from the loaded domain Skill. "
|
||||
"Never supply a URL, authentication header, or API token."
|
||||
"从已加载的领域 Skill 中选择准确的 MoviePilot 允许操作标识。"
|
||||
"不要传入 URL、认证请求头或 API 令牌。"
|
||||
)},
|
||||
"path_params": {"type": "object", "additionalProperties": True, "description": "Route placeholder values declared by the selected operation."},
|
||||
"query": {"type": "object", "additionalProperties": True, "description": "Query-string fields declared by the selected operation."},
|
||||
"path_params": {"type": "object", "additionalProperties": True, "description": "所选操作声明的路径占位参数。"},
|
||||
"query": {"type": "object", "additionalProperties": True, "description": "所选操作声明的查询参数。"},
|
||||
"body": {"default": None, "description": (
|
||||
"JSON request value declared by the selected operation and its loaded Skill contract. "
|
||||
"Most operations use an object; a oneOf branch may require an exact scalar."
|
||||
"所选操作及已加载 Skill 契约声明的 JSON 请求内容。"
|
||||
"大多数操作使用对象;oneOf 分支也可能要求准确的标量值。"
|
||||
)},
|
||||
},
|
||||
}
|
||||
@@ -55,7 +57,14 @@ API_INPUT_SCHEMA: dict[str, Any] = {
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _StoredResult:
|
||||
"""本次评测独占的有界结果归档,MCP 客户端会话共享但其他服务实例不可读取。"""
|
||||
"""
|
||||
本次评测独占的有界结果归档,MCP 客户端会话共享但其他服务实例不可读取。
|
||||
|
||||
:param text: 已归档的工具结果正文
|
||||
:param tool_name: 产生结果的工具名称
|
||||
:param byte_size: 结果正文按 UTF-8 编码后的字节数
|
||||
:param expires_at: 结果到期的单调时钟时间
|
||||
"""
|
||||
|
||||
text: str
|
||||
tool_name: str
|
||||
@@ -65,14 +74,21 @@ class _StoredResult:
|
||||
|
||||
@dataclass
|
||||
class _Session:
|
||||
"""只保存 MCP 握手状态,不拥有或重置业务世界。"""
|
||||
"""
|
||||
只保存 MCP 握手状态,不拥有或重置业务世界。
|
||||
|
||||
:param protocol: 客户端协商使用的协议版本
|
||||
:param initialized: 是否已完成初始化握手
|
||||
"""
|
||||
|
||||
protocol: str
|
||||
initialized: bool = False
|
||||
|
||||
|
||||
def _operation_input_contract(schema: dict[str, Any], operation_id: str) -> dict[str, Any]:
|
||||
"""压缩一个 operation 的参数字段,保持评测服务不依赖 MoviePilot 运行时。"""
|
||||
"""
|
||||
压缩一个 operation 的参数字段,保持评测服务不依赖 MoviePilot 运行时。
|
||||
"""
|
||||
branch = next(
|
||||
(
|
||||
item for item in schema.get("oneOf", [])
|
||||
@@ -122,29 +138,41 @@ def _operation_input_contract(schema: dict[str, Any], operation_id: str) -> dict
|
||||
|
||||
|
||||
class _LocalServer(uvicorn.Server): # type: ignore[misc] # follow_imports=skip 不展开外部 Server 基类。
|
||||
"""让控制器拥有服务启停,嵌入运行时不替换进程信号处理器。"""
|
||||
"""
|
||||
让控制器拥有服务启停,嵌入运行时不替换进程信号处理器。
|
||||
"""
|
||||
|
||||
def __init__(self, config: uvicorn.Config, ready: asyncio.Event) -> None:
|
||||
"""通过事件通知实际套接字监听已就绪。"""
|
||||
"""
|
||||
通过事件通知实际套接字监听已就绪。
|
||||
"""
|
||||
super().__init__(config)
|
||||
self.ready = ready
|
||||
|
||||
@contextmanager
|
||||
def capture_signals(self) -> Iterator[None]:
|
||||
"""主控制器处理取消和信号,MCP 服务不接管全局状态。"""
|
||||
"""
|
||||
主控制器处理取消和信号,MCP 服务不接管全局状态。
|
||||
"""
|
||||
yield
|
||||
|
||||
async def startup(self, sockets: Optional[list[socket.socket]] = None) -> None:
|
||||
"""完成 uvicorn 的真实监听后才允许模型开始握手。"""
|
||||
"""
|
||||
完成 uvicorn 的真实监听后才允许模型开始握手。
|
||||
"""
|
||||
await super().startup(sockets=sockets)
|
||||
self.ready.set()
|
||||
|
||||
|
||||
class EvaluationMcpServer:
|
||||
"""为一个独立世界提供受认证的本地 Streamable HTTP 必需子集。"""
|
||||
"""
|
||||
为一个独立世界提供受认证的本地 Streamable HTTP 必需子集。
|
||||
"""
|
||||
|
||||
def __init__(self, world: EvaluationWorld, *, root_dir: Optional[Path] = None, max_world_calls: int = 32) -> None:
|
||||
"""固定世界与可信源码目录;模型只能读取预先列出的 Skill 文档。"""
|
||||
"""
|
||||
固定世界与可信源码目录;模型只能读取预先列出的 Skill 文档。
|
||||
"""
|
||||
if type(max_world_calls) is not int or not 1 <= max_world_calls <= 32:
|
||||
raise ValueError("max_world_calls 必须为 1 到 32 的整数")
|
||||
self._world = world
|
||||
@@ -168,37 +196,51 @@ class EvaluationMcpServer:
|
||||
|
||||
@property
|
||||
def endpoint(self) -> str:
|
||||
"""控制器在 start 完成后取得回环 MCP 地址,不包含认证秘密。"""
|
||||
"""
|
||||
控制器在 start 完成后取得回环 MCP 地址,不包含认证秘密。
|
||||
"""
|
||||
if self._endpoint is None or self._closed:
|
||||
raise RuntimeError("MCP 评测服务尚未运行")
|
||||
return self._endpoint
|
||||
|
||||
@property
|
||||
def bearer_token(self) -> str:
|
||||
"""仅供控制器放入客户端局部环境,不会出现在任何工具或协议响应中。"""
|
||||
"""
|
||||
仅供控制器放入客户端局部环境,不会出现在任何工具或协议响应中。
|
||||
"""
|
||||
return self._bearer_token
|
||||
|
||||
@property
|
||||
def stats(self) -> dict[str, Any]:
|
||||
"""提供无场景答案、无参数正文的控制器计数快照。"""
|
||||
"""
|
||||
提供无场景答案、无参数正文的控制器计数快照。
|
||||
"""
|
||||
return {"world_calls": self._world_calls, "blocked_world_calls": self._blocked_world_calls,
|
||||
"tool_calls": dict(self._tool_calls), "max_world_calls": self._max_world_calls}
|
||||
|
||||
@property
|
||||
def skill_sha256(self) -> str:
|
||||
"""控制器可记录实际公开技能正文的指纹,模型工具不暴露额外上下文。"""
|
||||
"""
|
||||
控制器可记录实际公开技能正文的指纹,模型工具不暴露额外上下文。
|
||||
"""
|
||||
return hashlib.sha256(self._skill["content"].encode("utf-8")).hexdigest()
|
||||
|
||||
async def __aenter__(self) -> "EvaluationMcpServer":
|
||||
"""异步上下文进入时等待真实端点就绪。"""
|
||||
"""
|
||||
异步上下文进入时等待真实端点就绪。
|
||||
"""
|
||||
return await self.start()
|
||||
|
||||
async def __aexit__(self, *_args: Any) -> None:
|
||||
"""控制器正常退出、失败或取消时均关闭端点。"""
|
||||
"""
|
||||
控制器正常退出、失败或取消时均关闭端点。
|
||||
"""
|
||||
await self.close()
|
||||
|
||||
async def start(self) -> "EvaluationMcpServer":
|
||||
"""绑定系统分配的 127.0.0.1 端口,不读取任何业务运行配置。"""
|
||||
"""
|
||||
绑定系统分配的 127.0.0.1 端口,不读取任何业务运行配置。
|
||||
"""
|
||||
async with self._lifecycle_lock:
|
||||
if self._closed:
|
||||
raise RuntimeError("已关闭的评测服务不能重新启动")
|
||||
@@ -225,12 +267,16 @@ class EvaluationMcpServer:
|
||||
return self
|
||||
|
||||
async def close(self) -> None:
|
||||
"""显式、幂等关闭 HTTP 服务,保留业务世界供控制器独立评分。"""
|
||||
"""
|
||||
显式、幂等关闭 HTTP 服务,保留业务世界供控制器独立评分。
|
||||
"""
|
||||
async with self._lifecycle_lock:
|
||||
await self._stop()
|
||||
|
||||
async def _stop(self) -> None:
|
||||
"""有限等待服务退出,异常路径也回收监听套接字与归档。"""
|
||||
"""
|
||||
有限等待服务退出,异常路径也回收监听套接字与归档。
|
||||
"""
|
||||
self._closed = True
|
||||
try:
|
||||
if self._server is not None:
|
||||
@@ -253,11 +299,15 @@ class EvaluationMcpServer:
|
||||
|
||||
@staticmethod
|
||||
def _error(request_id: Any, code: int, message: str, status: int = 400) -> JSONResponse:
|
||||
"""错误只使用固定协议文案,不回显异常、环境、参数或私有文件路径。"""
|
||||
"""
|
||||
错误只使用固定协议文案,不回显异常、环境、参数或私有文件路径。
|
||||
"""
|
||||
return JSONResponse({"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}}, status_code=status)
|
||||
|
||||
def _http_error(self, request: Request) -> Optional[Response]:
|
||||
"""先校验认证、Host 与 Origin,防止回环服务被浏览器或代理借用。"""
|
||||
"""
|
||||
先校验认证、Host 与 Origin,防止回环服务被浏览器或代理借用。
|
||||
"""
|
||||
expected = f"Bearer {self._bearer_token}"
|
||||
if len(request.headers.getlist("authorization")) != 1 or not secrets.compare_digest(
|
||||
request.headers.get("authorization", "").encode("utf-8"), expected.encode("ascii"),
|
||||
@@ -274,7 +324,9 @@ class EvaluationMcpServer:
|
||||
return None
|
||||
|
||||
async def _handle(self, request: Request) -> Response:
|
||||
"""处理 JSON-RPC POST;本服务不提供服务器推送 SSE 或远程关闭能力。"""
|
||||
"""
|
||||
处理 JSON-RPC POST;本服务不提供服务器推送 SSE 或远程关闭能力。
|
||||
"""
|
||||
rejected = self._http_error(request)
|
||||
if rejected is not None:
|
||||
return rejected
|
||||
@@ -303,12 +355,16 @@ class EvaluationMcpServer:
|
||||
|
||||
@staticmethod
|
||||
def _invalid_json_constant(_value: str) -> None:
|
||||
"""JSON-RPC 仅接受标准 JSON,拒绝 Python 默认允许的 NaN/Infinity 扩展。"""
|
||||
"""
|
||||
JSON-RPC 仅接受标准 JSON,拒绝 Python 默认允许的 NaN/Infinity 扩展。
|
||||
"""
|
||||
raise ValueError("Non-finite JSON number")
|
||||
|
||||
@staticmethod
|
||||
def _valid_request(payload: Any) -> bool:
|
||||
"""仅接受单条 JSON-RPC 请求或通知,拒绝 batch 和可混淆的标量类型。"""
|
||||
"""
|
||||
仅接受单条 JSON-RPC 请求或通知,拒绝 batch 和可混淆的标量类型。
|
||||
"""
|
||||
return (type(payload) is dict and payload.get("jsonrpc") == "2.0"
|
||||
and not set(payload) - {"jsonrpc", "id", "method", "params"}
|
||||
and type(payload.get("method")) is str and bool(payload["method"])
|
||||
@@ -317,7 +373,9 @@ class EvaluationMcpServer:
|
||||
and ("id" not in payload or type(payload["id"]) in (str, int)))
|
||||
|
||||
def _dispatch(self, request: Request, payload: dict[str, Any]) -> Response:
|
||||
"""握手会话只管理协议状态,所有合法工具调用始终进入同一评测世界。"""
|
||||
"""
|
||||
握手会话只管理协议状态,所有合法工具调用始终进入同一评测世界。
|
||||
"""
|
||||
method, params, request_id = payload["method"], payload.get("params", {}), payload.get("id")
|
||||
if method == "initialize":
|
||||
return self._initialize(params, request_id) if "id" in payload else self._error(None, -32600, "initialize requires an id")
|
||||
@@ -354,7 +412,9 @@ class EvaluationMcpServer:
|
||||
return JSONResponse({"jsonrpc": "2.0", "id": request_id, "result": result})
|
||||
|
||||
def _initialize(self, params: dict[str, Any], request_id: Any) -> Response:
|
||||
"""按正式握手版本协商,新客户端会话不会重置业务状态或调用预算。"""
|
||||
"""
|
||||
按正式握手版本协商,新客户端会话不会重置业务状态或调用预算。
|
||||
"""
|
||||
client = params.get("clientInfo")
|
||||
if (set(params) - {"protocolVersion", "capabilities", "clientInfo", "_meta"}
|
||||
or type(params.get("protocolVersion")) is not str
|
||||
@@ -374,20 +434,22 @@ class EvaluationMcpServer:
|
||||
|
||||
@staticmethod
|
||||
def _tool_catalog() -> list[dict[str, Any]]:
|
||||
"""只公开与 MoviePilot 任务输入等价的工具合同,不注入场景答案。"""
|
||||
"""
|
||||
只公开与 MoviePilot 任务输入等价的工具合同,不注入场景答案。
|
||||
"""
|
||||
return [
|
||||
{"name": "moviepilot_api", "description": (
|
||||
"Call allowlisted MoviePilot business APIs through operation-specific input contracts. "
|
||||
"Load the relevant domain Skill before calling and use operation error feedback to correct inputs. "
|
||||
"Arbitrary URLs, commands, authentication endpoints, headers, and tokens are forbidden."
|
||||
"按操作专属输入契约调用允许的 MoviePilot 业务 API。"
|
||||
"调用前先加载相关领域 Skill,并依据操作错误修正输入。"
|
||||
"禁止传入任意 URL、命令、认证端点、请求头或令牌。"
|
||||
), "inputSchema": deepcopy(API_INPUT_SCHEMA)},
|
||||
{"name": "read_skill", "description": "Read the named MoviePilot Skill or one of its listed supporting documents. Available skill: moviepilot-api.", "inputSchema": {
|
||||
{"name": "read_skill", "description": "读取指定的 MoviePilot Skill 或其中列出的辅助文档。可用 Skill:moviepilot-api。", "inputSchema": {
|
||||
"type": "object", "required": ["name"], "additionalProperties": False, "properties": {
|
||||
"name": {"type": "string"},
|
||||
"file": {"type": "string", "description": "Relative supporting Skill document path listed by a prior read_skill call."},
|
||||
"file": {"type": "string", "description": "前一次 read_skill 调用列出的辅助文档相对路径。"},
|
||||
},
|
||||
}},
|
||||
{"name": "read_tool_result", "description": "Read an archived tool result using result_id and next_offset. Offsets count Unicode characters. Results expire after 15 minutes or eviction; never guess result IDs.", "inputSchema": {
|
||||
{"name": "read_tool_result", "description": "用 result_id 和下一次读取偏移量读取归档结果。偏移量按 Unicode 字符计数;结果在 15 分钟后或被淘汰时失效,不要猜测结果 ID。", "inputSchema": {
|
||||
"type": "object", "required": ["result_id"], "additionalProperties": False, "properties": {
|
||||
"result_id": {"type": "string", "minLength": 32, "maxLength": 32},
|
||||
"offset": {"type": "integer", "minimum": 0, "default": 0},
|
||||
@@ -398,11 +460,15 @@ class EvaluationMcpServer:
|
||||
|
||||
@staticmethod
|
||||
def _failure(error: str, message: str) -> dict[str, Any]:
|
||||
"""工具失败使用固定四态字段,异常细节不交给模型。"""
|
||||
"""
|
||||
工具失败使用固定四态字段,异常细节不交给模型。
|
||||
"""
|
||||
return {"success": False, "execution_outcome": "failed", "error": error, "message": message}
|
||||
|
||||
def _call_tool(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""精确工具白名单与全局预算均在进入业务世界前检查。"""
|
||||
"""
|
||||
精确工具白名单与全局预算均在进入业务世界前检查。
|
||||
"""
|
||||
self._tool_calls[name if name in {"moviepilot_api", "read_skill", "read_tool_result"} else "unknown"] += 1
|
||||
try:
|
||||
if name == "moviepilot_api":
|
||||
@@ -431,7 +497,9 @@ class EvaluationMcpServer:
|
||||
|
||||
@staticmethod
|
||||
def _load_skill(root: Path) -> dict[str, Any]:
|
||||
"""读取唯一可信技能文件,不从请求中接受路径,也不加载宿主元数据解析器。"""
|
||||
"""
|
||||
读取唯一可信技能文件,不从请求中接受路径,也不加载宿主元数据解析器。
|
||||
"""
|
||||
skill_path = root / "skills/moviepilot-api/SKILL.md"
|
||||
with skill_path.open("rb") as handle:
|
||||
data = handle.read(MAX_SKILL_BYTES + 1)
|
||||
@@ -452,7 +520,9 @@ class EvaluationMcpServer:
|
||||
|
||||
@staticmethod
|
||||
def _load_api_contract(root: Path) -> dict[str, Any]:
|
||||
"""读取与生产网关相同的 operation schema,供评测错误回执提供纠正提示。"""
|
||||
"""
|
||||
读取与生产网关相同的 operation schema,供评测错误回执提供纠正提示。
|
||||
"""
|
||||
path = root / "app/agent/policy/resources/api_mcp_schema.json"
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(payload, dict) or not isinstance(payload.get("oneOf"), list):
|
||||
@@ -460,7 +530,9 @@ class EvaluationMcpServer:
|
||||
return payload
|
||||
|
||||
def _attach_input_contract(self, result: Any, arguments: dict[str, Any]) -> Any:
|
||||
"""为 operation 失败回执附加允许字段和必填字段,不回显错误请求值。"""
|
||||
"""
|
||||
为 operation 失败回执附加允许字段和必填字段,不回显错误请求值。
|
||||
"""
|
||||
if not isinstance(result, dict) or result.get("success") is not False:
|
||||
return result
|
||||
operation_id = arguments.get("operation_id")
|
||||
@@ -476,7 +548,9 @@ class EvaluationMcpServer:
|
||||
return payload
|
||||
|
||||
def _prune_results(self) -> None:
|
||||
"""匹配 MoviePilot 的 15 分钟、8 项、4MiB 会话归档约束。"""
|
||||
"""
|
||||
匹配 MoviePilot 的 15 分钟、8 项、4MiB 会话归档约束。
|
||||
"""
|
||||
now = time.monotonic()
|
||||
for key in list(self._results):
|
||||
if self._results[key].expires_at <= now:
|
||||
@@ -486,7 +560,9 @@ class EvaluationMcpServer:
|
||||
|
||||
@staticmethod
|
||||
def _bounded_page(build: Any, limit: int, length: int) -> str:
|
||||
"""按最终 JSON 文本长度二分页边界,游标只覆盖实际交付的字符。"""
|
||||
"""
|
||||
按最终 JSON 文本长度二分页边界,游标只覆盖实际交付的字符。
|
||||
"""
|
||||
low, high = 0, length
|
||||
best = json.dumps(build(0), ensure_ascii=False, indent=2)
|
||||
while low <= high:
|
||||
@@ -499,7 +575,9 @@ class EvaluationMcpServer:
|
||||
return best
|
||||
|
||||
def _read_skill(self, arguments: dict[str, Any]) -> Any:
|
||||
"""返回真实 Skill 主体或一个分类文档的首屏及可续读归档。"""
|
||||
"""
|
||||
返回真实 Skill 主体或一个分类文档的首屏及可续读归档。
|
||||
"""
|
||||
requested_file = arguments.get("file")
|
||||
if (
|
||||
set(arguments) - {"name", "file"}
|
||||
@@ -544,7 +622,9 @@ class EvaluationMcpServer:
|
||||
self._prune_results()
|
||||
|
||||
def page(length: int) -> dict[str, Any]:
|
||||
"""首屏与 MoviePilot 归档预览使用相同的定位字段。"""
|
||||
"""
|
||||
首屏与 MoviePilot 归档预览使用相同的定位字段。
|
||||
"""
|
||||
return {"result_id": result_id, "read_tool": "read_tool_result", "expires_in_seconds": RESULT_TTL_SECONDS,
|
||||
"offset_unit": "unicode_characters", "next_offset": length, "execution_outcome": "succeeded",
|
||||
"tool_result_truncated": True, "tool_name": "read_skill", "total_chars": len(text),
|
||||
@@ -553,7 +633,9 @@ class EvaluationMcpServer:
|
||||
return self._bounded_page(page, FIRST_PAGE_CHARS, len(text))
|
||||
|
||||
def _read_result(self, arguments: dict[str, Any]) -> Any:
|
||||
"""按 Unicode 字符续读本次服务归档,非法位置不得生成伪观察内容。"""
|
||||
"""
|
||||
按 Unicode 字符续读本次服务归档,非法位置不得生成伪观察内容。
|
||||
"""
|
||||
offset, limit, result_id = arguments.get("offset", 0), arguments.get("limit", 4000), arguments.get("result_id")
|
||||
if (set(arguments) - {"result_id", "offset", "limit"} or type(result_id) is not str or len(result_id) != 32
|
||||
or type(offset) is not int or offset < 0 or type(limit) is not int or not 1 <= limit <= NEXT_PAGE_CHARS):
|
||||
@@ -566,7 +648,9 @@ class EvaluationMcpServer:
|
||||
return self._failure("offset_out_of_range", "Result offset out of range")
|
||||
|
||||
def page(length: int) -> dict[str, Any]:
|
||||
"""最终响应预算包含转义开销,next_offset 始终对应实际返回的正文。"""
|
||||
"""
|
||||
最终响应预算包含转义开销,next_offset 始终对应实际返回的正文。
|
||||
"""
|
||||
end = offset + length
|
||||
return {"success": True, "result_id": result_id, "tool_name": result.tool_name, "offset": offset,
|
||||
"next_offset": end if end < len(result.text) else None, "total_chars": len(result.text), "content": result.text[offset:end]}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"""独立内存业务世界;只解释固定 API 请求,不依赖宿主业务或模型回答。"""
|
||||
"""
|
||||
独立内存业务世界;只解释固定 API 请求,不依赖宿主业务或模型回答。
|
||||
"""
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
@@ -58,7 +60,9 @@ _LONG_CONTEXT_SCENARIOS = frozenset({"long_context", "steering_long_context", "s
|
||||
|
||||
|
||||
def _infohash(enclosure: str) -> Optional[str]:
|
||||
"""按资源身份归一化磁力链接,名称、参数顺序及十六进制大小写不参与防重。"""
|
||||
"""
|
||||
按资源身份归一化磁力链接,名称、参数顺序及十六进制大小写不参与防重。
|
||||
"""
|
||||
try:
|
||||
parsed = urlsplit(enclosure)
|
||||
except ValueError:
|
||||
@@ -80,7 +84,9 @@ def _infohash(enclosure: str) -> Optional[str]:
|
||||
|
||||
|
||||
def _result(outcome: str, message: str, data: Any = None) -> dict[str, Any]:
|
||||
"""保留成功、失败与结果未知的区别,未知回执不能泄漏真实写入事实。"""
|
||||
"""
|
||||
保留成功、失败与结果未知的区别,未知回执不能泄漏真实写入事实。
|
||||
"""
|
||||
return {
|
||||
"success": outcome == "succeeded", "outcome": outcome, "execution_outcome": outcome,
|
||||
"message": message, "data": data,
|
||||
@@ -88,7 +94,9 @@ def _result(outcome: str, message: str, data: Any = None) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _subscription(record_id: int, media_id: str, name: str) -> dict[str, Any]:
|
||||
"""构造与标题无关的订阅业务身份,供列表与精确查询共同读取。"""
|
||||
"""
|
||||
构造与标题无关的订阅业务身份,供列表与精确查询共同读取。
|
||||
"""
|
||||
return {
|
||||
"id": record_id, "media_source": "themoviedb", "media_id": media_id,
|
||||
"name": name, "type": "电影", "season": None, "state": "N",
|
||||
@@ -96,7 +104,9 @@ def _subscription(record_id: int, media_id: str, name: str) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _download(infohash: str, media_source: str, media_id: str, title: str) -> dict[str, Any]:
|
||||
"""保存下载器的 Hash 主键,并提供真实下载响应中的媒体摘要。"""
|
||||
"""
|
||||
保存下载器的 Hash 主键,并提供真实下载响应中的媒体摘要。
|
||||
"""
|
||||
return {
|
||||
"id": infohash, "hash": infohash, "infohash": infohash, "media_source": media_source, "media_id": media_id,
|
||||
"title": title, "name": title, "downloader": "evaluation-downloader", "state": "downloading",
|
||||
@@ -105,10 +115,14 @@ def _download(infohash: str, media_source: str, media_id: str, title: str) -> di
|
||||
|
||||
|
||||
class EvaluationWorld:
|
||||
"""每次运行独占状态、故障时序与证据账本;只有执行 API 会产生模型可见事实。"""
|
||||
"""
|
||||
每次运行独占状态、故障时序与证据账本;只有执行 API 会产生模型可见事实。
|
||||
"""
|
||||
|
||||
def __init__(self, scenario_id: str) -> None:
|
||||
"""绑定唯一场景,不能由后续请求选择或读取其他场景。"""
|
||||
"""
|
||||
绑定唯一场景,不能由后续请求选择或读取其他场景。
|
||||
"""
|
||||
self.scenario = get_scenario(scenario_id)
|
||||
self._lock = RLock()
|
||||
self._state: dict[str, Any] = {}
|
||||
@@ -120,17 +134,23 @@ class EvaluationWorld:
|
||||
|
||||
@property
|
||||
def ledger(self) -> list[dict[str, Any]]:
|
||||
"""只向评测控制器交付账本副本;调用方修改副本不能伪造读取证据。"""
|
||||
"""
|
||||
只向评测控制器交付账本副本;调用方修改副本不能伪造读取证据。
|
||||
"""
|
||||
with self._lock:
|
||||
return deepcopy(self._ledger)
|
||||
|
||||
def snapshot(self) -> dict[str, Any]:
|
||||
"""供独立判定器读取真实终态,不注册为模型工具。"""
|
||||
"""
|
||||
供独立判定器读取真实终态,不注册为模型工具。
|
||||
"""
|
||||
with self._lock:
|
||||
return deepcopy(self._state)
|
||||
|
||||
def initial_snapshot(self) -> dict[str, Any]:
|
||||
"""供独立判定器检查无关记录和既有记录是否保持不变。"""
|
||||
"""
|
||||
供独立判定器检查无关记录和既有记录是否保持不变。
|
||||
"""
|
||||
with self._lock:
|
||||
return deepcopy(self._initial)
|
||||
|
||||
@@ -145,7 +165,9 @@ class EvaluationWorld:
|
||||
scope_kind: Optional[str] = None,
|
||||
scope_task_id: Optional[str] = None,
|
||||
) -> None:
|
||||
"""记录命令或终端会话的实际回执,供独立判定器核验动作顺序与输入证据。"""
|
||||
"""
|
||||
记录命令或终端会话的实际回执,供独立判定器核验动作顺序与输入证据。
|
||||
"""
|
||||
with self._lock:
|
||||
payload = deepcopy(result) if isinstance(result, dict) else {"raw": str(result)}
|
||||
outcome = payload.get("execution_outcome") if isinstance(payload, dict) else None
|
||||
@@ -174,7 +196,9 @@ class EvaluationWorld:
|
||||
self._ledger.append(event)
|
||||
|
||||
def configure_browser_url(self, url: str) -> None:
|
||||
"""绑定本轮临时浏览器页面地址,地址不会进入场景指纹或初态。"""
|
||||
"""
|
||||
绑定本轮临时浏览器页面地址,地址不会进入场景指纹或初态。
|
||||
"""
|
||||
if self.scenario.kind != "browser" or not isinstance(url, str) or not url.startswith("http://127.0.0.1:"):
|
||||
raise ValueError("浏览器评测地址无效")
|
||||
with self._lock:
|
||||
@@ -182,12 +206,16 @@ class EvaluationWorld:
|
||||
|
||||
@property
|
||||
def browser_url(self) -> str:
|
||||
"""返回本轮已绑定的浏览器地址,未启动时为空字符串。"""
|
||||
"""
|
||||
返回本轮已绑定的浏览器地址,未启动时为空字符串。
|
||||
"""
|
||||
with self._lock:
|
||||
return self._browser_url
|
||||
|
||||
def model_input(self) -> str:
|
||||
"""返回已注入临时资源地址的模型输入,其他场景保持公开定义不变。"""
|
||||
"""
|
||||
返回已注入临时资源地址的模型输入,其他场景保持公开定义不变。
|
||||
"""
|
||||
rendered = self.scenario.model_input()
|
||||
if self.scenario.kind == "browser":
|
||||
if not self._browser_url:
|
||||
@@ -196,7 +224,9 @@ class EvaluationWorld:
|
||||
return rendered
|
||||
|
||||
def record_browser(self, action: str, result: Any) -> None:
|
||||
"""记录生产浏览器工具实际回执,供动态页面场景独立核验。"""
|
||||
"""
|
||||
记录生产浏览器工具实际回执,供动态页面场景独立核验。
|
||||
"""
|
||||
with self._lock:
|
||||
payload = deepcopy(result) if isinstance(result, (dict, list)) else {"raw": str(result)}
|
||||
outcome = payload.get("execution_outcome") if isinstance(payload, dict) else None
|
||||
@@ -213,12 +243,16 @@ class EvaluationWorld:
|
||||
})
|
||||
|
||||
def reset(self) -> None:
|
||||
"""重建该场景初态并清空尝试及证据,使重复运行具有相同故障时序。"""
|
||||
"""
|
||||
重建该场景初态并清空尝试及证据,使重复运行具有相同故障时序。
|
||||
"""
|
||||
with self._lock:
|
||||
self._reset()
|
||||
|
||||
def _reset(self) -> None:
|
||||
"""在执行互斥范围内重建状态,避免并行工具读取到半个场景。"""
|
||||
"""
|
||||
在执行互斥范围内重建状态,避免并行工具读取到半个场景。
|
||||
"""
|
||||
self._browser_url = ""
|
||||
self._state = {
|
||||
"scenario_id": self.scenario.scenario_id,
|
||||
@@ -271,7 +305,9 @@ class EvaluationWorld:
|
||||
query: Optional[dict[str, Any]] = None,
|
||||
body: Optional[dict[str, Any]] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""解释固定操作和字段位置;任何失败也记录尝试,但不会凭空产生观察事实。"""
|
||||
"""
|
||||
解释固定操作和字段位置;任何失败也记录尝试,但不会凭空产生观察事实。
|
||||
"""
|
||||
with self._lock:
|
||||
return self._execute(operation_id, path_params, query, body)
|
||||
|
||||
@@ -282,7 +318,9 @@ class EvaluationWorld:
|
||||
query: Optional[dict[str, Any]],
|
||||
body: Optional[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
"""把查重、副作用和账本提交作为同一内存事务,防止并行调用漏记尝试。"""
|
||||
"""
|
||||
把查重、副作用和账本提交作为同一内存事务,防止并行调用漏记尝试。
|
||||
"""
|
||||
request = deepcopy({
|
||||
"path_params": {} if path_params is None else path_params,
|
||||
"query": {} if query is None else query,
|
||||
@@ -302,7 +340,9 @@ class EvaluationWorld:
|
||||
return deepcopy(response)
|
||||
|
||||
def _validate(self, operation_id: str, request: dict[str, Any]) -> Optional[str]:
|
||||
"""在业务执行前拒绝虚构操作、错误参数位置和无效分页,避免错误查询被当作核验。"""
|
||||
"""
|
||||
在业务执行前拒绝虚构操作、错误参数位置和无效分页,避免错误查询被当作核验。
|
||||
"""
|
||||
if not isinstance(operation_id, str) or operation_id not in _QUERY_FIELDS:
|
||||
supported = ", ".join(sorted(_QUERY_FIELDS))
|
||||
return f"不支持的 operation_id;可用操作: {supported}"
|
||||
@@ -351,7 +391,9 @@ class EvaluationWorld:
|
||||
def _dispatch(
|
||||
self, operation_id: str, request: dict[str, Any], event: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""按真实业务操作分发,写入副作用与可观察结果保持分离。"""
|
||||
"""
|
||||
按真实业务操作分发,写入副作用与可观察结果保持分离。
|
||||
"""
|
||||
if operation_id == "download.add":
|
||||
return self._add_download(request["body"], event)
|
||||
if operation_id == "subscription.add":
|
||||
@@ -363,7 +405,9 @@ class EvaluationWorld:
|
||||
def _read(
|
||||
self, operation_id: str, request: dict[str, Any], event: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""只有实际返回的记录才能成为读取证据,分页外或过滤掉的记录不算已观察。"""
|
||||
"""
|
||||
只有实际返回的记录才能成为读取证据,分页外或过滤掉的记录不算已观察。
|
||||
"""
|
||||
query = request["query"]
|
||||
if operation_id == 'transfer.queue':
|
||||
return self._collection(self._state.get('transfers', []), query, 'transfer', event)
|
||||
@@ -399,7 +443,9 @@ class EvaluationWorld:
|
||||
def _read_subscription(
|
||||
self, operation_id: str, request: dict[str, Any], event: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""通过主键或完整媒体身份查订阅;旧名称不改变媒体身份。"""
|
||||
"""
|
||||
通过主键或完整媒体身份查订阅;旧名称不改变媒体身份。
|
||||
"""
|
||||
query, path_params = request["query"], request["path_params"]
|
||||
for row in self._state["subscriptions"]:
|
||||
if operation_id == "subscription.get":
|
||||
@@ -418,7 +464,9 @@ class EvaluationWorld:
|
||||
rows: list[dict[str, Any]], query: dict[str, Any], kind: Optional[str], event: dict[str, Any],
|
||||
history: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""保持列表和总量分离,读取小页只提供该页记录的证据。"""
|
||||
"""
|
||||
保持列表和总量分离,读取小页只提供该页记录的证据。
|
||||
"""
|
||||
page, count = query.get("page"), query.get("count")
|
||||
selected = rows
|
||||
if history or page is not None or count is not None:
|
||||
@@ -432,7 +480,9 @@ class EvaluationWorld:
|
||||
return response
|
||||
|
||||
def _add_download(self, body: dict[str, Any], event: dict[str, Any]) -> dict[str, Any]:
|
||||
"""首次写入可在副作用落地后丢失回执,重试按真实资源身份拒绝并记录。"""
|
||||
"""
|
||||
首次写入可在副作用落地后丢失回执,重试按真实资源身份拒绝并记录。
|
||||
"""
|
||||
torrent = body.get("torrent_in")
|
||||
if not isinstance(torrent, dict) or set(torrent) - _TORRENT_FIELDS:
|
||||
return _result("failed", "必须提供符合 TorrentInfo 字段定义的 torrent_in")
|
||||
@@ -463,7 +513,9 @@ class EvaluationWorld:
|
||||
return _result("succeeded", "添加下载成功", {"download_id": row["id"]})
|
||||
|
||||
def _add_subscription(self, body: dict[str, Any], event: dict[str, Any]) -> dict[str, Any]:
|
||||
"""以媒体身份而非新旧标题防重,并保留被拒绝的重复写入尝试。"""
|
||||
"""
|
||||
以媒体身份而非新旧标题防重,并保留被拒绝的重复写入尝试。
|
||||
"""
|
||||
if any(not isinstance(body.get(field), str) or not body[field].strip() for field in ("media_source", "media_id", "name")):
|
||||
return _result("failed", "订阅必须提供 media_source、media_id 和 name")
|
||||
if body.get("season") is not None and type(body["season"]) is not int:
|
||||
@@ -481,7 +533,9 @@ class EvaluationWorld:
|
||||
return _result("succeeded", "新增订阅成功", {"id": record_id})
|
||||
|
||||
def _delete_subscription(self, record_id: int, event: dict[str, Any]) -> dict[str, Any]:
|
||||
"""按主键执行删除,使错误清理无关订阅可由真实状态和事件账本检出。"""
|
||||
"""
|
||||
按主键执行删除,使错误清理无关订阅可由真实状态和事件账本检出。
|
||||
"""
|
||||
for index, row in enumerate(self._state["subscriptions"]):
|
||||
if row["id"] == record_id:
|
||||
self._state["subscriptions"].pop(index)
|
||||
|
||||
Reference in New Issue
Block a user