mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-02 21:34:32 +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",
|
||||
|
||||
Reference in New Issue
Block a user