tools.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381
  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. @tool
  174. async def search_videos_v2(run_id: str, round_index: int, searches: list[dict[str, Any]]) -> str:
  175. """批量搜索并仅写入 find_agent_v2_search/candidate;searches 最多 6 项。"""
  176. if not searches or len(searches) > 6:
  177. return json.dumps({"error": "searches 必须为 1~6 项"}, ensure_ascii=False)
  178. service = get_find_agent_v2_service()
  179. outputs: list[dict[str, Any]] = []
  180. for raw_task in searches:
  181. keyword = str(raw_task.get("keyword") or "").strip()
  182. reason = str(raw_task.get("query_reason") or "").strip()
  183. provider = str(raw_task.get("provider") or "internal_keyword")
  184. if not keyword or not reason:
  185. outputs.append({"error": "keyword/query_reason 不能为空"})
  186. continue
  187. max_pages = max(1, min(int(raw_task.get("max_pages") or 1), 2))
  188. cursor: str | int = raw_task.get("cursor") or 0
  189. provider_search_id = str(raw_task.get("search_id") or "")
  190. backtrace = str(raw_task.get("backtrace") or "")
  191. for page_no in range(1, max_pages + 1):
  192. common = {
  193. "keyword": keyword,
  194. "content_type": str(raw_task.get("content_type") or "视频"),
  195. "sort_type": str(raw_task.get("sort_type") or "综合排序"),
  196. "publish_time": str(raw_task.get("publish_time") or "不限"),
  197. "min_duration_seconds": int(raw_task.get("min_duration_seconds") or 30),
  198. }
  199. if provider == "tikhub":
  200. payload = await search_tikhub(
  201. **common,
  202. cursor=int(cursor or 0),
  203. filter_duration=str(raw_task.get("filter_duration") or "不限"),
  204. search_id=provider_search_id,
  205. backtrace=backtrace,
  206. )
  207. else:
  208. provider = "internal_keyword"
  209. payload = await search_internal(**common, cursor=str(cursor or "0"))
  210. saved = service.save_search(
  211. run_id=run_id,
  212. round_index=int(round_index),
  213. keyword=keyword,
  214. query_reason=reason,
  215. source_type=str(raw_task.get("source_type") or "mixed"),
  216. provider=provider,
  217. cursor=str(cursor),
  218. page_no=page_no,
  219. payload=payload,
  220. )
  221. outputs.append({
  222. "keyword": keyword,
  223. "provider": provider,
  224. "page_no": page_no,
  225. "error": payload.get("error"),
  226. "has_more": bool(payload.get("has_more")),
  227. "next_cursor": payload.get("next_cursor"),
  228. **saved,
  229. })
  230. if payload.get("error") or not payload.get("has_more"):
  231. break
  232. cursor = payload.get("next_cursor") or cursor
  233. provider_search_id = str(payload.get("search_id") or provider_search_id)
  234. backtrace = str(payload.get("backtrace") or backtrace)
  235. return json.dumps({"run_id": run_id, "searches": outputs}, ensure_ascii=False)
  236. @tool
  237. async def fetch_candidate_details_v2(run_id: str, candidate_ids: list[int]) -> str:
  238. """批量获取候选详情并仅写入 find_agent_v2_candidate/evidence;最多 8 项。"""
  239. service = get_find_agent_v2_service()
  240. candidates = service.candidate_inputs(run_id, candidate_ids[:8])
  241. requested_aweme_ids = [str(item["aweme_id"]) for item in candidates]
  242. payload = await fetch_details(requested_aweme_ids)
  243. details = list(payload.get("details") or [])
  244. errors = list(payload.get("errors") or [])
  245. service.save_details(
  246. run_id,
  247. details,
  248. errors,
  249. requested_aweme_ids=requested_aweme_ids,
  250. )
  251. successful_ids = {
  252. str(item.get("content_id") or "") for item in details
  253. } & set(requested_aweme_ids)
  254. error_by_id = {str(item.get("content_id") or ""): item for item in errors}
  255. normalized_errors = [
  256. error_by_id.get(aweme_id) or {
  257. "content_id": aweme_id,
  258. "error": "上游详情响应未包含已请求候选",
  259. "error_code": "UPSTREAM_RESULT_MISSING",
  260. }
  261. for aweme_id in requested_aweme_ids
  262. if aweme_id not in successful_ids
  263. ]
  264. return json.dumps({
  265. "run_id": run_id,
  266. "success_count": len(successful_ids),
  267. "failed_count": len(normalized_errors),
  268. "errors": normalized_errors,
  269. }, ensure_ascii=False)
  270. @tool
  271. async def fetch_candidate_portraits_v2(run_id: str, candidate_ids: list[int]) -> str:
  272. """批量获取候选双侧年龄画像并仅写入 find_agent_v2_candidate/evidence。"""
  273. service = get_find_agent_v2_service()
  274. candidates = service.candidate_inputs(run_id, candidate_ids[:8])
  275. requested_aweme_ids = [str(item["aweme_id"]) for item in candidates]
  276. payload = await fetch_portraits([{
  277. "aweme_id": item["aweme_id"],
  278. "author_sec_uid": item.get("author_sec_uid"),
  279. } for item in candidates])
  280. results = list(payload.get("results") or [])
  281. service.save_portraits(
  282. run_id,
  283. results,
  284. requested_aweme_ids=requested_aweme_ids,
  285. )
  286. result_by_id = {str(item.get("aweme_id") or ""): item for item in results}
  287. successful_ids = {
  288. aweme_id
  289. for aweme_id in requested_aweme_ids
  290. if aweme_id in result_by_id and not result_by_id[aweme_id].get("error")
  291. }
  292. errors = [
  293. result_by_id.get(aweme_id) or {
  294. "aweme_id": aweme_id,
  295. "error": "上游画像响应未包含已请求候选",
  296. "error_code": "UPSTREAM_RESULT_MISSING",
  297. }
  298. for aweme_id in requested_aweme_ids
  299. if aweme_id not in successful_ids
  300. ]
  301. return json.dumps({
  302. "run_id": run_id,
  303. "count": len(results),
  304. "success_count": len(successful_ids),
  305. "failed_count": len(errors),
  306. "errors": errors,
  307. "results": results,
  308. }, ensure_ascii=False)
  309. @tool
  310. def evaluate_candidates_v2(run_id: str, items: list[CandidateEvaluation]) -> str:
  311. """按 candidate_id 写入完整分片的 R/E/S/V 与分池;不要用 aweme_id 代替。"""
  312. payload = [item.model_dump(exclude_none=True) if isinstance(item, CandidateEvaluation) else item for item in items]
  313. updated = get_find_agent_v2_service().evaluate(run_id, payload)
  314. return json.dumps({"run_id": run_id, "updated": updated}, ensure_ascii=False)
  315. @tool
  316. def query_find_agent_v2_state(run_id: str, limit: int = 100) -> str:
  317. """查询完全隔离的 find_agent_v2 运行、搜索与候选状态。"""
  318. state = get_find_agent_v2_service().get_full_state(run_id, limit=limit)
  319. return json.dumps(state, ensure_ascii=False, default=str)
  320. @tool
  321. def query_pending_candidates_v2(run_id: str, limit: int = 100) -> str:
  322. """仅查询当前 run 尚未分池的 pending_evaluation 候选。"""
  323. state = get_find_agent_v2_service().get_full_state(
  324. run_id, limit=limit, pending_only=True,
  325. )
  326. return json.dumps(state, ensure_ascii=False, default=str)
  327. SEARCH_TOOLS: tuple[ToolFn, ...] = (search_videos_v2, query_find_agent_v2_state)
  328. EVIDENCE_TOOLS: tuple[ToolFn, ...] = (
  329. fetch_candidate_details_v2,
  330. fetch_candidate_portraits_v2,
  331. query_pending_candidates_v2,
  332. )
  333. EVALUATION_TOOLS: tuple[ToolFn, ...] = (
  334. understand_candidate_video_30s_v2,
  335. evaluate_candidates_v2,
  336. query_pending_candidates_v2,
  337. )
  338. REPORT_TOOLS: tuple[ToolFn, ...] = (query_find_agent_v2_state,)
  339. def build_tool_registry(functions: Iterable[ToolFn]) -> ToolRegistry:
  340. return ToolRegistry().from_decorated(*tuple(functions))