test_discover_videos_from_demands.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852
  1. from __future__ import annotations
  2. import asyncio
  3. import importlib
  4. import json
  5. from contextlib import contextmanager
  6. from datetime import datetime
  7. from decimal import Decimal
  8. from unittest.mock import patch
  9. import pytest
  10. from sqlalchemy import create_engine, event, select
  11. from sqlalchemy.orm import Session, sessionmaker
  12. from agents.find_agent import create_find_agent
  13. from agents.find_agent.async_runner import arun_find_agent
  14. from agents.find_agent.demand_run import (
  15. FindDemandContext,
  16. FindDemandPoint,
  17. FindDemandVideo,
  18. build_find_agent_user_input,
  19. prepare_video_discovery_run,
  20. )
  21. from agents.find_agent.tools import video_discovery_store
  22. from agents.find_agent.tools.batch_search_and_record import batch_search_and_record
  23. from agents.find_agent.support.search_persistence import _candidate_from_search_result
  24. from agents.find_agent.support.douyin_search import (
  25. DEFAULT_MIN_DURATION_SECONDS as INTERNAL_SEARCH_MIN_DURATION,
  26. )
  27. from agents.find_agent.support.douyin_search_tikhub import (
  28. DEFAULT_MIN_DURATION_SECONDS as TIKHUB_SEARCH_MIN_DURATION,
  29. )
  30. from agents.find_agent.support.douyin_user_videos import (
  31. DEFAULT_MIN_DURATION_SECONDS as AUTHOR_SEARCH_MIN_DURATION,
  32. )
  33. from supply_infra.video_discovery_gates import evaluate_candidate_gate
  34. from supply_infra.db.models.video_discovery import (
  35. VideoDiscoveryCandidate,
  36. VideoDiscoveryRun,
  37. VideoDiscoverySearch,
  38. )
  39. from supply_infra.db.repositories.video_discovery_repo import (
  40. VideoDiscoveryRepository,
  41. )
  42. from supply_infra.scheduler.jobs.discover_videos_from_demands import (
  43. discover_videos_from_demands,
  44. )
  45. def _expire_on_commit_session_factory() -> sessionmaker[Session]:
  46. """Match production get_session: commit expires ORM instances."""
  47. engine = create_engine("sqlite+pysqlite:///:memory:")
  48. # SQLite 对 BigInteger PK 不会自增,测试里显式写入 id。
  49. VideoDiscoveryRun.__table__.create(engine)
  50. VideoDiscoveryCandidate.__table__.create(engine)
  51. return sessionmaker(bind=engine, autoflush=False, autocommit=False)
  52. def _patch_service_session(
  53. monkeypatch: pytest.MonkeyPatch,
  54. factory: sessionmaker[Session],
  55. ) -> None:
  56. @contextmanager
  57. def get_test_session():
  58. session = factory()
  59. try:
  60. yield session
  61. session.commit()
  62. except Exception:
  63. session.rollback()
  64. raise
  65. finally:
  66. session.close()
  67. monkeypatch.setattr(
  68. "supply_infra.services.video_discovery_service.get_session",
  69. get_test_session,
  70. )
  71. import supply_infra.services.video_discovery_service as service_module
  72. service_module._default_service = None
  73. def _seed_run(
  74. factory: sessionmaker[Session],
  75. *,
  76. run_id: str,
  77. demand_grade_id: int,
  78. status: str = "running",
  79. row_id: int = 1,
  80. ) -> None:
  81. with factory() as session:
  82. session.add(
  83. VideoDiscoveryRun(
  84. id=row_id,
  85. run_id=run_id,
  86. biz_dt="20260728",
  87. demand_grade_id=demand_grade_id,
  88. demand_word="广场舞",
  89. seed_video_id="vid-1",
  90. seed_video_title="参考标题",
  91. relevant_points_json="[]",
  92. status=status,
  93. )
  94. )
  95. session.commit()
  96. def test_create_video_discovery_run_reuses_precreated_run(
  97. monkeypatch: pytest.MonkeyPatch,
  98. ) -> None:
  99. """定时任务预创建 run 后,Agent 复用 run_id 不得触发 DetachedInstanceError。"""
  100. factory = _expire_on_commit_session_factory()
  101. _patch_service_session(monkeypatch, factory)
  102. _seed_run(factory, run_id="precreated-run", demand_grade_id=101)
  103. payload = json.loads(
  104. video_discovery_store.create_video_discovery_run(
  105. demand_word="广场舞",
  106. seed_video_title="参考标题",
  107. relevant_points=[],
  108. run_id="precreated-run",
  109. demand_grade_id=101,
  110. )
  111. )
  112. assert "error" not in payload
  113. assert payload["run_id"] == "precreated-run"
  114. assert payload["status"] == "running"
  115. assert payload["pre_created"] is True
  116. def test_prepare_then_reuse_run_id_scheduled_flow(
  117. monkeypatch: pytest.MonkeyPatch,
  118. ) -> None:
  119. """完整调度衔接:prepare 得到 run_id → create 复用,全程无 DetachedInstanceError。"""
  120. factory = _expire_on_commit_session_factory()
  121. _patch_service_session(monkeypatch, factory)
  122. # 先写入一条 failed 记录,prepare 会原地重置为 running 并复用 run_id。
  123. _seed_run(
  124. factory,
  125. run_id="scheduled-run",
  126. demand_grade_id=202,
  127. status="failed",
  128. )
  129. ctx = FindDemandContext(
  130. biz_dt="20260728",
  131. demand_grade_id=202,
  132. demand_name="广场舞",
  133. grade="S",
  134. videos=[
  135. FindDemandVideo(
  136. video_id="vid-1",
  137. title="参考标题",
  138. points=[
  139. FindDemandPoint(
  140. point="动作简单",
  141. point_type="key",
  142. )
  143. ],
  144. )
  145. ],
  146. )
  147. run_id, skip_reason = prepare_video_discovery_run(ctx)
  148. assert skip_reason is None
  149. assert run_id == "scheduled-run"
  150. reuse_id, reuse_skip = prepare_video_discovery_run(ctx)
  151. assert reuse_id == "scheduled-run"
  152. assert reuse_skip is None
  153. payload = json.loads(
  154. video_discovery_store.create_video_discovery_run(
  155. demand_word=ctx.demand_name,
  156. seed_video_title="参考标题",
  157. relevant_points=[{"point": "动作简单", "point_type": "key"}],
  158. run_id=run_id,
  159. demand_grade_id=ctx.demand_grade_id,
  160. seed_video_id="vid-1",
  161. )
  162. )
  163. assert "error" not in payload
  164. assert payload["pre_created"] is True
  165. assert payload["run_id"] == run_id
  166. def test_prepare_skips_when_run_already_finished(
  167. monkeypatch: pytest.MonkeyPatch,
  168. ) -> None:
  169. factory = _expire_on_commit_session_factory()
  170. _patch_service_session(monkeypatch, factory)
  171. _seed_run(
  172. factory,
  173. run_id="finished-run",
  174. demand_grade_id=303,
  175. status="finished",
  176. )
  177. ctx = FindDemandContext(
  178. biz_dt="20260728",
  179. demand_grade_id=303,
  180. demand_name="广场舞",
  181. grade="S",
  182. videos=[
  183. FindDemandVideo(
  184. video_id="vid-1",
  185. title="参考标题",
  186. points=[FindDemandPoint(point="动作简单", point_type="key")],
  187. )
  188. ],
  189. )
  190. run_id, skip_reason = prepare_video_discovery_run(ctx)
  191. assert run_id is None
  192. assert skip_reason is not None
  193. assert "finished" in skip_reason
  194. def test_video_discovery_models_exclude_unused_columns() -> None:
  195. run_columns = set(VideoDiscoveryRun.__table__.columns.keys())
  196. candidate_columns = set(VideoDiscoveryCandidate.__table__.columns.keys())
  197. assert "backup_count" not in run_columns
  198. assert {
  199. "video_url",
  200. "content_analysis",
  201. "content_analysis_verified",
  202. "hit_points_json",
  203. "publish_timestamp",
  204. "detail_verified",
  205. "content_portrait_attempted",
  206. "account_portrait_attempted",
  207. "age_portraits_normalized",
  208. "expansion_worthy_tags_json",
  209. "confidence",
  210. "relevance_reason",
  211. "elder_reason",
  212. "share_reason",
  213. "manual_review_note",
  214. "manual_review_status",
  215. }.isdisjoint(candidate_columns)
  216. assert "search_id" in candidate_columns
  217. candidate_constraints = {
  218. constraint.name
  219. for constraint in VideoDiscoveryCandidate.__table__.constraints
  220. }
  221. search_constraints = {
  222. constraint.name
  223. for constraint in VideoDiscoverySearch.__table__.constraints
  224. }
  225. assert "uk_video_discovery_candidate_run_aweme" not in candidate_constraints
  226. assert "fk_video_discovery_candidate_search" in candidate_constraints
  227. assert "uk_video_discovery_search_key" not in search_constraints
  228. def test_create_find_agent_registers_discovery_tools() -> None:
  229. agent = create_find_agent()
  230. assert agent.name == "find_agent"
  231. assert "batch_search_and_record" in agent.tools.list_tools()
  232. assert "batch_update_video_discovery_candidates" in agent.tools.list_tools()
  233. assert "update_video_discovery_run_status" in agent.tools.list_tools()
  234. assert "create_video_discovery_run" not in agent.tools.list_tools()
  235. assert "batch_save_video_candidate_evaluations" not in agent.tools.list_tools()
  236. assert "audit_video_discovery_run" not in agent.tools.list_tools()
  237. assert "query_video_discovery_state" in agent.tools.list_tools()
  238. def test_find_agent_input_uses_reference_videos_without_seed_fields() -> None:
  239. ctx = FindDemandContext(
  240. biz_dt="20260729",
  241. demand_grade_id=101,
  242. demand_name="广场舞",
  243. grade="S",
  244. videos=[
  245. FindDemandVideo(
  246. video_id="vid-1",
  247. title="参考标题",
  248. points=[FindDemandPoint(point="动作简单", point_type="key")],
  249. )
  250. ],
  251. )
  252. user_input = build_find_agent_user_input(ctx, "scheduled-run")
  253. assert "seed_video_id:" not in user_input
  254. assert "seed_video_title:" not in user_input
  255. assert "reference_videos:" in user_input
  256. assert '"video_id": "vid-1"' in user_input
  257. assert '"title": "参考标题"' in user_input
  258. assert "create_video_discovery_run" not in user_input
  259. assert "relevant_points" not in user_input
  260. assert "current_datetime:" in user_input
  261. assert "timezone:Asia/Shanghai" in user_input
  262. assert "quality_gate_rules:" in user_input
  263. _P0_RULES = {
  264. "rule_version": "test-p0",
  265. "timezone": "Asia/Shanghai",
  266. "current_datetime": "2026-07-31T12:00:00+08:00",
  267. "current_date": "2026-07-31",
  268. "min_duration_seconds": 30,
  269. "min_share_count": 1000,
  270. "min_content_50_plus_ratio": 0.20,
  271. "min_account_50_plus_ratio": 0.20,
  272. "festival_lead_days": 7,
  273. "event_max_age_days": 7,
  274. "seasonal_max_age_days": 180,
  275. }
  276. def _p0_candidate(**overrides):
  277. candidate = {
  278. "title": "适合家庭分享的生活技巧",
  279. "publish_at": "2026-07-31T09:00:00+08:00",
  280. "duration_seconds": 30,
  281. "share_count": 1000,
  282. "content_50_plus_ratio": 0.20,
  283. "account_50_plus_ratio": 0.30,
  284. }
  285. candidate.update(overrides)
  286. return candidate
  287. def test_p0_gate_enforces_boundaries_and_time_context() -> None:
  288. assert evaluate_candidate_gate(_p0_candidate(), _P0_RULES)["primary_eligible"]
  289. short = evaluate_candidate_gate(
  290. _p0_candidate(duration_seconds=Decimal("29.999")),
  291. _P0_RULES,
  292. )
  293. assert "DURATION_TOO_SHORT" in short["failed_reason_codes"]
  294. low_share = evaluate_candidate_gate(_p0_candidate(share_count=999), _P0_RULES)
  295. assert "SHARE_COUNT_TOO_LOW" in low_share["failed_reason_codes"]
  296. low_elder = evaluate_candidate_gate(
  297. _p0_candidate(content_50_plus_ratio=0.199),
  298. _P0_RULES,
  299. )
  300. assert "CONTENT_50_PLUS_TOO_LOW" in low_elder["failed_reason_codes"]
  301. morning = evaluate_candidate_gate(
  302. _p0_candidate(title="早上好,送给家人的祝福"),
  303. _P0_RULES,
  304. )
  305. assert "DAYPART_EXPIRED" in morning["failed_reason_codes"]
  306. festival = evaluate_candidate_gate(
  307. _p0_candidate(title="春节祝福送给全家"),
  308. _P0_RULES,
  309. )
  310. assert "FESTIVAL_OUT_OF_WINDOW" in festival["failed_reason_codes"]
  311. def test_p0_gate_keeps_content_and_account_portraits_separate() -> None:
  312. conflict = evaluate_candidate_gate(
  313. _p0_candidate(
  314. content_50_plus_ratio=0.28,
  315. account_50_plus_ratio=0.08,
  316. ),
  317. _P0_RULES,
  318. )
  319. assert conflict["content_portrait_status"] == "pass"
  320. assert conflict["account_portrait_status"] == "fail"
  321. assert conflict["portrait_conflict"] is True
  322. assert conflict["primary_eligible"] is True
  323. account_only = evaluate_candidate_gate(
  324. _p0_candidate(
  325. content_50_plus_ratio=None,
  326. account_50_plus_ratio=0.55,
  327. ),
  328. _P0_RULES,
  329. )
  330. assert "CONTENT_PORTRAIT_MISSING" in account_only["failed_reason_codes"]
  331. def test_search_candidate_persists_publish_time_duration_and_shares() -> None:
  332. candidate = _candidate_from_search_result(
  333. {
  334. "aweme_id": "video-p0",
  335. "desc": "测试视频",
  336. "duration_ms": 65000,
  337. "publish_at": "2026-07-31T08:30:00+08:00",
  338. "statistics": {"share_count": 45},
  339. },
  340. "测试关键词",
  341. )
  342. assert candidate is not None
  343. assert candidate["duration_seconds"] == Decimal("65.000")
  344. assert candidate["publish_at"] == datetime(2026, 7, 31, 8, 30)
  345. assert candidate["share_count"] == 45
  346. def test_all_search_sources_default_to_thirty_seconds() -> None:
  347. assert INTERNAL_SEARCH_MIN_DURATION == 30
  348. assert TIKHUB_SEARCH_MIN_DURATION == 30
  349. assert AUTHOR_SEARCH_MIN_DURATION == 30
  350. def test_repository_rejects_primary_that_fails_p0_gate() -> None:
  351. factory = _expire_on_commit_session_factory()
  352. with factory() as session:
  353. session.add(
  354. VideoDiscoveryRun(
  355. id=1,
  356. run_id="p0-gate-run",
  357. demand_word="生活技巧",
  358. relevant_points_json="[]",
  359. status="running",
  360. rule_version="test-p0",
  361. rule_config_json=json.dumps(_P0_RULES, ensure_ascii=False),
  362. )
  363. )
  364. session.add(
  365. VideoDiscoveryCandidate(
  366. id=2,
  367. run_id="p0-gate-run",
  368. aweme_id="video-p0",
  369. title="适合家庭分享的生活技巧",
  370. publish_at=datetime(2026, 7, 31, 9, 0),
  371. duration_seconds=Decimal("30.000"),
  372. share_count=999,
  373. content_50_plus_ratio=Decimal("0.280000"),
  374. account_50_plus_ratio=Decimal("0.080000"),
  375. decision_bucket="pending_evaluation",
  376. )
  377. )
  378. session.commit()
  379. with factory() as session:
  380. repo = VideoDiscoveryRepository(session)
  381. with pytest.raises(ValueError, match="SHARE_COUNT_TOO_LOW"):
  382. repo.update_candidates(
  383. "p0-gate-run",
  384. [{"candidate_id": 2, "decision_bucket": "primary"}],
  385. )
  386. @pytest.mark.asyncio
  387. async def test_douyin_search_automatically_persists_page(
  388. monkeypatch: pytest.MonkeyPatch,
  389. ) -> None:
  390. search_module = importlib.import_module(
  391. "agents.find_agent.tools.douyin_search"
  392. )
  393. async def fake_raw_search(**_kwargs):
  394. return json.dumps(
  395. {
  396. "results_count": 1,
  397. "has_more": False,
  398. "search_results": [{"aweme_id": "auto-saved"}],
  399. }
  400. )
  401. persisted: dict[str, object] = {}
  402. def fake_persist(payload_json: str, **kwargs):
  403. persisted.update(kwargs)
  404. payload = json.loads(payload_json)
  405. payload.update(
  406. {
  407. "persisted": True,
  408. "search_id": 11,
  409. "new_candidate_count": 1,
  410. "candidates": [
  411. {
  412. "candidate_id": 21,
  413. "search_id": 11,
  414. "aweme_id": "auto-saved",
  415. "title": "自动保存",
  416. "decision_bucket": "pending_evaluation",
  417. }
  418. ],
  419. }
  420. )
  421. return json.dumps(payload)
  422. monkeypatch.setattr(search_module, "_douyin_search_raw", fake_raw_search)
  423. monkeypatch.setattr(search_module, "persist_search_payload", fake_persist)
  424. result = json.loads(
  425. await search_module.douyin_search(
  426. run_id="run-auto-save",
  427. keyword="广场舞",
  428. query_reason="验证需求根搜索",
  429. source_type="demand",
  430. )
  431. )
  432. assert result["persisted"] is True
  433. assert result["search_id"] == 11
  434. assert result["candidates"][0]["candidate_id"] == 21
  435. assert persisted["run_id"] == "run-auto-save"
  436. assert persisted["keyword"] == "广场舞"
  437. assert persisted["provider"] == "internal_keyword"
  438. @pytest.mark.asyncio
  439. async def test_batch_search_records_each_page_and_carries_parent(
  440. monkeypatch: pytest.MonkeyPatch,
  441. ) -> None:
  442. batch_module = importlib.import_module(
  443. "agents.find_agent.tools.batch_search_and_record"
  444. )
  445. calls: list[dict[str, object]] = []
  446. async def fake_search(**kwargs):
  447. calls.append(kwargs)
  448. page_no = int(kwargs["page_no"])
  449. return json.dumps(
  450. {
  451. "results_count": 1,
  452. "has_more": page_no == 1,
  453. "next_cursor": "next-page" if page_no == 1 else None,
  454. "persisted": True,
  455. "search_id": 100 + page_no,
  456. "new_candidate_count": 1,
  457. "candidates": [
  458. {
  459. "candidate_id": 200 + page_no,
  460. "search_id": 100 + page_no,
  461. "aweme_id": "same-video",
  462. "title": f"第 {page_no} 页",
  463. "decision_bucket": "pending_evaluation",
  464. }
  465. ],
  466. }
  467. )
  468. monkeypatch.setattr(batch_module, "douyin_search", fake_search)
  469. result = json.loads(
  470. await batch_search_and_record(
  471. run_id="run-batch",
  472. searches=[
  473. {
  474. "keyword": "广场舞",
  475. "query_reason": "验证需求根搜索",
  476. "source_type": "demand",
  477. "max_pages": 2,
  478. }
  479. ],
  480. )
  481. )
  482. assert result["saved_page_count"] == 2
  483. assert result["new_candidate_count"] == 2
  484. assert result["tasks"][0]["pages"][0]["candidates"][0]["candidate_id"] == 201
  485. assert result["tasks"][0]["pages"][1]["candidates"][0]["candidate_id"] == 202
  486. assert calls[0]["parent_search_id"] is None
  487. assert calls[1]["parent_search_id"] == 101
  488. assert calls[1]["cursor"] == "next-page"
  489. def test_batch_update_candidates_uses_database_candidate_id(
  490. monkeypatch: pytest.MonkeyPatch,
  491. ) -> None:
  492. captured: dict[str, object] = {}
  493. class FakeService:
  494. def update_candidates(self, run_id, rows):
  495. captured["run_id"] = run_id
  496. captured["rows"] = rows
  497. return {
  498. "updated_count": 1,
  499. "candidates": [
  500. {
  501. "candidate_id": 901,
  502. "search_id": 801,
  503. "aweme_id": "same-video",
  504. "decision_bucket": "primary",
  505. }
  506. ],
  507. }
  508. monkeypatch.setattr(
  509. video_discovery_store,
  510. "get_video_discovery_service",
  511. lambda: FakeService(),
  512. )
  513. result = json.loads(
  514. video_discovery_store.batch_update_video_discovery_candidates(
  515. run_id="run-update",
  516. items=[
  517. {
  518. "candidate_id": 901,
  519. "decision_bucket": "primary",
  520. "relevance_score": 0.8,
  521. "elder_score": 0.7,
  522. "share_score": 0.6,
  523. }
  524. ],
  525. )
  526. )
  527. assert result["updated_count"] == 1
  528. assert captured["run_id"] == "run-update"
  529. assert captured["rows"][0]["candidate_id"] == 901
  530. assert "aweme_id" not in captured["rows"][0]
  531. def test_each_search_inserts_new_candidate_occurrences() -> None:
  532. engine = create_engine("sqlite+pysqlite:///:memory:")
  533. VideoDiscoveryRun.__table__.create(engine)
  534. VideoDiscoverySearch.__table__.create(engine)
  535. VideoDiscoveryCandidate.__table__.create(engine)
  536. factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
  537. ids = {"search": 100, "candidate": 1000}
  538. @event.listens_for(factory.class_, "before_flush")
  539. def assign_sqlite_bigint_ids(session, _flush_context, _instances):
  540. for entity in session.new:
  541. if isinstance(entity, VideoDiscoverySearch) and entity.id is None:
  542. ids["search"] += 1
  543. entity.id = ids["search"]
  544. elif isinstance(entity, VideoDiscoveryCandidate) and entity.id is None:
  545. ids["candidate"] += 1
  546. entity.id = ids["candidate"]
  547. with factory() as session:
  548. session.add(
  549. VideoDiscoveryRun(
  550. id=1,
  551. run_id="run-occurrences",
  552. demand_word="广场舞",
  553. relevant_points_json="[]",
  554. status="running",
  555. )
  556. )
  557. session.commit()
  558. search_values = {
  559. "run_id": "run-occurrences",
  560. "search_key": "same-search-key",
  561. "keyword": "广场舞",
  562. "query_reason": "验证相同搜索也生成新记录",
  563. "source_type": "demand",
  564. "provider": "internal_keyword",
  565. "content_type": "视频",
  566. "sort_type": "综合排序",
  567. "publish_time": "不限",
  568. "cursor": "0",
  569. "page_no": 1,
  570. "results_count": 1,
  571. "new_candidate_count": 0,
  572. "has_more": 0,
  573. "status": "success",
  574. }
  575. candidate_rows = [
  576. {
  577. "aweme_id": "same-video",
  578. "title": "同一个视频",
  579. "_source_keyword": "广场舞",
  580. }
  581. ]
  582. with factory() as session:
  583. repo = VideoDiscoveryRepository(session)
  584. first_search, first_candidates = repo.save_search_page(
  585. dict(search_values),
  586. candidate_rows,
  587. )
  588. second_search, second_candidates = repo.save_search_page(
  589. dict(search_values),
  590. candidate_rows,
  591. )
  592. session.commit()
  593. assert first_search.id != second_search.id
  594. assert first_candidates[0].id != second_candidates[0].id
  595. assert first_candidates[0].search_id == first_search.id
  596. assert second_candidates[0].search_id == second_search.id
  597. with factory() as session:
  598. searches = session.scalars(select(VideoDiscoverySearch)).all()
  599. candidates = session.scalars(select(VideoDiscoveryCandidate)).all()
  600. assert len(searches) == 2
  601. assert len(candidates) == 2
  602. assert {candidate.aweme_id for candidate in candidates} == {"same-video"}
  603. def _seed_candidate(
  604. factory: sessionmaker[Session],
  605. *,
  606. run_id: str,
  607. aweme_id: str = "7631830155522179258",
  608. row_id: int = 1,
  609. ) -> None:
  610. with factory() as session:
  611. session.add(
  612. VideoDiscoveryCandidate(
  613. id=row_id,
  614. run_id=run_id,
  615. aweme_id=aweme_id,
  616. decision_bucket="pending_evaluation",
  617. )
  618. )
  619. session.commit()
  620. def test_list_skip_grade_ids_when_candidates_exist(
  621. monkeypatch: pytest.MonkeyPatch,
  622. ) -> None:
  623. factory = _expire_on_commit_session_factory()
  624. _patch_service_session(monkeypatch, factory)
  625. _seed_run(factory, run_id="running-empty", demand_grade_id=301, status="running")
  626. _seed_run(
  627. factory,
  628. run_id="running-with-candidates",
  629. demand_grade_id=302,
  630. status="running",
  631. row_id=2,
  632. )
  633. _seed_candidate(factory, run_id="running-with-candidates")
  634. from supply_infra.services.video_discovery_service import get_video_discovery_service
  635. skip_ids = get_video_discovery_service().list_skip_grade_ids("20260728")
  636. assert skip_ids == {302}
  637. def test_evaluate_find_agent_run_succeeds_when_candidates_exist(
  638. monkeypatch: pytest.MonkeyPatch,
  639. ) -> None:
  640. from agents.find_agent.run_outcome import evaluate_find_agent_run
  641. from supply_agent.types import AgentResult
  642. monkeypatch.setattr(
  643. "agents.find_agent.run_outcome.get_video_discovery_service",
  644. lambda: type(
  645. "Svc",
  646. (),
  647. {"has_candidates": staticmethod(lambda _run_id: True)},
  648. )(),
  649. )
  650. outcome = evaluate_find_agent_run(
  651. "run-1",
  652. AgentResult(content="任意文案", messages=[], iterations=3, tool_calls_made=0),
  653. )
  654. assert outcome.succeeded is True
  655. assert outcome.failure_reason is None
  656. def test_evaluate_find_agent_run_fails_without_candidates(
  657. monkeypatch: pytest.MonkeyPatch,
  658. ) -> None:
  659. from agents.find_agent.run_outcome import evaluate_find_agent_run
  660. from supply_agent.types import AgentResult
  661. monkeypatch.setattr(
  662. "agents.find_agent.run_outcome.get_video_discovery_service",
  663. lambda: type(
  664. "Svc",
  665. (),
  666. {"has_candidates": staticmethod(lambda _run_id: False)},
  667. )(),
  668. )
  669. outcome = evaluate_find_agent_run(
  670. "run-1",
  671. AgentResult(
  672. content="任务未完成(工具故障)",
  673. messages=[],
  674. iterations=1,
  675. tool_calls_made=0,
  676. ),
  677. )
  678. assert outcome.succeeded is False
  679. assert outcome.failure_reason == "no_candidates"
  680. @patch(
  681. "supply_infra.scheduler.jobs.discover_videos_from_demands.process_single_discover"
  682. )
  683. @patch("supply_infra.scheduler.jobs.discover_videos_from_demands._count_passed_videos")
  684. @patch(
  685. "supply_infra.scheduler.jobs.discover_videos_from_demands.filter_pending_contexts"
  686. )
  687. @patch(
  688. "supply_infra.scheduler.jobs.discover_videos_from_demands.list_find_demand_contexts"
  689. )
  690. def test_stops_discovery_after_200_passed_videos(
  691. mock_list_contexts,
  692. mock_filter_contexts,
  693. mock_count_passed,
  694. mock_process,
  695. ) -> None:
  696. contexts = [
  697. FindDemandContext(
  698. biz_dt="20260727",
  699. demand_grade_id=1,
  700. demand_name="需求A",
  701. grade="S",
  702. ),
  703. FindDemandContext(
  704. biz_dt="20260727",
  705. demand_grade_id=2,
  706. demand_name="需求B",
  707. grade="A",
  708. ),
  709. ]
  710. mock_list_contexts.return_value = ("20260727", contexts)
  711. mock_filter_contexts.return_value = (
  712. contexts,
  713. {"total_loaded": 2, "skipped_already_done": 0},
  714. )
  715. mock_count_passed.side_effect = [199, 200]
  716. mock_process.return_value = {"success": True, "skipped": False}
  717. result = discover_videos_from_demands("20260727", workers=1)
  718. assert mock_process.call_count == 1
  719. assert result["processed"] == 1
  720. assert result["passed_videos"] == 200
  721. assert result["stopped_by_passed_video_limit"] is True
  722. class _AsyncClient:
  723. def __init__(self) -> None:
  724. self.closed = False
  725. async def close(self) -> None:
  726. self.closed = True
  727. class _SlowAgent:
  728. def __init__(self) -> None:
  729. self.llm = type("LLM", (), {"_async_client": _AsyncClient()})()
  730. async def arun_core(self, _user_input: str) -> None:
  731. await asyncio.sleep(60)
  732. @pytest.mark.asyncio
  733. async def test_find_agent_timeout_closes_async_client() -> None:
  734. agent = _SlowAgent()
  735. with pytest.raises(TimeoutError, match="find_agent timed out"):
  736. await arun_find_agent(
  737. agent, # type: ignore[arg-type]
  738. "test",
  739. run_id="test-run",
  740. timeout_seconds=0.01,
  741. )
  742. assert agent.llm._async_client.closed is True