test(agent): add isolated task world and evidence-based evaluation

This commit is contained in:
jxxghp
2026-09-09 21:48:29 +08:00
parent f32a210223
commit aca9b00db2
9 changed files with 1163 additions and 0 deletions

View File

@@ -0,0 +1 @@
"""离线 Agent 评测,业务模拟、判定器与执行入口各自独立。"""

View File

@@ -0,0 +1,74 @@
"""离线回放评测轨迹;此入口不会调用模型、真实 MoviePilot 或外部服务。"""
import argparse
import hashlib
import json
from dataclasses import asdict
from pathlib import Path
from typing import Any
from scripts.evaluation.scenarios import list_scenarios
from scripts.evaluation.score import evaluate
from scripts.evaluation.world import EvaluationWorld
MAX_REPLAY_BYTES = 256 * 1024
MAX_REPLAY_CALLS = 32
_CALL_FIELDS = frozenset({"operation_id", "path_params", "query", "body"})
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()
for path in sorted(Path(__file__).parent.glob("*.py")):
digest.update(path.name.encode("utf-8") + b"\0" + path.read_bytes() + b"\0")
return {"schema_version": 1, "scenario_sha256": hashlib.sha256(fixture.encode("utf-8")).hexdigest(),
"harness_sha256": digest.hexdigest(), "model": None, "model_calls": 0, "tokens": None}
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"]
if not isinstance(calls, list) or len(calls) > MAX_REPLAY_CALLS:
raise ValueError(f"calls 必须为不超过 {MAX_REPLAY_CALLS} 项的数组")
for call in calls:
if not isinstance(call, dict) or set(call) - _CALL_FIELDS or not isinstance(call.get("operation_id"), str):
raise ValueError("调用只接受 operation_id/path_params/query/body")
world = EvaluationWorld(scenario_id)
for call in calls:
world.execute(**call)
return {**evaluate(world, payload["final"]).to_dict(), **_provenance(world)}
def main(argv: list[str] | None = None) -> int:
"""打印公开场景或回放文件,并以非零退出码表达验收未通过。"""
parser = argparse.ArgumentParser(description="MoviePilot Agent 离线轨迹验收;不代表真实模型评分")
parser.add_argument("--list", action="store_true", help="列出公开场景,不输出初态或答案")
parser.add_argument("--scenario", choices=[item.scenario_id for item in list_scenarios()])
parser.add_argument("--replay", type=Path, help="包含 calls 与 final 的 JSON 轨迹")
parser.add_argument("--output", type=Path, help="可选 JSON 报告路径")
args = parser.parse_args(argv)
if args.list:
print(json.dumps([{"id": item.scenario_id, "task": item.model_input()} for item in list_scenarios()], ensure_ascii=False, indent=2))
return 0
if args.scenario is None or args.replay is None:
parser.error("回放需要 --scenario 和 --replay")
try:
if args.replay.stat().st_size > MAX_REPLAY_BYTES:
raise ValueError("回放文件超过 256 KiB")
payload = json.loads(args.replay.read_text(encoding="utf-8"))
result = replay(args.scenario, payload)
except (OSError, UnicodeError, ValueError, TypeError) as error:
parser.error(str(error))
rendered = json.dumps(result, ensure_ascii=False, indent=2)
if args.output is not None:
args.output.write_text(rendered + "\n", encoding="utf-8")
print(rendered)
return 0 if result["passed"] else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,76 @@
"""只向被测 Agent 公开用户任务与已知媒体、资源输入。"""
from dataclasses import dataclass
_PUBLIC_INSTRUCTIONS = (
"媒体身份与磁力资源已明确给出,默认下载器和目录已配置,无需重新搜索识别。"
"不要重复提交已有订阅或资源。"
"最终仅返回 JSON 对象:status 为 completed 或 blocked;subscription_ids、download_ids、enabled_site_ids "
"为实际确认的 ID 数组;completed、unresolved 为子目标名数组,可用名称为 subscription、download、sites。"
"无证据的 ID 不要编造,暂时无法确认的事项放入 unresolved。"
)
@dataclass(frozen=True)
class Scenario:
"""场景的公开输入,不包含业务初态、故障配置或验收答案。"""
scenario_id: str
task: str
media_source: str
media_id: str
title: str
magnet: str
infohash: str
def model_input(self) -> str:
"""提供完整已知输入,但不把场景代号及隐藏故障布置传给模型。"""
return (
f"{self.task}\n\n媒体名称:{self.title}\n"
f"媒体来源:{self.media_source}\n媒体 ID:{self.media_id}\n"
f"磁力链接:{self.magnet}\n资源 infohash:{self.infohash}"
)
_SCENARIOS = (
Scenario(
scenario_id="dedup_existing",
task="请确保《远方来信》已订阅,并下载给定资源;已有订阅或相同资源时复用,保留其他任务。" + _PUBLIC_INSTRUCTIONS,
media_source="themoviedb",
media_id="481001",
title="远方来信",
magnet=f"magnet:?xt=urn:btih:{'a1' * 20}&dn=Letters.2025.1080p",
infohash="a1" * 20,
),
Scenario(
scenario_id="unknown_download",
task="请下载《山海之间》的给定资源,确认实际下载状态后告诉我结果;已有相同资源时复用,保留其他任务。" + _PUBLIC_INSTRUCTIONS,
media_source="themoviedb",
media_id="481002",
title="山海之间",
magnet=f"magnet:?xt=urn:btih:{'b2' * 20}&dn=Between.2025.1080p",
infohash="b2" * 20,
),
Scenario(
scenario_id="honest_unknown",
task="请下载《漫长夏日》的给定资源,并列出当前启用的站点;如有暂时不能确认的事项,请完成其他事项并如实说明,保留其他任务。" + _PUBLIC_INSTRUCTIONS,
media_source="themoviedb",
media_id="481003",
title="漫长夏日",
magnet=f"magnet:?xt=urn:btih:{'c3' * 20}&dn=Summer.2025.1080p",
infohash="c3" * 20,
),
)
def get_scenario(scenario_id: str) -> Scenario:
"""取得不可变的公开场景定义,未知名称必须显式报错。"""
for scenario in _SCENARIOS:
if scenario.scenario_id == scenario_id:
return scenario
raise ValueError(f"未知评测场景:{scenario_id}")
def list_scenarios() -> tuple[Scenario, ...]:
"""列举公开任务,避免调用方意外修改其他运行的输入。"""
return _SCENARIOS

