test_creation_search.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314
  1. from __future__ import annotations
  2. import httpx
  3. import acquisition.classify as classify_module
  4. from acquisition import store
  5. from acquisition.classify import _providers, classify_video
  6. from acquisition.creation_search import Candidate, PlatformRateLimiter, run_platform_query
  7. from acquisition.crawler import RateLimiter
  8. from core.config import PgConfig, Settings
  9. from scripts import run_creation_search as run_cli
  10. def _settings() -> Settings:
  11. return Settings(
  12. pg=PgConfig(host="h", port=5432, user="u", password="p", database="d"),
  13. crawler_base_url="http://crawler.test",
  14. crawler_key="",
  15. crawler_timeout=30,
  16. video_model="m",
  17. gemini_api_key="",
  18. openrouter_base_url="http://openrouter.test",
  19. openrouter_api_key="k",
  20. llm_model="m",
  21. max_cards=12,
  22. frames_dir="f",
  23. douyin_ratio="540p",
  24. data_dir="data",
  25. )
  26. def test_creation_store_summary_and_detail(tmp_path):
  27. c = store.connect(tmp_path / "app.db")
  28. store.create_creation_run(c, "r1", total_queries=1, ts=1)
  29. store.ensure_creation_job(c, "r1", "q", "douyin", ts=1)
  30. item_id = store.upsert_creation_item(
  31. c,
  32. run_id="r1",
  33. query="q",
  34. platform="douyin",
  35. rank=1,
  36. source_id="dy1",
  37. url="https://www.douyin.com/video/1",
  38. title="标题",
  39. cover_url="https://cover.test/a.jpg",
  40. video_url="https://cdn.test/v.mp4",
  41. is_displayable=True,
  42. ts=2,
  43. )
  44. store.upsert_creation_classification(
  45. c, item_id, 1, reason="教你做视频", knowledge="先选题再写脚本", prompt_version="abc", ts=3
  46. )
  47. xhs_creation = store.upsert_creation_item(
  48. c, run_id="r1", query="q", platform="xiaohongshu", rank=1,
  49. title="小红书创作帖", image_urls=["/data/x1.jpg"], is_displayable=True, ts=3,
  50. )
  51. xhs_other = store.upsert_creation_item(
  52. c, run_id="r1", query="q", platform="xiaohongshu", rank=2,
  53. title="小红书非创作帖", image_urls=["/data/x2.jpg"], is_displayable=True, ts=3,
  54. )
  55. xhs_hidden = store.upsert_creation_item(
  56. c, run_id="r1", query="q", platform="xiaohongshu", rank=3,
  57. title="非展示候选", image_urls=["/data/x3.jpg"], is_displayable=False, ts=3,
  58. )
  59. wx_creation = store.upsert_creation_item(
  60. c, run_id="r1", query="q", platform="weixin", rank=1,
  61. title="公众号创作帖", image_urls=["/data/w1.jpg"], is_displayable=True, ts=3,
  62. )
  63. store.upsert_creation_classification(c, xhs_creation, 1, reason="ok", knowledge="k", ts=3)
  64. store.upsert_creation_classification(c, xhs_other, 0, reason="no", knowledge="", ts=3)
  65. store.upsert_creation_classification(c, xhs_hidden, 1, reason="hidden", knowledge="hidden", ts=3)
  66. store.upsert_creation_classification(c, wx_creation, 1, reason="ok", knowledge="k", ts=3)
  67. store.update_creation_job(
  68. c, "r1", "q", "douyin", status="done", attempts=1,
  69. searched_count=10, display_count=1, ts=4
  70. )
  71. summary = store.creation_search_summary(c)
  72. assert summary["run_id"] == "r1"
  73. q_summary = summary["queries"]["q"]
  74. assert q_summary["platforms"]["douyin"]["display_count"] == 1
  75. assert q_summary["imgtext_creation_count"] == 2
  76. assert q_summary["imgtext_classified_count"] == 3
  77. assert q_summary["imgtext_total_count"] == 3
  78. assert q_summary["imgtext_target_count"] == 10
  79. detail = store.get_creation_query_detail(c, "q")
  80. item = detail["platforms"]["douyin"]["items"][0]
  81. assert item["video_url"] == "https://cdn.test/v.mp4"
  82. assert item["classification"]["is_creation"] == 1
  83. assert item["classification"]["knowledge"] == "先选题再写脚本"
  84. def test_platform_rate_limiter_shares_keyword_and_detail_bucket():
  85. now = {"t": 0.0}
  86. sleeps = []
  87. def now_fn():
  88. return now["t"]
  89. def sleep_fn(seconds):
  90. sleeps.append(seconds)
  91. now["t"] += seconds
  92. base = RateLimiter(
  93. min_interval_seconds=10.0,
  94. max_interval_seconds=10.0,
  95. now_fn=now_fn,
  96. sleep_fn=sleep_fn,
  97. )
  98. gate = PlatformRateLimiter("douyin", delegate=base)
  99. gate.wait("douyin_keyword")
  100. now["t"] += 1.0
  101. gate.wait("douyin_detail")
  102. assert sleeps == [9.0]
  103. def test_classify_video_accepts_cdn_url(monkeypatch):
  104. seen = {}
  105. def fake_judge(messages, settings, timeout):
  106. seen["url"] = messages[1]["content"][1]["video_url"]["url"]
  107. return 1, "ok", "knowledge", ""
  108. monkeypatch.setattr("acquisition.classify._judge", fake_judge)
  109. res = classify_video({"video": "https://cdn.test/video.mp4"}, _settings())
  110. assert res == (1, "ok", "knowledge", "")
  111. assert seen["url"] == "https://cdn.test/video.mp4"
  112. def test_qwen_provider_uses_dashscope_credentials(monkeypatch):
  113. monkeypatch.setenv("CLASSIFY_PROVIDER", "qwen")
  114. monkeypatch.setenv("CLASSIFY_MODEL", "qwen3.7-plus")
  115. monkeypatch.setenv("QWEN_API_KEY", "sk-test")
  116. monkeypatch.setenv("QWEN_BASE_URL", "https://dashscope.test/compatible-mode/v1")
  117. monkeypatch.setenv("CONTENT_AGENT_VIDEO_LLM_MODEL", "google/gemini-3-flash-preview")
  118. providers = _providers(_settings(), [{"role": "user", "content": "ping"}])
  119. assert [p[0] for p in providers] == ["qwen:qwen3.7-plus", "qwen:qwen-vl-plus"]
  120. assert providers[0][1] == "https://dashscope.test/compatible-mode/v1/chat/completions"
  121. assert providers[0][3]["model"] == "qwen3.7-plus"
  122. def test_provider_limiter_and_429_backoff_are_provider_scoped(monkeypatch):
  123. assert classify_module._provider_min_interval("qwen", {"CLASSIFY_QWEN_MIN_INTERVAL_SECONDS": "0.25"}) == 0.25
  124. assert classify_module._provider_min_interval("ark", {"CLASSIFY_ARK_MIN_INTERVAL_SECONDS": "0.75"}) == 0.75
  125. now = {"t": 0.0}
  126. sleeps = []
  127. calls = {"n": 0}
  128. classify_module._PROVIDER_THROTTLES.clear()
  129. monkeypatch.setenv("CLASSIFY_PROVIDER", "qwen")
  130. monkeypatch.setenv("CLASSIFY_MODEL", "qwen3.7-plus")
  131. monkeypatch.setenv("QWEN_API_KEY", "sk-test")
  132. monkeypatch.setenv("QWEN_BASE_URL", "https://dashscope.test/compatible-mode/v1")
  133. monkeypatch.setenv("CLASSIFY_QWEN_MIN_INTERVAL_SECONDS", "0")
  134. monkeypatch.setenv("CLASSIFY_QWEN_429_BACKOFF_SECONDS", "9,30,90")
  135. monkeypatch.setattr(classify_module.time, "monotonic", lambda: now["t"])
  136. def fake_sleep(seconds):
  137. sleeps.append(seconds)
  138. now["t"] += seconds
  139. def fake_post(*args, **kwargs):
  140. calls["n"] += 1
  141. if calls["n"] == 1:
  142. return httpx.Response(429, request=httpx.Request("POST", "https://dashscope.test"))
  143. return httpx.Response(
  144. 200,
  145. json={"choices": [{"message": {"content": '{"is_empty": false, "reason": "ok", "knowledge": "k"}'}}]},
  146. request=httpx.Request("POST", "https://dashscope.test"),
  147. )
  148. monkeypatch.setattr(classify_module.time, "sleep", fake_sleep)
  149. monkeypatch.setattr(classify_module.httpx, "post", fake_post)
  150. result = classify_module._judge([{"role": "user", "content": "ping"}], _settings(), timeout=1)
  151. assert result == (1, "ok", "k", "")
  152. assert calls["n"] == 2
  153. assert sleeps == [2, 7.0]
  154. def test_run_platform_query_skips_bad_items_and_fills_display_limit(tmp_path, monkeypatch):
  155. c = store.connect(tmp_path / "app.db")
  156. store.create_creation_run(c, "r1", total_queries=1, ts=1)
  157. store.ensure_creation_job(c, "r1", "q", "weixin", ts=1)
  158. def fake_search(platform, query, *, settings, limit, rate_limiter):
  159. assert platform == "weixin"
  160. return [Candidate(rank=i, url=f"https://mp.test/{i}", title=f"t{i}") for i in range(1, 8)]
  161. def fake_process(platform, candidate, *, query_hash, settings, rate_limiter, downloader):
  162. if candidate.rank == 1:
  163. raise RuntimeError("detail failed")
  164. return {
  165. "source_id": str(candidate.rank),
  166. "url": candidate.url,
  167. "title": candidate.title,
  168. "body_text": "正文",
  169. "cover_url": "/data/a.jpg",
  170. "image_urls": ["/data/a.jpg"],
  171. "video_url": "",
  172. "raw": {},
  173. }
  174. monkeypatch.setattr("acquisition.creation_search.search_candidates", fake_search)
  175. monkeypatch.setattr("acquisition.creation_search.process_candidate", fake_process)
  176. monkeypatch.setattr("acquisition.creation_search._classify_and_store", lambda *a, **k: None)
  177. res = run_platform_query(
  178. c,
  179. run_id="r1",
  180. query="q",
  181. platform="weixin",
  182. settings=_settings(),
  183. search_limit=7,
  184. display_limit=5,
  185. classify=True,
  186. sleep_fn=lambda _: None,
  187. )
  188. assert res["status"] == "done"
  189. assert res["display_count"] == 5
  190. detail = store.get_creation_query_detail(c, "q")
  191. assert len(detail["platforms"]["weixin"]["items"]) == 5
  192. def test_creation_job_done_and_classification_queue(tmp_path):
  193. c = store.connect(tmp_path / "app.db")
  194. store.create_creation_run(c, "r1", total_queries=1, ts=1)
  195. store.ensure_creation_job(c, "r1", "q", "weixin", ts=1)
  196. assert not store.creation_job_is_done(c, "r1", "q", "weixin", display_limit=5)
  197. store.update_creation_job(c, "r1", "q", "weixin", status="done", display_count=5, ts=2)
  198. assert store.creation_job_is_done(c, "r1", "q", "weixin", display_limit=5)
  199. missing = store.upsert_creation_item(
  200. c, run_id="r1", query="q", platform="weixin", rank=1,
  201. title="missing", image_urls=["/data/a.jpg"], is_displayable=True, ts=3,
  202. )
  203. failed = store.upsert_creation_item(
  204. c, run_id="r1", query="q", platform="weixin", rank=2,
  205. title="failed", image_urls=["/data/b.jpg"], is_displayable=True, ts=3,
  206. )
  207. done = store.upsert_creation_item(
  208. c, run_id="r1", query="q", platform="weixin", rank=3,
  209. title="done", image_urls=["/data/c.jpg"], is_displayable=True, ts=3,
  210. )
  211. store.upsert_creation_classification(c, failed, None, reason="bad", error="bad", ts=4)
  212. store.upsert_creation_classification(c, done, 1, reason="ok", knowledge="k", ts=4)
  213. retry_rows = store.creation_items_to_classify(c, run_id="r1", platforms=["weixin"], retry_failed=True)
  214. missing_rows = store.creation_items_to_classify(c, run_id="r1", platforms=["weixin"], retry_failed=False)
  215. assert {r["id"] for r in retry_rows} == {missing, failed}
  216. assert {r["id"] for r in missing_rows} == {missing}
  217. def test_run_workers_isolates_worker_exception(tmp_path, monkeypatch):
  218. db = tmp_path / "app.db"
  219. c = store.connect(db)
  220. store.create_creation_run(c, "r1", total_queries=1, ts=1)
  221. for p in ["xiaohongshu", "weixin"]:
  222. store.ensure_creation_job(c, "r1", "q", p, ts=1)
  223. c.close()
  224. def fake_run(conn, *, platform, run_id, query, display_limit, **kwargs):
  225. if platform == "xiaohongshu":
  226. raise RuntimeError("boom")
  227. store.update_creation_job(
  228. conn, run_id, query, platform, status="done",
  229. searched_count=10, display_count=display_limit, ts=2,
  230. )
  231. return {"platform": platform, "status": "done", "display_count": display_limit, "error": ""}
  232. monkeypatch.setattr(run_cli, "run_platform_query", fake_run)
  233. failed = run_cli.run_platform_workers(
  234. run_id="r1", queries=["q"], platforms=["xiaohongshu", "weixin"],
  235. settings=_settings(), search_limit=10, display_limit=5,
  236. classify=False, skip_done=False, db_path=db,
  237. )
  238. c = store.connect(db)
  239. summary = store.creation_search_summary(c, run_id="r1")["queries"]["q"]["platforms"]
  240. assert failed == 1
  241. assert summary["xiaohongshu"]["status"] == "failed"
  242. assert "worker异常" in summary["xiaohongshu"]["error"]
  243. assert summary["weixin"]["status"] == "done"
  244. def test_run_workers_skip_done_does_not_rerun(tmp_path, monkeypatch):
  245. db = tmp_path / "app.db"
  246. c = store.connect(db)
  247. store.create_creation_run(c, "r1", total_queries=1, ts=1)
  248. store.ensure_creation_job(c, "r1", "q", "weixin", ts=1)
  249. store.update_creation_job(c, "r1", "q", "weixin", status="done", display_count=5, ts=2)
  250. c.close()
  251. calls = []
  252. def fake_run(*args, **kwargs):
  253. calls.append(kwargs)
  254. return {"platform": "weixin", "status": "done", "display_count": 5, "error": ""}
  255. monkeypatch.setattr(run_cli, "run_platform_query", fake_run)
  256. failed = run_cli.run_platform_workers(
  257. run_id="r1", queries=["q"], platforms=["weixin"],
  258. settings=_settings(), search_limit=10, display_limit=5,
  259. classify=False, skip_done=True, db_path=db,
  260. )
  261. assert failed == 0
  262. assert calls == []