| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164 |
- """TMDB MovieTool — 可展开为 movies_discover / movies_search,供检索 Agent 调用。
- 内部复用 MovieService,与 /api/movies/* 同一数据源(双通道同源)。
- """
- from __future__ import annotations
- import json
- from typing import Any, Dict, List, Optional
- from hello_agents.tools import Tool, ToolParameter, ToolResponse, tool_action
- from hello_agents.tools.errors import ToolErrorCode
- from ..services.movie_service import MovieServiceError, get_movie_service, normalize_tmdb_language
- from ..utils.logger import get_logger
- logger = get_logger("app.movie_tool")
- def _movies_to_payload(movies: list) -> Dict[str, Any]:
- items = [m.model_dump() for m in movies]
- text = json.dumps({"count": len(items), "movies": items}, ensure_ascii=False)
- return {"text": text, "data": {"movies": items, "count": len(items)}}
- class MovieTool(Tool):
- """可展开电影工具:注册后变成 movies_discover / movies_search 两个子工具。"""
- def __init__(self) -> None:
- super().__init__(
- name="movies",
- description="TMDB 电影检索:discover 条件发现、search 文本搜索",
- expandable=True, # True → Agent.add_tool 时自动展开子工具
- )
- self._service = get_movie_service() # 与 /api/movies 共用同一服务
- # 编排器可按次 run 设置上限,防止 LLM 一轮内并行狂打 discover
- self._discover_calls = 0
- self._discover_call_limit: Optional[int] = None
- def begin_search_run(self, discover_limit: int = 1) -> None:
- """检索阶段开始:重置计数并设置 movies_discover 调用上限。"""
- self._discover_calls = 0
- self._discover_call_limit = discover_limit
- def end_search_run(self) -> None:
- """检索阶段结束:取消调用上限。"""
- self._discover_call_limit = None
- self._discover_calls = 0
- @tool_action("movies_discover", "按类型/年份/时长/语言等条件发现电影")
- def discover(
- self,
- with_genres: str = "",
- year: int = 0,
- year_gte: int = 0,
- year_lte: int = 0,
- max_runtime: int = 0,
- with_original_language: str = "",
- sort_by: str = "popularity.desc",
- page: int = 1,
- ) -> ToolResponse:
- """条件发现电影(主路径)。
- Args:
- with_genres: 类型名或 id,逗号分隔,如 剧情,科幻
- year: 精确上映年,0 表示不限
- year_gte: 上映年起,0 表示不限
- year_lte: 上映年止,0 表示不限
- max_runtime: 最大片长分钟,0 表示不限
- with_original_language: 原始语言代码,如 zh/en/ja/ko
- sort_by: 排序,默认 popularity.desc
- page: 页码
- """
- if self._discover_call_limit is not None:
- self._discover_calls += 1
- if self._discover_calls > self._discover_call_limit:
- logger.warning(
- "movies_discover 已达上限 %d,拒绝第 %d 次调用",
- self._discover_call_limit,
- self._discover_calls,
- )
- return ToolResponse.error(
- code=ToolErrorCode.INTERNAL_ERROR,
- message=(
- f"movies_discover 本轮最多调用 {self._discover_call_limit} 次;"
- "请基于已有工具结果直接输出含 movies 的 JSON,"
- "并保留工具返回的 poster_url 等字段。"
- ),
- )
- try:
- raw_lang = with_original_language or None
- lang = normalize_tmdb_language(raw_lang)
- if raw_lang and not lang:
- logger.warning(
- "movies_discover 丢弃非法 language=%r,将按无语言过滤查询",
- raw_lang,
- )
- logger.info(
- "movies_discover 请求 genres=%r lang=%r(raw=%r) year=%s gte=%s lte=%s "
- "runtime_lte=%s sort=%s page=%s",
- with_genres or None,
- lang,
- raw_lang,
- year or None,
- year_gte or None,
- year_lte or None,
- max_runtime or None,
- sort_by or "popularity.desc",
- page or 1,
- )
- # 空结果时自动放宽条件,避免 Agent 一次非法/过严参数直接失败
- movies = self._service.discover_with_relax(
- with_genres=with_genres or None,
- year=year or None,
- year_gte=year_gte or None,
- year_lte=year_lte or None,
- max_runtime=max_runtime or None,
- with_original_language=raw_lang,
- sort_by=sort_by or "popularity.desc",
- page=page or 1,
- )
- payload = _movies_to_payload(movies)
- logger.info("movies_discover -> %d", payload["data"]["count"])
- return ToolResponse.success(text=payload["text"], data=payload["data"])
- except MovieServiceError as e:
- return ToolResponse.error(code=ToolErrorCode.INTERNAL_ERROR, message=str(e))
- @tool_action("movies_search", "按关键词搜索电影")
- def search(self, q: str, year: int = 0, page: int = 1) -> ToolResponse:
- """文本搜索电影(已看解析 / 兜底)。
- Args:
- q: 搜索关键词
- year: 上映年,0 表示不限
- page: 页码
- """
- try:
- movies = self._service.search(q=q, year=year or None, page=page or 1)
- payload = _movies_to_payload(movies)
- logger.info("movies_search q=%r -> %d", q, payload["data"]["count"])
- return ToolResponse.success(text=payload["text"], data=payload["data"])
- except MovieServiceError as e:
- return ToolResponse.error(code=ToolErrorCode.INTERNAL_ERROR, message=str(e))
- def run(self, parameters: Dict[str, Any]) -> ToolResponse:
- return ToolResponse.error(
- code=ToolErrorCode.NOT_IMPLEMENTED,
- message="请使用子工具 movies_discover 或 movies_search",
- )
- def get_parameters(self) -> List[ToolParameter]:
- return []
- _movie_tool: Optional[MovieTool] = None
- def get_movie_tool() -> MovieTool:
- global _movie_tool
- if _movie_tool is None:
- _movie_tool = MovieTool()
- return _movie_tool
|