find_agent_v2.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  1. """Admin-facing read model and execution service for find_agent_v2."""
  2. from __future__ import annotations
  3. import json
  4. from datetime import datetime, timedelta
  5. from typing import Any
  6. from zoneinfo import ZoneInfo
  7. from sqlalchemy import func, or_, select, update
  8. from find_agent_v2.demand_context import prepare_latest_v2_demand_run_by_name
  9. from find_agent_v2.models import (
  10. FindAgentV2Candidate,
  11. FindAgentV2Evidence,
  12. FindAgentV2Round,
  13. FindAgentV2Run,
  14. FindAgentV2Search,
  15. )
  16. from find_agent_v2.runner import run_prepared_find_agent_v2
  17. from find_agent_v2.service import (
  18. RUN_TIMEOUT_MINUTES,
  19. RUN_TIMEOUT_REASON,
  20. get_find_agent_v2_service,
  21. )
  22. from supply_infra.db.session import get_session
  23. def _expire_overdue_runs(session, *, now: datetime | None = None) -> int:
  24. current = now or datetime.now(ZoneInfo("Asia/Shanghai")).replace(tzinfo=None)
  25. cutoff = current - timedelta(minutes=RUN_TIMEOUT_MINUTES)
  26. result = session.execute(
  27. update(FindAgentV2Run)
  28. .where(
  29. FindAgentV2Run.status == "running",
  30. FindAgentV2Run.create_time < cutoff,
  31. )
  32. .values(
  33. status="failed",
  34. outcome_status="failed",
  35. stop_reason=RUN_TIMEOUT_REASON,
  36. update_time=current,
  37. )
  38. )
  39. return int(result.rowcount or 0)
  40. def _json(raw: str | None, default: Any) -> Any:
  41. try:
  42. return json.loads(raw) if raw else default
  43. except (TypeError, ValueError):
  44. return default
  45. def _time(value: Any) -> str | None:
  46. return value.isoformat() if value is not None else None
  47. def _number(value: Any) -> float | None:
  48. return float(value) if value is not None else None
  49. def _run(row: FindAgentV2Run) -> dict[str, Any]:
  50. return {
  51. "id": int(row.id),
  52. "run_id": row.run_id,
  53. "demand_grade_id": row.demand_grade_id,
  54. "demand_word": row.demand_word,
  55. "status": row.status,
  56. "outcome_status": row.outcome_status,
  57. "current_round": row.current_round,
  58. "search_count": row.search_count,
  59. "candidate_count": row.candidate_count,
  60. "valid_primary_count": row.valid_primary_count,
  61. "input_tokens": row.input_tokens,
  62. "output_tokens": row.output_tokens,
  63. "total_tokens": row.total_tokens,
  64. "cost_usd": _number(row.cost_usd) or 0,
  65. "intent_summary": row.intent_summary,
  66. "stop_reason": row.stop_reason,
  67. "obagent_run_uid": row.obagent_run_uid,
  68. "triggered_by_user_id": row.triggered_by_user_id,
  69. "triggered_by_username": row.triggered_by_username,
  70. "trigger_source": row.trigger_source,
  71. "create_time": _time(row.create_time),
  72. "update_time": _time(row.update_time),
  73. }
  74. def _evidence_item(row: FindAgentV2Evidence) -> dict[str, Any]:
  75. return {
  76. "id": int(row.id), "candidate_id": row.candidate_id,
  77. "evidence_type": row.evidence_type, "provider": row.provider,
  78. "status": row.status, "raw": _json(row.raw_json, {}),
  79. "normalized": _json(row.normalized_json, {}),
  80. "error_message": row.error_message, "create_time": _time(row.create_time),
  81. }
  82. def _candidate_item(
  83. row: FindAgentV2Candidate, evidence: list[dict[str, Any]] | None = None,
  84. ) -> dict[str, Any]:
  85. return {
  86. "id": int(row.id), "first_search_id": row.first_search_id,
  87. "aweme_id": row.aweme_id, "title": row.title, "content_link": row.content_link,
  88. "author_name": row.author_name, "author_sec_uid": row.author_sec_uid,
  89. "source_keywords": _json(row.source_keywords_json, []), "tags": _json(row.tags_json, []),
  90. "publish_at": _time(row.publish_at), "duration_seconds": _number(row.duration_seconds),
  91. "play_count": row.play_count, "like_count": row.like_count,
  92. "comment_count": row.comment_count, "collect_count": row.collect_count,
  93. "share_count": row.share_count, "detail": _json(row.detail_json, {}),
  94. "portrait": _json(row.portrait_json, {}), "detail_status": row.detail_status,
  95. "portrait_status": row.portrait_status,
  96. "content_50_plus_ratio": _number(row.content_50_plus_ratio),
  97. "account_50_plus_ratio": _number(row.account_50_plus_ratio),
  98. "relevance_score": _number(row.relevance_score), "elder_score": _number(row.elder_score),
  99. "share_score": _number(row.share_score), "value_score": _number(row.value_score),
  100. "gate_status": row.gate_status, "gate_result": _json(row.gate_result_json, {}),
  101. "decision_bucket": row.decision_bucket, "decision_reason": row.decision_reason,
  102. "reject_reason_code": row.reject_reason_code, "evidence": evidence or [],
  103. "create_time": _time(row.create_time), "update_time": _time(row.update_time),
  104. }
  105. def _candidate_rows_with_evidence(session, query) -> list[dict[str, Any]]:
  106. rows = list(session.scalars(query))
  107. ids = [int(row.id) for row in rows]
  108. evidence_rows = list(session.scalars(
  109. select(FindAgentV2Evidence).where(FindAgentV2Evidence.candidate_id.in_(ids))
  110. .order_by(FindAgentV2Evidence.id)
  111. )) if ids else []
  112. evidence_by_candidate: dict[int, list[dict[str, Any]]] = {}
  113. for row in evidence_rows:
  114. if row.candidate_id is not None:
  115. evidence_by_candidate.setdefault(int(row.candidate_id), []).append(
  116. _evidence_item(row)
  117. )
  118. return [
  119. _candidate_item(row, evidence_by_candidate.get(int(row.id), []))
  120. for row in rows
  121. ]
  122. def list_runs(
  123. *, status: str | None = None, keyword: str | None = None,
  124. page: int = 1, page_size: int = 30,
  125. ) -> dict[str, Any]:
  126. normalized_page = max(1, int(page))
  127. normalized_page_size = min(100, max(1, int(page_size)))
  128. offset = (normalized_page - 1) * normalized_page_size
  129. with get_session() as session:
  130. _expire_overdue_runs(session)
  131. conditions = []
  132. if status:
  133. conditions.append(FindAgentV2Run.status == status)
  134. normalized = str(keyword or "").strip().lower()
  135. if normalized:
  136. pattern = f"%{normalized}%"
  137. conditions.append(or_(
  138. func.lower(FindAgentV2Run.demand_word).like(pattern),
  139. func.lower(FindAgentV2Run.run_id).like(pattern),
  140. func.lower(func.coalesce(FindAgentV2Run.triggered_by_username, "")).like(pattern),
  141. ))
  142. total = int(session.scalar(
  143. select(func.count()).select_from(FindAgentV2Run).where(*conditions)
  144. ) or 0)
  145. rows = list(session.scalars(
  146. select(FindAgentV2Run).where(*conditions)
  147. .order_by(FindAgentV2Run.create_time.desc(), FindAgentV2Run.id.desc())
  148. .limit(normalized_page_size).offset(offset)
  149. ))
  150. total_pages = max(1, (total + normalized_page_size - 1) // normalized_page_size)
  151. return {
  152. "items": [_run(row) for row in rows],
  153. "total": total,
  154. "page": normalized_page,
  155. "page_size": normalized_page_size,
  156. "total_pages": total_pages,
  157. "has_previous": normalized_page > 1,
  158. "has_next": normalized_page < total_pages,
  159. }
  160. def get_run_detail(run_id: str) -> dict[str, Any] | None:
  161. with get_session() as session:
  162. _expire_overdue_runs(session)
  163. run = session.scalar(select(FindAgentV2Run).where(FindAgentV2Run.run_id == run_id))
  164. if run is None:
  165. return None
  166. rounds = list(session.scalars(
  167. select(FindAgentV2Round).where(FindAgentV2Round.run_id == run_id)
  168. .order_by(FindAgentV2Round.round_index, FindAgentV2Round.id)
  169. ))
  170. searches = list(session.scalars(
  171. select(FindAgentV2Search).where(FindAgentV2Search.run_id == run_id)
  172. .order_by(FindAgentV2Search.round_index, FindAgentV2Search.id)
  173. ))
  174. candidate_summaries = list(session.execute(select(
  175. FindAgentV2Candidate.aweme_id, FindAgentV2Candidate.decision_bucket,
  176. ).where(FindAgentV2Candidate.run_id == run_id)))
  177. round_items = [{
  178. "id": int(row.id), "round_index": row.round_index, "phase": row.phase,
  179. "status": row.status, "plan": _json(row.plan_json, {}),
  180. "start_snapshot": _json(row.start_snapshot_json, {}),
  181. "end_snapshot": _json(row.end_snapshot_json, {}),
  182. "error_message": row.error_message, "create_time": _time(row.create_time),
  183. "update_time": _time(row.update_time),
  184. } for row in rounds]
  185. search_items = [{
  186. "id": int(row.id), "round_index": row.round_index, "keyword": row.keyword,
  187. "query_reason": row.query_reason, "source_type": row.source_type,
  188. "provider": row.provider, "cursor": row.cursor, "page_no": row.page_no,
  189. "has_more": bool(row.has_more), "next_cursor": row.next_cursor,
  190. "result_count": row.result_count, "status": row.status,
  191. "error_message": row.error_message,
  192. "create_time": _time(row.create_time),
  193. } for row in searches]
  194. bucket_by_aweme = {
  195. str(aweme_id): str(bucket) for aweme_id, bucket in candidate_summaries
  196. }
  197. search_rows_by_id = {int(row.id): row for row in searches}
  198. for search in search_items:
  199. row = search_rows_by_id[search["id"]]
  200. raw_results = _json(row.raw_response_json, {}).get("search_results", [])
  201. result_ids = list(dict.fromkeys(
  202. str(item.get("aweme_id"))
  203. for item in raw_results
  204. if isinstance(item, dict) and item.get("aweme_id")
  205. ))
  206. buckets = [bucket_by_aweme.get(item, "pending_evaluation") for item in result_ids]
  207. search["video_count"] = len(result_ids)
  208. search["primary_count"] = sum(item == "primary" for item in buckets)
  209. search["rejected_count"] = sum(item == "rejected" for item in buckets)
  210. search["pending_count"] = (
  211. search["video_count"] - search["primary_count"] - search["rejected_count"]
  212. )
  213. timeline = []
  214. for row in round_items:
  215. timeline.append({"type": "round", "time": row["create_time"], "data": row})
  216. for row in search_items:
  217. timeline.append({"type": "search", "time": row["create_time"], "data": row})
  218. timeline.sort(key=lambda item: str(item.get("time") or ""))
  219. return {
  220. "run": {**_run(run), "input": _json(run.input_json, {}),
  221. "rule_config": _json(run.rule_config_json, {})},
  222. "rounds": round_items, "searches": search_items, "timeline": timeline,
  223. }
  224. def get_run_candidates(run_id: str) -> list[dict[str, Any]] | None:
  225. with get_session() as session:
  226. if session.scalar(select(FindAgentV2Run.id).where(FindAgentV2Run.run_id == run_id)) is None:
  227. return None
  228. return _candidate_rows_with_evidence(session, select(FindAgentV2Candidate).where(
  229. FindAgentV2Candidate.run_id == run_id,
  230. ).order_by(FindAgentV2Candidate.id))
  231. def get_search_candidates(run_id: str, search_id: int) -> list[dict[str, Any]] | None:
  232. with get_session() as session:
  233. search = session.scalar(select(FindAgentV2Search).where(
  234. FindAgentV2Search.run_id == run_id, FindAgentV2Search.id == int(search_id),
  235. ))
  236. if search is None:
  237. return None
  238. raw_results = _json(search.raw_response_json, {}).get("search_results", [])
  239. aweme_ids = list(dict.fromkeys(
  240. str(item.get("aweme_id")) for item in raw_results
  241. if isinstance(item, dict) and item.get("aweme_id")
  242. ))
  243. if not aweme_ids:
  244. return []
  245. items = _candidate_rows_with_evidence(session, select(FindAgentV2Candidate).where(
  246. FindAgentV2Candidate.run_id == run_id,
  247. FindAgentV2Candidate.aweme_id.in_(aweme_ids),
  248. ))
  249. by_aweme = {item["aweme_id"]: item for item in items}
  250. return [by_aweme[item] for item in aweme_ids if item in by_aweme]
  251. def prepare_test_run(demand_word: str, current_user: dict[str, Any]) -> dict[str, Any]:
  252. prepared = prepare_latest_v2_demand_run_by_name(
  253. demand_word,
  254. triggered_by_user_id=int(current_user["id"]),
  255. triggered_by_username=str(current_user["username"]),
  256. trigger_source="web_admin",
  257. )
  258. return prepared.summary()
  259. def execute_test_run(run_id: str) -> None:
  260. """Background task entry point. Always persist bootstrap failures."""
  261. try:
  262. run_prepared_find_agent_v2(run_id)
  263. except Exception as exc:
  264. get_find_agent_v2_service().fail_run(run_id, f"{type(exc).__name__}: {exc}")