mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-06 07:56:52 +08:00
refine agent subscription defaults and silent tool prompts
This commit is contained in:
@@ -60,6 +60,7 @@ Core Capabilities:
|
|||||||
2. Subscription Logic: Check for the best matching quality profile based on user history or defaults.
|
2. Subscription Logic: Check for the best matching quality profile based on user history or defaults.
|
||||||
3. Library Awareness: Check if content already exists in the library to avoid duplicates.
|
3. Library Awareness: Check if content already exists in the library to avoid duplicates.
|
||||||
4. Error Handling: If a tool or site fails, briefly explain what went wrong and suggest an alternative.
|
4. Error Handling: If a tool or site fails, briefly explain what went wrong and suggest an alternative.
|
||||||
|
5. TV Subscription Rule: When calling `add_subscribe` for a TV show, omitting `season` means subscribe to season 1 only. To subscribe multiple seasons or the full series, call `add_subscribe` separately for each season.
|
||||||
</media_management_rules>
|
</media_management_rules>
|
||||||
|
|
||||||
<markdown_spec>
|
<markdown_spec>
|
||||||
|
|||||||
@@ -82,11 +82,13 @@ class PromptManager:
|
|||||||
verbose_spec = ""
|
verbose_spec = ""
|
||||||
if not settings.AI_AGENT_VERBOSE:
|
if not settings.AI_AGENT_VERBOSE:
|
||||||
verbose_spec = (
|
verbose_spec = (
|
||||||
"\n\n[Important Instruction] STRICTLY ENFORCED: DO NOT output any conversational "
|
"\n\n[Important Instruction] STRICTLY ENFORCED WHEN VERBOSE MODE IS OFF: "
|
||||||
"text, thinking processes, or explanations before or during tool calls. Call tools "
|
"If tools are needed, DO NOT output any conversational text, explanations, progress updates, "
|
||||||
"directly without any transitional phrases. "
|
"or acknowledgements before the first tool call or between tool calls. "
|
||||||
"You MUST remain completely silent until the task is completely finished. "
|
"Call tools directly without any transitional phrases. "
|
||||||
"DO NOT output any content whatsoever until your final summary reply."
|
"You MUST remain completely silent until all required tools have finished and you have the final result. "
|
||||||
|
"Only then may you send one final user-facing reply. "
|
||||||
|
"DO NOT output any intermediate content whatsoever."
|
||||||
)
|
)
|
||||||
|
|
||||||
# MoviePilot系统信息
|
# MoviePilot系统信息
|
||||||
@@ -193,18 +195,18 @@ class PromptManager:
|
|||||||
def _generate_button_choice_instructions(
|
def _generate_button_choice_instructions(
|
||||||
channel: MessageChannel = None,
|
channel: MessageChannel = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
if channel and ChannelCapabilityManager.supports_buttons(
|
if (
|
||||||
channel
|
channel
|
||||||
) and ChannelCapabilityManager.supports_callbacks(channel):
|
and ChannelCapabilityManager.supports_buttons(channel)
|
||||||
|
and ChannelCapabilityManager.supports_callbacks(channel)
|
||||||
|
):
|
||||||
return (
|
return (
|
||||||
"- User questions: If you need the user to choose from a few clear options, "
|
"- User questions: If you need the user to choose from a few clear options, "
|
||||||
"call `ask_user_choice` to send button options. After the user clicks a button, "
|
"call `ask_user_choice` to send button options. After the user clicks a button, "
|
||||||
"the selected value will come back as the user's next message. After calling this tool, "
|
"the selected value will come back as the user's next message. After calling this tool, "
|
||||||
"wait for the user's selection instead of repeating the question in plain text."
|
"wait for the user's selection instead of repeating the question in plain text."
|
||||||
)
|
)
|
||||||
return (
|
return "- User questions: When you truly need user input, ask briefly in plain text."
|
||||||
"- User questions: When you truly need user input, ask briefly in plain text."
|
|
||||||
)
|
|
||||||
|
|
||||||
def clear_cache(self):
|
def clear_cache(self):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -12,36 +12,74 @@ from app.schemas.types import MediaType
|
|||||||
|
|
||||||
class AddSubscribeInput(BaseModel):
|
class AddSubscribeInput(BaseModel):
|
||||||
"""添加订阅工具的输入参数模型"""
|
"""添加订阅工具的输入参数模型"""
|
||||||
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
|
|
||||||
title: str = Field(..., description="The title of the media to subscribe to (e.g., 'The Matrix', 'Breaking Bad')")
|
explanation: str = Field(
|
||||||
year: str = Field(..., description="Release year of the media (required for accurate identification)")
|
...,
|
||||||
media_type: str = Field(...,
|
description="Clear explanation of why this tool is being used in the current context",
|
||||||
description="Allowed values: movie, tv")
|
)
|
||||||
season: Optional[int] = Field(None,
|
title: str = Field(
|
||||||
description="Season number for TV shows (optional, if not specified will subscribe to all seasons)")
|
...,
|
||||||
tmdb_id: Optional[int] = Field(None,
|
description="The title of the media to subscribe to (e.g., 'The Matrix', 'Breaking Bad')",
|
||||||
description="TMDB database ID for precise media identification (optional, can be obtained from search_media tool)")
|
)
|
||||||
douban_id: Optional[str] = Field(None,
|
year: str = Field(
|
||||||
description="Douban ID for precise media identification (optional, alternative to tmdb_id)")
|
...,
|
||||||
start_episode: Optional[int] = Field(None,
|
description="Release year of the media (required for accurate identification)",
|
||||||
description="Starting episode number for TV shows (optional, defaults to 1 if not specified)")
|
)
|
||||||
total_episode: Optional[int] = Field(None,
|
media_type: str = Field(..., description="Allowed values: movie, tv")
|
||||||
description="Total number of episodes for TV shows (optional, will be auto-detected from TMDB if not specified)")
|
season: Optional[int] = Field(
|
||||||
quality: Optional[str] = Field(None,
|
None,
|
||||||
description="Quality filter as regular expression (optional, e.g., 'BluRay|WEB-DL|HDTV')")
|
description=(
|
||||||
resolution: Optional[str] = Field(None,
|
"Season number for TV shows (optional). If omitted, the subscription defaults to season 1 only. "
|
||||||
description="Resolution filter as regular expression (optional, e.g., '1080p|720p|2160p')")
|
"To subscribe multiple seasons or the full series, call this tool separately for each season."
|
||||||
effect: Optional[str] = Field(None,
|
),
|
||||||
description="Effect filter as regular expression (optional, e.g., 'HDR|DV|SDR')")
|
)
|
||||||
filter_groups: Optional[List[str]] = Field(None,
|
tmdb_id: Optional[int] = Field(
|
||||||
description="List of filter rule group names to apply (optional, can be obtained from query_rule_groups tool)")
|
None,
|
||||||
sites: Optional[List[int]] = Field(None,
|
description="TMDB database ID for precise media identification (optional, can be obtained from search_media tool)",
|
||||||
description="List of site IDs to search from (optional, can be obtained from query_sites tool)")
|
)
|
||||||
|
douban_id: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description="Douban ID for precise media identification (optional, alternative to tmdb_id)",
|
||||||
|
)
|
||||||
|
start_episode: Optional[int] = Field(
|
||||||
|
None,
|
||||||
|
description="Starting episode number for TV shows (optional, defaults to 1 if not specified)",
|
||||||
|
)
|
||||||
|
total_episode: Optional[int] = Field(
|
||||||
|
None,
|
||||||
|
description="Total number of episodes for TV shows (optional, will be auto-detected from TMDB if not specified)",
|
||||||
|
)
|
||||||
|
quality: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description="Quality filter as regular expression (optional, e.g., 'BluRay|WEB-DL|HDTV')",
|
||||||
|
)
|
||||||
|
resolution: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description="Resolution filter as regular expression (optional, e.g., '1080p|720p|2160p')",
|
||||||
|
)
|
||||||
|
effect: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description="Effect filter as regular expression (optional, e.g., 'HDR|DV|SDR')",
|
||||||
|
)
|
||||||
|
filter_groups: Optional[List[str]] = Field(
|
||||||
|
None,
|
||||||
|
description="List of filter rule group names to apply (optional, can be obtained from query_rule_groups tool)",
|
||||||
|
)
|
||||||
|
sites: Optional[List[int]] = Field(
|
||||||
|
None,
|
||||||
|
description="List of site IDs to search from (optional, can be obtained from query_sites tool)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AddSubscribeTool(MoviePilotTool):
|
class AddSubscribeTool(MoviePilotTool):
|
||||||
name: str = "add_subscribe"
|
name: str = "add_subscribe"
|
||||||
description: str = "Add media subscription to create automated download rules for movies and TV shows. The system will automatically search and download new episodes or releases based on the subscription criteria. Supports advanced filtering options like quality, resolution, and effect filters using regular expressions."
|
description: str = (
|
||||||
|
"Add media subscription to create automated download rules for movies and TV shows. "
|
||||||
|
"The system will automatically search and download new episodes or releases based on the subscription criteria. "
|
||||||
|
"For TV shows, omitting `season` subscribes season 1 only by default; to subscribe multiple seasons or "
|
||||||
|
"the full series, call this tool once per season. Supports advanced filtering options like quality, "
|
||||||
|
"resolution, and effect filters using regular expressions."
|
||||||
|
)
|
||||||
args_schema: Type[BaseModel] = AddSubscribeInput
|
args_schema: Type[BaseModel] = AddSubscribeInput
|
||||||
|
|
||||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||||
@@ -50,7 +88,7 @@ class AddSubscribeTool(MoviePilotTool):
|
|||||||
year = kwargs.get("year", "")
|
year = kwargs.get("year", "")
|
||||||
media_type = kwargs.get("media_type", "")
|
media_type = kwargs.get("media_type", "")
|
||||||
season = kwargs.get("season")
|
season = kwargs.get("season")
|
||||||
|
|
||||||
message = f"正在添加订阅: {title}"
|
message = f"正在添加订阅: {title}"
|
||||||
if year:
|
if year:
|
||||||
message += f" ({year})"
|
message += f" ({year})"
|
||||||
@@ -58,44 +96,64 @@ class AddSubscribeTool(MoviePilotTool):
|
|||||||
message += f" [{media_type}]"
|
message += f" [{media_type}]"
|
||||||
if season:
|
if season:
|
||||||
message += f" 第{season}季"
|
message += f" 第{season}季"
|
||||||
|
elif media_type == "tv":
|
||||||
|
message += " 第1季(默认)"
|
||||||
|
|
||||||
return message
|
return message
|
||||||
|
|
||||||
async def run(self, title: str, year: str, media_type: str,
|
async def run(
|
||||||
season: Optional[int] = None, tmdb_id: Optional[int] = None,
|
self,
|
||||||
douban_id: Optional[str] = None,
|
title: str,
|
||||||
start_episode: Optional[int] = None, total_episode: Optional[int] = None,
|
year: str,
|
||||||
quality: Optional[str] = None, resolution: Optional[str] = None,
|
media_type: str,
|
||||||
effect: Optional[str] = None, filter_groups: Optional[List[str]] = None,
|
season: Optional[int] = None,
|
||||||
sites: Optional[List[int]] = None, **kwargs) -> str:
|
tmdb_id: Optional[int] = None,
|
||||||
|
douban_id: Optional[str] = None,
|
||||||
|
start_episode: Optional[int] = None,
|
||||||
|
total_episode: Optional[int] = None,
|
||||||
|
quality: Optional[str] = None,
|
||||||
|
resolution: Optional[str] = None,
|
||||||
|
effect: Optional[str] = None,
|
||||||
|
filter_groups: Optional[List[str]] = None,
|
||||||
|
sites: Optional[List[int]] = None,
|
||||||
|
**kwargs,
|
||||||
|
) -> str:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"执行工具: {self.name}, 参数: title={title}, year={year}, media_type={media_type}, "
|
f"执行工具: {self.name}, 参数: title={title}, year={year}, media_type={media_type}, "
|
||||||
f"season={season}, tmdb_id={tmdb_id}, douban_id={douban_id}, start_episode={start_episode}, "
|
f"season={season}, tmdb_id={tmdb_id}, douban_id={douban_id}, start_episode={start_episode}, "
|
||||||
f"total_episode={total_episode}, quality={quality}, resolution={resolution}, "
|
f"total_episode={total_episode}, quality={quality}, resolution={resolution}, "
|
||||||
f"effect={effect}, filter_groups={filter_groups}, sites={sites}")
|
f"effect={effect}, filter_groups={filter_groups}, sites={sites}"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
subscribe_chain = SubscribeChain()
|
subscribe_chain = SubscribeChain()
|
||||||
media_type_enum = MediaType.from_agent(media_type)
|
media_type_enum = MediaType.from_agent(media_type)
|
||||||
if not media_type_enum:
|
if not media_type_enum:
|
||||||
return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv'"
|
return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv'"
|
||||||
|
effective_season = (
|
||||||
|
season
|
||||||
|
if season is not None
|
||||||
|
else 1
|
||||||
|
if media_type_enum == MediaType.TV
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
# 构建额外的订阅参数
|
# 构建额外的订阅参数
|
||||||
subscribe_kwargs = {}
|
subscribe_kwargs = {}
|
||||||
if start_episode is not None:
|
if start_episode is not None:
|
||||||
subscribe_kwargs['start_episode'] = start_episode
|
subscribe_kwargs["start_episode"] = start_episode
|
||||||
if total_episode is not None:
|
if total_episode is not None:
|
||||||
subscribe_kwargs['total_episode'] = total_episode
|
subscribe_kwargs["total_episode"] = total_episode
|
||||||
if quality:
|
if quality:
|
||||||
subscribe_kwargs['quality'] = quality
|
subscribe_kwargs["quality"] = quality
|
||||||
if resolution:
|
if resolution:
|
||||||
subscribe_kwargs['resolution'] = resolution
|
subscribe_kwargs["resolution"] = resolution
|
||||||
if effect:
|
if effect:
|
||||||
subscribe_kwargs['effect'] = effect
|
subscribe_kwargs["effect"] = effect
|
||||||
if filter_groups:
|
if filter_groups:
|
||||||
subscribe_kwargs['filter_groups'] = filter_groups
|
subscribe_kwargs["filter_groups"] = filter_groups
|
||||||
if sites:
|
if sites:
|
||||||
subscribe_kwargs['sites'] = sites
|
subscribe_kwargs["sites"] = sites
|
||||||
|
|
||||||
sid, message = await subscribe_chain.async_add(
|
sid, message = await subscribe_chain.async_add(
|
||||||
mtype=media_type_enum,
|
mtype=media_type_enum,
|
||||||
@@ -105,13 +163,21 @@ class AddSubscribeTool(MoviePilotTool):
|
|||||||
doubanid=douban_id,
|
doubanid=douban_id,
|
||||||
season=season,
|
season=season,
|
||||||
username=self._user_id,
|
username=self._user_id,
|
||||||
**subscribe_kwargs
|
**subscribe_kwargs,
|
||||||
)
|
)
|
||||||
if sid:
|
if sid:
|
||||||
if message and "已存在" in message:
|
if message and "已存在" in message:
|
||||||
return f"订阅已存在:{title} ({year})。如需修改参数请先删除旧订阅。"
|
result_msg = f"订阅已存在:{title} ({year})"
|
||||||
|
if effective_season is not None:
|
||||||
|
result_msg += f" 第{effective_season}季"
|
||||||
|
result_msg += "。如需修改参数请先删除旧订阅。"
|
||||||
|
return result_msg
|
||||||
|
|
||||||
result_msg = f"成功添加订阅:{title} ({year})"
|
result_msg = f"成功添加订阅:{title} ({year})"
|
||||||
|
if effective_season is not None:
|
||||||
|
result_msg += f" 第{effective_season}季"
|
||||||
|
if season is None:
|
||||||
|
result_msg += "(未指定季号,默认按第一季订阅)"
|
||||||
if subscribe_kwargs:
|
if subscribe_kwargs:
|
||||||
params = []
|
params = []
|
||||||
if start_episode is not None:
|
if start_episode is not None:
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import asyncio
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from app.agent.tools.impl.add_subscribe import AddSubscribeTool
|
||||||
|
|
||||||
|
|
||||||
|
class TestAgentAddSubscribeTool(unittest.TestCase):
|
||||||
|
def test_tv_subscription_without_season_reports_default_first_season(self):
|
||||||
|
tool = AddSubscribeTool(session_id="session-1", user_id="10001")
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.agent.tools.impl.add_subscribe.SubscribeChain.async_add",
|
||||||
|
new=AsyncMock(return_value=(1, "")),
|
||||||
|
):
|
||||||
|
result = asyncio.run(
|
||||||
|
tool.run(
|
||||||
|
title="Breaking Bad",
|
||||||
|
year="2008",
|
||||||
|
media_type="tv",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("第1季", result)
|
||||||
|
self.assertIn("默认按第一季订阅", result)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import unittest
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from app.agent.middleware.memory import MEMORY_ONBOARDING_PROMPT
|
from app.agent.middleware.memory import MEMORY_ONBOARDING_PROMPT
|
||||||
from app.agent.prompt import prompt_manager
|
from app.agent.prompt import prompt_manager
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
class TestAgentPromptStyle(unittest.TestCase):
|
class TestAgentPromptStyle(unittest.TestCase):
|
||||||
@@ -12,6 +14,44 @@ class TestAgentPromptStyle(unittest.TestCase):
|
|||||||
self.assertIn("Do NOT flatter the user", prompt)
|
self.assertIn("Do NOT flatter the user", prompt)
|
||||||
self.assertIn("NO praise, emotional cushioning", prompt)
|
self.assertIn("NO praise, emotional cushioning", prompt)
|
||||||
|
|
||||||
|
def test_agent_prompt_defines_tv_subscription_default_season_rule(self):
|
||||||
|
prompt = prompt_manager.get_agent_prompt()
|
||||||
|
|
||||||
|
self.assertIn(
|
||||||
|
"omitting `season` means subscribe to season 1 only",
|
||||||
|
prompt,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"call `add_subscribe` separately for each season",
|
||||||
|
prompt,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_non_verbose_prompt_requires_silence_until_all_tools_finish(self):
|
||||||
|
with patch.object(settings, "AI_AGENT_VERBOSE", False):
|
||||||
|
prompt = prompt_manager.get_agent_prompt()
|
||||||
|
|
||||||
|
self.assertIn(
|
||||||
|
"STRICTLY ENFORCED WHEN VERBOSE MODE IS OFF",
|
||||||
|
prompt,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"DO NOT output any conversational text, explanations, progress updates, or acknowledgements before the first tool call or between tool calls",
|
||||||
|
prompt,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"Only then may you send one final user-facing reply",
|
||||||
|
prompt,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_verbose_prompt_does_not_inject_silence_until_tools_finish_rule(self):
|
||||||
|
with patch.object(settings, "AI_AGENT_VERBOSE", True):
|
||||||
|
prompt = prompt_manager.get_agent_prompt()
|
||||||
|
|
||||||
|
self.assertNotIn(
|
||||||
|
"STRICTLY ENFORCED WHEN VERBOSE MODE IS OFF",
|
||||||
|
prompt,
|
||||||
|
)
|
||||||
|
|
||||||
def test_memory_onboarding_does_not_force_warm_intro(self):
|
def test_memory_onboarding_does_not_force_warm_intro(self):
|
||||||
self.assertIn("Do NOT interrupt the current task", MEMORY_ONBOARDING_PROMPT)
|
self.assertIn("Do NOT interrupt the current task", MEMORY_ONBOARDING_PROMPT)
|
||||||
self.assertIn("Do NOT proactively greet warmly", MEMORY_ONBOARDING_PROMPT)
|
self.assertIn("Do NOT proactively greet warmly", MEMORY_ONBOARDING_PROMPT)
|
||||||
|
|||||||
Reference in New Issue
Block a user