This commit is contained in:
@@ -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
|
||||||
@@ -62,3 +62,5 @@ def load_all_agents() -> None:
|
|||||||
|
|
||||||
# Also import skill modules so they self-register
|
# Also import skill modules so they self-register
|
||||||
import skills.media_info # noqa: F401
|
import skills.media_info # noqa: F401
|
||||||
|
import skills.seerr # noqa: F401
|
||||||
|
import skills.triage # noqa: F401
|
||||||
|
|||||||
+13
-6
@@ -2,18 +2,25 @@
|
|||||||
media-agent — an agent that knows how to handle media queries
|
media-agent — an agent that knows how to handle media queries
|
||||||
(Jellyfin / Sonarr / Seerr / subtitle requests).
|
(Jellyfin / Sonarr / Seerr / subtitle requests).
|
||||||
|
|
||||||
For now it only loads the *media_info* demo skill which teaches it
|
Skills:
|
||||||
a structured response format. Later you'll add real API-calling 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
|
from agents import Agent, register
|
||||||
|
|
||||||
media_agent = Agent(
|
media_agent = Agent(
|
||||||
agent_id="media-agent",
|
agent_id="media-agent",
|
||||||
description="Media assistant — handles movie/TV/subtitle/ticket requests. "
|
description="Media assistant — handles movie/TV/subtitle/ticket requests "
|
||||||
"Will eventually connect to Seerr, Sonarr, Jellyfin, etc.",
|
"via Seerr, Jellyfin, Sonarr, etc.",
|
||||||
skills=["media_info"],
|
skills=["media_info", "seerr", "triage"],
|
||||||
base_prompt="You are a media assistant. Help users with their media library.",
|
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)
|
register(media_agent)
|
||||||
|
|
||||||
|
|||||||
+192
-25
@@ -1,4 +1,4 @@
|
|||||||
from fastapi import APIRouter, Body, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from openai import OpenAI
|
from openai import OpenAI
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -7,6 +7,7 @@ import asyncio
|
|||||||
|
|
||||||
from api.dependencies import get_llm_client
|
from api.dependencies import get_llm_client
|
||||||
from agents import get as get_agent, list_all as list_all_agents
|
from agents import get as get_agent, list_all as list_all_agents
|
||||||
|
from skills import get_all_tools, execute_tool, ToolResult
|
||||||
|
|
||||||
router = APIRouter()
|
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):
|
def _resolve_agent(agent_id: str | None = None, model: str | None = None):
|
||||||
"""
|
"""
|
||||||
Look up the agent. Resolution order:
|
Resolution order:
|
||||||
1. explicit agent_id
|
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"
|
3. fallback to "naked"
|
||||||
"""
|
"""
|
||||||
lookup = agent_id or model
|
lookup = agent_id or model
|
||||||
if lookup is None:
|
if lookup is None:
|
||||||
agent = get_agent("naked")
|
return get_agent("naked")
|
||||||
else:
|
agent = get_agent(lookup)
|
||||||
agent = get_agent(lookup)
|
return agent if agent else get_agent("naked")
|
||||||
if agent is None:
|
|
||||||
agent = get_agent("naked")
|
|
||||||
return agent
|
|
||||||
|
|
||||||
|
|
||||||
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,
|
client: OpenAI,
|
||||||
message: str,
|
message: str,
|
||||||
session_id: str | None = None,
|
|
||||||
agent_id: str | None = None,
|
agent_id: str | None = None,
|
||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
) -> str:
|
) -> 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)
|
agent = _resolve_agent(agent_id, model)
|
||||||
response = client.chat.completions.create(
|
response = client.chat.completions.create(
|
||||||
model="deepseek-chat",
|
model="deepseek-chat",
|
||||||
@@ -63,15 +129,105 @@ def run_agent(
|
|||||||
return response.choices[0].message.content
|
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(
|
async def run_agent_stream(
|
||||||
client: OpenAI,
|
client: OpenAI,
|
||||||
message: str,
|
message: str,
|
||||||
session_id: str | None = None,
|
|
||||||
agent_id: str | None = None,
|
agent_id: str | None = None,
|
||||||
model: 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)
|
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()
|
system_prompt = agent.build_system_prompt()
|
||||||
loop = asyncio.get_running_loop()
|
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."""
|
"""Streaming chat endpoint — returns Server-Sent Events."""
|
||||||
async def event_stream():
|
async def event_stream():
|
||||||
async for token in run_agent_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})
|
payload = json.dumps({"token": token, "session_id": req.session_id})
|
||||||
yield f"data: {payload}\n\n"
|
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")
|
@router.post("/chat/sync")
|
||||||
def chat_sync(req: ChatRequest, client: OpenAI = Depends(get_llm_client)):
|
async def chat_sync(req: ChatRequest, client: OpenAI = Depends(get_llm_client)):
|
||||||
"""Non-streaming fallback — returns the full response at once."""
|
"""Non-streaming endpoint — uses tool-calling when the agent has tools."""
|
||||||
response = run_agent(client, req.message, req.session_id, req.agent_id)
|
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}
|
return {"response": response, "session_id": req.session_id}
|
||||||
|
|
||||||
|
|
||||||
@@ -174,11 +339,9 @@ async def chat_completions(
|
|||||||
client: OpenAI = Depends(get_llm_client),
|
client: OpenAI = Depends(get_llm_client),
|
||||||
):
|
):
|
||||||
"""OpenAI-compatible /chat/completions — supports stream=True.
|
"""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"]
|
user_message = req.messages[-1]["content"]
|
||||||
|
|
||||||
# Resolve agent from the model field (OpenWebUI sends this)
|
|
||||||
agent = _resolve_agent(model=req.model)
|
agent = _resolve_agent(model=req.model)
|
||||||
|
|
||||||
if req.stream:
|
if req.stream:
|
||||||
@@ -219,9 +382,13 @@ async def chat_completions(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Non-streaming path — resolve agent from model field
|
# Non-streaming path
|
||||||
agent = _resolve_agent(model=req.model)
|
tools = get_all_tools(agent.skills)
|
||||||
response = run_agent(client, user_message, agent_id=agent.agent_id)
|
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 {
|
return {
|
||||||
"id": "chatcmpl-local",
|
"id": "chatcmpl-local",
|
||||||
"object": "chat.completion",
|
"object": "chat.completion",
|
||||||
|
|||||||
+26
-2
@@ -2,6 +2,30 @@ from dotenv import load_dotenv
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import os
|
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")
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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"))
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
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.config import DEEPSEEK_API_KEY
|
||||||
from core.llm import create_client
|
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
|
# Load all agents & skills so they self-register at startup
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
+2
-1
@@ -1,4 +1,5 @@
|
|||||||
fastapi
|
fastapi
|
||||||
openai
|
openai
|
||||||
uvicorn
|
uvicorn
|
||||||
python-dotenv
|
python-dotenv
|
||||||
|
httpx
|
||||||
+82
-3
@@ -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.
|
that can be attached to an agent to shape its behavior and system prompt.
|
||||||
|
|
||||||
A Skill is a lightweight object with:
|
A Skill is a lightweight object with:
|
||||||
- name : short identifier (e.g. "media_info")
|
- name : short identifier (e.g. "media_info")
|
||||||
- description : human-readable summary
|
- description : human-readable summary
|
||||||
- prompt_fragment : extra text injected into the agent's system prompt
|
- 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 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
|
@dataclass
|
||||||
@@ -17,6 +45,8 @@ class Skill:
|
|||||||
name: str
|
name: str
|
||||||
description: str
|
description: str
|
||||||
prompt_fragment: 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:
|
if s and s.prompt_fragment:
|
||||||
parts.append(s.prompt_fragment)
|
parts.append(s.prompt_fragment)
|
||||||
return "\n\n".join(parts)
|
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
|
||||||
|
|||||||
+16
-30
@@ -1,45 +1,31 @@
|
|||||||
"""
|
"""
|
||||||
Demo skill: media_info
|
Demo skill: media_info
|
||||||
|
|
||||||
Gives the agent knowledge about how to respond to media-related queries
|
A lightweight base skill that teaches the agent it is a media assistant.
|
||||||
(movie / TV / subtitle requests). This is intentionally simple — in the future
|
Real API capabilities come from other skills (seerr, triage, etc.).
|
||||||
you would add real API-calling skills here (Sonarr / Jellyfin / Seerr / etc.).
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from skills import Skill, register
|
from skills import Skill, register
|
||||||
|
|
||||||
media_info_skill = Skill(
|
media_info_skill = Skill(
|
||||||
name="media_info",
|
name="media_info",
|
||||||
description="Respond to media queries with a structured format "
|
description="Base media assistant persona — movie, TV, subtitle, and media requests.",
|
||||||
"(movie / TV show requests, subtitles, tickets).",
|
prompt_fragment="""## Media Assistant Persona
|
||||||
prompt_fragment="""## Media Agent Instructions
|
|
||||||
|
|
||||||
You are a media assistant. When users ask about movies, TV shows, subtitles,
|
You are a friendly media assistant connected to a media back-end (Seerr,
|
||||||
or media library requests, follow these rules:
|
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
|
When responding:
|
||||||
confirmation using this format:
|
- 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.
|
||||||
|
|
||||||
```
|
This is the base media assistant persona. Real API capabilities come from the
|
||||||
[MEDIA REQUEST]
|
attached skills (seerr, triage, etc.).""",
|
||||||
Title: <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.""",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
register(media_info_skill)
|
register(media_info_skill)
|
||||||
|
|
||||||
|
|||||||
+470
@@ -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)
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user