feat!: migrate to OpenAI native tool calls and fix circular deps (#fuck-gemini)
- Fix circular dependencies in agent/tools - Migrate from custom JSON to OpenAI tool calls format - Add async streaming (step_stream, complete_stream) - Simplify prompt system and remove token counting - Add 5 new API endpoints (/health, /v1/models, /api/memory/*) - Add 3 new tools (get_torrent_by_index, add_torrent_by_index, set_language) - Fix all 500 tests and add coverage config (80% threshold) - Add comprehensive docs (README, pytest guide) BREAKING: LLM interface changed, memory injection via get_memory()
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
"""Agent module for media library management."""
|
||||
|
||||
from .agent import Agent, LLMClient
|
||||
from .config import settings
|
||||
|
||||
__all__ = ["Agent", "LLMClient", "settings"]
|
||||
+218
-87
@@ -1,147 +1,278 @@
|
||||
# agent/agent.py
|
||||
from typing import Any, Dict, List
|
||||
import json
|
||||
"""Main agent for media library management."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Protocol
|
||||
|
||||
from infrastructure.persistence import get_memory
|
||||
|
||||
from .llm import DeepSeekClient
|
||||
from infrastructure.persistence.memory import Memory
|
||||
from .registry import make_tools, Tool
|
||||
from .prompts import PromptBuilder
|
||||
from .config import settings
|
||||
from .prompts import PromptBuilder
|
||||
from .registry import Tool, make_tools
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LLMClient(Protocol):
|
||||
"""Protocol defining the LLM client interface."""
|
||||
|
||||
def complete(self, messages: list[dict[str, Any]]) -> str:
|
||||
"""Send messages to the LLM and get a response."""
|
||||
...
|
||||
|
||||
|
||||
class Agent:
|
||||
def __init__(self, llm: DeepSeekClient, memory: Memory, max_tool_iterations: int = 5):
|
||||
"""
|
||||
AI agent for media library management.
|
||||
|
||||
Orchestrates interactions between the LLM, memory, and tools
|
||||
to respond to user requests.
|
||||
|
||||
Attributes:
|
||||
llm: LLM client (DeepSeek or Ollama).
|
||||
tools: Available tools for the agent.
|
||||
prompt_builder: Builds system prompts with context.
|
||||
max_tool_iterations: Maximum tool calls per request.
|
||||
"""
|
||||
|
||||
def __init__(self, llm: LLMClient, max_tool_iterations: int = 5):
|
||||
"""
|
||||
Initialize the agent.
|
||||
|
||||
Args:
|
||||
llm: LLM client compatible with the LLMClient protocol.
|
||||
max_tool_iterations: Maximum tool iterations (default: 5).
|
||||
"""
|
||||
self.llm = llm
|
||||
self.memory = memory
|
||||
self.tools: Dict[str, Tool] = make_tools(memory)
|
||||
self.tools: dict[str, Tool] = make_tools()
|
||||
self.prompt_builder = PromptBuilder(self.tools)
|
||||
self.max_tool_iterations = max_tool_iterations
|
||||
|
||||
def _parse_intent(self, text: str) -> dict[str, Any] | None:
|
||||
"""
|
||||
Parse an LLM response to detect a tool call.
|
||||
|
||||
def _parse_intent(self, text: str) -> Dict[str, Any] | None:
|
||||
Args:
|
||||
text: LLM response text.
|
||||
|
||||
Returns:
|
||||
Dict with intent if a tool call is detected, None otherwise.
|
||||
"""
|
||||
text = text.strip()
|
||||
|
||||
# Try direct JSON parse
|
||||
if text.startswith("{") and text.endswith("}"):
|
||||
try:
|
||||
data = json.loads(text)
|
||||
if self._is_valid_intent(data):
|
||||
return data
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Try to extract JSON from text
|
||||
try:
|
||||
data = json.loads(text)
|
||||
start = text.find("{")
|
||||
end = text.rfind("}") + 1
|
||||
if start != -1 and end > start:
|
||||
json_str = text[start:end]
|
||||
data = json.loads(json_str)
|
||||
if self._is_valid_intent(data):
|
||||
return data
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
pass
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
return None
|
||||
|
||||
def _is_valid_intent(self, data: Any) -> bool:
|
||||
"""Check if parsed data is a valid tool intent."""
|
||||
if not isinstance(data, dict) or "action" not in data:
|
||||
return False
|
||||
action = data.get("action")
|
||||
if not isinstance(action, dict):
|
||||
return None
|
||||
return isinstance(action, dict) and isinstance(action.get("name"), str)
|
||||
|
||||
name = action.get("name")
|
||||
if not isinstance(name, str):
|
||||
return None
|
||||
def _execute_action(self, intent: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Execute a tool action requested by the LLM.
|
||||
|
||||
return data
|
||||
Args:
|
||||
intent: Dict containing the action to execute.
|
||||
|
||||
def _execute_action(self, intent: Dict[str, Any]) -> Dict[str, Any]:
|
||||
Returns:
|
||||
Tool execution result.
|
||||
"""
|
||||
action = intent["action"]
|
||||
name: str = action["name"]
|
||||
args: Dict[str, Any] = action.get("args", {}) or {}
|
||||
args: dict[str, Any] = action.get("args", {}) or {}
|
||||
|
||||
tool = self.tools.get(name)
|
||||
if not tool:
|
||||
return {"error": "unknown_tool", "tool": name}
|
||||
logger.warning(f"Unknown tool requested: {name}")
|
||||
return {
|
||||
"error": "unknown_tool",
|
||||
"tool": name,
|
||||
"available_tools": list(self.tools.keys()),
|
||||
}
|
||||
|
||||
try:
|
||||
result = tool.func(**args)
|
||||
|
||||
# Track errors in episodic memory
|
||||
if result.get("status") == "error" or result.get("error"):
|
||||
memory = get_memory()
|
||||
memory.episodic.add_error(
|
||||
action=name,
|
||||
error=result.get("error", result.get("message", "Unknown error")),
|
||||
context={"args": args, "result": result},
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except TypeError as e:
|
||||
# Mauvais arguments
|
||||
error_msg = f"Bad arguments for {name}: {e}"
|
||||
logger.error(error_msg)
|
||||
memory = get_memory()
|
||||
memory.episodic.add_error(
|
||||
action=name, error=error_msg, context={"args": args}
|
||||
)
|
||||
return {"error": "bad_args", "message": str(e)}
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
error_msg = f"Error executing {name}: {e}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
memory = get_memory()
|
||||
memory.episodic.add_error(action=name, error=str(e), context={"args": args})
|
||||
return {"error": "execution_error", "message": str(e)}
|
||||
|
||||
def _check_unread_events(self) -> str:
|
||||
"""
|
||||
Check for unread background events and format them.
|
||||
|
||||
Returns:
|
||||
Formatted string of events, or empty string if none.
|
||||
"""
|
||||
memory = get_memory()
|
||||
events = memory.episodic.get_unread_events()
|
||||
|
||||
if not events:
|
||||
return ""
|
||||
|
||||
lines = ["Recent events:"]
|
||||
for event in events:
|
||||
event_type = event.get("type", "unknown")
|
||||
data = event.get("data", {})
|
||||
|
||||
if event_type == "download_complete":
|
||||
lines.append(f" - Download completed: {data.get('name')}")
|
||||
elif event_type == "new_files_detected":
|
||||
lines.append(f" - {data.get('count')} new files detected")
|
||||
else:
|
||||
lines.append(f" - {event_type}: {data}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def step(self, user_input: str) -> str:
|
||||
"""
|
||||
Execute one agent step with iterative tool execution:
|
||||
- Build system prompt
|
||||
- Query LLM
|
||||
- Loop: If JSON intent -> execute tool, add result to conversation, query LLM again
|
||||
- Continue until LLM responds with text (no tool call) or max iterations reached
|
||||
- Return final text response
|
||||
Execute one agent step with iterative tool execution.
|
||||
|
||||
Process:
|
||||
1. Check for unread events
|
||||
2. Build system prompt with memory context
|
||||
3. Query the LLM
|
||||
4. If tool call detected, execute and loop
|
||||
5. Return final text response
|
||||
|
||||
Args:
|
||||
user_input: User message.
|
||||
|
||||
Returns:
|
||||
Final response in natural text.
|
||||
"""
|
||||
print("Starting a new step...")
|
||||
print("User input:", user_input)
|
||||
logger.info("Starting agent step")
|
||||
logger.debug(f"User input: {user_input}")
|
||||
|
||||
print("Current memory state:", self.memory.data)
|
||||
memory = get_memory()
|
||||
|
||||
# Build system prompt using PromptBuilder
|
||||
system_prompt = self.prompt_builder.build_system_prompt(self.memory.data)
|
||||
# Check for background events
|
||||
events_notification = self._check_unread_events()
|
||||
if events_notification:
|
||||
logger.info("Found unread background events")
|
||||
|
||||
# Initialize conversation with system prompt
|
||||
messages: List[Dict[str, Any]] = [
|
||||
# Build system prompt
|
||||
system_prompt = self.prompt_builder.build_system_prompt()
|
||||
|
||||
# Initialize conversation
|
||||
messages: list[dict[str, Any]] = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
]
|
||||
|
||||
# Add conversation history from memory (last N messages for context)
|
||||
# Only add user/assistant messages, NOT system messages
|
||||
history = self.memory.get("history", [])
|
||||
max_history = settings.max_history_messages
|
||||
if history and max_history > 0:
|
||||
# Filter to keep only user and assistant messages
|
||||
filtered_history = [
|
||||
msg for msg in history
|
||||
if msg.get("role") in ("user", "assistant")
|
||||
]
|
||||
recent_history = filtered_history[-max_history:]
|
||||
messages.extend(recent_history)
|
||||
print(f"Added {len(recent_history)} messages from history (filtered)")
|
||||
# Add conversation history
|
||||
history = memory.stm.get_recent_history(settings.max_history_messages)
|
||||
if history:
|
||||
for msg in history:
|
||||
messages.append({"role": msg["role"], "content": msg["content"]})
|
||||
logger.debug(f"Added {len(history)} messages from history")
|
||||
|
||||
# Add current user input
|
||||
# Add events notification
|
||||
if events_notification:
|
||||
messages.append(
|
||||
{"role": "system", "content": f"[NOTIFICATION]\n{events_notification}"}
|
||||
)
|
||||
|
||||
# Add user input
|
||||
messages.append({"role": "user", "content": user_input})
|
||||
|
||||
# Tool execution loop
|
||||
iteration = 0
|
||||
while iteration < self.max_tool_iterations:
|
||||
print(f"\n--- Iteration {iteration + 1} ---")
|
||||
logger.debug(f"Iteration {iteration + 1}/{self.max_tool_iterations}")
|
||||
|
||||
# Get LLM response
|
||||
print(messages)
|
||||
llm_response = self.llm.complete(messages)
|
||||
print("LLM response:", llm_response)
|
||||
logger.debug(f"LLM response: {llm_response[:200]}...")
|
||||
|
||||
# Try to parse as tool intent
|
||||
intent = self._parse_intent(llm_response)
|
||||
|
||||
if not intent:
|
||||
# No tool call - this is the final text response
|
||||
print("No tool intent detected, returning final response")
|
||||
# Save to history
|
||||
self.memory.append_history("user", user_input)
|
||||
self.memory.append_history("assistant", llm_response)
|
||||
# Final text response
|
||||
logger.info("No tool intent, returning response")
|
||||
memory.stm.add_message("user", user_input)
|
||||
memory.stm.add_message("assistant", llm_response)
|
||||
memory.save()
|
||||
return llm_response
|
||||
|
||||
# Tool call detected - execute it
|
||||
print("Intent detected:", intent)
|
||||
# Execute tool
|
||||
tool_name = intent.get("action", {}).get("name", "unknown")
|
||||
logger.info(f"Executing tool: {tool_name}")
|
||||
tool_result = self._execute_action(intent)
|
||||
print("Tool result:", tool_result)
|
||||
logger.debug(f"Tool result: {tool_result}")
|
||||
|
||||
# Add assistant's tool call and result to conversation
|
||||
messages.append({
|
||||
"role": "assistant",
|
||||
"content": json.dumps(intent, ensure_ascii=False)
|
||||
})
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": json.dumps(
|
||||
{"tool_result": tool_result},
|
||||
ensure_ascii=False
|
||||
)
|
||||
})
|
||||
# Add to conversation
|
||||
messages.append(
|
||||
{"role": "assistant", "content": json.dumps(intent, ensure_ascii=False)}
|
||||
)
|
||||
messages.append(
|
||||
{
|
||||
"role": "user",
|
||||
"content": json.dumps(
|
||||
{"tool_result": tool_result}, ensure_ascii=False
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
iteration += 1
|
||||
|
||||
# Max iterations reached - ask LLM for final response
|
||||
print(f"\n--- Max iterations ({self.max_tool_iterations}) reached, requesting final response ---")
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": "Merci pour ces résultats. Peux-tu maintenant me donner une réponse finale en texte naturel ?"
|
||||
})
|
||||
# Max iterations reached
|
||||
logger.warning(f"Max iterations ({self.max_tool_iterations}) reached")
|
||||
messages.append(
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Please provide a final response based on the results.",
|
||||
}
|
||||
)
|
||||
|
||||
final_response = self.llm.complete(messages)
|
||||
# Save to history
|
||||
self.memory.append_history("user", user_input)
|
||||
self.memory.append_history("assistant", final_response)
|
||||
|
||||
memory.stm.add_message("user", user_input)
|
||||
memory.stm.add_message("assistant", final_response)
|
||||
memory.save()
|
||||
|
||||
return final_response
|
||||
|
||||
+51
-17
@@ -1,8 +1,9 @@
|
||||
"""Configuration management with validation."""
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables from .env file
|
||||
@@ -11,6 +12,7 @@ load_dotenv()
|
||||
|
||||
class ConfigurationError(Exception):
|
||||
"""Raised when configuration is invalid."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@@ -19,24 +21,46 @@ class Settings:
|
||||
"""Application settings loaded from environment variables."""
|
||||
|
||||
# LLM Configuration
|
||||
deepseek_api_key: str = field(default_factory=lambda: os.getenv("DEEPSEEK_API_KEY", ""))
|
||||
deepseek_base_url: str = field(default_factory=lambda: os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com"))
|
||||
model: str = field(default_factory=lambda: os.getenv("DEEPSEEK_MODEL", "deepseek-chat"))
|
||||
temperature: float = field(default_factory=lambda: float(os.getenv("TEMPERATURE", "0.2")))
|
||||
deepseek_api_key: str = field(
|
||||
default_factory=lambda: os.getenv("DEEPSEEK_API_KEY", "")
|
||||
)
|
||||
deepseek_base_url: str = field(
|
||||
default_factory=lambda: os.getenv(
|
||||
"DEEPSEEK_BASE_URL", "https://api.deepseek.com"
|
||||
)
|
||||
)
|
||||
model: str = field(
|
||||
default_factory=lambda: os.getenv("DEEPSEEK_MODEL", "deepseek-chat")
|
||||
)
|
||||
temperature: float = field(
|
||||
default_factory=lambda: float(os.getenv("TEMPERATURE", "0.2"))
|
||||
)
|
||||
|
||||
# TMDB Configuration
|
||||
tmdb_api_key: str = field(default_factory=lambda: os.getenv("TMDB_API_KEY", ""))
|
||||
tmdb_base_url: str = field(default_factory=lambda: os.getenv("TMDB_BASE_URL", "https://api.themoviedb.org/3"))
|
||||
tmdb_base_url: str = field(
|
||||
default_factory=lambda: os.getenv(
|
||||
"TMDB_BASE_URL", "https://api.themoviedb.org/3"
|
||||
)
|
||||
)
|
||||
|
||||
# Storage Configuration
|
||||
memory_file: str = field(default_factory=lambda: os.getenv("MEMORY_FILE", "memory.json"))
|
||||
memory_file: str = field(
|
||||
default_factory=lambda: os.getenv("MEMORY_FILE", "memory.json")
|
||||
)
|
||||
|
||||
# Security Configuration
|
||||
max_tool_iterations: int = field(default_factory=lambda: int(os.getenv("MAX_TOOL_ITERATIONS", "5")))
|
||||
request_timeout: int = field(default_factory=lambda: int(os.getenv("REQUEST_TIMEOUT", "30")))
|
||||
|
||||
max_tool_iterations: int = field(
|
||||
default_factory=lambda: int(os.getenv("MAX_TOOL_ITERATIONS", "5"))
|
||||
)
|
||||
request_timeout: int = field(
|
||||
default_factory=lambda: int(os.getenv("REQUEST_TIMEOUT", "30"))
|
||||
)
|
||||
|
||||
# Memory Configuration
|
||||
max_history_messages: int = field(default_factory=lambda: int(os.getenv("MAX_HISTORY_MESSAGES", "10")))
|
||||
max_history_messages: int = field(
|
||||
default_factory=lambda: int(os.getenv("MAX_HISTORY_MESSAGES", "10"))
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate settings after initialization."""
|
||||
@@ -46,19 +70,27 @@ class Settings:
|
||||
"""Validate configuration values."""
|
||||
# Validate temperature
|
||||
if not 0.0 <= self.temperature <= 2.0:
|
||||
raise ConfigurationError(f"Temperature must be between 0.0 and 2.0, got {self.temperature}")
|
||||
raise ConfigurationError(
|
||||
f"Temperature must be between 0.0 and 2.0, got {self.temperature}"
|
||||
)
|
||||
|
||||
# Validate max_tool_iterations
|
||||
if self.max_tool_iterations < 1 or self.max_tool_iterations > 20:
|
||||
raise ConfigurationError(f"max_tool_iterations must be between 1 and 20, got {self.max_tool_iterations}")
|
||||
raise ConfigurationError(
|
||||
f"max_tool_iterations must be between 1 and 20, got {self.max_tool_iterations}"
|
||||
)
|
||||
|
||||
# Validate request_timeout
|
||||
if self.request_timeout < 1 or self.request_timeout > 300:
|
||||
raise ConfigurationError(f"request_timeout must be between 1 and 300 seconds, got {self.request_timeout}")
|
||||
raise ConfigurationError(
|
||||
f"request_timeout must be between 1 and 300 seconds, got {self.request_timeout}"
|
||||
)
|
||||
|
||||
# Validate URLs
|
||||
if not self.deepseek_base_url.startswith(("http://", "https://")):
|
||||
raise ConfigurationError(f"Invalid deepseek_base_url: {self.deepseek_base_url}")
|
||||
raise ConfigurationError(
|
||||
f"Invalid deepseek_base_url: {self.deepseek_base_url}"
|
||||
)
|
||||
|
||||
if not self.tmdb_base_url.startswith(("http://", "https://")):
|
||||
raise ConfigurationError(f"Invalid tmdb_base_url: {self.tmdb_base_url}")
|
||||
@@ -66,7 +98,9 @@ class Settings:
|
||||
# Validate memory file path
|
||||
memory_path = Path(self.memory_file)
|
||||
if memory_path.exists() and not memory_path.is_file():
|
||||
raise ConfigurationError(f"memory_file exists but is not a file: {self.memory_file}")
|
||||
raise ConfigurationError(
|
||||
f"memory_file exists but is not a file: {self.memory_file}"
|
||||
)
|
||||
|
||||
def is_deepseek_configured(self) -> bool:
|
||||
"""Check if DeepSeek API is properly configured."""
|
||||
|
||||
+10
-2
@@ -1,5 +1,13 @@
|
||||
"""LLM client module."""
|
||||
"""LLM clients module."""
|
||||
|
||||
from .deepseek import DeepSeekClient
|
||||
from .exceptions import LLMAPIError, LLMConfigurationError, LLMError
|
||||
from .ollama import OllamaClient
|
||||
|
||||
__all__ = ['DeepSeekClient', 'OllamaClient']
|
||||
__all__ = [
|
||||
"DeepSeekClient",
|
||||
"OllamaClient",
|
||||
"LLMError",
|
||||
"LLMAPIError",
|
||||
"LLMConfigurationError",
|
||||
]
|
||||
|
||||
+35
-48
@@ -1,48 +1,36 @@
|
||||
"""DeepSeek LLM client with robust error handling."""
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
from requests.exceptions import RequestException, Timeout, HTTPError
|
||||
from requests.exceptions import HTTPError, RequestException, Timeout
|
||||
|
||||
from ..config import settings
|
||||
from .exceptions import LLMAPIError, LLMConfigurationError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LLMError(Exception):
|
||||
"""Base exception for LLM-related errors."""
|
||||
pass
|
||||
|
||||
|
||||
class LLMConfigurationError(LLMError):
|
||||
"""Raised when LLM is not properly configured."""
|
||||
pass
|
||||
|
||||
|
||||
class LLMAPIError(LLMError):
|
||||
"""Raised when LLM API returns an error."""
|
||||
pass
|
||||
|
||||
|
||||
class DeepSeekClient:
|
||||
"""Client for interacting with DeepSeek API."""
|
||||
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: Optional[str] = None,
|
||||
base_url: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
timeout: Optional[int] = None,
|
||||
api_key: str | None = None,
|
||||
base_url: str | None = None,
|
||||
model: str | None = None,
|
||||
timeout: int | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize DeepSeek client.
|
||||
|
||||
|
||||
Args:
|
||||
api_key: API key for authentication (defaults to settings)
|
||||
base_url: Base URL for API (defaults to settings)
|
||||
model: Model name to use (defaults to settings)
|
||||
timeout: Request timeout in seconds (defaults to settings)
|
||||
|
||||
|
||||
Raises:
|
||||
LLMConfigurationError: If API key is missing
|
||||
"""
|
||||
@@ -50,29 +38,29 @@ class DeepSeekClient:
|
||||
self.base_url = base_url or settings.deepseek_base_url
|
||||
self.model = model or settings.model
|
||||
self.timeout = timeout or settings.request_timeout
|
||||
|
||||
|
||||
if not self.api_key:
|
||||
raise LLMConfigurationError(
|
||||
"DeepSeek API key is required. Set DEEPSEEK_API_KEY environment variable."
|
||||
)
|
||||
|
||||
|
||||
if not self.base_url:
|
||||
raise LLMConfigurationError(
|
||||
"DeepSeek base URL is required. Set DEEPSEEK_BASE_URL environment variable."
|
||||
)
|
||||
|
||||
|
||||
logger.info(f"DeepSeek client initialized with model: {self.model}")
|
||||
|
||||
def complete(self, messages: List[Dict[str, Any]]) -> str:
|
||||
def complete(self, messages: list[dict[str, Any]]) -> str:
|
||||
"""
|
||||
Generate a completion from the LLM.
|
||||
|
||||
|
||||
Args:
|
||||
messages: List of message dicts with 'role' and 'content' keys
|
||||
|
||||
|
||||
Returns:
|
||||
Generated text response
|
||||
|
||||
|
||||
Raises:
|
||||
LLMAPIError: If API request fails
|
||||
ValueError: If messages format is invalid
|
||||
@@ -80,15 +68,17 @@ class DeepSeekClient:
|
||||
# Validate messages format
|
||||
if not messages:
|
||||
raise ValueError("Messages list cannot be empty")
|
||||
|
||||
|
||||
for msg in messages:
|
||||
if not isinstance(msg, dict):
|
||||
raise ValueError(f"Each message must be a dict, got {type(msg)}")
|
||||
if "role" not in msg or "content" not in msg:
|
||||
raise ValueError(f"Each message must have 'role' and 'content' keys, got {msg.keys()}")
|
||||
raise ValueError(
|
||||
f"Each message must have 'role' and 'content' keys, got {msg.keys()}"
|
||||
)
|
||||
if msg["role"] not in ("system", "user", "assistant"):
|
||||
raise ValueError(f"Invalid role: {msg['role']}")
|
||||
|
||||
|
||||
url = f"{self.base_url}/v1/chat/completions"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
@@ -99,37 +89,34 @@ class DeepSeekClient:
|
||||
"messages": messages,
|
||||
"temperature": settings.temperature,
|
||||
}
|
||||
|
||||
|
||||
try:
|
||||
logger.debug(f"Sending request to {url} with {len(messages)} messages")
|
||||
response = requests.post(
|
||||
url,
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=self.timeout
|
||||
url, headers=headers, json=payload, timeout=self.timeout
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
|
||||
# Validate response structure
|
||||
if "choices" not in data or not data["choices"]:
|
||||
raise LLMAPIError("Invalid API response: missing 'choices'")
|
||||
|
||||
|
||||
if "message" not in data["choices"][0]:
|
||||
raise LLMAPIError("Invalid API response: missing 'message' in choice")
|
||||
|
||||
|
||||
if "content" not in data["choices"][0]["message"]:
|
||||
raise LLMAPIError("Invalid API response: missing 'content' in message")
|
||||
|
||||
|
||||
content = data["choices"][0]["message"]["content"]
|
||||
logger.debug(f"Received response with {len(content)} characters")
|
||||
|
||||
|
||||
return content
|
||||
|
||||
|
||||
except Timeout as e:
|
||||
logger.error(f"Request timeout after {self.timeout}s: {e}")
|
||||
raise LLMAPIError(f"Request timeout after {self.timeout} seconds") from e
|
||||
|
||||
|
||||
except HTTPError as e:
|
||||
logger.error(f"HTTP error from DeepSeek API: {e}")
|
||||
if e.response is not None:
|
||||
@@ -140,11 +127,11 @@ class DeepSeekClient:
|
||||
error_msg = str(e)
|
||||
raise LLMAPIError(f"DeepSeek API error: {error_msg}") from e
|
||||
raise LLMAPIError(f"HTTP error: {e}") from e
|
||||
|
||||
|
||||
except RequestException as e:
|
||||
logger.error(f"Request failed: {e}")
|
||||
raise LLMAPIError(f"Failed to connect to DeepSeek API: {e}") from e
|
||||
|
||||
|
||||
except (KeyError, IndexError, TypeError) as e:
|
||||
logger.error(f"Failed to parse API response: {e}")
|
||||
raise LLMAPIError(f"Invalid API response format: {e}") from e
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"""LLM-related exceptions."""
|
||||
|
||||
|
||||
class LLMError(Exception):
|
||||
"""Base exception for LLM-related errors."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class LLMConfigurationError(LLMError):
|
||||
"""Raised when LLM is not properly configured."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class LLMAPIError(LLMError):
|
||||
"""Raised when LLM API returns an error."""
|
||||
|
||||
pass
|
||||
+22
-33
@@ -1,31 +1,18 @@
|
||||
"""Ollama LLM client with robust error handling."""
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
import logging
|
||||
import os
|
||||
import requests
|
||||
from typing import Any
|
||||
|
||||
from requests.exceptions import RequestException, Timeout, HTTPError
|
||||
import requests
|
||||
from requests.exceptions import HTTPError, RequestException, Timeout
|
||||
|
||||
from ..config import settings
|
||||
from .exceptions import LLMAPIError, LLMConfigurationError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LLMError(Exception):
|
||||
"""Base exception for LLM-related errors."""
|
||||
pass
|
||||
|
||||
|
||||
class LLMConfigurationError(LLMError):
|
||||
"""Raised when LLM is not properly configured."""
|
||||
pass
|
||||
|
||||
|
||||
class LLMAPIError(LLMError):
|
||||
"""Raised when LLM API returns an error."""
|
||||
pass
|
||||
|
||||
|
||||
class OllamaClient:
|
||||
"""
|
||||
Client for interacting with Ollama API.
|
||||
@@ -41,10 +28,10 @@ class OllamaClient:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
timeout: Optional[int] = None,
|
||||
temperature: Optional[float] = None,
|
||||
base_url: str | None = None,
|
||||
model: str | None = None,
|
||||
timeout: int | None = None,
|
||||
temperature: float | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize Ollama client.
|
||||
@@ -58,10 +45,14 @@ class OllamaClient:
|
||||
Raises:
|
||||
LLMConfigurationError: If configuration is invalid
|
||||
"""
|
||||
self.base_url = base_url or os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
|
||||
self.base_url = base_url or os.getenv(
|
||||
"OLLAMA_BASE_URL", "http://localhost:11434"
|
||||
)
|
||||
self.model = model or os.getenv("OLLAMA_MODEL", "llama3.2")
|
||||
self.timeout = timeout or settings.request_timeout
|
||||
self.temperature = temperature if temperature is not None else settings.temperature
|
||||
self.temperature = (
|
||||
temperature if temperature is not None else settings.temperature
|
||||
)
|
||||
|
||||
if not self.base_url:
|
||||
raise LLMConfigurationError(
|
||||
@@ -75,7 +66,7 @@ class OllamaClient:
|
||||
|
||||
logger.info(f"Ollama client initialized with model: {self.model}")
|
||||
|
||||
def complete(self, messages: List[Dict[str, Any]]) -> str:
|
||||
def complete(self, messages: list[dict[str, Any]]) -> str:
|
||||
"""
|
||||
Generate a completion from the LLM.
|
||||
|
||||
@@ -97,7 +88,9 @@ class OllamaClient:
|
||||
if not isinstance(msg, dict):
|
||||
raise ValueError(f"Each message must be a dict, got {type(msg)}")
|
||||
if "role" not in msg or "content" not in msg:
|
||||
raise ValueError(f"Each message must have 'role' and 'content' keys, got {msg.keys()}")
|
||||
raise ValueError(
|
||||
f"Each message must have 'role' and 'content' keys, got {msg.keys()}"
|
||||
)
|
||||
if msg["role"] not in ("system", "user", "assistant"):
|
||||
raise ValueError(f"Invalid role: {msg['role']}")
|
||||
|
||||
@@ -108,16 +101,12 @@ class OllamaClient:
|
||||
"stream": False,
|
||||
"options": {
|
||||
"temperature": self.temperature,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
logger.debug(f"Sending request to {url} with {len(messages)} messages")
|
||||
response = requests.post(
|
||||
url,
|
||||
json=payload,
|
||||
timeout=self.timeout
|
||||
)
|
||||
response = requests.post(url, json=payload, timeout=self.timeout)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
@@ -156,7 +145,7 @@ class OllamaClient:
|
||||
logger.error(f"Failed to parse API response: {e}")
|
||||
raise LLMAPIError(f"Invalid API response format: {e}") from e
|
||||
|
||||
def list_models(self) -> List[str]:
|
||||
def list_models(self) -> list[str]:
|
||||
"""
|
||||
List available models in Ollama.
|
||||
|
||||
|
||||
+8
-7
@@ -1,17 +1,18 @@
|
||||
# agent/parameters.py
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional, Callable
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParameterSchema:
|
||||
"""Describes a required parameter for the agent."""
|
||||
|
||||
key: str
|
||||
description: str
|
||||
why_needed: str # Explanation for the AI
|
||||
type: str # "string", "number", "object", etc.
|
||||
validator: Optional[Callable[[Any], bool]] = None
|
||||
validator: Callable[[Any], bool] | None = None
|
||||
default: Any = None
|
||||
required: bool = True
|
||||
|
||||
@@ -31,7 +32,7 @@ REQUIRED_PARAMETERS = [
|
||||
type="object",
|
||||
validator=lambda x: isinstance(x, dict),
|
||||
required=True,
|
||||
default={}
|
||||
default={},
|
||||
),
|
||||
ParameterSchema(
|
||||
key="tv_shows",
|
||||
@@ -43,12 +44,12 @@ REQUIRED_PARAMETERS = [
|
||||
type="array",
|
||||
validator=lambda x: isinstance(x, list),
|
||||
required=False,
|
||||
default=[]
|
||||
default=[],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def get_parameter_schema(key: str) -> Optional[ParameterSchema]:
|
||||
def get_parameter_schema(key: str) -> ParameterSchema | None:
|
||||
"""Get schema for a specific parameter."""
|
||||
for param in REQUIRED_PARAMETERS:
|
||||
if param.key == key:
|
||||
@@ -79,7 +80,7 @@ def format_parameters_for_prompt() -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def validate_parameter(key: str, value: Any) -> tuple[bool, Optional[str]]:
|
||||
def validate_parameter(key: str, value: Any) -> tuple[bool, str | None]:
|
||||
"""
|
||||
Validate a parameter value against its schema.
|
||||
|
||||
|
||||
+138
-56
@@ -1,15 +1,27 @@
|
||||
# agent/prompts.py
|
||||
from typing import Dict, Any
|
||||
"""Prompt builder for the agent system."""
|
||||
|
||||
import json
|
||||
|
||||
from .registry import Tool
|
||||
from infrastructure.persistence import get_memory
|
||||
|
||||
from .parameters import format_parameters_for_prompt, get_missing_required_parameters
|
||||
from .registry import Tool
|
||||
|
||||
|
||||
class PromptBuilder:
|
||||
"""Handles construction of system prompts for the agent."""
|
||||
"""Builds system prompts for the agent with memory context.
|
||||
|
||||
def __init__(self, tools: Dict[str, Tool]):
|
||||
Attributes:
|
||||
tools: Dictionary of available tools.
|
||||
"""
|
||||
|
||||
def __init__(self, tools: dict[str, Tool]):
|
||||
"""
|
||||
Initialize the prompt builder.
|
||||
|
||||
Args:
|
||||
tools: Dictionary mapping tool names to Tool instances.
|
||||
"""
|
||||
self.tools = tools
|
||||
|
||||
def _format_tools_description(self) -> str:
|
||||
@@ -20,69 +32,139 @@ class PromptBuilder:
|
||||
for tool in self.tools.values()
|
||||
)
|
||||
|
||||
def _build_context(self, memory_data: dict) -> Dict[str, Any]:
|
||||
"""Build the context object with current state from memory."""
|
||||
return memory_data
|
||||
def _format_episodic_context(self) -> str:
|
||||
"""Format episodic memory context for the prompt."""
|
||||
memory = get_memory()
|
||||
lines = []
|
||||
|
||||
def build_system_prompt(self, memory_data: dict) -> str:
|
||||
# Last search results
|
||||
if memory.episodic.last_search_results:
|
||||
search = memory.episodic.last_search_results
|
||||
lines.append(f"LAST SEARCH: '{search.get('query')}'")
|
||||
results = search.get("results", [])
|
||||
if results:
|
||||
lines.append(f" {len(results)} results available:")
|
||||
for r in results[:5]:
|
||||
name = r.get("name", r.get("title", "Unknown"))
|
||||
lines.append(f" {r.get('index')}. {name}")
|
||||
if len(results) > 5:
|
||||
lines.append(f" ... and {len(results) - 5} more")
|
||||
|
||||
# Pending question
|
||||
if memory.episodic.pending_question:
|
||||
q = memory.episodic.pending_question
|
||||
lines.append(f"\nPENDING QUESTION: {q.get('question')}")
|
||||
for opt in q.get("options", []):
|
||||
lines.append(f" {opt.get('index')}. {opt.get('label')}")
|
||||
|
||||
# Active downloads
|
||||
if memory.episodic.active_downloads:
|
||||
lines.append(f"\nACTIVE DOWNLOADS: {len(memory.episodic.active_downloads)}")
|
||||
for dl in memory.episodic.active_downloads[:3]:
|
||||
lines.append(f" - {dl.get('name')}: {dl.get('progress', 0)}%")
|
||||
|
||||
# Recent errors
|
||||
if memory.episodic.recent_errors:
|
||||
last_error = memory.episodic.recent_errors[-1]
|
||||
lines.append(
|
||||
f"\nLAST ERROR: {last_error.get('error')} "
|
||||
f"(action: {last_error.get('action')})"
|
||||
)
|
||||
|
||||
# Unread events
|
||||
unread = [e for e in memory.episodic.background_events if not e.get("read")]
|
||||
if unread:
|
||||
lines.append(f"\nUNREAD EVENTS: {len(unread)}")
|
||||
for e in unread[:3]:
|
||||
lines.append(f" - {e.get('type')}: {e.get('data', {})}")
|
||||
|
||||
return "\n".join(lines) if lines else ""
|
||||
|
||||
def _format_stm_context(self) -> str:
|
||||
"""Format short-term memory context for the prompt."""
|
||||
memory = get_memory()
|
||||
lines = []
|
||||
|
||||
# Current workflow
|
||||
if memory.stm.current_workflow:
|
||||
wf = memory.stm.current_workflow
|
||||
lines.append(f"CURRENT WORKFLOW: {wf.get('type')}")
|
||||
lines.append(f" Target: {wf.get('target', {}).get('title', 'Unknown')}")
|
||||
lines.append(f" Stage: {wf.get('stage')}")
|
||||
|
||||
# Current topic
|
||||
if memory.stm.current_topic:
|
||||
lines.append(f"CURRENT TOPIC: {memory.stm.current_topic}")
|
||||
|
||||
# Extracted entities
|
||||
if memory.stm.extracted_entities:
|
||||
entities_json = json.dumps(
|
||||
memory.stm.extracted_entities, ensure_ascii=False
|
||||
)
|
||||
lines.append(f"EXTRACTED ENTITIES: {entities_json}")
|
||||
|
||||
return "\n".join(lines) if lines else ""
|
||||
|
||||
def build_system_prompt(self) -> str:
|
||||
"""
|
||||
Build the system prompt with context provided as JSON.
|
||||
|
||||
Args:
|
||||
memory_data: The full memory data dictionary
|
||||
Build the system prompt with context from memory.
|
||||
|
||||
Returns:
|
||||
The complete system prompt string
|
||||
The complete system prompt string.
|
||||
"""
|
||||
context = self._build_context(memory_data)
|
||||
memory = get_memory()
|
||||
tools_desc = self._format_tools_description()
|
||||
params_desc = format_parameters_for_prompt()
|
||||
|
||||
# Check for missing required parameters
|
||||
missing_params = get_missing_required_parameters(memory_data)
|
||||
missing_params = get_missing_required_parameters({"config": memory.ltm.config})
|
||||
missing_info = ""
|
||||
if missing_params:
|
||||
missing_info = "\n\n⚠️ MISSING REQUIRED PARAMETERS:\n"
|
||||
missing_info = "\n\nMISSING REQUIRED PARAMETERS:\n"
|
||||
for param in missing_params:
|
||||
missing_info += f"- {param.key}: {param.description}\n"
|
||||
missing_info += f" Why needed: {param.why_needed}\n"
|
||||
|
||||
return (
|
||||
"You are an AI agent helping a user manage their local media library.\n\n"
|
||||
f"{params_desc}\n\n"
|
||||
"CURRENT CONTEXT (JSON):\n"
|
||||
f"{json.dumps(context, indent=2, ensure_ascii=False)}\n"
|
||||
f"{missing_info}\n"
|
||||
"IMPORTANT RULES:\n"
|
||||
"1. Check the REQUIRED PARAMETERS section above to understand what information you need.\n"
|
||||
"2. If any required parameter is missing (shown in MISSING REQUIRED PARAMETERS), "
|
||||
"you MUST ask the user for it and explain WHY you need it based on the parameter description.\n"
|
||||
"3. To use a tool, respond STRICTLY with this JSON format:\n"
|
||||
' { "thought": "explanation", "action": { "name": "tool_name", "args": { "arg": "value" } } }\n'
|
||||
" - No text before or after the JSON\n"
|
||||
" - All args must be complete and non-null\n"
|
||||
"4. You can use MULTIPLE TOOLS IN SEQUENCE:\n"
|
||||
" - After executing a tool, you will receive its result\n"
|
||||
" - You can then decide to use another tool based on the result\n"
|
||||
" - Or provide a final text response to the user\n"
|
||||
" - Continue using tools until you have all the information needed\n"
|
||||
"5. If you respond with text (not using a tool), respond normally in French.\n"
|
||||
"6. When you have all the information needed, provide a final response in NATURAL TEXT (not JSON).\n"
|
||||
"7. Extract the relevant information from the user's request and pass it as tool arguments.\n"
|
||||
"\n"
|
||||
"EXAMPLES:\n"
|
||||
" To set the download folder:\n"
|
||||
' { "thought": "User provided download path", "action": { "name": "set_path", "args": { "path_type": "download_folder", "path_value": "/home/user/downloads" } } }\n'
|
||||
"\n"
|
||||
" To set the TV show folder:\n"
|
||||
' { "thought": "User provided TV show path", "action": { "name": "set_path", "args": { "path_type": "tvshow_folder", "path_value": "/home/user/media/tvshows" } } }\n'
|
||||
"\n"
|
||||
" To list the download folder:\n"
|
||||
' { "thought": "User wants to see downloads", "action": { "name": "list_folder", "args": { "folder_type": "download", "path": "." } } }\n'
|
||||
"\n"
|
||||
" To list a subfolder in TV shows:\n"
|
||||
' { "thought": "User wants to see a specific show", "action": { "name": "list_folder", "args": { "folder_type": "tvshow", "path": "Game.of.Thrones" } } }\n'
|
||||
"\n"
|
||||
"AVAILABLE TOOLS:\n"
|
||||
f"{tools_desc}\n"
|
||||
)
|
||||
# Build context sections
|
||||
episodic_context = self._format_episodic_context()
|
||||
stm_context = self._format_stm_context()
|
||||
|
||||
config_json = json.dumps(memory.ltm.config, indent=2, ensure_ascii=False)
|
||||
|
||||
return f"""You are an AI agent helping a user manage their local media library.
|
||||
|
||||
{params_desc}
|
||||
|
||||
CURRENT CONFIGURATION:
|
||||
{config_json}
|
||||
{missing_info}
|
||||
|
||||
{f"SESSION CONTEXT:{chr(10)}{stm_context}" if stm_context else ""}
|
||||
|
||||
{f"CURRENT STATE:{chr(10)}{episodic_context}" if episodic_context else ""}
|
||||
|
||||
IMPORTANT RULES:
|
||||
1. When the user refers to a number (e.g., "the 3rd one", "download number 2"), \
|
||||
use `add_torrent_by_index` or `get_torrent_by_index` with that number.
|
||||
2. If a torrent search was performed, results are numbered. \
|
||||
The user can reference them by number.
|
||||
3. To use a tool, respond STRICTLY with this JSON format:
|
||||
{{ "thought": "explanation", "action": {{ "name": "tool_name", "args": {{ }} }} }}
|
||||
- No text before or after the JSON
|
||||
4. You can use MULTIPLE TOOLS IN SEQUENCE.
|
||||
5. When you have all the information needed, respond in NATURAL TEXT (not JSON).
|
||||
6. If a required parameter is missing, ask the user for it.
|
||||
7. Respond in the same language as the user.
|
||||
|
||||
EXAMPLES:
|
||||
- After a torrent search, if the user says "download the 3rd one":
|
||||
{{ "thought": "User wants torrent #3", "action": {{ "name": "add_torrent_by_index", \
|
||||
"args": {{ "index": 3 }} }} }}
|
||||
|
||||
- To search for torrents:
|
||||
{{ "thought": "Searching torrents", "action": {{ "name": "find_torrents", \
|
||||
"args": {{ "media_title": "Inception 1080p" }} }} }}
|
||||
|
||||
AVAILABLE TOOLS:
|
||||
{tools_desc}
|
||||
"""
|
||||
|
||||
+108
-50
@@ -1,123 +1,181 @@
|
||||
"""Tool registry and definitions."""
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Any, Dict
|
||||
from functools import partial
|
||||
"""Tool registry - defines and registers all available tools for the agent."""
|
||||
|
||||
from infrastructure.persistence.memory import Memory
|
||||
from .tools.filesystem import set_path_for_folder, list_folder
|
||||
from .tools.api import find_media_imdb_id, find_torrent, add_torrent_to_qbittorrent
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from .tools import api as api_tools
|
||||
from .tools import filesystem as fs_tools
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Tool:
|
||||
"""Represents a tool that can be used by the agent."""
|
||||
"""Represents a tool that can be used by the agent.
|
||||
|
||||
Attributes:
|
||||
name: Unique identifier for the tool.
|
||||
description: Human-readable description for the LLM.
|
||||
func: The callable that implements the tool.
|
||||
parameters: JSON Schema describing the tool's parameters.
|
||||
"""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
func: Callable[..., Dict[str, Any]]
|
||||
parameters: Dict[str, Any] # JSON Schema des paramètres
|
||||
func: Callable[..., dict[str, Any]]
|
||||
parameters: dict[str, Any]
|
||||
|
||||
|
||||
def make_tools(memory: Memory) -> Dict[str, Tool]:
|
||||
def make_tools() -> dict[str, Tool]:
|
||||
"""
|
||||
Create all available tools with memory bound to them.
|
||||
Create and register all available tools.
|
||||
|
||||
Args:
|
||||
memory: Memory instance to be used by the tools
|
||||
Tools access memory via get_memory() context.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping tool names to Tool instances
|
||||
Dictionary mapping tool names to Tool instances.
|
||||
"""
|
||||
# Create partial functions with memory pre-bound for filesystem tools
|
||||
set_path_func = partial(set_path_for_folder, memory)
|
||||
list_folder_func = partial(list_folder, memory)
|
||||
|
||||
tools = [
|
||||
# Filesystem tools
|
||||
Tool(
|
||||
name="set_path_for_folder",
|
||||
description="Sets a path in the configuration (download_folder, tvshow_folder, movie_folder, or torrent_folder).",
|
||||
func=set_path_func,
|
||||
description=(
|
||||
"Sets a path in the configuration "
|
||||
"(download_folder, tvshow_folder, movie_folder, or torrent_folder)."
|
||||
),
|
||||
func=fs_tools.set_path_for_folder,
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"folder_name": {
|
||||
"type": "string",
|
||||
"description": "Name of folder to set",
|
||||
"enum": ["download", "tvshow", "movie", "torrent"]
|
||||
"enum": ["download", "tvshow", "movie", "torrent"],
|
||||
},
|
||||
"path_value": {
|
||||
"type": "string",
|
||||
"description": "Absolute path to the folder (e.g., /home/user/downloads)"
|
||||
}
|
||||
"description": "Absolute path to the folder",
|
||||
},
|
||||
},
|
||||
"required": ["folder_name", "path_value"]
|
||||
}
|
||||
"required": ["folder_name", "path_value"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="list_folder",
|
||||
description="Lists the contents of a specified folder (download, tvshow, movie, or torrent).",
|
||||
func=list_folder_func,
|
||||
description="Lists the contents of a configured folder.",
|
||||
func=fs_tools.list_folder,
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"folder_type": {
|
||||
"type": "string",
|
||||
"description": "Type of folder to list: 'download', 'tvshow', 'movie', or 'torrent'",
|
||||
"enum": ["download", "tvshow", "movie", "torrent"]
|
||||
"description": "Type of folder to list",
|
||||
"enum": ["download", "tvshow", "movie", "torrent"],
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Relative path within the folder (default: '.' for root)",
|
||||
"default": "."
|
||||
}
|
||||
"description": "Relative path within the folder",
|
||||
"default": ".",
|
||||
},
|
||||
},
|
||||
"required": ["folder_type"]
|
||||
}
|
||||
"required": ["folder_type"],
|
||||
},
|
||||
),
|
||||
# Media search tools
|
||||
Tool(
|
||||
name="find_media_imdb_id",
|
||||
description="Finds the IMDb ID for a given media title using TMDB API.",
|
||||
func=find_media_imdb_id,
|
||||
description=(
|
||||
"Finds the IMDb ID for a given media title using TMDB API. "
|
||||
"Use this to get information about a movie or TV show."
|
||||
),
|
||||
func=api_tools.find_media_imdb_id,
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"media_title": {
|
||||
"type": "string",
|
||||
"description": "Title of the media to find the IMDb ID for"
|
||||
"description": "Title of the media to search for",
|
||||
},
|
||||
},
|
||||
"required": ["media_title"]
|
||||
}
|
||||
"required": ["media_title"],
|
||||
},
|
||||
),
|
||||
# Torrent tools
|
||||
Tool(
|
||||
name="find_torrents",
|
||||
description="Finds torrents for a given media title using Knaben API.",
|
||||
func=find_torrent,
|
||||
description=(
|
||||
"Finds torrents for a given media title. "
|
||||
"Results are numbered (1, 2, 3...) so the user can select by number."
|
||||
),
|
||||
func=api_tools.find_torrent,
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"media_title": {
|
||||
"type": "string",
|
||||
"description": "Title of the media to find torrents for"
|
||||
"description": "Title to search for (include quality if specified)",
|
||||
},
|
||||
},
|
||||
"required": ["media_title"]
|
||||
}
|
||||
"required": ["media_title"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="add_torrent_by_index",
|
||||
description=(
|
||||
"Adds a torrent from the previous search results by its number. "
|
||||
"Use when the user says 'download the 3rd one' or 'take number 2'."
|
||||
),
|
||||
func=api_tools.add_torrent_by_index,
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"index": {
|
||||
"type": "integer",
|
||||
"description": "Number of the torrent in search results (1, 2, 3...)",
|
||||
},
|
||||
},
|
||||
"required": ["index"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="add_torrent_to_qbittorrent",
|
||||
description="Adds a torrent to qBittorrent client.",
|
||||
func=add_torrent_to_qbittorrent,
|
||||
description=(
|
||||
"Adds a torrent to qBittorrent using a magnet link directly. "
|
||||
"Use add_torrent_by_index if user selected from search results."
|
||||
),
|
||||
func=api_tools.add_torrent_to_qbittorrent,
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"magnet_link": {
|
||||
"type": "string",
|
||||
"description": "Title of the media to find torrents for"
|
||||
"description": "The magnet link of the torrent",
|
||||
},
|
||||
},
|
||||
"required": ["magnet_link"]
|
||||
}
|
||||
"required": ["magnet_link"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="get_torrent_by_index",
|
||||
description=(
|
||||
"Gets details of a torrent from search results by its number, "
|
||||
"without downloading it."
|
||||
),
|
||||
func=api_tools.get_torrent_by_index,
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"index": {
|
||||
"type": "integer",
|
||||
"description": "Number of the torrent in search results (1, 2, 3...)",
|
||||
},
|
||||
},
|
||||
"required": ["index"],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
logger.info(f"Registered {len(tools)} tools")
|
||||
return {t.name: t for t in tools}
|
||||
|
||||
+17
-8
@@ -1,11 +1,20 @@
|
||||
"""Tools module - filesystem and API tools."""
|
||||
from .filesystem import set_path_for_folder, list_folder
|
||||
from .api import find_media_imdb_id, find_torrent, add_torrent_to_qbittorrent
|
||||
"""Tools module - filesystem and API tools for the agent."""
|
||||
|
||||
from .api import (
|
||||
add_torrent_by_index,
|
||||
add_torrent_to_qbittorrent,
|
||||
find_media_imdb_id,
|
||||
find_torrent,
|
||||
get_torrent_by_index,
|
||||
)
|
||||
from .filesystem import list_folder, set_path_for_folder
|
||||
|
||||
__all__ = [
|
||||
'set_path_for_folder',
|
||||
'list_folder',
|
||||
'find_media_imdb_id',
|
||||
'find_torrent',
|
||||
'add_torrent_to_qbittorrent',
|
||||
"set_path_for_folder",
|
||||
"list_folder",
|
||||
"find_media_imdb_id",
|
||||
"find_torrent",
|
||||
"get_torrent_by_index",
|
||||
"add_torrent_to_qbittorrent",
|
||||
"add_torrent_by_index",
|
||||
]
|
||||
|
||||
+158
-49
@@ -1,87 +1,196 @@
|
||||
"""API tools for interacting with external services - Adapted for DDD architecture."""
|
||||
from typing import Dict, Any
|
||||
"""API tools for interacting with external services."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
# Import use cases instead of direct API clients
|
||||
from application.movies import SearchMovieUseCase
|
||||
from application.torrents import SearchTorrentsUseCase, AddTorrentUseCase
|
||||
|
||||
# Import infrastructure clients
|
||||
from infrastructure.api.tmdb import tmdb_client
|
||||
from application.torrents import AddTorrentUseCase, SearchTorrentsUseCase
|
||||
from infrastructure.api.knaben import knaben_client
|
||||
from infrastructure.api.qbittorrent import qbittorrent_client
|
||||
from infrastructure.api.tmdb import tmdb_client
|
||||
from infrastructure.persistence import get_memory
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def find_media_imdb_id(media_title: str) -> Dict[str, Any]:
|
||||
def find_media_imdb_id(media_title: str) -> dict[str, Any]:
|
||||
"""
|
||||
Find the IMDb ID for a given media title using TMDB API.
|
||||
|
||||
This is a wrapper that uses the SearchMovieUseCase.
|
||||
|
||||
Args:
|
||||
media_title: Title of the media to search for
|
||||
media_title: Title of the media to search for.
|
||||
|
||||
Returns:
|
||||
Dict with IMDb ID or error information
|
||||
|
||||
Example:
|
||||
>>> result = find_media_imdb_id("Inception")
|
||||
>>> print(result)
|
||||
{'status': 'ok', 'imdb_id': 'tt1375666', 'title': 'Inception', ...}
|
||||
Dict with IMDb ID and media info, or error details.
|
||||
"""
|
||||
# Create use case with TMDB client
|
||||
use_case = SearchMovieUseCase(tmdb_client)
|
||||
|
||||
# Execute use case
|
||||
response = use_case.execute(media_title)
|
||||
|
||||
# Return as dict
|
||||
return response.to_dict()
|
||||
result = response.to_dict()
|
||||
|
||||
if result.get("status") == "ok":
|
||||
memory = get_memory()
|
||||
memory.stm.set_entity(
|
||||
"last_media_search",
|
||||
{
|
||||
"title": result.get("title"),
|
||||
"imdb_id": result.get("imdb_id"),
|
||||
"media_type": result.get("media_type"),
|
||||
"tmdb_id": result.get("tmdb_id"),
|
||||
},
|
||||
)
|
||||
memory.stm.set_topic("searching_media")
|
||||
logger.debug(f"Stored media search result in STM: {result.get('title')}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def find_torrent(media_title: str) -> Dict[str, Any]:
|
||||
def find_torrent(media_title: str) -> dict[str, Any]:
|
||||
"""
|
||||
Find torrents for a given media title using Knaben API.
|
||||
|
||||
This is a wrapper that uses the SearchTorrentsUseCase.
|
||||
Results are stored in episodic memory so the user can reference them
|
||||
by index (e.g., "download the 3rd one").
|
||||
|
||||
Args:
|
||||
media_title: Title of the media to search for
|
||||
media_title: Title of the media to search for.
|
||||
|
||||
Returns:
|
||||
Dict with torrent information or error details
|
||||
Dict with torrent list or error details.
|
||||
"""
|
||||
# Create use case with Knaben client
|
||||
logger.info(f"Searching torrents for: {media_title}")
|
||||
|
||||
use_case = SearchTorrentsUseCase(knaben_client)
|
||||
|
||||
# Execute use case
|
||||
response = use_case.execute(media_title, limit=10)
|
||||
|
||||
# Return as dict
|
||||
return response.to_dict()
|
||||
result = response.to_dict()
|
||||
|
||||
if result.get("status") == "ok":
|
||||
memory = get_memory()
|
||||
torrents = result.get("torrents", [])
|
||||
memory.episodic.store_search_results(
|
||||
query=media_title, results=torrents, search_type="torrent"
|
||||
)
|
||||
memory.stm.set_topic("selecting_torrent")
|
||||
logger.info(f"Stored {len(torrents)} torrent results in episodic memory")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def add_torrent_to_qbittorrent(magnet_link: str) -> Dict[str, Any]:
|
||||
def get_torrent_by_index(index: int) -> dict[str, Any]:
|
||||
"""
|
||||
Get a torrent from the last search results by its index.
|
||||
|
||||
Allows the user to reference results by number after a search.
|
||||
|
||||
Args:
|
||||
index: 1-based index of the torrent in the search results.
|
||||
|
||||
Returns:
|
||||
Dict with torrent data or error if not found.
|
||||
"""
|
||||
logger.info(f"Getting torrent at index: {index}")
|
||||
|
||||
memory = get_memory()
|
||||
|
||||
if memory.episodic.last_search_results:
|
||||
results_count = len(memory.episodic.last_search_results.get("results", []))
|
||||
query = memory.episodic.last_search_results.get("query", "unknown")
|
||||
logger.debug(f"Episodic memory has {results_count} results from: {query}")
|
||||
else:
|
||||
logger.warning("No search results in episodic memory")
|
||||
|
||||
result = memory.episodic.get_result_by_index(index)
|
||||
|
||||
if result:
|
||||
logger.info(f"Found torrent at index {index}: {result.get('name', 'unknown')}")
|
||||
return {"status": "ok", "torrent": result}
|
||||
|
||||
logger.warning(f"No torrent found at index {index}")
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "not_found",
|
||||
"message": f"No torrent found at index {index}. Search for torrents first.",
|
||||
}
|
||||
|
||||
|
||||
def add_torrent_to_qbittorrent(magnet_link: str) -> dict[str, Any]:
|
||||
"""
|
||||
Add a torrent to qBittorrent using a magnet link.
|
||||
|
||||
This is a wrapper that uses the AddTorrentUseCase.
|
||||
|
||||
Args:
|
||||
magnet_link: Magnet link of the torrent to add
|
||||
magnet_link: Magnet link of the torrent to add.
|
||||
|
||||
Returns:
|
||||
Dict with success or error information
|
||||
|
||||
Example:
|
||||
>>> result = add_torrent_to_qbittorrent("magnet:?xt=urn:btih:...")
|
||||
>>> print(result)
|
||||
{'status': 'ok', 'message': 'Torrent added successfully'}
|
||||
Dict with success status or error details.
|
||||
"""
|
||||
# Create use case with qBittorrent client
|
||||
logger.info("Adding torrent to qBittorrent")
|
||||
|
||||
use_case = AddTorrentUseCase(qbittorrent_client)
|
||||
|
||||
# Execute use case
|
||||
response = use_case.execute(magnet_link)
|
||||
|
||||
# Return as dict
|
||||
return response.to_dict()
|
||||
result = response.to_dict()
|
||||
|
||||
if result.get("status") == "ok":
|
||||
memory = get_memory()
|
||||
last_search = memory.episodic.get_search_results()
|
||||
torrent_name = "Unknown"
|
||||
|
||||
if last_search:
|
||||
for t in last_search.get("results", []):
|
||||
if t.get("magnet") == magnet_link:
|
||||
torrent_name = t.get("name", "Unknown")
|
||||
break
|
||||
|
||||
memory.episodic.add_active_download(
|
||||
{
|
||||
"task_id": magnet_link[:20],
|
||||
"name": torrent_name,
|
||||
"magnet": magnet_link,
|
||||
"progress": 0,
|
||||
"status": "queued",
|
||||
}
|
||||
)
|
||||
|
||||
memory.stm.set_topic("downloading")
|
||||
memory.stm.end_workflow()
|
||||
logger.info(f"Added download to episodic memory: {torrent_name}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def add_torrent_by_index(index: int) -> dict[str, Any]:
|
||||
"""
|
||||
Add a torrent from the last search results by its index.
|
||||
|
||||
Combines get_torrent_by_index and add_torrent_to_qbittorrent.
|
||||
|
||||
Args:
|
||||
index: 1-based index of the torrent in the search results.
|
||||
|
||||
Returns:
|
||||
Dict with success status or error details.
|
||||
"""
|
||||
logger.info(f"Adding torrent by index: {index}")
|
||||
|
||||
torrent_result = get_torrent_by_index(index)
|
||||
|
||||
if torrent_result.get("status") != "ok":
|
||||
return torrent_result
|
||||
|
||||
torrent = torrent_result.get("torrent", {})
|
||||
magnet = torrent.get("magnet")
|
||||
|
||||
if not magnet:
|
||||
logger.error("Torrent has no magnet link")
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "no_magnet",
|
||||
"message": "The selected torrent has no magnet link",
|
||||
}
|
||||
|
||||
logger.info(f"Adding torrent: {torrent.get('name', 'unknown')}")
|
||||
|
||||
result = add_torrent_to_qbittorrent(magnet)
|
||||
|
||||
if result.get("status") == "ok":
|
||||
result["torrent_name"] = torrent.get("name", "Unknown")
|
||||
|
||||
return result
|
||||
|
||||
+15
-34
@@ -1,59 +1,40 @@
|
||||
"""Filesystem tools - Adapted for DDD architecture."""
|
||||
from typing import Dict, Any
|
||||
"""Filesystem tools for folder management."""
|
||||
|
||||
# Import use cases
|
||||
from application.filesystem import SetFolderPathUseCase, ListFolderUseCase
|
||||
from typing import Any
|
||||
|
||||
# Import infrastructure
|
||||
from application.filesystem import ListFolderUseCase, SetFolderPathUseCase
|
||||
from infrastructure.filesystem import FileManager
|
||||
from infrastructure.persistence.memory import Memory
|
||||
|
||||
|
||||
def set_path_for_folder(memory: Memory, folder_name: str, path_value: str) -> Dict[str, Any]:
|
||||
def set_path_for_folder(folder_name: str, path_value: str) -> dict[str, Any]:
|
||||
"""
|
||||
Set a path in the configuration.
|
||||
Set a folder path in the configuration.
|
||||
|
||||
Args:
|
||||
memory: Memory instance to store the configuration
|
||||
folder_name: Name of folder to set (download, tvshow, movie, torrent)
|
||||
path_value: Absolute path to the folder
|
||||
folder_name: Name of folder to set (download, tvshow, movie, torrent).
|
||||
path_value: Absolute path to the folder.
|
||||
|
||||
Returns:
|
||||
Dict with status or error information
|
||||
Dict with status or error information.
|
||||
"""
|
||||
# Create file manager
|
||||
file_manager = FileManager(memory)
|
||||
|
||||
# Create use case
|
||||
file_manager = FileManager()
|
||||
use_case = SetFolderPathUseCase(file_manager)
|
||||
|
||||
# Execute use case
|
||||
response = use_case.execute(folder_name, path_value)
|
||||
|
||||
# Return as dict
|
||||
return response.to_dict()
|
||||
|
||||
|
||||
def list_folder(memory: Memory, folder_type: str, path: str = ".") -> Dict[str, Any]:
|
||||
def list_folder(folder_type: str, path: str = ".") -> dict[str, Any]:
|
||||
"""
|
||||
List contents of a folder.
|
||||
List contents of a configured folder.
|
||||
|
||||
Args:
|
||||
memory: Memory instance to retrieve the configuration
|
||||
folder_type: Type of folder to list (download, tvshow, movie, torrent)
|
||||
path: Relative path within the folder (default: ".")
|
||||
folder_type: Type of folder to list (download, tvshow, movie, torrent).
|
||||
path: Relative path within the folder (default: root).
|
||||
|
||||
Returns:
|
||||
Dict with folder contents or error information
|
||||
Dict with folder contents or error information.
|
||||
"""
|
||||
# Create file manager
|
||||
file_manager = FileManager(memory)
|
||||
|
||||
# Create use case
|
||||
file_manager = FileManager()
|
||||
use_case = ListFolderUseCase(file_manager)
|
||||
|
||||
# Execute use case
|
||||
response = use_case.execute(folder_type, path)
|
||||
|
||||
# Return as dict
|
||||
return response.to_dict()
|
||||
|
||||
Reference in New Issue
Block a user