video_discovery_store.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571
  1. """持久化 find_agent 的搜索轨迹、候选证据和分池结果。"""
  2. from __future__ import annotations
  3. import hashlib
  4. import json
  5. import logging
  6. import uuid
  7. from decimal import Decimal
  8. from typing import Any
  9. from agents.find_agent.tools.decision_support import (
  10. audit_video_discovery_process,
  11. )
  12. from supply_agent.tools import tool
  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. _SOURCE_TYPES = {
  22. "demand",
  23. "seed",
  24. "point",
  25. "tag",
  26. "author",
  27. "pagination",
  28. "mixed",
  29. }
  30. _ROOT_SOURCE_TYPES = {"demand", "seed", "point", "mixed"}
  31. def _json(value: Any) -> str:
  32. return json.dumps(value, ensure_ascii=False, default=str)
  33. def _input_error(message: str) -> str:
  34. return _json({"error": message, "input_error": True})
  35. def _load_json(value: str | None, default: Any) -> Any:
  36. if not value:
  37. return default
  38. try:
  39. return json.loads(value)
  40. except (TypeError, ValueError):
  41. return default
  42. def _clean_text(value: Any, *, max_length: int | None = None) -> str | None:
  43. if value is None:
  44. return None
  45. text = str(value).strip()
  46. if not text:
  47. return None
  48. return text[:max_length] if max_length else text
  49. def _nonnegative_int(value: Any) -> int | None:
  50. if value is None or value == "":
  51. return None
  52. try:
  53. return max(0, int(value))
  54. except (TypeError, ValueError):
  55. return None
  56. def _optional_decimal(value: Any, places: int) -> Decimal | None:
  57. if value is None or value == "":
  58. return None
  59. try:
  60. number = Decimal(str(value))
  61. except (ArithmeticError, TypeError, ValueError):
  62. return None
  63. quantum = Decimal(1).scaleb(-places)
  64. return number.quantize(quantum)
  65. def _search_key(values: dict[str, Any]) -> str:
  66. identity = {
  67. key: values.get(key)
  68. for key in (
  69. "provider",
  70. "keyword",
  71. "content_type",
  72. "sort_type",
  73. "publish_time",
  74. "cursor",
  75. )
  76. }
  77. raw = json.dumps(identity, ensure_ascii=False, sort_keys=True)
  78. return hashlib.sha256(raw.encode("utf-8")).hexdigest()
  79. def _candidate_from_search_result(item: dict[str, Any], keyword: str) -> dict[str, Any] | None:
  80. aweme_id = _clean_text(item.get("aweme_id") or item.get("content_id"), max_length=64)
  81. if not aweme_id:
  82. return None
  83. author = item.get("author") if isinstance(item.get("author"), dict) else {}
  84. stats = item.get("statistics") if isinstance(item.get("statistics"), dict) else {}
  85. topics = item.get("topics") if isinstance(item.get("topics"), list) else []
  86. return {
  87. "aweme_id": aweme_id,
  88. "title": _clean_text(item.get("desc") or item.get("title"), max_length=512),
  89. "content_link": _clean_text(
  90. item.get("url") or item.get("content_link"), max_length=1024
  91. ),
  92. "author_name": _clean_text(
  93. author.get("nickname") or item.get("author_name"), max_length=256
  94. ),
  95. "author_sec_uid": _clean_text(
  96. author.get("sec_uid") or item.get("author_sec_uid"), max_length=256
  97. ),
  98. "like_count": _nonnegative_int(
  99. stats.get("digg_count") or item.get("like_count")
  100. ),
  101. "comment_count": _nonnegative_int(
  102. stats.get("comment_count") or item.get("comment_count")
  103. ),
  104. "share_count": _nonnegative_int(
  105. stats.get("share_count") or item.get("share_count")
  106. ),
  107. "collect_count": _nonnegative_int(
  108. stats.get("collect_count") or item.get("collect_count")
  109. ),
  110. "play_count": _nonnegative_int(
  111. stats.get("play_count") or item.get("play_count")
  112. ),
  113. "tags_json": _json(topics) if topics else None,
  114. "_source_keyword": keyword,
  115. }
  116. @tool
  117. def create_video_discovery_run(
  118. demand_word: str,
  119. seed_video_title: str,
  120. relevant_points: list[dict[str, Any]],
  121. seed_video_id: str | None = None,
  122. demand_grade_id: int | None = None,
  123. intent_summary: str | None = None,
  124. run_id: str | None = None,
  125. ) -> str:
  126. """
  127. 创建一次可追踪的视频发现运行。
  128. 若用户消息已提供预创建 run_id,必须原样传入 run_id;工具会复用已有记录,
  129. 不会重复创建。
  130. Args:
  131. demand_word: 用户给定需求词;它是输入语义,不强制作为实际搜索词。
  132. seed_video_title: 与需求相关的参考视频标题。
  133. relevant_points: 参考视频中与需求相关的点位对象列表。
  134. seed_video_id: 可选参考视频 id。
  135. demand_grade_id: 可选 demand_grade.id。
  136. intent_summary: Agent 对真正受欢迎内容的初步解释,可稍后更新。
  137. run_id: 系统预创建的运行 id;传入已存在记录时直接复用。
  138. Returns:
  139. JSON,包含后续存储工具必须使用的 run_id。
  140. """
  141. service = get_video_discovery_service()
  142. cleaned_run_id = _clean_text(run_id, max_length=64)
  143. if cleaned_run_id:
  144. try:
  145. existing = service.lookup_run(cleaned_run_id)
  146. if existing is not None:
  147. return _json(
  148. {
  149. "title": "视频发现运行已存在",
  150. "run_id": cleaned_run_id,
  151. "status": existing["status"],
  152. "pre_created": True,
  153. "output": f"run_id={cleaned_run_id}",
  154. }
  155. )
  156. except Exception as exc:
  157. logger.error("create_video_discovery_run lookup failed: %s", exc, exc_info=True)
  158. return _json({"error": format_db_error(exc), "title": "查询视频发现运行失败"})
  159. demand = _clean_text(demand_word, max_length=256)
  160. if not demand:
  161. return _input_error("demand_word 不能为空")
  162. new_run_id = cleaned_run_id or uuid.uuid4().hex
  163. values = {
  164. "run_id": new_run_id,
  165. "demand_grade_id": demand_grade_id,
  166. "demand_word": demand,
  167. "seed_video_id": _clean_text(seed_video_id, max_length=64),
  168. "seed_video_title": _clean_text(seed_video_title, max_length=512),
  169. "relevant_points_json": _json(relevant_points or []),
  170. "intent_summary": _clean_text(intent_summary),
  171. "status": "running",
  172. }
  173. try:
  174. created = service.create_run(values)
  175. return _json(
  176. {
  177. "title": "视频发现运行已创建",
  178. "run_id": created["run_id"],
  179. "status": created["status"],
  180. "output": f"run_id={created['run_id']}",
  181. }
  182. )
  183. except Exception as exc:
  184. logger.error("create_video_discovery_run failed: %s", exc, exc_info=True)
  185. return _json({"error": format_db_error(exc), "title": "创建视频发现运行失败"})
  186. @tool
  187. def record_video_search_page(
  188. run_id: str,
  189. keyword: str,
  190. query_reason: str,
  191. source_type: str,
  192. results: list[dict[str, Any]],
  193. cursor: str = "0",
  194. page_no: int = 1,
  195. has_more: bool = False,
  196. next_cursor: str | None = None,
  197. source_value: str | None = None,
  198. parent_search_id: int | None = None,
  199. provider: str = "internal_keyword",
  200. provider_state: dict[str, Any] | None = None,
  201. content_type: str = "视频",
  202. sort_type: str = "综合排序",
  203. publish_time: str = "不限",
  204. error_message: str | None = None,
  205. ) -> str:
  206. """
  207. 保存一次关键词搜索页,并把该页视频幂等并入候选集。
  208. 每次 douyin_search 后调用。关键词可来自需求语义、参考标题、点位、优质视频标签,
  209. 或前一页的 next_cursor;source_type 用于保留扩展来源。
  210. Args:
  211. run_id: create_video_discovery_run 返回值。
  212. keyword: Agent 本次自主确定的实际搜索词。
  213. query_reason: 该词验证的内容假设。
  214. source_type: demand / seed / point / tag / author / pagination / mixed。
  215. results: douyin_search.search_results 数组。
  216. cursor / page_no / has_more / next_cursor: 本页翻页状态。
  217. source_value: 触发扩展的点位、标签或父关键词。
  218. parent_search_id: 标签扩展或翻页对应的父搜索记录。
  219. provider: internal_keyword / tikhub / internal_blogger 等来源标识。
  220. provider_state: 来源特有的分页状态,如 TikHub 的 search_id/backtrace。
  221. content_type / sort_type / publish_time: 原样保存搜索条件。
  222. error_message: 搜索失败时保存错误;results 可为空。
  223. """
  224. run_text = _clean_text(run_id, max_length=64)
  225. keyword_text = _clean_text(keyword, max_length=256)
  226. reason_text = _clean_text(query_reason)
  227. if not run_text or not keyword_text or not reason_text:
  228. return _input_error("run_id、keyword、query_reason 不能为空")
  229. if source_type not in _SOURCE_TYPES:
  230. return _input_error(f"source_type 必须是: {sorted(_SOURCE_TYPES)}")
  231. normalized_page_no = max(1, int(page_no))
  232. normalized_source_type = (
  233. "pagination" if normalized_page_no > 1 else source_type
  234. )
  235. normalized_parent_search_id = (
  236. None
  237. if normalized_source_type in _ROOT_SOURCE_TYPES
  238. else parent_search_id
  239. )
  240. candidate_rows = [
  241. row
  242. for item in results or []
  243. if isinstance(item, dict)
  244. if (row := _candidate_from_search_result(dict(item), keyword_text)) is not None
  245. ]
  246. search_values: dict[str, Any] = {
  247. "run_id": run_text,
  248. "keyword": keyword_text,
  249. "query_reason": reason_text,
  250. "source_type": normalized_source_type,
  251. "source_value": _clean_text(source_value),
  252. "parent_search_id": normalized_parent_search_id,
  253. "provider": _clean_text(provider, max_length=32) or "internal_keyword",
  254. "provider_state_json": _json(provider_state) if provider_state else None,
  255. "content_type": _clean_text(content_type, max_length=16) or "视频",
  256. "sort_type": _clean_text(sort_type, max_length=32) or "综合排序",
  257. "publish_time": _clean_text(publish_time, max_length=32) or "不限",
  258. "cursor": _clean_text(cursor, max_length=128) or "0",
  259. "page_no": normalized_page_no,
  260. "results_count": len(results or []),
  261. "new_candidate_count": 0,
  262. "has_more": int(bool(has_more)),
  263. "next_cursor": _clean_text(next_cursor, max_length=128),
  264. "result_ids_json": None,
  265. "status": "failed" if error_message else "success",
  266. "error_message": _clean_text(error_message),
  267. }
  268. search_values["search_key"] = _search_key(search_values)
  269. try:
  270. saved = get_video_discovery_service().save_search_page(
  271. run_text,
  272. search_values,
  273. candidate_rows,
  274. )
  275. payload = {
  276. "title": "搜索页已保存",
  277. **saved,
  278. "output": (
  279. f"search_id={saved['search_id']},本页 {saved['results_count']} 条,"
  280. f"新增候选 {saved['new_candidate_count']} 条"
  281. ),
  282. }
  283. return _json(payload)
  284. except RunNotFoundError as exc:
  285. return _input_error(str(exc))
  286. except Exception as exc:
  287. logger.error("record_video_search_page failed: %s", exc, exc_info=True)
  288. return _json({"error": format_db_error(exc), "title": "保存搜索页失败"})
  289. def _normalize_evaluation(item: dict[str, Any]) -> dict[str, Any]:
  290. aweme_id = _clean_text(item.get("aweme_id"), max_length=64)
  291. if not aweme_id:
  292. raise ValueError("aweme_id 不能为空")
  293. content_age = item.get("content_age_evidence")
  294. account_age = item.get("account_age_evidence")
  295. age_normalization = item.get("age_normalization")
  296. detail_verified = bool(item.get("detail_verified"))
  297. content_portrait_attempted = bool(item.get("content_portrait_attempted"))
  298. account_portrait_attempted = bool(item.get("account_portrait_attempted"))
  299. age_portraits_normalized = bool(item.get("age_portraits_normalized"))
  300. decision_bucket = (
  301. _clean_text(item.get("decision_bucket"), max_length=24)
  302. or ""
  303. )
  304. if decision_bucket not in _FINAL_DECISION_BUCKETS:
  305. raise ValueError(
  306. "decision_bucket 必须是 primary 或 rejected"
  307. )
  308. mapping = {
  309. "aweme_id": aweme_id,
  310. "title": _clean_text(item.get("title"), max_length=512),
  311. "content_link": _clean_text(item.get("content_link"), max_length=1024),
  312. "author_name": _clean_text(item.get("author_name"), max_length=256),
  313. "author_sec_uid": _clean_text(item.get("author_sec_uid"), max_length=256),
  314. "source_keywords_json": item.get("source_keywords") or [],
  315. "source_search_ids_json": item.get("source_search_ids") or [],
  316. "tags_json": item.get("tags") or [],
  317. "hit_points_json": item.get("hit_points") or [],
  318. "play_count": _nonnegative_int(item.get("play_count")),
  319. "like_count": _nonnegative_int(item.get("like_count")),
  320. "comment_count": _nonnegative_int(item.get("comment_count")),
  321. "collect_count": _nonnegative_int(item.get("collect_count")),
  322. "share_count": _nonnegative_int(item.get("share_count")),
  323. "publish_timestamp": _nonnegative_int(item.get("publish_timestamp")),
  324. "content_age_evidence_json": (
  325. _json(content_age) if content_age is not None else None
  326. ),
  327. "account_age_evidence_json": (
  328. _json(account_age) if account_age is not None else None
  329. ),
  330. "age_normalization_json": (
  331. _json(age_normalization) if age_normalization is not None else None
  332. ),
  333. "detail_verified": int(detail_verified),
  334. "content_portrait_attempted": int(content_portrait_attempted),
  335. "account_portrait_attempted": int(account_portrait_attempted),
  336. "age_portraits_normalized": int(age_portraits_normalized),
  337. "expansion_worthy_tags_json": item.get("expansion_worthy_tags") or [],
  338. "relevance_score": _optional_decimal(item.get("relevance_score"), 6),
  339. "elder_score": _optional_decimal(item.get("elder_score"), 6),
  340. "share_score": _optional_decimal(item.get("share_score"), 6),
  341. "value_score": _optional_decimal(item.get("value_score"), 2),
  342. "confidence": _clean_text(item.get("confidence"), max_length=16),
  343. "relevance_reason": _clean_text(item.get("relevance_reason")),
  344. "elder_reason": _clean_text(item.get("elder_reason")),
  345. "share_reason": _clean_text(item.get("share_reason")),
  346. "decision_reason": _clean_text(item.get("decision_reason")),
  347. "decision_bucket": decision_bucket,
  348. }
  349. return mapping
  350. @tool
  351. def batch_save_video_candidate_evaluations(
  352. run_id: str,
  353. items: list[dict[str, Any]],
  354. run_status: str = "running",
  355. intent_summary: str | None = None,
  356. stop_reason: str | None = None,
  357. ) -> str:
  358. """
  359. 原样保存 Agent 给出的候选详情、证据、评分和分池。
  360. 本工具不重算 R/E/S/V,不执行画像证据上限,也不根据阈值修改
  361. decision_bucket。最终分池只接受 primary / rejected。
  362. Args:
  363. run_id: 发现运行 id。
  364. items: 候选数组。每项至少包含 aweme_id,并由 Agent 直接提供
  365. decision_bucket。其余详情、证据、评分和理由按模型输出原样保存。
  366. run_status: running / finished / failed。
  367. intent_summary: 对目标内容的最终解释。
  368. stop_reason: 完成或失败时的停止依据。
  369. """
  370. run_text = _clean_text(run_id, max_length=64)
  371. if not run_text:
  372. return _input_error("run_id 不能为空")
  373. if run_status not in _RUN_STATUSES:
  374. return _input_error(
  375. f"run_status 必须是: {sorted(_RUN_STATUSES)}"
  376. )
  377. rows: list[dict[str, Any]] = []
  378. errors: list[str] = []
  379. for index, item in enumerate(items or []):
  380. if not isinstance(item, dict):
  381. errors.append(f"[{index}] 不是对象")
  382. continue
  383. try:
  384. rows.append(_normalize_evaluation(dict(item)))
  385. except ValueError as exc:
  386. errors.append(f"[{index}] {exc}")
  387. try:
  388. saved = get_video_discovery_service().save_evaluations_and_finish(
  389. run_text,
  390. rows,
  391. status=run_status,
  392. intent_summary=_clean_text(intent_summary),
  393. stop_reason=_clean_text(stop_reason),
  394. )
  395. payload = {
  396. "title": "候选评估已保存",
  397. "run_id": run_text,
  398. "saved_count": saved["saved_count"],
  399. "error_count": len(errors),
  400. "errors": errors,
  401. "input_error": bool(errors and not rows),
  402. "audit_relevant_changed": saved["audit_relevant_changed"],
  403. "status": saved["status"],
  404. "search_count": saved["search_count"],
  405. "primary_count": saved["primary_count"],
  406. "output": (
  407. f"保存 {saved['saved_count']} 条;主推荐 {saved['primary_count']} 条;"
  408. f"状态 {saved['status']}"
  409. ),
  410. }
  411. return _json(payload)
  412. except RunNotFoundError as exc:
  413. return _input_error(str(exc))
  414. except Exception as exc:
  415. logger.error(
  416. "batch_save_video_candidate_evaluations failed: %s", exc, exc_info=True
  417. )
  418. return _json({"error": format_db_error(exc), "title": "保存候选评估失败"})
  419. @tool
  420. def query_video_discovery_state(
  421. run_id: str,
  422. include_rejected: bool = True,
  423. limit: int = 100,
  424. ) -> str:
  425. """
  426. 查询一次运行已经保存的搜索轨迹、主推荐与淘汰候选。
  427. 用于长搜索过程恢复状态、检查是否真的翻页和扩词,也用于最终自动保留判断。
  428. """
  429. run_text = _clean_text(run_id, max_length=64)
  430. if not run_text:
  431. return _json({"error": "run_id 不能为空"})
  432. try:
  433. state = get_video_discovery_service().get_full_state(
  434. run_text,
  435. include_rejected=include_rejected,
  436. limit=limit,
  437. )
  438. run = state["run"]
  439. payload = {
  440. "title": f"视频发现状态: {run_text}",
  441. "run": run,
  442. "searches": state["searches"],
  443. "candidates": state["candidates"],
  444. "output": (
  445. f"搜索页 {run['search_count']};主推荐 {run['primary_count']}"
  446. ),
  447. }
  448. return _json(payload)
  449. except RunNotFoundError:
  450. return _json({"error": f"run_id 不存在: {run_text}"})
  451. except Exception as exc:
  452. logger.error("query_video_discovery_state failed: %s", exc, exc_info=True)
  453. return _json({"error": format_db_error(exc), "title": "查询视频发现状态失败"})
  454. @tool
  455. def audit_video_discovery_run(
  456. run_id: str,
  457. intended_status: str = "finished",
  458. ) -> str:
  459. """
  460. 直接从数据库读取一次发现运行的完整状态并执行结束审计。
  461. 相比把 query_video_discovery_state 的大量 searches/candidates 再复制给审计工具,
  462. 本工具只需要 run_id,可避免长参数截断或 malformed function call。数据库可用时
  463. 应优先使用本工具;数据库不可用的降级流程仍使用 audit_video_discovery_process。
  464. Args:
  465. run_id: create_video_discovery_run 返回的运行 id。
  466. intended_status: 准备结束时传 finished。
  467. Returns:
  468. JSON,包含 can_finish、critical_violations、warnings、coverage 和持久化状态。
  469. """
  470. run_text = _clean_text(run_id, max_length=64)
  471. if not run_text:
  472. return _input_error("run_id 不能为空")
  473. if intended_status not in _RUN_STATUSES:
  474. return _input_error(
  475. f"intended_status 必须是: {sorted(_RUN_STATUSES)}"
  476. )
  477. try:
  478. snapshot = get_video_discovery_service().get_audit_snapshot(run_text)
  479. persisted_run = snapshot["persisted_run"]
  480. result = _load_json(
  481. audit_video_discovery_process(
  482. searches=snapshot["searches"],
  483. candidates=snapshot["candidates"],
  484. intended_status=intended_status,
  485. ),
  486. {},
  487. )
  488. if not result:
  489. return _json({"error": "审计工具返回了无效结果"})
  490. result.update(
  491. {
  492. "run_id": run_text,
  493. "persisted_status": persisted_run["status"],
  494. "persisted_search_count": persisted_run["search_count"],
  495. "persisted_primary_count": persisted_run["primary_count"],
  496. }
  497. )
  498. if (
  499. intended_status == "finished"
  500. and persisted_run["status"] != "finished"
  501. ):
  502. violations = list(result.get("critical_violations") or [])
  503. violations.append("运行状态尚未持久化为 finished")
  504. result["critical_violations"] = list(dict.fromkeys(violations))
  505. result["can_finish"] = False
  506. return _json(result)
  507. except RunNotFoundError as exc:
  508. return _input_error(str(exc))
  509. except Exception as exc:
  510. logger.error(
  511. "audit_video_discovery_run failed: %s",
  512. exc,
  513. exc_info=True,
  514. )
  515. return _json(
  516. {"error": format_db_error(exc), "title": "数据库运行审计失败"}
  517. )