video_discovery_records.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475
  1. """Authenticated-user records for the find-agent video discovery workflow."""
  2. from __future__ import annotations
  3. import json
  4. from typing import Any
  5. from sqlalchemy import func, or_, select
  6. from sqlalchemy.orm import load_only
  7. from supply_infra.db.models.video_discovery import (
  8. VideoDiscoveryCandidate,
  9. VideoDiscoveryRun,
  10. VideoDiscoverySearch,
  11. )
  12. from supply_infra.db.session import get_session
  13. _MIN_VISIBLE_BIZ_DT = "20260730"
  14. def _json_value(raw: str | None) -> Any:
  15. if not raw:
  16. return None
  17. try:
  18. return json.loads(raw)
  19. except (TypeError, ValueError):
  20. return raw
  21. def _timestamp(value: Any) -> str | None:
  22. return value.isoformat() if value is not None else None
  23. def _number(value: Any) -> float | None:
  24. return float(value) if value is not None else None
  25. def _serialize_run(row: VideoDiscoveryRun, counts: dict[str, int]) -> dict[str, Any]:
  26. return {
  27. "id": int(row.id),
  28. "run_id": row.run_id,
  29. "biz_dt": row.biz_dt,
  30. "demand_grade_id": (
  31. int(row.demand_grade_id) if row.demand_grade_id is not None else None
  32. ),
  33. "demand_word": row.demand_word,
  34. "seed_video_id": row.seed_video_id,
  35. "seed_video_title": row.seed_video_title,
  36. "relevant_points": _json_value(row.relevant_points_json),
  37. "intent_summary": row.intent_summary,
  38. "status": row.status,
  39. "search_count": counts.get("search", int(row.search_count or 0)),
  40. "candidate_count": counts.get("candidate", 0),
  41. "primary_count": counts.get("primary", int(row.primary_count or 0)),
  42. "valid_primary_count": int(row.valid_primary_count or 0),
  43. "outcome_status": row.outcome_status,
  44. "attempt_count": int(row.attempt_count or 0),
  45. "rejected_count": counts.get("rejected", 0),
  46. "pending_count": counts.get("pending_evaluation", 0),
  47. "stop_reason": row.stop_reason,
  48. "rule_version": row.rule_version,
  49. "rule_config": _json_value(row.rule_config_json),
  50. "create_time": _timestamp(row.create_time),
  51. "update_time": _timestamp(row.update_time),
  52. }
  53. def _serialize_search_candidate(row: VideoDiscoveryCandidate) -> dict[str, Any]:
  54. return {
  55. "id": int(row.id),
  56. "aweme_id": row.aweme_id,
  57. "title": row.title,
  58. "content_link": row.content_link,
  59. "author_name": row.author_name,
  60. "decision_bucket": row.decision_bucket,
  61. "publish_at": _timestamp(row.publish_at),
  62. "duration_seconds": _number(row.duration_seconds),
  63. "play_count": int(row.play_count) if row.play_count is not None else None,
  64. "like_count": int(row.like_count) if row.like_count is not None else None,
  65. "comment_count": (
  66. int(row.comment_count) if row.comment_count is not None else None
  67. ),
  68. "collect_count": (
  69. int(row.collect_count) if row.collect_count is not None else None
  70. ),
  71. "share_count": int(row.share_count) if row.share_count is not None else None,
  72. "relevance_score": _number(row.relevance_score),
  73. "elder_score": _number(row.elder_score),
  74. "share_score": _number(row.share_score),
  75. "value_score": _number(row.value_score),
  76. "content_50_plus_ratio": _number(row.content_50_plus_ratio),
  77. "content_portrait_status": row.content_portrait_status,
  78. "account_50_plus_ratio": _number(row.account_50_plus_ratio),
  79. "account_portrait_status": row.account_portrait_status,
  80. "portrait_conflict": bool(row.portrait_conflict),
  81. "temporal_status": row.temporal_status,
  82. "gate_status": row.gate_status,
  83. "reject_reason_code": row.reject_reason_code,
  84. "decision_reason": row.decision_reason,
  85. }
  86. def _serialize_search(
  87. row: VideoDiscoverySearch,
  88. candidates: list[VideoDiscoveryCandidate],
  89. ) -> dict[str, Any]:
  90. return {
  91. "id": int(row.id),
  92. "run_id": row.run_id,
  93. "search_key": row.search_key,
  94. "keyword": row.keyword,
  95. "query_reason": row.query_reason,
  96. "source_type": row.source_type,
  97. "source_value": row.source_value,
  98. "parent_search_id": (
  99. int(row.parent_search_id) if row.parent_search_id is not None else None
  100. ),
  101. "provider": row.provider,
  102. "provider_state": _json_value(row.provider_state_json),
  103. "content_type": row.content_type,
  104. "sort_type": row.sort_type,
  105. "publish_time": row.publish_time,
  106. "cursor": row.cursor,
  107. "page_no": int(row.page_no),
  108. "results_count": int(row.results_count or 0),
  109. "new_candidate_count": int(row.new_candidate_count or 0),
  110. "has_more": bool(row.has_more),
  111. "next_cursor": row.next_cursor,
  112. "result_ids": _json_value(row.result_ids_json),
  113. "candidates": [_serialize_search_candidate(candidate) for candidate in candidates],
  114. "status": row.status,
  115. "error_message": row.error_message,
  116. "create_time": _timestamp(row.create_time),
  117. "update_time": _timestamp(row.update_time),
  118. }
  119. def _serialize_candidate(row: VideoDiscoveryCandidate) -> dict[str, Any]:
  120. return {
  121. "id": int(row.id),
  122. "run_id": row.run_id,
  123. "search_id": int(row.search_id) if row.search_id is not None else None,
  124. "aweme_id": row.aweme_id,
  125. "title": row.title,
  126. "content_link": row.content_link,
  127. "author_name": row.author_name,
  128. "author_sec_uid": row.author_sec_uid,
  129. "source_keywords": _json_value(row.source_keywords_json),
  130. "source_search_ids": _json_value(row.source_search_ids_json),
  131. "tags": _json_value(row.tags_json),
  132. "publish_at": _timestamp(row.publish_at),
  133. "duration_seconds": _number(row.duration_seconds),
  134. "play_count": int(row.play_count) if row.play_count is not None else None,
  135. "like_count": int(row.like_count) if row.like_count is not None else None,
  136. "comment_count": (
  137. int(row.comment_count) if row.comment_count is not None else None
  138. ),
  139. "collect_count": (
  140. int(row.collect_count) if row.collect_count is not None else None
  141. ),
  142. "share_count": int(row.share_count) if row.share_count is not None else None,
  143. "content_age_evidence": _json_value(row.content_age_evidence_json),
  144. "account_age_evidence": _json_value(row.account_age_evidence_json),
  145. "age_normalization": _json_value(row.age_normalization_json),
  146. "content_50_plus_ratio": _number(row.content_50_plus_ratio),
  147. "content_50_plus_tgi": _number(row.content_50_plus_tgi),
  148. "content_portrait_status": row.content_portrait_status,
  149. "account_50_plus_ratio": _number(row.account_50_plus_ratio),
  150. "account_50_plus_tgi": _number(row.account_50_plus_tgi),
  151. "account_portrait_status": row.account_portrait_status,
  152. "portrait_conflict": bool(row.portrait_conflict),
  153. "temporal_type": row.temporal_type,
  154. "temporal_status": row.temporal_status,
  155. "temporal_evidence": _json_value(row.temporal_evidence_json),
  156. "gate_status": row.gate_status,
  157. "gate_results": _json_value(row.gate_results_json),
  158. "reject_reason_code": row.reject_reason_code,
  159. "rule_version": row.rule_version,
  160. "relevance_score": _number(row.relevance_score),
  161. "elder_score": _number(row.elder_score),
  162. "share_score": _number(row.share_score),
  163. "value_score": _number(row.value_score),
  164. "detail_fetch_status": row.detail_fetch_status,
  165. "content_portrait_fetch_status": row.content_portrait_fetch_status,
  166. "account_portrait_fetch_status": row.account_portrait_fetch_status,
  167. "decision_reason": row.decision_reason,
  168. "decision_bucket": row.decision_bucket,
  169. "aigc_crawler_plan_id": row.aigc_crawler_plan_id,
  170. "aigc_produce_plan_id": row.aigc_produce_plan_id,
  171. "aigc_publish_plan_id": row.aigc_publish_plan_id,
  172. "aigc_plan_label": row.aigc_plan_label,
  173. "create_time": _timestamp(row.create_time),
  174. "update_time": _timestamp(row.update_time),
  175. }
  176. def _run_counts(session: Any, run_ids: list[str]) -> dict[str, dict[str, int]]:
  177. counts = {
  178. run_id: {
  179. "search": 0,
  180. "candidate": 0,
  181. "primary": 0,
  182. "rejected": 0,
  183. "pending_evaluation": 0,
  184. }
  185. for run_id in run_ids
  186. }
  187. if not run_ids:
  188. return counts
  189. search_stmt = (
  190. select(VideoDiscoverySearch.run_id, func.count(VideoDiscoverySearch.id))
  191. .where(VideoDiscoverySearch.run_id.in_(run_ids))
  192. .group_by(VideoDiscoverySearch.run_id)
  193. )
  194. for run_id, count in session.execute(search_stmt):
  195. counts[str(run_id)]["search"] = int(count)
  196. candidate_stmt = (
  197. select(
  198. VideoDiscoveryCandidate.run_id,
  199. VideoDiscoveryCandidate.decision_bucket,
  200. func.count(VideoDiscoveryCandidate.id),
  201. )
  202. .where(VideoDiscoveryCandidate.run_id.in_(run_ids))
  203. .group_by(
  204. VideoDiscoveryCandidate.run_id,
  205. VideoDiscoveryCandidate.decision_bucket,
  206. )
  207. )
  208. for run_id, bucket, count in session.execute(candidate_stmt):
  209. run_counts = counts[str(run_id)]
  210. run_counts[str(bucket)] = int(count)
  211. run_counts["candidate"] = run_counts.get("candidate", 0) + int(count)
  212. return counts
  213. def list_video_discovery_runs(
  214. *,
  215. biz_dt: str | None = None,
  216. status: str | None = None,
  217. keyword: str | None = None,
  218. aweme_id: str | None = None,
  219. limit: int = 20,
  220. offset: int = 0,
  221. ) -> dict[str, Any]:
  222. """List find-agent runs with live search and candidate counts."""
  223. with get_session() as session:
  224. conditions = [VideoDiscoveryRun.biz_dt >= _MIN_VISIBLE_BIZ_DT]
  225. if biz_dt:
  226. conditions.append(VideoDiscoveryRun.biz_dt == biz_dt)
  227. if status:
  228. conditions.append(VideoDiscoveryRun.status == status)
  229. normalized_keyword = (keyword or "").strip().lower()
  230. if normalized_keyword:
  231. pattern = f"%{normalized_keyword}%"
  232. conditions.append(
  233. or_(
  234. func.lower(VideoDiscoveryRun.demand_word).like(pattern),
  235. func.lower(VideoDiscoveryRun.run_id).like(pattern),
  236. func.lower(func.coalesce(VideoDiscoveryRun.seed_video_title, "")).like(
  237. pattern
  238. ),
  239. )
  240. )
  241. normalized_aweme_id = (aweme_id or "").strip()
  242. if normalized_aweme_id:
  243. conditions.append(
  244. select(VideoDiscoveryCandidate.id)
  245. .where(
  246. VideoDiscoveryCandidate.run_id == VideoDiscoveryRun.run_id,
  247. VideoDiscoveryCandidate.aweme_id == normalized_aweme_id,
  248. )
  249. .exists()
  250. )
  251. total_stmt = select(func.count(VideoDiscoveryRun.id))
  252. rows_stmt = select(VideoDiscoveryRun)
  253. if conditions:
  254. total_stmt = total_stmt.where(*conditions)
  255. rows_stmt = rows_stmt.where(*conditions)
  256. rows_stmt = rows_stmt.order_by(
  257. VideoDiscoveryRun.create_time.desc(),
  258. VideoDiscoveryRun.id.desc(),
  259. ).limit(limit).offset(offset)
  260. total = int(session.scalar(total_stmt) or 0)
  261. rows = list(session.scalars(rows_stmt).all())
  262. counts = _run_counts(session, [row.run_id for row in rows])
  263. return {
  264. "items": [_serialize_run(row, counts.get(row.run_id, {})) for row in rows],
  265. "total": total,
  266. "limit": limit,
  267. "offset": offset,
  268. }
  269. def get_video_discovery_run(run_id: str) -> dict[str, Any] | None:
  270. with get_session() as session:
  271. row = session.scalar(
  272. select(VideoDiscoveryRun).where(
  273. VideoDiscoveryRun.run_id == run_id,
  274. VideoDiscoveryRun.biz_dt >= _MIN_VISIBLE_BIZ_DT,
  275. )
  276. )
  277. if row is None:
  278. return None
  279. counts = _run_counts(session, [run_id])
  280. return _serialize_run(row, counts.get(run_id, {}))
  281. def list_video_discovery_searches(
  282. run_id: str,
  283. *,
  284. keyword: str | None = None,
  285. limit: int = 20,
  286. offset: int = 0,
  287. ) -> dict[str, Any] | None:
  288. with get_session() as session:
  289. exists = session.scalar(
  290. select(VideoDiscoveryRun.id).where(
  291. VideoDiscoveryRun.run_id == run_id,
  292. VideoDiscoveryRun.biz_dt >= _MIN_VISIBLE_BIZ_DT,
  293. )
  294. )
  295. if exists is None:
  296. return None
  297. conditions = [VideoDiscoverySearch.run_id == run_id]
  298. normalized_keyword = (keyword or "").strip().lower()
  299. if normalized_keyword:
  300. pattern = f"%{normalized_keyword}%"
  301. conditions.append(
  302. or_(
  303. func.lower(VideoDiscoverySearch.keyword).like(pattern),
  304. func.lower(VideoDiscoverySearch.query_reason).like(pattern),
  305. func.lower(
  306. func.coalesce(VideoDiscoverySearch.source_value, "")
  307. ).like(pattern),
  308. )
  309. )
  310. total = int(
  311. session.scalar(
  312. select(func.count(VideoDiscoverySearch.id)).where(*conditions)
  313. )
  314. or 0
  315. )
  316. rows = list(
  317. session.scalars(
  318. select(VideoDiscoverySearch)
  319. .where(*conditions)
  320. .order_by(VideoDiscoverySearch.id)
  321. .limit(limit)
  322. .offset(offset)
  323. ).all()
  324. )
  325. candidates_by_search: dict[int, list[VideoDiscoveryCandidate]] = {
  326. int(row.id): [] for row in rows
  327. }
  328. if candidates_by_search:
  329. candidate_rows = session.scalars(
  330. select(VideoDiscoveryCandidate)
  331. .options(
  332. load_only(
  333. VideoDiscoveryCandidate.id,
  334. VideoDiscoveryCandidate.search_id,
  335. VideoDiscoveryCandidate.aweme_id,
  336. VideoDiscoveryCandidate.title,
  337. VideoDiscoveryCandidate.content_link,
  338. VideoDiscoveryCandidate.author_name,
  339. VideoDiscoveryCandidate.decision_bucket,
  340. VideoDiscoveryCandidate.publish_at,
  341. VideoDiscoveryCandidate.duration_seconds,
  342. VideoDiscoveryCandidate.play_count,
  343. VideoDiscoveryCandidate.like_count,
  344. VideoDiscoveryCandidate.comment_count,
  345. VideoDiscoveryCandidate.collect_count,
  346. VideoDiscoveryCandidate.share_count,
  347. VideoDiscoveryCandidate.relevance_score,
  348. VideoDiscoveryCandidate.elder_score,
  349. VideoDiscoveryCandidate.share_score,
  350. VideoDiscoveryCandidate.value_score,
  351. VideoDiscoveryCandidate.content_50_plus_ratio,
  352. VideoDiscoveryCandidate.content_portrait_status,
  353. VideoDiscoveryCandidate.account_50_plus_ratio,
  354. VideoDiscoveryCandidate.account_portrait_status,
  355. VideoDiscoveryCandidate.portrait_conflict,
  356. VideoDiscoveryCandidate.temporal_status,
  357. VideoDiscoveryCandidate.gate_status,
  358. VideoDiscoveryCandidate.reject_reason_code,
  359. VideoDiscoveryCandidate.decision_reason,
  360. )
  361. )
  362. .where(VideoDiscoveryCandidate.search_id.in_(candidates_by_search))
  363. .order_by(
  364. VideoDiscoveryCandidate.search_id,
  365. VideoDiscoveryCandidate.id,
  366. )
  367. ).all()
  368. for candidate in candidate_rows:
  369. if candidate.search_id is not None:
  370. candidates_by_search[int(candidate.search_id)].append(candidate)
  371. return {
  372. "items": [
  373. _serialize_search(row, candidates_by_search.get(int(row.id), []))
  374. for row in rows
  375. ],
  376. "total": total,
  377. "limit": limit,
  378. "offset": offset,
  379. }
  380. def list_video_discovery_candidates(
  381. run_id: str,
  382. *,
  383. bucket: str | None = None,
  384. keyword: str | None = None,
  385. aweme_id: str | None = None,
  386. limit: int = 20,
  387. offset: int = 0,
  388. ) -> dict[str, Any] | None:
  389. with get_session() as session:
  390. exists = session.scalar(
  391. select(VideoDiscoveryRun.id).where(
  392. VideoDiscoveryRun.run_id == run_id,
  393. VideoDiscoveryRun.biz_dt >= _MIN_VISIBLE_BIZ_DT,
  394. )
  395. )
  396. if exists is None:
  397. return None
  398. conditions = [VideoDiscoveryCandidate.run_id == run_id]
  399. if bucket:
  400. conditions.append(VideoDiscoveryCandidate.decision_bucket == bucket)
  401. normalized_aweme_id = (aweme_id or "").strip()
  402. if normalized_aweme_id:
  403. conditions.append(VideoDiscoveryCandidate.aweme_id == normalized_aweme_id)
  404. normalized_keyword = (keyword or "").strip().lower()
  405. if normalized_keyword:
  406. pattern = f"%{normalized_keyword}%"
  407. conditions.append(
  408. or_(
  409. func.lower(VideoDiscoveryCandidate.aweme_id).like(pattern),
  410. func.lower(
  411. func.coalesce(VideoDiscoveryCandidate.title, "")
  412. ).like(pattern),
  413. func.lower(
  414. func.coalesce(VideoDiscoveryCandidate.author_name, "")
  415. ).like(pattern),
  416. )
  417. )
  418. total = int(
  419. session.scalar(
  420. select(func.count(VideoDiscoveryCandidate.id)).where(*conditions)
  421. )
  422. or 0
  423. )
  424. rows = list(
  425. session.scalars(
  426. select(VideoDiscoveryCandidate)
  427. .where(*conditions)
  428. .order_by(
  429. VideoDiscoveryCandidate.value_score.desc(),
  430. VideoDiscoveryCandidate.id,
  431. )
  432. .limit(limit)
  433. .offset(offset)
  434. ).all()
  435. )
  436. return {
  437. "items": [_serialize_candidate(row) for row in rows],
  438. "total": total,
  439. "limit": limit,
  440. "offset": offset,
  441. }