video_discovery_repo.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422
  1. from __future__ import annotations
  2. import json
  3. from typing import Any
  4. from sqlalchemy import func, select
  5. from sqlalchemy.exc import OperationalError, ProgrammingError
  6. from supply_infra.video_discovery_gates import (
  7. evaluate_candidate_gate,
  8. load_rule_snapshot,
  9. )
  10. from supply_infra.db.models.video_discovery import (
  11. VideoDiscoveryCandidate,
  12. VideoDiscoveryRun,
  13. VideoDiscoverySearch,
  14. )
  15. from supply_infra.db.repositories.base import BaseRepository
  16. def _json_list(raw: str | None) -> list[Any]:
  17. if not raw:
  18. return []
  19. try:
  20. value = json.loads(raw)
  21. except (TypeError, ValueError):
  22. return []
  23. return value if isinstance(value, list) else []
  24. def _merge_json_list(raw: str | None, values: list[Any]) -> str | None:
  25. merged = _json_list(raw)
  26. seen = {
  27. json.dumps(value, ensure_ascii=False, sort_keys=True, default=str)
  28. for value in merged
  29. }
  30. for value in values:
  31. key = json.dumps(value, ensure_ascii=False, sort_keys=True, default=str)
  32. if key not in seen:
  33. seen.add(key)
  34. merged.append(value)
  35. return json.dumps(merged, ensure_ascii=False) if merged else None
  36. _PUBLISHABLE_BUCKETS = ("primary",)
  37. class VideoDiscoveryRepository(BaseRepository[VideoDiscoveryRun]):
  38. """需求找视频运行、搜索轨迹和候选快照的统一 Repository。"""
  39. model = VideoDiscoveryRun
  40. def get_run(self, run_id: str) -> VideoDiscoveryRun | None:
  41. stmt = select(VideoDiscoveryRun).where(VideoDiscoveryRun.run_id == run_id)
  42. return self.session.scalar(stmt)
  43. def get_by_biz_dt_and_grade(
  44. self, biz_dt: str, demand_grade_id: int
  45. ) -> VideoDiscoveryRun | None:
  46. stmt = select(VideoDiscoveryRun).where(
  47. VideoDiscoveryRun.biz_dt == biz_dt,
  48. VideoDiscoveryRun.demand_grade_id == int(demand_grade_id),
  49. )
  50. return self.session.scalar(stmt)
  51. def list_skip_grade_ids(self, biz_dt: str) -> set[int]:
  52. """返回指定业务日已有候选落库的 demand_grade.id(与 run_outcome 成功口径一致)。"""
  53. stmt = (
  54. select(VideoDiscoveryRun.demand_grade_id)
  55. .join(
  56. VideoDiscoveryCandidate,
  57. VideoDiscoveryCandidate.run_id == VideoDiscoveryRun.run_id,
  58. )
  59. .where(
  60. VideoDiscoveryRun.biz_dt == biz_dt,
  61. VideoDiscoveryRun.demand_grade_id.is_not(None),
  62. )
  63. .distinct()
  64. )
  65. try:
  66. return {
  67. int(grade_id)
  68. for grade_id in self.session.scalars(stmt).all()
  69. if grade_id is not None
  70. }
  71. except (OperationalError, ProgrammingError) as exc:
  72. if "biz_dt" not in str(exc).lower():
  73. raise
  74. return set()
  75. def upsert_scheduled_run(self, values: dict[str, Any]) -> VideoDiscoveryRun:
  76. """按 (biz_dt, demand_grade_id) 预创建或重置调度运行记录。"""
  77. biz_dt = str(values["biz_dt"])
  78. demand_grade_id = int(values["demand_grade_id"])
  79. existing = self.get_by_biz_dt_and_grade(biz_dt, demand_grade_id)
  80. if existing is None:
  81. entity = VideoDiscoveryRun(**values)
  82. self.session.add(entity)
  83. self.session.flush()
  84. return entity
  85. for key, value in values.items():
  86. if key in {"id", "create_time"}:
  87. continue
  88. if hasattr(existing, key):
  89. setattr(existing, key, value)
  90. existing.status = str(values.get("status") or "running")
  91. existing.stop_reason = values.get("stop_reason")
  92. self.session.flush()
  93. return existing
  94. def mark_run_failed(self, run_id: str, *, stop_reason: str | None = None) -> None:
  95. run = self.get_run(run_id)
  96. if run is None:
  97. return
  98. run.status = "failed"
  99. if stop_reason:
  100. run.stop_reason = stop_reason[:2000]
  101. self.session.flush()
  102. def create_run(self, values: dict[str, Any]) -> VideoDiscoveryRun:
  103. entity = VideoDiscoveryRun(**values)
  104. self.session.add(entity)
  105. self.session.flush()
  106. return entity
  107. def save_search_page(
  108. self,
  109. search_values: dict[str, Any],
  110. candidate_rows: list[dict[str, Any]],
  111. ) -> tuple[VideoDiscoverySearch, list[VideoDiscoveryCandidate]]:
  112. """新增一个搜索页,并为本页每条结果新增独立候选记录。"""
  113. run_id = str(search_values["run_id"])
  114. search = VideoDiscoverySearch(**search_values)
  115. self.session.add(search)
  116. self.session.flush()
  117. candidates: list[VideoDiscoveryCandidate] = []
  118. aweme_ids: list[str] = []
  119. for raw_row in candidate_rows:
  120. row = dict(raw_row)
  121. aweme_id = str(row.pop("aweme_id", "") or "").strip()
  122. if not aweme_id:
  123. continue
  124. source_keyword = row.pop("_source_keyword", None)
  125. entity = VideoDiscoveryCandidate(
  126. run_id=run_id,
  127. search_id=int(search.id),
  128. aweme_id=aweme_id,
  129. source_keywords_json=(
  130. json.dumps([source_keyword], ensure_ascii=False)
  131. if source_keyword
  132. else None
  133. ),
  134. source_search_ids_json=json.dumps([int(search.id)]),
  135. decision_bucket="pending_evaluation",
  136. )
  137. for key, value in row.items():
  138. if value is None:
  139. continue
  140. if hasattr(entity, key):
  141. setattr(entity, key, value)
  142. self.session.add(entity)
  143. candidates.append(entity)
  144. aweme_ids.append(aweme_id)
  145. search.new_candidate_count = len(candidates)
  146. search.result_ids_json = (
  147. json.dumps(aweme_ids, ensure_ascii=False) if aweme_ids else None
  148. )
  149. self.session.flush()
  150. self._refresh_run_counts(run_id)
  151. return search, candidates
  152. def update_candidates(
  153. self,
  154. run_id: str,
  155. rows: list[dict[str, Any]],
  156. ) -> list[VideoDiscoveryCandidate]:
  157. """严格按 candidate_id 更新候选;不新增记录、不修改运行状态。"""
  158. candidate_ids = [int(row["candidate_id"]) for row in rows]
  159. if len(candidate_ids) != len(set(candidate_ids)):
  160. raise ValueError("candidate_id 不能重复")
  161. run = self.get_run(run_id)
  162. if run is None:
  163. raise ValueError(f"run_id 不存在: {run_id}")
  164. rule_snapshot = load_rule_snapshot(run.rule_config_json)
  165. stmt = select(VideoDiscoveryCandidate).where(
  166. VideoDiscoveryCandidate.run_id == run_id,
  167. VideoDiscoveryCandidate.id.in_(candidate_ids),
  168. )
  169. existing = {
  170. int(item.id): item for item in self.session.scalars(stmt).all()
  171. }
  172. missing = [candidate_id for candidate_id in candidate_ids if candidate_id not in existing]
  173. if missing:
  174. raise ValueError(
  175. f"candidate_id 不存在或不属于 run_id={run_id}: {missing}"
  176. )
  177. gate_failures: list[str] = []
  178. for row in rows:
  179. candidate_id = int(row["candidate_id"])
  180. entity = existing[candidate_id]
  181. for key, value in row.items():
  182. if key in {"candidate_id", "id", "run_id", "search_id", "aweme_id"}:
  183. continue
  184. if value is None:
  185. continue
  186. if key in {
  187. "source_keywords_json",
  188. "source_search_ids_json",
  189. "tags_json",
  190. }:
  191. values = value if isinstance(value, list) else _json_list(str(value))
  192. setattr(entity, key, _merge_json_list(getattr(entity, key), values))
  193. elif hasattr(entity, key):
  194. setattr(entity, key, value)
  195. gate = evaluate_candidate_gate(
  196. {
  197. "title": entity.title,
  198. "tags_json": entity.tags_json,
  199. "publish_at": entity.publish_at,
  200. "duration_seconds": entity.duration_seconds,
  201. "share_count": entity.share_count,
  202. "content_50_plus_ratio": entity.content_50_plus_ratio,
  203. "account_50_plus_ratio": entity.account_50_plus_ratio,
  204. "temporal_type": entity.temporal_type,
  205. "temporal_status": entity.temporal_status,
  206. "temporal_evidence_json": entity.temporal_evidence_json,
  207. },
  208. rule_snapshot,
  209. )
  210. entity.content_portrait_status = gate["content_portrait_status"]
  211. entity.account_portrait_status = gate["account_portrait_status"]
  212. entity.portrait_conflict = int(bool(gate["portrait_conflict"]))
  213. entity.temporal_type = gate["temporal"]["temporal_type"]
  214. entity.temporal_status = gate["temporal"]["status"]
  215. entity.temporal_evidence_json = json.dumps(
  216. gate["temporal"],
  217. ensure_ascii=False,
  218. default=str,
  219. )
  220. entity.gate_status = gate["status"]
  221. entity.gate_results_json = json.dumps(
  222. gate,
  223. ensure_ascii=False,
  224. default=str,
  225. )
  226. entity.rule_version = str(gate["rule_version"])
  227. if entity.decision_bucket == "primary":
  228. if not gate["primary_eligible"]:
  229. codes = ", ".join(gate["failed_reason_codes"])
  230. gate_failures.append(f"candidate_id={candidate_id}: {codes}")
  231. else:
  232. entity.reject_reason_code = None
  233. else:
  234. failed_codes = gate["failed_reason_codes"]
  235. if failed_codes:
  236. entity.reject_reason_code = str(failed_codes[0])
  237. elif not entity.reject_reason_code:
  238. entity.reject_reason_code = "REJECTED_BY_AGENT"
  239. if gate_failures:
  240. raise ValueError(
  241. "primary 候选未通过 P0 硬门槛: " + "; ".join(gate_failures)
  242. )
  243. self.session.flush()
  244. return [existing[candidate_id] for candidate_id in candidate_ids]
  245. def finish_run(
  246. self,
  247. run_id: str,
  248. *,
  249. status: str,
  250. intent_summary: str | None = None,
  251. stop_reason: str | None = None,
  252. ) -> VideoDiscoveryRun:
  253. run = self.get_run(run_id)
  254. if run is None:
  255. raise ValueError(f"run_id 不存在: {run_id}")
  256. run.status = status
  257. if intent_summary is not None:
  258. run.intent_summary = intent_summary
  259. if stop_reason is not None:
  260. run.stop_reason = stop_reason
  261. self.session.flush()
  262. self._refresh_run_counts(run_id)
  263. return run
  264. def list_searches(self, run_id: str) -> list[VideoDiscoverySearch]:
  265. stmt = (
  266. select(VideoDiscoverySearch)
  267. .where(VideoDiscoverySearch.run_id == run_id)
  268. .order_by(VideoDiscoverySearch.id)
  269. )
  270. return list(self.session.scalars(stmt).all())
  271. def list_candidates(
  272. self,
  273. run_id: str,
  274. *,
  275. buckets: tuple[str, ...] | None = None,
  276. limit: int = 100,
  277. ) -> list[VideoDiscoveryCandidate]:
  278. stmt = select(VideoDiscoveryCandidate).where(
  279. VideoDiscoveryCandidate.run_id == run_id
  280. )
  281. if buckets:
  282. stmt = stmt.where(VideoDiscoveryCandidate.decision_bucket.in_(buckets))
  283. stmt = stmt.order_by(
  284. VideoDiscoveryCandidate.value_score.desc(),
  285. VideoDiscoveryCandidate.share_count.desc(),
  286. VideoDiscoveryCandidate.id,
  287. ).limit(limit)
  288. return list(self.session.scalars(stmt).all())
  289. def has_candidates(self, run_id: str) -> bool:
  290. stmt = (
  291. select(VideoDiscoveryCandidate.id)
  292. .where(VideoDiscoveryCandidate.run_id == run_id)
  293. .limit(1)
  294. )
  295. return self.session.scalar(stmt) is not None
  296. def list_publishable_candidates(
  297. self,
  298. *,
  299. run_id: str | None = None,
  300. biz_dt: str | None = None,
  301. skip_published: bool = True,
  302. limit: int | None = None,
  303. ) -> list[VideoDiscoveryCandidate]:
  304. """查询待提交 AIGC 的候选视频;仅 primary 分池,且 aweme_id 非空。"""
  305. stmt = (
  306. select(VideoDiscoveryCandidate)
  307. .join(
  308. VideoDiscoveryRun,
  309. VideoDiscoveryRun.run_id == VideoDiscoveryCandidate.run_id,
  310. )
  311. .where(VideoDiscoveryCandidate.decision_bucket.in_(_PUBLISHABLE_BUCKETS))
  312. .where(VideoDiscoveryCandidate.aweme_id.is_not(None))
  313. .where(func.trim(VideoDiscoveryCandidate.aweme_id) != "")
  314. .order_by(
  315. VideoDiscoveryCandidate.value_score.desc(),
  316. VideoDiscoveryCandidate.share_count.desc(),
  317. VideoDiscoveryCandidate.id,
  318. )
  319. )
  320. if run_id:
  321. stmt = stmt.where(VideoDiscoveryCandidate.run_id == run_id)
  322. if biz_dt:
  323. stmt = stmt.where(VideoDiscoveryRun.biz_dt == biz_dt)
  324. if skip_published:
  325. stmt = stmt.where(VideoDiscoveryCandidate.aigc_crawler_plan_id.is_(None))
  326. if limit is not None:
  327. stmt = stmt.limit(limit)
  328. return list(self.session.scalars(stmt).all())
  329. def count_passed_videos(self, biz_dt: str) -> int:
  330. """统计业务日内 primary 的唯一视频数。"""
  331. stmt = (
  332. select(func.count(func.distinct(VideoDiscoveryCandidate.aweme_id)))
  333. .join(
  334. VideoDiscoveryRun,
  335. VideoDiscoveryRun.run_id == VideoDiscoveryCandidate.run_id,
  336. )
  337. .where(VideoDiscoveryRun.biz_dt == biz_dt)
  338. .where(VideoDiscoveryCandidate.decision_bucket.in_(_PUBLISHABLE_BUCKETS))
  339. .where(VideoDiscoveryCandidate.aweme_id.is_not(None))
  340. .where(func.trim(VideoDiscoveryCandidate.aweme_id) != "")
  341. )
  342. return int(self.session.scalar(stmt) or 0)
  343. def mark_candidates_aigc_plans(
  344. self,
  345. candidate_ids: list[int],
  346. *,
  347. crawler_plan_id: str,
  348. produce_plan_id: str,
  349. publish_plan_id: str,
  350. plan_label: str,
  351. ) -> int:
  352. if not candidate_ids:
  353. return 0
  354. stmt = select(VideoDiscoveryCandidate).where(
  355. VideoDiscoveryCandidate.id.in_(candidate_ids)
  356. )
  357. updated = 0
  358. for entity in self.session.scalars(stmt).all():
  359. entity.aigc_crawler_plan_id = crawler_plan_id
  360. entity.aigc_produce_plan_id = produce_plan_id
  361. entity.aigc_publish_plan_id = publish_plan_id
  362. entity.aigc_plan_label = plan_label
  363. updated += 1
  364. self.session.flush()
  365. return updated
  366. def _refresh_run_counts(self, run_id: str) -> None:
  367. run = self.get_run(run_id)
  368. if run is None:
  369. return
  370. search_stmt = select(func.count(VideoDiscoverySearch.id)).where(
  371. VideoDiscoverySearch.run_id == run_id
  372. )
  373. run.search_count = int(self.session.scalar(search_stmt) or 0)
  374. bucket_stmt = (
  375. select(
  376. VideoDiscoveryCandidate.decision_bucket,
  377. func.count(VideoDiscoveryCandidate.id),
  378. )
  379. .where(VideoDiscoveryCandidate.run_id == run_id)
  380. .group_by(VideoDiscoveryCandidate.decision_bucket)
  381. )
  382. counts = {str(bucket): int(count) for bucket, count in self.session.execute(bucket_stmt)}
  383. run.primary_count = counts.get("primary", 0)
  384. self.session.flush()