mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/jxxghp/MoviePilot.git
synced 2026-09-20 16:13:35 +08:00
851 lines
42 KiB
Python
851 lines
42 KiB
Python
#!/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))
|
|
|
|
MOVIEPILOT_API_SKILL_VERSION = "33"
|
|
|
|
|
|
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.",
|
|
),
|
|
"agentinvocation": (
|
|
"Durable Agent write invocation identity and last observed outcome, including confirmed asynchronous "
|
|
"submission.",
|
|
"Inspect an exact principal and session's running, unknown, pending, succeeded, or failed receipts; "
|
|
"compare timestamps when diagnosing an interrupted write.",
|
|
"Owned by the host's atomic claim and reconciliation path. Never change IDs, fingerprints, claim "
|
|
"tokens, or statuses to bypass duplicate protection. Running and unknown records are recovery state; "
|
|
"ordinary retention does not delete them. Pending means submission was confirmed, not that the "
|
|
"external task finished. Raw arguments and tool output are not stored here.",
|
|
),
|
|
"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.",
|
|
),
|
|
"plugininstance": (
|
|
"Stores one row per shared-source plugin runtime instance, covering both clones and the host plugin "
|
|
"itself (instance_id equals source_plugin_id, so equality identifies the host and inequality a clone), "
|
|
"together with that instance's display overrides, its own log-level override and the moment that "
|
|
"override expires, and the plugin's own configuration payload. A clone exists exactly while its row "
|
|
"exists, so deleting the row uninstalls the clone and discards its configuration.",
|
|
"Diagnosing clone naming and ownership, inspecting what a plugin or one of its clones is configured "
|
|
"with, or finding which instance currently overrides the global log level and until when.",
|
|
"Owned by the plugin instance, plugin configuration, and plugin log-level APIs; never edit rows directly.",
|
|
),
|
|
"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.",
|
|
),
|
|
"subscriptionsearchbatch": (
|
|
"Stores durable subscription search batches, source, aggregate state, counts, and cancellation requests.",
|
|
"Inspecting user-visible search progress, recovery state, cancellation, and terminal outcomes.",
|
|
"Owned by subscription search orchestration; create and cancel batches through the subscription API.",
|
|
),
|
|
"subscriptionsearchtask": (
|
|
"Stores one durable subscription search task per batch and subscription with leases and execution phases.",
|
|
"Diagnosing queued, running, failed, cancelled, or recovered work and its current site.",
|
|
"Advanced only by the search queue lease state machine; never rewrite leases or terminal states manually.",
|
|
),
|
|
"subscriptionsitebudget": (
|
|
"Stores per-site subscription search concurrency, cooldown, health, and fairness state.",
|
|
"Diagnosing site pressure, cooldown deferrals, recent failures, and active search ownership.",
|
|
"Owned by the subscription site-budget coordinator; do not clear cooldowns or counters by direct SQL.",
|
|
),
|
|
"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/resources/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):
|
|
if any(
|
|
any("\u3400" <= character <= "\u9fff" for character in str(value))
|
|
for value in enum
|
|
):
|
|
return str(schema.get("type", "value"))
|
|
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 collection operations, `data` keeps its existing list or page-object shape. The gateway may add a sibling `collection` object with `result_count`, optional exact `total_count`, `page`, and `count`; it never replaces the list body with a new wrapper.",
|
|
"When a collection contract exposes an exact total, answer count or summary requests from that API metadata. For optional legacy pagination, send `page=1,count=1` and read `collection.total_count`; never query the database merely because item data or a tool preview was truncated.",
|
|
"If an endpoint or external source does not expose a total, `collection.total_count` is omitted instead of being guessed from the current page.",
|
|
"",
|
|
]
|
|
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()}",
|
|
]
|
|
)
|
|
collection_contract = branch.get("x-moviepilot-collection")
|
|
if isinstance(collection_contract, Mapping):
|
|
if collection_contract.get("body_shape") == "page_object":
|
|
lines.append(
|
|
"- `response`: structured page object; items stay in "
|
|
f"`{collection_contract['items_field']}` and the exact total stays in "
|
|
f"`{collection_contract['total_count_field']}`."
|
|
)
|
|
elif collection_contract.get("total_count_field"):
|
|
if collection_contract.get("default_pagination") == "unpaginated":
|
|
lines.append(
|
|
"- `response`: `data` remains a list; omitting both `page` and `count` "
|
|
"keeps the complete legacy result. `collection.result_count` reports the "
|
|
"returned items and `collection.total_count` reports the exact pre-pagination "
|
|
"total. For counts or summaries, send `page=1,count=1`, read "
|
|
"`collection.total_count`, and do not fall back to a database query because "
|
|
"the item preview was truncated."
|
|
)
|
|
else:
|
|
lines.append(
|
|
"- `response`: `data` remains a list and the endpoint's documented pagination "
|
|
"or limit defaults remain in effect. `collection.result_count` reports the "
|
|
"returned items and `collection.total_count` reports the exact total. For a "
|
|
"count-only request, use the smallest valid page and read that metadata instead "
|
|
"of querying the database after item truncation."
|
|
)
|
|
else:
|
|
lines.append(
|
|
"- `response`: `data` remains a list and `collection.result_count` reports "
|
|
"the returned items. `collection.total_count` is omitted because this endpoint "
|
|
"or its upstream source does not expose a total."
|
|
)
|
|
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 _split_api_docs(rendered: str) -> tuple[dict[str, str], str, str]:
|
|
"""Split generated API contracts into namespace, model, and settings documents."""
|
|
category_sections: dict[str, list[str]] = {}
|
|
model_lines: list[str] = []
|
|
settings_lines: list[str] = []
|
|
section = ""
|
|
operation_id: str | None = None
|
|
operation_lines: list[str] = []
|
|
|
|
def flush_operation() -> None:
|
|
"""Store the current operation under its first namespace segment."""
|
|
nonlocal operation_id, operation_lines
|
|
if operation_id is not None:
|
|
category = operation_id.split(".", 1)[0]
|
|
category_sections.setdefault(category, []).append(
|
|
"\n".join(operation_lines).rstrip()
|
|
)
|
|
operation_id = None
|
|
operation_lines = []
|
|
|
|
for line in rendered.splitlines():
|
|
if line == "## Operation Catalog":
|
|
section = "operations"
|
|
continue
|
|
if line == "### Referenced Body Models":
|
|
flush_operation()
|
|
section = "models"
|
|
continue
|
|
if line == "## System Settings Contract":
|
|
flush_operation()
|
|
section = "settings"
|
|
settings_lines = [line]
|
|
continue
|
|
if line == "## Operation Order And Failure Handling":
|
|
section = "failure"
|
|
continue
|
|
|
|
if section == "operations":
|
|
if line.startswith("### `") and line.endswith("`"):
|
|
flush_operation()
|
|
operation_id = line.removeprefix("### `").removesuffix("`")
|
|
operation_lines = [line]
|
|
elif operation_id is not None:
|
|
operation_lines.append(line)
|
|
elif section == "models":
|
|
if line.startswith("#### "):
|
|
line = "### " + line.removeprefix("#### ")
|
|
model_lines.append(line)
|
|
elif section == "settings":
|
|
settings_lines.append(line)
|
|
|
|
flush_operation()
|
|
return (
|
|
{category: "\n\n".join(sections) for category, sections in category_sections.items()},
|
|
"\n".join(model_lines).strip(),
|
|
"\n".join(settings_lines).strip(),
|
|
)
|
|
|
|
|
|
def _sync_api_category_docs(
|
|
category_sections: Mapping[str, str],
|
|
settings_section: str,
|
|
models_section: str,
|
|
) -> None:
|
|
"""Write standalone API category contracts with their shared body models."""
|
|
api_dir = PROJECT_ROOT / "skills/moviepilot-api/api"
|
|
model_section = (
|
|
"## Body Models\n\n"
|
|
"This category document is self-contained: the shared models below are included "
|
|
"so the Agent does not need a second Skill document before calling the API.\n\n"
|
|
f"{models_section.rstrip()}"
|
|
)
|
|
for category, operations in category_sections.items():
|
|
category_path = api_dir / f"{category}.md"
|
|
if not category_path.is_file():
|
|
raise FileNotFoundError(
|
|
f"Missing API category document for namespace {category!r}: {category_path}"
|
|
)
|
|
category_text = category_path.read_text(encoding="utf-8")
|
|
operation_section = f"## Operations\n\n{operations}"
|
|
next_heading = "## System Settings Contract" if category == "config" else None
|
|
category_text = _replace_section(
|
|
category_text,
|
|
"## Operations",
|
|
operation_section,
|
|
next_heading,
|
|
)
|
|
if category == "config":
|
|
category_text = _replace_section(
|
|
category_text,
|
|
"## System Settings Contract",
|
|
settings_section,
|
|
)
|
|
if "## Body Models" in category_text:
|
|
category_text = _replace_section(category_text, "## Body Models", model_section)
|
|
else:
|
|
category_text = category_text.rstrip() + "\n\n" + model_section
|
|
category_path.write_text(category_text.rstrip() + "\n", encoding="utf-8")
|
|
|
|
|
|
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")
|
|
version_index = next(
|
|
(
|
|
index
|
|
for index, line in enumerate(lines[:frontmatter_end])
|
|
if line.startswith("version:")
|
|
),
|
|
None,
|
|
)
|
|
if version_index is None:
|
|
raise ValueError("moviepilot-api Skill version field is missing")
|
|
lines[version_index] = f"version: {MOVIEPILOT_API_SKILL_VERSION}\n"
|
|
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(api_text, encoding="utf-8")
|
|
category_sections, models_section, settings_section = _split_api_docs(_render_api_docs())
|
|
_sync_api_category_docs(category_sections, settings_section, models_section)
|
|
|
|
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())
|