gemini_video.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428
  1. """Gemini 视频相关性 client (V4-M3).
  2. 实现 interfaces.GeminiVideoClient.analyze:取视频(video_fetch)→ 多模态投给
  3. Gemini(OpenRouter image_url data URL)→ 解析 V4 query relevance-only 结构。
  4. 任何失败一律返回 V4 failed 结构,不抛、不卡 run。
  5. """
  6. from __future__ import annotations
  7. import json
  8. import os
  9. import re
  10. import time
  11. from typing import Any, Callable, Mapping
  12. import httpx
  13. from content_agent.integrations import timeout_config, video_fetch
  14. DEFAULT_OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
  15. DEFAULT_VIDEO_MODEL = "google/gemini-3-flash-preview"
  16. DEFAULT_VIDEO_TIMEOUT_SECONDS = timeout_config.total_timeout("video_llm") # 10min(原 30min)
  17. V4_GEMINI_QUERY_RELEVANCE_SCHEMA_VERSION = "v4_gemini_query_relevance.v1"
  18. _SYSTEM_PROMPT = "你是视频内容与搜索需求相关性审核助手。只输出一个 JSON 对象,不要任何解释或 markdown。"
  19. _USER_PROMPT = (
  20. "判断这条视频,严格按以下 JSON 结构输出(只输出 JSON):\n"
  21. '{{"query_relevance_score": 0~100 的整数或小数, "query_relevance_reason": "中文简述理由"}}\n'
  22. "字段含义:\n"
  23. "- query_relevance_score: 视频内容与搜索需求【{query_text}】的相关程度,0 表示完全无关,100 表示高度相关。\n"
  24. "- query_relevance_reason: 一句话说明相关或不相关的关键原因。"
  25. )
  26. def _fail(
  27. failure_type: str,
  28. *,
  29. query_text: str = "(未指定)",
  30. exception_type: str | None = None,
  31. error_message: str | None = None,
  32. http_status_code: int | None = None,
  33. retry_count: int = 1,
  34. response_body_summary: dict[str, Any] | None = None,
  35. ) -> dict[str, Any]:
  36. result = {
  37. "schema_version": V4_GEMINI_QUERY_RELEVANCE_SCHEMA_VERSION,
  38. "query_text": query_text,
  39. "query_relevance_score": None,
  40. "query_relevance_reason": "",
  41. "final_status": "failed",
  42. "failure_type": failure_type,
  43. "exception_type": exception_type,
  44. "http_status_code": http_status_code,
  45. "retry_count": retry_count,
  46. }
  47. if error_message:
  48. result["error_message"] = error_message
  49. if response_body_summary:
  50. result["response_body_summary"] = response_body_summary
  51. return result
  52. def _video_url_candidates(media: dict[str, Any]) -> list[tuple[str, str]]:
  53. raw_payload = media.get("raw_payload") if isinstance(media.get("raw_payload"), dict) else {}
  54. candidates: list[tuple[str, str]] = []
  55. seen: set[str] = set()
  56. for source, value in [
  57. ("oss_url", media.get("oss_url")),
  58. ("cdn_url", media.get("cdn_url")),
  59. ("raw_payload.cdn_url", raw_payload.get("cdn_url")),
  60. ("play_url", media.get("play_url")),
  61. ]:
  62. if value:
  63. url = str(value)
  64. if url not in seen:
  65. seen.add(url)
  66. candidates.append((url, source))
  67. return candidates
  68. def _with_media_update(result: dict[str, Any], update: dict[str, Any] | None) -> dict[str, Any]:
  69. if update:
  70. return {**result, "media_storage_update": update}
  71. return result
  72. def _with_timing(result: dict[str, Any], timing: dict[str, Any]) -> dict[str, Any]:
  73. return {**result, "timing_metrics": timing}
  74. def _clamp_score(value: Any) -> float:
  75. try:
  76. number = float(value)
  77. except (TypeError, ValueError):
  78. return 0.0
  79. return max(0.0, min(100.0, number))
  80. def _parse(payload: dict[str, Any], query_text: str) -> dict[str, Any]:
  81. content = payload["choices"][0]["message"]["content"]
  82. text = str(content).strip()
  83. if text.startswith("```"):
  84. text = text.split("```", 2)[1]
  85. if text.startswith("json"):
  86. text = text[4:]
  87. data = json.loads(text)
  88. return {
  89. "schema_version": V4_GEMINI_QUERY_RELEVANCE_SCHEMA_VERSION,
  90. "query_text": query_text,
  91. "query_relevance_score": _clamp_score(data["query_relevance_score"]),
  92. "query_relevance_reason": str(data.get("query_relevance_reason") or ""),
  93. "final_status": "ok",
  94. "retry_count": 0,
  95. }
  96. def _query_text(content: dict[str, Any], source_context: dict[str, Any]) -> str:
  97. matched = content.get("matched_search_queries")
  98. if isinstance(matched, list) and matched:
  99. return "、".join(str(item) for item in matched if item)
  100. query_sources = content.get("query_sources")
  101. if isinstance(query_sources, list):
  102. queries = [
  103. str(item.get("search_query"))
  104. for item in query_sources
  105. if isinstance(item, dict) and item.get("search_query")
  106. ]
  107. if queries:
  108. return "、".join(queries)
  109. if content.get("search_query"):
  110. return str(content["search_query"])
  111. evidence = source_context.get("ext_data", {}).get("evidence_pack", {})
  112. terms = evidence.get("seed_terms") or []
  113. return "、".join(str(t) for t in terms) or "(未指定)"
  114. def _http_status(exc: httpx.HTTPError) -> int | None:
  115. response = getattr(exc, "response", None)
  116. status_code = getattr(response, "status_code", None)
  117. return int(status_code) if isinstance(status_code, int) else None
  118. def _response_body_summary(response: Any | None) -> dict[str, Any] | None:
  119. if response is None:
  120. return None
  121. summary: dict[str, Any] = {
  122. "http_status_code": getattr(response, "status_code", None),
  123. }
  124. headers = getattr(response, "headers", None)
  125. if headers is not None:
  126. summary["content_type"] = headers.get("content-type", "")
  127. retry_after = headers.get("retry-after")
  128. if retry_after:
  129. summary["retry_after"] = retry_after
  130. try:
  131. content = response.content or b""
  132. except Exception:
  133. content = b""
  134. if content:
  135. summary["body_bytes"] = len(content)
  136. try:
  137. payload = response.json()
  138. except Exception:
  139. payload = None
  140. if isinstance(payload, dict):
  141. summary["json_top_level_keys"] = sorted(str(key) for key in payload.keys())[:20]
  142. text_source = payload.get("error") or payload.get("message") or payload.get("msg")
  143. if isinstance(text_source, dict):
  144. text_source = text_source.get("message") or text_source.get("msg") or text_source.get("code")
  145. if text_source is not None:
  146. summary["text_excerpt"] = _scrub_response_text(str(text_source))
  147. elif content:
  148. summary["text_excerpt"] = _scrub_response_text(content.decode("utf-8", errors="replace"))
  149. return {key: value for key, value in summary.items() if value not in (None, "", [], {})}
  150. def _scrub_response_text(text: str, limit: int = 500) -> str:
  151. text = " ".join(text.split())
  152. text = re.sub(r"Bearer\s+[A-Za-z0-9._~+/=-]+", "Bearer [REDACTED]", text, flags=re.IGNORECASE)
  153. text = re.sub(r"sk-[A-Za-z0-9_-]{16,}", "sk-[REDACTED]", text)
  154. text = re.sub(r"data:[^,\s]+;base64,[A-Za-z0-9+/=]{128,}", "data:[REDACTED]", text)
  155. text = re.sub(r"(https?://[^\s?]+)\?[^\s]+", r"\1?[REDACTED]", text)
  156. return text[:limit] + ("..." if len(text) > limit else "")
  157. def _retryable_http(exc: httpx.HTTPError) -> bool:
  158. if isinstance(exc, (httpx.ConnectError, httpx.TimeoutException, httpx.NetworkError)):
  159. return True
  160. status = _http_status(exc)
  161. return status in {408, 429} or (status is not None and 500 <= status <= 599)
  162. class GeminiVideoClient:
  163. def __init__(
  164. self,
  165. *,
  166. api_key: str,
  167. model: str = DEFAULT_VIDEO_MODEL,
  168. base_url: str = DEFAULT_OPENROUTER_BASE_URL,
  169. timeout_seconds: float = DEFAULT_VIDEO_TIMEOUT_SECONDS,
  170. fetch_fn: Callable[..., str] = video_fetch.fetch_and_compress,
  171. http_post: Callable[..., Any] = httpx.post,
  172. ) -> None:
  173. self.api_key = api_key
  174. self.model = model
  175. self.base_url = base_url.rstrip("/")
  176. self.timeout_seconds = timeout_seconds
  177. self.fetch_fn = fetch_fn
  178. self.http_post = http_post
  179. @classmethod
  180. def from_env(cls, env: Mapping[str, str] | None = None) -> "GeminiVideoClient":
  181. source = os.environ if env is None else env
  182. api_key = source.get("OPENROUTER_API_KEY") or source.get("OPEN_ROUTER_API_KEY")
  183. if not api_key:
  184. return MissingGeminiVideoClient("gemini video config missing: OPENROUTER_API_KEY")
  185. return cls(
  186. api_key=api_key,
  187. model=source.get("CONTENT_AGENT_VIDEO_LLM_MODEL") or DEFAULT_VIDEO_MODEL,
  188. base_url=source.get("OPENROUTER_BASE_URL") or DEFAULT_OPENROUTER_BASE_URL,
  189. timeout_seconds=float(source.get("CONTENT_AGENT_VIDEO_LLM_TIMEOUT_SECONDS") or DEFAULT_VIDEO_TIMEOUT_SECONDS),
  190. )
  191. def analyze(
  192. self,
  193. content: dict[str, Any],
  194. media: dict[str, Any],
  195. source_context: dict[str, Any],
  196. ) -> dict[str, Any]:
  197. query_text = _query_text(content, source_context)
  198. video_candidates = _video_url_candidates(media)
  199. if not video_candidates:
  200. media_raw = media.get("raw_payload") if isinstance(media.get("raw_payload"), dict) else {}
  201. failure_reason = (
  202. media.get("failure_reason")
  203. or media_raw.get("failure_reason")
  204. or "no_play_url"
  205. )
  206. return _with_media_update(
  207. _with_timing(
  208. _fail(str(failure_reason), query_text=query_text),
  209. {"video_fetch": {"skipped": str(failure_reason)}},
  210. ),
  211. _media_unavailable_update(str(failure_reason)),
  212. )
  213. timing_metrics: dict[str, Any] = {}
  214. data_url = ""
  215. fetch_attempts: list[dict[str, Any]] = []
  216. last_fetch_exc: Exception | None = None
  217. for video_url, video_source in video_candidates:
  218. try:
  219. data_url, fetch_metrics = self._fetch_video(video_url, content.get("platform", "douyin"))
  220. timing_metrics["video_fetch"] = {
  221. **fetch_metrics,
  222. "gemini_video_source": video_source,
  223. "attempts": fetch_attempts,
  224. }
  225. break
  226. except Exception as exc:
  227. last_fetch_exc = exc
  228. fetch_metrics = getattr(exc, "metrics", {}) or {}
  229. fetch_attempts.append(
  230. {
  231. **fetch_metrics,
  232. "gemini_video_source": video_source,
  233. "status": "failed",
  234. "exception_type": type(exc).__name__,
  235. "error_message": str(exc),
  236. }
  237. )
  238. else:
  239. assert last_fetch_exc is not None
  240. fetch_metrics = getattr(last_fetch_exc, "metrics", {}) or {}
  241. timing_metrics["video_fetch"] = {
  242. **fetch_metrics,
  243. "gemini_video_source": video_candidates[-1][1],
  244. "attempts": fetch_attempts,
  245. }
  246. return _with_timing(
  247. _fail(
  248. "video_fetch_failed",
  249. query_text=query_text,
  250. exception_type=type(last_fetch_exc).__name__,
  251. error_message=str(last_fetch_exc),
  252. ),
  253. timing_metrics,
  254. )
  255. messages = [
  256. {"role": "system", "content": _SYSTEM_PROMPT},
  257. {
  258. "role": "user",
  259. "content": [
  260. {"type": "text", "text": _USER_PROMPT.format(query_text=query_text)},
  261. {"type": "image_url", "image_url": {"url": data_url}},
  262. ],
  263. },
  264. ]
  265. last_failure: dict[str, Any] | None = None
  266. request_attempts: list[dict[str, Any]] = []
  267. request_started_at = time.monotonic()
  268. for attempt in range(2):
  269. retry_count = attempt + 1
  270. attempt_started_at = time.monotonic()
  271. response_summary: dict[str, Any] | None = None
  272. try:
  273. response = self.http_post(
  274. f"{self.base_url}/chat/completions",
  275. headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"},
  276. json={"model": self.model, "messages": messages},
  277. timeout=timeout_config.as_httpx_timeout(
  278. self.timeout_seconds, read=timeout_config.read_timeout("video_llm")
  279. ),
  280. )
  281. response_summary = _response_body_summary(response)
  282. response.raise_for_status()
  283. payload = response.json()
  284. request_attempts.append(
  285. {
  286. "attempt": retry_count,
  287. "duration_ms": _elapsed_ms(attempt_started_at, time.monotonic()),
  288. "http_status_code": getattr(response, "status_code", None),
  289. "status": "ok",
  290. }
  291. )
  292. timing_metrics["gemini_request"] = {
  293. "total_duration_ms": _elapsed_ms(request_started_at, time.monotonic()),
  294. "attempts": request_attempts,
  295. }
  296. return _with_timing(_parse(payload, query_text), timing_metrics)
  297. except httpx.HTTPError as exc:
  298. failure_type = "gemini_client_timeout" if isinstance(exc, httpx.TimeoutException) else "gemini_http_error"
  299. response_summary = response_summary or _response_body_summary(getattr(exc, "response", None))
  300. request_attempts.append(
  301. {
  302. "attempt": retry_count,
  303. "duration_ms": _elapsed_ms(attempt_started_at, time.monotonic()),
  304. "http_status_code": _http_status(exc),
  305. "status": "failed",
  306. "failure_type": failure_type,
  307. "exception_type": type(exc).__name__,
  308. **({"response_body_summary": response_summary} if response_summary else {}),
  309. }
  310. )
  311. timing_metrics["gemini_request"] = {
  312. "total_duration_ms": _elapsed_ms(request_started_at, time.monotonic()),
  313. "attempts": request_attempts,
  314. }
  315. last_failure = _fail(
  316. failure_type,
  317. query_text=query_text,
  318. exception_type=type(exc).__name__,
  319. error_message=str(exc),
  320. http_status_code=_http_status(exc),
  321. retry_count=retry_count,
  322. response_body_summary=response_summary,
  323. )
  324. if attempt == 0 and _retryable_http(exc):
  325. continue
  326. return _with_timing(last_failure, timing_metrics)
  327. except (KeyError, IndexError, TypeError, ValueError, json.JSONDecodeError) as exc:
  328. request_attempts.append(
  329. {
  330. "attempt": retry_count,
  331. "duration_ms": _elapsed_ms(attempt_started_at, time.monotonic()),
  332. "status": "failed",
  333. "failure_type": "gemini_response_invalid",
  334. "exception_type": type(exc).__name__,
  335. **({"response_body_summary": response_summary} if response_summary else {}),
  336. }
  337. )
  338. timing_metrics["gemini_request"] = {
  339. "total_duration_ms": _elapsed_ms(request_started_at, time.monotonic()),
  340. "attempts": request_attempts,
  341. }
  342. last_failure = _fail(
  343. "gemini_response_invalid",
  344. query_text=query_text,
  345. exception_type=type(exc).__name__,
  346. error_message=str(exc),
  347. retry_count=retry_count,
  348. response_body_summary=response_summary,
  349. )
  350. if attempt == 0:
  351. continue
  352. return _with_timing(last_failure, timing_metrics)
  353. return _with_timing(last_failure or _fail("gemini_unknown_error", query_text=query_text), timing_metrics)
  354. def _fetch_video(self, play_url: str, platform: str) -> tuple[str, dict[str, Any]]:
  355. if self.fetch_fn is video_fetch.fetch_and_compress:
  356. return video_fetch.fetch_and_compress_with_metrics(play_url, platform)
  357. started_at = time.monotonic()
  358. result = self.fetch_fn(play_url, platform)
  359. if isinstance(result, tuple) and len(result) == 2 and isinstance(result[1], dict):
  360. return result[0], result[1]
  361. return result, {"total_duration_ms": _elapsed_ms(started_at, time.monotonic())}
  362. class MissingGeminiVideoClient:
  363. def __init__(self, reason: str) -> None:
  364. self.reason = reason
  365. def analyze(
  366. self,
  367. content: dict[str, Any],
  368. media: dict[str, Any],
  369. source_context: dict[str, Any],
  370. ) -> dict[str, Any]:
  371. return _fail(
  372. "gemini_config_missing",
  373. query_text=_query_text(content, source_context),
  374. exception_type=self.reason,
  375. error_message=self.reason,
  376. )
  377. def _media_unavailable_update(failure_reason: str) -> dict[str, Any]:
  378. return {
  379. "content_media_status": "unavailable",
  380. "oss_url": None,
  381. "local_path": None,
  382. "failure_reason": failure_reason,
  383. "raw_payload": {"failure_reason": failure_reason},
  384. }
  385. def _elapsed_ms(start: float, end: float) -> int:
  386. return max(0, int((end - start) * 1000))