video_discovery.py 14 KB

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