tools.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445
  1. """Stage tools that persist exclusively to ``find_agent_v2_*`` tables."""
  2. from __future__ import annotations
  3. import json
  4. from collections.abc import Callable, Iterable
  5. from typing import Any
  6. from pydantic import BaseModel, Field, model_validator
  7. from find_agent_v2.qwen_video_understanding_30s import understand_candidate_video_30s_v2
  8. from find_agent_v2.providers import (
  9. fetch_details,
  10. fetch_portraits,
  11. search_internal,
  12. search_tikhub,
  13. )
  14. from find_agent_v2.service import get_find_agent_v2_service
  15. from supply_agent.tools import tool
  16. from supply_agent.tools.registry import ToolRegistry
  17. ToolFn = Callable[..., Any]
  18. class CandidateEvaluation(BaseModel):
  19. """One candidate decision. Prefer candidate_id; aweme_id is a safe fallback."""
  20. candidate_id: int | None = Field(
  21. default=None,
  22. description="数据库候选编号,即输入中的 candidate_id;不要填写 aweme_id。",
  23. )
  24. aweme_id: str | None = Field(
  25. default=None,
  26. description="抖音视频号;仅在无法填写 candidate_id 时作为兼容定位字段。",
  27. )
  28. relevance_score: float = Field(ge=0, le=1, description="R 相关性评分,0~1。")
  29. elder_score: float = Field(ge=0, le=1, description="E 中老年适配评分,0~1。")
  30. share_score: float = Field(ge=0, le=1, description="S 传播力评分,0~1。")
  31. value_score: float = Field(ge=0, le=1, description="V 综合价值评分,0~1。")
  32. decision_bucket: str = Field(description="只能是 primary 或 rejected。")
  33. decision_reason: str = Field(min_length=1, description="基于真实证据的中文判断理由。")
  34. reject_reason_code: str | None = None
  35. @model_validator(mode="after")
  36. def validate_identity_and_bucket(self):
  37. if self.candidate_id is None and not str(self.aweme_id or "").strip():
  38. raise ValueError("candidate_id 和 aweme_id 至少提供一个")
  39. if self.decision_bucket not in {"primary", "rejected"}:
  40. raise ValueError("decision_bucket 只能是 primary/rejected")
  41. return self
  42. class EvaluationBatch(BaseModel):
  43. """Structured evaluator output consumed and persisted by the host."""
  44. items: list[CandidateEvaluation] = Field(
  45. min_length=1,
  46. description="当前 Worker 分片内全部候选的评估结果,每个候选恰好一条。",
  47. )
  48. def normalize_evaluation_items(
  49. items: list[dict[str, Any] | CandidateEvaluation],
  50. *,
  51. allowed_candidates: list[dict[str, Any]],
  52. ) -> list[dict[str, Any]]:
  53. """Validate a complete worker shard and resolve aweme_id without widening access."""
  54. allowed_ids = {int(item["candidate_id"]) for item in allowed_candidates}
  55. aweme_to_id = {
  56. str(item.get("aweme_id") or ""): int(item["candidate_id"])
  57. for item in allowed_candidates
  58. }
  59. normalized: list[dict[str, Any]] = []
  60. for raw in items:
  61. value = raw if isinstance(raw, CandidateEvaluation) else CandidateEvaluation.model_validate(raw)
  62. candidate_id = value.candidate_id
  63. if candidate_id is None:
  64. candidate_id = aweme_to_id.get(str(value.aweme_id or ""))
  65. if candidate_id not in allowed_ids:
  66. raise ValueError(
  67. "评估项不属于当前 Worker 分片;"
  68. f"candidate_id={candidate_id}, aweme_id={value.aweme_id}, "
  69. f"allowed_candidate_ids={sorted(allowed_ids)}"
  70. )
  71. payload = value.model_dump(exclude_none=True)
  72. payload["candidate_id"] = candidate_id
  73. payload.pop("aweme_id", None)
  74. normalized.append(payload)
  75. requested = [int(item["candidate_id"]) for item in normalized]
  76. if len(requested) != len(set(requested)):
  77. raise ValueError("同一 candidate_id 不能重复评估")
  78. missing = allowed_ids - set(requested)
  79. if missing:
  80. raise ValueError(f"必须一次评估完整分片,缺少 candidate_ids={sorted(missing)}")
  81. return normalized
  82. def bound_candidate_tools(
  83. functions: tuple[ToolFn, ...], *, run_id: str, candidate_ids: list[int],
  84. ) -> tuple[ToolFn, ...]:
  85. """Physically constrain worker tools to one run and one candidate shard."""
  86. allowed = {int(value) for value in candidate_ids}
  87. output: list[ToolFn] = []
  88. for original in functions:
  89. name = str(getattr(original, "_tool_name", original.__name__))
  90. if name == "query_pending_candidates_v2":
  91. def make_query(fn_name: str, worker_run_id: str, worker_allowed: set[int]):
  92. @tool(name=fn_name, description="仅查询当前 Worker 分片中的待评估候选。")
  93. def bound_query(run_id: str, limit: int = 100) -> str:
  94. if run_id != worker_run_id:
  95. return json.dumps({"error": "run_id 不属于当前 Worker"}, ensure_ascii=False)
  96. service = get_find_agent_v2_service()
  97. state = {
  98. "run": service.require_run(run_id),
  99. "candidates": [
  100. item
  101. for item in service.candidate_inputs(
  102. run_id, sorted(worker_allowed),
  103. )
  104. if item.get("decision_bucket") == "pending_evaluation"
  105. ][:max(1, int(limit))],
  106. }
  107. return json.dumps(state, ensure_ascii=False, default=str)
  108. return bound_query
  109. output.append(make_query(name, run_id, allowed))
  110. continue
  111. def make_async(fn: ToolFn, fn_name: str, worker_run_id: str, worker_allowed: set[int]):
  112. @tool(name=fn_name, description=getattr(fn, "_tool_description", ""))
  113. async def bound_async(run_id: str, candidate_ids: list[int]) -> str:
  114. if run_id != worker_run_id:
  115. return json.dumps({"error": "run_id 不属于当前 Worker"}, ensure_ascii=False)
  116. requested = [int(value) for value in candidate_ids]
  117. if not requested or not set(requested) <= worker_allowed:
  118. return json.dumps({
  119. "error": "candidate_ids 超出当前 Worker 分片",
  120. "allowed_candidate_ids": sorted(worker_allowed),
  121. }, ensure_ascii=False)
  122. if set(requested) != worker_allowed:
  123. return json.dumps({
  124. "error": "必须一次处理完整 Evidence Worker 分片",
  125. "requested_candidate_ids": requested,
  126. "required_candidate_ids": sorted(worker_allowed),
  127. }, ensure_ascii=False)
  128. return await fn(run_id=run_id, candidate_ids=requested)
  129. return bound_async
  130. def make_video_understanding(
  131. fn: ToolFn, fn_name: str, worker_run_id: str, worker_allowed: set[int],
  132. ):
  133. @tool(name=fn_name, description=getattr(fn, "_tool_description", ""))
  134. async def bound_video_understanding(
  135. run_id: str, candidate_id: int, prompt: str,
  136. ) -> str:
  137. if run_id != worker_run_id or int(candidate_id) not in worker_allowed:
  138. return json.dumps({
  139. "error": "candidate_id 或 run_id 超出当前评估 Worker 分片",
  140. "allowed_candidate_ids": sorted(worker_allowed),
  141. }, ensure_ascii=False)
  142. return await fn(run_id=run_id, candidate_id=int(candidate_id), prompt=prompt)
  143. return bound_video_understanding
  144. def make_sync(fn: ToolFn, fn_name: str, worker_run_id: str, worker_allowed: set[int]):
  145. @tool(name=fn_name, description=getattr(fn, "_tool_description", ""))
  146. def bound_sync(run_id: str, items: list[CandidateEvaluation]) -> str:
  147. if run_id != worker_run_id:
  148. raise ValueError("run_id 不属于当前 Worker")
  149. service = get_find_agent_v2_service()
  150. allowed_candidates = [
  151. item
  152. for item in service.candidate_inputs(
  153. run_id, sorted(worker_allowed),
  154. )
  155. if item.get("decision_bucket") == "pending_evaluation"
  156. ]
  157. normalized = normalize_evaluation_items(
  158. items, allowed_candidates=allowed_candidates,
  159. )
  160. return fn(run_id=run_id, items=normalized)
  161. return bound_sync
  162. wrapper: ToolFn
  163. if name == "understand_candidate_video_30s_v2":
  164. wrapper = make_video_understanding(original, name, run_id, allowed)
  165. elif name in {"fetch_candidate_details_v2", "fetch_candidate_portraits_v2"}:
  166. wrapper = make_async(original, name, run_id, allowed)
  167. elif name == "evaluate_candidates_v2":
  168. wrapper = make_sync(original, name, run_id, allowed)
  169. else:
  170. wrapper = original
  171. output.append(wrapper)
  172. return tuple(output)
  173. def _search_has_results(payload: dict[str, Any]) -> bool:
  174. return bool(payload.get("search_results"))
  175. async def _search_provider_pages(
  176. *,
  177. service: Any,
  178. run_id: str,
  179. round_index: int,
  180. keyword: str,
  181. query_reason: str,
  182. source_type: str,
  183. provider: str,
  184. raw_task: dict[str, Any],
  185. common: dict[str, Any],
  186. max_pages: int,
  187. cursor: str | int,
  188. provider_search_id: str = "",
  189. backtrace: str = "",
  190. extra_output: dict[str, Any] | None = None,
  191. ) -> tuple[list[dict[str, Any]], bool]:
  192. outputs: list[dict[str, Any]] = []
  193. has_results = False
  194. for page_no in range(1, max_pages + 1):
  195. if provider == "tikhub":
  196. payload = await search_tikhub(
  197. **common,
  198. cursor=int(cursor or 0),
  199. filter_duration=str(raw_task.get("filter_duration") or "不限"),
  200. search_id=provider_search_id,
  201. backtrace=backtrace,
  202. )
  203. else:
  204. payload = await search_internal(**common, cursor=str(cursor or "0"))
  205. saved = service.save_search(
  206. run_id=run_id,
  207. round_index=int(round_index),
  208. keyword=keyword,
  209. query_reason=query_reason,
  210. source_type=source_type,
  211. provider=provider,
  212. cursor=str(cursor),
  213. page_no=page_no,
  214. payload=payload,
  215. )
  216. if _search_has_results(payload):
  217. has_results = True
  218. outputs.append({
  219. "keyword": keyword,
  220. "provider": provider,
  221. "page_no": page_no,
  222. "error": payload.get("error"),
  223. "has_more": bool(payload.get("has_more")),
  224. "next_cursor": payload.get("next_cursor"),
  225. **(extra_output or {}),
  226. **saved,
  227. })
  228. if payload.get("error") or not payload.get("has_more"):
  229. break
  230. cursor = payload.get("next_cursor") or cursor
  231. provider_search_id = str(payload.get("search_id") or provider_search_id)
  232. backtrace = str(payload.get("backtrace") or backtrace)
  233. return outputs, has_results
  234. @tool
  235. async def search_videos_v2(run_id: str, round_index: int, searches: list[dict[str, Any]]) -> str:
  236. """批量搜索并仅写入 find_agent_v2_search/candidate;searches 最多 6 项。
  237. 未指定 provider 时先打内部搜索,无候选再回退 TikHub。
  238. 显式指定 internal_keyword 只用内部搜索;显式指定 tikhub 只用 TikHub。
  239. """
  240. if not searches or len(searches) > 6:
  241. return json.dumps({"error": "searches 必须为 1~6 项"}, ensure_ascii=False)
  242. service = get_find_agent_v2_service()
  243. outputs: list[dict[str, Any]] = []
  244. for raw_task in searches:
  245. keyword = str(raw_task.get("keyword") or "").strip()
  246. reason = str(raw_task.get("query_reason") or "").strip()
  247. requested_provider = str(raw_task.get("provider") or "").strip()
  248. allow_tikhub_fallback = not requested_provider
  249. provider = "tikhub" if requested_provider == "tikhub" else "internal_keyword"
  250. if not keyword or not reason:
  251. outputs.append({"error": "keyword/query_reason 不能为空"})
  252. continue
  253. max_pages = max(1, min(int(raw_task.get("max_pages") or 1), 2))
  254. source_type = str(raw_task.get("source_type") or "mixed")
  255. common = {
  256. "keyword": keyword,
  257. "content_type": str(raw_task.get("content_type") or "视频"),
  258. "sort_type": str(raw_task.get("sort_type") or "综合排序"),
  259. "publish_time": str(raw_task.get("publish_time") or "不限"),
  260. "min_duration_seconds": int(raw_task.get("min_duration_seconds") or 30),
  261. }
  262. pages, has_results = await _search_provider_pages(
  263. service=service,
  264. run_id=run_id,
  265. round_index=round_index,
  266. keyword=keyword,
  267. query_reason=reason,
  268. source_type=source_type,
  269. provider=provider,
  270. raw_task=raw_task,
  271. common=common,
  272. max_pages=max_pages,
  273. cursor=raw_task.get("cursor") or 0,
  274. provider_search_id=str(raw_task.get("search_id") or ""),
  275. backtrace=str(raw_task.get("backtrace") or ""),
  276. )
  277. outputs.extend(pages)
  278. if allow_tikhub_fallback and not has_results:
  279. fallback, _ = await _search_provider_pages(
  280. service=service,
  281. run_id=run_id,
  282. round_index=round_index,
  283. keyword=keyword,
  284. query_reason=reason,
  285. source_type=source_type,
  286. provider="tikhub",
  287. raw_task=raw_task,
  288. common=common,
  289. max_pages=max_pages,
  290. cursor=0,
  291. extra_output={"fallback_from": "internal_keyword"},
  292. )
  293. outputs.extend(fallback)
  294. return json.dumps({"run_id": run_id, "searches": outputs}, ensure_ascii=False)
  295. @tool
  296. async def fetch_candidate_details_v2(run_id: str, candidate_ids: list[int]) -> str:
  297. """批量获取候选详情并仅写入 find_agent_v2_candidate/evidence;最多 8 项。"""
  298. service = get_find_agent_v2_service()
  299. candidates = service.candidate_inputs(run_id, candidate_ids[:8])
  300. requested_aweme_ids = [str(item["aweme_id"]) for item in candidates]
  301. payload = await fetch_details(requested_aweme_ids)
  302. details = list(payload.get("details") or [])
  303. errors = list(payload.get("errors") or [])
  304. service.save_details(
  305. run_id,
  306. details,
  307. errors,
  308. requested_aweme_ids=requested_aweme_ids,
  309. )
  310. successful_ids = {
  311. str(item.get("content_id") or "") for item in details
  312. } & set(requested_aweme_ids)
  313. error_by_id = {str(item.get("content_id") or ""): item for item in errors}
  314. normalized_errors = [
  315. error_by_id.get(aweme_id) or {
  316. "content_id": aweme_id,
  317. "error": "上游详情响应未包含已请求候选",
  318. "error_code": "UPSTREAM_RESULT_MISSING",
  319. }
  320. for aweme_id in requested_aweme_ids
  321. if aweme_id not in successful_ids
  322. ]
  323. return json.dumps({
  324. "run_id": run_id,
  325. "success_count": len(successful_ids),
  326. "failed_count": len(normalized_errors),
  327. "errors": normalized_errors,
  328. }, ensure_ascii=False)
  329. @tool
  330. async def fetch_candidate_portraits_v2(run_id: str, candidate_ids: list[int]) -> str:
  331. """批量获取候选双侧年龄画像并仅写入 find_agent_v2_candidate/evidence。"""
  332. service = get_find_agent_v2_service()
  333. candidates = service.candidate_inputs(run_id, candidate_ids[:8])
  334. requested_aweme_ids = [str(item["aweme_id"]) for item in candidates]
  335. payload = await fetch_portraits([{
  336. "aweme_id": item["aweme_id"],
  337. "author_sec_uid": item.get("author_sec_uid"),
  338. } for item in candidates])
  339. results = list(payload.get("results") or [])
  340. service.save_portraits(
  341. run_id,
  342. results,
  343. requested_aweme_ids=requested_aweme_ids,
  344. )
  345. result_by_id = {str(item.get("aweme_id") or ""): item for item in results}
  346. successful_ids = {
  347. aweme_id
  348. for aweme_id in requested_aweme_ids
  349. if aweme_id in result_by_id and not result_by_id[aweme_id].get("error")
  350. }
  351. errors = [
  352. result_by_id.get(aweme_id) or {
  353. "aweme_id": aweme_id,
  354. "error": "上游画像响应未包含已请求候选",
  355. "error_code": "UPSTREAM_RESULT_MISSING",
  356. }
  357. for aweme_id in requested_aweme_ids
  358. if aweme_id not in successful_ids
  359. ]
  360. return json.dumps({
  361. "run_id": run_id,
  362. "count": len(results),
  363. "success_count": len(successful_ids),
  364. "failed_count": len(errors),
  365. "errors": errors,
  366. "results": results,
  367. }, ensure_ascii=False)
  368. @tool
  369. def evaluate_candidates_v2(run_id: str, items: list[CandidateEvaluation]) -> str:
  370. """按 candidate_id 写入完整分片的 R/E/S/V 与分池;不要用 aweme_id 代替。"""
  371. payload = [item.model_dump(exclude_none=True) if isinstance(item, CandidateEvaluation) else item for item in items]
  372. updated = get_find_agent_v2_service().evaluate(run_id, payload)
  373. return json.dumps({"run_id": run_id, "updated": updated}, ensure_ascii=False)
  374. @tool
  375. def query_find_agent_v2_state(run_id: str, limit: int = 100) -> str:
  376. """查询完全隔离的 find_agent_v2 运行、搜索与候选状态。"""
  377. state = get_find_agent_v2_service().get_full_state(run_id, limit=limit)
  378. return json.dumps(state, ensure_ascii=False, default=str)
  379. @tool
  380. def query_pending_candidates_v2(run_id: str, limit: int = 100) -> str:
  381. """仅查询当前 run 尚未分池的 pending_evaluation 候选。"""
  382. state = get_find_agent_v2_service().get_full_state(
  383. run_id, limit=limit, pending_only=True,
  384. )
  385. return json.dumps(state, ensure_ascii=False, default=str)
  386. SEARCH_TOOLS: tuple[ToolFn, ...] = (search_videos_v2, query_find_agent_v2_state)
  387. EVIDENCE_TOOLS: tuple[ToolFn, ...] = (
  388. fetch_candidate_details_v2,
  389. fetch_candidate_portraits_v2,
  390. query_pending_candidates_v2,
  391. )
  392. EVALUATION_TOOLS: tuple[ToolFn, ...] = (
  393. understand_candidate_video_30s_v2,
  394. evaluate_candidates_v2,
  395. query_pending_candidates_v2,
  396. )
  397. REPORT_TOOLS: tuple[ToolFn, ...] = (query_find_agent_v2_state,)
  398. def build_tool_registry(functions: Iterable[ToolFn]) -> ToolRegistry:
  399. return ToolRegistry().from_decorated(*tuple(functions))