video_discovery_records.py 17 KB

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