mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-07 00:16:57 +08:00
feat(activity_log): enhance activity logging with query tool and context handling
This commit is contained in:
@@ -534,7 +534,7 @@ class MoviePilotAgent:
|
|||||||
@property
|
@property
|
||||||
def is_background(self) -> bool:
|
def is_background(self) -> bool:
|
||||||
"""
|
"""
|
||||||
是否为后台任务模式(无渠道信息,如定时唤醒)
|
是否为无需回传捕获内容的后台任务模式。
|
||||||
"""
|
"""
|
||||||
return (not self.channel or not self.source) and not callable(self.output_callback)
|
return (not self.channel or not self.source) and not callable(self.output_callback)
|
||||||
|
|
||||||
@@ -555,6 +555,13 @@ class MoviePilotAgent:
|
|||||||
"""
|
"""
|
||||||
return self.session_id.startswith(HEARTBEAT_SESSION_PREFIX)
|
return self.session_id.startswith(HEARTBEAT_SESSION_PREFIX)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_message_context(self) -> bool:
|
||||||
|
"""
|
||||||
|
是否具备真实消息渠道上下文。
|
||||||
|
"""
|
||||||
|
return bool(self.channel and self.source)
|
||||||
|
|
||||||
async def _is_system_admin_context(self) -> bool:
|
async def _is_system_admin_context(self) -> bool:
|
||||||
"""
|
"""
|
||||||
判断当前 Agent 会话是否应按系统管理员上下文运行工具。
|
判断当前 Agent 会话是否应按系统管理员上下文运行工具。
|
||||||
@@ -1042,7 +1049,7 @@ class MoviePilotAgent:
|
|||||||
UsageMiddleware(on_usage=self._record_usage),
|
UsageMiddleware(on_usage=self._record_usage),
|
||||||
]
|
]
|
||||||
|
|
||||||
if not self.is_heartbeat_session:
|
if self.has_message_context:
|
||||||
middlewares.insert(
|
middlewares.insert(
|
||||||
4,
|
4,
|
||||||
ActivityLogMiddleware(
|
ActivityLogMiddleware(
|
||||||
|
|||||||
@@ -3,13 +3,14 @@
|
|||||||
|
|
||||||
按日期存储在 CONFIG_PATH/agent/activity/YYYY-MM-DD.md 中,
|
按日期存储在 CONFIG_PATH/agent/activity/YYYY-MM-DD.md 中,
|
||||||
每次 Agent 执行完毕后自动调用 LLM 对本轮对话生成简洁的活动摘要,
|
每次 Agent 执行完毕后自动调用 LLM 对本轮对话生成简洁的活动摘要,
|
||||||
并在每次 Agent 启动时加载近几天的活动日志注入系统提示词。
|
并在每次 Agent 启动时注入轻量索引,完整日志由工具按需查询。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Annotated, Any, NotRequired, TypedDict
|
from pathlib import Path
|
||||||
|
from typing import Annotated, Any, NotRequired, Optional, TypedDict
|
||||||
|
|
||||||
from anyio import Path as AsyncPath
|
from anyio import Path as AsyncPath
|
||||||
from langchain.agents.middleware.types import (
|
from langchain.agents.middleware.types import (
|
||||||
@@ -30,29 +31,210 @@ from app.log import logger
|
|||||||
# 活动日志保留天数
|
# 活动日志保留天数
|
||||||
DEFAULT_RETENTION_DAYS = 7
|
DEFAULT_RETENTION_DAYS = 7
|
||||||
|
|
||||||
# 注入系统提示词时加载的天数
|
# 注入系统提示词时索引的天数
|
||||||
PROMPT_LOAD_DAYS = 3
|
PROMPT_LOAD_DAYS = 3
|
||||||
|
|
||||||
|
# 工具默认查询的天数
|
||||||
|
DEFAULT_QUERY_DAYS = 7
|
||||||
|
|
||||||
|
# 工具单次返回的最大条数
|
||||||
|
DEFAULT_QUERY_LIMIT = 20
|
||||||
|
MAX_QUERY_LIMIT = 50
|
||||||
|
|
||||||
# 每日日志文件最大大小 (256KB)
|
# 每日日志文件最大大小 (256KB)
|
||||||
MAX_LOG_FILE_SIZE = 256 * 1024
|
MAX_LOG_FILE_SIZE = 256 * 1024
|
||||||
|
|
||||||
# 提取本轮对话上下文的最大字符数(避免过长的对话消耗太多 token)
|
# 提取本轮对话上下文的最大字符数(避免过长的对话消耗太多 token)
|
||||||
MAX_CONTEXT_FOR_SUMMARY = 4000
|
MAX_CONTEXT_FOR_SUMMARY = 4000
|
||||||
|
|
||||||
|
TRIVIAL_USER_TEXT_PATTERN = re.compile(
|
||||||
|
r"^\s*(你好|您好|hi|hello|hey|谢谢|谢了|多谢|ok|好的|收到|嗯|嗯嗯|是的|对|可以|行|好)\s*[。.!!]?\s*$",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
SUMMARY_SKIP_MARKER = "SKIP"
|
||||||
|
|
||||||
# LLM 总结的提示词
|
# LLM 总结的提示词
|
||||||
SUMMARY_PROMPT = """请根据以下 AI 助手与用户的对话记录,生成一条简洁的活动摘要(中文,一句话,不超过80字)。
|
SUMMARY_PROMPT = """请判断以下 AI 助手与用户的对话是否值得写入 MoviePilot 活动日志。
|
||||||
摘要应包含:用户的需求是什么、助手做了什么、结果如何。
|
|
||||||
只输出摘要内容,不要加任何前缀、标点序号或解释。
|
如果本轮只是问候、寒暄、感谢、确认、闲聊、没有实际任务、没有工具动作、任务没有推进、纯粹的格式纠正或无意义空转,请只输出:SKIP
|
||||||
|
|
||||||
|
如果值得记录,请输出一条中文单行活动摘要,要求:
|
||||||
|
- 40 到 160 个汉字左右,信息密度高,不要写成泛泛一句话。
|
||||||
|
- 只输出摘要正文,不要标题、编号、Markdown、JSON 或解释。
|
||||||
|
- 尽量包含:用户目标、关键对象(影片/剧集/站点/路径/任务/设置)、助手采取的关键动作或工具、结果状态、失败原因或下一步。
|
||||||
|
- 如果有明确 ID、路径、站点名、任务状态、成功/失败数量,请保留关键值。
|
||||||
|
- 不要记录 API Key、Cookie、Token、密码等敏感信息;如出现请写成“敏感信息已省略”。
|
||||||
|
|
||||||
|
推荐格式示例:
|
||||||
|
用户要求整理 `/downloads/Show`,助手识别为《示例剧》TMDB 12345,并提交 transfer_file 整理,结果成功。
|
||||||
|
用户排查下载失败,助手查询 qBittorrent 任务和站点状态,发现 tracker 超时,建议更换站点或重试。
|
||||||
|
|
||||||
对话记录:
|
对话记录:
|
||||||
{conversation}"""
|
{conversation}"""
|
||||||
|
|
||||||
|
ACTIVITY_ENTRY_PATTERN = re.compile(r"^-\s+\*\*(?P<time>\d{2}:\d{2})\*\*\s+(?P<summary>.+)$")
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_query_limit(limit: Optional[int]) -> int:
|
||||||
|
"""规范化活动日志查询条数。"""
|
||||||
|
if limit is None:
|
||||||
|
return DEFAULT_QUERY_LIMIT
|
||||||
|
try:
|
||||||
|
value = int(limit)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return DEFAULT_QUERY_LIMIT
|
||||||
|
return min(max(value, 1), MAX_QUERY_LIMIT)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_log_path(activity_dir: str, date_str: str) -> Path:
|
||||||
|
"""构建指定日期的活动日志路径。"""
|
||||||
|
return Path(activity_dir) / f"{date_str}.md"
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_recent_dates(days: int) -> list[str]:
|
||||||
|
"""返回从今天开始向前的日期字符串列表。"""
|
||||||
|
normalized_days = max(1, int(days or 1))
|
||||||
|
today = datetime.now().date()
|
||||||
|
return [
|
||||||
|
(today - timedelta(days=index)).strftime("%Y-%m-%d")
|
||||||
|
for index in range(normalized_days)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_activity_entries(date_str: str, content: str) -> list[dict[str, str]]:
|
||||||
|
"""从单日活动日志 Markdown 中解析活动条目。"""
|
||||||
|
entries: list[dict[str, str]] = []
|
||||||
|
for line in content.splitlines():
|
||||||
|
match = ACTIVITY_ENTRY_PATTERN.match(line.strip())
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
entries.append(
|
||||||
|
{
|
||||||
|
"date": date_str,
|
||||||
|
"time": match.group("time"),
|
||||||
|
"summary": match.group("summary").strip(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def _activity_summary_matches_keyword(
|
||||||
|
summary: str,
|
||||||
|
keyword: str,
|
||||||
|
regex_pattern: Optional[re.Pattern[str]],
|
||||||
|
) -> bool:
|
||||||
|
"""判断活动摘要是否命中普通关键词或正则表达式。"""
|
||||||
|
if regex_pattern:
|
||||||
|
return bool(regex_pattern.search(summary))
|
||||||
|
return keyword.lower() in summary.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def load_activity_log_index(activity_dir: str, days: int = PROMPT_LOAD_DAYS) -> dict[str, str]:
|
||||||
|
"""加载近期活动日志索引,不返回完整日志正文。"""
|
||||||
|
index: dict[str, str] = {}
|
||||||
|
for date_str in _iter_recent_dates(days):
|
||||||
|
log_path = _build_log_path(activity_dir, date_str)
|
||||||
|
if not log_path.is_file():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
content = log_path.read_text(encoding="utf-8")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"读取活动日志索引失败 {log_path}: {e}")
|
||||||
|
continue
|
||||||
|
entry_count = len(_parse_activity_entries(date_str, content))
|
||||||
|
if entry_count:
|
||||||
|
index[date_str] = f"{entry_count} 条活动记录"
|
||||||
|
return index
|
||||||
|
|
||||||
|
|
||||||
|
def query_activity_logs(
|
||||||
|
activity_dir: str,
|
||||||
|
*,
|
||||||
|
keyword: Optional[str] = None,
|
||||||
|
use_regex: bool = False,
|
||||||
|
date: Optional[str] = None,
|
||||||
|
days: int = DEFAULT_QUERY_DAYS,
|
||||||
|
limit: Optional[int] = DEFAULT_QUERY_LIMIT,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
查询活动日志条目。
|
||||||
|
|
||||||
|
:param activity_dir: 活动日志目录
|
||||||
|
:param keyword: 可选关键词,按摘要文本过滤
|
||||||
|
:param use_regex: 是否将关键词按正则表达式匹配
|
||||||
|
:param date: 可选日期,格式为 ``YYYY-MM-DD``
|
||||||
|
:param days: 未指定日期时向前查询的天数
|
||||||
|
:param limit: 返回条数上限
|
||||||
|
:return: 查询结果载荷
|
||||||
|
"""
|
||||||
|
normalized_limit = _coerce_query_limit(limit)
|
||||||
|
normalized_keyword = str(keyword or "").strip()
|
||||||
|
normalized_use_regex = bool(use_regex)
|
||||||
|
regex_pattern: Optional[re.Pattern[str]] = None
|
||||||
|
if normalized_keyword and normalized_use_regex:
|
||||||
|
try:
|
||||||
|
regex_pattern = re.compile(normalized_keyword, re.IGNORECASE)
|
||||||
|
except re.error as err:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": f"无效的活动日志正则表达式: {err}",
|
||||||
|
"activity_dir": activity_dir,
|
||||||
|
"keyword": normalized_keyword,
|
||||||
|
"use_regex": normalized_use_regex,
|
||||||
|
"date": date,
|
||||||
|
"days": days if not date else None,
|
||||||
|
"searched_dates": [],
|
||||||
|
"total_count": 0,
|
||||||
|
"returned_count": 0,
|
||||||
|
"truncated": False,
|
||||||
|
"entries": [],
|
||||||
|
}
|
||||||
|
date_candidates = [date] if date else _iter_recent_dates(days)
|
||||||
|
entries: list[dict[str, str]] = []
|
||||||
|
searched_dates: list[str] = []
|
||||||
|
|
||||||
|
for date_str in date_candidates:
|
||||||
|
if not date_str:
|
||||||
|
continue
|
||||||
|
searched_dates.append(date_str)
|
||||||
|
log_path = _build_log_path(activity_dir, date_str)
|
||||||
|
if not log_path.is_file():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
content = log_path.read_text(encoding="utf-8")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"读取活动日志失败 {log_path}: {e}")
|
||||||
|
continue
|
||||||
|
for entry in _parse_activity_entries(date_str, content):
|
||||||
|
if normalized_keyword and not _activity_summary_matches_keyword(
|
||||||
|
entry["summary"], normalized_keyword, regex_pattern
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
entries.append(entry)
|
||||||
|
|
||||||
|
entries.sort(key=lambda item: (item["date"], item["time"]), reverse=True)
|
||||||
|
total_count = len(entries)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"activity_dir": activity_dir,
|
||||||
|
"keyword": normalized_keyword or None,
|
||||||
|
"use_regex": normalized_use_regex,
|
||||||
|
"date": date,
|
||||||
|
"days": days if not date else None,
|
||||||
|
"searched_dates": searched_dates,
|
||||||
|
"total_count": total_count,
|
||||||
|
"returned_count": min(total_count, normalized_limit),
|
||||||
|
"truncated": total_count > normalized_limit,
|
||||||
|
"entries": entries[:normalized_limit],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class ActivityLogState(AgentState):
|
class ActivityLogState(AgentState):
|
||||||
"""ActivityLogMiddleware 的状态模型。"""
|
"""ActivityLogMiddleware 的状态模型。"""
|
||||||
|
|
||||||
activity_log_contents: NotRequired[Annotated[dict[str, str], PrivateStateAttr]]
|
activity_log_contents: NotRequired[Annotated[dict[str, str], PrivateStateAttr]]
|
||||||
"""将日期字符串映射到日志内容的字典。标记为私有,不包含在最终代理状态中。"""
|
"""将日期字符串映射到日志索引摘要的字典。标记为私有,不包含在最终代理状态中。"""
|
||||||
|
|
||||||
|
|
||||||
class ActivityLogStateUpdate(TypedDict):
|
class ActivityLogStateUpdate(TypedDict):
|
||||||
@@ -61,7 +243,7 @@ class ActivityLogStateUpdate(TypedDict):
|
|||||||
activity_log_contents: dict[str, str]
|
activity_log_contents: dict[str, str]
|
||||||
|
|
||||||
|
|
||||||
def _extract_last_round(messages: list) -> list | None:
|
def _extract_last_round(messages: list) -> Optional[list]:
|
||||||
"""从完整消息列表中提取最后一轮交互。
|
"""从完整消息列表中提取最后一轮交互。
|
||||||
|
|
||||||
从最后一条 HumanMessage 到消息末尾即为本轮交互。
|
从最后一条 HumanMessage 到消息末尾即为本轮交互。
|
||||||
@@ -148,7 +330,31 @@ def _format_conversation_for_summary(round_messages: list) -> str:
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
async def _summarize_with_llm(conversation_text: str) -> str | None:
|
def _should_skip_activity_summary(round_messages: list) -> bool:
|
||||||
|
"""判断本轮交互是否无需生成活动日志。"""
|
||||||
|
if not round_messages:
|
||||||
|
return True
|
||||||
|
|
||||||
|
has_tool_activity = any(
|
||||||
|
isinstance(msg, ToolMessage)
|
||||||
|
or (isinstance(msg, AIMessage) and bool(getattr(msg, "tool_calls", None)))
|
||||||
|
for msg in round_messages
|
||||||
|
)
|
||||||
|
if has_tool_activity:
|
||||||
|
return False
|
||||||
|
|
||||||
|
user_text_parts = []
|
||||||
|
for msg in round_messages:
|
||||||
|
if not isinstance(msg, HumanMessage):
|
||||||
|
continue
|
||||||
|
content = msg.content if isinstance(msg.content, str) else str(msg.content)
|
||||||
|
if content:
|
||||||
|
user_text_parts.append(content)
|
||||||
|
user_text = " ".join(user_text_parts).strip()
|
||||||
|
return bool(user_text and TRIVIAL_USER_TEXT_PATTERN.match(user_text))
|
||||||
|
|
||||||
|
|
||||||
|
async def _summarize_with_llm(conversation_text: str) -> Optional[str]:
|
||||||
"""调用 LLM 对对话文本生成活动摘要。
|
"""调用 LLM 对对话文本生成活动摘要。
|
||||||
|
|
||||||
参数:
|
参数:
|
||||||
@@ -166,50 +372,47 @@ async def _summarize_with_llm(conversation_text: str) -> str | None:
|
|||||||
summary = response.content.strip()
|
summary = response.content.strip()
|
||||||
# 清理模型可能输出的前缀(如 "摘要:" "总结:")
|
# 清理模型可能输出的前缀(如 "摘要:" "总结:")
|
||||||
summary = re.sub(r"^(摘要|总结|活动记录)[::]\s*", "", summary)
|
summary = re.sub(r"^(摘要|总结|活动记录)[::]\s*", "", summary)
|
||||||
|
if summary.strip().upper() == SUMMARY_SKIP_MARKER:
|
||||||
|
return None
|
||||||
return summary if summary else None
|
return summary if summary else None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("LLM summarization failed: %s", e)
|
logger.debug(f"LLM 活动摘要生成失败: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
ACTIVITY_LOG_SYSTEM_PROMPT = """<activity_log>
|
ACTIVITY_LOG_SYSTEM_PROMPT = """<activity_log>
|
||||||
{activity_log}
|
<activity_log_index>
|
||||||
</activity_log>
|
{activity_log_index}
|
||||||
|
</activity_log_index>
|
||||||
|
|
||||||
<activity_log_guidelines>
|
<activity_log_guidelines>
|
||||||
The above <activity_log> contains a record of your recent interactions with the user, automatically maintained by the system.
|
Activity logs are automatically maintained by the system and are available for continuity, but full log contents are not injected into context by default.
|
||||||
|
|
||||||
**How to use this information:**
|
**How to use this information:**
|
||||||
- Reference past activities when relevant to provide continuity (e.g., "之前帮你订阅了《XXX》,现在有更新了")
|
- The <activity_log_index> above only lists which recent dates have activity records and how many entries exist.
|
||||||
- Use activity history to understand ongoing tasks and user patterns
|
- Use `query_activity_log` when the user asks about previous work, asks to continue a prior task, or when recent activity is clearly relevant to the current request.
|
||||||
- When the user asks "你之前帮我做了什么" or similar questions, refer to this log
|
- To find related logs, start with a broad search: use the exact date if known; otherwise query recent days with a short keyword, or omit `keyword` and inspect the latest entries. If there are no matches, retry with a larger `days` value or a shorter object/path fragment.
|
||||||
- Activity logs are automatically recorded after each interaction - you do NOT need to manually update them
|
- `query_activity_log.keyword` is a plain substring by default. Set `use_regex=true` only when matching alternatives or patterns such as multiple titles, paths, or task IDs.
|
||||||
|
- Do not query activity logs for routine standalone tasks such as file organization, media recognition, downloads, subscriptions, or diagnostics unless the user explicitly references prior activity.
|
||||||
**What is automatically logged:**
|
- Activity logs are read-only from your perspective. Do not attempt to edit or write to activity log files.
|
||||||
- Each user interaction: what was asked, which tools were used, and the outcome
|
- For long-term preferences and knowledge, continue to use MEMORY.md.
|
||||||
- Timestamps for all activities
|
- Activity logs are retained for {retention_days} days and then automatically cleaned up.
|
||||||
- The log is organized by date for easy reference
|
|
||||||
|
|
||||||
**Important:**
|
|
||||||
- Activity logs are READ-ONLY from your perspective - the system manages them automatically
|
|
||||||
- Do not attempt to edit or write to activity log files
|
|
||||||
- For long-term preferences and knowledge, continue to use MEMORY.md
|
|
||||||
- Activity logs are retained for {retention_days} days and then automatically cleaned up
|
|
||||||
</activity_log_guidelines>
|
</activity_log_guidelines>
|
||||||
|
</activity_log>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, ResponseT]): # noqa
|
class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, ResponseT]): # noqa
|
||||||
"""自动记录和加载 Agent 活动日志的中间件。
|
"""自动记录 Agent 活动日志并注入轻量索引的中间件。
|
||||||
|
|
||||||
- abefore_agent: 加载近几天的活动日志
|
- abefore_agent: 加载近几天的活动日志索引
|
||||||
- awrap_model_call: 将活动日志注入系统提示词
|
- awrap_model_call: 将活动日志索引和检索规则注入系统提示词
|
||||||
- aafter_agent: 从本次对话中提取摘要并追加到当日日志文件
|
- aafter_agent: 从本次对话中提取摘要并追加到当日日志文件
|
||||||
|
|
||||||
参数:
|
参数:
|
||||||
activity_dir: 活动日志存储目录路径。
|
activity_dir: 活动日志存储目录路径。
|
||||||
retention_days: 日志保留天数(默认 7 天)。
|
retention_days: 日志保留天数(默认 7 天)。
|
||||||
prompt_load_days: 注入系统提示词时加载的天数(默认 3 天)。
|
prompt_load_days: 注入系统提示词时索引的天数(默认 3 天)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
state_schema = ActivityLogState
|
state_schema = ActivityLogState
|
||||||
@@ -230,10 +433,10 @@ class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, Response
|
|||||||
return AsyncPath(self.activity_dir) / f"{date_str}.md"
|
return AsyncPath(self.activity_dir) / f"{date_str}.md"
|
||||||
|
|
||||||
def _format_activity_log(self, contents: dict[str, str]) -> str:
|
def _format_activity_log(self, contents: dict[str, str]) -> str:
|
||||||
"""格式化活动日志用于系统提示词注入。"""
|
"""格式化活动日志索引用于系统提示词注入。"""
|
||||||
if not contents:
|
if not contents:
|
||||||
return ACTIVITY_LOG_SYSTEM_PROMPT.format(
|
return ACTIVITY_LOG_SYSTEM_PROMPT.format(
|
||||||
activity_log="(暂无活动记录)",
|
activity_log_index="(近期暂无活动日志索引。需要历史上下文时可调用 query_activity_log。)",
|
||||||
retention_days=self.retention_days,
|
retention_days=self.retention_days,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -247,35 +450,22 @@ class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, Response
|
|||||||
|
|
||||||
if not sections:
|
if not sections:
|
||||||
return ACTIVITY_LOG_SYSTEM_PROMPT.format(
|
return ACTIVITY_LOG_SYSTEM_PROMPT.format(
|
||||||
activity_log="(暂无活动记录)",
|
activity_log_index="(近期暂无活动日志索引。需要历史上下文时可调用 query_activity_log。)",
|
||||||
retention_days=self.retention_days,
|
retention_days=self.retention_days,
|
||||||
)
|
)
|
||||||
|
|
||||||
log_body = "\n\n".join(sections)
|
log_body = "\n".join(sections)
|
||||||
return ACTIVITY_LOG_SYSTEM_PROMPT.format(
|
return ACTIVITY_LOG_SYSTEM_PROMPT.format(
|
||||||
activity_log=log_body,
|
activity_log_index=log_body,
|
||||||
retention_days=self.retention_days,
|
retention_days=self.retention_days,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _load_recent_logs(self) -> dict[str, str]:
|
async def _load_recent_logs(self) -> dict[str, str]:
|
||||||
"""加载近几天的活动日志。"""
|
"""加载近几天的活动日志索引。"""
|
||||||
contents: dict[str, str] = {}
|
return load_activity_log_index(
|
||||||
today = datetime.now().date()
|
activity_dir=self.activity_dir,
|
||||||
|
days=self.prompt_load_days,
|
||||||
for i in range(self.prompt_load_days):
|
)
|
||||||
date = today - timedelta(days=i)
|
|
||||||
date_str = date.strftime("%Y-%m-%d")
|
|
||||||
log_path = self._get_log_path(date_str)
|
|
||||||
|
|
||||||
if await log_path.exists():
|
|
||||||
try:
|
|
||||||
content = await log_path.read_text(encoding="utf-8")
|
|
||||||
contents[date_str] = content
|
|
||||||
logger.debug("Loaded activity log for %s", date_str)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Failed to load activity log %s: %s", date_str, e)
|
|
||||||
|
|
||||||
return contents
|
|
||||||
|
|
||||||
async def _append_activity(self, summary: str) -> None:
|
async def _append_activity(self, summary: str) -> None:
|
||||||
"""将一条活动记录追加到当日日志文件。"""
|
"""将一条活动记录追加到当日日志文件。"""
|
||||||
@@ -340,7 +530,7 @@ class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, Response
|
|||||||
|
|
||||||
async def abefore_agent(
|
async def abefore_agent(
|
||||||
self, state: ActivityLogState, runtime: Runtime
|
self, state: ActivityLogState, runtime: Runtime
|
||||||
) -> ActivityLogStateUpdate | None:
|
) -> Optional[ActivityLogStateUpdate]:
|
||||||
"""在 Agent 执行前加载近期活动日志。"""
|
"""在 Agent 执行前加载近期活动日志。"""
|
||||||
# 如果已经加载则跳过
|
# 如果已经加载则跳过
|
||||||
if "activity_log_contents" in state:
|
if "activity_log_contents" in state:
|
||||||
@@ -376,7 +566,7 @@ class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, Response
|
|||||||
|
|
||||||
async def aafter_agent(
|
async def aafter_agent(
|
||||||
self, state: ActivityLogState, runtime: Runtime
|
self, state: ActivityLogState, runtime: Runtime
|
||||||
) -> dict[str, Any] | None:
|
) -> Optional[dict[str, Any]]:
|
||||||
"""Agent 执行完毕后,调用 LLM 对本轮对话生成摘要并追加到当日活动日志。"""
|
"""Agent 执行完毕后,调用 LLM 对本轮对话生成摘要并追加到当日活动日志。"""
|
||||||
try:
|
try:
|
||||||
messages = state.get("messages", [])
|
messages = state.get("messages", [])
|
||||||
@@ -387,6 +577,8 @@ class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, Response
|
|||||||
round_messages = _extract_last_round(messages)
|
round_messages = _extract_last_round(messages)
|
||||||
if not round_messages:
|
if not round_messages:
|
||||||
return None
|
return None
|
||||||
|
if _should_skip_activity_summary(round_messages):
|
||||||
|
return None
|
||||||
|
|
||||||
# 格式化对话文本
|
# 格式化对话文本
|
||||||
conversation_text = _format_conversation_for_summary(round_messages)
|
conversation_text = _format_conversation_for_summary(round_messages)
|
||||||
@@ -403,4 +595,8 @@ class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, Response
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["ActivityLogMiddleware"]
|
__all__ = [
|
||||||
|
"ActivityLogMiddleware",
|
||||||
|
"load_activity_log_index",
|
||||||
|
"query_activity_logs",
|
||||||
|
]
|
||||||
|
|||||||
@@ -26,6 +26,130 @@ from typing_extensions import TypedDict # noqa
|
|||||||
|
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
|
|
||||||
|
MIN_SELECTED_TOOL_COUNT = 4
|
||||||
|
|
||||||
|
MOVIEPILOT_TOOL_SELECTION_HINT = """
|
||||||
|
|
||||||
|
MoviePilot tool-chain hints:
|
||||||
|
- For media search and download tasks, keep related steps together when relevant:
|
||||||
|
search_media, search_torrents, get_search_results, add_download_tasks, query_download_tasks.
|
||||||
|
- For file organization and library transfer tasks, keep related steps together when relevant:
|
||||||
|
list_directory, query_directory_settings, recognize_media, query_library_exists, transfer_file, query_transfer_history, scrape_metadata.
|
||||||
|
- For subscription tasks, keep related steps together when relevant:
|
||||||
|
search_subscribe, add_subscribe, query_subscribes, update_subscribe, query_subscribe_history, query_popular_subscribes.
|
||||||
|
- For download management tasks, keep related steps together when relevant:
|
||||||
|
query_download_tasks, update_download_tasks, delete_download_tasks, query_downloaders.
|
||||||
|
- For site diagnostics or maintenance tasks, keep related steps together when relevant:
|
||||||
|
query_sites, query_site_userdata, test_site, update_site, update_site_cookie.
|
||||||
|
- For scheduler and workflow tasks, keep related steps together when relevant:
|
||||||
|
query_schedulers, run_scheduler, query_workflows, run_workflow, query_episode_schedule.
|
||||||
|
- For plugin tasks, keep related steps together when relevant:
|
||||||
|
query_installed_plugins, query_market_plugins, query_plugin_capabilities, query_plugin_config, update_plugin_config, query_plugin_data, install_plugin, uninstall_plugin, reload_plugin.
|
||||||
|
- For rule, identifier, or system setting tasks, keep related steps together when relevant:
|
||||||
|
query_rule_groups, query_builtin_filter_rules, query_custom_filter_rules, add_custom_filter_rule, update_custom_filter_rule, delete_custom_filter_rule, add_rule_group, update_rule_group, delete_rule_group, query_custom_identifiers, update_custom_identifiers, query_system_settings, update_system_settings.
|
||||||
|
- Prefer including the likely next-step tools in the same workflow instead of selecting only the first tool.
|
||||||
|
"""
|
||||||
|
|
||||||
|
TOOL_CHAIN_GROUPS = (
|
||||||
|
(
|
||||||
|
"media_download",
|
||||||
|
(
|
||||||
|
"search_media",
|
||||||
|
"search_torrents",
|
||||||
|
"get_search_results",
|
||||||
|
"add_download_tasks",
|
||||||
|
"query_download_tasks",
|
||||||
|
"query_downloaders",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"library_transfer",
|
||||||
|
(
|
||||||
|
"list_directory",
|
||||||
|
"query_directory_settings",
|
||||||
|
"recognize_media",
|
||||||
|
"query_library_exists",
|
||||||
|
"transfer_file",
|
||||||
|
"query_transfer_history",
|
||||||
|
"scrape_metadata",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"subscription",
|
||||||
|
(
|
||||||
|
"search_subscribe",
|
||||||
|
"add_subscribe",
|
||||||
|
"query_subscribes",
|
||||||
|
"update_subscribe",
|
||||||
|
"delete_subscribe",
|
||||||
|
"query_subscribe_history",
|
||||||
|
"query_popular_subscribes",
|
||||||
|
"query_subscribe_shares",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"download_management",
|
||||||
|
(
|
||||||
|
"query_download_tasks",
|
||||||
|
"update_download_tasks",
|
||||||
|
"delete_download_tasks",
|
||||||
|
"query_downloaders",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"site_management",
|
||||||
|
(
|
||||||
|
"query_sites",
|
||||||
|
"query_site_userdata",
|
||||||
|
"test_site",
|
||||||
|
"update_site",
|
||||||
|
"update_site_cookie",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"workflow_scheduler",
|
||||||
|
(
|
||||||
|
"query_schedulers",
|
||||||
|
"run_scheduler",
|
||||||
|
"query_workflows",
|
||||||
|
"run_workflow",
|
||||||
|
"query_episode_schedule",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"plugin_management",
|
||||||
|
(
|
||||||
|
"query_installed_plugins",
|
||||||
|
"query_market_plugins",
|
||||||
|
"query_plugin_capabilities",
|
||||||
|
"query_plugin_config",
|
||||||
|
"update_plugin_config",
|
||||||
|
"query_plugin_data",
|
||||||
|
"install_plugin",
|
||||||
|
"uninstall_plugin",
|
||||||
|
"reload_plugin",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"rule_settings",
|
||||||
|
(
|
||||||
|
"query_rule_groups",
|
||||||
|
"query_builtin_filter_rules",
|
||||||
|
"query_custom_filter_rules",
|
||||||
|
"add_custom_filter_rule",
|
||||||
|
"update_custom_filter_rule",
|
||||||
|
"delete_custom_filter_rule",
|
||||||
|
"add_rule_group",
|
||||||
|
"update_rule_group",
|
||||||
|
"delete_rule_group",
|
||||||
|
"query_custom_identifiers",
|
||||||
|
"update_custom_identifiers",
|
||||||
|
"query_system_settings",
|
||||||
|
"update_system_settings",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ToolSelectionState(AgentState):
|
class ToolSelectionState(AgentState):
|
||||||
"""工具筛选中间件私有状态。"""
|
"""工具筛选中间件私有状态。"""
|
||||||
@@ -73,12 +197,63 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
|
|||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(
|
super().__init__(
|
||||||
model=model,
|
model=model,
|
||||||
system_prompt=system_prompt,
|
system_prompt=self._append_tool_selection_hint(system_prompt),
|
||||||
max_tools=max_tools,
|
max_tools=max_tools,
|
||||||
always_include=always_include,
|
always_include=always_include,
|
||||||
)
|
)
|
||||||
self.selection_tools = selection_tools or []
|
self.selection_tools = selection_tools or []
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _append_tool_selection_hint(system_prompt: str) -> str:
|
||||||
|
"""追加 MoviePilot 工具组选择提示,避免复杂链路只选中首个工具。"""
|
||||||
|
if "MoviePilot tool-chain hints:" in system_prompt:
|
||||||
|
return system_prompt
|
||||||
|
return f"{system_prompt.rstrip()}{MOVIEPILOT_TOOL_SELECTION_HINT}"
|
||||||
|
|
||||||
|
def _get_tool_selection_limit(self, valid_tool_names: list[str]) -> int:
|
||||||
|
"""计算补齐筛选结果时允许使用的工具数量上限。"""
|
||||||
|
if self.max_tools:
|
||||||
|
return min(self.max_tools, len(valid_tool_names))
|
||||||
|
return len(valid_tool_names)
|
||||||
|
|
||||||
|
def _complete_low_count_selection(
|
||||||
|
self,
|
||||||
|
selected_tool_names: list[str],
|
||||||
|
valid_tool_names: list[str],
|
||||||
|
) -> list[str]:
|
||||||
|
"""
|
||||||
|
当模型只选出极少工具时,按 MoviePilot 常见工具链补齐相邻工具。
|
||||||
|
|
||||||
|
这只补齐已经命中的工具组,不会把所有工具组都展开,因此能降低
|
||||||
|
“选了搜索工具但漏了结果/下载工具”这类链式任务失败概率。
|
||||||
|
"""
|
||||||
|
limit = self._get_tool_selection_limit(valid_tool_names)
|
||||||
|
target_count = min(MIN_SELECTED_TOOL_COUNT, limit)
|
||||||
|
selected_names = [
|
||||||
|
tool_name
|
||||||
|
for tool_name in selected_tool_names
|
||||||
|
if tool_name in valid_tool_names
|
||||||
|
]
|
||||||
|
if len(selected_names) >= target_count:
|
||||||
|
return selected_names[:limit]
|
||||||
|
|
||||||
|
selected_set = set(selected_names)
|
||||||
|
valid_tool_set = set(valid_tool_names)
|
||||||
|
completed_names = list(selected_names)
|
||||||
|
|
||||||
|
for _, group_tool_names in TOOL_CHAIN_GROUPS:
|
||||||
|
if not selected_set.intersection(group_tool_names):
|
||||||
|
continue
|
||||||
|
for tool_name in group_tool_names:
|
||||||
|
if tool_name in selected_set or tool_name not in valid_tool_set:
|
||||||
|
continue
|
||||||
|
completed_names.append(tool_name)
|
||||||
|
selected_set.add(tool_name)
|
||||||
|
if len(completed_names) >= target_count:
|
||||||
|
return completed_names[:limit]
|
||||||
|
|
||||||
|
return completed_names[:limit]
|
||||||
|
|
||||||
def _process_selection_response(
|
def _process_selection_response(
|
||||||
self,
|
self,
|
||||||
response: dict[str, Any],
|
response: dict[str, Any],
|
||||||
@@ -103,6 +278,14 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
|
|||||||
tools=[*available_tools, *always_included_tools, *provider_tools]
|
tools=[*available_tools, *always_included_tools, *provider_tools]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
response["tools"] = self._complete_low_count_selection(
|
||||||
|
selected_tool_names=[
|
||||||
|
tool_name
|
||||||
|
for tool_name in response.get("tools", [])
|
||||||
|
if isinstance(tool_name, str)
|
||||||
|
],
|
||||||
|
valid_tool_names=valid_tool_names,
|
||||||
|
)
|
||||||
return super()._process_selection_response(
|
return super()._process_selection_response(
|
||||||
response,
|
response,
|
||||||
available_tools,
|
available_tools,
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ from app.agent.tools.impl.uninstall_plugin import UninstallPluginTool
|
|||||||
from app.agent.tools.impl.run_slash_command import RunSlashCommandTool
|
from app.agent.tools.impl.run_slash_command import RunSlashCommandTool
|
||||||
from app.agent.tools.impl.list_slash_commands import ListSlashCommandsTool
|
from app.agent.tools.impl.list_slash_commands import ListSlashCommandsTool
|
||||||
from app.agent.tools.impl.query_custom_identifiers import QueryCustomIdentifiersTool
|
from app.agent.tools.impl.query_custom_identifiers import QueryCustomIdentifiersTool
|
||||||
|
from app.agent.tools.impl.query_activity_log import QueryActivityLogTool
|
||||||
from app.agent.tools.impl.query_doctor_report import QueryDoctorReportTool
|
from app.agent.tools.impl.query_doctor_report import QueryDoctorReportTool
|
||||||
from app.agent.tools.impl.update_custom_identifiers import UpdateCustomIdentifiersTool
|
from app.agent.tools.impl.update_custom_identifiers import UpdateCustomIdentifiersTool
|
||||||
from app.agent.tools.impl.query_system_settings import QuerySystemSettingsTool
|
from app.agent.tools.impl.query_system_settings import QuerySystemSettingsTool
|
||||||
@@ -93,7 +94,7 @@ class MoviePilotToolFactory:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# 这些通用工具需要始终保留,避免大工具集裁剪后让 Agent 丢失基础的
|
# 这些通用工具需要始终保留,避免大工具集裁剪后让 Agent 丢失基础的
|
||||||
# 文件系统、命令执行、主动消息发送或交互确认能力。AskUserChoiceTool 仅在支持按钮
|
# 文件系统、命令执行、历史检索或交互确认能力。AskUserChoiceTool 仅在支持按钮
|
||||||
# 的渠道中才会实际注入,因此后续会再按已加载工具做一次求交集。
|
# 的渠道中才会实际注入,因此后续会再按已加载工具做一次求交集。
|
||||||
TOOL_SELECTOR_ALWAYS_INCLUDE_NAMES = (
|
TOOL_SELECTOR_ALWAYS_INCLUDE_NAMES = (
|
||||||
"list_directory",
|
"list_directory",
|
||||||
@@ -101,8 +102,7 @@ class MoviePilotToolFactory:
|
|||||||
"read_file",
|
"read_file",
|
||||||
"edit_file",
|
"edit_file",
|
||||||
"execute_command",
|
"execute_command",
|
||||||
"query_doctor_report",
|
"query_activity_log",
|
||||||
"send_message",
|
|
||||||
"ask_user_choice",
|
"ask_user_choice",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -224,6 +224,7 @@ class MoviePilotToolFactory:
|
|||||||
UninstallPluginTool,
|
UninstallPluginTool,
|
||||||
RunSlashCommandTool,
|
RunSlashCommandTool,
|
||||||
ListSlashCommandsTool,
|
ListSlashCommandsTool,
|
||||||
|
QueryActivityLogTool,
|
||||||
QueryDoctorReportTool,
|
QueryDoctorReportTool,
|
||||||
QueryCustomIdentifiersTool,
|
QueryCustomIdentifiersTool,
|
||||||
UpdateCustomIdentifiersTool,
|
UpdateCustomIdentifiersTool,
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""查询 Agent 活动日志工具。"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Optional, Type
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.agent.middleware.activity_log import (
|
||||||
|
DEFAULT_QUERY_DAYS,
|
||||||
|
DEFAULT_QUERY_LIMIT,
|
||||||
|
query_activity_logs,
|
||||||
|
)
|
||||||
|
from app.agent.runtime import agent_runtime_manager
|
||||||
|
from app.agent.tools.base import MoviePilotTool
|
||||||
|
from app.agent.tools.tags import ToolTag
|
||||||
|
from app.log import logger
|
||||||
|
|
||||||
|
|
||||||
|
class QueryActivityLogInput(BaseModel):
|
||||||
|
"""查询活动日志工具的输入参数模型。"""
|
||||||
|
|
||||||
|
explanation: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description="Clear explanation of why this tool is being used in the current context",
|
||||||
|
)
|
||||||
|
keyword: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description=(
|
||||||
|
"Optional plain-text keyword to filter activity summaries. Use short title, path, site, task, "
|
||||||
|
"or status fragments; omit it to inspect latest entries."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
use_regex: Optional[bool] = Field(
|
||||||
|
False,
|
||||||
|
description=(
|
||||||
|
"Whether to treat keyword as a regular expression. Defaults to false; enable only for "
|
||||||
|
"alternative or pattern matching."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
date: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description="Optional exact date in YYYY-MM-DD format. If omitted, recent days are searched.",
|
||||||
|
)
|
||||||
|
days: Optional[int] = Field(
|
||||||
|
DEFAULT_QUERY_DAYS,
|
||||||
|
description="Number of recent days to search when date is not specified.",
|
||||||
|
)
|
||||||
|
limit: Optional[int] = Field(
|
||||||
|
DEFAULT_QUERY_LIMIT,
|
||||||
|
description="Maximum number of activity entries to return.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class QueryActivityLogTool(MoviePilotTool):
|
||||||
|
"""
|
||||||
|
Agent 活动日志只读查询工具。
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str = "query_activity_log"
|
||||||
|
tags: list[str] = [
|
||||||
|
ToolTag.Read,
|
||||||
|
ToolTag.System,
|
||||||
|
]
|
||||||
|
description: str = (
|
||||||
|
"Query recent MoviePilot Agent activity logs on demand. Use this when the user asks what was done before, "
|
||||||
|
"asks to continue a previous task, or explicitly references recent agent activity. Supports keyword, date, "
|
||||||
|
"recent-day window, limit, and optional regex filters. If a keyword search returns no results, retry with "
|
||||||
|
"a shorter keyword, a larger days window, or no keyword to inspect recent entries."
|
||||||
|
)
|
||||||
|
args_schema: Type[BaseModel] = QueryActivityLogInput
|
||||||
|
|
||||||
|
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||||
|
"""根据查询参数生成友好的提示消息。"""
|
||||||
|
keyword = kwargs.get("keyword")
|
||||||
|
date = kwargs.get("date")
|
||||||
|
if date and keyword:
|
||||||
|
return f"查询活动日志: {date} / {keyword}"
|
||||||
|
if date:
|
||||||
|
return f"查询活动日志: {date}"
|
||||||
|
if keyword:
|
||||||
|
return f"搜索活动日志: {keyword}"
|
||||||
|
return "查询近期活动日志"
|
||||||
|
|
||||||
|
async def run(
|
||||||
|
self,
|
||||||
|
keyword: Optional[str] = None,
|
||||||
|
use_regex: Optional[bool] = False,
|
||||||
|
date: Optional[str] = None,
|
||||||
|
days: Optional[int] = DEFAULT_QUERY_DAYS,
|
||||||
|
limit: Optional[int] = DEFAULT_QUERY_LIMIT,
|
||||||
|
**kwargs,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
查询活动日志并返回 JSON 字符串。
|
||||||
|
"""
|
||||||
|
logger.info(
|
||||||
|
f"执行工具: {self.name}, keyword={keyword}, use_regex={use_regex}, date={date}, "
|
||||||
|
f"days={days}, limit={limit}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
payload = await self.run_blocking(
|
||||||
|
"default",
|
||||||
|
query_activity_logs,
|
||||||
|
str(agent_runtime_manager.activity_dir),
|
||||||
|
keyword=keyword,
|
||||||
|
use_regex=bool(use_regex),
|
||||||
|
date=date,
|
||||||
|
days=days or DEFAULT_QUERY_DAYS,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
return json.dumps(payload, ensure_ascii=False, indent=2)
|
||||||
|
except Exception as err:
|
||||||
|
logger.error(f"查询活动日志失败: {err}", exc_info=True)
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"message": f"查询活动日志时发生错误: {str(err)}",
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
@@ -66,6 +66,19 @@ class TransferFileTool(MoviePilotTool):
|
|||||||
args_schema: Type[BaseModel] = TransferFileInput
|
args_schema: Type[BaseModel] = TransferFileInput
|
||||||
require_admin: bool = True
|
require_admin: bool = True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_fileitem_type(file_path: str, storage: Optional[str] = "local") -> str:
|
||||||
|
"""
|
||||||
|
判断待整理路径的文件类型。
|
||||||
|
|
||||||
|
:param file_path: 已规范化的源文件或目录路径
|
||||||
|
:param storage: 源存储类型
|
||||||
|
:return: ``dir`` 或 ``file``
|
||||||
|
"""
|
||||||
|
if (storage or "local") == "local" and Path(file_path).is_dir():
|
||||||
|
return "dir"
|
||||||
|
return "dir" if file_path.endswith("/") else "file"
|
||||||
|
|
||||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||||
"""根据整理参数生成友好的提示消息"""
|
"""根据整理参数生成友好的提示消息"""
|
||||||
file_path = kwargs.get("file_path", "")
|
file_path = kwargs.get("file_path", "")
|
||||||
@@ -119,7 +132,7 @@ class TransferFileTool(MoviePilotTool):
|
|||||||
fileitem = FileItem(
|
fileitem = FileItem(
|
||||||
storage=storage or "local",
|
storage=storage or "local",
|
||||||
path=file_path,
|
path=file_path,
|
||||||
type="dir" if file_path.endswith("/") else "file",
|
type=TransferFileTool._get_fileitem_type(file_path, storage),
|
||||||
)
|
)
|
||||||
target_path_obj = Path(target_path) if target_path else None
|
target_path_obj = Path(target_path) if target_path else None
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -596,7 +596,7 @@ class ConfigModel(BaseModel):
|
|||||||
# AI推荐条目数量限制
|
# AI推荐条目数量限制
|
||||||
AI_RECOMMEND_MAX_ITEMS: int = 50
|
AI_RECOMMEND_MAX_ITEMS: int = 50
|
||||||
# LLM工具选择中间件最大工具数量,0为不启用工具选择中间件
|
# LLM工具选择中间件最大工具数量,0为不启用工具选择中间件
|
||||||
LLM_MAX_TOOLS: int = 0
|
LLM_MAX_TOOLS: int = 20
|
||||||
# AI智能体定时任务检查间隔(小时),0为不启用,默认24小时
|
# AI智能体定时任务检查间隔(小时),0为不启用,默认24小时
|
||||||
AI_AGENT_JOB_INTERVAL: int = 0
|
AI_AGENT_JOB_INTERVAL: int = 0
|
||||||
# AI智能体啰嗦模式,开启后会回复工具调用过程
|
# AI智能体啰嗦模式,开启后会回复工具调用过程
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
|
||||||
|
|
||||||
|
from app.agent.middleware.activity_log import (
|
||||||
|
ActivityLogMiddleware,
|
||||||
|
_summarize_with_llm,
|
||||||
|
load_activity_log_index,
|
||||||
|
query_activity_logs,
|
||||||
|
)
|
||||||
|
from app.agent.tools.factory import MoviePilotToolFactory
|
||||||
|
from app.agent.tools.impl.query_activity_log import QueryActivityLogTool
|
||||||
|
from app.agent.tools.manager import MoviePilotToolsManager
|
||||||
|
|
||||||
|
|
||||||
|
def _write_activity_log(activity_dir, date_str: str, lines: list[str]) -> None:
|
||||||
|
"""写入测试用活动日志。"""
|
||||||
|
activity_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
body = "\n".join(lines)
|
||||||
|
(activity_dir / f"{date_str}.md").write_text(
|
||||||
|
f"# {date_str} 活动日志\n\n{body}\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_activity_log_index_counts_entries_without_body(tmp_path):
|
||||||
|
"""活动日志索引只应包含条目数量,不暴露完整摘要正文。"""
|
||||||
|
date_str = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
_write_activity_log(
|
||||||
|
tmp_path,
|
||||||
|
date_str,
|
||||||
|
[
|
||||||
|
"- **10:00** 帮用户整理了电影文件",
|
||||||
|
"- **11:00** 查询了下载任务状态",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
index = load_activity_log_index(str(tmp_path), days=1)
|
||||||
|
|
||||||
|
assert index == {date_str: "2 条活动记录"}
|
||||||
|
assert "整理了电影文件" not in json.dumps(index, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_activity_log_prompt_injects_index_not_full_log(tmp_path):
|
||||||
|
"""ActivityLogMiddleware 注入系统提示词时不应携带完整活动日志正文。"""
|
||||||
|
date_str = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
_write_activity_log(
|
||||||
|
tmp_path,
|
||||||
|
date_str,
|
||||||
|
["- **10:00** 这是一条不应默认进入上下文的活动正文"],
|
||||||
|
)
|
||||||
|
middleware = ActivityLogMiddleware(activity_dir=str(tmp_path), prompt_load_days=1)
|
||||||
|
state_update = asyncio.run(middleware.abefore_agent({}, runtime=None))
|
||||||
|
request = SimpleNamespace(
|
||||||
|
state=state_update,
|
||||||
|
system_message=SystemMessage(content="SYSTEM"),
|
||||||
|
override=lambda **kwargs: SimpleNamespace(
|
||||||
|
state=state_update,
|
||||||
|
system_message=kwargs.get("system_message", SystemMessage(content="SYSTEM")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
modified = middleware.modify_request(request)
|
||||||
|
system_text = str(modified.system_message.content)
|
||||||
|
|
||||||
|
assert "1 条活动记录" in system_text
|
||||||
|
assert "这是一条不应默认进入上下文的活动正文" not in system_text
|
||||||
|
assert "query_activity_log" in system_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_activity_log_skips_trivial_greeting_without_llm(tmp_path):
|
||||||
|
"""无实际任务的寒暄不应调用 LLM,也不应写入活动日志。"""
|
||||||
|
middleware = ActivityLogMiddleware(activity_dir=str(tmp_path))
|
||||||
|
summarize_mock = AsyncMock(return_value="不应写入")
|
||||||
|
append_mock = AsyncMock()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"app.agent.middleware.activity_log._summarize_with_llm",
|
||||||
|
new=summarize_mock,
|
||||||
|
),
|
||||||
|
patch.object(middleware, "_append_activity", new=append_mock),
|
||||||
|
):
|
||||||
|
asyncio.run(
|
||||||
|
middleware.aafter_agent(
|
||||||
|
{
|
||||||
|
"messages": [
|
||||||
|
HumanMessage(content="你好"),
|
||||||
|
AIMessage(content="你好,有什么可以帮你?"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
runtime=None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
summarize_mock.assert_not_awaited()
|
||||||
|
append_mock.assert_not_awaited()
|
||||||
|
assert not list(tmp_path.glob("*.md"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_summarize_with_llm_ignores_skip_marker():
|
||||||
|
"""LLM 返回 SKIP 时应视为无需记录活动日志。"""
|
||||||
|
llm = SimpleNamespace(
|
||||||
|
ainvoke=AsyncMock(return_value=SimpleNamespace(content="SKIP"))
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.agent.llm.LLMHelper.get_llm",
|
||||||
|
new=AsyncMock(return_value=llm),
|
||||||
|
):
|
||||||
|
summary = asyncio.run(_summarize_with_llm("用户: 你好"))
|
||||||
|
|
||||||
|
assert summary is None
|
||||||
|
llm.ainvoke.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_activity_log_records_detailed_summary(tmp_path):
|
||||||
|
"""有实际工具动作的交互应写入较完整的活动摘要。"""
|
||||||
|
middleware = ActivityLogMiddleware(activity_dir=str(tmp_path))
|
||||||
|
summary = (
|
||||||
|
"用户要求整理 `/downloads/Show`,助手调用 transfer_file 识别并转移剧集,"
|
||||||
|
"结果成功写入目标媒体库。"
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.agent.middleware.activity_log._summarize_with_llm",
|
||||||
|
new=AsyncMock(return_value=summary),
|
||||||
|
):
|
||||||
|
asyncio.run(
|
||||||
|
middleware.aafter_agent(
|
||||||
|
{
|
||||||
|
"messages": [
|
||||||
|
HumanMessage(content="帮我整理 /downloads/Show"),
|
||||||
|
AIMessage(
|
||||||
|
content="",
|
||||||
|
tool_calls=[
|
||||||
|
{
|
||||||
|
"name": "transfer_file",
|
||||||
|
"args": {"path": "/downloads/Show"},
|
||||||
|
"id": "call_1",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
),
|
||||||
|
ToolMessage(
|
||||||
|
content='{"success": true, "target": "/media/Show"}',
|
||||||
|
tool_call_id="call_1",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
runtime=None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
log_files = list(tmp_path.glob("*.md"))
|
||||||
|
assert len(log_files) == 1
|
||||||
|
content = log_files[0].read_text(encoding="utf-8")
|
||||||
|
assert summary in content
|
||||||
|
assert "- **" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_activity_logs_filters_by_keyword_and_date(tmp_path):
|
||||||
|
"""活动日志查询应支持日期和关键词过滤。"""
|
||||||
|
_write_activity_log(
|
||||||
|
tmp_path,
|
||||||
|
"2026-06-18",
|
||||||
|
[
|
||||||
|
"- **10:00** 帮用户整理了电影 A",
|
||||||
|
"- **10:30** 查询了站点状态",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
_write_activity_log(
|
||||||
|
tmp_path,
|
||||||
|
"2026-06-17",
|
||||||
|
["- **09:00** 帮用户整理了电影 B"],
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = query_activity_logs(
|
||||||
|
str(tmp_path),
|
||||||
|
keyword="整理",
|
||||||
|
date="2026-06-18",
|
||||||
|
limit=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert payload["total_count"] == 1
|
||||||
|
assert payload["entries"][0]["date"] == "2026-06-18"
|
||||||
|
assert payload["entries"][0]["time"] == "10:00"
|
||||||
|
assert payload["entries"][0]["summary"] == "帮用户整理了电影 A"
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_activity_logs_supports_optional_regex(tmp_path):
|
||||||
|
"""活动日志查询应在显式开启时支持正则匹配。"""
|
||||||
|
_write_activity_log(
|
||||||
|
tmp_path,
|
||||||
|
"2026-06-18",
|
||||||
|
[
|
||||||
|
"- **10:00** 帮用户整理了剧集 A",
|
||||||
|
"- **10:30** 查询了站点状态",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = query_activity_logs(
|
||||||
|
str(tmp_path),
|
||||||
|
keyword="整理|站点",
|
||||||
|
use_regex=True,
|
||||||
|
date="2026-06-18",
|
||||||
|
limit=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert payload["use_regex"] is True
|
||||||
|
assert payload["total_count"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_activity_logs_reports_invalid_regex(tmp_path):
|
||||||
|
"""活动日志查询遇到无效正则时应返回结构化错误。"""
|
||||||
|
payload = query_activity_logs(
|
||||||
|
str(tmp_path),
|
||||||
|
keyword="[",
|
||||||
|
use_regex=True,
|
||||||
|
date="2026-06-18",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert payload["success"] is False
|
||||||
|
assert "无效的活动日志正则表达式" in payload["message"]
|
||||||
|
assert payload["entries"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_activity_log_tool_returns_json_payload(tmp_path):
|
||||||
|
"""query_activity_log 工具应返回结构化 JSON 查询结果。"""
|
||||||
|
_write_activity_log(
|
||||||
|
tmp_path,
|
||||||
|
"2026-06-18",
|
||||||
|
["- **10:00** 帮用户整理了电影 A"],
|
||||||
|
)
|
||||||
|
tool = QueryActivityLogTool(session_id="activity-session", user_id="10001")
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.agent.tools.impl.query_activity_log.agent_runtime_manager.activity_dir",
|
||||||
|
tmp_path,
|
||||||
|
):
|
||||||
|
result = asyncio.run(
|
||||||
|
tool.run(keyword="整理", date="2026-06-18", limit=5)
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = json.loads(result)
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert payload["returned_count"] == 1
|
||||||
|
assert payload["entries"][0]["summary"] == "帮用户整理了电影 A"
|
||||||
|
|
||||||
|
|
||||||
|
def test_factory_registers_activity_log_tool():
|
||||||
|
"""工具工厂应注册活动日志查询工具。"""
|
||||||
|
with patch(
|
||||||
|
"app.agent.tools.factory.PluginManager.get_plugin_agent_tools",
|
||||||
|
return_value=[],
|
||||||
|
):
|
||||||
|
tools = MoviePilotToolFactory.create_tools(
|
||||||
|
session_id="activity-session",
|
||||||
|
user_id="10001",
|
||||||
|
)
|
||||||
|
|
||||||
|
tool_names = {tool.name for tool in tools}
|
||||||
|
assert "query_activity_log" in tool_names
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_tool_manager_exposes_activity_log_tool():
|
||||||
|
"""MCP 工具管理器应暴露活动日志查询工具。"""
|
||||||
|
tool = QueryActivityLogTool(session_id="activity-session", user_id="10001")
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.agent.tools.manager.MoviePilotToolFactory.create_tools",
|
||||||
|
return_value=[tool],
|
||||||
|
):
|
||||||
|
manager = MoviePilotToolsManager(is_admin=True)
|
||||||
|
|
||||||
|
tool_definitions = manager.list_tools()
|
||||||
|
assert [item.name for item in tool_definitions] == ["query_activity_log"]
|
||||||
|
schema = tool_definitions[0].input_schema
|
||||||
|
assert "keyword" in schema["properties"]
|
||||||
|
assert "use_regex" in schema["properties"]
|
||||||
|
assert "date" in schema["properties"]
|
||||||
@@ -330,14 +330,62 @@ class AgentBackgroundOutputTest(unittest.IsolatedAsyncioTestCase):
|
|||||||
created["middleware"],
|
created["middleware"],
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_send_message_tool_is_always_included_by_tool_selector(self):
|
async def test_create_agent_excludes_activity_log_without_message_context(self):
|
||||||
|
"""无渠道信息的后台捕获任务不应注入活动日志。"""
|
||||||
|
agent = MoviePilotAgent(
|
||||||
|
session_id="background-capture-session",
|
||||||
|
user_id="system",
|
||||||
|
output_callback=lambda _text: None,
|
||||||
|
)
|
||||||
|
agent._initialize_tools = lambda: []
|
||||||
|
agent._initialize_subagent_tools = lambda: []
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(settings, "LLM_MAX_TOOLS", 0),
|
||||||
|
patch.object(agent, "_initialize_llm", new=AsyncMock(return_value=object())),
|
||||||
|
patch("app.agent.prompt_manager.get_agent_prompt", return_value="PROMPT"),
|
||||||
|
patch("app.agent.create_subagent_middlewares", return_value=([], [])),
|
||||||
|
patch(
|
||||||
|
"app.agent.MoviePilotToolFactory.get_tool_selector_always_include_names",
|
||||||
|
return_value=[],
|
||||||
|
),
|
||||||
|
patch("app.agent.SkillsMiddleware", side_effect=lambda *args, **kwargs: "skills"),
|
||||||
|
patch("app.agent.JobsMiddleware", side_effect=lambda *args, **kwargs: "jobs"),
|
||||||
|
patch("app.agent.RuntimeConfigMiddleware", side_effect=lambda *args, **kwargs: "runtime"),
|
||||||
|
patch("app.agent.MemoryMiddleware", side_effect=lambda *args, **kwargs: "memory"),
|
||||||
|
patch("app.agent.ActivityLogMiddleware", side_effect=lambda *args, **kwargs: "activity"),
|
||||||
|
patch("app.agent.SummarizationMiddleware", side_effect=lambda *args, **kwargs: "summary"),
|
||||||
|
patch("app.agent.PatchToolCallsMiddleware", side_effect=lambda *args, **kwargs: "patch"),
|
||||||
|
patch("app.agent.UsageMiddleware", side_effect=lambda *args, **kwargs: "usage"),
|
||||||
|
patch("app.agent.InMemorySaver", return_value="checkpointer"),
|
||||||
|
patch("app.agent.create_agent", side_effect=lambda **kwargs: kwargs),
|
||||||
|
):
|
||||||
|
created = await agent._create_agent(streaming=False)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
["skills", "jobs", "runtime", "memory", "summary", "patch", "usage"],
|
||||||
|
created["middleware"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_message_tool_is_not_always_included_by_tool_selector(self):
|
||||||
|
"""消息发送工具不应绕过工具筛选。"""
|
||||||
send_message_tool = SimpleNamespace(name="send_message")
|
send_message_tool = SimpleNamespace(name="send_message")
|
||||||
|
|
||||||
always_include = MoviePilotToolFactory.get_tool_selector_always_include_names(
|
always_include = MoviePilotToolFactory.get_tool_selector_always_include_names(
|
||||||
[send_message_tool]
|
[send_message_tool]
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertIn("send_message", always_include)
|
self.assertNotIn("send_message", always_include)
|
||||||
|
|
||||||
|
def test_activity_log_tool_is_always_included_by_tool_selector(self):
|
||||||
|
"""活动日志查询工具应绕过工具筛选。"""
|
||||||
|
activity_log_tool = SimpleNamespace(name="query_activity_log")
|
||||||
|
|
||||||
|
always_include = MoviePilotToolFactory.get_tool_selector_always_include_names(
|
||||||
|
[activity_log_tool]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("query_activity_log", always_include)
|
||||||
|
|
||||||
async def test_create_agent_always_includes_subagent_tools(self):
|
async def test_create_agent_always_includes_subagent_tools(self):
|
||||||
"""工具筛选开启时应保留同步和异步子代理入口。"""
|
"""工具筛选开启时应保留同步和异步子代理入口。"""
|
||||||
@@ -386,7 +434,12 @@ class AgentBackgroundOutputTest(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertIn(SUBAGENT_CONTROL_TOOL_NAME, captured["always_include"])
|
self.assertIn(SUBAGENT_CONTROL_TOOL_NAME, captured["always_include"])
|
||||||
|
|
||||||
async def test_create_agent_keeps_activity_log_for_normal_session(self):
|
async def test_create_agent_keeps_activity_log_for_normal_session(self):
|
||||||
agent = MoviePilotAgent(session_id="normal-session", user_id="system")
|
agent = MoviePilotAgent(
|
||||||
|
session_id="normal-session",
|
||||||
|
user_id="system",
|
||||||
|
channel="Web",
|
||||||
|
source="openai",
|
||||||
|
)
|
||||||
agent._initialize_tools = lambda: []
|
agent._initialize_tools = lambda: []
|
||||||
agent._initialize_subagent_tools = lambda: []
|
agent._initialize_subagent_tools = lambda: []
|
||||||
|
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ class ToolSelectorMiddlewareTest(unittest.TestCase):
|
|||||||
self.assertIn("Return the answer in JSON only.", prompt)
|
self.assertIn("Return the answer in JSON only.", prompt)
|
||||||
self.assertIn('- search: Search for information', prompt)
|
self.assertIn('- search: Search for information', prompt)
|
||||||
self.assertIn('- calendar: Manage events', prompt)
|
self.assertIn('- calendar: Manage events', prompt)
|
||||||
|
self.assertIn("MoviePilot tool-chain hints:", prompt)
|
||||||
self.assertEqual(len(handled_requests), 1)
|
self.assertEqual(len(handled_requests), 1)
|
||||||
|
|
||||||
def test_awrap_model_call_reuses_first_selection_for_later_model_rounds(self):
|
def test_awrap_model_call_reuses_first_selection_for_later_model_rounds(self):
|
||||||
@@ -221,3 +222,112 @@ class ToolSelectorMiddlewareTest(unittest.TestCase):
|
|||||||
normalized = middleware._normalize_selection_response(response)
|
normalized = middleware._normalize_selection_response(response)
|
||||||
|
|
||||||
self.assertEqual(normalized, {"tools": ["search"]})
|
self.assertEqual(normalized, {"tools": ["search"]})
|
||||||
|
|
||||||
|
def test_process_selection_response_completes_low_count_tool_chain(self):
|
||||||
|
"""筛选结果过少时应按已命中的工具链补齐相邻工具。"""
|
||||||
|
tools = [
|
||||||
|
SimpleNamespace(name="search_media", description="Search media"),
|
||||||
|
SimpleNamespace(name="search_torrents", description="Search torrents"),
|
||||||
|
SimpleNamespace(name="get_search_results", description="Get results"),
|
||||||
|
SimpleNamespace(name="add_download_tasks", description="Add downloads"),
|
||||||
|
SimpleNamespace(name="query_download_tasks", description="Query downloads"),
|
||||||
|
]
|
||||||
|
middleware = tool_selector_module.ToolSelectorMiddleware(
|
||||||
|
max_tools=4,
|
||||||
|
selection_tools=tools,
|
||||||
|
)
|
||||||
|
request = _FakeRequest(
|
||||||
|
tools=tools,
|
||||||
|
messages=[HumanMessage(content="帮我下载流浪地球")],
|
||||||
|
model=_FakeModel(),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = middleware._process_selection_response(
|
||||||
|
{"tools": ["search_media"]},
|
||||||
|
available_tools=tools,
|
||||||
|
valid_tool_names=[tool.name for tool in tools],
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[tool.name for tool in result.tools],
|
||||||
|
[
|
||||||
|
"search_media",
|
||||||
|
"search_torrents",
|
||||||
|
"get_search_results",
|
||||||
|
"add_download_tasks",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_process_selection_response_keeps_high_count_selection(self):
|
||||||
|
"""筛选结果数量足够时不应额外补齐工具。"""
|
||||||
|
tools = [
|
||||||
|
SimpleNamespace(name="search_media", description="Search media"),
|
||||||
|
SimpleNamespace(name="search_torrents", description="Search torrents"),
|
||||||
|
SimpleNamespace(name="get_search_results", description="Get results"),
|
||||||
|
SimpleNamespace(name="query_sites", description="Query sites"),
|
||||||
|
]
|
||||||
|
middleware = tool_selector_module.ToolSelectorMiddleware(
|
||||||
|
max_tools=4,
|
||||||
|
selection_tools=tools,
|
||||||
|
)
|
||||||
|
request = _FakeRequest(
|
||||||
|
tools=tools,
|
||||||
|
messages=[HumanMessage(content="帮我下载流浪地球")],
|
||||||
|
model=_FakeModel(),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = middleware._process_selection_response(
|
||||||
|
{
|
||||||
|
"tools": [
|
||||||
|
"search_media",
|
||||||
|
"search_torrents",
|
||||||
|
"get_search_results",
|
||||||
|
"query_sites",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
available_tools=tools,
|
||||||
|
valid_tool_names=[tool.name for tool in tools],
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[tool.name for tool in result.tools],
|
||||||
|
[
|
||||||
|
"search_media",
|
||||||
|
"search_torrents",
|
||||||
|
"get_search_results",
|
||||||
|
"query_sites",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_process_selection_response_respects_max_tools_when_completing(self):
|
||||||
|
"""工具链补齐不应突破 max_tools 上限。"""
|
||||||
|
tools = [
|
||||||
|
SimpleNamespace(name="list_directory", description="List directory"),
|
||||||
|
SimpleNamespace(name="query_directory_settings", description="Query settings"),
|
||||||
|
SimpleNamespace(name="recognize_media", description="Recognize media"),
|
||||||
|
SimpleNamespace(name="transfer_file", description="Transfer file"),
|
||||||
|
]
|
||||||
|
middleware = tool_selector_module.ToolSelectorMiddleware(
|
||||||
|
max_tools=2,
|
||||||
|
selection_tools=tools,
|
||||||
|
)
|
||||||
|
request = _FakeRequest(
|
||||||
|
tools=tools,
|
||||||
|
messages=[HumanMessage(content="帮我整理这个目录")],
|
||||||
|
model=_FakeModel(),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = middleware._process_selection_response(
|
||||||
|
{"tools": ["transfer_file"]},
|
||||||
|
available_tools=tools,
|
||||||
|
valid_tool_names=[tool.name for tool in tools],
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(result.tools), 2)
|
||||||
|
self.assertEqual(
|
||||||
|
{tool.name for tool in result.tools},
|
||||||
|
{"transfer_file", "list_directory"},
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from app.agent.tools.impl.transfer_file import TransferFileTool
|
||||||
|
|
||||||
|
|
||||||
|
def test_transfer_file_local_directory_without_trailing_slash_uses_dir(tmp_path):
|
||||||
|
"""本地目录路径即使没有尾斜杠,也应按目录交给整理链路。"""
|
||||||
|
source_dir = tmp_path / "Movie Folder"
|
||||||
|
source_dir.mkdir()
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def _manual_transfer(self, **kwargs):
|
||||||
|
"""记录工具传给整理链路的参数。"""
|
||||||
|
captured.update(kwargs)
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.chain.transfer.TransferChain.manual_transfer",
|
||||||
|
new=_manual_transfer,
|
||||||
|
):
|
||||||
|
result = TransferFileTool._transfer_file_sync(str(source_dir))
|
||||||
|
|
||||||
|
assert result == f"整理成功:{source_dir}"
|
||||||
|
assert captured["fileitem"].type == "dir"
|
||||||
|
assert captured["fileitem"].path == str(source_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def test_transfer_file_local_file_uses_file(tmp_path):
|
||||||
|
"""本地文件路径应继续按文件交给整理链路。"""
|
||||||
|
source_file = tmp_path / "Movie.mkv"
|
||||||
|
source_file.write_text("fake media", encoding="utf-8")
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def _manual_transfer(self, **kwargs):
|
||||||
|
"""记录工具传给整理链路的参数。"""
|
||||||
|
captured.update(kwargs)
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.chain.transfer.TransferChain.manual_transfer",
|
||||||
|
new=_manual_transfer,
|
||||||
|
):
|
||||||
|
result = TransferFileTool._transfer_file_sync(str(source_file))
|
||||||
|
|
||||||
|
assert result == f"整理成功:{source_file}"
|
||||||
|
assert captured["fileitem"].type == "file"
|
||||||
|
assert captured["fileitem"].path == str(source_file)
|
||||||
|
|
||||||
|
|
||||||
|
def test_transfer_file_remote_directory_still_uses_trailing_slash():
|
||||||
|
"""远程存储无法用本地 stat 判断时,继续使用尾斜杠识别目录。"""
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def _manual_transfer(self, **kwargs):
|
||||||
|
"""记录工具传给整理链路的参数。"""
|
||||||
|
captured.update(kwargs)
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.chain.transfer.TransferChain.manual_transfer",
|
||||||
|
new=_manual_transfer,
|
||||||
|
):
|
||||||
|
result = TransferFileTool._transfer_file_sync(
|
||||||
|
"downloads/Show/",
|
||||||
|
storage="alist",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == "整理成功:/downloads/Show/"
|
||||||
|
assert captured["fileitem"].type == "dir"
|
||||||
|
assert captured["fileitem"].path == "/downloads/Show/"
|
||||||
Reference in New Issue
Block a user