feat(agent): workflow-scoped tool catalog + start/end_workflow meta-tools
Introduce a scope-aware agent so the LLM never sees the full 21-tool
catalog at once. The system prompt now describes either:
- idle mode: core noyau (5 tools: set_language, set_path_for_folder,
list_folder, start_workflow, end_workflow) + a list of available
workflows with their goals;
- active mode: the noyau plus the tools declared by the active
workflow's YAML, with the step plan inlined into the prompt.
Pieces:
- alfred/agent/tools/workflow.py: start_workflow / end_workflow tools
(with YAML specs under tools/specs/) that drive memory.stm.workflow.
- alfred/agent/prompt.py: CORE_TOOLS constant, visible_tool_names(),
filtered build_tools_spec() / _format_tools_description(), and a new
_format_workflow_scope() section in the system prompt.
- alfred/agent/agent.py: WorkflowLoader wired into Agent, defensive
out-of-scope check in _execute_tool_call.
- alfred/agent/registry.py: registers the two new meta-tools (21 total,
7 with YAML spec).
- workflows/media.organize_media.yaml: tools/steps list refreshed to
match the current resolver split (analyze_release, probe_media,
resolve_*_destination, move_to_destination).
This commit is contained in:
+16
-2
@@ -10,6 +10,7 @@ from alfred.settings import settings
|
|||||||
|
|
||||||
from .prompt import PromptBuilder
|
from .prompt import PromptBuilder
|
||||||
from .registry import Tool, make_tools
|
from .registry import Tool, make_tools
|
||||||
|
from .workflows import WorkflowLoader
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -33,8 +34,8 @@ class Agent:
|
|||||||
self.settings = settings
|
self.settings = settings
|
||||||
self.llm = llm
|
self.llm = llm
|
||||||
self.tools: dict[str, Tool] = make_tools(settings)
|
self.tools: dict[str, Tool] = make_tools(settings)
|
||||||
self.prompt_builder = PromptBuilder(self.tools)
|
self.workflow_loader = WorkflowLoader()
|
||||||
self.settings = settings
|
self.prompt_builder = PromptBuilder(self.tools, self.workflow_loader)
|
||||||
self.max_tool_iterations = max_tool_iterations
|
self.max_tool_iterations = max_tool_iterations
|
||||||
|
|
||||||
def step(self, user_input: str) -> str:
|
def step(self, user_input: str) -> str:
|
||||||
@@ -168,6 +169,19 @@ class Agent:
|
|||||||
"available_tools": available,
|
"available_tools": available,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Defensive: reject calls to tools that are not currently in scope.
|
||||||
|
visible = set(self.prompt_builder.visible_tool_names())
|
||||||
|
if tool_name not in visible:
|
||||||
|
return {
|
||||||
|
"error": "tool_out_of_scope",
|
||||||
|
"message": (
|
||||||
|
f"Tool '{tool_name}' is not available in the current "
|
||||||
|
"workflow scope. Call end_workflow first or start the "
|
||||||
|
"appropriate workflow."
|
||||||
|
),
|
||||||
|
"available_tools": sorted(visible),
|
||||||
|
}
|
||||||
|
|
||||||
tool = self.tools[tool_name]
|
tool = self.tools[tool_name]
|
||||||
|
|
||||||
# Execute tool
|
# Execute tool
|
||||||
|
|||||||
+96
-6
@@ -8,15 +8,56 @@ from alfred.infrastructure.persistence.memory import MemoryRegistry
|
|||||||
|
|
||||||
from .expressions import build_expressions_context
|
from .expressions import build_expressions_context
|
||||||
from .registry import Tool
|
from .registry import Tool
|
||||||
|
from .workflows import WorkflowLoader
|
||||||
|
|
||||||
|
# Tools that are always available, regardless of workflow scope.
|
||||||
|
# Kept small on purpose — the noyau is what the agent uses to either
|
||||||
|
# answer trivially or pivot into a workflow.
|
||||||
|
CORE_TOOLS: tuple[str, ...] = (
|
||||||
|
"set_language",
|
||||||
|
"set_path_for_folder",
|
||||||
|
"list_folder",
|
||||||
|
"start_workflow",
|
||||||
|
"end_workflow",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class PromptBuilder:
|
class PromptBuilder:
|
||||||
"""Builds system prompts for the agent with memory context."""
|
"""Builds system prompts for the agent with memory context."""
|
||||||
|
|
||||||
def __init__(self, tools: dict[str, Tool]):
|
def __init__(
|
||||||
|
self,
|
||||||
|
tools: dict[str, Tool],
|
||||||
|
workflow_loader: WorkflowLoader | None = None,
|
||||||
|
):
|
||||||
self.tools = tools
|
self.tools = tools
|
||||||
|
self.workflow_loader = workflow_loader or WorkflowLoader()
|
||||||
self._memory_registry = MemoryRegistry()
|
self._memory_registry = MemoryRegistry()
|
||||||
|
|
||||||
|
def _active_workflow(self, memory) -> dict | None:
|
||||||
|
"""Return the YAML definition of the active workflow, or None."""
|
||||||
|
current = memory.stm.workflow.current
|
||||||
|
if current is None:
|
||||||
|
return None
|
||||||
|
return self.workflow_loader.get(current.get("type"))
|
||||||
|
|
||||||
|
def visible_tool_names(self) -> list[str]:
|
||||||
|
"""
|
||||||
|
Return the names of the tools currently in scope.
|
||||||
|
|
||||||
|
- Idle (no workflow): core noyau only. The LLM enters a workflow
|
||||||
|
via start_workflow to access more tools.
|
||||||
|
- Workflow active: core noyau + the workflow's declared tools.
|
||||||
|
"""
|
||||||
|
memory = get_memory()
|
||||||
|
visible = set(CORE_TOOLS)
|
||||||
|
workflow = self._active_workflow(memory)
|
||||||
|
if workflow is not None:
|
||||||
|
for name in workflow.get("tools", []):
|
||||||
|
visible.add(name)
|
||||||
|
# Only return tools that actually exist in the registry.
|
||||||
|
return [name for name in self.tools if name in visible]
|
||||||
|
|
||||||
def _format_identity(self, memory) -> str:
|
def _format_identity(self, memory) -> str:
|
||||||
"""Build Alfred's identity and personality section."""
|
"""Build Alfred's identity and personality section."""
|
||||||
username = memory.stm.get_entity("username")
|
username = memory.stm.get_entity("username")
|
||||||
@@ -58,9 +99,12 @@ EXPRESSIONS À UTILISER (une par situation, naturellement intégrées dans ta r
|
|||||||
{expressions_block}"""
|
{expressions_block}"""
|
||||||
|
|
||||||
def build_tools_spec(self) -> list[dict[str, Any]]:
|
def build_tools_spec(self) -> list[dict[str, Any]]:
|
||||||
"""Build the tool specification for the LLM API."""
|
"""Build the tool specification for the LLM API (scope-filtered)."""
|
||||||
|
visible = set(self.visible_tool_names())
|
||||||
tool_specs = []
|
tool_specs = []
|
||||||
for tool in self.tools.values():
|
for tool in self.tools.values():
|
||||||
|
if tool.name not in visible:
|
||||||
|
continue
|
||||||
spec = {
|
spec = {
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
@@ -73,15 +117,57 @@ EXPRESSIONS À UTILISER (une par situation, naturellement intégrées dans ta r
|
|||||||
return tool_specs
|
return tool_specs
|
||||||
|
|
||||||
def _format_tools_description(self) -> str:
|
def _format_tools_description(self) -> str:
|
||||||
"""Format tools with their descriptions and parameters."""
|
"""Format the currently-visible tools with description + params."""
|
||||||
if not self.tools:
|
visible = set(self.visible_tool_names())
|
||||||
|
visible_tools = [t for t in self.tools.values() if t.name in visible]
|
||||||
|
if not visible_tools:
|
||||||
return ""
|
return ""
|
||||||
return "\n".join(
|
return "\n".join(
|
||||||
f"- {tool.name}: {tool.description}\n"
|
f"- {tool.name}: {tool.description}\n"
|
||||||
f" Parameters: {json.dumps(tool.parameters, ensure_ascii=False)}"
|
f" Parameters: {json.dumps(tool.parameters, ensure_ascii=False)}"
|
||||||
for tool in self.tools.values()
|
for tool in visible_tools
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _format_workflow_scope(self, memory) -> str:
|
||||||
|
"""Describe the current workflow scope so the LLM has a plan."""
|
||||||
|
workflow = self._active_workflow(memory)
|
||||||
|
if workflow is None:
|
||||||
|
available = self.workflow_loader.names()
|
||||||
|
if not available:
|
||||||
|
return ""
|
||||||
|
lines = ["WORKFLOW SCOPE: idle (broad catalog narrowed to core noyau)."]
|
||||||
|
lines.append(
|
||||||
|
" Call start_workflow(workflow_name, params) to enter a scope."
|
||||||
|
)
|
||||||
|
lines.append(" Available workflows:")
|
||||||
|
for name in available:
|
||||||
|
wf = self.workflow_loader.get(name) or {}
|
||||||
|
desc = (wf.get("description") or "").strip().splitlines()
|
||||||
|
summary = desc[0] if desc else ""
|
||||||
|
lines.append(f" - {name}: {summary}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
current = memory.stm.workflow.current or {}
|
||||||
|
lines = [
|
||||||
|
f"WORKFLOW SCOPE: active — {current.get('type')} "
|
||||||
|
f"(stage: {current.get('stage')})",
|
||||||
|
]
|
||||||
|
target = current.get("target")
|
||||||
|
if target:
|
||||||
|
lines.append(f" Params: {target}")
|
||||||
|
wf_desc = (workflow.get("description") or "").strip()
|
||||||
|
if wf_desc:
|
||||||
|
lines.append(f" Goal: {wf_desc}")
|
||||||
|
steps = workflow.get("steps", [])
|
||||||
|
if steps:
|
||||||
|
lines.append(" Steps:")
|
||||||
|
for step in steps:
|
||||||
|
step_id = step.get("id", "?")
|
||||||
|
step_tool = step.get("tool") or ("ask_user" if step.get("ask_user") else "—")
|
||||||
|
lines.append(f" - {step_id} ({step_tool})")
|
||||||
|
lines.append(" Call end_workflow(reason) when done, cancelled, or off-topic.")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
def _format_episodic_context(self, memory) -> str:
|
def _format_episodic_context(self, memory) -> str:
|
||||||
"""Format episodic memory context for the prompt."""
|
"""Format episodic memory context for the prompt."""
|
||||||
lines = []
|
lines = []
|
||||||
@@ -213,7 +299,10 @@ EXPRESSIONS À UTILISER (une par situation, naturellement intégrées dans ta r
|
|||||||
# Memory schema
|
# Memory schema
|
||||||
memory_schema = self._format_memory_schema()
|
memory_schema = self._format_memory_schema()
|
||||||
|
|
||||||
# Available tools
|
# Workflow scope (active workflow plan or list of options)
|
||||||
|
workflow_section = self._format_workflow_scope(memory)
|
||||||
|
|
||||||
|
# Available tools (already filtered by scope)
|
||||||
tools_desc = self._format_tools_description()
|
tools_desc = self._format_tools_description()
|
||||||
tools_section = f"\nOUTILS DISPONIBLES:\n{tools_desc}" if tools_desc else ""
|
tools_section = f"\nOUTILS DISPONIBLES:\n{tools_desc}" if tools_desc else ""
|
||||||
|
|
||||||
@@ -233,6 +322,7 @@ RÈGLES:
|
|||||||
stm_context,
|
stm_context,
|
||||||
episodic_context,
|
episodic_context,
|
||||||
memory_schema,
|
memory_schema,
|
||||||
|
workflow_section,
|
||||||
tools_section,
|
tools_section,
|
||||||
rules,
|
rules,
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ def make_tools(settings) -> dict[str, Tool]:
|
|||||||
from .tools import api as api_tools # noqa: PLC0415
|
from .tools import api as api_tools # noqa: PLC0415
|
||||||
from .tools import filesystem as fs_tools # noqa: PLC0415
|
from .tools import filesystem as fs_tools # noqa: PLC0415
|
||||||
from .tools import language as lang_tools # noqa: PLC0415
|
from .tools import language as lang_tools # noqa: PLC0415
|
||||||
|
from .tools import workflow as wf_tools # noqa: PLC0415
|
||||||
|
|
||||||
tool_functions = [
|
tool_functions = [
|
||||||
fs_tools.set_path_for_folder,
|
fs_tools.set_path_for_folder,
|
||||||
@@ -150,6 +151,8 @@ def make_tools(settings) -> dict[str, Tool]:
|
|||||||
api_tools.add_torrent_to_qbittorrent,
|
api_tools.add_torrent_to_qbittorrent,
|
||||||
api_tools.get_torrent_by_index,
|
api_tools.get_torrent_by_index,
|
||||||
lang_tools.set_language,
|
lang_tools.set_language,
|
||||||
|
wf_tools.start_workflow,
|
||||||
|
wf_tools.end_workflow,
|
||||||
]
|
]
|
||||||
|
|
||||||
specs = load_tool_specs()
|
specs = load_tool_specs()
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
name: end_workflow
|
||||||
|
|
||||||
|
summary: >
|
||||||
|
Leave the current workflow scope and return to the broad-catalog mode.
|
||||||
|
|
||||||
|
description: |
|
||||||
|
Clears the active workflow from STM. After this call the visible tool
|
||||||
|
catalog returns to the core noyau plus start_workflow, so the agent is
|
||||||
|
ready to handle a different request.
|
||||||
|
|
||||||
|
when_to_use: |
|
||||||
|
- When all the workflow's steps have completed successfully.
|
||||||
|
- When the user explicitly cancels the current task.
|
||||||
|
- When the user changes subject mid-conversation and the active
|
||||||
|
workflow is no longer relevant.
|
||||||
|
- When an unrecoverable error makes continuing pointless — explain
|
||||||
|
in 'reason'.
|
||||||
|
|
||||||
|
when_not_to_use: |
|
||||||
|
- Do not call when there is no active workflow — it will return an
|
||||||
|
error. Just call start_workflow for the new request instead.
|
||||||
|
- Do not call mid-step just to "free up tools"; finish the step
|
||||||
|
or fail it explicitly first.
|
||||||
|
|
||||||
|
next_steps: |
|
||||||
|
- After ending, you can either call start_workflow for a new task or
|
||||||
|
answer the user directly from the broad catalog.
|
||||||
|
|
||||||
|
parameters:
|
||||||
|
reason:
|
||||||
|
description: Short reason for ending — completed, cancelled, changed_subject, error, ...
|
||||||
|
why_needed: |
|
||||||
|
Recorded in episodic memory for debugging and future audits. A
|
||||||
|
structured short string is more useful than a long sentence.
|
||||||
|
example: completed
|
||||||
|
|
||||||
|
returns:
|
||||||
|
ok:
|
||||||
|
description: Workflow ended; catalog is back to the broad noyau.
|
||||||
|
fields:
|
||||||
|
workflow: Name of the workflow that just ended.
|
||||||
|
reason: The reason that was passed in.
|
||||||
|
|
||||||
|
error:
|
||||||
|
description: Could not end — typically because nothing was active.
|
||||||
|
fields:
|
||||||
|
error: Short error code (no_active_workflow).
|
||||||
|
message: Human-readable explanation.
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
name: start_workflow
|
||||||
|
|
||||||
|
summary: >
|
||||||
|
Enter a workflow scope — narrows the visible tool catalog and gives the
|
||||||
|
agent a clear multi-step plan to follow.
|
||||||
|
|
||||||
|
description: |
|
||||||
|
Activates a named workflow defined in YAML under agent/workflows/.
|
||||||
|
Once active, only the workflow's declared tools (plus the core noyau)
|
||||||
|
are exposed to the LLM, which keeps the decision space small and
|
||||||
|
focused. The returned plan (description + steps) is the script the
|
||||||
|
agent should execute until end_workflow is called.
|
||||||
|
|
||||||
|
when_to_use: |
|
||||||
|
Use as the very first action whenever the user request maps to a
|
||||||
|
known workflow (e.g. "organize Breaking Bad" → media.organize_media).
|
||||||
|
Pass any parameters you already know (release name, target media,
|
||||||
|
flags) in 'params' so later steps can read them from STM.
|
||||||
|
|
||||||
|
when_not_to_use: |
|
||||||
|
- Do not start a workflow for purely conversational replies or
|
||||||
|
one-shot lookups that need a single tool call.
|
||||||
|
- Do not start a new workflow while one is already active — call
|
||||||
|
end_workflow first.
|
||||||
|
|
||||||
|
next_steps: |
|
||||||
|
- On status=ok: follow the returned 'steps' list, calling the tools
|
||||||
|
in order. The visible tool catalog has already been narrowed.
|
||||||
|
- On status=error (unknown_workflow): surface the available list to
|
||||||
|
the user and ask which one they meant.
|
||||||
|
- On status=error (workflow_already_active): either continue the
|
||||||
|
active workflow or call end_workflow first.
|
||||||
|
|
||||||
|
parameters:
|
||||||
|
workflow_name:
|
||||||
|
description: Fully-qualified name of the workflow to start (e.g. media.organize_media).
|
||||||
|
why_needed: |
|
||||||
|
Identifies which YAML definition to load. Names use the
|
||||||
|
'domain.action' convention (media.*, mail.*, ...).
|
||||||
|
example: media.organize_media
|
||||||
|
|
||||||
|
params:
|
||||||
|
description: Initial parameters to seed the workflow with (release name, target, flags).
|
||||||
|
why_needed: |
|
||||||
|
Later steps read these from STM instead of asking the user again.
|
||||||
|
Pass whatever you already extracted from the user's message.
|
||||||
|
example: '{"release_name": "Breaking.Bad.S01.1080p.BluRay.x265-GROUP", "keep_seeding": true}'
|
||||||
|
|
||||||
|
returns:
|
||||||
|
ok:
|
||||||
|
description: Workflow activated; catalog has been narrowed.
|
||||||
|
fields:
|
||||||
|
workflow: Name of the activated workflow.
|
||||||
|
description: Human-readable description of what the workflow does.
|
||||||
|
steps: Ordered list of steps to execute.
|
||||||
|
tools: Tools that are now visible (in addition to the core noyau).
|
||||||
|
|
||||||
|
error:
|
||||||
|
description: Could not activate the workflow.
|
||||||
|
fields:
|
||||||
|
error: Short error code (unknown_workflow, workflow_already_active).
|
||||||
|
message: Human-readable explanation.
|
||||||
|
available_workflows: List of valid workflow names (only on unknown_workflow).
|
||||||
|
active_workflow: Name of the currently active workflow (only on workflow_already_active).
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""Workflow scoping tools — start_workflow / end_workflow meta-tools.
|
||||||
|
|
||||||
|
These tools let the agent enter and leave a workflow scope. While a
|
||||||
|
workflow is active, the PromptBuilder narrows the visible tool catalog
|
||||||
|
to the noyau + the workflow's declared tools, so the LLM doesn't have
|
||||||
|
to reason over the full set.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from alfred.infrastructure.persistence import get_memory
|
||||||
|
|
||||||
|
from ..workflows import WorkflowLoader
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_loader_cache: list[WorkflowLoader] = []
|
||||||
|
|
||||||
|
|
||||||
|
def _get_loader() -> WorkflowLoader:
|
||||||
|
"""Lazily build the module-level WorkflowLoader."""
|
||||||
|
if not _loader_cache:
|
||||||
|
_loader_cache.append(WorkflowLoader())
|
||||||
|
return _loader_cache[0]
|
||||||
|
|
||||||
|
|
||||||
|
def start_workflow(workflow_name: str, params: dict) -> dict[str, Any]:
|
||||||
|
"""See specs/start_workflow.yaml for full description."""
|
||||||
|
loader = _get_loader()
|
||||||
|
workflow = loader.get(workflow_name)
|
||||||
|
if workflow is None:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": "unknown_workflow",
|
||||||
|
"message": f"Workflow '{workflow_name}' not found",
|
||||||
|
"available_workflows": loader.names(),
|
||||||
|
}
|
||||||
|
|
||||||
|
memory = get_memory()
|
||||||
|
current = memory.stm.workflow.current
|
||||||
|
if current is not None:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": "workflow_already_active",
|
||||||
|
"message": (
|
||||||
|
f"Workflow '{current.get('type')}' is already active. "
|
||||||
|
"Call end_workflow before starting a new one."
|
||||||
|
),
|
||||||
|
"active_workflow": current.get("type"),
|
||||||
|
}
|
||||||
|
|
||||||
|
memory.stm.start_workflow(workflow_name, params or {})
|
||||||
|
memory.save()
|
||||||
|
logger.info(f"start_workflow: '{workflow_name}' with params={params}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"workflow": workflow_name,
|
||||||
|
"description": workflow.get("description", ""),
|
||||||
|
"steps": workflow.get("steps", []),
|
||||||
|
"tools": workflow.get("tools", []),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def end_workflow(reason: str) -> dict[str, Any]:
|
||||||
|
"""See specs/end_workflow.yaml for full description."""
|
||||||
|
memory = get_memory()
|
||||||
|
current = memory.stm.workflow.current
|
||||||
|
if current is None:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": "no_active_workflow",
|
||||||
|
"message": "No workflow is currently active.",
|
||||||
|
}
|
||||||
|
|
||||||
|
workflow_name = current.get("type")
|
||||||
|
memory.stm.end_workflow()
|
||||||
|
memory.save()
|
||||||
|
logger.info(f"end_workflow: '{workflow_name}' reason={reason!r}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"workflow": workflow_name,
|
||||||
|
"reason": reason,
|
||||||
|
}
|
||||||
@@ -14,9 +14,14 @@ trigger:
|
|||||||
|
|
||||||
tools:
|
tools:
|
||||||
- list_folder
|
- list_folder
|
||||||
|
- analyze_release
|
||||||
|
- probe_media
|
||||||
- find_media_imdb_id
|
- find_media_imdb_id
|
||||||
- resolve_destination
|
- resolve_season_destination
|
||||||
- move_media
|
- resolve_episode_destination
|
||||||
|
- resolve_movie_destination
|
||||||
|
- resolve_series_destination
|
||||||
|
- move_to_destination
|
||||||
- manage_subtitles
|
- manage_subtitles
|
||||||
- create_seed_links
|
- create_seed_links
|
||||||
|
|
||||||
@@ -34,22 +39,31 @@ steps:
|
|||||||
params:
|
params:
|
||||||
folder_type: download
|
folder_type: download
|
||||||
|
|
||||||
|
- id: analyze
|
||||||
|
tool: analyze_release
|
||||||
|
description: >
|
||||||
|
Parse the release name to detect media_type (movie / tv_season /
|
||||||
|
tv_episode / tv_complete) and extract season/episode info.
|
||||||
|
|
||||||
- id: identify_media
|
- id: identify_media
|
||||||
tool: find_media_imdb_id
|
tool: find_media_imdb_id
|
||||||
description: Confirm title, type (series/movie), and metadata via TMDB.
|
description: Confirm canonical title and year via TMDB.
|
||||||
|
|
||||||
- id: resolve_destination
|
- id: resolve_destination
|
||||||
tool: resolve_destination
|
|
||||||
description: >
|
description: >
|
||||||
Compute the correct destination path in the library.
|
Call the resolver that matches media_type from analyze_release:
|
||||||
Uses the release name + TMDB metadata to build folder and file names.
|
movie → resolve_movie_destination
|
||||||
If multiple series folders exist for this title, returns
|
tv_season → resolve_season_destination
|
||||||
needs_clarification and the user must pick one (re-call with confirmed_folder).
|
tv_episode → resolve_episode_destination
|
||||||
|
tv_complete → resolve_series_destination
|
||||||
|
If the resolver returns needs_clarification, ask the user and
|
||||||
|
re-call with confirmed_folder.
|
||||||
|
|
||||||
- id: move_file
|
- id: move_file
|
||||||
tool: move_media
|
tool: move_to_destination
|
||||||
description: >
|
description: >
|
||||||
Move the video file to library_file returned by resolve_destination.
|
Move the video file/folder to the destination returned by the
|
||||||
|
resolver above.
|
||||||
|
|
||||||
- id: handle_subtitles
|
- id: handle_subtitles
|
||||||
tool: manage_subtitles
|
tool: manage_subtitles
|
||||||
@@ -63,7 +77,7 @@ steps:
|
|||||||
question: "Do you want to keep seeding this torrent?"
|
question: "Do you want to keep seeding this torrent?"
|
||||||
answers:
|
answers:
|
||||||
"yes": { next_step: create_seed_links }
|
"yes": { next_step: create_seed_links }
|
||||||
"no": { next_step: update_library }
|
"no": { next_step: end }
|
||||||
|
|
||||||
- id: create_seed_links
|
- id: create_seed_links
|
||||||
tool: create_seed_links
|
tool: create_seed_links
|
||||||
@@ -72,10 +86,6 @@ steps:
|
|||||||
and copy all remaining files from the original download folder
|
and copy all remaining files from the original download folder
|
||||||
(subs, nfo, jpg, …) so the torrent stays complete for seeding.
|
(subs, nfo, jpg, …) so the torrent stays complete for seeding.
|
||||||
|
|
||||||
- id: update_library
|
|
||||||
memory_write: Library
|
|
||||||
description: Add the entry to the LTM library after a successful move.
|
|
||||||
|
|
||||||
naming_convention:
|
naming_convention:
|
||||||
# Resolved by domain entities (Movie, Episode) — not hardcoded here
|
# Resolved by domain entities (Movie, Episode) — not hardcoded here
|
||||||
tv_show: "{title}/Season {season:02d}/{title}.S{season:02d}E{episode:02d}.{ext}"
|
tv_show: "{title}/Season {season:02d}/{title}.S{season:02d}E{episode:02d}.{ext}"
|
||||||
|
|||||||
Reference in New Issue
Block a user