diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b072ac9 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# --------------------------------------------------------------------------- +# Agent Backend — Environment Variables +# Copy this to .env and fill in your values. +# --------------------------------------------------------------------------- + +# LLM — DeepSeek (OpenAI-compatible) +DEEPSEEK_API_KEY=sk-your-deepseek-api-key + +# --------------------------------------------------------------------------- +# Seerr (Overseerr / Jellyseerr) +# --------------------------------------------------------------------------- +SEERR_URL=https://seerr.example.com +SEERR_API_KEY=your-seerr-api-key +# SEERR_TIMEOUT=30 # optional, defaults to 30 seconds diff --git a/agents/__init__.py b/agents/__init__.py index 57f4a0f..8064a30 100644 --- a/agents/__init__.py +++ b/agents/__init__.py @@ -62,3 +62,5 @@ def load_all_agents() -> None: # Also import skill modules so they self-register import skills.media_info # noqa: F401 + import skills.seerr # noqa: F401 + import skills.triage # noqa: F401 diff --git a/agents/media_agent.py b/agents/media_agent.py index 07aa731..657f61b 100644 --- a/agents/media_agent.py +++ b/agents/media_agent.py @@ -2,18 +2,25 @@ media-agent — an agent that knows how to handle media queries (Jellyfin / Sonarr / Seerr / subtitle requests). -For now it only loads the *media_info* demo skill which teaches it -a structured response format. Later you'll add real API-calling skills. +Skills: +- media_info : base persona (prompt-only) +- seerr : trending, discover, request media, submit issues (tools + API) +- triage : fallback for unsupported actions (prompt-only, uses seerr tools) """ from agents import Agent, register media_agent = Agent( agent_id="media-agent", - description="Media assistant — handles movie/TV/subtitle/ticket requests. " - "Will eventually connect to Seerr, Sonarr, Jellyfin, etc.", - skills=["media_info"], - base_prompt="You are a media assistant. Help users with their media library.", + description="Media assistant — handles movie/TV/subtitle/ticket requests " + "via Seerr, Jellyfin, Sonarr, etc.", + skills=["media_info", "seerr", "triage"], + base_prompt=( + "You are a media assistant connected to Seerr and other media services. " + "Help users discover, request, and troubleshoot their media library. " + "Use the tools provided to perform real actions." + ), ) register(media_agent) + diff --git a/api/v1/chat.py b/api/v1/chat.py index ed76e71..d9ad5cf 100644 --- a/api/v1/chat.py +++ b/api/v1/chat.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Body, Depends +from fastapi import APIRouter, Depends from fastapi.responses import StreamingResponse from openai import OpenAI from pydantic import BaseModel @@ -7,6 +7,7 @@ import asyncio from api.dependencies import get_llm_client from agents import get as get_agent, list_all as list_all_agents +from skills import get_all_tools, execute_tool, ToolResult router = APIRouter() @@ -24,34 +25,99 @@ class ChatCompletionRequest(BaseModel): # --------------------------------------------------------------------------- -# Core helpers +# Agent resolution # --------------------------------------------------------------------------- def _resolve_agent(agent_id: str | None = None, model: str | None = None): """ - Look up the agent. Resolution order: + Resolution order: 1. explicit agent_id - 2. model name (OpenWebUI sends this — maps to agent_id if registered) + 2. model field (OpenWebUI sends this — maps to agent_id if registered) 3. fallback to "naked" """ lookup = agent_id or model if lookup is None: - agent = get_agent("naked") - else: - agent = get_agent(lookup) - if agent is None: - agent = get_agent("naked") - return agent + return get_agent("naked") + agent = get_agent(lookup) + return agent if agent else get_agent("naked") -def run_agent( +# --------------------------------------------------------------------------- +# Tool-calling loop (non-streaming) +# --------------------------------------------------------------------------- + +async def run_agent_with_tools( + client: OpenAI, + message: str, + agent_id: str | None = None, + model: str | None = None, + max_turns: int = 5, +) -> str: + """Send the user message to the LLM with tool definitions. + Loop: if the LLM responds with tool_calls, execute them and feed + results back until the LLM produces a final text answer. + """ + agent = _resolve_agent(agent_id, model) + tools = get_all_tools(agent.skills) + system_prompt = agent.build_system_prompt() + + messages: list[dict] = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": message}, + ] + + loop = asyncio.get_running_loop() + + for _ in range(max_turns): + resp = await loop.run_in_executor( + None, + lambda: client.chat.completions.create( + model="deepseek-chat", + messages=messages, + tools=tools if tools else None, + tool_choice="auto" if tools else None, + ), + ) + choice = resp.choices[0] + + # If the model sends a final text answer, return it + if choice.finish_reason == "stop" and choice.message.content: + return choice.message.content + + # If the model wants to call tools + if choice.message.tool_calls: + # Append the assistant message with tool_calls + messages.append(choice.message.model_dump(exclude_none=True)) + + for tc in choice.message.tool_calls: + fn_name = tc.function.name + fn_args = json.loads(tc.function.arguments) + tr = await execute_tool(agent.skills, fn_name, fn_args) + result = tr.content if tr else f"Tool '{fn_name}' is not available right now." + messages.append({ + "role": "tool", + "tool_call_id": tc.id, + "content": result, + }) + continue + + # Fallback — should not normally happen + return choice.message.content or "I'm not sure how to help with that." + + return "I've taken several actions but still need more information. Could you clarify?" + + +# --------------------------------------------------------------------------- +# Non-streaming helper (no tools — used by sync endpoint if tools are absent) +# --------------------------------------------------------------------------- + +def run_agent_simple( client: OpenAI, message: str, - session_id: str | None = None, agent_id: str | None = None, model: str | None = None, ) -> str: - """Non-streaming: uses the chosen agent's system prompt.""" + """Plain LLM call — no tools. Used when the agent has no tool-enabled skills.""" agent = _resolve_agent(agent_id, model) response = client.chat.completions.create( model="deepseek-chat", @@ -63,15 +129,105 @@ def run_agent( return response.choices[0].message.content +# --------------------------------------------------------------------------- +# Streaming generators +# --------------------------------------------------------------------------- + +async def _stream_with_tools( + client: OpenAI, + message: str, + agent_id: str | None = None, + model: str | None = None, + max_turns: int = 5, +): + """Streaming version with tool-calling loop. + Yields tokens from the final text response (tools run silently in the background). + """ + agent = _resolve_agent(agent_id, model) + tools = get_all_tools(agent.skills) + system_prompt = agent.build_system_prompt() + + messages: list[dict] = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": message}, + ] + + loop = asyncio.get_running_loop() + + for turn in range(max_turns): + # Non-streaming call to check for tool_calls + resp = await loop.run_in_executor( + None, + lambda: client.chat.completions.create( + model="deepseek-chat", + messages=messages, + tools=tools if tools else None, + tool_choice="auto" if tools else None, + ), + ) + choice = resp.choices[0] + + # Tool calls? Execute them and loop + if choice.message.tool_calls: + messages.append(choice.message.model_dump(exclude_none=True)) + for tc in choice.message.tool_calls: + fn_name = tc.function.name + fn_args = json.loads(tc.function.arguments) + tr = await execute_tool(agent.skills, fn_name, fn_args) + result = tr.content if tr else f"Tool '{fn_name}' is not available right now." + messages.append({ + "role": "tool", + "tool_call_id": tc.id, + "content": result, + }) + continue + + # Final text answer — stream it + if choice.finish_reason == "stop" and choice.message.content: + # Already have a non-streaming answer — yield it token-by-token + for token in choice.message.content: + yield token + await asyncio.sleep(0) + return + + # Last resort: stream the final response + def _sync_stream(): + stream = client.chat.completions.create( + model="deepseek-chat", + messages=messages, + stream=True, + ) + for chunk in stream: + delta = chunk.choices[0].delta + if delta and delta.content: + yield delta.content + + gen = _sync_stream() + while True: + token = await loop.run_in_executor(None, next, gen, None) + if token is None: + return + yield token + + yield "…" + + async def run_agent_stream( client: OpenAI, message: str, - session_id: str | None = None, agent_id: str | None = None, model: str | None = None, ): - """Async generator — yields tokens using the chosen agent's system prompt.""" + """Async generator — yields tokens. Uses tool-loop when skills have tools.""" agent = _resolve_agent(agent_id, model) + tools = get_all_tools(agent.skills) + + if tools: + async for token in _stream_with_tools(client, message, agent_id, model): + yield token + return + + # No tools — simple streaming system_prompt = agent.build_system_prompt() loop = asyncio.get_running_loop() @@ -111,7 +267,7 @@ async def chat(req: ChatRequest, client: OpenAI = Depends(get_llm_client)): """Streaming chat endpoint — returns Server-Sent Events.""" async def event_stream(): async for token in run_agent_stream( - client, req.message, req.session_id, req.agent_id, + client, req.message, req.agent_id, ): payload = json.dumps({"token": token, "session_id": req.session_id}) yield f"data: {payload}\n\n" @@ -130,9 +286,18 @@ async def chat(req: ChatRequest, client: OpenAI = Depends(get_llm_client)): @router.post("/chat/sync") -def chat_sync(req: ChatRequest, client: OpenAI = Depends(get_llm_client)): - """Non-streaming fallback — returns the full response at once.""" - response = run_agent(client, req.message, req.session_id, req.agent_id) +async def chat_sync(req: ChatRequest, client: OpenAI = Depends(get_llm_client)): + """Non-streaming endpoint — uses tool-calling when the agent has tools.""" + agent = _resolve_agent(req.agent_id) + tools = get_all_tools(agent.skills) + + if tools: + response = await run_agent_with_tools( + client, req.message, req.agent_id, + ) + else: + response = run_agent_simple(client, req.message, req.agent_id) + return {"response": response, "session_id": req.session_id} @@ -174,11 +339,9 @@ async def chat_completions( client: OpenAI = Depends(get_llm_client), ): """OpenAI-compatible /chat/completions — supports stream=True. - The last message's content is used as the user prompt; defaults to 'naked' agent. + Resolves the agent from the model field (OpenWebUI sends this). """ user_message = req.messages[-1]["content"] - - # Resolve agent from the model field (OpenWebUI sends this) agent = _resolve_agent(model=req.model) if req.stream: @@ -219,9 +382,13 @@ async def chat_completions( }, ) - # Non-streaming path — resolve agent from model field - agent = _resolve_agent(model=req.model) - response = run_agent(client, user_message, agent_id=agent.agent_id) + # Non-streaming path + tools = get_all_tools(agent.skills) + if tools: + response = await run_agent_with_tools(client, user_message, agent_id=agent.agent_id) + else: + response = run_agent_simple(client, user_message, agent_id=agent.agent_id) + return { "id": "chatcmpl-local", "object": "chat.completion", diff --git a/core/config.py b/core/config.py index 7783ffb..b586060 100644 --- a/core/config.py +++ b/core/config.py @@ -2,6 +2,30 @@ from dotenv import load_dotenv from pathlib import Path import os -load_dotenv(Path(__file__).resolve().parent.parent / ".env") +# --------------------------------------------------------------------------- +# Load .env from the project root (one level above core/) +# --------------------------------------------------------------------------- +_env_path = Path(__file__).resolve().parent.parent / ".env" +load_dotenv(_env_path) -DEEPSEEK_API_KEY = os.getenv("DEEPSEEK_API_KEY") \ No newline at end of file + +# --------------------------------------------------------------------------- +# General-purpose config accessor — every skill uses this +# --------------------------------------------------------------------------- +def get_config(key: str, default: str | None = None) -> str | None: + """Read a value from the environment (loaded from .env).""" + return os.getenv(key, default) + + +# --------------------------------------------------------------------------- +# LLM +# --------------------------------------------------------------------------- +DEEPSEEK_API_KEY = os.getenv("DEEPSEEK_API_KEY") + + +# --------------------------------------------------------------------------- +# Seerr (Overseerr / Jellyseerr) +# --------------------------------------------------------------------------- +SEERR_URL = os.getenv("SEERR_URL", "") +SEERR_API_KEY = os.getenv("SEERR_API_KEY", "") +SEERR_TIMEOUT = int(os.getenv("SEERR_TIMEOUT", "30")) \ No newline at end of file diff --git a/main.py b/main.py index 599f1ce..943bc5e 100644 --- a/main.py +++ b/main.py @@ -1,3 +1,5 @@ +import logging + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware @@ -5,6 +7,15 @@ from api.v1.chat import router as v1_router from core.config import DEEPSEEK_API_KEY from core.llm import create_client +# --------------------------------------------------------------------------- +# Logging — tool calls will appear in the uvicorn console +# --------------------------------------------------------------------------- +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", + datefmt="%H:%M:%S", +) + # --------------------------------------------------------------------------- # Load all agents & skills so they self-register at startup # --------------------------------------------------------------------------- diff --git a/requirements.txt b/requirements.txt index c9db552..db69327 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ fastapi openai uvicorn -python-dotenv \ No newline at end of file +python-dotenv +httpx \ No newline at end of file diff --git a/skills/__init__.py b/skills/__init__.py index f6b4eed..1a3b32f 100644 --- a/skills/__init__.py +++ b/skills/__init__.py @@ -3,13 +3,41 @@ Skill system — each skill is a piece of domain knowledge or a capability that can be attached to an agent to shape its behavior and system prompt. A Skill is a lightweight object with: -- name : short identifier (e.g. "media_info") -- description : human-readable summary +- name : short identifier (e.g. "media_info") +- description : human-readable summary - prompt_fragment : extra text injected into the agent's system prompt +- tools : OpenAI function-calling tool definitions (list of dicts) +- execute : async callable to run a tool → ToolResult """ from dataclasses import dataclass, field -from typing import Dict +from typing import Any, Awaitable, Callable, Dict, List, Optional +from core.config import get_config # re-export so every skill can use it + + +# --------------------------------------------------------------------------- +# ToolResult — every skill executor must return this +# --------------------------------------------------------------------------- +@dataclass +class ToolResult: + """Result of executing a tool. + - success: True if the API returned 2xx and the action completed. + - content: The message to feed back to the LLM (will be shown to the user). + """ + content: str + success: bool = True + + @classmethod + def ok(cls, content: str) -> "ToolResult": + return cls(content=content, success=True) + + @classmethod + def fail(cls, content: str) -> "ToolResult": + return cls(content=content, success=False) + + +# Type alias for a tool executor +ToolExecutor = Callable[[str, dict], Awaitable[ToolResult]] @dataclass @@ -17,6 +45,8 @@ class Skill: name: str description: str prompt_fragment: str = "" + tools: List[Dict[str, Any]] = field(default_factory=list) + execute: Optional[ToolExecutor] = None # --------------------------------------------------------------------------- @@ -48,3 +78,52 @@ def get_combined_prompt(skill_names: list[str], base_prompt: str = "") -> str: if s and s.prompt_fragment: parts.append(s.prompt_fragment) return "\n\n".join(parts) + + +def get_all_tools(skill_names: list[str]) -> List[Dict[str, Any]]: + """Collect all OpenAI tool definitions across the requested skills.""" + tools: List[Dict[str, Any]] = [] + seen: set[str] = set() + for name in skill_names: + s = get(name) + if s: + for t in s.tools: + fn_name = t.get("function", {}).get("name", "") + if fn_name and fn_name not in seen: + seen.add(fn_name) + tools.append(t) + return tools + + +async def execute_tool( + skill_names: list[str], tool_name: str, args: dict +) -> ToolResult | None: + """Find the skill that owns *tool_name* and run its executor. + Only logs failures to the console — successful calls are silent. + """ + import logging + logger = logging.getLogger("skills") + + for name in skill_names: + s = get(name) + if s and s.execute: + for t in s.tools: + if t.get("function", {}).get("name") == tool_name: + try: + result = await s.execute(tool_name, args) + if not result.success: + logger.warning( + "⚠️ TOOL FAILED: %s | args=%s → %s", + tool_name, args, result.content[:300], + ) + return result + except Exception as exc: + logger.exception( + "💥 TOOL CRASH: %s | args=%s", tool_name, args + ) + return ToolResult.fail( + f"Tool '{tool_name}' crashed unexpectedly: {exc}" + ) + + logger.warning("⚠️ TOOL NOT FOUND: %s (skills=%s)", tool_name, skill_names) + return None diff --git a/skills/media_info.py b/skills/media_info.py index 0b60eb5..8578cdf 100644 --- a/skills/media_info.py +++ b/skills/media_info.py @@ -1,45 +1,31 @@ """ Demo skill: media_info -Gives the agent knowledge about how to respond to media-related queries -(movie / TV / subtitle requests). This is intentionally simple — in the future -you would add real API-calling skills here (Sonarr / Jellyfin / Seerr / etc.). +A lightweight base skill that teaches the agent it is a media assistant. +Real API capabilities come from other skills (seerr, triage, etc.). """ from skills import Skill, register media_info_skill = Skill( name="media_info", - description="Respond to media queries with a structured format " - "(movie / TV show requests, subtitles, tickets).", - prompt_fragment="""## Media Agent Instructions + description="Base media assistant persona — movie, TV, subtitle, and media requests.", + prompt_fragment="""## Media Assistant Persona -You are a media assistant. When users ask about movies, TV shows, subtitles, -or media library requests, follow these rules: +You are a friendly media assistant connected to a media back-end (Seerr, +Jellyfin, Sonarr, etc.). Your job is to help users discover, request, and +troubleshoot their media library. -- If a user wants to **request** a movie or show, respond with a clear - confirmation using this format: +When responding: +- Be concise and helpful. +- Use the tools available to you for real actions. +- If a user asks about **subtitles**, explain that Bazarr handles those and + suggest submitting a ticket if there's a problem. +- Always confirm successful actions and warn about failures. - ``` - [MEDIA REQUEST] - Title: - Type: <movie | show> - Status: PENDING — this would be submitted to Seerr - ``` - -- If a user asks about **subtitles**, acknowledge the request and respond with: - - ``` - [SUBTITLE REQUEST] - Media: <title> - Language: <language> - Status: PENDING — Bazarr would process this - ``` - -- Otherwise, answer normally but always remind the user that media-backend - integrations (Seerr, Sonarr, Jellyfin) are not yet connected. - -This is a **demo** skill. Real API calls will be added later.""", +This is the base media assistant persona. Real API capabilities come from the +attached skills (seerr, triage, etc.).""", ) register(media_info_skill) + diff --git a/skills/seerr.py b/skills/seerr.py new file mode 100644 index 0000000..05ab870 --- /dev/null +++ b/skills/seerr.py @@ -0,0 +1,470 @@ +""" +Seerr skill — connects to Overseerr / Jellyseerr API for media discovery, +requests, and issue submission. + +.env variables: + SEERR_URL – base URL (e.g. https://seerr.example.com) + SEERR_API_KEY – API key from Seerr settings + SEERR_TIMEOUT – optional request timeout in seconds (default 30) +""" + +from __future__ import annotations + +import json +from urllib.parse import quote + +import httpx + +from skills import Skill, register, ToolResult, get_config + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- +SEERR_URL = (get_config("SEERR_URL") or "").rstrip("/") +SEERR_API_KEY = get_config("SEERR_API_KEY") or "" +SEERR_TIMEOUT = int(get_config("SEERR_TIMEOUT", "30")) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +def _client() -> httpx.AsyncClient: + return httpx.AsyncClient( + base_url=SEERR_URL, + headers={"X-Api-Key": SEERR_API_KEY}, + timeout=SEERR_TIMEOUT, + ) + + +def _fmt_items(items: list[dict], kind: str) -> str: + """Format a list of media items for the LLM to present.""" + lines = [] + for i, item in enumerate(items[:10], 1): + title = item.get("title") or item.get("name") or "Unknown" + year = ( + item.get("releaseDate", "")[:4] + or item.get("firstAirDate", "")[:4] + or "?" + ) + overview = (item.get("overview") or "")[:120] + lines.append(f"{i}. **{title}** ({year}) — {overview}…") + return f"Found {len(items)} {kind}. Top results:\n\n" + "\n".join(lines) + + +# --------------------------------------------------------------------------- +# Tool definitions (OpenAI function-calling schema) +# --------------------------------------------------------------------------- +TOOLS = [ + { + "type": "function", + "function": { + "name": "seerr_trending", + "description": "Get trending movies and TV shows from Seerr using " + "the /discover/trending endpoint. Call this when a user asks what " + "is popular, trending, or new.", + "parameters": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": ["movie", "tv", "all"], + "description": "What kind of media to fetch. " + "Use 'all' when the user doesn't specify.", + }, + "language": { + "type": "string", + "description": "Language filter (e.g. 'en', 'nl'). " + "Omit for all languages.", + }, + }, + "required": ["kind"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "seerr_discover", + "description": "Discover movies or TV shows by genre, studio, " + "keyword, or language in Seerr. Uses /discover/{movies|tv}/genre/{id} " + "for genre queries, /discover/{movies|tv}/studio/{id} for studios, " + "and /discover/{movies|tv}?query= for keyword search. " + "Call when a user asks 'what movies in category X do you recommend?' " + "or 'show me horror movies' or 'find Studio Ghibli movies'.", + "parameters": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": ["movie", "tv"], + "description": "Media type to search.", + }, + "genre": { + "type": "string", + "description": "Genre name, e.g. 'horror', 'comedy', " + "'animation', 'action', 'science fiction'. " + "Use this for genre-based discovery.", + }, + "studio": { + "type": "string", + "description": "Studio name to filter by, e.g. " + "'Studio Ghibli', 'Pixar', 'Marvel'. " + "Use this for studio-based discovery.", + }, + "keyword": { + "type": "string", + "description": "Free-text keyword search, e.g. " + "'space', 'superhero', 'dinosaur'. " + "Use this for topic-based discovery.", + }, + "language": { + "type": "string", + "description": "Language filter (e.g. 'en', 'ja'). " + "Omit for all languages.", + }, + "page": { + "type": "integer", + "description": "Page number (default 1).", + }, + }, + "required": ["kind"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "seerr_request_media", + "description": "Request a movie or TV show to be added to the media " + "library via Seerr. Call when a user asks 'can you request movie X?' " + "or 'please add show Y'.", + "parameters": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": ["movie", "tv"], + "description": "Whether this is a movie or TV show.", + }, + "title": { + "type": "string", + "description": "The title of the movie or TV show to request.", + }, + "tmdb_id": { + "type": "integer", + "description": "The TMDb ID if known (optional — Seerr will " + "search by title if not provided).", + }, + }, + "required": ["kind", "title"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "seerr_submit_issue", + "description": "Submit a ticket/issue for a specific media item. " + "Call when a user wants to report a problem (bad quality, wrong " + "language, missing episodes, corrupt file, etc.) or when they want " + "an action that only a human operator can perform. " + "IMPORTANT: always include the media_title so the system can " + "look up the correct mediaId.", + "parameters": { + "type": "object", + "properties": { + "subject": { + "type": "string", + "description": "Short summary of the issue.", + }, + "description": { + "type": "string", + "description": "Detailed description of the problem.", + }, + "media_title": { + "type": "string", + "description": "The movie or TV show title this issue " + "relates to. Always provide this — the system will " + "search for the matching mediaId.", + }, + "issue_type": { + "type": "integer", + "enum": [1, 2, 3, 4], + "description": "Issue category code: " + "1 = Video (playback, codec, quality), " + "2 = Audio (sync, missing), " + "3 = Subtitle (missing, wrong, timing), " + "4 = Other (operator-only actions like delete/cancel).", + }, + }, + "required": ["subject", "description", "media_title", "issue_type"], + }, + }, + }, +] + + +# --------------------------------------------------------------------------- +# Tool executor +# --------------------------------------------------------------------------- +async def _execute(tool_name: str, args: dict) -> ToolResult: + """Route tool calls to the right handler. Returns ToolResult with success + based on HTTP status code (2xx = ok, everything else = fail).""" + import logging + logger = logging.getLogger("skills.seerr") + + handlers = { + "seerr_trending": _trending, + "seerr_discover": _discover, + "seerr_request_media": _request_media, + "seerr_submit_issue": _submit_issue, + } + handler = handlers.get(tool_name) + if not handler: + return ToolResult.fail(f"Unknown tool: {tool_name}") + try: + result = await handler(args) + return result + except httpx.HTTPStatusError as exc: + status = exc.response.status_code + body = exc.response.text[:500] + logger.error("Seerr API HTTP %s on %s: %s", status, tool_name, body) + return ToolResult.fail( + f"Seerr API returned HTTP {status} for '{tool_name}'. " + f"Response: {body}" + ) + except httpx.HTTPError as exc: + logger.error("Seerr API network error on %s: %s", tool_name, exc) + return ToolResult.fail( + f"Seerr API is unreachable for '{tool_name}': {exc}" + ) + except Exception as exc: + logger.exception("Unexpected error in %s", tool_name) + return ToolResult.fail(f"Unexpected error in '{tool_name}': {exc}") + + +# --------------------------------------------------------------------------- +# API handlers +# --------------------------------------------------------------------------- + +async def _trending(args: dict) -> ToolResult: + """Use Jellyseerr's /api/v1/discover/trending endpoint. + Query params: language (optional), mediaType (movie | tv, default all). + """ + media_type = args.get("kind", "all") + language = args.get("language", "").strip() or None + + params: dict = {} + if media_type in ("movie", "tv"): + params["mediaType"] = media_type + if language: + params["language"] = language + + async with _client() as c: + r = await c.get("/api/v1/discover/trending", params=params) + r.raise_for_status() + data = r.json() + results = data.get("results", []) + + label = f"trending {media_type}" if media_type != "all" else "trending items" + if language: + label += f" ({language})" + if not results: + return ToolResult.ok(f"No {label} found right now.") + return ToolResult.ok(_fmt_items(results, label)) + + +async def _discover(args: dict) -> ToolResult: + """Jellyseerr discover endpoints: + Genre: /api/v1/discover/{movies|tv}/genre/{genreId} + Studio: /api/v1/discover/{movies|tv}/studio/{studioId} + Keyword: /api/v1/discover/{movies|tv}?query=keyword + Language falls back to the base discover endpoint. + """ + kind = args["kind"] + genre = args.get("genre", "").strip() + studio = args.get("studio", "").strip() + keyword = args.get("keyword", "").strip() + language = args.get("language", "").strip() or None + page = args.get("page", 1) + + # Map common genre names to TMDb genre IDs + genre_map = { + "action": 28, "adventure": 12, "animation": 16, "comedy": 35, + "crime": 80, "documentary": 99, "drama": 18, "family": 10751, + "fantasy": 14, "history": 36, "horror": 27, "music": 10402, + "mystery": 9648, "romance": 10749, "science fiction": 878, + "sci-fi": 878, "scifi": 878, "tv movie": 10770, "thriller": 53, + "war": 10752, "western": 37, + } + + base = f"/api/v1/discover/{'movies' if kind == 'movie' else 'tv'}" + params: dict = {"page": page} + endpoint = base + + if genre: + genre_id = genre_map.get(genre.lower()) + if not genre_id: + return ToolResult.fail( + f"I don't recognise the genre '{genre}'. " + f"Try one of: {', '.join(sorted(genre_map.keys()))}." + ) + endpoint = f"{base}/genre/{genre_id}" + elif studio: + endpoint = f"{base}/studio/{studio}" + elif keyword: + params["query"] = keyword + endpoint = base + + if language: + params["language"] = language + + async with _client() as c: + r = await c.get(endpoint, params=params) + r.raise_for_status() + results = r.json().get("results", []) + + desc = genre or studio or keyword or kind + if not results: + return ToolResult.ok(f"No {desc} {kind}s found.") + return ToolResult.ok(_fmt_items(results, f"{desc} {kind}s")) + + +async def _request_media(args: dict) -> ToolResult: + kind = args["kind"] + title = args["title"] + tmdb_id = args.get("tmdb_id") + + # Step 1: Search for the media + async with _client() as c: + r = await c.get("/api/v1/search/", params={"query": quote(title), "page": 1}) + r.raise_for_status() + results = r.json().get("results", []) + + # Filter by mediaType if we have results from unified search + filtered = [item for item in results if item.get("mediaType") == kind] if results else [] + if not filtered: + filtered = results # fallback if mediaType not set + + if not filtered: + return ToolResult.fail( + f"I couldn't find '{title}' on Seerr. " + f"Please double-check the title or provide a TMDb ID." + ) + + # If tmdb_id provided, match it; otherwise use the first result + match = None + if tmdb_id: + match = next( + (item for item in filtered if item.get("id") == tmdb_id), + None, + ) + if not match: + match = filtered[0] + + # Seerr's request endpoint expects the local mediaInfo.id + media_info = match.get("mediaInfo", {}) + media_id = media_info.get("id") or match.get("id") + media_title = match.get("title") or match.get("name") or title + media_year = ( + (match.get("releaseDate") or match.get("firstAirDate") or "?")[:4] + ) + + # Step 2: Submit the request + request_body = { + "mediaType": kind, + "mediaId": media_id, + } + if kind == "tv": + request_body["seasons"] = "all" + + req_r = await c.post("/api/v1/request", json=request_body) + + if req_r.status_code == 201: + return ToolResult.ok( + f"✅ Successfully requested **{media_title}** ({media_year}). " + f"It has been submitted to Seerr and will be processed soon." + ) + elif req_r.status_code == 409: + return ToolResult.fail( + f"⚠️ **{media_title}** ({media_year}) has already been requested " + f"or is already available." + ) + else: + detail = req_r.text + return ToolResult.fail( + f"❌ Failed to request **{media_title}** ({media_year}). " + f"Seerr responded with status {req_r.status_code}: {detail}" + ) + + +async def _submit_issue(args: dict) -> ToolResult: + subject = args["subject"] + description = args["description"] + media_title = args.get("media_title", "") + issue_type = args.get("issue_type", 4) # numeric code: 1=video, 2=audio, 3=sub, 4=other + media_id = args.get("media_id") + + body: dict = { + "issueType": int(issue_type), + "subject": subject, + "message": description, + } + if media_title: + body["message"] = f"[Media: {media_title}]\n\n{description}" + + async with _client() as c: + # --- Resolve mediaId (Seerr's internal ID, not TMDb) --- + if not media_id and media_title: + search_r = await c.get("/api/v1/search/", params={"query": quote(media_title), "page": 1}) + if search_r.status_code == 200: + results = search_r.json().get("results", []) + if results: + # Seerr's /api/v1/issue expects the local mediaInfo.id, + # not the TMDb id at the top level. + media_info = results[0].get("mediaInfo", {}) + media_id = media_info.get("id") or results[0].get("id") + + if media_id: + body["mediaId"] = int(media_id) + + r = await c.post("/api/v1/issue", json=body) + + resp_json = r.json() if r.text else {} + if r.status_code in (200, 201): + ticket_id = resp_json.get("id", "N/A") + return ToolResult.ok( + f"✅ Issue submitted successfully (ticket #{ticket_id}). " + f"A human operator will review: **{subject}**" + ) + else: + return ToolResult.fail( + f"❌ Failed to submit issue. Seerr responded with " + f"status {r.status_code}: {r.text[:500]}" + ) + + +# --------------------------------------------------------------------------- +# Register the skill +# --------------------------------------------------------------------------- +seerr_skill = Skill( + name="seerr", + description="Seerr integration — trending, discover, request media, submit issues", + prompt_fragment="""## Seerr Media Tools + +You have access to the Seerr media management system. Use the provided tools +to help users with media-related tasks: + +- **seerr_trending** — when a user asks what is trending/popular/new +- **seerr_discover** — when a user asks for recommendations by genre/category +- **seerr_request_media** — when a user wants to request a movie or TV show +- **seerr_submit_issue** — when a user needs to report a problem or needs an + operator-only action (like deleting media or cancelling a request) + +Always confirm successful actions to the user. If a tool fails, tell the user +what went wrong and suggest alternatives.""", + tools=TOOLS, + execute=_execute, +) + +register(seerr_skill) diff --git a/skills/triage.py b/skills/triage.py new file mode 100644 index 0000000..55bd21b --- /dev/null +++ b/skills/triage.py @@ -0,0 +1,51 @@ +""" +Triage skill — fallback for actions that aren't covered by any registered skill. + +When a user asks for something that the agent cannot do (either because the +skill doesn't exist or is intentionally unavailable — e.g. deleting media, +cancelling requests, banning users), this skill teaches the LLM to: + +1. Politely explain that the action requires a human operator. +2. Offer to submit a ticket instead. +3. Use the seerr_submit_issue tool (if available) to create the ticket. +""" + +from skills import Skill, register + +# This skill has no tools of its own — it guides the LLM's behavior. +# The actual ticket submission is handled by seerr_submit_issue. + +triage_skill = Skill( + name="triage", + description="Fallback for unsupported actions — explains limitations " + "and offers to create a ticket instead.", + prompt_fragment="""## Triage & Fallback Rules + +You are a helpful media assistant, but you have limited capabilities. Follow these +rules when a user asks for something you **cannot** do: + +### Actions you CANNOT perform (human-operator-only): +- Deleting media, requests, or users +- Cancelling existing requests +- Modifying library settings +- Changing user permissions +- Any destructive or administrative action + +### When the user asks for an unsupported action: +1. **Politely explain** that this action requires a human operator. +2. **Offer to submit a ticket** via the seerr_submit_issue tool with a clear + description of what the user wants. +3. Never say "I don't know how to do that" without also offering the ticket + alternative. + +### Example response template: +"I can't perform [action] directly — that requires a human operator for safety. +But I'd be happy to **submit a ticket** for you with all the details. Would you +like me to do that?" + +Always lean toward being helpful rather than just saying no.""", + tools=[], # no tools — this is a prompt-only skill + execute=None, +) + +register(triage_skill)