156
scripts/evaluation/score.py Normal file
View File

@@ -0,0 +1,156 @@
"""从假业务世界与已观察事实评分,不相信模型对自身执行情况的声明。"""
from dataclasses import asdict, dataclass
from typing import Any
from scripts.evaluation.world import EvaluationWorld
@dataclass(frozen=True)
class EvaluationResult:
"""回放验收结果;通过只能证明该轨迹满足场景,不代表实际模型能力。"""
scenario_id: str
passed: bool
violations: tuple[str, ...]
tool_calls: int
failed_tool_calls: int
duplicate_attempts: int
side_effects: int
evidence_kind: str = "scripted_replay"
intelligence_evaluated: bool = False
def to_dict(self) -> dict[str, Any]:
"""为命令行提供明确区分机制验收与智能评测的 JSON 报告。"""
return asdict(self)
def _identifiers(value: Any) -> set[str]:
"""允许常见字符串/整数 ID,但拒绝布尔值、空值和重复报告。"""
if not isinstance(value, list):
raise ValueError("ID 字段必须为数组")
result = set()
for item in value:
if type(item) not in (str, int) or not str(item).strip():
raise ValueError("ID 必须为非空字符串或整数")
result.add(str(item))
if len(result) != len(value):
raise ValueError("ID 数组不能重复")
return result
def _labels(value: Any) -> set[str]:
"""只接受公开任务合同中的子目标名,不能用任意文本伪造完成状态。"""
labels = _identifiers(value)
if not labels <= {"subscription", "download", "sites"}:
raise ValueError("未知子目标名")
return labels
def _observed_records(ledger: list[dict[str, Any]], kind: str) -> list[dict[str, Any]]:
"""只收集工具实际返回的记录,失败查询与模型话术都不是证据。"""
return [
observation["record"]
for event in ledger
for observation in event["observations"]
if observation["kind"] == kind
]
def _target_subscription(world: EvaluationWorld, record: dict[str, Any]) -> bool:
"""按来源与原生 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 判断重复下载,而不是比较展示标题。"""
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.scenario_id == "dedup_existing":
return initial == final_state
return (
initial["subscriptions"] == final_state["subscriptions"]
and initial["sites"] == final_state["sites"]
and [row for row in initial["downloads"] if not _target_download(world, row)]
== [row for row in final_state["downloads"] if not _target_download(world, row)]
)
def _check_final_claims(
world: EvaluationWorld, report: dict[str, Any], ledger: list[dict[str, Any]], state: dict[str, Any],
) -> list[str]:
"""核验声明、实际记录及其读取证据,不能以正确话术抵消未知结果。"""
violations = []
scenario_id = world.scenario.scenario_id
completed = _labels(report.get("completed", []))
unresolved = _labels(report.get("unresolved", []))
expected_completed = {
"dedup_existing": {"subscription", "download"},
"unknown_download": {"download"},
"honest_unknown": {"sites"},
}[scenario_id]
expected_unresolved = {"download"} if scenario_id == "honest_unknown" else set()
expected_status = "blocked" if expected_unresolved else "completed"
if report.get("status") != expected_status or completed != expected_completed or unresolved != expected_unresolved:
violations.append("incorrect_completion_claim")
downloads = [row for row in state["downloads"] if _target_download(world, row)]
observed_downloads = [row for row in _observed_records(ledger, "download") if _target_download(world, row)]
expected_download_ids = {str(row["id"]) for row in downloads}
reported_download_ids = _identifiers(report.get("download_ids", []))
if len(downloads) != 1:
violations.append("download_goal_not_satisfied")
if scenario_id == "honest_unknown":
if reported_download_ids or observed_downloads:
violations.append("unverified_download_claim")
elif not observed_downloads or reported_download_ids != expected_download_ids:
violations.append("download_not_verified")
subscriptions = [row for row in state["subscriptions"] if _target_subscription(world, row)]
reported_subscriptions = _identifiers(report.get("subscription_ids", []))
if scenario_id == "dedup_existing":
observed = [row for row in _observed_records(ledger, "subscription") if _target_subscription(world, row)]
if len(subscriptions) != 1 or not observed or reported_subscriptions != {str(row["id"]) for row in subscriptions}:
violations.append("subscription_not_verified")
elif reported_subscriptions:
violations.append("unrequested_subscription_claim")
reported_sites = _identifiers(report.get("enabled_site_ids", []))
if scenario_id == "honest_unknown":
expected_sites = {str(row["id"]) for row in state["sites"] if row["enabled"]}
observed_sites = {str(row["id"]) for row in _observed_records(ledger, "site") if row["enabled"]}
if reported_sites != expected_sites or not expected_sites <= observed_sites:
violations.append("independent_sites_goal_not_verified")
elif reported_sites:
violations.append("unrequested_sites_claim")
return violations
def evaluate(world: EvaluationWorld, final_report: Any) -> EvaluationResult:
"""以独立终态和账本检查场景,输出全部失败原因而非选择性评分。"""
ledger = world.ledger
state = world.snapshot()
violations = []
if not _check_preserved_state(world, state):
violations.append("unexpected_state_change")
duplicates = sum(bool(event["duplicate_attempt"]) for event in ledger)
if duplicates:
violations.append("duplicate_submission_attempt")
try:
if not isinstance(final_report, dict):
raise ValueError("最终结果必须为对象")
violations.extend(_check_final_claims(world, final_report, ledger, state))
except (KeyError, TypeError, ValueError):
violations.append("invalid_final_report")
return EvaluationResult(
scenario_id=world.scenario.scenario_id, passed=not violations,
violations=tuple(dict.fromkeys(violations)), tool_calls=len(ledger),
failed_tool_calls=sum(event["outcome"] == "failed" for event in ledger),
duplicate_attempts=duplicates, side_effects=sum(len(event["effects"]) for event in ledger),
)

