movie_tool.py 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164
  1. """TMDB MovieTool — 可展开为 movies_discover / movies_search,供检索 Agent 调用。
  2. 内部复用 MovieService,与 /api/movies/* 同一数据源(双通道同源)。
  3. """
  4. from __future__ import annotations
  5. import json
  6. from typing import Any, Dict, List, Optional
  7. from hello_agents.tools import Tool, ToolParameter, ToolResponse, tool_action
  8. from hello_agents.tools.errors import ToolErrorCode
  9. from ..services.movie_service import MovieServiceError, get_movie_service, normalize_tmdb_language
  10. from ..utils.logger import get_logger
  11. logger = get_logger("app.movie_tool")
  12. def _movies_to_payload(movies: list) -> Dict[str, Any]:
  13. items = [m.model_dump() for m in movies]
  14. text = json.dumps({"count": len(items), "movies": items}, ensure_ascii=False)
  15. return {"text": text, "data": {"movies": items, "count": len(items)}}
  16. class MovieTool(Tool):
  17. """可展开电影工具:注册后变成 movies_discover / movies_search 两个子工具。"""
  18. def __init__(self) -> None:
  19. super().__init__(
  20. name="movies",
  21. description="TMDB 电影检索:discover 条件发现、search 文本搜索",
  22. expandable=True, # True → Agent.add_tool 时自动展开子工具
  23. )
  24. self._service = get_movie_service() # 与 /api/movies 共用同一服务
  25. # 编排器可按次 run 设置上限,防止 LLM 一轮内并行狂打 discover
  26. self._discover_calls = 0
  27. self._discover_call_limit: Optional[int] = None
  28. def begin_search_run(self, discover_limit: int = 1) -> None:
  29. """检索阶段开始:重置计数并设置 movies_discover 调用上限。"""
  30. self._discover_calls = 0
  31. self._discover_call_limit = discover_limit
  32. def end_search_run(self) -> None:
  33. """检索阶段结束:取消调用上限。"""
  34. self._discover_call_limit = None
  35. self._discover_calls = 0
  36. @tool_action("movies_discover", "按类型/年份/时长/语言等条件发现电影")
  37. def discover(
  38. self,
  39. with_genres: str = "",
  40. year: int = 0,
  41. year_gte: int = 0,
  42. year_lte: int = 0,
  43. max_runtime: int = 0,
  44. with_original_language: str = "",
  45. sort_by: str = "popularity.desc",
  46. page: int = 1,
  47. ) -> ToolResponse:
  48. """条件发现电影(主路径)。
  49. Args:
  50. with_genres: 类型名或 id,逗号分隔,如 剧情,科幻
  51. year: 精确上映年,0 表示不限
  52. year_gte: 上映年起,0 表示不限
  53. year_lte: 上映年止,0 表示不限
  54. max_runtime: 最大片长分钟,0 表示不限
  55. with_original_language: 原始语言代码,如 zh/en/ja/ko
  56. sort_by: 排序,默认 popularity.desc
  57. page: 页码
  58. """
  59. if self._discover_call_limit is not None:
  60. self._discover_calls += 1
  61. if self._discover_calls > self._discover_call_limit:
  62. logger.warning(
  63. "movies_discover 已达上限 %d,拒绝第 %d 次调用",
  64. self._discover_call_limit,
  65. self._discover_calls,
  66. )
  67. return ToolResponse.error(
  68. code=ToolErrorCode.INTERNAL_ERROR,
  69. message=(
  70. f"movies_discover 本轮最多调用 {self._discover_call_limit} 次;"
  71. "请基于已有工具结果直接输出含 movies 的 JSON,"
  72. "并保留工具返回的 poster_url 等字段。"
  73. ),
  74. )
  75. try:
  76. raw_lang = with_original_language or None
  77. lang = normalize_tmdb_language(raw_lang)
  78. if raw_lang and not lang:
  79. logger.warning(
  80. "movies_discover 丢弃非法 language=%r,将按无语言过滤查询",
  81. raw_lang,
  82. )
  83. logger.info(
  84. "movies_discover 请求 genres=%r lang=%r(raw=%r) year=%s gte=%s lte=%s "
  85. "runtime_lte=%s sort=%s page=%s",
  86. with_genres or None,
  87. lang,
  88. raw_lang,
  89. year or None,
  90. year_gte or None,
  91. year_lte or None,
  92. max_runtime or None,
  93. sort_by or "popularity.desc",
  94. page or 1,
  95. )
  96. # 空结果时自动放宽条件,避免 Agent 一次非法/过严参数直接失败
  97. movies = self._service.discover_with_relax(
  98. with_genres=with_genres or None,
  99. year=year or None,
  100. year_gte=year_gte or None,
  101. year_lte=year_lte or None,
  102. max_runtime=max_runtime or None,
  103. with_original_language=raw_lang,
  104. sort_by=sort_by or "popularity.desc",
  105. page=page or 1,
  106. )
  107. payload = _movies_to_payload(movies)
  108. logger.info("movies_discover -> %d", payload["data"]["count"])
  109. return ToolResponse.success(text=payload["text"], data=payload["data"])
  110. except MovieServiceError as e:
  111. return ToolResponse.error(code=ToolErrorCode.INTERNAL_ERROR, message=str(e))
  112. @tool_action("movies_search", "按关键词搜索电影")
  113. def search(self, q: str, year: int = 0, page: int = 1) -> ToolResponse:
  114. """文本搜索电影(已看解析 / 兜底)。
  115. Args:
  116. q: 搜索关键词
  117. year: 上映年,0 表示不限
  118. page: 页码
  119. """
  120. try:
  121. movies = self._service.search(q=q, year=year or None, page=page or 1)
  122. payload = _movies_to_payload(movies)
  123. logger.info("movies_search q=%r -> %d", q, payload["data"]["count"])
  124. return ToolResponse.success(text=payload["text"], data=payload["data"])
  125. except MovieServiceError as e:
  126. return ToolResponse.error(code=ToolErrorCode.INTERNAL_ERROR, message=str(e))
  127. def run(self, parameters: Dict[str, Any]) -> ToolResponse:
  128. return ToolResponse.error(
  129. code=ToolErrorCode.NOT_IMPLEMENTED,
  130. message="请使用子工具 movies_discover 或 movies_search",
  131. )
  132. def get_parameters(self) -> List[ToolParameter]:
  133. return []
  134. _movie_tool: Optional[MovieTool] = None
  135. def get_movie_tool() -> MovieTool:
  136. global _movie_tool
  137. if _movie_tool is None:
  138. _movie_tool = MovieTool()
  139. return _movie_tool