mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-06 07:09:09 +08:00
refactor: complete agent skill API contracts
This commit is contained in:
@@ -14,7 +14,7 @@ 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.agent.policy.api_mcp_contract import build_api_mcp_input_schema # noqa: E402
|
||||
from app.agent.policy.mcp import build_api_mcp_input_schema # noqa: E402
|
||||
from app.api.apiv1 import api_router # noqa: E402
|
||||
|
||||
OUTPUT_PATH = PROJECT_ROOT / "app/agent/policy/api_mcp_schema.json"
|
||||
|
||||
349
scripts/generate_agent_api_surface_audit.py
Normal file
349
scripts/generate_agent_api_surface_audit.py
Normal file
@@ -0,0 +1,349 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate the complete OpenAPI-to-Agent surface audit."""
|
||||
|
||||
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 # noqa: E402
|
||||
from app.api.apiv1 import api_router # noqa: E402
|
||||
|
||||
JSON_OUTPUT = PROJECT_ROOT / "docs/architecture/agent-api-surface-audit.json"
|
||||
MARKDOWN_OUTPUT = PROJECT_ROOT / "docs/architecture/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",
|
||||
}
|
||||
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/system/logging",
|
||||
"/api/v1/system/logging/download/{name}",
|
||||
"/api/v1/system/message",
|
||||
"/api/v1/system/progress/{process_type}",
|
||||
}
|
||||
)
|
||||
UI_PRESENTATION_PATHS = frozenset(
|
||||
{
|
||||
"/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/sidebar_nav",
|
||||
}
|
||||
)
|
||||
EXPLICIT_TRANSPORT_PATHS = frozenset(
|
||||
{
|
||||
"/api/v1/plugin/remotes",
|
||||
"/api/v1/subscribe/seerr",
|
||||
"/api/v1/system/ping",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _gateway_routes() -> dict[tuple[str, str], list[str]]:
|
||||
"""Return exact HTTP routes and every stable gateway operation using them."""
|
||||
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]]:
|
||||
"""Classify one OpenAPI operation into one reviewed Agent ownership boundary."""
|
||||
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 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 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]:
|
||||
"""Build a deterministic entry for every v1 OpenAPI HTTP operation."""
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
def render_markdown(audit: dict[str, Any]) -> str:
|
||||
"""Render the complete audit as a reviewable architecture document."""
|
||||
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]} |")
|
||||
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:
|
||||
"""Write deterministic JSON and Markdown audit artifacts."""
|
||||
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(
|
||||
"generated "
|
||||
f"{JSON_OUTPUT.relative_to(PROJECT_ROOT)} and {MARKDOWN_OUTPUT.relative_to(PROJECT_ROOT)}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
663
scripts/generate_agent_skill_docs.py
Normal file
663
scripts/generate_agent_skill_docs.py
Normal file
@@ -0,0 +1,663 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate English Agent Skill contracts from runtime metadata."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import runpy
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
|
||||
DATABASE_TABLE_GUIDES: dict[str, tuple[str, str, str]] = {
|
||||
"alembic_version": (
|
||||
"Records the Alembic migration revision currently applied to the database.",
|
||||
"Diagnosing startup migration failures or a database/code revision mismatch.",
|
||||
"Never edit it directly; advance or roll back revisions only through Alembic.",
|
||||
),
|
||||
"agentchat": (
|
||||
"Stores Web Agent and messaging-channel session indexes, titles, previews, and message snapshots.",
|
||||
"Tracing Agent history or context restoration by user, session, or update time.",
|
||||
"Owned by the Agent conversation service; do not rewrite message JSON, counters, or ownership.",
|
||||
),
|
||||
"agenttask": (
|
||||
"Stores one-shot or recurring Agent task definitions, triggers, and the latest execution summary.",
|
||||
"Inspecting task ownership, enablement, cron/run_at settings, and the latest result.",
|
||||
"Create, update, enable, disable, or delete tasks through the Agent task API.",
|
||||
),
|
||||
"agenttaskrun": (
|
||||
"Stores the input snapshot, status, timestamps, and result of each Agent task execution.",
|
||||
"Auditing one run or correlating a failure with task_id, run_id, and trigger source.",
|
||||
"Execution evidence owned by the task runner; never fabricate rows or edit run status.",
|
||||
),
|
||||
"downloadfailure": (
|
||||
"Stores stable fingerprints, media/torrent context, errors, and retry scheduling for failed downloads.",
|
||||
"Analyzing failure causes, retry counts, next retry time, and affected media or sites.",
|
||||
"Owned by download-failure compensation; retry or clean records through its business API.",
|
||||
),
|
||||
"downloadfiles": (
|
||||
"Maps downloader task hashes to full paths, save directories, relative files, and active state.",
|
||||
"Finding task files by downloader/download_hash or diagnosing savepath associations.",
|
||||
"Maintained by download and transfer flows; do not manually change state or path mappings.",
|
||||
),
|
||||
"downloadhistory": (
|
||||
"Stores media identity, torrent, downloader, user, and recognition context for submitted downloads.",
|
||||
"Reviewing download history or tracing a media identity or hash back to its source.",
|
||||
"Written by the download use case; delete or correct records through the download-history API.",
|
||||
),
|
||||
"mediaserveritem": (
|
||||
"Stores the local index and canonical media identity projected from media-server libraries.",
|
||||
"Checking library presence, server/library/path placement, and season information.",
|
||||
"This is a rebuildable projection; writes and cleanup belong to media-server synchronization.",
|
||||
),
|
||||
"message": (
|
||||
"Stores inbound and outbound messages, channels, content, attachments, users, and timestamps.",
|
||||
"Paging notification history, distinguishing direction, or tracing duplicates by source.",
|
||||
"Written by messaging and notification services; clean it through the message API or retention job.",
|
||||
),
|
||||
"outboxmessage": (
|
||||
"Stores externally visible side-effect intents committed atomically with business transactions.",
|
||||
"Diagnosing pending/processing/failed state, leases, attempts, and the last error.",
|
||||
"Owned by the Outbox Dispatcher state machine; never mark completion or delete undelivered events manually.",
|
||||
),
|
||||
"passkey": (
|
||||
"Stores WebAuthn/PassKey credentials, public keys, signature counters, and activation state.",
|
||||
"Authorized authentication diagnostics such as ownership, activation, and last use.",
|
||||
"Security-sensitive; manage it only through the PassKey API and never disclose credential material.",
|
||||
),
|
||||
"plugindata": (
|
||||
"Stores plugin-owned JSON values isolated by plugin_id and key.",
|
||||
"Diagnosing persistence or migration issues for one explicitly identified plugin and key.",
|
||||
"The plugin owns these values; prefer plugin capabilities or the plugin-data API.",
|
||||
),
|
||||
"pluginidentity": (
|
||||
"Stores trusted source, payload source, version, receipt, and CAS revision for a physical plugin package.",
|
||||
"Auditing source binding, package generation, payload application, or identity conflicts.",
|
||||
"Plugin supply-chain state owned exclusively by installation and update transactions.",
|
||||
),
|
||||
"plugininstallation": (
|
||||
"Stores plugin installation phase, membership target, identity revisions, and backup state.",
|
||||
"Diagnosing interrupted installations, rollback conditions, and package or backup presence.",
|
||||
"Owned by the plugin installation state machine; never advance phase or overwrite evidence manually.",
|
||||
),
|
||||
"site": (
|
||||
"Stores private-tracker URLs, RSS, credentials, rate limits, proxy state, and downloader binding.",
|
||||
"Inspecting enablement, domain, rate limits, or downloader binding with minimal credential exposure.",
|
||||
"Contains cookies, API keys, and tokens; manage it through the site API.",
|
||||
),
|
||||
"siteicon": (
|
||||
"Caches site names, domains, icon URLs, and Base64 icon content.",
|
||||
"Diagnosing missing icons, incorrect domain mapping, or cache generation.",
|
||||
"Rebuildable cache owned by site-icon synchronization; direct writes are not recommended.",
|
||||
),
|
||||
"sitestatistic": (
|
||||
"Aggregates site request successes, failures, durations, latest state, and diagnostic notes.",
|
||||
"Comparing site availability, failure rate, and the most recent access state.",
|
||||
"Accumulated by site access statistics; never edit counters to conceal runtime behavior.",
|
||||
),
|
||||
"siteuserdata": (
|
||||
"Stores tracker account level, traffic, ratio, seeding, and unread-message data.",
|
||||
"Inspecting account state, traffic trends, seeding volume, and the latest collection error.",
|
||||
"A site-scraping projection refreshed by synchronization; do not edit it directly.",
|
||||
),
|
||||
"subscribe": (
|
||||
"Stores active movie, TV, or music subscriptions, filters, progress, and download targets.",
|
||||
"Inspecting state, missing episodes/tracks, quality rules, site scope, and match progress.",
|
||||
"Create, update, search, or delete through the subscription API to preserve state-machine consistency.",
|
||||
),
|
||||
"subscribehistory": (
|
||||
"Stores snapshots of completed or archived subscriptions and their final filter state.",
|
||||
"Auditing historical subscriptions, media identity, completion criteria, and filter configuration.",
|
||||
"Generated by subscription completion and archival; restore or delete through its business API.",
|
||||
),
|
||||
"systemconfig": (
|
||||
"Stores JSON business configuration values keyed by SystemConfigKey.",
|
||||
"Verifying the physical value only when the managed settings API behaves unexpectedly.",
|
||||
"Use config.system.get/update first; direct writes bypass validation, events, and plugin admission.",
|
||||
),
|
||||
"transferexecutionstep": (
|
||||
"Stores intent, attempt identity, state, and result evidence for each durable transfer operation.",
|
||||
"Diagnosing stuck, failed, or repeated steps by task_id or operation_id.",
|
||||
"Owned by the transfer execution state machine and lease CAS; never force state transitions manually.",
|
||||
),
|
||||
"transferhistory": (
|
||||
"Stores transfer source, destination, mode, media identity, download linkage, and outcome.",
|
||||
"Reviewing success/failure history, destination paths, media classification, and download linkage.",
|
||||
"Written by transfer settlement; delete or retry through transfer-history business APIs.",
|
||||
),
|
||||
"transferpending": (
|
||||
"Durably stores pending transfer input, plans, checkpoints, leases, retries, and manual review state.",
|
||||
"Diagnosing restart recovery, expired leases, retry_wait, terminal failures, or manual review.",
|
||||
"Core durable state machine advanced only by planning, execution, retry, and review services.",
|
||||
),
|
||||
"transfersettlementreceipt": (
|
||||
"Stores immutable terminal settlement receipts with contiguous revisions per transfer task.",
|
||||
"Verifying that history, pending deletion, and execution fingerprints were settled reliably.",
|
||||
"Idempotency and audit evidence; append revisions only and never overwrite or delete old receipts.",
|
||||
),
|
||||
"user": (
|
||||
"Stores user accounts, password hashes, administrator state, OTP, permissions, and preferences.",
|
||||
"Authorized diagnostics of account state, permissions, or authentication configuration.",
|
||||
"Security-sensitive; manage through user, permission, password, and two-factor APIs.",
|
||||
),
|
||||
"userconfig": (
|
||||
"Stores per-user JSON configuration isolated by username and key.",
|
||||
"Inspecting UI preferences, message clear cursors, or other personalized state.",
|
||||
"Modify through the owning user or messaging API to preserve key semantics.",
|
||||
),
|
||||
"workflow": (
|
||||
"Stores workflow definitions, triggers, action graphs, execution context, and runtime state.",
|
||||
"Inspecting scheduled/event workflows, pause state, current action, run count, and failures.",
|
||||
"Create, modify, run, pause, or reset through the workflow API.",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _load_json_schema() -> dict[str, Any]:
|
||||
"""Load the generated moviepilot_api MCP schema."""
|
||||
path = PROJECT_ROOT / "app/agent/policy/api_mcp_schema.json"
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("The API MCP schema must be a JSON object")
|
||||
return payload
|
||||
|
||||
|
||||
def _schema_type(schema: Mapping[str, Any], definitions: Mapping[str, Any]) -> str:
|
||||
"""Compress JSON Schema into a readable Skill type expression."""
|
||||
reference = schema.get("$ref")
|
||||
if isinstance(reference, str):
|
||||
return reference.rsplit("/", 1)[-1]
|
||||
if isinstance(schema.get("const"), (str, int, float, bool)):
|
||||
return f"{schema.get('type', 'value')}={schema['const']}"
|
||||
enum = schema.get("enum")
|
||||
if isinstance(enum, list):
|
||||
return f"{schema.get('type', 'value')}({','.join(map(str, enum))})"
|
||||
schema_type = schema.get("type")
|
||||
if schema_type == "array":
|
||||
items = schema.get("items")
|
||||
item_type = _schema_type(items, definitions) if isinstance(items, Mapping) else "value"
|
||||
return f"array<{item_type}>"
|
||||
if schema_type:
|
||||
return str(schema_type)
|
||||
any_of = schema.get("anyOf")
|
||||
if isinstance(any_of, list):
|
||||
return "|".join(
|
||||
_schema_type(item, definitions)
|
||||
for item in any_of
|
||||
if isinstance(item, Mapping)
|
||||
)
|
||||
if "properties" in schema or schema.get("additionalProperties") is not None:
|
||||
return "object"
|
||||
return "value"
|
||||
|
||||
|
||||
def _field_suffix(schema: Mapping[str, Any]) -> str:
|
||||
"""Format defaults, ranges, and collection constraints."""
|
||||
suffix: list[str] = []
|
||||
if "default" in schema:
|
||||
suffix.append(f"default `{schema['default']}`")
|
||||
if "minimum" in schema:
|
||||
suffix.append(f"minimum `{schema['minimum']}`")
|
||||
if "maximum" in schema:
|
||||
suffix.append(f"maximum `{schema['maximum']}`")
|
||||
if "minLength" in schema:
|
||||
suffix.append(f"minimum length `{schema['minLength']}`")
|
||||
if "minItems" in schema:
|
||||
suffix.append(f"minimum items `{schema['minItems']}`")
|
||||
return f"; {'; '.join(suffix)}" if suffix else ""
|
||||
|
||||
|
||||
def _schema_fields(
|
||||
schema: Mapping[str, Any],
|
||||
definitions: Mapping[str, Any],
|
||||
) -> list[tuple[str, str, str, bool]]:
|
||||
"""Extract fields, types, descriptions, and required markers."""
|
||||
reference = schema.get("$ref")
|
||||
if isinstance(reference, str):
|
||||
schema = definitions.get(reference.rsplit("/", 1)[-1], {})
|
||||
properties = schema.get("properties")
|
||||
if not isinstance(properties, Mapping):
|
||||
return []
|
||||
required = set(schema.get("required") or [])
|
||||
fields = []
|
||||
for name, raw_schema in properties.items():
|
||||
if not isinstance(raw_schema, Mapping):
|
||||
continue
|
||||
fields.append(
|
||||
(
|
||||
str(name),
|
||||
_schema_type(raw_schema, definitions),
|
||||
str(raw_schema.get("description") or ""),
|
||||
str(name) in required,
|
||||
)
|
||||
)
|
||||
return fields
|
||||
|
||||
|
||||
def _resolve_schema(
|
||||
schema: Mapping[str, Any],
|
||||
definitions: Mapping[str, Any],
|
||||
) -> Mapping[str, Any]:
|
||||
"""Resolve a local JSON Schema reference into an enumerable object."""
|
||||
reference = schema.get("$ref")
|
||||
if isinstance(reference, str):
|
||||
resolved = definitions.get(reference.rsplit("/", 1)[-1])
|
||||
if isinstance(resolved, Mapping):
|
||||
return resolved
|
||||
return schema
|
||||
|
||||
|
||||
def _render_api_docs() -> str:
|
||||
"""Render the complete MoviePilot API operation and field contract."""
|
||||
schema = _load_json_schema()
|
||||
definitions = schema.get("$defs") if isinstance(schema.get("$defs"), Mapping) else {}
|
||||
from app.agent.policy.api import API_OPERATION_ROUTES, API_OPERATION_SPECS
|
||||
|
||||
specs = {spec.operation_id: spec for spec in API_OPERATION_SPECS}
|
||||
lines = [
|
||||
"## Operation Catalog",
|
||||
"",
|
||||
"The operations, HTTP methods, routes, and path/query/body fields below exactly match external MCP `tools/list`.",
|
||||
"A field name ending in `*` is required. Omit an empty bucket or send `{}`. Referenced body models are expanded below.",
|
||||
"",
|
||||
]
|
||||
for operation_id in sorted(API_OPERATION_ROUTES):
|
||||
route = API_OPERATION_ROUTES[operation_id]
|
||||
branch = next(
|
||||
item
|
||||
for item in schema["oneOf"]
|
||||
if item["properties"]["operation_id"].get("const") == operation_id
|
||||
)
|
||||
description = str(branch.get("description") or "")
|
||||
effect = specs[operation_id].effect.value
|
||||
lines.extend(
|
||||
[
|
||||
f"### `{operation_id}`",
|
||||
f"`{route.method} {route.path}`; policy effect: `{effect}`.",
|
||||
f"Purpose: {description.split(' Method:', 1)[0].strip()}",
|
||||
]
|
||||
)
|
||||
for bucket in ("path_params", "query", "body"):
|
||||
bucket_schema = branch["properties"].get(bucket)
|
||||
if not isinstance(bucket_schema, Mapping):
|
||||
lines.append(f"- `{bucket}`: none")
|
||||
continue
|
||||
resolved_bucket = _resolve_schema(bucket_schema, definitions)
|
||||
fields = _schema_fields(resolved_bucket, definitions)
|
||||
if not fields:
|
||||
if resolved_bucket.get("type") != "object":
|
||||
required_mark = "*" if bucket in set(branch.get("required") or []) else ""
|
||||
description_text = str(resolved_bucket.get("description") or "")
|
||||
if not description_text:
|
||||
raise ValueError(
|
||||
f"API Skill scalar guidance is missing: {operation_id}.{bucket}"
|
||||
)
|
||||
lines.append(
|
||||
f"- `{bucket}{required_mark}` ({_schema_type(resolved_bucket, definitions)}): "
|
||||
f"{description_text}"
|
||||
)
|
||||
continue
|
||||
dynamic_description = str(resolved_bucket.get("description") or "")
|
||||
if resolved_bucket.get("additionalProperties") is True and dynamic_description:
|
||||
required_mark = "*" if bucket in set(branch.get("required") or []) else ""
|
||||
lines.append(f"- `{bucket}{required_mark}` (object): {dynamic_description}")
|
||||
continue
|
||||
body_ref = bucket_schema.get("$ref")
|
||||
label = (
|
||||
body_ref.rsplit("/", 1)[-1]
|
||||
if isinstance(body_ref, str)
|
||||
else "empty object"
|
||||
)
|
||||
lines.append(f"- `{bucket}`: `{label}` with no direct fields")
|
||||
continue
|
||||
rendered = []
|
||||
for name, field_type, description_text, required in fields:
|
||||
required_mark = "*" if required else ""
|
||||
if not description_text:
|
||||
raise ValueError(
|
||||
f"API Skill field guidance is missing: {operation_id}.{bucket}.{name}"
|
||||
)
|
||||
raw_schema = resolved_bucket["properties"][name]
|
||||
rendered.append(
|
||||
f"`{name}{required_mark}` ({field_type}{_field_suffix(raw_schema)}): {description_text}"
|
||||
)
|
||||
lines.append(f"- `{bucket}`: " + "; ".join(rendered))
|
||||
lines.append("")
|
||||
|
||||
lines.extend(["### Referenced Body Models", ""])
|
||||
referenced: set[str] = set()
|
||||
for branch in schema["oneOf"]:
|
||||
for bucket in ("path_params", "query", "body"):
|
||||
bucket_schema = branch["properties"].get(bucket)
|
||||
if not isinstance(bucket_schema, Mapping):
|
||||
continue
|
||||
resolved_bucket = _resolve_schema(bucket_schema, definitions)
|
||||
for raw_schema in (resolved_bucket.get("properties") or {}).values():
|
||||
if not isinstance(raw_schema, Mapping):
|
||||
continue
|
||||
refs = [raw_schema.get("$ref")]
|
||||
refs.extend(
|
||||
item.get("$ref")
|
||||
for item in raw_schema.get("anyOf", [])
|
||||
if isinstance(item, Mapping)
|
||||
)
|
||||
for reference in refs:
|
||||
if isinstance(reference, str) and reference.startswith("#/$defs/"):
|
||||
referenced.add(reference.rsplit("/", 1)[-1])
|
||||
pending = list(referenced)
|
||||
while pending:
|
||||
name = pending.pop()
|
||||
model = definitions.get(name)
|
||||
if not isinstance(model, Mapping):
|
||||
continue
|
||||
for raw_schema in (model.get("properties") or {}).values():
|
||||
if not isinstance(raw_schema, Mapping):
|
||||
continue
|
||||
references = [raw_schema.get("$ref")]
|
||||
references.extend(
|
||||
item.get("$ref")
|
||||
for item in raw_schema.get("anyOf", [])
|
||||
if isinstance(item, Mapping)
|
||||
)
|
||||
for reference in references:
|
||||
if isinstance(reference, str) and reference.startswith("#/$defs/"):
|
||||
child = reference.rsplit("/", 1)[-1]
|
||||
if child not in referenced:
|
||||
referenced.add(child)
|
||||
pending.append(child)
|
||||
|
||||
for name in sorted(referenced):
|
||||
model = definitions.get(name)
|
||||
if not isinstance(model, Mapping):
|
||||
continue
|
||||
lines.append(f"#### `{name}`")
|
||||
model_description = str(model.get("description") or "")
|
||||
if not model_description:
|
||||
raise ValueError(f"API Skill model guidance is missing: {name}")
|
||||
lines.append(model_description)
|
||||
fields = _schema_fields(model, definitions)
|
||||
if not fields:
|
||||
lines.append("This runtime model has no directly writable fields.")
|
||||
else:
|
||||
for field_name, field_type, description_text, required in fields:
|
||||
required_mark = "*" if required else ""
|
||||
raw_schema = model["properties"][field_name]
|
||||
if not description_text:
|
||||
raise ValueError(f"API Skill model field guidance is missing: {name}.{field_name}")
|
||||
lines.append(
|
||||
f"- `{field_name}{required_mark}` ({field_type}{_field_suffix(raw_schema)}): {description_text}"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
lines.append(_render_system_settings_docs().rstrip())
|
||||
lines.append("")
|
||||
lines.extend(
|
||||
[
|
||||
"## Operation Order And Failure Handling",
|
||||
"",
|
||||
"1. Select the operation first, then place each value in its documented bucket. Never move query fields into path_params or send undeclared fields.",
|
||||
"2. Reuse the exact `media_source` + `media_id` pair returned by search. For music, also preserve `music_type`.",
|
||||
"3. Downloads, transfers, configuration/rule/plugin writes, scheduler/workflow runs, and deletions have side effects; obtain confirmation and inspect the result.",
|
||||
"4. `success=false`, HTTP errors, validation errors, and empty results are real outcomes. Never report them as success.",
|
||||
"5. Use `database-operation`, `downloader-operation`, or `mediaserver-operation` for their native capabilities. Never bypass the gateway with an arbitrary URL.",
|
||||
"",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
def _render_service_docs(script_path: Path, title: str) -> str:
|
||||
"""Render one downloader or media-server action contract."""
|
||||
namespace = runpy.run_path(str(script_path))
|
||||
actions = namespace.get("ACTIONS")
|
||||
if not isinstance(actions, Mapping):
|
||||
raise ValueError(f"{script_path} does not expose ACTIONS")
|
||||
lines = [
|
||||
"## Complete Action Contract",
|
||||
"",
|
||||
f"This is the complete {title} action contract. It comes directly from the script `ACTIONS` registry and matches the external MCP `tools/list` oneOf branches.",
|
||||
"A field name ending in `*` is required. Put every action parameter in the `arguments` object.",
|
||||
"",
|
||||
"| action | Purpose and argument summary |",
|
||||
"| :--- | :--- |",
|
||||
]
|
||||
for name, spec in sorted(actions.items()):
|
||||
contract = spec.to_dict(name)
|
||||
argument_names = [
|
||||
f"`{argument['name']}{'*' if argument['required'] else ''}`"
|
||||
for argument in contract["arguments"]
|
||||
]
|
||||
summary = contract["description"]
|
||||
if argument_names:
|
||||
summary += "; arguments: " + ", ".join(argument_names)
|
||||
else:
|
||||
summary += "; no arguments"
|
||||
lines.append(f"| `{name}` | {summary} |")
|
||||
lines.append("")
|
||||
for name, spec in sorted(actions.items()):
|
||||
contract = spec.to_dict(name)
|
||||
lines.extend(
|
||||
[
|
||||
f"### `{name}`",
|
||||
f"{contract['description']} Effect: `{contract['effect']}`. Providers: `{', '.join(contract['providers'])}`.",
|
||||
]
|
||||
)
|
||||
if not contract["arguments"]:
|
||||
lines.append("- `arguments`: `{}`")
|
||||
else:
|
||||
for argument in contract["arguments"]:
|
||||
required_mark = "*" if argument["required"] else ""
|
||||
extras = []
|
||||
if "default" in argument:
|
||||
extras.append(f"default `{argument['default']}`")
|
||||
if argument.get("enum"):
|
||||
extras.append(f"allowed values `{','.join(map(str, argument['enum']))}`")
|
||||
suffix = f"; {'; '.join(extras)}" if extras else ""
|
||||
lines.append(
|
||||
f"- `{argument['name']}{required_mark}` ({argument['type']}{suffix}): {argument['description']}"
|
||||
)
|
||||
for rule in contract.get("argument_rules") or []:
|
||||
lines.append(f"- Rule: {rule}")
|
||||
lines.append("")
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
def _render_database_tables() -> str:
|
||||
"""Render all ORM tables, purposes, and access boundaries."""
|
||||
from app.db.base import Base
|
||||
from app.db.models import load_all_models
|
||||
|
||||
load_all_models()
|
||||
table_names = {"alembic_version", *Base.metadata.tables}
|
||||
missing_guides = table_names - DATABASE_TABLE_GUIDES.keys()
|
||||
stale_guides = DATABASE_TABLE_GUIDES.keys() - table_names
|
||||
if missing_guides or stale_guides:
|
||||
raise ValueError(
|
||||
"Database table guidance does not match ORM metadata: "
|
||||
f"missing={sorted(missing_guides)}, stale={sorted(stale_guides)}"
|
||||
)
|
||||
lines = [
|
||||
"## Core Tables",
|
||||
"",
|
||||
"`tables` returns the tables that exist in the current instance. The catalog below covers every MoviePilot ORM table plus Alembic metadata. Always treat the live `schema <table>` result as authoritative.",
|
||||
"",
|
||||
]
|
||||
for table_name in sorted(table_names):
|
||||
purpose, query_usage, write_boundary = DATABASE_TABLE_GUIDES[table_name]
|
||||
if table_name == "alembic_version":
|
||||
columns = "`version_num`"
|
||||
else:
|
||||
table = Base.metadata.tables[table_name]
|
||||
columns = ", ".join(f"`{column.name}`" for column in table.columns)
|
||||
lines.extend(
|
||||
[
|
||||
f"### `{table_name}`",
|
||||
f"- Purpose: {purpose}",
|
||||
f"- Useful queries: {query_usage}",
|
||||
f"- Write boundary: {write_boundary}",
|
||||
f"- Columns: {columns}",
|
||||
"",
|
||||
]
|
||||
)
|
||||
lines.extend(
|
||||
[
|
||||
"## Database Action Contract",
|
||||
"",
|
||||
"- `tables`: `arguments={}` lists current database tables.",
|
||||
"- `schema`: `arguments={\"table_name\":\"downloadhistory\"}`; table_name must come from `tables`.",
|
||||
"- `query`: `arguments={\"sql\":\"SELECT ...\",\"limit\":100,\"write\":false}`; provide exactly one of sql and file. SELECT/WITH/EXPLAIN are allowed by default.",
|
||||
"- `write`: `arguments={\"sql\":\"UPDATE ... WHERE ...\"}`; provide exactly one of sql and file and only one statement.",
|
||||
"- `file` is a local SQL path readable by the MoviePilot process. MCP clients normally send `sql` directly.",
|
||||
"",
|
||||
"Use the live `schema` result instead of guessing columns from older documentation. Treat `media_source` and `media_id` as one atomic identity pair.",
|
||||
"",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
def _render_system_settings_docs() -> str:
|
||||
"""Render dynamic system-setting discovery and update guidance."""
|
||||
return "\n".join([
|
||||
"## System Settings Contract",
|
||||
"",
|
||||
"Do not enumerate setting keys in this Skill. Settings change as MoviePilot evolves, so use `config.system.get` as the runtime discovery operation before updating an unfamiliar key.",
|
||||
"",
|
||||
"| `source` | Contents | Persistence |",
|
||||
"| :--- | :--- | :--- |",
|
||||
"| `settings` | Runtime `Settings` fields such as APP_DOMAIN or LLM_MODEL | Type-converted and persisted to `app.env`, then applied to the current process |",
|
||||
"| `systemconfig` | Database-backed business configuration such as downloaders, media servers, directories, and notifications | Written through the configuration service with plugin admission and change events |",
|
||||
"",
|
||||
"The `systemconfig` database table is only the physical store for the second source. Use `config.system.get/update` for normal reads and writes. Direct SQL is reserved for an explicitly authorized repair when the managed API cannot complete the operation.",
|
||||
"",
|
||||
"### Discover definitions",
|
||||
"",
|
||||
"1. Call `config.system.get` with `query={\"group\":\"settings\",\"keyword\":\"LLM\"}` or another group/keyword. Discovery defaults to summaries instead of full values.",
|
||||
"2. Each returned setting includes `setting_key`, `source`, `group`, `label`, and a `definition` object with `declared_type`, current `value_shape`, `nullable`, `sensitive`, allowed `update_operations`, `default_match_field`, and `persistence`.",
|
||||
"3. Read one exact value with `query={\"setting_key\":\"LLM_MODEL\"}`. Exact-key reads include the value by default.",
|
||||
"4. Use `show_secrets=true` only when an administrator explicitly requests the plaintext value; secret reads remain confirmation-protected.",
|
||||
"",
|
||||
"### Update settings",
|
||||
"",
|
||||
"Choose an operation listed in the discovered setting definition, then send it in `body`:",
|
||||
"",
|
||||
"| operation | Fields | Meaning |",
|
||||
"| :--- | :--- | :--- |",
|
||||
"| `replace` | `setting_key*`, `value` | Replace the complete scalar, list, or object value |",
|
||||
"| `merge_dict` | `setting_key*`, `value`; optional `remove_keys` | Shallow-merge an object and optionally remove keys |",
|
||||
"| `upsert_list_item` | `setting_key*`, `value`; optional `match_field`, `match_value` | Replace a matched list item or append it when absent |",
|
||||
"| `remove_list_item` | `setting_key*`, `value` or `match_value`; optional `match_field` | Remove one matched list item without replacing the list |",
|
||||
"",
|
||||
"After every update, call `config.system.get` again with the exact setting_key and verify the saved value. Do not guess a key, value shape, list match field, or update operation when discovery can return it.",
|
||||
"",
|
||||
]).rstrip() + "\n"
|
||||
|
||||
|
||||
def _sync_api_frontmatter(text: str) -> str:
|
||||
"""Synchronize the API Skill authorization list with the fixed operation registry."""
|
||||
from app.agent.policy.api import list_api_operation_ids
|
||||
|
||||
operation_text = " ".join(list_api_operation_ids())
|
||||
wrapped = textwrap.wrap(
|
||||
operation_text,
|
||||
width=96,
|
||||
break_long_words=False,
|
||||
break_on_hyphens=False,
|
||||
)
|
||||
replacement = "allowed-api-operations: >-\n" + "\n".join(f" {line}" for line in wrapped) + "\n"
|
||||
lines = text.splitlines(keepends=True)
|
||||
frontmatter_end = next(
|
||||
(index for index, line in enumerate(lines[1:], start=1) if line.strip() == "---"),
|
||||
None,
|
||||
)
|
||||
if frontmatter_end is None:
|
||||
raise ValueError("moviepilot-api Skill frontmatter closing marker is missing")
|
||||
field_index = next(
|
||||
(
|
||||
index
|
||||
for index, line in enumerate(lines[:frontmatter_end])
|
||||
if line.startswith("allowed-api-operations:")
|
||||
),
|
||||
None,
|
||||
)
|
||||
if field_index is None:
|
||||
raise ValueError("moviepilot-api Skill frontmatter operation list is missing")
|
||||
field_end = field_index + 1
|
||||
while field_end < frontmatter_end and lines[field_end].startswith((" ", "\t")):
|
||||
field_end += 1
|
||||
lines[field_index:field_end] = [replacement]
|
||||
return "".join(lines)
|
||||
|
||||
|
||||
def _replace_section(text: str, heading: str, replacement: str, next_heading: str | None = None) -> str:
|
||||
"""Replace one Markdown section from a heading to the next heading."""
|
||||
start = text.index(heading)
|
||||
if next_heading is None:
|
||||
return text[:start].rstrip() + "\n\n" + replacement
|
||||
end = text.index(next_heading, start)
|
||||
return text[:start].rstrip() + "\n\n" + replacement + "\n" + text[end:]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Generate complete contracts for four built-in Skills."""
|
||||
api_path = PROJECT_ROOT / "skills/moviepilot-api/SKILL.md"
|
||||
api_text = api_path.read_text(encoding="utf-8")
|
||||
api_text = _sync_api_frontmatter(api_text)
|
||||
api_path.write_text(
|
||||
_replace_section(api_text, "## Operation Catalog", _render_api_docs()),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
downloader_path = PROJECT_ROOT / "skills/downloader-operation/SKILL.md"
|
||||
downloader_text = downloader_path.read_text(encoding="utf-8")
|
||||
downloader_path.write_text(
|
||||
_replace_section(
|
||||
downloader_text,
|
||||
"## Complete Action Contract",
|
||||
_render_service_docs(
|
||||
PROJECT_ROOT / "skills/downloader-operation/scripts/mp-downloader.py",
|
||||
"Downloader Operation",
|
||||
),
|
||||
"## Verification",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
mediaserver_path = PROJECT_ROOT / "skills/mediaserver-operation/SKILL.md"
|
||||
mediaserver_text = mediaserver_path.read_text(encoding="utf-8")
|
||||
mediaserver_path.write_text(
|
||||
_replace_section(
|
||||
mediaserver_text,
|
||||
"## Complete Action Contract",
|
||||
_render_service_docs(
|
||||
PROJECT_ROOT / "skills/mediaserver-operation/scripts/mp-mediaserver.py",
|
||||
"Media Server Operation",
|
||||
),
|
||||
"## Safety And Verification",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
database_path = PROJECT_ROOT / "skills/database-operation/SKILL.md"
|
||||
database_text = database_path.read_text(encoding="utf-8")
|
||||
database_path.write_text(
|
||||
_replace_section(
|
||||
database_text,
|
||||
"## Core Tables",
|
||||
_render_database_tables(),
|
||||
"## Common Queries",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
print("generated four Agent Skill contracts")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user