feat: split resolve_destination, persona-driven prompts, qBittorrent relocation

Destination resolution
- Replace the single ResolveDestinationUseCase with four dedicated
  functions, one per release type:
    resolve_season_destination    (pack season, folder move)
    resolve_episode_destination   (single episode, file move)
    resolve_movie_destination     (movie, file move)
    resolve_series_destination    (multi-season pack, folder move)
- Each returns a dedicated DTO carrying only the fields relevant to
  that release type — no more polymorphic ResolvedDestination with
  half the fields unused depending on the case.
- Looser series folder matching: exact computed-name match is reused
  silently; any deviation (different group, multiple candidates) now
  prompts the user with all options including the computed name.

Agent tools
- Four new tools wrapping the use cases above; old resolve_destination
  removed from the registry.
- New move_to_destination tool: create_folder + move, chained — used
  after a resolve_* call to perform the actual relocation.
- Low-level filesystem_operations module (create_folder, move via mv)
  for instant same-FS renames (ZFS).

Prompt & persona
- New PromptBuilder (alfred/agent/prompt.py) replacing prompts.py:
  identity + personality block, situational expressions, memory
  schema, episodic/STM/config context, tool catalogue.
- Per-user expression system: knowledge/users/common.yaml +
  {username}.yaml are merged at runtime; one phrase per situation
  (greeting/success/error/...) is sampled into the system prompt.

qBittorrent integration
- Credentials now come from settings (qbittorrent_url/username/password)
  instead of hardcoded defaults.
- New client methods: find_by_name, set_location, recheck — the trio
  needed to update a torrent's save path and re-verify after a move.
- Host→container path translation settings (qbittorrent_host_path /
  qbittorrent_container_path) for docker-mounted setups.

Subtitles
- Identifier: strip parenthesized qualifiers (simplified, brazil…) at
  tokenization; new _tokenize_suffix used for the episode_subfolder
  pattern so episode-stem tokens no longer pollute language detection.
- Placer: extract _build_dest_name so it can be reused by the new
  dry_run path in ManageSubtitlesUseCase.
- Knowledge: add yue, ell, ind, msa, rus, vie, heb, tam, tel, tha,
  hin, ukr; add 'fre' to fra; add 'simplified'/'traditional' to zho.

Misc
- LTM workspace: add 'trash' folder slot.
- Default LLM provider switched to deepseek.
- testing/debug_release.py: CLI to parse a release, hit TMDB, and
  dry-run the destination resolution end-to-end.
