video_discovery_service.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. """视频发现运行的 Service 层:session 与 ORM 仅在此模块内出现。"""
  2. from __future__ import annotations
  3. import json
  4. from dataclasses import dataclass
  5. from typing import Any
  6. from sqlalchemy.exc import OperationalError, ProgrammingError
  7. from supply_infra.db.repositories.video_discovery_repo import VideoDiscoveryRepository
  8. from supply_infra.db.session import get_session
  9. _SKIP_STATUSES = frozenset({"running", "finished"})
  10. class RunNotFoundError(LookupError):
  11. """run_id 在 video_discovery_run 中不存在。"""
  12. def format_db_error(error: Exception) -> str:
  13. if isinstance(error, (OperationalError, ProgrammingError)):
  14. return (
  15. f"数据库表尚未初始化或不可用: {error}。"
  16. "请先运行 `.venv/bin/python -m supply_infra.db`。"
  17. )
  18. return str(error)
  19. def _load_json(value: str | None, default: Any) -> Any:
  20. if not value:
  21. return default
  22. try:
  23. return json.loads(value)
  24. except (TypeError, ValueError):
  25. return default
  26. def _serialize_run(run: Any) -> dict[str, Any]:
  27. return {
  28. "run_id": run.run_id,
  29. "biz_dt": run.biz_dt,
  30. "demand_grade_id": run.demand_grade_id,
  31. "demand_word": run.demand_word,
  32. "seed_video_id": run.seed_video_id,
  33. "seed_video_title": run.seed_video_title,
  34. "intent_summary": run.intent_summary,
  35. "status": run.status,
  36. "search_count": int(run.search_count or 0),
  37. "primary_count": int(run.primary_count or 0),
  38. "stop_reason": run.stop_reason,
  39. }
  40. def _serialize_search(item: Any) -> dict[str, Any]:
  41. return {
  42. "search_id": int(item.id),
  43. "keyword": item.keyword,
  44. "query_reason": item.query_reason,
  45. "source_type": item.source_type,
  46. "source_value": item.source_value,
  47. "parent_search_id": item.parent_search_id,
  48. "provider": item.provider,
  49. "provider_state": _load_json(item.provider_state_json, {}),
  50. "cursor": item.cursor,
  51. "page_no": item.page_no,
  52. "results_count": item.results_count,
  53. "new_candidate_count": item.new_candidate_count,
  54. "has_more": bool(item.has_more),
  55. "next_cursor": item.next_cursor,
  56. }
  57. def _serialize_candidate(item: Any) -> dict[str, Any]:
  58. decision_bucket = (
  59. "pending_evaluation"
  60. if item.decision_bucket == "unreviewed"
  61. else item.decision_bucket
  62. )
  63. return {
  64. "aweme_id": item.aweme_id,
  65. "title": item.title,
  66. "content_link": item.content_link,
  67. "author_name": item.author_name,
  68. "author_sec_uid": item.author_sec_uid,
  69. "source_keywords": _load_json(item.source_keywords_json, []),
  70. "source_search_ids": _load_json(item.source_search_ids_json, []),
  71. "tags": _load_json(item.tags_json, []),
  72. "hit_points": _load_json(item.hit_points_json, []),
  73. "play_count": item.play_count,
  74. "like_count": item.like_count,
  75. "comment_count": item.comment_count,
  76. "collect_count": item.collect_count,
  77. "share_count": item.share_count,
  78. "relevance_score": (
  79. float(item.relevance_score) if item.relevance_score is not None else None
  80. ),
  81. "elder_score": float(item.elder_score) if item.elder_score is not None else None,
  82. "share_score": float(item.share_score) if item.share_score is not None else None,
  83. "value_score": float(item.value_score) if item.value_score is not None else None,
  84. "confidence": item.confidence,
  85. "decision_bucket": decision_bucket,
  86. "content_age_evidence": _load_json(item.content_age_evidence_json, {}),
  87. "account_age_evidence": _load_json(item.account_age_evidence_json, {}),
  88. "age_normalization": _load_json(item.age_normalization_json, {}),
  89. "detail_verified": bool(item.detail_verified),
  90. "content_portrait_attempted": bool(item.content_portrait_attempted),
  91. "account_portrait_attempted": bool(item.account_portrait_attempted),
  92. "age_portraits_normalized": bool(item.age_portraits_normalized),
  93. "expansion_worthy_tags": _load_json(item.expansion_worthy_tags_json, []),
  94. "relevance_reason": item.relevance_reason,
  95. "elder_reason": item.elder_reason,
  96. "share_reason": item.share_reason,
  97. "decision_reason": item.decision_reason,
  98. }
  99. @dataclass(frozen=True)
  100. class PublishableCandidate:
  101. """脱离 ORM 的发布候选快照。"""
  102. id: int
  103. aweme_id: str
  104. class VideoDiscoveryService:
  105. """视频发现运行、搜索轨迹与候选的读写入口。"""
  106. def lookup_run(self, run_id: str) -> dict[str, Any] | None:
  107. with get_session() as session:
  108. run = VideoDiscoveryRepository(session).get_run(run_id)
  109. if run is None:
  110. return None
  111. return _serialize_run(run)
  112. def create_run(self, values: dict[str, Any]) -> dict[str, Any]:
  113. with get_session() as session:
  114. VideoDiscoveryRepository(session).create_run(values)
  115. return {
  116. "run_id": str(values["run_id"]),
  117. "status": str(values.get("status") or "running"),
  118. }
  119. def require_run(self, run_id: str) -> dict[str, Any]:
  120. run = self.lookup_run(run_id)
  121. if run is None:
  122. raise RunNotFoundError(f"run_id 不存在: {run_id}")
  123. return run
  124. def save_search_page(
  125. self,
  126. run_id: str,
  127. search_values: dict[str, Any],
  128. candidate_rows: list[dict[str, Any]],
  129. ) -> dict[str, Any]:
  130. with get_session() as session:
  131. repo = VideoDiscoveryRepository(session)
  132. if repo.get_run(run_id) is None:
  133. raise RunNotFoundError(f"run_id 不存在: {run_id}")
  134. search, new_count = repo.save_search_page(search_values, candidate_rows)
  135. return {
  136. "search_id": int(search.id),
  137. "run_id": run_id,
  138. "keyword": search.keyword,
  139. "source_type": search.source_type,
  140. "parent_search_id": search.parent_search_id,
  141. "page_no": search.page_no,
  142. "results_count": search.results_count,
  143. "new_candidate_count": new_count,
  144. "has_more": bool(search.has_more),
  145. "next_cursor": search.next_cursor,
  146. }
  147. def save_evaluations_and_finish(
  148. self,
  149. run_id: str,
  150. rows: list[dict[str, Any]],
  151. *,
  152. status: str,
  153. intent_summary: str | None = None,
  154. stop_reason: str | None = None,
  155. ) -> dict[str, Any]:
  156. with get_session() as session:
  157. repo = VideoDiscoveryRepository(session)
  158. if repo.get_run(run_id) is None:
  159. raise RunNotFoundError(f"run_id 不存在: {run_id}")
  160. if rows:
  161. saved, audit_relevant_changed = repo.save_candidate_evaluations(
  162. run_id, rows
  163. )
  164. else:
  165. saved, audit_relevant_changed = 0, False
  166. run = repo.finish_run(
  167. run_id,
  168. status=status,
  169. intent_summary=intent_summary,
  170. stop_reason=stop_reason,
  171. )
  172. snapshot = _serialize_run(run)
  173. return {
  174. "saved_count": saved,
  175. "audit_relevant_changed": audit_relevant_changed,
  176. **snapshot,
  177. }
  178. def get_full_state(
  179. self,
  180. run_id: str,
  181. *,
  182. include_rejected: bool = True,
  183. limit: int = 100,
  184. ) -> dict[str, Any]:
  185. with get_session() as session:
  186. repo = VideoDiscoveryRepository(session)
  187. run = repo.get_run(run_id)
  188. if run is None:
  189. raise RunNotFoundError(f"run_id 不存在: {run_id}")
  190. searches = repo.list_searches(run_id)
  191. buckets = (
  192. ("primary", "rejected", "pending_evaluation", "unreviewed")
  193. if include_rejected
  194. else ("primary", "pending_evaluation", "unreviewed")
  195. )
  196. candidates = repo.list_candidates(
  197. run_id, buckets=buckets, limit=max(1, min(int(limit), 500))
  198. )
  199. return {
  200. "run": _serialize_run(run),
  201. "searches": [_serialize_search(item) for item in searches],
  202. "candidates": [_serialize_candidate(item) for item in candidates],
  203. }
  204. def get_audit_snapshot(self, run_id: str) -> dict[str, Any]:
  205. with get_session() as session:
  206. repo = VideoDiscoveryRepository(session)
  207. run = repo.get_run(run_id)
  208. if run is None:
  209. raise RunNotFoundError(f"run_id 不存在: {run_id}")
  210. return {
  211. "persisted_run": {
  212. "status": run.status,
  213. "search_count": int(run.search_count or 0),
  214. "primary_count": int(run.primary_count or 0),
  215. },
  216. "searches": [
  217. _serialize_search(item)
  218. for item in repo.list_searches(run_id)
  219. ],
  220. "candidates": [
  221. _serialize_candidate(item)
  222. for item in repo.list_candidates(run_id, limit=500)
  223. ],
  224. }
  225. def prepare_scheduled_run(
  226. self,
  227. *,
  228. biz_dt: str,
  229. demand_grade_id: int,
  230. values: dict[str, Any],
  231. force: bool = False,
  232. ) -> tuple[str | None, str | None]:
  233. """预创建或复用调度运行;返回 (run_id, skip_reason)。"""
  234. with get_session() as session:
  235. repo = VideoDiscoveryRepository(session)
  236. existing = repo.get_by_biz_dt_and_grade(biz_dt, demand_grade_id)
  237. if existing is not None and not force and existing.status in _SKIP_STATUSES:
  238. return None, (
  239. f"biz_dt={biz_dt} demand_grade_id={demand_grade_id} "
  240. f"已执行过 run_id={existing.run_id} status={existing.status}"
  241. )
  242. run_id = existing.run_id if existing is not None else str(values["run_id"])
  243. payload = {**values, "run_id": run_id}
  244. repo.upsert_scheduled_run(payload)
  245. return run_id, None
  246. def list_skip_grade_ids(self, biz_dt: str) -> set[int]:
  247. with get_session() as session:
  248. return VideoDiscoveryRepository(session).list_skip_grade_ids(biz_dt)
  249. def mark_run_failed(self, run_id: str, *, stop_reason: str | None = None) -> None:
  250. with get_session() as session:
  251. VideoDiscoveryRepository(session).mark_run_failed(
  252. run_id,
  253. stop_reason=stop_reason,
  254. )
  255. def count_passed_videos(self, biz_dt: str) -> int:
  256. with get_session() as session:
  257. return VideoDiscoveryRepository(session).count_passed_videos(biz_dt)
  258. def list_publishable_candidates(
  259. self,
  260. *,
  261. run_id: str | None = None,
  262. biz_dt: str | None = None,
  263. skip_published: bool = True,
  264. limit: int | None = None,
  265. ) -> list[PublishableCandidate]:
  266. with get_session() as session:
  267. rows = VideoDiscoveryRepository(session).list_publishable_candidates(
  268. run_id=run_id,
  269. biz_dt=biz_dt,
  270. skip_published=skip_published,
  271. limit=limit,
  272. )
  273. return [
  274. PublishableCandidate(id=int(item.id), aweme_id=str(item.aweme_id))
  275. for item in rows
  276. ]
  277. def mark_candidates_aigc_plans(
  278. self,
  279. candidate_ids: list[int],
  280. *,
  281. crawler_plan_id: str,
  282. produce_plan_id: str,
  283. publish_plan_id: str,
  284. plan_label: str,
  285. ) -> int:
  286. with get_session() as session:
  287. return VideoDiscoveryRepository(session).mark_candidates_aigc_plans(
  288. candidate_ids,
  289. crawler_plan_id=crawler_plan_id,
  290. produce_plan_id=produce_plan_id,
  291. publish_plan_id=publish_plan_id,
  292. plan_label=plan_label,
  293. )
  294. _default_service: VideoDiscoveryService | None = None
  295. def get_video_discovery_service() -> VideoDiscoveryService:
  296. global _default_service
  297. if _default_service is None:
  298. _default_service = VideoDiscoveryService()
  299. return _default_service