video_discovery_service.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358
  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. "rule_version": run.rule_version,
  40. "rule_config": _load_json(run.rule_config_json, {}),
  41. }
  42. def _serialize_search(item: Any) -> dict[str, Any]:
  43. return {
  44. "search_id": int(item.id),
  45. "keyword": item.keyword,
  46. "query_reason": item.query_reason,
  47. "source_type": item.source_type,
  48. "source_value": item.source_value,
  49. "parent_search_id": item.parent_search_id,
  50. "provider": item.provider,
  51. "provider_state": _load_json(item.provider_state_json, {}),
  52. "content_type": item.content_type,
  53. "sort_type": item.sort_type,
  54. "publish_time": item.publish_time,
  55. "cursor": item.cursor,
  56. "page_no": item.page_no,
  57. "results_count": item.results_count,
  58. "new_candidate_count": item.new_candidate_count,
  59. "has_more": bool(item.has_more),
  60. "next_cursor": item.next_cursor,
  61. "status": item.status,
  62. "error_message": item.error_message,
  63. }
  64. def _serialize_candidate(item: Any) -> dict[str, Any]:
  65. decision_bucket = (
  66. "pending_evaluation"
  67. if item.decision_bucket == "unreviewed"
  68. else item.decision_bucket
  69. )
  70. return {
  71. "candidate_id": int(item.id),
  72. "search_id": int(item.search_id) if item.search_id is not None else None,
  73. "aweme_id": item.aweme_id,
  74. "title": item.title,
  75. "content_link": item.content_link,
  76. "author_name": item.author_name,
  77. "author_sec_uid": item.author_sec_uid,
  78. "source_keywords": _load_json(item.source_keywords_json, []),
  79. "source_search_ids": _load_json(item.source_search_ids_json, []),
  80. "tags": _load_json(item.tags_json, []),
  81. "publish_at": item.publish_at.isoformat() if item.publish_at else None,
  82. "duration_seconds": (
  83. float(item.duration_seconds) if item.duration_seconds is not None else None
  84. ),
  85. "play_count": item.play_count,
  86. "like_count": item.like_count,
  87. "comment_count": item.comment_count,
  88. "collect_count": item.collect_count,
  89. "share_count": item.share_count,
  90. "relevance_score": (
  91. float(item.relevance_score) if item.relevance_score is not None else None
  92. ),
  93. "elder_score": float(item.elder_score) if item.elder_score is not None else None,
  94. "share_score": float(item.share_score) if item.share_score is not None else None,
  95. "value_score": float(item.value_score) if item.value_score is not None else None,
  96. "decision_bucket": decision_bucket,
  97. "content_age_evidence": _load_json(item.content_age_evidence_json, {}),
  98. "account_age_evidence": _load_json(item.account_age_evidence_json, {}),
  99. "age_normalization": _load_json(item.age_normalization_json, {}),
  100. "content_50_plus_ratio": (
  101. float(item.content_50_plus_ratio)
  102. if item.content_50_plus_ratio is not None
  103. else None
  104. ),
  105. "content_50_plus_tgi": (
  106. float(item.content_50_plus_tgi)
  107. if item.content_50_plus_tgi is not None
  108. else None
  109. ),
  110. "content_portrait_status": item.content_portrait_status,
  111. "account_50_plus_ratio": (
  112. float(item.account_50_plus_ratio)
  113. if item.account_50_plus_ratio is not None
  114. else None
  115. ),
  116. "account_50_plus_tgi": (
  117. float(item.account_50_plus_tgi)
  118. if item.account_50_plus_tgi is not None
  119. else None
  120. ),
  121. "account_portrait_status": item.account_portrait_status,
  122. "portrait_conflict": bool(item.portrait_conflict),
  123. "temporal_type": item.temporal_type,
  124. "temporal_status": item.temporal_status,
  125. "temporal_evidence": _load_json(item.temporal_evidence_json, {}),
  126. "gate_status": item.gate_status,
  127. "gate_results": _load_json(item.gate_results_json, {}),
  128. "reject_reason_code": item.reject_reason_code,
  129. "rule_version": item.rule_version,
  130. "decision_reason": item.decision_reason,
  131. }
  132. @dataclass(frozen=True)
  133. class PublishableCandidate:
  134. """脱离 ORM 的发布候选快照。"""
  135. id: int
  136. aweme_id: str
  137. class VideoDiscoveryService:
  138. """视频发现运行、搜索轨迹与候选的读写入口。"""
  139. def lookup_run(self, run_id: str) -> dict[str, Any] | None:
  140. with get_session() as session:
  141. run = VideoDiscoveryRepository(session).get_run(run_id)
  142. if run is None:
  143. return None
  144. return _serialize_run(run)
  145. def has_candidates(self, run_id: str) -> bool:
  146. with get_session() as session:
  147. return VideoDiscoveryRepository(session).has_candidates(run_id)
  148. def create_run(self, values: dict[str, Any]) -> dict[str, Any]:
  149. with get_session() as session:
  150. VideoDiscoveryRepository(session).create_run(values)
  151. return {
  152. "run_id": str(values["run_id"]),
  153. "status": str(values.get("status") or "running"),
  154. }
  155. def require_run(self, run_id: str) -> dict[str, Any]:
  156. run = self.lookup_run(run_id)
  157. if run is None:
  158. raise RunNotFoundError(f"run_id 不存在: {run_id}")
  159. return run
  160. def save_search_page(
  161. self,
  162. run_id: str,
  163. search_values: dict[str, Any],
  164. candidate_rows: list[dict[str, Any]],
  165. ) -> dict[str, Any]:
  166. with get_session() as session:
  167. repo = VideoDiscoveryRepository(session)
  168. if repo.get_run(run_id) is None:
  169. raise RunNotFoundError(f"run_id 不存在: {run_id}")
  170. search, candidates = repo.save_search_page(search_values, candidate_rows)
  171. return {
  172. **_serialize_search(search),
  173. "run_id": run_id,
  174. "new_candidate_count": len(candidates),
  175. "candidates": [
  176. _serialize_candidate(candidate) for candidate in candidates
  177. ],
  178. }
  179. def update_candidates(
  180. self,
  181. run_id: str,
  182. rows: list[dict[str, Any]],
  183. ) -> dict[str, Any]:
  184. with get_session() as session:
  185. repo = VideoDiscoveryRepository(session)
  186. if repo.get_run(run_id) is None:
  187. raise RunNotFoundError(f"run_id 不存在: {run_id}")
  188. candidates = repo.update_candidates(run_id, rows)
  189. return {
  190. "updated_count": len(candidates),
  191. "candidates": [
  192. _serialize_candidate(candidate) for candidate in candidates
  193. ],
  194. }
  195. def update_run_status(
  196. self,
  197. run_id: str,
  198. *,
  199. status: str,
  200. intent_summary: str | None = None,
  201. stop_reason: str | None = None,
  202. ) -> dict[str, Any]:
  203. with get_session() as session:
  204. repo = VideoDiscoveryRepository(session)
  205. if repo.get_run(run_id) is None:
  206. raise RunNotFoundError(f"run_id 不存在: {run_id}")
  207. run = repo.finish_run(
  208. run_id,
  209. status=status,
  210. intent_summary=intent_summary,
  211. stop_reason=stop_reason,
  212. )
  213. return _serialize_run(run)
  214. def get_full_state(
  215. self,
  216. run_id: str,
  217. *,
  218. include_rejected: bool = True,
  219. limit: int = 100,
  220. ) -> dict[str, Any]:
  221. with get_session() as session:
  222. repo = VideoDiscoveryRepository(session)
  223. run = repo.get_run(run_id)
  224. if run is None:
  225. raise RunNotFoundError(f"run_id 不存在: {run_id}")
  226. searches = repo.list_searches(run_id)
  227. buckets = (
  228. ("primary", "rejected", "pending_evaluation", "unreviewed")
  229. if include_rejected
  230. else ("primary", "pending_evaluation", "unreviewed")
  231. )
  232. candidates = repo.list_candidates(
  233. run_id, buckets=buckets, limit=max(1, min(int(limit), 500))
  234. )
  235. return {
  236. "run": _serialize_run(run),
  237. "searches": [_serialize_search(item) for item in searches],
  238. "candidates": [_serialize_candidate(item) for item in candidates],
  239. }
  240. def prepare_scheduled_run(
  241. self,
  242. *,
  243. biz_dt: str,
  244. demand_grade_id: int,
  245. values: dict[str, Any],
  246. force: bool = False,
  247. ) -> tuple[str | None, str | None]:
  248. """预创建或复用调度运行;返回 (run_id, skip_reason)。"""
  249. with get_session() as session:
  250. repo = VideoDiscoveryRepository(session)
  251. existing = repo.get_by_biz_dt_and_grade(biz_dt, demand_grade_id)
  252. if existing is not None and not force:
  253. already_done = (
  254. existing.status in _SKIP_STATUSES
  255. or repo.has_candidates(str(existing.run_id))
  256. )
  257. if already_done:
  258. return None, (
  259. f"biz_dt={biz_dt} demand_grade_id={demand_grade_id} "
  260. f"已执行过 run_id={existing.run_id} status={existing.status}"
  261. )
  262. run_id = existing.run_id if existing is not None else str(values["run_id"])
  263. payload = {**values, "run_id": run_id}
  264. repo.upsert_scheduled_run(payload)
  265. return run_id, None
  266. def list_skip_grade_ids(self, biz_dt: str) -> set[int]:
  267. with get_session() as session:
  268. return VideoDiscoveryRepository(session).list_skip_grade_ids(biz_dt)
  269. def mark_run_failed(self, run_id: str, *, stop_reason: str | None = None) -> None:
  270. with get_session() as session:
  271. VideoDiscoveryRepository(session).mark_run_failed(
  272. run_id,
  273. stop_reason=stop_reason,
  274. )
  275. def count_passed_videos(self, biz_dt: str) -> int:
  276. with get_session() as session:
  277. return VideoDiscoveryRepository(session).count_passed_videos(biz_dt)
  278. def list_publishable_candidates(
  279. self,
  280. *,
  281. run_id: str | None = None,
  282. biz_dt: str | None = None,
  283. skip_published: bool = True,
  284. limit: int | None = None,
  285. ) -> list[PublishableCandidate]:
  286. with get_session() as session:
  287. rows = VideoDiscoveryRepository(session).list_publishable_candidates(
  288. run_id=run_id,
  289. biz_dt=biz_dt,
  290. skip_published=skip_published,
  291. limit=limit,
  292. )
  293. return [
  294. PublishableCandidate(id=int(item.id), aweme_id=str(item.aweme_id))
  295. for item in rows
  296. ]
  297. def mark_candidates_aigc_plans(
  298. self,
  299. candidate_ids: list[int],
  300. *,
  301. crawler_plan_id: str,
  302. produce_plan_id: str,
  303. publish_plan_id: str,
  304. plan_label: str,
  305. ) -> int:
  306. with get_session() as session:
  307. return VideoDiscoveryRepository(session).mark_candidates_aigc_plans(
  308. candidate_ids,
  309. crawler_plan_id=crawler_plan_id,
  310. produce_plan_id=produce_plan_id,
  311. publish_plan_id=publish_plan_id,
  312. plan_label=plan_label,
  313. )
  314. _default_service: VideoDiscoveryService | None = None
  315. def get_video_discovery_service() -> VideoDiscoveryService:
  316. global _default_service
  317. if _default_service is None:
  318. _default_service = VideoDiscoveryService()
  319. return _default_service