movie_recommender_agent.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697
  1. """多智能体电影推荐编排(串行流水线)。
  2. 范式:Pipeline + Tool-use
  3. ① 画像 Agent(无工具)→ TasteProfile
  4. ② 检索 Agent(挂 MovieTool)→ 真片候选
  5. ③ 推荐 Agent(无工具)→ 仅在候选 id 内产出 RecommendResult
  6. 本模块只提供编排器;HTTP 路由后续再接。
  7. """
  8. from __future__ import annotations
  9. import json
  10. import re
  11. import time
  12. from datetime import datetime
  13. from typing import Any, List, Optional, Tuple
  14. from hello_agents import Config, SimpleAgent
  15. from ..config import get_settings
  16. from ..models.schemas import (
  17. CandidateMovie,
  18. MovieCard,
  19. RecommendRequest,
  20. RecommendResult,
  21. TasteProfile,
  22. )
  23. from ..services.llm_service import get_llm
  24. from ..services.movie_service import get_movie_service, normalize_tmdb_language
  25. from ..tools.movie_tool import get_movie_tool
  26. from ..utils.logger import get_logger
  27. logger = get_logger("app.agents")
  28. # ============ Prompts ============
  29. PROFILE_AGENT_PROMPT = """你是观影口味画像专家。根据用户偏好输出结构化 JSON,不要推荐具体片名。
  30. 只返回如下 JSON(不要 Markdown 代码块外的解释):
  31. {
  32. "summary": "一句话口味摘要",
  33. "genre_hints": ["类型1", "类型2"],
  34. "language_hints": ["仅填 ISO 码:zh / en / ja / ko,可空;禁止写好莱坞、英语等中文"],
  35. "avoid": ["需规避的内容"],
  36. "discover_notes": "给 TMDB discover 用的简短检索说明"
  37. }
  38. """
  39. SEARCH_AGENT_PROMPT = """你是电影检索专家。必须调用工具从 TMDB 取真实影片,禁止编造片名。
  40. 可用工具:
  41. - movies_discover: 主路径,按类型/年份/时长/语言发现(本轮只允许调用 1 次)
  42. - movies_search: 仅当需要解析「已看片名」时再用(可选,最多 1~2 次)
  43. 硬规则:
  44. 1. movies_discover 只调用一次:用建议参数一次取够候选,禁止换参反复 discover
  45. 2. with_original_language 只能是 zh/en/ja/ko;不要传「好莱坞」「英语」等中文
  46. 3. 拿到工具结果后立即输出最终 JSON,不要再调工具「精炼」
  47. 4. 最终 movies 必须从工具结果原样抄写关键字段(含 poster_url)
  48. 取数后,最终回复必须是 JSON(不要多余解释):
  49. {
  50. "movies": [
  51. {
  52. "id": 123,
  53. "title": "...",
  54. "year": 2020,
  55. "genres": [],
  56. "rating": 7.5,
  57. "poster_url": "https://...",
  58. "overview": "..."
  59. }
  60. ]
  61. }
  62. 要求:
  63. 1. 尽量返回 15~25 部
  64. 2. id / title / poster_url 等必须来自工具结果,禁止省略 poster_url
  65. 3. 排除用户已给出的 exclude_ids
  66. """
  67. RECOMMEND_AGENT_PROMPT = """你是电影推荐专家。你没有外部工具,只能从「候选列表」中挑选 3~5 部。
  68. 硬约束:
  69. 1. 每部电影的 id 必须出现在候选列表中
  70. 2. 禁止编造候选之外的片名或 id
  71. 3. 遵守 spoilers_ok:若为 false,overview_safe 不要写结局剧透
  72. 4. why 要贴合用户心情与人群
  73. 5. title / year / genres / rating / poster_url 尽量原样沿用候选列表(勿改写为空)
  74. 只返回 JSON:
  75. {
  76. "playlist_name": "片单主题名",
  77. "profile_summary": "对用户口味的一句话总结",
  78. "movies": [
  79. {
  80. "id": 123,
  81. "title": "...",
  82. "year": 2020,
  83. "genres": ["..."],
  84. "runtime": null,
  85. "rating": 7.5,
  86. "poster_url": "https://image.tmdb.org/t/p/w500/...",
  87. "why": "推荐理由",
  88. "vibe_tags": ["标签"],
  89. "caution": null,
  90. "overview_safe": "安全简介"
  91. }
  92. ],
  93. "is_fallback": false
  94. }
  95. """
  96. REGION_LANGUAGE = {
  97. "华语": "zh",
  98. "好莱坞": "en",
  99. "日韩": "ja", # 简化:先按日语;韩语可由画像 language_hints 覆盖
  100. "欧洲": "",
  101. "不限": "",
  102. }
  103. class MultiAgentMovieRecommender:
  104. """串行三 Agent 推荐编排器(画像 → 检索 → 推荐 + 白名单校验)。"""
  105. def __init__(self) -> None:
  106. """初始化共享 LLM / MovieTool,并创建三个 SimpleAgent。"""
  107. self.llm = get_llm()
  108. self.movie_tool = get_movie_tool()
  109. settings = get_settings()
  110. # Trace 开关来自 .env:TRACE_ENABLED / TRACE_DIR
  111. agent_config = Config(
  112. trace_enabled=settings.trace_enabled,
  113. trace_dir=settings.trace_dir,
  114. )
  115. # 画像:只做偏好结构化,禁止挂工具(避免这步就去搜片/编片名)
  116. self.profile_agent = SimpleAgent(
  117. name="画像专家",
  118. llm=self.llm,
  119. system_prompt=PROFILE_AGENT_PROMPT,
  120. config=agent_config,
  121. enable_tool_calling=False,
  122. )
  123. # 检索:唯一允许碰 TMDB 的 Agent;工具展开为 discover / search
  124. # max_tool_iterations=2:1 轮工具 + 1 轮收尾文本;再高容易反复换参 discover
  125. self.search_agent = SimpleAgent(
  126. name="检索专家",
  127. llm=self.llm,
  128. system_prompt=SEARCH_AGENT_PROMPT,
  129. config=agent_config,
  130. max_tool_iterations=2,
  131. )
  132. self.search_agent.add_tool(self.movie_tool)
  133. # 推荐:无工具,只能在上游候选里选择与说理(防幻觉核心)
  134. self.recommend_agent = SimpleAgent(
  135. name="推荐专家",
  136. llm=self.llm,
  137. system_prompt=RECOMMEND_AGENT_PROMPT,
  138. config=agent_config,
  139. enable_tool_calling=False,
  140. )
  141. logger.info(
  142. "MultiAgentMovieRecommender 就绪: tools=%s trace=%s",
  143. self.search_agent.list_tools(),
  144. settings.trace_enabled,
  145. )
  146. def recommend(self, request: RecommendRequest) -> Tuple[RecommendResult, str]:
  147. """跑完整推荐流水线。
  148. Returns:
  149. (RecommendResult, message):业务结果 + 给人看的状态说明(含降级提示)。
  150. """
  151. pipeline_t0 = time.perf_counter()
  152. try:
  153. logger.info("推荐开始 mood=%s party=%s", request.mood, request.party_type)
  154. # ① 偏好 → TasteProfile;换一批可携带 taste_profile 跳过画像 LLM
  155. t0 = time.perf_counter()
  156. profile, profile_reused = self._resolve_profile(request)
  157. logger.info(
  158. "阶段完成 stage=profile elapsed=%.2fs reused=%s summary=%s",
  159. time.perf_counter() - t0,
  160. profile_reused,
  161. profile.summary,
  162. )
  163. # ② 真片候选;失败则降级,避免在空列表上瞎荐
  164. t0 = time.perf_counter()
  165. candidates = self._run_search(request, profile)
  166. logger.info(
  167. "阶段完成 stage=search elapsed=%.2fs candidates=%d",
  168. time.perf_counter() - t0,
  169. len(candidates),
  170. )
  171. if not candidates:
  172. result = self._fallback_result(request, profile, [], "未取得候选片")
  173. return result, "检索无结果,已返回降级片单"
  174. # ③ 候选内推荐 + 代码层 id 白名单(不信任模型自觉)
  175. t0 = time.perf_counter()
  176. result = self._run_recommend(request, profile, candidates)
  177. logger.info(
  178. "阶段完成 stage=recommend_llm elapsed=%.2fs",
  179. time.perf_counter() - t0,
  180. )
  181. t0 = time.perf_counter()
  182. result = self._enforce_candidate_ids(result, candidates, profile)
  183. result = self._attach_taste_profile(result, profile)
  184. logger.info(
  185. "阶段完成 stage=enforce elapsed=%.2fs movies=%d fallback=%s total=%.2fs",
  186. time.perf_counter() - t0,
  187. len(result.movies),
  188. result.is_fallback,
  189. time.perf_counter() - pipeline_t0,
  190. )
  191. msg = "推荐生成成功" if not result.is_fallback else "推荐已做 id 校正/降级"
  192. if profile_reused:
  193. msg = f"{msg}(已跳过画像)"
  194. return result, msg
  195. except Exception as e:
  196. # 未捕获异常也返回完整结构,前端不白屏
  197. logger.exception("推荐流水线异常")
  198. result = self._fallback_result(request, None, [], str(e))
  199. return result, f"推荐异常,已降级: {e}"
  200. # ----- stages -----
  201. def _resolve_profile(self, request: RecommendRequest) -> Tuple[TasteProfile, bool]:
  202. """解析画像:请求携带可用 taste_profile 则复用,否则跑画像 Agent。"""
  203. reused = request.taste_profile
  204. if reused is not None and (
  205. (reused.summary or "").strip()
  206. or reused.genre_hints
  207. or (reused.discover_notes or "").strip()
  208. ):
  209. logger.info("跳过画像 Agent,复用请求中的 taste_profile")
  210. return self._sanitize_profile(reused, request), True
  211. return self._sanitize_profile(self._run_profile(request), request), False
  212. def _sanitize_profile(
  213. self,
  214. profile: TasteProfile,
  215. request: RecommendRequest,
  216. ) -> TasteProfile:
  217. """规范化 language_hints 为 ISO 码;非法项丢弃。"""
  218. cleaned: List[str] = []
  219. for hint in profile.language_hints or []:
  220. code = normalize_tmdb_language(hint)
  221. if code and code not in cleaned:
  222. cleaned.append(code)
  223. if not cleaned:
  224. fallback = normalize_tmdb_language(
  225. REGION_LANGUAGE.get(request.region_preference, "")
  226. )
  227. if fallback:
  228. cleaned = [fallback]
  229. if cleaned != list(profile.language_hints or []):
  230. logger.info(
  231. "画像 language_hints 已归一化: %s -> %s",
  232. profile.language_hints,
  233. cleaned,
  234. )
  235. profile.language_hints = cleaned
  236. return profile
  237. def _resolve_language(
  238. self,
  239. request: RecommendRequest,
  240. profile: TasteProfile,
  241. ) -> Optional[str]:
  242. """解析最终用于 discover 的语言码。"""
  243. if profile.language_hints:
  244. code = normalize_tmdb_language(profile.language_hints[0])
  245. if code:
  246. return code
  247. return normalize_tmdb_language(
  248. REGION_LANGUAGE.get(request.region_preference, "")
  249. )
  250. def _attach_taste_profile(
  251. self,
  252. result: RecommendResult,
  253. profile: TasteProfile,
  254. ) -> RecommendResult:
  255. """把本次画像挂到结果上,供换一批回传。"""
  256. result.taste_profile = profile
  257. if not result.profile_summary:
  258. result.profile_summary = profile.summary
  259. return result
  260. def _run_profile(self, request: RecommendRequest) -> TasteProfile:
  261. """阶段①:调用画像 Agent,解析为 TasteProfile;失败则用表单字段兜底。"""
  262. self.profile_agent.clear_history()
  263. raw = self.profile_agent.run(self._build_profile_query(request))
  264. data = self._extract_json(raw) or {}
  265. try:
  266. return TasteProfile(**data)
  267. except Exception:
  268. # 画像 JSON 坏了:用表单字段拼可用 profile,保证后续检索能继续
  269. return TasteProfile(
  270. summary=f"{request.mood}/{request.party_type} 观影",
  271. genre_hints=list(request.genres),
  272. language_hints=[REGION_LANGUAGE.get(request.region_preference, "")],
  273. avoid=[],
  274. discover_notes=request.free_text or "",
  275. )
  276. def _run_search(
  277. self,
  278. request: RecommendRequest,
  279. profile: TasteProfile,
  280. ) -> List[CandidateMovie]:
  281. """阶段②:检索 Agent 调工具取真片;解析失败则 MovieService 规则 discover 兜底。"""
  282. self.search_agent.clear_history()
  283. self.movie_tool.begin_search_run(discover_limit=1)
  284. t0 = time.perf_counter()
  285. try:
  286. raw = self.search_agent.run(self._build_search_query(request, profile))
  287. finally:
  288. self.movie_tool.end_search_run()
  289. logger.info(
  290. "检索 Agent run 结束 elapsed=%.2fs raw_len=%d",
  291. time.perf_counter() - t0,
  292. len(raw or ""),
  293. )
  294. movies = self._parse_candidates(raw, request.exclude_ids)
  295. if movies:
  296. missing_poster = sum(1 for m in movies if not m.poster_url)
  297. logger.info(
  298. "检索 Agent 解析成功 count=%d missing_poster=%d",
  299. len(movies),
  300. missing_poster,
  301. )
  302. return movies
  303. # Agent 未给出可用 JSON 时,用 profile 规则直连 MovieService(仍是真数据)
  304. logger.warning("检索 Agent 未解析出候选,改用 MovieService 规则兜底")
  305. t0 = time.perf_counter()
  306. fallback = self._discover_by_profile(request, profile)
  307. logger.info(
  308. "阶段完成 stage=search_fallback_discover elapsed=%.2fs count=%d",
  309. time.perf_counter() - t0,
  310. len(fallback),
  311. )
  312. return fallback
  313. def _run_recommend(
  314. self,
  315. request: RecommendRequest,
  316. profile: TasteProfile,
  317. candidates: List[CandidateMovie],
  318. ) -> RecommendResult:
  319. """阶段③:推荐 Agent 仅在候选内产出 RecommendResult;JSON 坏则降级。"""
  320. self.recommend_agent.clear_history()
  321. raw = self.recommend_agent.run(
  322. self._build_recommend_query(request, profile, candidates)
  323. )
  324. data = self._extract_json(raw)
  325. if not data:
  326. return self._fallback_result(request, profile, candidates, "推荐 JSON 解析失败")
  327. try:
  328. data.setdefault("is_fallback", False)
  329. # 画像由编排器挂载,不采信模型自带的 taste_profile 字段
  330. data.pop("taste_profile", None)
  331. return RecommendResult(**data)
  332. except Exception:
  333. return self._fallback_result(request, profile, candidates, "推荐结构校验失败")
  334. # ----- queries -----
  335. def _build_profile_query(self, request: RecommendRequest) -> str:
  336. """把 RecommendRequest 拼成画像 Agent 的用户输入文本。"""
  337. return (
  338. f"心情: {request.mood}\n"
  339. f"人群: {request.party_type}\n"
  340. f"类型偏好: {', '.join(request.genres) or '无'}\n"
  341. f"时长上限(分钟): {request.max_runtime_minutes}\n"
  342. f"地区: {request.region_preference}\n"
  343. f"年代: {request.year_preference}\n"
  344. f"已看过: {', '.join(request.exclude_titles) or '无'}\n"
  345. f"允许剧透: {request.spoilers_ok}\n"
  346. f"额外要求: {request.free_text or '无'}\n"
  347. "请输出 TasteProfile JSON。"
  348. )
  349. def _build_search_query(self, request: RecommendRequest, profile: TasteProfile) -> str:
  350. """把画像 + 表单约束拼成检索 Agent 输入(含建议的 discover 参数)。"""
  351. # 预先算好 discover 参数提示,降低模型乱填工具参数的概率
  352. year_gte, year_lte = self._year_bounds(request.year_preference)
  353. lang = self._resolve_language(request, profile) or ""
  354. genres = ",".join(profile.genre_hints or request.genres)
  355. parts = [
  356. "请只调用一次 movies_discover(用下列建议参数),取到结果后立刻输出 JSON;不要反复换参 discover。",
  357. f"画像摘要: {profile.summary}",
  358. f"建议 with_genres: {genres or '不限'}",
  359. f"建议 year_gte: {year_gte or 0}, year_lte: {year_lte or 0}",
  360. f"建议 max_runtime: {request.max_runtime_minutes or 0}",
  361. f"建议 with_original_language: {lang or '不限'}(仅 zh/en/ja/ko)",
  362. f"discover_notes: {profile.discover_notes}",
  363. f"exclude_ids: {request.exclude_ids}",
  364. f"已看片名(仅必要时用 movies_search 辅助排除): {request.exclude_titles}",
  365. "最终只输出含 movies 数组的 JSON,且每部必须带工具返回的 poster_url。",
  366. ]
  367. return "\n".join(parts)
  368. def _build_recommend_query(
  369. self,
  370. request: RecommendRequest,
  371. profile: TasteProfile,
  372. candidates: List[CandidateMovie],
  373. ) -> str:
  374. """把用户偏好 + 精简候选列表拼成推荐 Agent 输入。"""
  375. # 只塞精简字段进 prompt;片名/海报等最终以候选元数据为准
  376. slim = [
  377. {
  378. "id": c.id,
  379. "title": c.title,
  380. "year": c.year,
  381. "genres": c.genres,
  382. "rating": c.rating,
  383. "poster_url": c.poster_url,
  384. "overview": (c.overview or "")[:180],
  385. }
  386. for c in candidates
  387. ]
  388. return (
  389. f"用户心情: {request.mood}; 人群: {request.party_type}; "
  390. f"剧透允许: {request.spoilers_ok}\n"
  391. f"画像: {profile.summary}\n"
  392. f"额外要求: {request.free_text or '无'}\n"
  393. f"候选列表(只能从中选):\n{json.dumps(slim, ensure_ascii=False)}\n"
  394. "请输出 RecommendResult JSON(3~5 部)。"
  395. )
  396. # ----- helpers -----
  397. @staticmethod
  398. def _year_bounds(year_preference: str) -> Tuple[Optional[int], Optional[int]]:
  399. """表单年代偏好 → TMDB discover 的 (year_gte, year_lte)。"""
  400. year = datetime.now().year
  401. if year_preference == "近5年":
  402. return year - 5, None
  403. if year_preference == "近10年":
  404. return year - 10, None
  405. if year_preference == "经典":
  406. return None, 2000
  407. return None, None
  408. def _discover_by_profile(
  409. self,
  410. request: RecommendRequest,
  411. profile: TasteProfile,
  412. ) -> List[CandidateMovie]:
  413. """不经 LLM,按画像字段确定性 discover;空结果自动放宽条件。"""
  414. year_gte, year_lte = self._year_bounds(request.year_preference)
  415. lang = self._resolve_language(request, profile)
  416. genres = ",".join(profile.genre_hints or request.genres) or None
  417. return get_movie_service().discover_with_relax(
  418. with_genres=genres,
  419. year_gte=year_gte,
  420. year_lte=year_lte,
  421. max_runtime=request.max_runtime_minutes,
  422. with_original_language=lang,
  423. page=1,
  424. exclude_ids=request.exclude_ids,
  425. )
  426. def _parse_candidates(self, raw: str, exclude_ids: List[int]) -> List[CandidateMovie]:
  427. """从检索 Agent 文本抽出 movies,过滤 exclude_ids 与空标题。"""
  428. data = self._extract_json(raw)
  429. if not data:
  430. return []
  431. items = data.get("movies") if isinstance(data, dict) else None
  432. if not isinstance(items, list):
  433. return []
  434. exclude = set(exclude_ids)
  435. out: List[CandidateMovie] = []
  436. for item in items:
  437. if not isinstance(item, dict) or "id" not in item:
  438. continue
  439. try:
  440. movie = CandidateMovie(
  441. id=int(item["id"]),
  442. title=str(item.get("title") or ""),
  443. year=item.get("year"),
  444. genres=item.get("genres") or [],
  445. runtime=item.get("runtime"),
  446. rating=item.get("rating"),
  447. poster_url=item.get("poster_url"),
  448. overview=item.get("overview") or "",
  449. )
  450. except Exception:
  451. continue
  452. if movie.id in exclude or not movie.title:
  453. continue
  454. out.append(movie)
  455. return out
  456. def _card_from_candidate(
  457. self,
  458. src: CandidateMovie,
  459. *,
  460. why: str = "",
  461. vibe_tags: Optional[List[str]] = None,
  462. caution: Optional[str] = None,
  463. overview_safe: str = "",
  464. runtime: Optional[int] = None,
  465. poster_url: Optional[str] = None,
  466. ) -> MovieCard:
  467. """候选 → MovieCard;缺海报时按 id 拉 detail 回填(仅最终 3~5 部)。"""
  468. poster = src.poster_url or poster_url
  469. title = src.title
  470. year = src.year
  471. genres = list(src.genres or [])
  472. rating = src.rating
  473. overview = overview_safe or (src.overview or "")[:200]
  474. rt = runtime if runtime is not None else src.runtime
  475. if not poster:
  476. try:
  477. detail = get_movie_service().get_detail(src.id)
  478. poster = detail.poster_url
  479. title = title or detail.title
  480. year = year if year is not None else detail.year
  481. genres = genres or list(detail.genres or [])
  482. rating = rating if rating is not None else detail.rating
  483. if not overview_safe and detail.overview:
  484. overview = detail.overview[:200]
  485. if rt is None:
  486. rt = detail.runtime
  487. except Exception:
  488. logger.warning("MovieCard 海报回填失败 id=%s", src.id)
  489. return MovieCard(
  490. id=src.id,
  491. title=title,
  492. year=year,
  493. genres=genres,
  494. runtime=rt,
  495. rating=rating,
  496. poster_url=poster,
  497. why=why,
  498. vibe_tags=vibe_tags or [],
  499. caution=caution,
  500. overview_safe=overview,
  501. )
  502. def _enforce_candidate_ids(
  503. self,
  504. result: RecommendResult,
  505. candidates: List[CandidateMovie],
  506. profile: TasteProfile,
  507. ) -> RecommendResult:
  508. """白名单闸:丢弃候选外 id;元数据以 TMDB 候选为准;不足 3 部则补齐并降级。"""
  509. allowed = {c.id: c for c in candidates}
  510. kept: List[MovieCard] = []
  511. for card in result.movies:
  512. if card.id not in allowed:
  513. continue
  514. src = allowed[card.id]
  515. kept.append(
  516. self._card_from_candidate(
  517. src,
  518. why=card.why,
  519. vibe_tags=card.vibe_tags,
  520. caution=card.caution,
  521. overview_safe=card.overview_safe or (src.overview or "")[:200],
  522. runtime=card.runtime if card.runtime is not None else src.runtime,
  523. poster_url=card.poster_url,
  524. )
  525. )
  526. if 3 <= len(kept) <= 5:
  527. result.movies = kept
  528. return result
  529. # 合法片不足 3 部:按评分从候选补齐,并标记降级
  530. result.is_fallback = True
  531. have = {m.id for m in kept}
  532. ranked = sorted(
  533. candidates,
  534. key=lambda m: (m.rating is not None, m.rating or 0),
  535. reverse=True,
  536. )
  537. for c in ranked:
  538. if c.id in have:
  539. continue
  540. kept.append(
  541. self._card_from_candidate(
  542. c,
  543. why="系统按候选热度补齐",
  544. overview_safe=(c.overview or "")[:200],
  545. )
  546. )
  547. if len(kept) >= 3:
  548. break
  549. result.movies = kept[:5]
  550. if not result.profile_summary and profile:
  551. result.profile_summary = profile.summary
  552. if not result.playlist_name:
  553. result.playlist_name = "今日候选速选"
  554. return result
  555. def _fallback_result(
  556. self,
  557. request: RecommendRequest,
  558. profile: Optional[TasteProfile],
  559. candidates: List[CandidateMovie],
  560. reason: str,
  561. ) -> RecommendResult:
  562. """诚实降级:尽量用真片凑片单,强制 is_fallback=True。"""
  563. if not candidates:
  564. try:
  565. candidates = self._discover_by_profile(
  566. request,
  567. profile
  568. or TasteProfile(
  569. summary=reason,
  570. genre_hints=list(request.genres),
  571. ),
  572. )
  573. except Exception:
  574. candidates = []
  575. movies: List[MovieCard] = []
  576. for c in candidates[:5]:
  577. movies.append(
  578. self._card_from_candidate(
  579. c,
  580. why=f"降级推荐({reason})",
  581. overview_safe=(c.overview or "")[:200],
  582. )
  583. )
  584. return RecommendResult(
  585. playlist_name="降级片单",
  586. profile_summary=(profile.summary if profile else reason),
  587. movies=movies,
  588. is_fallback=True,
  589. taste_profile=profile,
  590. )
  591. @staticmethod
  592. def _extract_json(text: str) -> Optional[dict]:
  593. """从模型文本提取 JSON 对象(纯 JSON / 代码块 / 夹杂说明均可)。"""
  594. if not text:
  595. return None
  596. text = text.strip()
  597. try:
  598. data = json.loads(text)
  599. return data if isinstance(data, dict) else None
  600. except json.JSONDecodeError:
  601. pass
  602. fence = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
  603. if fence:
  604. try:
  605. data = json.loads(fence.group(1))
  606. return data if isinstance(data, dict) else None
  607. except json.JSONDecodeError:
  608. pass
  609. start, end = text.find("{"), text.rfind("}")
  610. if start >= 0 and end > start:
  611. try:
  612. data = json.loads(text[start : end + 1])
  613. return data if isinstance(data, dict) else None
  614. except json.JSONDecodeError:
  615. return None
  616. return None
  617. def health_snapshot(self) -> dict[str, Any]:
  618. """返回各 Agent 名称与工具数量(供 /api/recommend/health)。"""
  619. return {
  620. "agents": [
  621. {"name": self.profile_agent.name, "tools_count": 0},
  622. {
  623. "name": self.search_agent.name,
  624. "tools_count": len(self.search_agent.list_tools()),
  625. },
  626. {"name": self.recommend_agent.name, "tools_count": 0},
  627. ]
  628. }
  629. _recommender: Optional[MultiAgentMovieRecommender] = None
  630. def get_movie_recommender() -> MultiAgentMovieRecommender:
  631. """获取进程内编排器单例(懒加载,避免重复初始化 LLM/Agent)。"""
  632. global _recommender
  633. if _recommender is None:
  634. _recommender = MultiAgentMovieRecommender()
  635. return _recommender