|
|
@@ -0,0 +1,191 @@
|
|
|
+"""通义千问(DashScope)视频理解 client (V4-M3 同契约).
|
|
|
+
|
|
|
+与 GeminiVideoClient 同一 analyze 契约,但两点不同:
|
|
|
+1. 由模型侧 fetch 视频 URL(优先 oss_url),不在本地下载/压缩/base64;
|
|
|
+2. 走 DashScope OpenAI 兼容端点(默认中国节点 dashscope.aliyuncs.com)。
|
|
|
+失败一律返回 V4 failed 结构,不抛、不卡 run。复用 gemini_video 的解析/失败/打分机器。
|
|
|
+"""
|
|
|
+
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+import json
|
|
|
+import os
|
|
|
+import time
|
|
|
+from typing import Any, Callable, Mapping
|
|
|
+
|
|
|
+import httpx
|
|
|
+
|
|
|
+from content_agent.integrations.gemini_video import (
|
|
|
+ DEFAULT_VIDEO_TIMEOUT_SECONDS,
|
|
|
+ MissingGeminiVideoClient,
|
|
|
+ _SYSTEM_PROMPT,
|
|
|
+ _USER_PROMPT,
|
|
|
+ _elapsed_ms,
|
|
|
+ _fail,
|
|
|
+ _http_status,
|
|
|
+ _media_unavailable_update,
|
|
|
+ _parse,
|
|
|
+ _query_text,
|
|
|
+ _response_body_summary,
|
|
|
+ _retryable_http,
|
|
|
+ _video_url_candidates,
|
|
|
+ _with_media_update,
|
|
|
+ _with_timing,
|
|
|
+)
|
|
|
+
|
|
|
+DEFAULT_DASHSCOPE_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
|
|
+DEFAULT_QWEN_VIDEO_MODEL = "qwen3-vl-plus"
|
|
|
+
|
|
|
+
|
|
|
+class QwenVideoClient:
|
|
|
+ def __init__(
|
|
|
+ self,
|
|
|
+ *,
|
|
|
+ api_key: str,
|
|
|
+ model: str = DEFAULT_QWEN_VIDEO_MODEL,
|
|
|
+ base_url: str = DEFAULT_DASHSCOPE_BASE_URL,
|
|
|
+ timeout_seconds: float = DEFAULT_VIDEO_TIMEOUT_SECONDS,
|
|
|
+ http_post: Callable[..., Any] = httpx.post,
|
|
|
+ ) -> None:
|
|
|
+ self.api_key = api_key
|
|
|
+ self.model = model
|
|
|
+ self.base_url = base_url.rstrip("/")
|
|
|
+ self.timeout_seconds = timeout_seconds
|
|
|
+ self.http_post = http_post
|
|
|
+
|
|
|
+ @classmethod
|
|
|
+ def from_env(cls, env: Mapping[str, str] | None = None) -> Any:
|
|
|
+ source = os.environ if env is None else env
|
|
|
+ api_key = (
|
|
|
+ source.get("CONTENT_AGENT_VIDEO_LLM_API_KEY")
|
|
|
+ or source.get("DASHSCOPE_API_KEY")
|
|
|
+ or source.get("QWEN_API_KEY")
|
|
|
+ )
|
|
|
+ if not api_key:
|
|
|
+ return MissingGeminiVideoClient("qwen video config missing: CONTENT_AGENT_VIDEO_LLM_API_KEY")
|
|
|
+ return cls(
|
|
|
+ api_key=api_key,
|
|
|
+ model=source.get("CONTENT_AGENT_VIDEO_LLM_MODEL") or DEFAULT_QWEN_VIDEO_MODEL,
|
|
|
+ base_url=source.get("CONTENT_AGENT_VIDEO_LLM_BASE_URL") or DEFAULT_DASHSCOPE_BASE_URL,
|
|
|
+ timeout_seconds=float(source.get("CONTENT_AGENT_VIDEO_LLM_TIMEOUT_SECONDS") or DEFAULT_VIDEO_TIMEOUT_SECONDS),
|
|
|
+ )
|
|
|
+
|
|
|
+ def analyze(
|
|
|
+ self,
|
|
|
+ content: dict[str, Any],
|
|
|
+ media: dict[str, Any],
|
|
|
+ source_context: dict[str, Any],
|
|
|
+ ) -> dict[str, Any]:
|
|
|
+ query_text = _query_text(content, source_context)
|
|
|
+ # 千问由模型侧 fetch URL,只用可公网访问的 oss/cdn;play_url(抖音签名/过期)模型取不到,排除。
|
|
|
+ fetchable = [(u, s) for (u, s) in _video_url_candidates(media) if s != "play_url"]
|
|
|
+ if not fetchable:
|
|
|
+ media_raw = media.get("raw_payload") if isinstance(media.get("raw_payload"), dict) else {}
|
|
|
+ reason = media.get("failure_reason") or media_raw.get("failure_reason") or "no_oss_url"
|
|
|
+ return _with_media_update(
|
|
|
+ _with_timing(
|
|
|
+ _fail(str(reason), query_text=query_text),
|
|
|
+ {"video_fetch": {"skipped": str(reason)}},
|
|
|
+ ),
|
|
|
+ _media_unavailable_update(str(reason)),
|
|
|
+ )
|
|
|
+
|
|
|
+ timing_metrics: dict[str, Any] = {}
|
|
|
+ request_attempts: list[dict[str, Any]] = []
|
|
|
+ last_failure: dict[str, Any] | None = None
|
|
|
+ started_at = time.monotonic()
|
|
|
+ for video_url, video_source in fetchable:
|
|
|
+ messages = [
|
|
|
+ {"role": "system", "content": _SYSTEM_PROMPT},
|
|
|
+ {
|
|
|
+ "role": "user",
|
|
|
+ "content": [
|
|
|
+ {"type": "video_url", "video_url": {"url": video_url}},
|
|
|
+ {"type": "text", "text": _USER_PROMPT.format(query_text=query_text)},
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ ]
|
|
|
+ for attempt in range(2):
|
|
|
+ retry_count = attempt + 1
|
|
|
+ attempt_started_at = time.monotonic()
|
|
|
+ response_summary: dict[str, Any] | None = None
|
|
|
+ try:
|
|
|
+ response = self.http_post(
|
|
|
+ f"{self.base_url}/chat/completions",
|
|
|
+ headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"},
|
|
|
+ json={"model": self.model, "messages": messages},
|
|
|
+ timeout=self.timeout_seconds,
|
|
|
+ )
|
|
|
+ response_summary = _response_body_summary(response)
|
|
|
+ response.raise_for_status()
|
|
|
+ payload = response.json()
|
|
|
+ request_attempts.append(
|
|
|
+ {
|
|
|
+ "attempt": retry_count,
|
|
|
+ "video_source": video_source,
|
|
|
+ "duration_ms": _elapsed_ms(attempt_started_at, time.monotonic()),
|
|
|
+ "http_status_code": getattr(response, "status_code", None),
|
|
|
+ "status": "ok",
|
|
|
+ }
|
|
|
+ )
|
|
|
+ timing_metrics["qwen_request"] = {
|
|
|
+ "total_duration_ms": _elapsed_ms(started_at, time.monotonic()),
|
|
|
+ "attempts": request_attempts,
|
|
|
+ "video_source": video_source,
|
|
|
+ }
|
|
|
+ return _with_timing(_parse(payload, query_text), timing_metrics)
|
|
|
+ except httpx.HTTPError as exc:
|
|
|
+ failure_type = "qwen_client_timeout" if isinstance(exc, httpx.TimeoutException) else "qwen_http_error"
|
|
|
+ response_summary = response_summary or _response_body_summary(getattr(exc, "response", None))
|
|
|
+ request_attempts.append(
|
|
|
+ {
|
|
|
+ "attempt": retry_count,
|
|
|
+ "video_source": video_source,
|
|
|
+ "duration_ms": _elapsed_ms(attempt_started_at, time.monotonic()),
|
|
|
+ "http_status_code": _http_status(exc),
|
|
|
+ "status": "failed",
|
|
|
+ "failure_type": failure_type,
|
|
|
+ "exception_type": type(exc).__name__,
|
|
|
+ **({"response_body_summary": response_summary} if response_summary else {}),
|
|
|
+ }
|
|
|
+ )
|
|
|
+ last_failure = _fail(
|
|
|
+ failure_type,
|
|
|
+ query_text=query_text,
|
|
|
+ exception_type=type(exc).__name__,
|
|
|
+ error_message=str(exc),
|
|
|
+ http_status_code=_http_status(exc),
|
|
|
+ retry_count=retry_count,
|
|
|
+ response_body_summary=response_summary,
|
|
|
+ )
|
|
|
+ if attempt == 0 and _retryable_http(exc):
|
|
|
+ continue
|
|
|
+ break # 不可重试 → 换下一个候选 URL
|
|
|
+ except (KeyError, IndexError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
|
|
+ request_attempts.append(
|
|
|
+ {
|
|
|
+ "attempt": retry_count,
|
|
|
+ "video_source": video_source,
|
|
|
+ "duration_ms": _elapsed_ms(attempt_started_at, time.monotonic()),
|
|
|
+ "status": "failed",
|
|
|
+ "failure_type": "qwen_response_invalid",
|
|
|
+ "exception_type": type(exc).__name__,
|
|
|
+ **({"response_body_summary": response_summary} if response_summary else {}),
|
|
|
+ }
|
|
|
+ )
|
|
|
+ last_failure = _fail(
|
|
|
+ "qwen_response_invalid",
|
|
|
+ query_text=query_text,
|
|
|
+ exception_type=type(exc).__name__,
|
|
|
+ error_message=str(exc),
|
|
|
+ retry_count=retry_count,
|
|
|
+ response_body_summary=response_summary,
|
|
|
+ )
|
|
|
+ if attempt == 0:
|
|
|
+ continue
|
|
|
+ break
|
|
|
+ timing_metrics["qwen_request"] = {
|
|
|
+ "total_duration_ms": _elapsed_ms(started_at, time.monotonic()),
|
|
|
+ "attempts": request_attempts,
|
|
|
+ }
|
|
|
+ return _with_timing(last_failure or _fail("qwen_unknown_error", query_text=query_text), timing_metrics)
|