mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-01 12:56:34 +08:00
fix(agent): preserve generated skill contracts and API guidance (#6991)
* feat(agent): scripted generation of agent skill docs with operation/field notes Generate skills/moviepilot-api API docs from scripts/generate_agent_skill_docs.py instead of hand-editing: - surface canonical MediaType agent keys in the API contract - add subscribeactivity table note and global operation notes - preserve upstream search.torrents execution semantics via field notes Co-Authored-By: pavel2 <pavelyuan468@gmail.com> * fix(agent): preserve generated skill contracts and English guidance --------- Co-authored-by: jxxghp <jxxghp@gmail.com>
This commit is contained in:
@@ -14,7 +14,7 @@ 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 = "54"
|
||||
MOVIEPILOT_API_SKILL_VERSION = "55"
|
||||
|
||||
|
||||
DATABASE_TABLE_GUIDES: dict[str, tuple[str, str, str]] = {
|
||||
@@ -144,6 +144,13 @@ DATABASE_TABLE_GUIDES: dict[str, tuple[str, str, str]] = {
|
||||
"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.",
|
||||
@@ -261,11 +268,117 @@ def _field_suffix(schema: Mapping[str, Any]) -> str:
|
||||
return f"; {'; '.join(suffix)}" if suffix else ""
|
||||
|
||||
|
||||
# Only these endpoint fields normalize Agent keys; identically named enum or
|
||||
# database filters retain their own contracts.
|
||||
_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",
|
||||
}
|
||||
|
||||
|
||||
# Operation-level Agent guidance that the raw OpenAPI schema cannot express.
|
||||
# Each entry is appended (as extra lines) right after the operation's buckets
|
||||
# when the contract is regenerated, so the running Skill carries the guidance
|
||||
# without anyone hand-editing the generated `api/*.md` files.
|
||||
_OPERATION_NOTES: dict[str, list[str]] = {
|
||||
"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. "
|
||||
"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.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# Preserve maintained operation guidance alongside the generated schema fields.
|
||||
_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]]:
|
||||
"""Extract fields, types, descriptions, and required markers."""
|
||||
"""Extract schema fields, adding Agent keys only for supported operation buckets."""
|
||||
reference = schema.get("$ref")
|
||||
if isinstance(reference, str):
|
||||
schema = definitions.get(reference.rsplit("/", 1)[-1], {})
|
||||
@@ -277,12 +390,22 @@ def _schema_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(
|
||||
(
|
||||
str(name),
|
||||
_schema_type(raw_schema, definitions),
|
||||
str(raw_schema.get("description") or ""),
|
||||
str(name) in required,
|
||||
field_name,
|
||||
field_type,
|
||||
description,
|
||||
field_name in required,
|
||||
)
|
||||
)
|
||||
return fields
|
||||
@@ -326,12 +449,15 @@ def _render_api_docs() -> str:
|
||||
if item["properties"]["operation_id"].get("const") == operation_id
|
||||
)
|
||||
description = str(branch.get("description") or "")
|
||||
purpose = description.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: {description.split(' Method:', 1)[0].strip()}",
|
||||
f"Purpose: {purpose}",
|
||||
f"Identity scope: `{specs[operation_id].identity_scope.value}`; required role: `{specs[operation_id].required_role.value}`.",
|
||||
]
|
||||
)
|
||||
@@ -367,13 +493,18 @@ def _render_api_docs() -> str:
|
||||
"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)
|
||||
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 ""
|
||||
@@ -412,6 +543,9 @@ def _render_api_docs() -> str:
|
||||
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", ""])
|
||||
@@ -686,9 +820,15 @@ def _render_database_tables() -> str:
|
||||
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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: database-operation
|
||||
version: 10
|
||||
version: 11
|
||||
description: >-
|
||||
Use this skill when you need to inspect, query, maintain, or carefully modify
|
||||
the MoviePilot database. This skill uses the bundled scripts/mp-db.py helper,
|
||||
@@ -262,7 +262,7 @@ python scripts/mp-db.py write "UPDATE subscribe SET state = 'S' WHERE id = 123"
|
||||
- Purpose: Stores active movie, TV, or music subscriptions, filters, progress, and download targets.
|
||||
- Useful queries: Inspecting state, missing episodes/tracks, quality rules, site scope, and match progress.
|
||||
- Write boundary: Create, update, search, or delete through the subscription API to preserve state-machine consistency.
|
||||
- Columns: `id`, `name`, `year`, `type`, `search_interval`, `keyword`, `media_source`, `media_id`, `music_type`, `total_tracks`, `downloaded_tracks`, `season`, `poster`, `backdrop`, `vote`, `description`, `filter`, `include`, `exclude`, `quality`, `resolution`, `effect`, `audio_quality`, `audio_format`, `min_bitrate`, `min_bit_depth`, `min_sample_rate`, `total_episode`, `start_episode`, `lack_episode`, `note`, `state`, `last_search`, `last_update`, `date`, `username`, `sites`, `downloader`, `best_version`, `best_version_full`, `current_priority`, `current_audio_format`, `current_bitrate`, `current_bit_depth`, `current_sample_rate`, `episode_priority`, `save_path`, `search_imdbid`, `manual_total_episode`, `custom_words`, `media_category_id`, `media_category`, `filter_groups`, `episode_group`, `activity_key`
|
||||
- Columns: `id`, `activity_key`, `name`, `year`, `type`, `search_interval`, `keyword`, `media_source`, `media_id`, `music_type`, `total_tracks`, `downloaded_tracks`, `season`, `poster`, `backdrop`, `vote`, `description`, `filter`, `include`, `exclude`, `quality`, `resolution`, `effect`, `audio_quality`, `audio_format`, `min_bitrate`, `min_bit_depth`, `min_sample_rate`, `total_episode`, `start_episode`, `lack_episode`, `note`, `state`, `last_search`, `last_update`, `date`, `username`, `sites`, `downloader`, `best_version`, `best_version_full`, `current_priority`, `current_audio_format`, `current_bitrate`, `current_bit_depth`, `current_sample_rate`, `episode_priority`, `save_path`, `search_imdbid`, `manual_total_episode`, `custom_words`, `media_category_id`, `media_category`, `filter_groups`, `episode_group`
|
||||
|
||||
### `subscribeactivity`
|
||||
- Purpose: Structured subscription business events and resumable search-round progress, recorded after the feature upgrade.
|
||||
@@ -275,7 +275,7 @@ python scripts/mp-db.py write "UPDATE subscribe SET state = 'S' WHERE id = 123"
|
||||
- Purpose: Stores snapshots of completed or archived subscriptions and their final filter state.
|
||||
- Useful queries: Auditing historical subscriptions, media identity, completion criteria, and filter configuration.
|
||||
- Write boundary: Generated by subscription completion and archival; restore or delete through its business API.
|
||||
- Columns: `id`, `name`, `year`, `type`, `search_interval`, `keyword`, `media_source`, `media_id`, `music_type`, `total_tracks`, `season`, `poster`, `backdrop`, `vote`, `description`, `filter`, `include`, `exclude`, `quality`, `resolution`, `effect`, `audio_quality`, `audio_format`, `min_bitrate`, `min_bit_depth`, `min_sample_rate`, `total_episode`, `start_episode`, `date`, `username`, `sites`, `best_version`, `best_version_full`, `current_priority`, `current_audio_format`, `current_bitrate`, `current_bit_depth`, `current_sample_rate`, `episode_priority`, `save_path`, `search_imdbid`, `custom_words`, `media_category_id`, `media_category`, `classification_rule_id`, `classification_policy_revision`, `classification_source`, `filter_groups`, `episode_group`, `activity_key`
|
||||
- Columns: `id`, `activity_key`, `name`, `year`, `type`, `search_interval`, `keyword`, `media_source`, `media_id`, `music_type`, `total_tracks`, `season`, `poster`, `backdrop`, `vote`, `description`, `filter`, `include`, `exclude`, `quality`, `resolution`, `effect`, `audio_quality`, `audio_format`, `min_bitrate`, `min_bit_depth`, `min_sample_rate`, `total_episode`, `start_episode`, `date`, `username`, `sites`, `best_version`, `best_version_full`, `current_priority`, `current_audio_format`, `current_bitrate`, `current_bit_depth`, `current_sample_rate`, `episode_priority`, `save_path`, `search_imdbid`, `custom_words`, `media_category_id`, `media_category`, `classification_rule_id`, `classification_policy_revision`, `classification_source`, `filter_groups`, `episode_group`
|
||||
|
||||
### `subscriptionsearchbatch`
|
||||
- Purpose: Stores durable subscription search batches, source, aggregate state, counts, and cancellation requests.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: moviepilot-api
|
||||
version: 54
|
||||
version: 55
|
||||
description: >-
|
||||
Use this skill for MoviePilot product operations such as media search, torrent
|
||||
search, downloads, subscriptions, library checks, sites, storage, workflows,
|
||||
|
||||
@@ -50,7 +50,7 @@ Purpose: Create one named filter-rule group.
|
||||
Identity scope: `admin`; required role: `system_admin`.
|
||||
- `path_params`: none
|
||||
- `query`: none
|
||||
- `body`: `category` (string|null): MoviePilot media category or filter-group category, depending on the operation.; `media_type` (string|null): MoviePilot media type used to filter recommendations or rule groups.; `name*` (string): Human-readable name of the site, storage item, subscription, or rule group.; `rule_string*` (string): Ordered filter-rule expression stored in the group.
|
||||
- `body`: `category` (string|null): MoviePilot media category or filter-group category, depending on the operation.; `media_type` (string(movie,tv,music)|null): MoviePilot media type used to filter recommendations or rule groups. Agent-facing values: movie,tv,music. Use this exact field name in its documented bucket.; `name*` (string): Human-readable name of the site, storage item, subscription, or rule group.; `rule_string*` (string): Ordered filter-rule expression stored in the group.
|
||||
|
||||
### `filter.group.delete`
|
||||
`DELETE /api/v1/rule/groups/{name}`; policy effect: `destructive_write`.
|
||||
@@ -66,7 +66,7 @@ Purpose: Update or rename one named filter-rule group.
|
||||
Identity scope: `admin`; required role: `system_admin`.
|
||||
- `path_params`: `name*` (string): Human-readable name of the site, storage item, subscription, or rule group.
|
||||
- `query`: none
|
||||
- `body`: `category` (string|null): MoviePilot media category or filter-group category, depending on the operation.; `media_type` (string|null): MoviePilot media type used to filter recommendations or rule groups.; `new_name` (string|null): Replacement name for the existing filter-rule group.; `rule_string` (string|null): Ordered filter-rule expression stored in the group.
|
||||
- `body`: `category` (string|null): MoviePilot media category or filter-group category, depending on the operation.; `media_type` (string(movie,tv,music)|null): MoviePilot media type used to filter recommendations or rule groups. Agent-facing values: movie,tv,music. Use this exact field name in its documented bucket.; `new_name` (string|null): Replacement name for the existing filter-rule group.; `rule_string` (string|null): Ordered filter-rule expression stored in the group.
|
||||
|
||||
### `filter.groups`
|
||||
`GET /api/v1/rule/groups`; policy effect: `safe_read`.
|
||||
|
||||
@@ -11,6 +11,7 @@ Identity scope: `bound_user`; required role: `user`.
|
||||
- `path_params`: none
|
||||
- `query`: `media_id` (string|null): Source-native media ID. Always pair it with the exact media_source returned by search.; `media_source` (MediaSource|null): Metadata source identifier. Preserve the exact value returned with media_id.; `mtype` (string|null): MoviePilot media type or subscription-history category required by the operation.; `season` (integer|null): Season number used by the media, search, subscription, or transfer operation.; `title` (string|null): Media, torrent, subscription, or history title used by the operation.; `year` (string|null): Release or premiere year used to disambiguate the media title.
|
||||
- `body`: none
|
||||
- **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`.
|
||||
|
||||
### `library.latest`
|
||||
`GET /api/v1/mediaserver/latest`; policy effect: `safe_read`.
|
||||
|
||||
@@ -182,6 +182,7 @@ Identity scope: `bound_user`; required role: `user`.
|
||||
- `path_params`: none
|
||||
- `query`: `custom_words` (string|null): Custom recognition or rename words applied to this media workflow.; `media_source` (MediaSource|null): Metadata source identifier. Preserve the exact value returned with media_id.; `subtitle` (string|null): Optional subtitle text used together with title during media recognition.; `title*` (string): Media, torrent, subscription, or history title used by the operation.
|
||||
- `body`: none
|
||||
- **Agent note:** Recognition returns a context containing `media_info`, not a candidate list. 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`.
|
||||
|
||||
### `media.recognize_file`
|
||||
`GET /api/v1/media/recognize_file`; policy effect: `safe_read`.
|
||||
|
||||
@@ -10,7 +10,7 @@ Purpose: Read public media or music charts and recommendations.
|
||||
Identity scope: `public`; required role: `user`.
|
||||
- `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.
|
||||
- `path_params`: none
|
||||
- `query`: `days` (integer; default `14`): Recommendation time window in days.; `fresh_sort` (string; default `release_date`): Freshness ordering used by the recommendation source.; `future` (boolean; default `True`): Include future recommendation periods when supported.; `media_type` (string; default `all`): MoviePilot media type used to filter recommendations or rule groups.; `min_listen_count` (integer; default `0`): Minimum listen count required for a music recommendation.; `music_type` (string|null): Music identity level: recording, album, or artist where supported.; `page` (integer; default `1`): One-based result page number.; `past` (boolean; default `True`): Include past recommendation periods when supported.; `range_name` (string; default `this_month`): Named recommendation time range.; `sort_by` (string; default `listen_count.desc`): Recommendation field used for ordering results.; `source` (string; default `tmdb_trending`): Exact metadata or recommendation source selected by the operation.; `with_cover` (boolean; default `False`): Require recommendation results to include cover artwork.
|
||||
- `query`: `days` (integer; default `14`): Recommendation time window in days.; `fresh_sort` (string; default `release_date`): Freshness ordering used by the recommendation source.; `future` (boolean; default `True`): Include future recommendation periods when supported.; `media_type` (string(all,movie,tv,music); default `all`): MoviePilot media type used to filter recommendations or rule groups. Agent-facing values: all,movie,tv,music. Use this exact field name in its documented bucket.; `min_listen_count` (integer; default `0`): Minimum listen count required for a music recommendation.; `music_type` (string|null): Music identity level: recording, album, or artist where supported.; `page` (integer; default `1`): One-based result page number.; `past` (boolean; default `True`): Include past recommendation periods when supported.; `range_name` (string; default `this_month`): Named recommendation time range.; `sort_by` (string; default `listen_count.desc`): Recommendation field used for ordering results.; `source` (string; default `tmdb_trending`): Exact metadata or recommendation source selected by the operation.; `with_cover` (boolean; default `False`): Require recommendation results to include cover artwork.
|
||||
- `body`: none
|
||||
|
||||
## Body Models
|
||||
|
||||
@@ -26,7 +26,7 @@ Purpose: Search torrent sites directly from a free-form title and optional media
|
||||
Identity scope: `bound_user`; required role: `user`.
|
||||
- `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.
|
||||
- `path_params`: none
|
||||
- `query`: `keyword` (string|null): Case-insensitive substring used to discover settings or filter storage entries.; `mtype` (string|null): MoviePilot media type or subscription-history category required by the operation.; `page` (integer|null; default `0`): One-based result page number.; `sites` (string|null): Exact site IDs included in the search or subscription scope.
|
||||
- `query`: `keyword` (string|null): Case-insensitive substring used to discover settings or filter storage entries.; `mtype` (string(movie,tv,music)|null): MoviePilot media type or subscription-history category required by the operation. Agent-facing values: movie,tv,music. Use this exact field name in its documented bucket.; `page` (integer|null; default `0`): One-based result page number.; `sites` (string|null): Exact site IDs included in the search or subscription scope.
|
||||
- `body`: none
|
||||
|
||||
### `search.torrents`
|
||||
@@ -36,8 +36,16 @@ Identity scope: `bound_user`; required role: `user`.
|
||||
- `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.
|
||||
- `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.
|
||||
- `path_params`: `media_id*` (string): Source-native media ID. Always pair it with the exact media_source returned by search.
|
||||
- `query`: `area` (string|null; default `title`): Optional region filter applied by the torrent search workflow.; `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `include_candidates` (boolean; default `False`): Include unconfirmed music resources and related albums for manual review. Defaults to false; candidates have no target media identity and must not be used for automatic download.; `media_source*` (MediaSource): Metadata source identifier. Preserve the exact value returned with media_id.; `mtype` (string|null): MoviePilot media type or subscription-history category required by the operation.; `music_type` (string|null): Music identity level: recording, album, or artist where supported.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.; `season` (string|null): Season number used by the media, search, subscription, or transfer operation.; `sites` (string|null): Exact site IDs included in the search or subscription scope.
|
||||
- `query`: `area` (string|null; default `title`): Optional region filter applied by the torrent search workflow.; `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `include_candidates` (boolean; default `False`): Include unconfirmed music resources and related albums for manual review. Defaults to false; candidates have no target media identity and must not be used for automatic download.; `media_source*` (MediaSource): Metadata source identifier. Preserve the exact value returned with media_id.; `mtype` (string(movie,tv,music)|null): MoviePilot media type or subscription-history category required by the operation. Agent-facing values: movie,tv,music. Use this exact field name in its documented bucket.; `music_type` (string|null): Music identity level: recording, album, or artist where supported.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.; `season` (string|null): Season number used by the media, search, subscription, or transfer operation.; `sites` (string|null): Exact site IDs included in the search or subscription scope.
|
||||
- `body`: none
|
||||
- **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.
|
||||
|
||||
## Body Models
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ Purpose: Browse torrent resources from one configured site with category and key
|
||||
Identity scope: `admin`; required role: `system_admin`.
|
||||
- `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.
|
||||
- `path_params`: `site_id*` (integer): Persistent site ID returned by site.list.
|
||||
- `query`: `cat` (string|null): Exact site category identifier returned by site.category.; `keyword` (string|null): Case-insensitive substring used to discover settings or filter storage entries.; `mtype` (string|null): MoviePilot media type or subscription-history category required by the operation.; `page` (integer|null; default `0`): One-based result page number.
|
||||
- `query`: `cat` (string|null): Exact site category identifier returned by site.category.; `keyword` (string|null): Case-insensitive substring used to discover settings or filter storage entries.; `mtype` (string(movie,tv,music)|null): MoviePilot media type or subscription-history category required by the operation. Agent-facing values: movie,tv,music. Use this exact field name in its documented bucket.; `page` (integer|null; default `0`): One-based result page number.
|
||||
- `body`: none
|
||||
|
||||
### `site.rss`
|
||||
@@ -125,9 +125,10 @@ Identity scope: `admin`; required role: `system_admin`.
|
||||
Purpose: List active configured sites supporting one exact media type.
|
||||
Identity scope: `admin`; required role: `system_admin`.
|
||||
- `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.
|
||||
- `path_params`: `media_type*` (string): MoviePilot media type used to filter recommendations or rule groups.
|
||||
- `path_params`: `media_type*` (string(movie,tv,music)): MoviePilot media type used to filter recommendations or rule groups. Agent-facing values: movie,tv,music. Use this exact field name in its documented bucket.
|
||||
- `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.
|
||||
- `body`: none
|
||||
- **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`.
|
||||
|
||||
### `site.statistic`
|
||||
`GET /api/v1/site/statistic/{site_url}`; policy effect: `safe_read`.
|
||||
|
||||
@@ -10,7 +10,7 @@ Purpose: Search subtitle providers for one canonical media identity and optional
|
||||
Identity scope: `bound_user`; required role: `user`.
|
||||
- `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.
|
||||
- `path_params`: `media_id*` (string): Source-native media ID. Always pair it with the exact media_source returned by search.
|
||||
- `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `episode` (string|null): Episode number used to narrow a subtitle or media search.; `media_source*` (MediaSource): Metadata source identifier. Preserve the exact value returned with media_id.; `mtype` (string|null): MoviePilot media type or subscription-history category required by the operation.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.; `season` (string|null): Season number used by the media, search, subscription, or transfer operation.; `sites` (string|null): Exact site IDs included in the search or subscription scope.
|
||||
- `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `episode` (string|null): Episode number used to narrow a subtitle or media search.; `media_source*` (MediaSource): Metadata source identifier. Preserve the exact value returned with media_id.; `mtype` (string(movie,tv,music)|null): MoviePilot media type or subscription-history category required by the operation. Agent-facing values: movie,tv,music. Use this exact field name in its documented bucket.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.; `season` (string|null): Season number used by the media, search, subscription, or transfer operation.; `sites` (string|null): Exact site IDs included in the search or subscription scope.
|
||||
- `body`: none
|
||||
|
||||
### `subtitle.search.title`
|
||||
|
||||
84
tests/test_agent_skill_docs_generator.py
Normal file
84
tests/test_agent_skill_docs_generator.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""验证技能文档生成器保留操作契约并输出可重复的英文文档。"""
|
||||
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts import generate_agent_skill_docs as generator
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def api_categories() -> dict[str, str]:
|
||||
"""从真实 MCP 元数据生成分类文档,供契约断言复用。"""
|
||||
categories, _, _ = generator._split_api_docs(generator._render_api_docs())
|
||||
return categories
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("category", "operation", "field"),
|
||||
[
|
||||
("search", "search.torrents", "mtype"),
|
||||
("site", "site.searchable", "media_type*"),
|
||||
("site", "site.resource", "mtype"),
|
||||
("filter", "filter.group.add", "media_type"),
|
||||
("subtitle", "subtitle.search.media", "mtype"),
|
||||
],
|
||||
)
|
||||
def test_agent_media_keys_are_scoped_to_supported_operations(
|
||||
api_categories: dict[str, str], category: str, operation: str, field: str
|
||||
) -> None:
|
||||
"""仅在接口接受 Agent 媒体键时显示英文取值。"""
|
||||
section = api_categories[category].split(f"### `{operation}`\n", 1)[1].split("\n### `", 1)[0]
|
||||
assert f"`{field}` (string(movie,tv,music)" in section
|
||||
|
||||
|
||||
def test_distinct_media_type_contracts_are_preserved(api_categories: dict[str, str]) -> None:
|
||||
"""推荐的 all、订阅枚举和数据库过滤字段不得被同名字段覆盖。"""
|
||||
assert "`media_type` (string(all,movie,tv,music); default `all`)" in api_categories["recommendation"]
|
||||
assert "`mtype` (MediaType|null)" in api_categories["subscription"]
|
||||
assert "`mtype*` (string):" in api_categories["subscription"]
|
||||
assert "`mtype` (string|null):" in api_categories["library"]
|
||||
assert "Agent-facing values" not in api_categories["subscription"]
|
||||
assert "Agent-facing values" not in api_categories["library"]
|
||||
|
||||
|
||||
def test_regeneration_retains_operation_guidance(api_categories: dict[str, str]) -> None:
|
||||
"""生成器必须保留无法从原始 schema 得出的业务和超时说明。"""
|
||||
assert "Music uses exact identity only." in api_categories["subscription"]
|
||||
assert "paused subscription without enabling it" in api_categories["subscription"]
|
||||
assert "Omitted fields keep their stored values." in api_categories["subscription"]
|
||||
assert "MUSIC_METADATA_TO_SIMPLIFIED" in api_categories["music"]
|
||||
assert "290 seconds" in api_categories["search"]
|
||||
assert "300 seconds" in api_categories["search"]
|
||||
assert "600 seconds by default" in api_categories["search"]
|
||||
assert "context containing `media_info`, not a candidate list" in api_categories["media"]
|
||||
assert "unavailable source does not prove there are no resources" in api_categories["search"]
|
||||
|
||||
|
||||
def test_full_regeneration_is_english_current_and_idempotent(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""在临时目录重建全部技能,验证已提交产物同步且重复执行无变化。"""
|
||||
source_root = generator.PROJECT_ROOT
|
||||
shutil.copytree(source_root / "skills", tmp_path / "skills")
|
||||
schema_path = Path("app/agent/policy/resources/api_mcp_schema.json")
|
||||
(tmp_path / schema_path).parent.mkdir(parents=True)
|
||||
shutil.copyfile(source_root / schema_path, tmp_path / schema_path)
|
||||
monkeypatch.setattr(generator, "PROJECT_ROOT", tmp_path)
|
||||
expected = {
|
||||
path.relative_to(tmp_path): path.read_bytes()
|
||||
for path in (tmp_path / "skills").rglob("*.md")
|
||||
}
|
||||
|
||||
for _ in range(2):
|
||||
assert generator.main() == 0
|
||||
actual = {
|
||||
path.relative_to(tmp_path): path.read_bytes()
|
||||
for path in (tmp_path / "skills").rglob("*.md")
|
||||
}
|
||||
assert actual == expected
|
||||
for skill in ("moviepilot-api", "downloader-operation", "mediaserver-operation", "database-operation"):
|
||||
for path in (tmp_path / "skills" / skill).rglob("*.md"):
|
||||
assert not re.search(r"[\u3400-\u9fff]", path.read_text(encoding="utf-8")), path
|
||||
@@ -115,9 +115,9 @@ def test_modified_builtin_skills_have_incremented_versions() -> None:
|
||||
expected_versions = {
|
||||
"browser-use": "4",
|
||||
"command-dispatch": "2",
|
||||
"database-operation": "10",
|
||||
"database-operation": "11",
|
||||
"feedback-issue": "11",
|
||||
"moviepilot-api": "54",
|
||||
"moviepilot-api": "55",
|
||||
"moviepilot-update": "7",
|
||||
"organize-files": "5",
|
||||
"transfer-failed-retry": "5",
|
||||
|
||||
Reference in New Issue
Block a user