agent.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347
  1. """
  2. find_agent 工厂 — 组装 Agent 实例。
  3. 每个业务 Agent 都应提供 create_xxx_agent() 工厂函数,
  4. 统一注册本 Agent 的工具 + 共享基础设施工具。
  5. """
  6. from __future__ import annotations
  7. import json
  8. import re
  9. from pathlib import Path
  10. from supply_agent import Agent
  11. from supply_agent.config import Settings
  12. from supply_agent.types import Message, Role
  13. from agents.find_agent.tools import register_all_tools
  14. _PROMPT_PATH = Path(__file__).parent / "prompt" / "system_prompt.md"
  15. FIND_AGENT_SYSTEM_PROMPT = _PROMPT_PATH.read_text(encoding="utf-8")
  16. _EVIDENCE_TOOLS = {
  17. "douyin_detail",
  18. "get_content_fans_portrait",
  19. "get_account_fans_portrait",
  20. "batch_fetch_portraits",
  21. "normalize_age_portraits",
  22. }
  23. _SEARCH_TOOLS = {
  24. "douyin_search",
  25. "douyin_search_tikhub",
  26. "douyin_user_videos",
  27. }
  28. _FAILURE_REPORT_PREFIX = "任务未完成(工具故障)"
  29. _MIN_PRIMARY_TARGET = 5
  30. _VIDEO_ID_PATTERN = re.compile(r"(?<!\d)\d{15,22}(?!\d)")
  31. def _report_section(
  32. content: str,
  33. start_marker: str,
  34. end_markers: tuple[str, ...],
  35. ) -> str | None:
  36. start = content.find(start_marker)
  37. if start < 0:
  38. return None
  39. ends = [
  40. position
  41. for marker in end_markers
  42. if (position := content.find(marker, start + len(start_marker))) >= 0
  43. ]
  44. end = min(ends) if ends else len(content)
  45. return content[start:end]
  46. def _validate_report_buckets(
  47. content: str,
  48. state: dict[str, object],
  49. ) -> str | None:
  50. candidates = state.get("candidates")
  51. if not isinstance(candidates, list):
  52. return "最终状态缺少 candidates,无法校验报告分池"
  53. bucket_by_id = {
  54. str(item.get("aweme_id")): str(item.get("decision_bucket"))
  55. for item in candidates
  56. if isinstance(item, dict) and item.get("aweme_id")
  57. }
  58. run = state.get("run")
  59. run_state = run if isinstance(run, dict) else {}
  60. primary_section = _report_section(
  61. content,
  62. "主推荐",
  63. ("淘汰候选",),
  64. )
  65. rejected_section = _report_section(
  66. content,
  67. "淘汰候选",
  68. ("搜索树", "缺失数据", "总结"),
  69. )
  70. if primary_section is None or rejected_section is None:
  71. return "最终报告必须分别包含“主推荐”和“淘汰候选”段"
  72. primary_ids = set(_VIDEO_ID_PATTERN.findall(primary_section))
  73. rejected_ids = set(_VIDEO_ID_PATTERN.findall(rejected_section))
  74. wrong_primary = sorted(
  75. video_id
  76. for video_id in primary_ids
  77. if bucket_by_id.get(video_id) != "primary"
  78. )
  79. wrong_rejected = sorted(
  80. video_id
  81. for video_id in rejected_ids
  82. if bucket_by_id.get(video_id) != "rejected"
  83. )
  84. if wrong_primary:
  85. return (
  86. "主推荐段包含非 primary 候选: "
  87. + ", ".join(wrong_primary[:5])
  88. + "。必须按数据库 decision_bucket 输出"
  89. )
  90. if wrong_rejected:
  91. return (
  92. "淘汰候选段包含非 rejected 候选: "
  93. + ", ".join(wrong_rejected[:5])
  94. + "。必须按数据库 decision_bucket 输出"
  95. )
  96. primary_count = int(run_state.get("primary_count") or 0)
  97. if primary_count > 0 and not primary_ids:
  98. return "数据库存在 primary 候选,但主推荐段没有输出 aweme_id"
  99. return None
  100. def _primary_count_from_state(state: dict[str, object]) -> int:
  101. run = state.get("run")
  102. if isinstance(run, dict):
  103. return int(run.get("primary_count") or 0)
  104. return 0
  105. def _validate_report_primary_section(
  106. content: str,
  107. state: dict[str, object],
  108. ) -> str | None:
  109. candidates = state.get("candidates")
  110. if not isinstance(candidates, list):
  111. return "最终状态缺少 candidates,无法校验报告分池"
  112. bucket_by_id = {
  113. str(item.get("aweme_id")): str(item.get("decision_bucket"))
  114. for item in candidates
  115. if isinstance(item, dict) and item.get("aweme_id")
  116. }
  117. run = state.get("run")
  118. run_state = run if isinstance(run, dict) else {}
  119. primary_section = _report_section(
  120. content,
  121. "主推荐",
  122. ("淘汰候选", "搜索树", "缺失数据", "总结"),
  123. )
  124. if primary_section is None:
  125. return "最终报告必须包含“主推荐”段"
  126. primary_ids = set(_VIDEO_ID_PATTERN.findall(primary_section))
  127. wrong_primary = sorted(
  128. video_id
  129. for video_id in primary_ids
  130. if bucket_by_id.get(video_id) != "primary"
  131. )
  132. if wrong_primary:
  133. return (
  134. "主推荐段包含非 primary 候选: "
  135. + ", ".join(wrong_primary[:5])
  136. + "。必须按数据库 decision_bucket 输出"
  137. )
  138. primary_count = int(run_state.get("primary_count") or 0)
  139. if primary_count > 0 and not primary_ids:
  140. return "数据库存在 primary 候选,但主推荐段没有输出 aweme_id"
  141. return None
  142. def _guard_relaxed_primary_target(
  143. events: list[tuple[int, str, dict[str, object]]],
  144. messages: list[Message],
  145. ) -> str | None:
  146. """Allow finish when enough primary candidates are persisted."""
  147. evaluation_events = [
  148. (index, payload)
  149. for index, name, payload in events
  150. if name == "batch_save_video_candidate_evaluations"
  151. ]
  152. if not evaluation_events:
  153. return "尚未保存候选评估"
  154. _, evaluation = evaluation_events[-1]
  155. if evaluation.get("status") != "finished":
  156. return "最后一次候选保存尚未把运行状态设置为 finished"
  157. state_events = [
  158. (index, payload)
  159. for index, name, payload in events
  160. if name == "query_video_discovery_state"
  161. ]
  162. if not state_events:
  163. return "尚未查询最终数据库状态"
  164. _, state = state_events[-1]
  165. if _primary_count_from_state(state) < _MIN_PRIMARY_TARGET:
  166. return f"数据库 primary 数量不足 {_MIN_PRIMARY_TARGET} 条"
  167. last_assistant = _last_final_assistant_message(messages)
  168. if last_assistant is None or not (last_assistant.content or "").strip():
  169. return "最终回答为空"
  170. report_error = _validate_report_primary_section(last_assistant.content, state)
  171. if report_error:
  172. return report_error
  173. return None
  174. def _successful_tool_events(
  175. messages: list[Message],
  176. ) -> list[tuple[int, str, dict[str, object]]]:
  177. events: list[tuple[int, str, dict[str, object]]] = []
  178. for index, message in enumerate(messages):
  179. if message.role != Role.TOOL or not message.name or not message.content:
  180. continue
  181. try:
  182. payload = json.loads(message.content)
  183. except (TypeError, json.JSONDecodeError):
  184. continue
  185. if not isinstance(payload, dict) or payload.get("error"):
  186. continue
  187. events.append((index, message.name, payload))
  188. return events
  189. def _last_final_assistant_message(messages: list[Message]) -> Message | None:
  190. return next(
  191. (
  192. message
  193. for message in reversed(messages)
  194. if message.role == Role.ASSISTANT and not message.tool_calls
  195. ),
  196. None,
  197. )
  198. def find_agent_completion_guard(messages: list[Message]) -> str | None:
  199. """Enforce the final search → evidence → evaluation → audit → state → report order."""
  200. last_assistant = _last_final_assistant_message(messages)
  201. content = (last_assistant.content or "").strip() if last_assistant else ""
  202. if content.startswith(_FAILURE_REPORT_PREFIX):
  203. return None
  204. events = _successful_tool_events(messages)
  205. if not any(name == "create_video_discovery_run" for _, name, _ in events):
  206. return "尚未成功创建视频发现运行"
  207. state_events = [
  208. (index, payload)
  209. for index, name, payload in events
  210. if name == "query_video_discovery_state"
  211. ]
  212. if state_events:
  213. _, latest_state = state_events[-1]
  214. if _primary_count_from_state(latest_state) >= _MIN_PRIMARY_TARGET:
  215. return _guard_relaxed_primary_target(events, messages)
  216. search_indexes = [
  217. index
  218. for index, name, _ in events
  219. if name == "record_video_search_page"
  220. ]
  221. if not search_indexes:
  222. return "尚未持久化任何搜索页"
  223. last_search_index = max(search_indexes)
  224. raw_search_indexes = [
  225. index for index, name, _ in events if name in _SEARCH_TOOLS
  226. ]
  227. if raw_search_indexes and last_search_index < max(raw_search_indexes):
  228. return "最后一次搜索结果尚未通过 record_video_search_page 持久化"
  229. evidence_indexes = [
  230. index for index, name, _ in events if name in _EVIDENCE_TOOLS
  231. ]
  232. if not evidence_indexes:
  233. return "尚未获取并整理候选证据"
  234. last_evidence_index = max(evidence_indexes)
  235. if last_evidence_index < last_search_index:
  236. return "最后一次搜索后尚未重新获取并整理候选证据"
  237. evaluation_events = [
  238. (index, payload)
  239. for index, name, payload in events
  240. if name == "batch_save_video_candidate_evaluations"
  241. ]
  242. if not evaluation_events:
  243. return "尚未保存候选评估"
  244. evaluation_index, evaluation = evaluation_events[-1]
  245. if evaluation_index < last_evidence_index:
  246. return "最后一次证据获取发生在候选评估之后,证据尚未重新评估并保存"
  247. if evaluation.get("status") != "finished":
  248. return "最后一次候选保存尚未把运行状态设置为 finished"
  249. audit_events = [
  250. (index, payload)
  251. for index, name, payload in events
  252. if name == "audit_video_discovery_run"
  253. ]
  254. if not audit_events:
  255. return "尚未调用 audit_video_discovery_run 执行数据库完成审计"
  256. audit_index, audit = audit_events[-1]
  257. if audit_index < evaluation_index:
  258. return "候选评估晚于最后一次审计,请重新调用 audit_video_discovery_run"
  259. if audit.get("can_finish") is not True:
  260. violations = audit.get("critical_violations")
  261. if isinstance(violations, list) and violations:
  262. summary = ";".join(str(item) for item in violations[:5])
  263. return f"最后一次审计未通过:{summary}"
  264. return "最后一次审计未通过"
  265. state_events = [
  266. (index, payload)
  267. for index, name, payload in events
  268. if name == "query_video_discovery_state"
  269. ]
  270. if not state_events:
  271. return "尚未查询最终数据库状态"
  272. state_index, state = state_events[-1]
  273. if state_index < audit_index:
  274. return "最终状态查询必须在审计通过之后执行"
  275. last_assistant = _last_final_assistant_message(messages)
  276. if last_assistant is None or not (last_assistant.content or "").strip():
  277. return "最终回答为空"
  278. report_error = _validate_report_buckets(last_assistant.content, state)
  279. if report_error:
  280. return report_error
  281. return None
  282. def create_find_agent(
  283. settings: Settings | None = None,
  284. *,
  285. model: str | None = None,
  286. ) -> Agent:
  287. """创建 find_agent 实例,注册所有相关工具。"""
  288. agent = Agent(
  289. settings=settings,
  290. name="find_agent",
  291. model="google/gemini-3-flash-preview",
  292. system_prompt=FIND_AGENT_SYSTEM_PROMPT,
  293. max_iterations=60,
  294. temperature=0.2,
  295. completion_guard=find_agent_completion_guard,
  296. tool_repeat_requires_change={
  297. "query_video_discovery_state": {
  298. "record_video_search_page",
  299. "batch_save_video_candidate_evaluations",
  300. "audit_video_discovery_run",
  301. },
  302. },
  303. )
  304. # 本 Agent 专属工具
  305. register_all_tools(agent.tools)
  306. return agent