test_creation_search.py 12 KB

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