demand_context.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318
  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(
  115. DemandVideoExpansion.create_time.desc(),
  116. DemandVideoExpansion.id.desc(),
  117. )
  118. ))
  119. video_order, points_by_video = _points_from_expansions(expansions)
  120. if not points_by_video:
  121. source_ids = _json_ids(grade.video_list)
  122. source_points = list(session.scalars(
  123. select(MultiDemandVideoPoint).where(
  124. MultiDemandVideoPoint.video_id.in_(source_ids)
  125. ).order_by(
  126. MultiDemandVideoPoint.video_id,
  127. MultiDemandVideoPoint.create_time.desc(),
  128. MultiDemandVideoPoint.id.desc(),
  129. )
  130. )) if source_ids else []
  131. video_order, points_by_video = _points_from_source_rows(source_ids, source_points)
  132. if not points_by_video:
  133. return None
  134. details = {
  135. str(row.vid): row
  136. for row in session.scalars(select(MultiDemandVideoDetail).where(
  137. MultiDemandVideoDetail.vid.in_(list(points_by_video))
  138. ))
  139. }
  140. videos = [
  141. V2ReferenceVideo(
  142. video_id=video_id,
  143. title=str(details[video_id].title or "").strip() or f"(无标题|{video_id})",
  144. points=points_by_video[video_id],
  145. )
  146. for video_id in video_order
  147. if video_id in points_by_video and video_id in details
  148. ]
  149. if not videos:
  150. return None
  151. return V2DemandContext(
  152. biz_dt=str(grade.biz_dt),
  153. demand_grade_id=int(grade.id),
  154. demand_name=str(grade.demand_name),
  155. grade=str(grade.grade),
  156. score=float(grade.score) if grade.score is not None else None,
  157. videos=videos,
  158. )
  159. def load_v2_demand_context(demand_grade_id: int) -> V2DemandContext:
  160. with get_session() as session:
  161. grade = session.get(DemandGrade, int(demand_grade_id))
  162. if grade is None:
  163. raise LookupError(f"demand_grade_id 不存在: {demand_grade_id}")
  164. context = _load_context_in_session(session, grade)
  165. if context is None:
  166. raise ValueError(f"需求没有可用参考视频和点位: demand_grade_id={demand_grade_id}")
  167. return context
  168. def _latest_demand_grade_query(demand_word: str):
  169. return (
  170. select(DemandGrade)
  171. .where(DemandGrade.demand_name == demand_word)
  172. .order_by(
  173. DemandGrade.biz_dt.desc(),
  174. DemandGrade.create_time.desc(),
  175. DemandGrade.id.desc(),
  176. )
  177. )
  178. def load_latest_v2_demand_context_by_name(demand_word: str) -> V2DemandContext:
  179. """Load the newest exact ``demand_name`` match from demand_grade."""
  180. normalized = str(demand_word or "").strip()
  181. if not normalized:
  182. raise ValueError("需求词不能为空")
  183. with get_session() as session:
  184. grades = list(session.scalars(_latest_demand_grade_query(normalized)))
  185. if not grades:
  186. raise LookupError(f"demand_grade 未找到需求词: {normalized}")
  187. newest = grades[0]
  188. context = _load_context_in_session(session, newest)
  189. if context is not None:
  190. return context
  191. raise ValueError(
  192. "需求的最新记录没有可用参考视频和点位: "
  193. f"demand_name={normalized}, demand_grade_id={newest.id}, biz_dt={newest.biz_dt}"
  194. )
  195. def pick_latest_v2_demand_context(*, index: int = 0) -> V2DemandContext:
  196. with get_session() as session:
  197. latest = session.scalar(select(DemandGrade.biz_dt).where(
  198. DemandGrade.grade == "S"
  199. ).order_by(DemandGrade.biz_dt.desc()).limit(1))
  200. if not latest:
  201. raise LookupError("本地没有 S 级需求")
  202. grades = list(session.scalars(select(DemandGrade).where(
  203. DemandGrade.biz_dt == latest,
  204. DemandGrade.grade == "S",
  205. ).order_by(DemandGrade.score.desc(), DemandGrade.id)))
  206. contexts = [
  207. context
  208. for grade in grades
  209. if (context := _load_context_in_session(session, grade)) is not None
  210. ]
  211. if not 0 <= int(index) < len(contexts):
  212. raise IndexError(f"可用 S 级上下文共 {len(contexts)} 条,index={index} 越界")
  213. return contexts[int(index)]
  214. def build_v2_user_input(
  215. context: V2DemandContext, *, run_id: str, rules: dict[str, Any],
  216. ) -> str:
  217. payload = {
  218. "task": "根据需求语义和参考视频点位,寻找新的高价值抖音候选视频。",
  219. "run_id": run_id,
  220. "demand": {
  221. "biz_dt": context.biz_dt,
  222. "demand_grade_id": context.demand_grade_id,
  223. "demand_word": context.demand_name,
  224. "grade": context.grade,
  225. "score": context.score,
  226. },
  227. "quality_gate_rules": rules,
  228. "reference_videos": [asdict(video) for video in context.videos],
  229. }
  230. return json.dumps(payload, ensure_ascii=False, default=str)
  231. def prepare_v2_demand_run(
  232. *, demand_grade_id: int | None = None, index: int = 0, run_id: str | None = None,
  233. ) -> PreparedV2DemandRun:
  234. context = (
  235. load_v2_demand_context(demand_grade_id)
  236. if demand_grade_id is not None
  237. else pick_latest_v2_demand_context(index=index)
  238. )
  239. run_key = str(run_id or f"local-test-{uuid.uuid4().hex}")[:64]
  240. rules = build_rule_snapshot()
  241. user_input = build_v2_user_input(context, run_id=run_key, rules=rules)
  242. get_find_agent_v2_service().create_run(
  243. run_id=run_key,
  244. user_input=user_input,
  245. demand_word=context.demand_name,
  246. demand_grade_id=context.demand_grade_id,
  247. rule_config=rules,
  248. )
  249. return PreparedV2DemandRun(
  250. run_id=run_key,
  251. demand_grade_id=context.demand_grade_id,
  252. demand_name=context.demand_name,
  253. biz_dt=context.biz_dt,
  254. user_input=user_input,
  255. reference_video_count=len(context.videos),
  256. point_count=context.point_count,
  257. )
  258. def prepare_latest_v2_demand_run_by_name(demand_word: str) -> PreparedV2DemandRun:
  259. """Resolve the newest demand record, build its full context, and create a V2 run."""
  260. context = load_latest_v2_demand_context_by_name(demand_word)
  261. run_key = f"demand-test-{uuid.uuid4().hex}"[:64]
  262. rules = build_rule_snapshot()
  263. user_input = build_v2_user_input(context, run_id=run_key, rules=rules)
  264. get_find_agent_v2_service().create_run(
  265. run_id=run_key,
  266. user_input=user_input,
  267. demand_word=context.demand_name,
  268. demand_grade_id=context.demand_grade_id,
  269. rule_config=rules,
  270. )
  271. return PreparedV2DemandRun(
  272. run_id=run_key,
  273. demand_grade_id=context.demand_grade_id,
  274. demand_name=context.demand_name,
  275. biz_dt=context.biz_dt,
  276. user_input=user_input,
  277. reference_video_count=len(context.videos),
  278. point_count=context.point_count,
  279. )