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.
403 lines
12 KiB
Python
403 lines
12 KiB
Python
"""Edge case tests for PromptBuilder."""
|
|
|
|
from alfred.agent.prompts import PromptBuilder
|
|
|
|
from alfred.agent.registry import make_tools
|
|
from alfred.settings import settings
|
|
|
|
|
|
class TestPromptBuilderEdgeCases:
|
|
"""Edge case tests for PromptBuilder."""
|
|
|
|
def test_prompt_with_empty_memory(self, memory):
|
|
"""Should build prompt with completely empty memory."""
|
|
tools = make_tools(settings)
|
|
builder = PromptBuilder(tools)
|
|
|
|
prompt = builder.build_system_prompt()
|
|
|
|
assert "AVAILABLE TOOLS" in prompt
|
|
assert "CURRENT CONFIGURATION" in prompt
|
|
|
|
def test_prompt_with_unicode_config(self, memory):
|
|
"""Should handle unicode in config."""
|
|
memory.ltm.download_folder = "/path/to/日本語"
|
|
memory.ltm.tvshow_folder = "/path/🎬"
|
|
|
|
tools = make_tools(settings)
|
|
builder = PromptBuilder(tools)
|
|
|
|
prompt = builder.build_system_prompt()
|
|
|
|
assert "日本語" in prompt
|
|
assert "🎬" in prompt
|
|
|
|
def test_prompt_with_very_long_config_value(self, memory):
|
|
"""Should handle very long config values."""
|
|
long_path = "/very/long/path/" + "x" * 1000
|
|
memory.ltm.download_folder = long_path
|
|
|
|
tools = make_tools(settings)
|
|
builder = PromptBuilder(tools)
|
|
|
|
prompt = builder.build_system_prompt()
|
|
|
|
# Should include the path (possibly truncated)
|
|
assert "very/long/path" in prompt
|
|
|
|
def test_prompt_with_special_chars_in_config(self, memory):
|
|
"""Should escape special characters in config."""
|
|
memory.ltm.download_folder = '/path/with "quotes" and \\backslash'
|
|
|
|
tools = make_tools(settings)
|
|
builder = PromptBuilder(tools)
|
|
|
|
prompt = builder.build_system_prompt()
|
|
|
|
# Should be valid (not crash)
|
|
assert "CURRENT CONFIGURATION" in prompt
|
|
|
|
def test_prompt_with_many_search_results(self, memory):
|
|
"""Should limit displayed search results."""
|
|
results = [{"name": f"Torrent {i}", "seeders": i} for i in range(50)]
|
|
memory.episodic.store_search_results("test query", results)
|
|
|
|
tools = make_tools(settings)
|
|
builder = PromptBuilder(tools)
|
|
|
|
prompt = builder.build_system_prompt()
|
|
|
|
# Should show limited results
|
|
assert "LAST SEARCH" in prompt
|
|
# Should indicate there are more
|
|
assert "more" in prompt.lower() or "..." in prompt
|
|
|
|
def test_prompt_with_search_results_missing_fields(self, memory):
|
|
"""Should handle search results with missing fields."""
|
|
results = [
|
|
{"name": "Complete"},
|
|
{}, # Empty
|
|
{"seeders": 100}, # Missing name
|
|
]
|
|
memory.episodic.store_search_results("test", results)
|
|
|
|
tools = make_tools(settings)
|
|
builder = PromptBuilder(tools)
|
|
|
|
prompt = builder.build_system_prompt()
|
|
|
|
# Should not crash
|
|
assert "LAST SEARCH" in prompt
|
|
|
|
def test_prompt_with_many_active_downloads(self, memory):
|
|
"""Should limit displayed active downloads."""
|
|
for i in range(20):
|
|
memory.episodic.add_active_download(
|
|
{
|
|
"task_id": str(i),
|
|
"name": f"Download {i}",
|
|
"progress": i * 5,
|
|
}
|
|
)
|
|
|
|
tools = make_tools(settings)
|
|
builder = PromptBuilder(tools)
|
|
|
|
prompt = builder.build_system_prompt()
|
|
|
|
assert "ACTIVE DOWNLOADS" in prompt
|
|
# Should show limited number
|
|
assert "Download 0" in prompt
|
|
|
|
def test_prompt_with_many_errors(self, memory):
|
|
"""Should show recent errors."""
|
|
for i in range(10):
|
|
memory.episodic.add_error(f"action_{i}", f"Error {i}")
|
|
|
|
tools = make_tools(settings)
|
|
builder = PromptBuilder(tools)
|
|
|
|
prompt = builder.build_system_prompt()
|
|
|
|
assert "RECENT ERRORS" in prompt
|
|
# Should show the most recent errors (up to 3)
|
|
|
|
def test_prompt_with_pending_question_many_options(self, memory):
|
|
"""Should handle pending question with many options."""
|
|
options = [{"index": i, "label": f"Option {i}"} for i in range(20)]
|
|
memory.episodic.set_pending_question("Choose one:", options, {})
|
|
|
|
tools = make_tools(settings)
|
|
builder = PromptBuilder(tools)
|
|
|
|
prompt = builder.build_system_prompt()
|
|
|
|
assert "PENDING QUESTION" in prompt
|
|
assert "Choose one:" in prompt
|
|
|
|
def test_prompt_with_complex_workflow(self, memory):
|
|
"""Should handle complex workflow state."""
|
|
memory.stm.start_workflow(
|
|
"download",
|
|
{
|
|
"title": "Test Movie",
|
|
"year": 2024,
|
|
"quality": "1080p",
|
|
"nested": {"deep": {"value": "test"}},
|
|
},
|
|
)
|
|
memory.stm.update_workflow_stage("searching_torrents")
|
|
|
|
tools = make_tools(settings)
|
|
builder = PromptBuilder(tools)
|
|
|
|
prompt = builder.build_system_prompt()
|
|
|
|
assert "CURRENT WORKFLOW" in prompt
|
|
assert "download" in prompt
|
|
assert "searching_torrents" in prompt
|
|
|
|
def test_prompt_with_many_entities(self, memory):
|
|
"""Should handle many extracted entities."""
|
|
for i in range(50):
|
|
memory.stm.set_entity(f"entity_{i}", f"value_{i}")
|
|
|
|
tools = make_tools(settings)
|
|
builder = PromptBuilder(tools)
|
|
|
|
prompt = builder.build_system_prompt()
|
|
|
|
assert "EXTRACTED ENTITIES" in prompt
|
|
|
|
def test_prompt_with_null_values_in_entities(self, memory):
|
|
"""Should handle null values in entities."""
|
|
memory.stm.set_entity("null_value", None)
|
|
memory.stm.set_entity("empty_string", "")
|
|
memory.stm.set_entity("zero", 0)
|
|
memory.stm.set_entity("false", False)
|
|
|
|
tools = make_tools(settings)
|
|
builder = PromptBuilder(tools)
|
|
|
|
prompt = builder.build_system_prompt()
|
|
|
|
# Should not crash
|
|
assert "EXTRACTED ENTITIES" in prompt
|
|
|
|
def test_prompt_with_unread_events(self, memory):
|
|
"""Should include unread events."""
|
|
memory.episodic.add_background_event("download_complete", {"name": "Movie.mkv"})
|
|
memory.episodic.add_background_event("new_files", {"count": 5})
|
|
|
|
tools = make_tools(settings)
|
|
builder = PromptBuilder(tools)
|
|
|
|
prompt = builder.build_system_prompt()
|
|
|
|
assert "UNREAD EVENTS" in prompt
|
|
|
|
def test_prompt_with_all_sections(self, memory):
|
|
"""Should include all sections when all data present."""
|
|
# Config
|
|
memory.ltm.download_folder = "/downloads"
|
|
|
|
# Search results
|
|
memory.episodic.store_search_results("test", [{"name": "Result"}])
|
|
|
|
# Active downloads
|
|
memory.episodic.add_active_download({"task_id": "1", "name": "Download"})
|
|
|
|
# Errors
|
|
memory.episodic.add_error("action", "error")
|
|
|
|
# Pending question
|
|
memory.episodic.set_pending_question("Question?", [], {})
|
|
|
|
# Workflow
|
|
memory.stm.start_workflow("download", {"title": "Test"})
|
|
|
|
# Topic
|
|
memory.stm.set_topic("searching")
|
|
|
|
# Entities
|
|
memory.stm.set_entity("key", "value")
|
|
|
|
# Events
|
|
memory.episodic.add_background_event("event", {})
|
|
|
|
tools = make_tools(settings)
|
|
builder = PromptBuilder(tools)
|
|
|
|
prompt = builder.build_system_prompt()
|
|
|
|
# All sections should be present
|
|
assert "CURRENT CONFIGURATION" in prompt
|
|
assert "LAST SEARCH" in prompt
|
|
assert "ACTIVE DOWNLOADS" in prompt
|
|
assert "RECENT ERRORS" in prompt
|
|
assert "PENDING QUESTION" in prompt
|
|
assert "CURRENT WORKFLOW" in prompt
|
|
assert "CURRENT TOPIC" in prompt
|
|
assert "EXTRACTED ENTITIES" in prompt
|
|
assert "UNREAD EVENTS" in prompt
|
|
|
|
def test_prompt_json_serializable(self, memory):
|
|
"""Should produce JSON-serializable content."""
|
|
memory.ltm.download_folder = "/some/path"
|
|
memory.stm.set_entity("complex", {"a": {"b": {"c": "d"}}})
|
|
|
|
tools = make_tools(settings)
|
|
builder = PromptBuilder(tools)
|
|
|
|
prompt = builder.build_system_prompt()
|
|
|
|
# The prompt itself is a string, but embedded JSON should be valid
|
|
assert isinstance(prompt, str)
|
|
|
|
|
|
class TestFormatToolsDescriptionEdgeCases:
|
|
"""Edge case tests for _format_tools_description."""
|
|
|
|
def test_format_with_no_tools(self, memory):
|
|
"""Should handle empty tools dict."""
|
|
builder = PromptBuilder({})
|
|
|
|
desc = builder._format_tools_description()
|
|
|
|
assert desc == ""
|
|
|
|
def test_format_with_complex_parameters(self, memory):
|
|
"""Should format complex parameter schemas."""
|
|
from alfred.agent.registry import Tool
|
|
|
|
tools = {
|
|
"complex_tool": Tool(
|
|
name="complex_tool",
|
|
description="A complex tool",
|
|
func=lambda: {},
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"nested": {
|
|
"type": "object",
|
|
"properties": {
|
|
"deep": {"type": "string"},
|
|
},
|
|
},
|
|
"array": {
|
|
"type": "array",
|
|
"items": {"type": "integer"},
|
|
},
|
|
},
|
|
"required": ["nested"],
|
|
},
|
|
),
|
|
}
|
|
|
|
builder = PromptBuilder(tools)
|
|
desc = builder._format_tools_description()
|
|
|
|
assert "complex_tool" in desc
|
|
assert "nested" in desc
|
|
|
|
|
|
class TestFormatEpisodicContextEdgeCases:
|
|
"""Edge case tests for _format_episodic_context."""
|
|
|
|
def test_format_with_empty_search_query(self, memory):
|
|
"""Should handle empty search query."""
|
|
memory.episodic.store_search_results("", [{"name": "Result"}])
|
|
|
|
tools = make_tools(settings)
|
|
builder = PromptBuilder(tools)
|
|
|
|
context = builder._format_episodic_context(memory)
|
|
|
|
assert "LAST SEARCH" in context
|
|
|
|
def test_format_with_search_results_none_names(self, memory):
|
|
"""Should handle results with None names."""
|
|
memory.episodic.store_search_results(
|
|
"test",
|
|
[
|
|
{"name": None},
|
|
{"title": None},
|
|
{},
|
|
],
|
|
)
|
|
|
|
tools = make_tools(settings)
|
|
builder = PromptBuilder(tools)
|
|
|
|
context = builder._format_episodic_context(memory)
|
|
|
|
# Should not crash
|
|
assert "LAST SEARCH" in context
|
|
|
|
def test_format_with_download_missing_progress(self, memory):
|
|
"""Should handle download without progress."""
|
|
memory.episodic.add_active_download({"task_id": "1", "name": "Test"})
|
|
|
|
tools = make_tools(settings)
|
|
builder = PromptBuilder(tools)
|
|
|
|
context = builder._format_episodic_context(memory)
|
|
|
|
assert "ACTIVE DOWNLOADS" in context
|
|
assert "0%" in context # Default progress
|
|
|
|
|
|
class TestFormatStmContextEdgeCases:
|
|
"""Edge case tests for _format_stm_context."""
|
|
|
|
def test_format_with_workflow_missing_target(self, memory):
|
|
"""Should handle workflow with missing target."""
|
|
memory.stm.current_workflow = {
|
|
"type": "download",
|
|
"stage": "started",
|
|
}
|
|
|
|
tools = make_tools(settings)
|
|
builder = PromptBuilder(tools)
|
|
|
|
context = builder._format_stm_context(memory)
|
|
|
|
assert "CURRENT WORKFLOW" in context
|
|
|
|
def test_format_with_workflow_none_target(self, memory):
|
|
"""Should handle workflow with None target."""
|
|
memory.stm.start_workflow("download", None)
|
|
|
|
tools = make_tools(settings)
|
|
builder = PromptBuilder(tools)
|
|
|
|
try:
|
|
context = builder._format_stm_context(memory)
|
|
assert "CURRENT WORKFLOW" in context or True
|
|
except (AttributeError, TypeError):
|
|
# Expected if None target causes issues
|
|
pass
|
|
|
|
def test_format_with_empty_topic(self, memory):
|
|
"""Should handle empty topic."""
|
|
memory.stm.set_topic("")
|
|
|
|
tools = make_tools(settings)
|
|
builder = PromptBuilder(tools)
|
|
|
|
context = builder._format_stm_context(memory)
|
|
|
|
# Empty topic might not be shown
|
|
assert isinstance(context, str)
|
|
|
|
def test_format_with_entities_containing_json(self, memory):
|
|
"""Should handle entities containing JSON strings."""
|
|
memory.stm.set_entity("json_string", '{"key": "value"}')
|
|
|
|
tools = make_tools(settings)
|
|
builder = PromptBuilder(tools)
|
|
|
|
context = builder._format_stm_context(memory)
|
|
|
|
assert "EXTRACTED ENTITIES" in context
|