Files
MoviePilot/scripts/generate_agent_api_surface_audit.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

456 lines
19 KiB
Python

#!/usr/bin/env python3
"""
生成全部 OpenAPI 操作与 Agent 接入范围的对应清单。
"""
from __future__ import annotations
import json
import sys
from collections import Counter, defaultdict
from pathlib import Path
from typing import Any
from fastapi import FastAPI
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
# 独立脚本先加入项目根目录,再加载宿主模块。
from app.agent.policy.api import API_OPERATION_ROUTES, API_OPERATION_SPECS # noqa: E402
# 独立脚本先加入项目根目录,再加载宿主模块。
from app.api.apiv1 import api_router # noqa: E402
JSON_OUTPUT = PROJECT_ROOT / "docs/refactor/agent-api-surface-audit.json"
MARKDOWN_OUTPUT = PROJECT_ROOT / "docs/refactor/agent-api-surface-audit.md"
HTTP_METHODS = frozenset({"GET", "POST", "PUT", "PATCH", "DELETE"})
TRANSPORT_TAGS = frozenset(
{
"agent",
"anthropic",
"auth",
"llm",
"login",
"mcp",
"message",
"mfa",
"notification",
"openai",
"user",
"webhook",
}
)
CONSOLIDATED_TAGS = frozenset(
{
"anilist",
"bangumi",
"discover",
"douban",
"recommend",
"tmdb",
}
)
PROVIDER_PATH_PREFIXES = (
"/api/v1/download/",
"/api/v1/mediaserver/",
)
CONSOLIDATED_ROUTE_OWNERS: dict[tuple[str, str], str] = {
("POST", "/api/v1/download/"): "download.add",
("GET", "/api/v1/plugin/installed"): "plugin.installed",
("GET", "/api/v1/plugin/source/{plugin_id}/options"): "plugin.source.options",
("GET", "/api/v1/plugin/{plugin_id}"): "plugin.config.get",
("GET", "/api/v1/search/last"): "search.results",
("GET", "/api/v1/search/media/{media_id}/stream"): "search.torrents",
("GET", "/api/v1/search/subtitle/media/{media_id}/stream"): "subtitle.search.media",
("GET", "/api/v1/search/subtitle/title/stream"): "subtitle.search.title",
("GET", "/api/v1/search/title/stream"): "search.title",
("GET", "/api/v1/site/"): "site.list",
("GET", "/api/v1/site/cookie/{site_id}"): "site.cookie.update",
("GET", "/api/v1/site/domain/{site_url}"): "site.list",
("GET", "/api/v1/site/{site_id}"): "site.list",
("POST", "/api/v1/storage/list"): "storage.list",
("GET", "/api/v1/subscribe/list"): "subscription.list",
("GET", "/api/v1/system/env"): "config.system.get",
("POST", "/api/v1/system/env"): "config.system.update",
("GET", "/api/v1/system/global"): "config.system.get",
("GET", "/api/v1/system/setting/{key}"): "config.system.get",
("POST", "/api/v1/system/setting/{key}"): "config.system.update",
("GET", "/api/v1/transfer/now"): "scheduler.run",
("GET", "/api/v1/workflow/"): "workflow.list",
("GET", "/api/v1/media/category"): "media.classification.policy.get",
("GET", "/api/v1/media/category/config"): "media.classification.policy.get",
}
STREAM_OR_BINARY_PATHS = frozenset(
{
"/api/v1/plugin/file/{plugin_id}/{filepath}",
"/api/v1/site/icon/{site_id}",
"/api/v1/storage/download",
"/api/v1/storage/image",
"/api/v1/system/cache/image",
"/api/v1/system/img/{proxy}",
"/api/v1/subscribe/{subscription_id}/logs",
"/api/v1/subscribe/{subscription_id}/logs/download",
"/api/v1/system/logging",
"/api/v1/system/logging/download/{name}",
"/api/v1/system/message",
"/api/v1/system/notification-image",
"/api/v1/system/progress/{process_type}",
}
)
UI_PRESENTATION_PATHS = frozenset(
{
"/api/v1/plugin/agent_pets",
"/api/v1/plugin/dashboard/meta",
"/api/v1/plugin/dashboard/{plugin_id}",
"/api/v1/plugin/dashboard/{plugin_id}/{key}",
"/api/v1/plugin/page/{plugin_id}",
"/api/v1/plugin/runtime/{plugin_id}/data/summary",
"/api/v1/plugin/sidebar_nav",
"/api/v1/rule/custom/reorder",
"/api/v1/rule/groups/reorder",
"/api/v1/storage/catalog",
"/api/v1/storage/options",
"/api/v1/transfer/queue/page",
}
)
EXPLICIT_TRANSPORT_PATHS = frozenset(
{
"/api/v1/plugin/remotes",
"/api/v1/subscribe/seerr",
"/api/v1/system/ping",
}
)
GITHUB_AUTH_PATH_PREFIXES = (
"/api/v1/github/auth/",
"/api/v1/login/github-auth/",
)
SUBSCRIPTION_EXECUTION_UI_PREFIX = "/api/v1/subscribe/execution/"
MUSIC_LIBRARY_STATUS_UI_PATH = "/api/v1/music/library/status"
def _gateway_routes() -> dict[tuple[str, str], list[str]]:
"""
收集确切的 HTTP 路由及使用该路由的所有稳定网关操作。
"""
results: dict[tuple[str, str], list[str]] = defaultdict(list)
for operation_id, route in API_OPERATION_ROUTES.items():
results[(route.method.upper(), route.path)].append(operation_id)
return {key: sorted(values) for key, values in results.items()}
def _classify(
*,
method: str,
path: str,
tags: list[str],
gateway_routes: dict[tuple[str, str], list[str]],
) -> tuple[str, str, str, list[str]]:
"""
把 OpenAPI 操作归入经过审查的 Agent 或直接管理端契约。
"""
operations = gateway_routes.get((method, path), [])
if operations:
return (
"gateway",
"moviepilot-api",
"Executable through moviepilot_api; exact inputs are generated into MCP tools/list and SKILL.md.",
operations,
)
primary_tag = tags[0] if tags else "untagged"
if primary_tag in TRANSPORT_TAGS:
return (
"transport_or_identity",
"host-runtime",
"Authentication, protocol compatibility, conversation transport, callback, or account lifecycle endpoint; never recursively exposed as an Agent business action.",
[],
)
consolidated_owner = CONSOLIDATED_ROUTE_OWNERS.get((method, path))
if consolidated_owner:
return (
"consolidated",
"moviepilot-api",
f"This compatibility, broader-response, or UI route is represented by the safer stable operation {consolidated_owner}.",
[consolidated_owner],
)
if path in STREAM_OR_BINARY_PATHS:
return (
"stream_or_binary",
"host-transport",
"Streaming, image, archive, or file response consumed by a direct client; the structured JSON Agent gateway does not proxy binary or unbounded streams.",
[],
)
if path in UI_PRESENTATION_PATHS:
return (
"ui_presentation",
"host-ui",
"Plugin-rendered page, dashboard, or navigation metadata owned by the frontend presentation contract rather than an Agent business action.",
[],
)
if method == "POST" and path == "/api/v1/history/transfer/{history_id}/cleanup-resolved":
return (
"ui_presentation",
"host-ui",
"Manual confirmation of downloader cleanup is owned by the authenticated history management workflow; it is not an Agent action that verifies external cleanup.",
[],
)
if path == "/api/v1/history/transfer/{history_id}/discard-corrupt":
return (
"ui_presentation",
"host-ui",
"Discarding corrupt transfer state is owned by the authenticated management recovery workflow; it is not a stable Agent gateway operation.",
[],
)
if path.startswith(SUBSCRIPTION_EXECUTION_UI_PREFIX) or path in {
"/api/v1/subscribe/{subscription_id}/activity",
"/api/v1/subscribe/{subscription_id}/activity/{event_id}",
}:
return (
"ui_presentation",
"host-ui",
"Subscription activity details, background execution status and cancellation are owned by the authenticated frontend workflow; they are not yet a stable Agent gateway contract.",
[],
)
if method == "POST" and path == MUSIC_LIBRARY_STATUS_UI_PATH:
return (
"ui_presentation",
"host-ui",
"Batch music-library presence is a bounded projection for the authenticated artist resource matrix; it is not a standalone Agent business action.",
[],
)
if path in EXPLICIT_TRANSPORT_PATHS:
return (
"transport_or_identity",
"host-runtime",
"Health, bootstrap, federation, or external webhook transport endpoint; it is not recursively callable as an Agent business action.",
[],
)
if method == "GET" and path == "/api/v1/github/community":
return (
"ui_presentation",
"host-ui",
"Community medals are a presentation of the administrator-configured GitHub identity, not an Agent business operation.",
[],
)
if path.startswith(GITHUB_AUTH_PATH_PREFIXES):
return (
"transport_or_identity",
"host-runtime",
"GitHub Token authorization and first-run credential setup are host identity/configuration flows; they are not Agent business actions.",
[],
)
if path.startswith(PROVIDER_PATH_PREFIXES):
return (
"provider-skill",
"downloader-operation" if "/download/" in path else "mediaserver-operation",
"Low-level provider behavior is exposed by the self-describing provider Skill; high-level MoviePilot operations remain in moviepilot-api.",
[],
)
if primary_tag in CONSOLIDATED_TAGS:
return (
"consolidated",
"moviepilot-api",
"Source-specific or presentation-oriented route is represented by a stable aggregate search, detail, person, recommendation, or music operation instead of duplicating every frontend route.",
[],
)
if path.endswith("2") or "/schedule2" in path or "/recognize2" in path or "/recognize_file2" in path:
return (
"alternate-auth-duplicate",
"moviepilot-api",
"API-token compatibility duplicate; the Agent uses the corresponding bearer-authenticated operation with its persisted user identity.",
[],
)
raise ValueError(f"Unclassified OpenAPI operation: {method} {path}")
def generate_audit() -> dict[str, Any]:
"""
为每个 v1 OpenAPI HTTP 操作生成顺序稳定的清单条目。
"""
app = FastAPI()
app.include_router(api_router, prefix="/api/v1")
openapi = app.openapi()
gateway_routes = _gateway_routes()
entries = []
for path, path_item in sorted(openapi.get("paths", {}).items()):
for raw_method, operation in sorted(path_item.items()):
method = raw_method.upper()
if method not in HTTP_METHODS or not isinstance(operation, dict):
continue
tags = [str(tag) for tag in operation.get("tags") or []]
disposition, owner, reason, operation_ids = _classify(
method=method,
path=path,
tags=tags,
gateway_routes=gateway_routes,
)
entries.append(
{
"method": method,
"path": path,
"tags": tags,
"summary": str(operation.get("summary") or ""),
"disposition": disposition,
"owner": owner,
"operation_ids": operation_ids,
"reason": reason,
}
)
counts = Counter(entry["disposition"] for entry in entries)
matched_gateway_routes = {
(entry["method"], entry["path"]) for entry in entries if entry["disposition"] == "gateway"
}
dynamic_gateway_routes = [
{
"method": method,
"path": path,
"operation_ids": operation_ids,
"reason": (
"The executor validates and expands this bounded source placeholder to one of "
"tmdb, douban, bangumi, or anilist before calling the corresponding concrete OpenAPI route."
),
}
for (method, path), operation_ids in sorted(gateway_routes.items())
if (method, path) not in matched_gateway_routes
]
return {
"openapi_operation_count": len(entries),
"gateway_operation_count": len(API_OPERATION_ROUTES),
"gateway_http_route_count": len(gateway_routes),
"matched_gateway_http_route_count": len(matched_gateway_routes),
"dynamic_gateway_routes": dynamic_gateway_routes,
"disposition_counts": dict(sorted(counts.items())),
"operations": entries,
"gateway_identity_contracts": [
{
"operation_id": spec.operation_id,
"method": API_OPERATION_ROUTES[spec.operation_id].method,
"path": API_OPERATION_ROUTES[spec.operation_id].path,
"identity_scope": spec.identity_scope.value,
"required_role": spec.required_role.value,
"effect": spec.effect.value,
"result_sensitivity": spec.result_sensitivity.value,
"confirmation": spec.confirmation.value,
}
for spec in sorted(API_OPERATION_SPECS, key=lambda item: item.operation_id)
],
}
def render_markdown(audit: dict[str, Any]) -> str:
"""
将完整清单渲染为可审阅的架构说明文档。
"""
lines = [
"# MoviePilot Agent API Surface Audit",
"",
"> Generated from the v1 FastAPI OpenAPI document and the fixed Agent API registry.",
"> Do not edit route rows manually; run `scripts/generate_agent_api_surface_audit.py`.",
"",
"## Result",
"",
f"- OpenAPI HTTP operations: **{audit['openapi_operation_count']}**",
f"- Stable `moviepilot_api` operations: **{audit['gateway_operation_count']}**",
f"- Exact HTTP routes used by the gateway: **{audit['gateway_http_route_count']}**",
f"- OpenAPI routes matched directly by the gateway: **{audit['matched_gateway_http_route_count']}**",
f"- Bounded dynamic gateway routes: **{len(audit['dynamic_gateway_routes'])}**",
"- Every gateway operation has a generated English oneOf input contract in MCP `tools/list` and `skills/moviepilot-api/SKILL.md`.",
"- Every non-gateway OpenAPI operation is listed below with an explicit ownership boundary; it is not silently callable through arbitrary URL/method input.",
"",
"## Dispositions",
"",
"| disposition | count | meaning |",
"| :--- | ---: | :--- |",
]
meanings = {
"gateway": "Approved structured MoviePilot Agent operation.",
"provider-skill": "Low-level downloader or media-server capability owned by a provider Skill.",
"consolidated": "Source/UI route represented by a stable aggregate Agent operation.",
"alternate-auth-duplicate": "API-token compatibility duplicate of a bearer-authenticated capability.",
"transport_or_identity": "Authentication, protocol, callback, account, or conversation transport boundary.",
"stream_or_binary": "Streaming or binary response owned by a direct client transport.",
"ui_presentation": "Frontend or plugin-rendered presentation contract.",
}
for disposition, count in audit["disposition_counts"].items():
lines.append(f"| `{disposition}` | {count} | {meanings[disposition]} |")
lines.extend([
"",
"## Gateway Identity Audit",
"",
"All gateway operations are audited below. New operations default to bound_user; safe_read alone never grants public access.",
"public: audited external public metadata only; the host-authenticated executor cannot call other operations.",
"bound_user: an active account bound to the stable channel sender ID, or the authenticated Web user ID; endpoint ownership and management permissions still apply.",
"admin: administrator authority, preserving existing verified channel/API integration semantics; this never substitutes for ownership on bound_user operations.",
"Title recognition retains bound_user because its Context exposes locally configured applied identification rules. File recognition, source availability, rules, search results, media libraries, storage and public UI settings remain instance data.",
"Plugin ratings include this installation's own rating and require the same administrator role as their REST dependencies. Community share/popularity/statistics and global plugin installation statistics are public reads; follow lists and rating submissions are not.",
"",
"| operation | method | path | identity scope | role | effect | result sensitivity | confirmation |",
"| :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |",
])
for item in audit["gateway_identity_contracts"]:
lines.append(
f"| `{item['operation_id']}` | `{item['method']}` | `{item['path']}` | "
f"`{item['identity_scope']}` | `{item['required_role']}` | `{item['effect']}` | "
f"`{item['result_sensitivity']}` | `{item['confirmation']}` |"
)
if audit["dynamic_gateway_routes"]:
lines.extend(
[
"",
"## Bounded Dynamic Routes",
"",
"| method | route template | operations | constraint |",
"| :--- | :--- | :--- | :--- |",
]
)
for item in audit["dynamic_gateway_routes"]:
lines.append(
f"| `{item['method']}` | `{item['path']}` | {', '.join(item['operation_ids'])} | {item['reason']} |"
)
lines.extend(
[
"",
"## Complete Route Inventory",
"",
"| method | path | tags | disposition | owner / operation | summary |",
"| :--- | :--- | :--- | :--- | :--- | :--- |",
]
)
for item in audit["operations"]:
tags = ", ".join(item["tags"]) or "-"
owner = ", ".join(item["operation_ids"]) or item["owner"]
summary = item["summary"].replace("|", "\\|")
lines.append(
f"| `{item['method']}` | `{item['path']}` | {tags} | `{item['disposition']}` | {owner} | {summary} |"
)
lines.extend(
[
"",
"## Exposure Rule",
"",
"Every structured JSON business endpoint is either a stable gateway operation, a provider Skill capability, or an explicitly consolidated compatibility route. Authentication, webhook, stream, binary, and UI-presentation endpoints remain owned by their direct transport or frontend consumer and must not be made recursively callable by the Agent.",
"",
]
)
return "\n".join(lines)
def main() -> int:
"""
写入确定性排序的 JSON 与 Markdown 清单产物。
"""
audit = generate_audit()
JSON_OUTPUT.write_text(
json.dumps(audit, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
MARKDOWN_OUTPUT.write_text(render_markdown(audit), encoding="utf-8")
print(f"generated {JSON_OUTPUT.relative_to(PROJECT_ROOT)} and {MARKDOWN_OUTPUT.relative_to(PROJECT_ROOT)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())