video_discovery_service.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  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({"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. "content_type": item.content_type,
  51. "sort_type": item.sort_type,
  52. "publish_time": item.publish_time,
  53. "cursor": item.cursor,
  54. "page_no": item.page_no,
  55. "results_count": item.results_count,
  56. "new_candidate_count": item.new_candidate_count,
  57. "has_more": bool(item.has_more),
  58. "next_cursor": item.next_cursor,
  59. "status": item.status,
  60. "error_message": item.error_message,
  61. }
  62. def _serialize_candidate(item: Any) -> dict[str, Any]:
  63. decision_bucket = (
  64. "pending_evaluation"
  65. if item.decision_bucket == "unreviewed"
  66. else item.decision_bucket
  67. )
  68. return {
  69. "candidate_id": int(item.id),
  70. "search_id": int(item.search_id) if item.search_id is not None else None,
  71. "aweme_id": item.aweme_id,
  72. "title": item.title,
  73. "content_link": item.content_link,
  74. "author_name": item.author_name,
  75. "author_sec_uid": item.author_sec_uid,
  76. "source_keywords": _load_json(item.source_keywords_json, []),
  77. "source_search_ids": _load_json(item.source_search_ids_json, []),
  78. "tags": _load_json(item.tags_json, []),
  79. "play_count": item.play_count,
  80. "like_count": item.like_count,
  81. "comment_count": item.comment_count,
  82. "collect_count": item.collect_count,
  83. "share_count": item.share_count,
  84. "relevance_score": (
  85. float(item.relevance_score) if item.relevance_score is not None else None
  86. ),
  87. "elder_score": float(item.elder_score) if item.elder_score is not None else None,
  88. "share_score": float(item.share_score) if item.share_score is not None else None,
  89. "value_score": float(item.value_score) if item.value_score is not None else None,
  90. "decision_bucket": decision_bucket,
  91. "content_age_evidence": _load_json(item.content_age_evidence_json, {}),
  92. "account_age_evidence": _load_json(item.account_age_evidence_json, {}),
  93. "age_normalization": _load_json(item.age_normalization_json, {}),
  94. "decision_reason": item.decision_reason,
  95. }
  96. @dataclass(frozen=True)
  97. class PublishableCandidate:
  98. """脱离 ORM 的发布候选快照。"""
  99. id: int
  100. aweme_id: str
  101. class VideoDiscoveryService:
  102. """视频发现运行、搜索轨迹与候选的读写入口。"""
  103. def lookup_run(self, run_id: str) -> dict[str, Any] | None:
  104. with get_session() as session:
  105. run = VideoDiscoveryRepository(session).get_run(run_id)
  106. if run is None:
  107. return None
  108. return _serialize_run(run)
  109. def has_candidates(self, run_id: str) -> bool:
  110. with get_session() as session:
  111. return VideoDiscoveryRepository(session).has_candidates(run_id)
  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, candidates = repo.save_search_page(search_values, candidate_rows)
  135. return {
  136. **_serialize_search(search),
  137. "run_id": run_id,
  138. "new_candidate_count": len(candidates),
  139. "candidates": [
  140. _serialize_candidate(candidate) for candidate in candidates
  141. ],
  142. }
  143. def update_candidates(
  144. self,
  145. run_id: str,
  146. rows: list[dict[str, Any]],
  147. ) -> dict[str, Any]:
  148. with get_session() as session:
  149. repo = VideoDiscoveryRepository(session)
  150. if repo.get_run(run_id) is None:
  151. raise RunNotFoundError(f"run_id 不存在: {run_id}")
  152. candidates = repo.update_candidates(run_id, rows)
  153. return {
  154. "updated_count": len(candidates),
  155. "candidates": [
  156. _serialize_candidate(candidate) for candidate in candidates
  157. ],
  158. }
  159. def update_run_status(
  160. self,
  161. run_id: str,
  162. *,
  163. status: str,
  164. intent_summary: str | None = None,
  165. stop_reason: str | None = None,
  166. ) -> dict[str, Any]:
  167. with get_session() as session:
  168. repo = VideoDiscoveryRepository(session)
  169. if repo.get_run(run_id) is None:
  170. raise RunNotFoundError(f"run_id 不存在: {run_id}")
  171. run = repo.finish_run(
  172. run_id,
  173. status=status,
  174. intent_summary=intent_summary,
  175. stop_reason=stop_reason,
  176. )
  177. return _serialize_run(run)
  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 prepare_scheduled_run(
  205. self,
  206. *,
  207. biz_dt: str,
  208. demand_grade_id: int,
  209. values: dict[str, Any],
  210. force: bool = False,
  211. ) -> tuple[str | None, str | None]:
  212. """预创建或复用调度运行;返回 (run_id, skip_reason)。"""
  213. with get_session() as session:
  214. repo = VideoDiscoveryRepository(session)
  215. existing = repo.get_by_biz_dt_and_grade(biz_dt, demand_grade_id)
  216. if existing is not None and not force:
  217. already_done = (
  218. existing.status in _SKIP_STATUSES
  219. or repo.has_candidates(str(existing.run_id))
  220. )
  221. if already_done:
  222. return None, (
  223. f"biz_dt={biz_dt} demand_grade_id={demand_grade_id} "
  224. f"已执行过 run_id={existing.run_id} status={existing.status}"
  225. )
  226. run_id = existing.run_id if existing is not None else str(values["run_id"])
  227. payload = {**values, "run_id": run_id}
  228. repo.upsert_scheduled_run(payload)
  229. return run_id, None
  230. def list_skip_grade_ids(self, biz_dt: str) -> set[int]:
  231. with get_session() as session:
  232. return VideoDiscoveryRepository(session).list_skip_grade_ids(biz_dt)
  233. def mark_run_failed(self, run_id: str, *, stop_reason: str | None = None) -> None:
  234. with get_session() as session:
  235. VideoDiscoveryRepository(session).mark_run_failed(
  236. run_id,
  237. stop_reason=stop_reason,
  238. )
  239. def count_passed_videos(self, biz_dt: str) -> int:
  240. with get_session() as session:
  241. return VideoDiscoveryRepository(session).count_passed_videos(biz_dt)
  242. def list_publishable_candidates(
  243. self,
  244. *,
  245. run_id: str | None = None,
  246. biz_dt: str | None = None,
  247. skip_published: bool = True,
  248. limit: int | None = None,
  249. ) -> list[PublishableCandidate]:
  250. with get_session() as session:
  251. rows = VideoDiscoveryRepository(session).list_publishable_candidates(
  252. run_id=run_id,
  253. biz_dt=biz_dt,
  254. skip_published=skip_published,
  255. limit=limit,
  256. )
  257. return [
  258. PublishableCandidate(id=int(item.id), aweme_id=str(item.aweme_id))
  259. for item in rows
  260. ]
  261. def mark_candidates_aigc_plans(
  262. self,
  263. candidate_ids: list[int],
  264. *,
  265. crawler_plan_id: str,
  266. produce_plan_id: str,
  267. publish_plan_id: str,
  268. plan_label: str,
  269. ) -> int:
  270. with get_session() as session:
  271. return VideoDiscoveryRepository(session).mark_candidates_aigc_plans(
  272. candidate_ids,
  273. crawler_plan_id=crawler_plan_id,
  274. produce_plan_id=produce_plan_id,
  275. publish_plan_id=publish_plan_id,
  276. plan_label=plan_label,
  277. )
  278. _default_service: VideoDiscoveryService | None = None
  279. def get_video_discovery_service() -> VideoDiscoveryService:
  280. global _default_service
  281. if _default_service is None:
  282. _default_service = VideoDiscoveryService()
  283. return _default_service