This commit is contained in:
2026-05-14 05:01:59 +02:00
parent 1723b9fa53
commit e45465d52d
81 changed files with 2904 additions and 896 deletions
+1 -1
View File
@@ -190,7 +190,7 @@ class Agent:
async def step_streaming(
self, user_input: str, completion_id: str, created_ts: int, model: str
) -> AsyncGenerator[dict[str, Any], None]:
) -> AsyncGenerator[dict[str, Any]]:
"""
Execute agent step with streaming support for LibreChat.
+79
View File
@@ -0,0 +1,79 @@
"""Expression loader — charge et merge les fichiers YAML d'expressions par user."""
import random
from pathlib import Path
import yaml
_USERS_DIR = Path(__file__).parent.parent / "knowledge" / "users"
def _load_yaml(path: Path) -> dict:
if not path.exists():
return {}
return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
def load_expressions(username: str | None) -> dict:
"""
Charge common.yaml et le merge avec {username}.yaml.
Retourne un dict avec :
- nickname: str (surnom de l'user, ou username en fallback)
- expressions: dict[situation -> list[str]]
"""
common = _load_yaml(_USERS_DIR / "common.yaml")
user_data = _load_yaml(_USERS_DIR / f"{username}.yaml") if username else {}
# Merge expressions : common + user (les phrases user s'ajoutent)
common_exprs: dict[str, list] = common.get("expressions", {})
user_exprs: dict[str, list] = user_data.get("expressions", {})
merged: dict[str, list] = {}
all_situations = set(common_exprs) | set(user_exprs)
for situation in all_situations:
base = list(common_exprs.get(situation, []))
extra = list(user_exprs.get(situation, []))
merged[situation] = base + extra
nickname = user_data.get("user", {}).get("nickname") or username or "mec"
return {
"nickname": nickname,
"expressions": merged,
}
def pick(expressions: dict, situation: str, nickname: str | None = None) -> str:
"""
Pioche une expression aléatoire pour une situation donnée.
Résout {user} avec le nickname si fourni.
Retourne une string vide si la situation n'existe pas.
"""
options = expressions.get("expressions", {}).get(situation, [])
if not options:
return ""
chosen = random.choice(options)
if nickname:
chosen = chosen.replace("{user}", nickname)
return chosen
def build_expressions_context(username: str | None) -> dict:
"""
Point d'entrée principal.
Retourne :
- nickname: str
- samples: dict[situation -> une phrase résolue] — une seule par situation
"""
data = load_expressions(username)
nickname = data["nickname"]
samples = {
situation: pick(data, situation, nickname) for situation in data["expressions"]
}
return {
"nickname": nickname,
"samples": samples,
}
+239
View File
@@ -0,0 +1,239 @@
"""Prompt builder for the agent system."""
import json
from typing import Any
from alfred.infrastructure.persistence import get_memory
from alfred.infrastructure.persistence.memory import MemoryRegistry
from .expressions import build_expressions_context
from .registry import Tool
class PromptBuilder:
"""Builds system prompts for the agent with memory context."""
def __init__(self, tools: dict[str, Tool]):
self.tools = tools
self._memory_registry = MemoryRegistry()
def _format_identity(self, memory) -> str:
"""Build Alfred's identity and personality section."""
username = memory.stm.get_entity("username")
expr = build_expressions_context(username)
nickname = expr["nickname"]
samples = expr["samples"]
# Format expressions as situational guidance for the LLM
expr_lines = []
situation_labels = {
"greeting": "Salutation",
"success": "Succès",
"working": "En cours",
"error": "Erreur",
"unclear": "Demande floue",
"warning": "Avertissement",
"not_found": "Introuvable",
}
for situation, label in situation_labels.items():
phrase = samples.get(situation, "")
if phrase:
expr_lines.append(f' {label}: "{phrase}"')
expressions_block = "\n".join(expr_lines)
return f"""Tu t'appelles Alfred. Tu es un assistant d'organisation de médiathèque — direct, opérationnel, légèrement impertinent.
PERSONNALITÉ:
- Tu parles franglais : français avec des touches d'anglais quand ça colle mieux
- Tu es sarcastique et n'as pas peur de te moquer si l'user fait une connerie
- Tu envoies chier poliment (mais clairement) quand la demande est trop floue
- Tu ne fais pas de blabla inutile. Si "ok c'est fait" suffit, c'est tout ce que tu dis
- Tu peux jurer (putain, merde, con, ...) — c'est naturel, pas du remplissage
- Jamais de "Great question!" ou de politesse creuse
USER COURANT: {nickname}
EXPRESSIONS À UTILISER (une par situation, naturellement intégrées dans ta réponse) :
{expressions_block}"""
def build_tools_spec(self) -> list[dict[str, Any]]:
"""Build the tool specification for the LLM API."""
tool_specs = []
for tool in self.tools.values():
spec = {
"type": "function",
"function": {
"name": tool.name,
"description": tool.description,
"parameters": tool.parameters,
},
}
tool_specs.append(spec)
return tool_specs
def _format_tools_description(self) -> str:
"""Format tools with their descriptions and parameters."""
if not self.tools:
return ""
return "\n".join(
f"- {tool.name}: {tool.description}\n"
f" Parameters: {json.dumps(tool.parameters, ensure_ascii=False)}"
for tool in self.tools.values()
)
def _format_episodic_context(self, memory) -> str:
"""Format episodic memory context for the prompt."""
lines = []
if memory.episodic.last_search_results:
results = memory.episodic.last_search_results
result_list = results.get("results", [])
lines.append(
f"\nLAST SEARCH: '{results.get('query')}' ({len(result_list)} results)"
)
# Show first 5 results
for i, result in enumerate(result_list[:5]):
name = result.get("name", "Unknown")
lines.append(f" {i + 1}. {name}")
if len(result_list) > 5:
lines.append(f" ... and {len(result_list) - 5} more")
if memory.episodic.pending_question:
question = memory.episodic.pending_question
lines.append(f"\nPENDING QUESTION: {question.get('question')}")
lines.append(f" Type: {question.get('type')}")
if question.get("options"):
lines.append(f" Options: {len(question.get('options'))}")
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)}%")
if memory.episodic.recent_errors:
lines.append("\nRECENT ERRORS (up to 3):")
for error in memory.episodic.recent_errors[-3:]:
lines.append(
f" - Action '{error.get('action')}' failed: {error.get('error')}"
)
# 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 event in unread[:3]:
lines.append(f" - {event.get('type')}: {event.get('data')}")
return "\n".join(lines)
def _format_stm_context(self, memory) -> str:
"""Format short-term memory context for the prompt."""
lines = []
if memory.stm.current_workflow:
workflow = memory.stm.current_workflow
lines.append(
f"CURRENT WORKFLOW: {workflow.get('type')} (stage: {workflow.get('stage')})"
)
if workflow.get("target"):
lines.append(f" Target: {workflow.get('target')}")
if memory.stm.current_topic:
lines.append(f"CURRENT TOPIC: {memory.stm.current_topic}")
if memory.stm.extracted_entities:
lines.append("EXTRACTED ENTITIES:")
for key, value in memory.stm.extracted_entities.items():
lines.append(f" - {key}: {value}")
if memory.stm.language:
lines.append(f"CONVERSATION LANGUAGE: {memory.stm.language}")
return "\n".join(lines)
def _format_memory_schema(self) -> str:
"""Describe available memory components so the agent knows what to read/write and when."""
schema = self._memory_registry.schema()
tier_labels = {
"ltm": "LONG-TERM (persisted)",
"stm": "SHORT-TERM (session)",
"episodic": "EPISODIC (volatile)",
}
lines = ["MEMORY COMPONENTS:"]
for tier, components in schema.items():
if not components:
continue
lines.append(f"\n [{tier_labels.get(tier, tier.upper())}]")
for c in components:
access = c.get("access", "read")
lines.append(f" {c['name']} ({access}): {c['description']}")
for field_name, field_desc in c.get("fields", {}).items():
lines.append(f" · {field_name}: {field_desc}")
return "\n".join(lines)
def _format_config_context(self, memory) -> str:
"""Format configuration context."""
lines = ["CURRENT CONFIGURATION:"]
folders = {
**memory.ltm.workspace.as_dict(),
**memory.ltm.library_paths.to_dict(),
}
if folders:
for key, value in folders.items():
lines.append(f" - {key}: {value}")
else:
lines.append(" (no configuration set)")
return "\n".join(lines)
def build_system_prompt(self) -> str:
"""Build the complete system prompt."""
memory = get_memory()
# Identity + personality
identity = self._format_identity(memory)
# Language instruction
language_instruction = (
"Si la langue de l'user est différente de la langue courante en STM, "
"appelle `set_language` en premier avant de répondre."
)
# Configuration
config_section = self._format_config_context(memory)
# STM context
stm_context = self._format_stm_context(memory)
# Episodic context
episodic_context = self._format_episodic_context(memory)
# Memory schema
memory_schema = self._format_memory_schema()
# Available tools
tools_desc = self._format_tools_description()
tools_section = f"\nOUTILS DISPONIBLES:\n{tools_desc}" if tools_desc else ""
rules = """
RÈGLES:
- Utilise les outils pour accomplir les tâches, pas pour décorer
- Si des résultats de recherche sont dispo en mémoire épisodique, référence-les par index
- Confirme toujours avant une opération destructive (move, delete, overwrite)
- Réponses courtes — si c'est fait, dis-le en une ligne
- Si la demande est floue, demande un éclaircissement AVANT de lancer quoi que ce soit
"""
sections = [
identity,
language_instruction,
config_section,
stm_context,
episodic_context,
memory_schema,
tools_section,
rules,
]
return "\n\n".join(s for s in sections if s and s.strip())
+5 -1
View File
@@ -99,8 +99,12 @@ def make_tools(settings) -> dict[str, Tool]:
fs_tools.list_folder,
fs_tools.analyze_release,
fs_tools.probe_media,
fs_tools.resolve_destination,
fs_tools.resolve_season_destination_tool,
fs_tools.resolve_episode_destination_tool,
fs_tools.resolve_movie_destination_tool,
fs_tools.resolve_series_destination_tool,
fs_tools.move_media,
fs_tools.move_to_destination,
fs_tools.manage_subtitles,
fs_tools.create_seed_links,
fs_tools.learn,
+190 -70
View File
@@ -3,20 +3,25 @@
from pathlib import Path
from typing import Any
import alfred as _alfred_pkg
import yaml
import alfred as _alfred_pkg
from alfred.application.filesystem import (
CreateSeedLinksUseCase,
ListFolderUseCase,
ManageSubtitlesUseCase,
MoveMediaUseCase,
ResolveDestinationUseCase,
SetFolderPathUseCase,
)
from alfred.application.filesystem.detect_media_type import detect_media_type
from alfred.application.filesystem.enrich_from_probe import enrich_from_probe
from alfred.infrastructure.filesystem import FileManager
from alfred.application.filesystem.resolve_destination import (
resolve_episode_destination,
resolve_movie_destination,
resolve_season_destination,
resolve_series_destination,
)
from alfred.infrastructure.filesystem import FileManager, create_folder, move
from alfred.infrastructure.filesystem.ffprobe import probe
from alfred.infrastructure.filesystem.find_video import find_video_file
@@ -42,7 +47,57 @@ def move_media(source: str, destination: str) -> dict[str, Any]:
return use_case.execute(source, destination).to_dict()
def resolve_destination(
def move_to_destination(source: str, destination: str) -> dict[str, Any]:
"""
Move a file or folder to a destination, creating parent directories if needed.
Use this after resolve_*_destination to perform the actual move.
The destination parent is created automatically if it doesn't exist.
Args:
source: Absolute path to the source file or folder.
destination: Absolute path to the destination.
Returns:
Dict with status, source, destination — or error details.
"""
parent = str(Path(destination).parent)
result = create_folder(parent)
if result["status"] != "ok":
return result
return move(source, destination)
def resolve_season_destination_tool(
release_name: str,
tmdb_title: str,
tmdb_year: int,
confirmed_folder: str | None = None,
) -> dict[str, Any]:
"""
Compute destination paths for a season pack (folder move).
Returns series_folder + season_folder. No file paths — the whole
source folder is moved as-is into season_folder.
Args:
release_name: Raw release folder name (e.g. "Oz.S03.1080p.WEBRip.x265-KONTRAST").
tmdb_title: Canonical show title from TMDB (e.g. "Oz").
tmdb_year: Show start year from TMDB (e.g. 1997).
confirmed_folder: If needs_clarification was returned, pass the chosen folder name.
Returns:
On success: dict with status, series_folder, season_folder, series_folder_name,
season_folder_name, is_new_series_folder.
On ambiguity: dict with status="needs_clarification", question, options.
On error: dict with status="error", error, message.
"""
return resolve_season_destination(
release_name, tmdb_title, tmdb_year, confirmed_folder
).to_dict()
def resolve_episode_destination_tool(
release_name: str,
source_file: str,
tmdb_title: str,
@@ -51,44 +106,91 @@ def resolve_destination(
confirmed_folder: str | None = None,
) -> dict[str, Any]:
"""
Compute the destination path in the media library for a release.
Compute destination paths for a single episode file.
Call this before move_media to get the correct library path. Handles:
- Parsing the release name (quality, codec, group, season/episode)
- Looking up any existing series folder in the library
- Applying group-conflict rules (asks user if ambiguous)
- Building the full destination path with correct naming conventions
Returns series_folder + season_folder + library_file (full path to destination .mkv).
Args:
release_name: Raw release folder or file name
(e.g. "Oz.S03.1080p.WEBRip.x265-KONTRAST").
release_name: Raw release file name (e.g. "Oz.S03E01.1080p.WEBRip.x265-KONTRAST.mkv").
source_file: Absolute path to the source video file (used for extension).
tmdb_title: Canonical show/movie title from TMDB (e.g. "Oz").
tmdb_year: Release/start year from TMDB (e.g. 1997).
tmdb_episode_title: Episode title from TMDB for single-episode releases
(e.g. "The Routine"). Omit for season packs and movies.
confirmed_folder: If a previous call returned needs_clarification, pass
the user-chosen folder name here to proceed.
tmdb_title: Canonical show title from TMDB (e.g. "Oz").
tmdb_year: Show start year from TMDB (e.g. 1997).
tmdb_episode_title: Episode title from TMDB (e.g. "The Routine"). Optional.
confirmed_folder: If needs_clarification was returned, pass the chosen folder name.
Returns:
On success: dict with status, library_file, series_folder, season_folder,
series_folder_name, season_folder_name, filename,
is_new_series_folder.
On success: dict with status, series_folder, season_folder, library_file,
series_folder_name, season_folder_name, filename, is_new_series_folder.
On ambiguity: dict with status="needs_clarification", question, options.
On error: dict with status="error", error, message.
"""
use_case = ResolveDestinationUseCase()
return use_case.execute(
release_name=release_name,
source_file=source_file,
tmdb_title=tmdb_title,
tmdb_year=tmdb_year,
tmdb_episode_title=tmdb_episode_title,
confirmed_folder=confirmed_folder,
return resolve_episode_destination(
release_name,
source_file,
tmdb_title,
tmdb_year,
tmdb_episode_title,
confirmed_folder,
).to_dict()
def create_seed_links(library_file: str, original_download_folder: str) -> dict[str, Any]:
def resolve_movie_destination_tool(
release_name: str,
source_file: str,
tmdb_title: str,
tmdb_year: int,
) -> dict[str, Any]:
"""
Compute destination paths for a movie file.
Returns movie_folder + library_file (full path to destination .mkv).
Args:
release_name: Raw release folder/file name (e.g. "Inception.2010.1080p.BluRay.x265-GROUP").
source_file: Absolute path to the source video file (used for extension).
tmdb_title: Canonical movie title from TMDB (e.g. "Inception").
tmdb_year: Movie release year from TMDB (e.g. 2010).
Returns:
On success: dict with status, movie_folder, library_file, movie_folder_name,
filename, is_new_folder.
On error: dict with status="error", error, message.
"""
return resolve_movie_destination(
release_name, source_file, tmdb_title, tmdb_year
).to_dict()
def resolve_series_destination_tool(
release_name: str,
tmdb_title: str,
tmdb_year: int,
confirmed_folder: str | None = None,
) -> dict[str, Any]:
"""
Compute destination path for a complete multi-season series pack (folder move).
Returns only series_folder — the whole pack lands directly inside it.
Args:
release_name: Raw release folder name.
tmdb_title: Canonical show title from TMDB.
tmdb_year: Show start year from TMDB.
confirmed_folder: If needs_clarification was returned, pass the chosen folder name.
Returns:
On success: dict with status, series_folder, series_folder_name, is_new_series_folder.
On ambiguity: dict with status="needs_clarification", question, options.
On error: dict with status="error", error, message.
"""
return resolve_series_destination(
release_name, tmdb_title, tmdb_year, confirmed_folder
).to_dict()
def create_seed_links(
library_file: str, original_download_folder: str
) -> dict[str, Any]:
"""
Prepare a torrent subfolder so qBittorrent can keep seeding after a move.
@@ -159,10 +261,18 @@ def learn(pack: str, category: str, key: str, values: list[str]) -> dict[str, An
_VALID_CATEGORIES = {"languages", "types", "formats"}
if pack not in _VALID_PACKS:
return {"status": "error", "error": "unknown_pack", "message": f"Unknown pack '{pack}'. Valid: {sorted(_VALID_PACKS)}"}
return {
"status": "error",
"error": "unknown_pack",
"message": f"Unknown pack '{pack}'. Valid: {sorted(_VALID_PACKS)}",
}
if category not in _VALID_CATEGORIES:
return {"status": "error", "error": "unknown_category", "message": f"Unknown category '{category}'. Valid: {sorted(_VALID_CATEGORIES)}"}
return {
"status": "error",
"error": "unknown_category",
"message": f"Unknown category '{category}'. Valid: {sorted(_VALID_CATEGORIES)}",
}
learned_path = _LEARNED_ROOT / "subtitles_learned.yaml"
_LEARNED_ROOT.mkdir(parents=True, exist_ok=True)
@@ -184,7 +294,9 @@ def learn(pack: str, category: str, key: str, values: list[str]) -> dict[str, An
tmp = learned_path.with_suffix(".yaml.tmp")
try:
with open(tmp, "w", encoding="utf-8") as f:
yaml.safe_dump(data, f, allow_unicode=True, default_flow_style=False, sort_keys=False)
yaml.safe_dump(
data, f, allow_unicode=True, default_flow_style=False, sort_keys=False
)
tmp.rename(learned_path)
except Exception as e:
tmp.unlink(missing_ok=True)
@@ -252,26 +364,26 @@ def analyze_release(release_name: str, source_path: str) -> dict[str, Any]:
return {
"status": "ok",
"media_type": parsed.media_type,
"parse_path": parsed.parse_path,
"title": parsed.title,
"year": parsed.year,
"season": parsed.season,
"episode": parsed.episode,
"episode_end": parsed.episode_end,
"quality": parsed.quality,
"source": parsed.source,
"codec": parsed.codec,
"group": parsed.group,
"languages": parsed.languages,
"audio_codec": parsed.audio_codec,
"audio_channels": parsed.audio_channels,
"bit_depth": parsed.bit_depth,
"hdr_format": parsed.hdr_format,
"edition": parsed.edition,
"site_tag": parsed.site_tag,
"is_season_pack": parsed.is_season_pack,
"probe_used": probe_used,
"media_type": parsed.media_type,
"parse_path": parsed.parse_path,
"title": parsed.title,
"year": parsed.year,
"season": parsed.season,
"episode": parsed.episode,
"episode_end": parsed.episode_end,
"quality": parsed.quality,
"source": parsed.source,
"codec": parsed.codec,
"group": parsed.group,
"languages": parsed.languages,
"audio_codec": parsed.audio_codec,
"audio_channels": parsed.audio_channels,
"bit_depth": parsed.bit_depth,
"hdr_format": parsed.hdr_format,
"edition": parsed.edition,
"site_tag": parsed.site_tag,
"is_season_pack": parsed.is_season_pack,
"probe_used": probe_used,
}
@@ -293,45 +405,53 @@ def probe_media(source_path: str) -> dict[str, Any]:
"""
path = Path(source_path)
if not path.exists():
return {"status": "error", "error": "not_found", "message": f"{source_path} does not exist"}
return {
"status": "error",
"error": "not_found",
"message": f"{source_path} does not exist",
}
media_info = probe(path)
if media_info is None:
return {"status": "error", "error": "probe_failed", "message": "ffprobe failed to read the file"}
return {
"status": "error",
"error": "probe_failed",
"message": "ffprobe failed to read the file",
}
return {
"status": "ok",
"video": {
"codec": media_info.video_codec,
"resolution": media_info.resolution,
"width": media_info.width,
"height": media_info.height,
"codec": media_info.video_codec,
"resolution": media_info.resolution,
"width": media_info.width,
"height": media_info.height,
"duration_seconds": media_info.duration_seconds,
"bitrate_kbps": media_info.bitrate_kbps,
"bitrate_kbps": media_info.bitrate_kbps,
},
"audio_tracks": [
{
"index": t.index,
"codec": t.codec,
"channels": t.channels,
"index": t.index,
"codec": t.codec,
"channels": t.channels,
"channel_layout": t.channel_layout,
"language": t.language,
"is_default": t.is_default,
"language": t.language,
"is_default": t.is_default,
}
for t in media_info.audio_tracks
],
"subtitle_tracks": [
{
"index": t.index,
"codec": t.codec,
"language": t.language,
"index": t.index,
"codec": t.codec,
"language": t.language,
"is_default": t.is_default,
"is_forced": t.is_forced,
"is_forced": t.is_forced,
}
for t in media_info.subtitle_tracks
],
"audio_languages": media_info.audio_languages,
"is_multi_audio": media_info.is_multi_audio,
"is_multi_audio": media_info.is_multi_audio,
}