Files
MoviePilot/scripts/generate_agent_skill_docs.py
Aqr-K d51320103c fix: make local setup and bundled skill operations recoverable (#7150)
* fix: make local setup and bundled skill operations recoverable

* test: verify user updates with real transaction runners
2026-10-23 06:49:13 +08:00

1041 lines
51 KiB
Python

#!/usr/bin/env python3
"""
根据运行时元数据生成 Agent Skill 契约,保留英文操作指南和源契约的中文说明。
"""
from __future__ import annotations
import json
import re
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 = "59"
DATABASE_OPERATION_SKILL_VERSION = "12"
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.",
),
"searchsession": (
"Stores the page cursor and pending candidates of one unfinished subscription search task, without site "
"credentials; removed when the task ends and purged after 14 days without updates.",
"Diagnosing a paused or resumed paged search through task_id, version, and updated_at.",
"Written only under the queue task lease with version CAS; never edit payloads, they can skip pages or resubmit candidates.",
),
"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.",
),
"subscribeactivity": (
"Structured subscription business events and resumable search-round progress, recorded after the feature upgrade.",
"Inspecting event summaries, frozen filter rules, safe resource decisions and retry attempts "
"through the authenticated subscription activity API.",
"Owned by subscription transactions and activity observation. Never reconstruct events by "
"parsing runtime logs or modify submitted-download facts directly.",
),
"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]:
"""
读取已生成的 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:
"""
把 JSON Schema 类型压缩为 Skill 文档中可读的类型表达式。
"""
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:
"""
格式化字段默认值、取值范围和集合约束。
"""
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 ""
# 只有这些端点字段会规范化 Agent 身份键,同名枚举或数据库筛选字段保留各自契约。
_MEDIA_TYPE_FIELD_VALUES: dict[tuple[str, str, str], str] = {
("filter.group.add", "body", "media_type"): "movie,tv,music",
("filter.group.update", "body", "media_type"): "movie,tv,music",
("recommendation.list", "query", "media_type"): "all,movie,tv,music",
("search.title", "query", "mtype"): "movie,tv,music",
("search.torrents", "query", "mtype"): "movie,tv,music",
("site.resource", "query", "mtype"): "movie,tv,music",
("site.searchable", "path_params", "media_type"): "movie,tv,music",
("subtitle.search.media", "query", "mtype"): "movie,tv,music",
}
# OpenAPI 无法表达的操作级 Agent 指引,在生成时追加到操作参数组之后,
# 保证运行中的 Skill 包含这些指引,无需手动修改生成的 api/*.md。
_OPERATION_NOTES: dict[str, list[str]] = {
"plugin.instance.purge": [
"- **Historical invalid-ID recovery:** If the exact instance ID from a warning is "
"rejected by normal validation, send only `config: true` (all other scope flags false). "
"The host removes only an empty disabled host row with no display fields, default target, "
"log override, or configuration; an empty configuration object still protects the row. "
"It never stops a plugin or deletes plugin data, databases, or files. Rows with settings, "
"clones, missing rows, or mixed scopes are rejected.",
],
"library.exists": [
"- **Agent note:** The media-type field name is `mtype` (placed inside `query`). "
"Do **not** reuse the `type` field from the recognition result — `type` is not a "
"valid field for this operation, and the gateway will reject it with `invalid_input`.",
],
"site.searchable": [
"- **Agent note:** `media_type` is a **path** parameter and MUST be nested under "
"`path_params`. Never flatten it to the top level or place it inside `query`, "
"otherwise the gateway rejects with `invalid_input`.",
],
"media.recognize": [
"- **Agent note:** Recognition returns a context containing `media_info`, not a candidate list. "
"A failed match still returns parsed `meta_info` with the applied recognition words, so treat "
"recognition as successful only when `media_info` is present. Check its title and media type "
"against the user's request. If recognition is missing or ambiguous, use `media.search` and "
"compare candidates by title, type, and year. Preserve the selected result's exact "
"`media_source` + `media_id` for `search.torrents`.",
],
"search.torrents": [
"- **Agent note:** `media_id` is a **path** parameter and MUST be nested under "
"`path_params`. Never flatten it to the top level or place it inside `query`, "
"otherwise the gateway rejects with `invalid_input`. The same rule applies to every "
"`*`-marked path parameter across this document set (`media_type`, `site_id`, …).",
"",
"#### Candidate Selection and Empty-Result Retries",
"",
"- When `media.search` returns multiple candidates, select based on "
"**title matching the user's request AND `type` (movie/series) matching**; do not "
"mechanically pick the first or an arbitrary candidate without comparison. If the first "
"candidate happens to exactly match and its type is correct, you may use it directly; "
"when several are close, prefer the higher-ranked / more popular one.",
"- `search.torrents` must be called with `media_source` + `media_id`, and both must come "
"from the **same** recognition result — do not mix `source`/`id` from different candidates.",
"- Do not repeat an unchanged empty or failed search without new evidence. On failure, "
"inspect and report the error; an unavailable source does not prove there are no resources.",
"- After a successful empty search, try another credible title/type/year match or "
"`search.title` with the original title keywords, preserving the user's site and media "
"constraints. Report the actual search scope and outcome without claiming exhaustive coverage.",
],
}
# 在生成的 Schema 字段旁保留经过维护的操作指引。
_OPERATION_PURPOSE_NOTES: dict[str, str] = {
"subscription.find": (
"For movies and TV, if exact identity misses and mtype/title are supplied, match across "
"sources by type, normalized title, optional season, and year. An exact year ranks first; "
"a subscription with no year may match when the card has a year, while a card with no "
"year only matches a subscription with no year. Music uses exact identity only."
),
}
_OPERATION_FIELD_NOTES: dict[str, list[str]] = {
"subscription.search": [
"An explicit search can search and download once for a paused subscription without enabling it.",
"State `S` stops automatic searches, including tasks already queued or waiting for site recovery;",
"cancel the execution batch to stop an explicit search. Already submitted downloads remain active.",
],
"subscription.update": [
"- Omitted fields keep their stored values. Send JSON `null` to clear `custom_words`, "
"`keyword`, `save_path`, `episode_group`, `downloader`, `min_bitrate`, `min_bit_depth`, "
"`min_sample_rate`, `filter`, `include`, `exclude`, `quality`, `resolution`, `effect`, "
"`audio_quality`, or `audio_format`. Form submissions using `\"\"` for these fields are "
"also accepted as explicit clearing. Nonempty strings are preserved without trimming, "
"and numeric `0` remains a value. Name, type, season, and episode counts retain their "
"existing normalization; submit `media_source` and `media_id` together when changing "
"or clearing media identity.",
],
"music.album.get": [
"- Album and track text follows `MUSIC_METADATA_TO_SIMPLIFIED`, matching recognition "
"and final transfer naming. Original aliases, source data, lyrics, and IDs remain unchanged. "
"Recording selection does not override local album-edition evidence, and multi-artist "
"credits remain complete.",
],
"search.torrents": [
"- `execution`: Searches may run multiple title/alias rounds across configured sites. "
"The inner Agent API request may wait up to 290 seconds and the Agent tool call is capped "
"at 300 seconds, leaving 10 seconds for timeout handling and returning the result; the V3 "
"MCP wrapper uses its existing configured timeout (600 seconds by default). `page` / `count` "
"paginate the completed result and do not shorten site-search execution; do not issue a "
"duplicate call while one is pending.",
],
}
def _schema_fields(
schema: Mapping[str, Any],
definitions: Mapping[str, Any],
*,
operation_id: str = "",
bucket: str = "",
) -> list[tuple[str, str, str, bool]]:
"""
提取 Schema 字段,仅为允许的操作参数组补充 Agent 身份键约定。
"""
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
field_name = str(name)
field_type = _schema_type(raw_schema, definitions)
description = str(raw_schema.get("description") or "")
media_type_values = _MEDIA_TYPE_FIELD_VALUES.get((operation_id, bucket, field_name))
if media_type_values:
field_type = field_type.replace("string", f"string({media_type_values})")
description = (
f"{description} Agent-facing values: {media_type_values}. "
"Use this exact field name in its documented bucket."
).strip()
fields.append(
(
field_name,
field_type,
description,
field_name in required,
)
)
return fields
def _resolve_schema(
schema: Mapping[str, Any],
definitions: Mapping[str, Any],
) -> Mapping[str, Any]:
"""
解析本地 JSON Schema 引用,返回可枚举的模型定义。
"""
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:
"""
生成全部 API 操作、参数和请求模型的完整契约。
"""
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 "")
purpose = description.split("请求方法:", 1)[0].split(" Method:", 1)[0].strip()
if operation_id in _OPERATION_PURPOSE_NOTES:
purpose += " " + _OPERATION_PURPOSE_NOTES[operation_id]
effect = specs[operation_id].effect.value
lines.extend(
[
f"### `{operation_id}`",
f"`{route.method} {route.path}`; policy effect: `{effect}`.",
f"Purpose: {purpose}",
f"Identity scope: `{specs[operation_id].identity_scope.value}`; required role: `{specs[operation_id].required_role.value}`.",
]
)
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."
)
extra_fields = _OPERATION_FIELD_NOTES.get(operation_id)
if extra_fields:
lines.extend(extra_fields)
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, operation_id=operation_id, bucket=bucket
)
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))
notes = _OPERATION_NOTES.get(operation_id)
if notes:
lines.extend(notes)
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]:
"""
按命名空间、请求模型和系统设置拆分生成的 API 契约。
"""
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:
"""
把当前操作归入操作标识第一段对应的命名空间。
"""
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:
"""
写入各 API 分类契约,并附上该分类需要的共享请求模型。
"""
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:
"""
读取运行时动作元数据,生成下载器或媒体服务器的操作契约。
"""
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:
"""
生成 ORM 表结构、用途说明及读写权限边界。
"""
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}",
]
)
if table_name == "subscribeactivity":
lines.append(
"- `activity_key` links the original subscription to its completed history independently "
"of reused integer IDs. `tracking` contains internal hashed deduplication keys and is "
"excluded from API responses."
)
lines.append("")
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:
"""
生成动态系统设置的发现与更新指南。
"""
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:
"""
根据固定操作登记表同步 API Skill 的授权列表。
"""
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:
"""
替换 Markdown 指定标题到下个同级标题之间的内容。
"""
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:
"""
生成四个内置 Skill 的完整契约,并同步 API 分类文档。
"""
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_text = re.sub(r"(?m)^version: \d+$", f"version: {DATABASE_OPERATION_SKILL_VERSION}",
database_text, count=1)
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())