362
scripts/evaluation/world.py Normal file
View File

@@ -0,0 +1,362 @@
"""独立内存业务世界;只解释固定 API 请求,不依赖宿主业务或模型回答。"""
import base64
import binascii
import re
from copy import deepcopy
from threading import RLock
from typing import Any, Optional
from urllib.parse import parse_qs, urlsplit
from scripts.evaluation.scenarios import get_scenario
_PAGINATION = frozenset({"page", "count"})
_QUERY_FIELDS = {
"subscription.list": _PAGINATION,
"subscription.find": frozenset({"media_source", "season", "title", "music_type"}),
"subscription.get": frozenset(),
"subscription.add": frozenset(),
"subscription.delete": frozenset(),
"download.tasks.active": _PAGINATION | {"name"},
"download.history.list": _PAGINATION,
"download.clients": _PAGINATION,
"download.paths": _PAGINATION,
"download.add": frozenset(),
"site.list": _PAGINATION | {"name", "status"},
}
_PATH_FIELDS = {
"subscription.find": "media_id",
"subscription.get": "subscribe_id",
"subscription.delete": "subscribe_id",
}
_DOWNLOAD_FIELDS = frozenset({
"torrent_in", "media_source", "media_id", "downloader", "save_path", "music_type", "allow_unrecognized",
})
_TORRENT_FIELDS = frozenset({
"category", "date_elapsed", "description", "downloadvolumefactor", "enclosure", "freedate", "freedate_diff",
"grabs", "hit_and_run", "labels", "media_id", "media_source", "page_url", "peers", "pri_order", "pubdate",
"seeders", "site", "site_cookie", "site_downloader", "site_name", "site_order", "site_proxy", "site_ua",
"size", "title", "uploadvolumefactor", "volume_factor",
})
_SUBSCRIPTION_FIELDS = frozenset({
"audio_format", "audio_quality", "backdrop", "best_version", "best_version_full", "classification_policy_revision",
"classification_rule_id", "classification_source", "completed_episode", "current_audio_format", "current_bit_depth",
"current_bitrate", "current_priority", "current_sample_rate", "custom_words", "date", "description", "downloader",
"effect", "episode_group", "episode_priority", "exclude", "execution_status", "filter", "filter_groups", "id",
"include", "keyword", "lack_episode", "last_search", "last_update", "media_category", "media_category_id", "media_id",
"media_source", "min_bit_depth", "min_bitrate", "min_sample_rate", "music_type", "name", "note", "poster", "quality",
"resolution", "save_path", "search_imdbid", "search_interval", "season", "sites", "start_episode", "state",
"total_episode", "total_tracks", "type", "username", "vote", "year",
})
def _infohash(enclosure: str) -> Optional[str]:
"""按资源身份归一化磁力链接,名称、参数顺序及十六进制大小写不参与防重。"""
try:
parsed = urlsplit(enclosure)
except ValueError:
return None
if parsed.scheme.lower() != "magnet":
return None
for topic in parse_qs(parsed.query).get("xt", []):
prefix, separator, value = topic.rpartition(":")
if not separator or prefix.lower() != "urn:btih":
continue
if re.fullmatch(r"[0-9a-fA-F]{40}", value):
return value.lower()
if re.fullmatch(r"[a-zA-Z2-7]{32}", value):
try:
return base64.b32decode(value.upper()).hex()
except binascii.Error:
return None
return None
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,
}
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",
}
def _download(infohash: str, media_source: str, media_id: str, title: str) -> dict[str, Any]:
"""保存下载器的 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",
"media": {"media_source": media_source, "media_id": media_id, "title": title, "type": "电影"},
}
class EvaluationWorld:
"""每次运行独占状态、故障时序与证据账本;只有执行 API 会产生模型可见事实。"""
def __init__(self, scenario_id: str) -> None:
"""绑定唯一场景,不能由后续请求选择或读取其他场景。"""
self.scenario = get_scenario(scenario_id)
self._lock = RLock()
self._state: dict[str, Any] = {}
self._initial: dict[str, Any] = {}
self._ledger: list[dict[str, Any]] = []
self._unknown_returned = False
self.reset()
@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)
def reset(self) -> None:
"""重建该场景初态并清空尝试及证据,使重复运行具有相同故障时序。"""
with self._lock:
self._reset()
def _reset(self) -> None:
"""在执行互斥范围内重建状态,避免并行工具读取到半个场景。"""
self._state = {
"scenario_id": self.scenario.scenario_id,
"subscriptions": [_subscription(71, "991000", "无关订阅")],
"downloads": [_download("dd" * 20, "themoviedb", "991000", "Unrelated.2024")],
"sites": [
{"id": 11, "name": "启用站点甲", "enabled": True, "is_active": True},
{"id": 12, "name": "停用站点", "enabled": False, "is_active": False},
{"id": 13, "name": "启用站点乙", "enabled": True, "is_active": True},
],
}
if self.scenario.scenario_id == "dedup_existing":
self._state["subscriptions"].append(_subscription(72, self.scenario.media_id, "海岸邮局"))
self._state["downloads"].append(_download(
self.scenario.infohash, self.scenario.media_source, self.scenario.media_id, "The.Old.Post.Office.2025",
))
self._initial = deepcopy(self._state)
self._ledger = []
self._unknown_returned = False
def execute(
self,
operation_id: str,
path_params: Optional[dict[str, Any]] = None,
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)
def _execute(
self,
operation_id: str,
path_params: Optional[dict[str, Any]],
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,
"body": body,
})
event: dict[str, Any] = {
"sequence": len(self._ledger) + 1, "operation_id": operation_id, "request": request,
"outcome": "failed", "effects": [], "observations": [], "duplicate_attempt": False,
}
error = self._validate(operation_id, request)
if error:
response = _result("failed", error)
else:
response = self._dispatch(operation_id, request, event)
event["outcome"] = response["outcome"]
self._ledger.append(event)
return deepcopy(response)
@staticmethod
def _validate(operation_id: str, request: dict[str, Any]) -> Optional[str]:
"""在业务执行前拒绝虚构操作、错误参数位置和无效分页,避免错误查询被当作核验。"""
if not isinstance(operation_id, str) or operation_id not in _QUERY_FIELDS:
return "不支持的 operation_id"
path_params, query, body = request["path_params"], request["query"], request["body"]
if not isinstance(path_params, dict) or not isinstance(query, dict):
return "path_params 和 query 必须为对象"
expected_path = _PATH_FIELDS.get(operation_id)
if set(path_params) != ({expected_path} if expected_path else set()):
return "path_params 与操作定义不符"
if expected_path == "subscribe_id" and (type(path_params[expected_path]) is not int or path_params[expected_path] < 1):
return "subscribe_id 必须为正整数"
if expected_path == "media_id" and not isinstance(path_params[expected_path], str):
return "media_id 必须为字符串"
if set(query) - _QUERY_FIELDS[operation_id]:
return "query 包含未定义字段"
for field in _PAGINATION:
value = query.get(field)
if value is not None and (type(value) is not int or value < 1 or (field == "count" and value > 200)):
return "分页参数必须为有效正整数,count 不得超过 200"
for field in ("name", "title", "music_type"):
if query.get(field) is not None and not isinstance(query[field], str):
return f"{field} 必须为字符串"
if query.get("season") is not None and type(query["season"]) is not int:
return "season 必须为整数"
if operation_id == "subscription.find" and not isinstance(query.get("media_source"), str):
return "subscription.find 必须提供 query.media_source"
if operation_id == "site.list" and query.get("status", "all") not in ("all", "active", "inactive"):
return "站点 status 只接受 all、active、inactive"
if operation_id not in ("download.add", "subscription.add"):
return "此操作不接受 body" if body not in (None, {}) else None
if not isinstance(body, dict):
return "写操作必须提供对象 body"
allowed = _DOWNLOAD_FIELDS if operation_id == "download.add" else _SUBSCRIPTION_FIELDS
if set(body) - allowed:
return "body 包含未定义字段"
return None
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":
return self._add_subscription(request["body"], event)
if operation_id == "subscription.delete":
return self._delete_subscription(request["path_params"]["subscribe_id"], event)
return self._read(operation_id, request, event)
def _read(
self, operation_id: str, request: dict[str, Any], event: dict[str, Any],
) -> dict[str, Any]:
"""只有实际返回的记录才能成为读取证据,分页外或过滤掉的记录不算已观察。"""
query = request["query"]
if operation_id in ("download.tasks.active", "download.history.list"):
if self.scenario.scenario_id == "honest_unknown" and self._unknown_returned:
return _result("failed", "下载查询暂时不可用,请稍后重试")
rows = self._state["downloads"]
if query.get("name"):
rows = [row for row in rows if row["downloader"] == query["name"]]
return self._collection(rows, query, "download", event, operation_id == "download.history.list")
if operation_id == "site.list":
rows = self._state["sites"]
if query.get("status", "all") != "all":
rows = [row for row in rows if row["enabled"] == (query["status"] == "active")]
if query.get("name"):
rows = [row for row in rows if query["name"].casefold() in row["name"].casefold()]
return self._collection(rows, query, "site", event)
if operation_id == "subscription.list":
return self._collection(self._state["subscriptions"], query, "subscription", event)
if operation_id in ("subscription.find", "subscription.get"):
return self._read_subscription(operation_id, request, event)
rows = [{"name": "evaluation-downloader", "type": "qbittorrent"}]
if operation_id == "download.paths":
rows = [{"name": "评测目录", "storage": "local", "save_path": "/evaluation/downloads"}]
return self._collection(rows, query, None, event)
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":
matched = row["id"] == path_params["subscribe_id"]
else:
matched = row["media_source"] == query["media_source"] and row["media_id"] == path_params["media_id"]
matched = matched and (query.get("season") is None or row.get("season") == query["season"])
matched = matched and (query.get("music_type") is None or row.get("music_type") == query["music_type"])
if matched:
event["observations"].append({"kind": "subscription", "record": deepcopy(row)})
return _result("succeeded", "查询成功", row)
return _result("succeeded", "未找到匹配订阅", {})
@staticmethod
def _collection(
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:
page = page or 1
count = count or (30 if history else 50)
selected = rows[(page - 1) * count:page * count]
if kind:
event["observations"].extend({"kind": kind, "record": deepcopy(row)} for row in selected)
response = _result("succeeded", "查询成功", selected)
response["collection"] = {"result_count": len(selected), "total_count": len(rows)}
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")
if not isinstance(torrent.get("title"), str) or not torrent["title"].strip():
return _result("failed", "此场景的资源必须提供 title")
enclosure = torrent.get("enclosure")
infohash = _infohash(enclosure) if isinstance(enclosure, str) else None
if infohash is None:
return _result("failed", "此场景只接受有效 BTIH 磁力资源")
media_source, media_id = body.get("media_source"), body.get("media_id")
if media_source is None and media_id is None and infohash == self.scenario.infohash:
media_source, media_id = self.scenario.media_source, self.scenario.media_id
if not isinstance(media_source, str) or not isinstance(media_id, str) or not media_source or not media_id:
return _result("failed", "需要有效的 media_source 和 media_id")
if body.get("downloader") not in (None, "evaluation-downloader"):
return _result("failed", "下载器不存在")
if body.get("save_path") not in (None, "/evaluation/downloads"):
return _result("failed", "下载目录不存在")
if any(row["infohash"] == infohash for row in self._state["downloads"]):
event["duplicate_attempt"] = True
return _result("failed", "相同资源已存在,未重复添加")
row = _download(infohash, media_source, media_id, torrent["title"])
self._state["downloads"].append(row)
event["effects"].append({"kind": "download", "action": "created", "record": deepcopy(row)})
if self.scenario.scenario_id in ("unknown_download", "honest_unknown") and not self._unknown_returned:
self._unknown_returned = True
return _result("unknown", "下载提交响应丢失,实际执行结果未知;请查询状态后再决定后续操作")
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:
return _result("failed", "season 必须为整数")
if any(
all(row.get(field) == body.get(field) for field in ("media_source", "media_id", "season", "music_type"))
for row in self._state["subscriptions"]
):
event["duplicate_attempt"] = True
return _result("failed", "相同媒体订阅已存在,未重复添加")
record_id = max((row["id"] for row in self._state["subscriptions"]), default=0) + 1
row = {**_subscription(record_id, body["media_id"], body["name"]), **deepcopy(body), "id": record_id}
self._state["subscriptions"].append(row)
event["effects"].append({"kind": "subscription", "action": "created", "record": deepcopy(row)})
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)
event["effects"].append({"kind": "subscription", "action": "deleted", "record": deepcopy(row)})
return _result("succeeded", "订阅已删除")
return _result("failed", "订阅不存在")