demand_context.py 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256
  1. """Build runnable v2 test contexts directly from generic demand data tables."""
  2. from __future__ import annotations
  3. import json
  4. import uuid
  5. from dataclasses import asdict, dataclass, field
  6. from typing import Any
  7. from sqlalchemy import select
  8. from find_agent_v2.gates import build_rule_snapshot
  9. from find_agent_v2.service import get_find_agent_v2_service
  10. from supply_infra.db.models.demand_grade import DemandGrade
  11. from supply_infra.db.models.demand_video_expansion import DemandVideoExpansion
  12. from supply_infra.db.models.multi_demand_video_detail import MultiDemandVideoDetail
  13. from supply_infra.db.models.multi_demand_video_point import MultiDemandVideoPoint
  14. from supply_infra.db.session import get_session
  15. _POINT_TYPES = {"inspiration", "purpose", "key"}
  16. @dataclass(frozen=True)
  17. class V2ReferencePoint:
  18. point: str
  19. point_type: str
  20. point_desc: str | None = None
  21. @dataclass
  22. class V2ReferenceVideo:
  23. video_id: str
  24. title: str
  25. points: list[V2ReferencePoint] = field(default_factory=list)
  26. @dataclass
  27. class V2DemandContext:
  28. biz_dt: str
  29. demand_grade_id: int
  30. demand_name: str
  31. grade: str
  32. score: float | None
  33. videos: list[V2ReferenceVideo] = field(default_factory=list)
  34. @property
  35. def point_count(self) -> int:
  36. return sum(len(video.points) for video in self.videos)
  37. def payload(self) -> dict[str, Any]:
  38. return {
  39. "biz_dt": self.biz_dt,
  40. "demand_grade_id": self.demand_grade_id,
  41. "demand_name": self.demand_name,
  42. "grade": self.grade,
  43. "score": self.score,
  44. "reference_videos": [asdict(video) for video in self.videos],
  45. }
  46. @dataclass(frozen=True)
  47. class PreparedV2DemandRun:
  48. run_id: str
  49. demand_grade_id: int
  50. demand_name: str
  51. biz_dt: str
  52. user_input: str
  53. reference_video_count: int
  54. point_count: int
  55. def summary(self) -> dict[str, Any]:
  56. return {
  57. "run_id": self.run_id,
  58. "demand_grade_id": self.demand_grade_id,
  59. "demand_name": self.demand_name,
  60. "biz_dt": self.biz_dt,
  61. "reference_video_count": self.reference_video_count,
  62. "point_count": self.point_count,
  63. }
  64. def _json_ids(raw: str | None) -> list[str]:
  65. try:
  66. value = json.loads(raw or "[]")
  67. except (TypeError, ValueError):
  68. return []
  69. if not isinstance(value, list):
  70. return []
  71. return list(dict.fromkeys(str(item).strip() for item in value if str(item).strip()))
  72. def _points_from_expansions(rows: list[Any]) -> tuple[list[str], dict[str, list[V2ReferencePoint]]]:
  73. order: list[str] = []
  74. result: dict[str, list[V2ReferencePoint]] = {}
  75. seen: set[tuple[str, str, str]] = set()
  76. for row in rows:
  77. video_id = str(row.video_id or "").strip()
  78. point_type = str(row.point_type or "").strip()
  79. point = str(row.expanded_text or "").strip()
  80. key = (video_id, point_type, point)
  81. if not video_id or point_type not in _POINT_TYPES or not point or key in seen:
  82. continue
  83. seen.add(key)
  84. if video_id not in result:
  85. order.append(video_id)
  86. result[video_id] = []
  87. result[video_id].append(V2ReferencePoint(
  88. point=point,
  89. point_type=point_type,
  90. point_desc=str(row.point_desc or "").strip() or None,
  91. ))
  92. return order, result
  93. def _points_from_source_rows(
  94. video_ids: list[str], rows: list[Any],
  95. ) -> tuple[list[str], dict[str, list[V2ReferencePoint]]]:
  96. result: dict[str, list[V2ReferencePoint]] = {video_id: [] for video_id in video_ids}
  97. for row in rows:
  98. video_id = str(row.video_id or "").strip()
  99. point_type = str(row.point_type or "").strip()
  100. point = str(row.point_data or "").strip()
  101. if video_id in result and point_type in _POINT_TYPES and point:
  102. result[video_id].append(V2ReferencePoint(
  103. point=point,
  104. point_type=point_type,
  105. point_desc=str(row.point_desc or "").strip() or None,
  106. ))
  107. return video_ids, {key: value for key, value in result.items() if value}
  108. def _load_context_in_session(session, grade: DemandGrade) -> V2DemandContext | None:
  109. expansions = list(session.scalars(
  110. select(DemandVideoExpansion).where(
  111. DemandVideoExpansion.biz_dt == grade.biz_dt,
  112. DemandVideoExpansion.source_demand_grade_id == grade.id,
  113. DemandVideoExpansion.is_delete == 0,
  114. ).order_by(DemandVideoExpansion.id)
  115. ))
  116. video_order, points_by_video = _points_from_expansions(expansions)
  117. if not points_by_video:
  118. source_ids = _json_ids(grade.video_list)
  119. source_points = list(session.scalars(
  120. select(MultiDemandVideoPoint).where(
  121. MultiDemandVideoPoint.video_id.in_(source_ids)
  122. ).order_by(MultiDemandVideoPoint.video_id, MultiDemandVideoPoint.id)
  123. )) if source_ids else []
  124. video_order, points_by_video = _points_from_source_rows(source_ids, source_points)
  125. if not points_by_video:
  126. return None
  127. details = {
  128. str(row.vid): row
  129. for row in session.scalars(select(MultiDemandVideoDetail).where(
  130. MultiDemandVideoDetail.vid.in_(list(points_by_video))
  131. ))
  132. }
  133. videos = [
  134. V2ReferenceVideo(
  135. video_id=video_id,
  136. title=str(details[video_id].title or "").strip() or f"(无标题|{video_id})",
  137. points=points_by_video[video_id],
  138. )
  139. for video_id in video_order
  140. if video_id in points_by_video and video_id in details
  141. ]
  142. if not videos:
  143. return None
  144. return V2DemandContext(
  145. biz_dt=str(grade.biz_dt),
  146. demand_grade_id=int(grade.id),
  147. demand_name=str(grade.demand_name),
  148. grade=str(grade.grade),
  149. score=float(grade.score) if grade.score is not None else None,
  150. videos=videos,
  151. )
  152. def load_v2_demand_context(demand_grade_id: int) -> V2DemandContext:
  153. with get_session() as session:
  154. grade = session.get(DemandGrade, int(demand_grade_id))
  155. if grade is None:
  156. raise LookupError(f"demand_grade_id 不存在: {demand_grade_id}")
  157. context = _load_context_in_session(session, grade)
  158. if context is None:
  159. raise ValueError(f"需求没有可用参考视频和点位: demand_grade_id={demand_grade_id}")
  160. return context
  161. def pick_latest_v2_demand_context(*, index: int = 0) -> V2DemandContext:
  162. with get_session() as session:
  163. latest = session.scalar(select(DemandGrade.biz_dt).where(
  164. DemandGrade.grade == "S"
  165. ).order_by(DemandGrade.biz_dt.desc()).limit(1))
  166. if not latest:
  167. raise LookupError("本地没有 S 级需求")
  168. grades = list(session.scalars(select(DemandGrade).where(
  169. DemandGrade.biz_dt == latest,
  170. DemandGrade.grade == "S",
  171. ).order_by(DemandGrade.score.desc(), DemandGrade.id)))
  172. contexts = [
  173. context
  174. for grade in grades
  175. if (context := _load_context_in_session(session, grade)) is not None
  176. ]
  177. if not 0 <= int(index) < len(contexts):
  178. raise IndexError(f"可用 S 级上下文共 {len(contexts)} 条,index={index} 越界")
  179. return contexts[int(index)]
  180. def build_v2_user_input(
  181. context: V2DemandContext, *, run_id: str, rules: dict[str, Any],
  182. ) -> str:
  183. payload = {
  184. "task": "根据需求语义和参考视频点位,寻找新的高价值抖音候选视频。",
  185. "run_id": run_id,
  186. "demand": {
  187. "biz_dt": context.biz_dt,
  188. "demand_grade_id": context.demand_grade_id,
  189. "demand_word": context.demand_name,
  190. "grade": context.grade,
  191. "score": context.score,
  192. },
  193. "quality_gate_rules": rules,
  194. "reference_videos": [asdict(video) for video in context.videos],
  195. }
  196. return json.dumps(payload, ensure_ascii=False, default=str)
  197. def prepare_v2_demand_run(
  198. *, demand_grade_id: int | None = None, index: int = 0, run_id: str | None = None,
  199. ) -> PreparedV2DemandRun:
  200. context = (
  201. load_v2_demand_context(demand_grade_id)
  202. if demand_grade_id is not None
  203. else pick_latest_v2_demand_context(index=index)
  204. )
  205. run_key = str(run_id or f"local-test-{uuid.uuid4().hex}")[:64]
  206. rules = build_rule_snapshot()
  207. user_input = build_v2_user_input(context, run_id=run_key, rules=rules)
  208. get_find_agent_v2_service().create_run(
  209. run_id=run_key,
  210. user_input=user_input,
  211. demand_word=context.demand_name,
  212. demand_grade_id=context.demand_grade_id,
  213. rule_config=rules,
  214. )
  215. return PreparedV2DemandRun(
  216. run_id=run_key,
  217. demand_grade_id=context.demand_grade_id,
  218. demand_name=context.demand_name,
  219. biz_dt=context.biz_dt,
  220. user_input=user_input,
  221. reference_video_count=len(context.videos),
  222. point_count=context.point_count,
  223. )