e45465d52d
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.
300 lines
9.4 KiB
Python
300 lines
9.4 KiB
Python
"""Tests for PromptBuilder."""
|
|
|
|
from alfred.agent.prompts import PromptBuilder
|
|
|
|
from alfred.agent.registry import make_tools
|
|
from alfred.settings import settings
|
|
|
|
|
|
class TestPromptBuilder:
|
|
"""Tests for PromptBuilder."""
|
|
|
|
def test_init(self, memory):
|
|
"""Should initialize with tools."""
|
|
tools = make_tools(settings)
|
|
builder = PromptBuilder(tools)
|
|
|
|
assert builder.tools is tools
|
|
|
|
def test_build_system_prompt(self, memory):
|
|
"""Should build a complete system prompt."""
|
|
tools = make_tools(settings)
|
|
builder = PromptBuilder(tools)
|
|
|
|
prompt = builder.build_system_prompt()
|
|
|
|
assert "AI assistant" in prompt
|
|
assert "media library" in prompt
|
|
assert "AVAILABLE TOOLS" in prompt
|
|
|
|
def test_includes_tools(self, memory):
|
|
"""Should include all tool descriptions."""
|
|
tools = make_tools(settings)
|
|
builder = PromptBuilder(tools)
|
|
|
|
prompt = builder.build_system_prompt()
|
|
|
|
for tool_name in tools.keys():
|
|
assert tool_name in prompt
|
|
|
|
def test_includes_config(self, memory):
|
|
"""Should include current configuration."""
|
|
memory.ltm.download_folder = "/path/to/downloads"
|
|
tools = make_tools(settings)
|
|
builder = PromptBuilder(tools)
|
|
|
|
prompt = builder.build_system_prompt()
|
|
|
|
assert "/path/to/downloads" in prompt
|
|
|
|
def test_includes_search_results(self, memory_with_search_results):
|
|
"""Should include search results summary."""
|
|
tools = make_tools(settings)
|
|
builder = PromptBuilder(tools)
|
|
|
|
prompt = builder.build_system_prompt()
|
|
|
|
assert "LAST SEARCH" in prompt
|
|
assert "Inception 1080p" in prompt
|
|
assert "3 results" in prompt or "results available" in prompt
|
|
|
|
def test_includes_search_result_names(self, memory_with_search_results):
|
|
"""Should include search result names."""
|
|
tools = make_tools(settings)
|
|
builder = PromptBuilder(tools)
|
|
|
|
prompt = builder.build_system_prompt()
|
|
|
|
assert "Inception.2010.1080p.BluRay.x264" in prompt
|
|
|
|
def test_includes_active_downloads(self, memory):
|
|
"""Should include active downloads."""
|
|
memory.episodic.add_active_download(
|
|
{
|
|
"task_id": "123",
|
|
"name": "Test.Movie.mkv",
|
|
"progress": 50,
|
|
}
|
|
)
|
|
tools = make_tools(settings)
|
|
builder = PromptBuilder(tools)
|
|
|
|
prompt = builder.build_system_prompt()
|
|
|
|
assert "ACTIVE DOWNLOADS" in prompt
|
|
assert "Test.Movie.mkv" in prompt
|
|
|
|
def test_includes_pending_question(self, memory):
|
|
"""Should include pending question."""
|
|
memory.episodic.set_pending_question(
|
|
"Which torrent?",
|
|
[{"index": 1, "label": "Option 1"}, {"index": 2, "label": "Option 2"}],
|
|
{},
|
|
)
|
|
tools = make_tools(settings)
|
|
builder = PromptBuilder(tools)
|
|
|
|
prompt = builder.build_system_prompt()
|
|
|
|
assert "PENDING QUESTION" in prompt
|
|
assert "Which torrent?" in prompt
|
|
|
|
def test_includes_last_error(self, memory):
|
|
"""Should include last error."""
|
|
memory.episodic.add_error("find_torrent", "API timeout")
|
|
tools = make_tools(settings)
|
|
builder = PromptBuilder(tools)
|
|
|
|
prompt = builder.build_system_prompt()
|
|
|
|
assert "RECENT ERRORS" in prompt
|
|
assert "API timeout" in prompt
|
|
|
|
def test_includes_workflow(self, memory):
|
|
"""Should include current workflow."""
|
|
memory.stm.start_workflow("download", {"title": "Inception"})
|
|
tools = make_tools(settings)
|
|
builder = PromptBuilder(tools)
|
|
|
|
prompt = builder.build_system_prompt()
|
|
|
|
assert "CURRENT WORKFLOW" in prompt
|
|
assert "download" in prompt
|
|
|
|
def test_includes_topic(self, memory):
|
|
"""Should include current topic."""
|
|
memory.stm.set_topic("selecting_torrent")
|
|
tools = make_tools(settings)
|
|
builder = PromptBuilder(tools)
|
|
|
|
prompt = builder.build_system_prompt()
|
|
|
|
assert "CURRENT TOPIC" in prompt
|
|
assert "selecting_torrent" in prompt
|
|
|
|
def test_includes_entities(self, memory):
|
|
"""Should include extracted entities."""
|
|
memory.stm.set_entity("movie_title", "Inception")
|
|
memory.stm.set_entity("year", 2010)
|
|
tools = make_tools(settings)
|
|
builder = PromptBuilder(tools)
|
|
|
|
prompt = builder.build_system_prompt()
|
|
|
|
assert "EXTRACTED ENTITIES" in prompt
|
|
assert "Inception" in prompt
|
|
|
|
def test_includes_rules(self, memory):
|
|
"""Should include important rules."""
|
|
tools = make_tools(settings)
|
|
builder = PromptBuilder(tools)
|
|
|
|
prompt = builder.build_system_prompt()
|
|
|
|
assert "IMPORTANT RULES" in prompt
|
|
assert "add_torrent_by_index" in prompt
|
|
|
|
def test_includes_examples(self, memory):
|
|
"""Should include usage examples."""
|
|
tools = make_tools(settings)
|
|
builder = PromptBuilder(tools)
|
|
|
|
prompt = builder.build_system_prompt()
|
|
|
|
assert "EXAMPLES" in prompt
|
|
assert "download the 3rd one" in prompt or "torrent number" in prompt
|
|
|
|
def test_empty_context(self, memory):
|
|
"""Should handle empty context gracefully."""
|
|
tools = make_tools(settings)
|
|
builder = PromptBuilder(tools)
|
|
|
|
prompt = builder.build_system_prompt()
|
|
|
|
# Should not crash and should have basic structure
|
|
assert "AVAILABLE TOOLS" in prompt
|
|
assert "CURRENT CONFIGURATION" in prompt
|
|
|
|
def test_limits_search_results_display(self, memory):
|
|
"""Should limit displayed search results."""
|
|
# Add many results
|
|
results = [{"name": f"Torrent {i}", "seeders": i} for i in range(20)]
|
|
memory.episodic.store_search_results("test", results)
|
|
|
|
tools = make_tools(settings)
|
|
builder = PromptBuilder(tools)
|
|
|
|
prompt = builder.build_system_prompt()
|
|
|
|
# Should show first 5 and indicate more
|
|
assert "Torrent 0" in prompt or "1." in prompt
|
|
assert "... and" in prompt or "more" in prompt
|
|
|
|
# REMOVED: test_json_format_in_prompt
|
|
# We removed the "action" format from prompts as it was confusing the LLM
|
|
# The LLM now uses native OpenAI tool calling format
|
|
|
|
|
|
class TestFormatToolsDescription:
|
|
"""Tests for _format_tools_description method."""
|
|
|
|
def test_format_all_tools(self, memory):
|
|
"""Should format all tools."""
|
|
tools = make_tools(settings)
|
|
builder = PromptBuilder(tools)
|
|
|
|
desc = builder._format_tools_description()
|
|
|
|
for tool in tools.values():
|
|
assert tool.name in desc
|
|
assert tool.description in desc
|
|
|
|
def test_includes_parameters(self, memory):
|
|
"""Should include parameter schemas."""
|
|
tools = make_tools(settings)
|
|
builder = PromptBuilder(tools)
|
|
|
|
desc = builder._format_tools_description()
|
|
|
|
assert "Parameters:" in desc
|
|
assert '"type"' in desc
|
|
|
|
|
|
class TestFormatEpisodicContext:
|
|
"""Tests for _format_episodic_context method."""
|
|
|
|
def test_empty_episodic(self, memory):
|
|
"""Should return empty string for empty episodic."""
|
|
tools = make_tools(settings)
|
|
builder = PromptBuilder(tools)
|
|
|
|
context = builder._format_episodic_context(memory)
|
|
|
|
assert context == ""
|
|
|
|
def test_with_search_results(self, memory_with_search_results):
|
|
"""Should format search results."""
|
|
tools = make_tools(settings)
|
|
builder = PromptBuilder(tools)
|
|
|
|
context = builder._format_episodic_context(memory_with_search_results)
|
|
|
|
assert "LAST SEARCH" in context
|
|
assert "Inception 1080p" in context
|
|
|
|
def test_with_multiple_sections(self, memory):
|
|
"""Should format multiple sections."""
|
|
memory.episodic.store_search_results("test", [{"name": "Result"}])
|
|
memory.episodic.add_active_download({"task_id": "1", "name": "Download"})
|
|
memory.episodic.add_error("action", "error")
|
|
|
|
tools = make_tools(settings)
|
|
builder = PromptBuilder(tools)
|
|
|
|
context = builder._format_episodic_context(memory)
|
|
|
|
assert "LAST SEARCH" in context
|
|
assert "ACTIVE DOWNLOADS" in context
|
|
assert "RECENT ERRORS" in context
|
|
|
|
|
|
class TestFormatStmContext:
|
|
"""Tests for _format_stm_context method."""
|
|
|
|
def test_empty_stm(self, memory):
|
|
"""Should return language info even for empty STM."""
|
|
tools = make_tools(settings)
|
|
builder = PromptBuilder(tools)
|
|
|
|
context = builder._format_stm_context(memory)
|
|
|
|
# Should at least show language
|
|
assert "CONVERSATION LANGUAGE" in context or context == ""
|
|
|
|
def test_with_workflow(self, memory):
|
|
"""Should format workflow."""
|
|
memory.stm.start_workflow("download", {"title": "Test"})
|
|
|
|
tools = make_tools(settings)
|
|
builder = PromptBuilder(tools)
|
|
|
|
context = builder._format_stm_context(memory)
|
|
|
|
assert "CURRENT WORKFLOW" in context
|
|
assert "download" in context
|
|
|
|
def test_with_all_sections(self, memory):
|
|
"""Should format all STM sections."""
|
|
memory.stm.start_workflow("download", {"title": "Test"})
|
|
memory.stm.set_topic("searching")
|
|
memory.stm.set_entity("key", "value")
|
|
|
|
tools = make_tools(settings)
|
|
builder = PromptBuilder(tools)
|
|
|
|
context = builder._format_stm_context(memory)
|
|
|
|
assert "CURRENT WORKFLOW" in context
|
|
assert "CURRENT TOPIC" in context
|
|
assert "EXTRACTED ENTITIES" in context
|