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:
pyss56
2026-10-14 11:39:24 +08:00
committed by GitHub
parent 13a58be2a6
commit b18833f912
12 changed files with 258 additions and 23 deletions

View File

@@ -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",