qwen_video.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  1. """通义千问(DashScope)视频理解 client (V4-M3 同契约).
  2. 与 GeminiVideoClient 同一 analyze 契约,但两点不同:
  3. 1. 由模型侧 fetch 视频 URL(优先 oss_url),不在本地下载/压缩/base64;
  4. 2. 走 DashScope OpenAI 兼容端点(默认中国节点 dashscope.aliyuncs.com)。
  5. 失败一律返回 V4 failed 结构,不抛、不卡 run。复用 gemini_video 的解析/失败/打分机器。
  6. """
  7. from __future__ import annotations
  8. import json
  9. import os
  10. import time
  11. from typing import Any, Callable, Mapping
  12. import httpx
  13. from content_agent.integrations import timeout_config
  14. from content_agent.integrations.gemini_video import (
  15. DEFAULT_VIDEO_TIMEOUT_SECONDS,
  16. MissingGeminiVideoClient,
  17. _SYSTEM_PROMPT,
  18. _USER_PROMPT,
  19. _elapsed_ms,
  20. _fail,
  21. _http_status,
  22. _media_unavailable_update,
  23. _parse,
  24. _query_text,
  25. _response_body_summary,
  26. _retryable_http,
  27. _video_url_candidates,
  28. _with_media_update,
  29. _with_timing,
  30. )
  31. DEFAULT_DASHSCOPE_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
  32. DEFAULT_QWEN_VIDEO_MODEL = "qwen3.6-plus" # 通义千问 3.6 Plus(多模态,支持视频理解)
  33. DEFAULT_QWEN_MAX_ATTEMPTS = 2
  34. class QwenVideoClient:
  35. def __init__(
  36. self,
  37. *,
  38. api_key: str,
  39. model: str = DEFAULT_QWEN_VIDEO_MODEL,
  40. base_url: str = DEFAULT_DASHSCOPE_BASE_URL,
  41. timeout_seconds: float = DEFAULT_VIDEO_TIMEOUT_SECONDS,
  42. http_post: Callable[..., Any] = httpx.post,
  43. ) -> None:
  44. self.api_key = api_key
  45. self.model = model
  46. self.base_url = base_url.rstrip("/")
  47. self.timeout_seconds = timeout_seconds
  48. self.http_post = http_post
  49. @classmethod
  50. def from_env(cls, env: Mapping[str, str] | None = None) -> Any:
  51. source = os.environ if env is None else env
  52. api_key = (
  53. source.get("CONTENT_AGENT_VIDEO_LLM_API_KEY")
  54. or source.get("DASHSCOPE_API_KEY")
  55. or source.get("QWEN_API_KEY")
  56. )
  57. if not api_key:
  58. return MissingGeminiVideoClient("qwen video config missing: CONTENT_AGENT_VIDEO_LLM_API_KEY")
  59. return cls(
  60. api_key=api_key,
  61. model=source.get("CONTENT_AGENT_VIDEO_LLM_MODEL") or DEFAULT_QWEN_VIDEO_MODEL,
  62. base_url=source.get("CONTENT_AGENT_VIDEO_LLM_BASE_URL") or DEFAULT_DASHSCOPE_BASE_URL,
  63. timeout_seconds=float(source.get("CONTENT_AGENT_VIDEO_LLM_TIMEOUT_SECONDS") or DEFAULT_VIDEO_TIMEOUT_SECONDS),
  64. )
  65. def analyze(
  66. self,
  67. content: dict[str, Any],
  68. media: dict[str, Any],
  69. source_context: dict[str, Any],
  70. ) -> dict[str, Any]:
  71. result = self._no_fetchable_result(content, media, source_context)
  72. if result is not None:
  73. return result
  74. request_attempts: list[dict[str, Any]] = []
  75. started_at = time.monotonic()
  76. last_result: dict[str, Any] | None = None
  77. for attempt in range(1, 4):
  78. result = self.analyze_once(
  79. content,
  80. media,
  81. source_context,
  82. attempt_number=attempt,
  83. previous_attempts=request_attempts,
  84. request_started_at=started_at,
  85. )
  86. request_attempts = _qwen_attempts(result)
  87. last_result = result
  88. if result.get("final_status") != "failed":
  89. return result
  90. if not result.get("qwen_retryable") or attempt >= _max_attempts_for_failure(result):
  91. return result
  92. return last_result or _fail("qwen_unknown_error", query_text=_query_text(content, source_context))
  93. def analyze_once(
  94. self,
  95. content: dict[str, Any],
  96. media: dict[str, Any],
  97. source_context: dict[str, Any],
  98. *,
  99. attempt_number: int = 1,
  100. previous_attempts: list[dict[str, Any]] | None = None,
  101. request_started_at: float | None = None,
  102. ) -> dict[str, Any]:
  103. query_text = _query_text(content, source_context)
  104. fetchable = [(u, s) for (u, s) in _video_url_candidates(media) if s != "play_url"]
  105. if not fetchable:
  106. return self._no_fetchable_result(content, media, source_context) or _fail("no_oss_url", query_text=query_text)
  107. timing_metrics: dict[str, Any] = {}
  108. request_attempts = list(previous_attempts or [])
  109. started_at = request_started_at if request_started_at is not None else time.monotonic()
  110. video_url, video_source = fetchable[0]
  111. messages = [
  112. {"role": "system", "content": _SYSTEM_PROMPT},
  113. {
  114. "role": "user",
  115. "content": [
  116. {"type": "video_url", "video_url": {"url": video_url}},
  117. {"type": "text", "text": _USER_PROMPT.format(query_text=query_text)},
  118. ],
  119. },
  120. ]
  121. attempt_started_at = time.monotonic()
  122. response_summary: dict[str, Any] | None = None
  123. try:
  124. response = self.http_post(
  125. f"{self.base_url}/chat/completions",
  126. headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"},
  127. json={"model": self.model, "messages": messages},
  128. timeout=timeout_config.as_httpx_timeout(
  129. self.timeout_seconds, read=timeout_config.read_timeout("video_llm")
  130. ),
  131. )
  132. response_summary = _response_body_summary(response)
  133. response.raise_for_status()
  134. payload = response.json()
  135. parsed = _parse(payload, query_text)
  136. request_attempts.append(
  137. {
  138. "attempt": attempt_number,
  139. "video_source": video_source,
  140. "duration_ms": _elapsed_ms(attempt_started_at, time.monotonic()),
  141. "http_status_code": getattr(response, "status_code", None),
  142. "status": "ok",
  143. }
  144. )
  145. timing_metrics["qwen_request"] = {
  146. "total_duration_ms": _elapsed_ms(started_at, time.monotonic()),
  147. "attempts": request_attempts,
  148. "video_source": video_source,
  149. }
  150. return _with_timing(parsed, timing_metrics)
  151. except httpx.HTTPError as exc:
  152. failure_type = "qwen_client_timeout" if isinstance(exc, httpx.TimeoutException) else "qwen_http_error"
  153. response_summary = response_summary or _response_body_summary(getattr(exc, "response", None))
  154. if _is_content_inspection_failure(failure_type, response_summary):
  155. failure_type = "content_inspection_blocked"
  156. request_attempts.append(
  157. {
  158. "attempt": attempt_number,
  159. "video_source": video_source,
  160. "duration_ms": _elapsed_ms(attempt_started_at, time.monotonic()),
  161. "http_status_code": _http_status(exc),
  162. "status": "failed",
  163. "failure_type": failure_type,
  164. "exception_type": type(exc).__name__,
  165. **({"response_body_summary": response_summary} if response_summary else {}),
  166. }
  167. )
  168. retryable = failure_type != "content_inspection_blocked" and _retryable_http(exc)
  169. failure = _fail(
  170. failure_type,
  171. query_text=query_text,
  172. exception_type=type(exc).__name__,
  173. error_message=str(exc),
  174. http_status_code=_http_status(exc),
  175. retry_count=attempt_number,
  176. response_body_summary=response_summary,
  177. )
  178. return _with_timing(
  179. {
  180. **failure,
  181. "qwen_retryable": retryable,
  182. "qwen_retry_after_seconds": _retry_after_seconds(response_summary),
  183. },
  184. {
  185. "qwen_request": {
  186. "total_duration_ms": _elapsed_ms(started_at, time.monotonic()),
  187. "attempts": request_attempts,
  188. "video_source": video_source,
  189. }
  190. },
  191. )
  192. except (KeyError, IndexError, TypeError, ValueError, json.JSONDecodeError) as exc:
  193. request_attempts.append(
  194. {
  195. "attempt": attempt_number,
  196. "video_source": video_source,
  197. "duration_ms": _elapsed_ms(attempt_started_at, time.monotonic()),
  198. "status": "failed",
  199. "failure_type": "qwen_response_invalid",
  200. "exception_type": type(exc).__name__,
  201. **({"response_body_summary": response_summary} if response_summary else {}),
  202. }
  203. )
  204. failure = _fail(
  205. "qwen_response_invalid",
  206. query_text=query_text,
  207. exception_type=type(exc).__name__,
  208. error_message=str(exc),
  209. retry_count=attempt_number,
  210. response_body_summary=response_summary,
  211. )
  212. return _with_timing(
  213. {**failure, "qwen_retryable": True},
  214. {
  215. "qwen_request": {
  216. "total_duration_ms": _elapsed_ms(started_at, time.monotonic()),
  217. "attempts": request_attempts,
  218. "video_source": video_source,
  219. }
  220. },
  221. )
  222. def _no_fetchable_result(
  223. self,
  224. content: dict[str, Any],
  225. media: dict[str, Any],
  226. source_context: dict[str, Any],
  227. ) -> dict[str, Any] | None:
  228. query_text = _query_text(content, source_context)
  229. fetchable = [(u, s) for (u, s) in _video_url_candidates(media) if s != "play_url"]
  230. if fetchable:
  231. return None
  232. media_raw = media.get("raw_payload") if isinstance(media.get("raw_payload"), dict) else {}
  233. reason = media.get("failure_reason") or media_raw.get("failure_reason") or "no_oss_url"
  234. return _with_media_update(
  235. _with_timing(
  236. _fail(str(reason), query_text=query_text),
  237. {"video_fetch": {"skipped": str(reason)}},
  238. ),
  239. _media_unavailable_update(str(reason)),
  240. )
  241. def _qwen_attempts(result: dict[str, Any]) -> list[dict[str, Any]]:
  242. timing = result.get("timing_metrics") if isinstance(result.get("timing_metrics"), dict) else {}
  243. request = timing.get("qwen_request") if isinstance(timing.get("qwen_request"), dict) else {}
  244. attempts = request.get("attempts")
  245. return list(attempts) if isinstance(attempts, list) else []
  246. def _is_content_inspection_failure(failure_type: str, response_summary: dict[str, Any] | None) -> bool:
  247. if failure_type != "qwen_http_error":
  248. return False
  249. excerpt = str((response_summary or {}).get("text_excerpt", "")).lower()
  250. return "datainspection" in excerpt or "inappropriate content" in excerpt
  251. def _retry_after_seconds(response_summary: dict[str, Any] | None) -> float | None:
  252. raw = (response_summary or {}).get("retry_after")
  253. if raw is None:
  254. return None
  255. try:
  256. return max(0.0, float(raw))
  257. except (TypeError, ValueError):
  258. return None
  259. def _max_attempts_for_failure(result: dict[str, Any]) -> int:
  260. if result.get("failure_type") == "qwen_http_error" and result.get("http_status_code") == 429:
  261. return 3
  262. return DEFAULT_QWEN_MAX_ATTEMPTS