video_discovery.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329
  1. """持久化 find_agent 的搜索轨迹、候选证据和分池结果。"""
  2. from __future__ import annotations
  3. import json
  4. import logging
  5. import uuid
  6. from decimal import Decimal
  7. from typing import Any
  8. from supply_infra.services.video_discovery_service import (
  9. RunNotFoundError,
  10. format_db_error,
  11. get_video_discovery_service,
  12. )
  13. logger = logging.getLogger(__name__)
  14. _RUN_STATUSES = {"running", "finished", "failed"}
  15. _FINAL_DECISION_BUCKETS = {"primary", "rejected"}
  16. def _json(value: Any) -> str:
  17. return json.dumps(value, ensure_ascii=False, default=str)
  18. def _input_error(message: str) -> str:
  19. return _json({"error": message, "input_error": True})
  20. def _clean_text(value: Any, *, max_length: int | None = None) -> str | None:
  21. if value is None:
  22. return None
  23. text = str(value).strip()
  24. if not text:
  25. return None
  26. return text[:max_length] if max_length else text
  27. def _nonnegative_int(value: Any) -> int | None:
  28. if value is None or value == "":
  29. return None
  30. try:
  31. return max(0, int(value))
  32. except (TypeError, ValueError):
  33. return None
  34. def _optional_decimal(value: Any, places: int) -> Decimal | None:
  35. if value is None or value == "":
  36. return None
  37. try:
  38. number = Decimal(str(value))
  39. except (ArithmeticError, TypeError, ValueError):
  40. return None
  41. quantum = Decimal(1).scaleb(-places)
  42. return number.quantize(quantum)
  43. def create_video_discovery_run(
  44. demand_word: str,
  45. relevant_points: list[dict[str, Any]],
  46. seed_video_id: str | None = None,
  47. seed_video_title: str | None = None,
  48. demand_grade_id: int | None = None,
  49. intent_summary: str | None = None,
  50. run_id: str | None = None,
  51. ) -> str:
  52. """
  53. 创建一次可追踪的视频发现运行。
  54. 若用户消息已提供预创建 run_id,必须原样传入 run_id;工具会复用已有记录,
  55. 不会重复创建。
  56. Args:
  57. demand_word: 用户给定需求词;它是输入语义,不强制作为实际搜索词。
  58. relevant_points: 参考视频中与需求相关的点位对象列表。
  59. seed_video_id: 兼容旧调用方的可选参考视频 id;调度调用不传。
  60. seed_video_title: 兼容旧调用方的可选参考视频标题;调度调用不传。
  61. demand_grade_id: 可选 demand_grade.id。
  62. intent_summary: Agent 对真正受欢迎内容的初步解释,可稍后更新。
  63. run_id: 系统预创建的运行 id;传入已存在记录时直接复用。
  64. Returns:
  65. JSON,包含后续存储工具必须使用的 run_id。
  66. """
  67. service = get_video_discovery_service()
  68. cleaned_run_id = _clean_text(run_id, max_length=64)
  69. if cleaned_run_id:
  70. try:
  71. existing = service.lookup_run(cleaned_run_id)
  72. if existing is not None:
  73. return _json(
  74. {
  75. "title": "视频发现运行已存在",
  76. "run_id": cleaned_run_id,
  77. "status": existing["status"],
  78. "pre_created": True,
  79. "output": f"run_id={cleaned_run_id}",
  80. }
  81. )
  82. except Exception as exc:
  83. logger.error("create_video_discovery_run lookup failed: %s", exc, exc_info=True)
  84. return _json({"error": format_db_error(exc), "title": "查询视频发现运行失败"})
  85. demand = _clean_text(demand_word, max_length=256)
  86. if not demand:
  87. return _input_error("demand_word 不能为空")
  88. new_run_id = cleaned_run_id or uuid.uuid4().hex
  89. values = {
  90. "run_id": new_run_id,
  91. "demand_grade_id": demand_grade_id,
  92. "demand_word": demand,
  93. "seed_video_id": _clean_text(seed_video_id, max_length=64),
  94. "seed_video_title": _clean_text(seed_video_title, max_length=512),
  95. "relevant_points_json": _json(relevant_points or []),
  96. "intent_summary": _clean_text(intent_summary),
  97. "status": "running",
  98. }
  99. try:
  100. created = service.create_run(values)
  101. return _json(
  102. {
  103. "title": "视频发现运行已创建",
  104. "run_id": created["run_id"],
  105. "status": created["status"],
  106. "output": f"run_id={created['run_id']}",
  107. }
  108. )
  109. except Exception as exc:
  110. logger.error("create_video_discovery_run failed: %s", exc, exc_info=True)
  111. return _json({"error": format_db_error(exc), "title": "创建视频发现运行失败"})
  112. def _normalize_candidate_update(item: dict[str, Any]) -> dict[str, Any]:
  113. try:
  114. candidate_id = int(item.get("candidate_id"))
  115. except (TypeError, ValueError):
  116. raise ValueError("candidate_id 必须是整数") from None
  117. if candidate_id <= 0:
  118. raise ValueError("candidate_id 必须大于 0")
  119. content_age = item.get("content_age_evidence")
  120. account_age = item.get("account_age_evidence")
  121. age_normalization = item.get("age_normalization")
  122. decision_bucket = (
  123. _clean_text(item.get("decision_bucket"), max_length=24)
  124. or ""
  125. )
  126. if decision_bucket not in _FINAL_DECISION_BUCKETS:
  127. raise ValueError(
  128. "decision_bucket 必须是 primary 或 rejected"
  129. )
  130. mapping = {
  131. "candidate_id": candidate_id,
  132. "title": _clean_text(item.get("title"), max_length=512),
  133. "content_link": _clean_text(item.get("content_link"), max_length=1024),
  134. "author_name": _clean_text(item.get("author_name"), max_length=256),
  135. "author_sec_uid": _clean_text(item.get("author_sec_uid"), max_length=256),
  136. "tags_json": item.get("tags") if "tags" in item else None,
  137. "play_count": _nonnegative_int(item.get("play_count")),
  138. "like_count": _nonnegative_int(item.get("like_count")),
  139. "comment_count": _nonnegative_int(item.get("comment_count")),
  140. "collect_count": _nonnegative_int(item.get("collect_count")),
  141. "share_count": _nonnegative_int(item.get("share_count")),
  142. "content_age_evidence_json": (
  143. _json(content_age) if content_age is not None else None
  144. ),
  145. "account_age_evidence_json": (
  146. _json(account_age) if account_age is not None else None
  147. ),
  148. "age_normalization_json": (
  149. _json(age_normalization) if age_normalization is not None else None
  150. ),
  151. "relevance_score": _optional_decimal(item.get("relevance_score"), 6),
  152. "elder_score": _optional_decimal(item.get("elder_score"), 6),
  153. "share_score": _optional_decimal(item.get("share_score"), 6),
  154. "value_score": _optional_decimal(item.get("value_score"), 2),
  155. "decision_reason": _clean_text(item.get("decision_reason")),
  156. "decision_bucket": decision_bucket,
  157. }
  158. return mapping
  159. def batch_update_video_discovery_candidates(
  160. run_id: str,
  161. items: list[dict[str, Any]],
  162. ) -> str:
  163. """
  164. 严格按 candidate_id 批量更新候选详情、证据、评分和最终分池。
  165. 只更新 video_discovery_candidate;不会新增候选、修改搜索记录或修改运行状态。
  166. Args:
  167. run_id: 发现运行 id。
  168. items: 候选数组。每项必须包含搜索工具返回的 candidate_id,并直接提供
  169. decision_bucket;可更新标题、链接、作者、互动量、标签、双侧年龄证据、
  170. 画像标准化结果、R/E/S/V 和最终分池理由。
  171. Returns:
  172. JSON,包含 updated_count 和按 candidate_id 更新后的候选记录。
  173. """
  174. run_text = _clean_text(run_id, max_length=64)
  175. if not run_text:
  176. return _input_error("run_id 不能为空")
  177. rows: list[dict[str, Any]] = []
  178. errors: list[str] = []
  179. for index, item in enumerate(items or []):
  180. if not isinstance(item, dict):
  181. errors.append(f"[{index}] 不是对象")
  182. continue
  183. try:
  184. rows.append(_normalize_candidate_update(dict(item)))
  185. except ValueError as exc:
  186. errors.append(f"[{index}] {exc}")
  187. if errors:
  188. return _json(
  189. {
  190. "error": "候选更新参数不合法",
  191. "input_error": True,
  192. "errors": errors,
  193. }
  194. )
  195. if not rows:
  196. return _input_error("items 不能为空")
  197. try:
  198. updated = get_video_discovery_service().update_candidates(
  199. run_text,
  200. rows,
  201. )
  202. payload = {
  203. "title": "候选已更新",
  204. "run_id": run_text,
  205. "updated_count": updated["updated_count"],
  206. "candidates": updated["candidates"],
  207. "output": f"更新 {updated['updated_count']} 条候选",
  208. }
  209. return _json(payload)
  210. except RunNotFoundError as exc:
  211. return _input_error(str(exc))
  212. except ValueError as exc:
  213. return _input_error(str(exc))
  214. except Exception as exc:
  215. logger.error(
  216. "batch_update_video_discovery_candidates failed: %s",
  217. exc,
  218. exc_info=True,
  219. )
  220. return _json({"error": format_db_error(exc), "title": "更新候选失败"})
  221. def update_video_discovery_run_status(
  222. run_id: str,
  223. status: str,
  224. intent_summary: str | None = None,
  225. stop_reason: str | None = None,
  226. ) -> str:
  227. """单独更新 video_discovery_run 的状态、意图摘要和停止原因。"""
  228. run_text = _clean_text(run_id, max_length=64)
  229. if not run_text:
  230. return _input_error("run_id 不能为空")
  231. if status not in _RUN_STATUSES:
  232. return _input_error(f"status 必须是: {sorted(_RUN_STATUSES)}")
  233. try:
  234. run = get_video_discovery_service().update_run_status(
  235. run_text,
  236. status=status,
  237. intent_summary=_clean_text(intent_summary),
  238. stop_reason=_clean_text(stop_reason),
  239. )
  240. return _json(
  241. {
  242. "title": "视频发现运行状态已更新",
  243. "run": run,
  244. "output": (
  245. f"run_id={run_text},status={run['status']},"
  246. f"primary_count={run['primary_count']}"
  247. ),
  248. }
  249. )
  250. except RunNotFoundError as exc:
  251. return _input_error(str(exc))
  252. except Exception as exc:
  253. logger.error(
  254. "update_video_discovery_run_status failed: %s",
  255. exc,
  256. exc_info=True,
  257. )
  258. return _json({"error": format_db_error(exc), "title": "更新运行状态失败"})
  259. def query_video_discovery_state(
  260. run_id: str,
  261. include_rejected: bool = True,
  262. limit: int = 100,
  263. ) -> str:
  264. """
  265. 查询一次运行已经保存的搜索轨迹、主推荐与淘汰候选。
  266. 用于长搜索过程恢复状态、检查是否真的翻页和扩词,也用于最终自动保留判断。
  267. """
  268. run_text = _clean_text(run_id, max_length=64)
  269. if not run_text:
  270. return _json({"error": "run_id 不能为空"})
  271. try:
  272. state = get_video_discovery_service().get_full_state(
  273. run_text,
  274. include_rejected=include_rejected,
  275. limit=limit,
  276. )
  277. run = state["run"]
  278. payload = {
  279. "title": f"视频发现状态: {run_text}",
  280. "run": run,
  281. "searches": state["searches"],
  282. "candidates": state["candidates"],
  283. "output": (
  284. f"搜索页 {run['search_count']};主推荐 {run['primary_count']}"
  285. ),
  286. }
  287. return _json(payload)
  288. except RunNotFoundError:
  289. return _json({"error": f"run_id 不存在: {run_text}"})
  290. except Exception as exc:
  291. logger.error("query_video_discovery_state failed: %s", exc, exc_info=True)
  292. return _json({"error": format_db_error(exc), "title": "查询视频发现状态失败"})