video_discovery.py 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. """Demand-first video discovery data for the web workspace."""
  2. from __future__ import annotations
  3. import json
  4. from typing import Any
  5. from supply_infra.db.repositories.demand_grade_repo import DemandGradeRepository
  6. from supply_infra.db.repositories.demand_video_expansion_repo import (
  7. DemandVideoExpansionRepository,
  8. )
  9. from supply_infra.db.repositories.global_tree_category_repo import (
  10. GlobalTreeCategoryRepository,
  11. )
  12. from supply_infra.db.repositories.multi_demand_video_detail_repo import (
  13. MultiDemandVideoDetailRepository,
  14. )
  15. from supply_infra.db.session import get_session
  16. _POINT_TYPES = {"inspiration", "purpose", "key"}
  17. def _parse_string_list(raw: Any) -> list[str]:
  18. """Parse JSON arrays and legacy comma-separated text into a stable string list."""
  19. if raw is None:
  20. return []
  21. values: list[Any]
  22. if isinstance(raw, str):
  23. text = raw.strip()
  24. if not text:
  25. return []
  26. try:
  27. parsed = json.loads(text)
  28. except (TypeError, ValueError):
  29. parsed = None
  30. if isinstance(parsed, list):
  31. values = parsed
  32. elif parsed is not None:
  33. values = [parsed]
  34. else:
  35. values = [part.strip() for part in text.split(",")]
  36. elif isinstance(raw, (list, tuple)):
  37. values = list(raw)
  38. else:
  39. values = [raw]
  40. result: list[str] = []
  41. seen: set[str] = set()
  42. for value in values:
  43. text = str(value).strip() if value is not None else ""
  44. if text and text not in seen:
  45. seen.add(text)
  46. result.append(text)
  47. return result
  48. def _parse_int_list(raw: Any) -> list[int]:
  49. result: list[int] = []
  50. for value in _parse_string_list(raw):
  51. try:
  52. result.append(int(value))
  53. except ValueError:
  54. continue
  55. return result
  56. def _number(value: Any) -> float | None:
  57. return float(value) if value is not None else None
  58. def _serialize_demand(
  59. row: Any,
  60. category_names: dict[int, str],
  61. video_count: int,
  62. ) -> dict[str, Any]:
  63. category_ids = _parse_int_list(row.category_ids)
  64. return {
  65. "id": int(row.id),
  66. "demand_name": row.demand_name,
  67. "biz_dt": str(row.biz_dt),
  68. "grade": row.grade,
  69. "score": _number(row.score),
  70. "prior_total_score": _number(row.prior_total_score),
  71. "posterior_rov_avg": _number(row.posterior_rov_avg),
  72. "posterior_rov_count": int(row.posterior_rov_count or 0),
  73. "has_posterior": bool(row.has_posterior),
  74. "category_ids": category_ids,
  75. "category_names": [
  76. category_names[category_id]
  77. for category_id in category_ids
  78. if category_id in category_names
  79. ],
  80. "strategies": _parse_string_list(row.strategies),
  81. "reason": row.reason,
  82. "video_count": video_count,
  83. }
  84. def _category_name_map(session: Any, rows: list[Any]) -> dict[int, str]:
  85. category_ids = sorted(
  86. {
  87. category_id
  88. for row in rows
  89. for category_id in _parse_int_list(row.category_ids)
  90. }
  91. )
  92. categories = GlobalTreeCategoryRepository(session).list_active_by_ids(category_ids)
  93. return {
  94. int(category.id): str(category.name)
  95. for category in categories
  96. if category.name
  97. }
  98. def list_video_discovery_demands(biz_dt: str | None = None) -> dict[str, Any]:
  99. """Return one demand card per demand_grade row for the selected/latest day."""
  100. with get_session() as session:
  101. grade_repo = DemandGradeRepository(session)
  102. resolved_biz_dt = biz_dt or grade_repo.get_latest_biz_dt()
  103. if not resolved_biz_dt:
  104. return {"biz_dt": None, "items": []}
  105. rows = grade_repo.list_by_biz_dt(str(resolved_biz_dt))
  106. category_names = _category_name_map(session, rows)
  107. video_counts = DemandVideoExpansionRepository(
  108. session
  109. ).count_distinct_videos_by_demand_grade(str(resolved_biz_dt))
  110. return {
  111. "biz_dt": str(resolved_biz_dt),
  112. "items": [
  113. _serialize_demand(
  114. row,
  115. category_names,
  116. video_counts.get(int(row.id), 0),
  117. )
  118. for row in rows
  119. ],
  120. }
  121. def get_video_discovery_demand(demand_grade_id: int) -> dict[str, Any] | None:
  122. """
  123. Resolve the judged video list and hit evidence from demand_video_expansion.
  124. multi_demand_video_detail contributes only the title and no other field.
  125. """
  126. with get_session() as session:
  127. grade = DemandGradeRepository(session).get_by_id(demand_grade_id)
  128. if grade is None:
  129. return None
  130. expansions = DemandVideoExpansionRepository(session).list_by_demand_grade(
  131. str(grade.biz_dt),
  132. demand_grade_id,
  133. )
  134. category_names = _category_name_map(session, [grade])
  135. video_ids: list[str] = []
  136. points_by_video: dict[str, list[dict[str, Any]]] = {}
  137. for row in expansions:
  138. video_id = str(row.video_id).strip()
  139. if not video_id or row.point_type not in _POINT_TYPES:
  140. continue
  141. if video_id not in points_by_video:
  142. video_ids.append(video_id)
  143. points_by_video[video_id] = []
  144. points_by_video[video_id].append(
  145. {
  146. "point_type": row.point_type,
  147. "expanded_text": row.expanded_text,
  148. "point_desc": row.point_desc,
  149. "reason": row.reason,
  150. }
  151. )
  152. details = MultiDemandVideoDetailRepository(session).list_by_vids(video_ids)
  153. videos: list[dict[str, Any]] = []
  154. for video_id in video_ids:
  155. points = points_by_video[video_id]
  156. detail = details.get(video_id)
  157. videos.append(
  158. {
  159. "vid": video_id,
  160. "title": detail.title if detail else None,
  161. "point_count": len(points),
  162. "points": points,
  163. }
  164. )
  165. demand = _serialize_demand(grade, category_names, len(videos))
  166. demand["point_count"] = sum(video["point_count"] for video in videos)
  167. demand["videos"] = videos
  168. return demand