video_discovery_records.py 17 KB

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