test_gemini_video.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326
  1. """V4-M3: GeminiVideoClient.analyze relevance-only contract."""
  2. from __future__ import annotations
  3. import httpx
  4. from content_agent.integrations.gemini_video import (
  5. GeminiVideoClient,
  6. MissingGeminiVideoClient,
  7. )
  8. class FakeResponse:
  9. def __init__(self, content, *, status_code=200, json_payload=None, headers=None):
  10. self._content = content
  11. self._json_payload = json_payload
  12. self.status_code = status_code
  13. self.headers = headers or {"content-type": "application/json"}
  14. self.content = (
  15. content.encode("utf-8")
  16. if isinstance(content, str)
  17. else content or b""
  18. )
  19. self.request = httpx.Request("POST", "https://openrouter.test/chat/completions")
  20. def raise_for_status(self):
  21. if self.status_code >= 400:
  22. raise httpx.HTTPStatusError("bad", request=self.request, response=self)
  23. def json(self):
  24. if self._json_payload is not None:
  25. return self._json_payload
  26. return {"choices": [{"message": {"content": self._content}}]}
  27. def _client(content=None, *, post=None, fetch=None):
  28. return GeminiVideoClient(
  29. api_key="k",
  30. fetch_fn=fetch or (lambda play_url, platform: "data:video/mp4;base64,AAAA"),
  31. http_post=post or (lambda *a, **k: FakeResponse(content)),
  32. )
  33. _ITEM = {
  34. "platform": "douyin",
  35. "platform_content_id": "c1",
  36. "matched_search_queries": ["中医养生"],
  37. }
  38. _MEDIA = {"play_url": "http://v/x"}
  39. _CTX = {"ext_data": {"evidence_pack": {"seed_terms": ["中医养生"]}}}
  40. def test_analyze_returns_v4_relevance_fields():
  41. body = '{"query_relevance_score": 83, "query_relevance_reason": "贴切"}'
  42. result = _client(body).analyze(_ITEM, _MEDIA, _CTX)
  43. assert result["schema_version"] == "v4_gemini_query_relevance.v1"
  44. assert result["query_text"] == "中医养生"
  45. assert result["query_relevance_score"] == 83.0
  46. assert result["query_relevance_reason"] == "贴切"
  47. assert result["final_status"] == "ok"
  48. assert result["retry_count"] == 0
  49. assert result["timing_metrics"]["video_fetch"]["total_duration_ms"] >= 0
  50. assert result["timing_metrics"]["gemini_request"]["attempts"][0]["status"] == "ok"
  51. def test_analyze_prompt_does_not_contain_legacy_50_plus_fields():
  52. seen = {}
  53. def post(*args, **kwargs):
  54. seen.update(kwargs)
  55. return FakeResponse('{"query_relevance_score": 70, "query_relevance_reason": "x"}')
  56. _client(post=post).analyze(_ITEM, _MEDIA, _CTX)
  57. prompt = seen["json"]["messages"][1]["content"][0]["text"]
  58. assert "fit_senior_50plus" not in prompt
  59. assert "fit_confidence" not in prompt
  60. assert '"relevance_score"' not in prompt
  61. assert "query_relevance_score" in prompt
  62. def test_analyze_parses_json_in_markdown_fence_and_clamps_score():
  63. body = '```json\n{"query_relevance_score": 180, "query_relevance_reason": "x"}\n```'
  64. result = _client(body).analyze(_ITEM, _MEDIA, _CTX)
  65. assert result["query_relevance_score"] == 100.0
  66. def test_analyze_does_not_upload_video_to_oss_by_default():
  67. body = '{"query_relevance_score": 80, "query_relevance_reason": "x"}'
  68. seen = {}
  69. def fetch(play_url, platform):
  70. seen["fetch"] = {"play_url": play_url, "platform": platform}
  71. return "data:video/mp4;base64,AAAA"
  72. client = GeminiVideoClient(
  73. api_key="k",
  74. fetch_fn=fetch,
  75. http_post=lambda *a, **k: FakeResponse(body),
  76. )
  77. item = {**_ITEM, "run_id": "run_1"}
  78. result = client.analyze(item, _MEDIA, _CTX)
  79. assert seen["fetch"] == {"play_url": "http://v/x", "platform": "douyin"}
  80. assert result["query_relevance_score"] == 80.0
  81. assert "media_storage_update" not in result
  82. assert result["timing_metrics"]["video_fetch"]["gemini_video_source"] == "play_url"
  83. def test_analyze_prefers_oss_url_for_video_fetch():
  84. body = '{"query_relevance_score": 80, "query_relevance_reason": "x"}'
  85. seen = {}
  86. def fetch(play_url, platform):
  87. seen["fetch"] = {"play_url": play_url, "platform": platform}
  88. return "data:video/mp4;base64,AAAA"
  89. media = {**_MEDIA, "oss_url": "https://res.example/video.mp4"}
  90. result = _client(body, fetch=fetch).analyze(_ITEM, media, _CTX)
  91. assert seen["fetch"] == {"play_url": "https://res.example/video.mp4", "platform": "douyin"}
  92. assert result["query_relevance_score"] == 80.0
  93. assert result["timing_metrics"]["video_fetch"]["gemini_video_source"] == "oss_url"
  94. def test_analyze_falls_back_to_play_url_when_oss_url_fetch_fails():
  95. body = '{"query_relevance_score": 80, "query_relevance_reason": "x"}'
  96. calls = []
  97. def fetch(play_url, platform):
  98. calls.append(play_url)
  99. if play_url == "https://res.example/video.mp4":
  100. raise RuntimeError("cdn failed")
  101. return "data:video/mp4;base64,AAAA"
  102. media = {**_MEDIA, "oss_url": "https://res.example/video.mp4"}
  103. result = _client(body, fetch=fetch).analyze(_ITEM, media, _CTX)
  104. assert calls == ["https://res.example/video.mp4", "http://v/x"]
  105. assert result["query_relevance_score"] == 80.0
  106. assert result["timing_metrics"]["video_fetch"]["gemini_video_source"] == "play_url"
  107. assert result["timing_metrics"]["video_fetch"]["attempts"][0]["gemini_video_source"] == "oss_url"
  108. assert result["timing_metrics"]["video_fetch"]["attempts"][0]["error_message"] == "cdn failed"
  109. def test_analyze_video_fetch_failure_after_oss_and_play_url_returns_v4_fail():
  110. def fetch(play_url, platform):
  111. raise RuntimeError(f"dl {play_url}")
  112. media = {**_MEDIA, "oss_url": "https://res.example/video.mp4"}
  113. result = _client("{}", fetch=fetch).analyze(_ITEM, media, _CTX)
  114. assert result["final_status"] == "failed"
  115. assert result["failure_type"] == "video_fetch_failed"
  116. assert result["exception_type"] == "RuntimeError"
  117. assert result["error_message"] == "dl http://v/x"
  118. assert result["timing_metrics"]["video_fetch"]["gemini_video_source"] == "play_url"
  119. assert [item["gemini_video_source"] for item in result["timing_metrics"]["video_fetch"]["attempts"]] == [
  120. "oss_url",
  121. "play_url",
  122. ]
  123. def test_analyze_timeout_returns_client_timeout_failure():
  124. body = '{"query_relevance_score": 80, "query_relevance_reason": "x"}'
  125. calls = []
  126. def post(*a, **k):
  127. calls.append(1)
  128. raise httpx.ReadTimeout("slow")
  129. client = GeminiVideoClient(
  130. api_key="k",
  131. fetch_fn=lambda play_url, platform: "data:video/mp4;base64,AAAA",
  132. http_post=post,
  133. )
  134. result = client.analyze(_ITEM, _MEDIA, _CTX)
  135. assert len(calls) == 2
  136. assert result["failure_type"] == "gemini_client_timeout"
  137. assert result["exception_type"] == "ReadTimeout"
  138. assert result["error_message"] == "slow"
  139. assert result["retry_count"] == 2
  140. def test_analyze_no_play_url_returns_v4_fail():
  141. result = _client("{}").analyze(_ITEM, {}, _CTX)
  142. assert result["final_status"] == "failed"
  143. assert result["failure_type"] == "no_play_url"
  144. assert result["retry_count"] == 1
  145. def test_analyze_no_valid_play_url_preserves_media_failure_reason():
  146. result = _client("{}").analyze(
  147. _ITEM,
  148. {
  149. "failure_reason": "no_valid_play_url",
  150. "raw_payload": {"no_valid_play_url": True},
  151. },
  152. _CTX,
  153. )
  154. assert result["final_status"] == "failed"
  155. assert result["failure_type"] == "no_valid_play_url"
  156. assert result["media_storage_update"]["failure_reason"] == "no_valid_play_url"
  157. def test_analyze_video_fetch_failure_returns_v4_fail():
  158. def boom(play_url, platform):
  159. raise RuntimeError("dl")
  160. result = _client("{}", fetch=boom).analyze(_ITEM, _MEDIA, _CTX)
  161. assert result["final_status"] == "failed"
  162. assert result["failure_type"] == "video_fetch_failed"
  163. assert result["exception_type"] == "RuntimeError"
  164. assert result["error_message"] == "dl"
  165. def test_analyze_retryable_http_error_retries_once_then_fails():
  166. calls = []
  167. def post(*a, **k):
  168. calls.append(1)
  169. return FakeResponse("{}", status_code=500)
  170. result = _client(post=post).analyze(_ITEM, _MEDIA, _CTX)
  171. assert len(calls) == 2
  172. assert result["failure_type"] == "gemini_http_error"
  173. assert result["http_status_code"] == 500
  174. assert result["error_message"] == "bad"
  175. assert result["retry_count"] == 2
  176. def test_analyze_http_error_keeps_response_body_summary():
  177. calls = []
  178. def post(*a, **k):
  179. calls.append(1)
  180. return FakeResponse(
  181. '{"error":{"message":"upstream overloaded"}}',
  182. status_code=502,
  183. json_payload={"error": {"message": "upstream overloaded"}, "provider": "x"},
  184. )
  185. result = _client(post=post).analyze(_ITEM, _MEDIA, _CTX)
  186. assert len(calls) == 2
  187. assert result["failure_type"] == "gemini_http_error"
  188. assert result["response_body_summary"]["http_status_code"] == 502
  189. assert result["response_body_summary"]["json_top_level_keys"] == ["error", "provider"]
  190. assert result["response_body_summary"]["text_excerpt"] == "upstream overloaded"
  191. attempts = result["timing_metrics"]["gemini_request"]["attempts"]
  192. assert attempts[-1]["response_body_summary"]["text_excerpt"] == "upstream overloaded"
  193. def test_analyze_response_body_summary_redacts_sensitive_text():
  194. def post(*a, **k):
  195. return FakeResponse(
  196. '{"error":{"message":"failed https://x.test/video.mp4?token=secret Bearer abc.def sk-testsecret1234567890"}}',
  197. status_code=502,
  198. json_payload={
  199. "error": {
  200. "message": "failed https://x.test/video.mp4?token=secret Bearer abc.def sk-testsecret1234567890"
  201. }
  202. },
  203. )
  204. result = _client(post=post).analyze(_ITEM, _MEDIA, _CTX)
  205. excerpt = result["response_body_summary"]["text_excerpt"]
  206. assert "token=secret" not in excerpt
  207. assert "abc.def" not in excerpt
  208. assert "sk-testsecret" not in excerpt
  209. assert "https://x.test/video.mp4?[REDACTED]" in excerpt
  210. assert "Bearer [REDACTED]" in excerpt
  211. assert "sk-[REDACTED]" in excerpt
  212. def test_analyze_bad_json_retries_once_then_fails():
  213. calls = []
  214. def post(*a, **k):
  215. calls.append(1)
  216. return FakeResponse("not-json")
  217. result = _client(post=post).analyze(_ITEM, _MEDIA, _CTX)
  218. assert len(calls) == 2
  219. assert result["failure_type"] == "gemini_response_invalid"
  220. assert result["retry_count"] == 2
  221. def test_analyze_missing_choices_keeps_response_body_summary():
  222. calls = []
  223. def post(*a, **k):
  224. calls.append(1)
  225. return FakeResponse(
  226. '{"error":{"message":"missing choices body"}}',
  227. json_payload={"error": {"message": "missing choices body"}, "id": "r1"},
  228. )
  229. result = _client(post=post).analyze(_ITEM, _MEDIA, _CTX)
  230. assert len(calls) == 2
  231. assert result["failure_type"] == "gemini_response_invalid"
  232. assert result["exception_type"] == "KeyError"
  233. assert result["response_body_summary"]["json_top_level_keys"] == ["error", "id"]
  234. assert result["response_body_summary"]["text_excerpt"] == "missing choices body"
  235. def test_from_env_missing_key_returns_missing_client():
  236. client = GeminiVideoClient.from_env({})
  237. assert isinstance(client, MissingGeminiVideoClient)
  238. result = client.analyze(_ITEM, _MEDIA, _CTX)
  239. assert result["final_status"] == "failed"
  240. assert result["failure_type"] == "gemini_config_missing"