graph.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763
  1. """One autonomous but policy-guarded business round compiled as LangGraph."""
  2. from __future__ import annotations
  3. import asyncio
  4. import json
  5. import re
  6. from typing import Any, Protocol
  7. from langgraph.graph import END, START, StateGraph
  8. from find_agent_v2.context import assignment_slots, render_assignment
  9. from find_agent_v2.gates import evaluate_candidate_gate
  10. from find_agent_v2.observability import InputSlot, NullObserver, graph_spec_for
  11. from find_agent_v2.prompts import (
  12. EVALUATOR_PROMPT,
  13. EVIDENCE_PROMPT,
  14. PLANNER_PROMPT,
  15. SEARCH_PROMPT,
  16. SUPERVISOR_PROMPT,
  17. )
  18. from find_agent_v2.service import FindAgentV2Service
  19. from find_agent_v2.state import (
  20. EvidenceAssignment,
  21. EvaluationAssignment,
  22. ExecutionPlan,
  23. FindAgentGraphState,
  24. FindAgentState,
  25. NodeRun,
  26. PlannerAssignment,
  27. PlanningDecision,
  28. SearchAssignment,
  29. SupervisorAssignment,
  30. SupervisorDecision,
  31. )
  32. from find_agent_v2.tools import (
  33. EVALUATION_TOOLS,
  34. EVIDENCE_TOOLS,
  35. SEARCH_TOOLS,
  36. ToolFn,
  37. bound_candidate_tools,
  38. normalize_evaluation_items,
  39. )
  40. class NodeRunner(Protocol):
  41. async def run_node(
  42. self,
  43. *,
  44. node: str,
  45. round_index: int,
  46. system_prompt: str,
  47. user_content: str,
  48. tools: tuple[ToolFn, ...] = (),
  49. max_iterations: int = 12,
  50. slots: tuple[InputSlot, ...] = (),
  51. branch_key: str = "",
  52. allow_delegation: bool = True,
  53. ) -> NodeRun: ...
  54. class FindAgentRoundGraph:
  55. """Supervisor-directed graph with deterministic policy approval."""
  56. def __init__(
  57. self,
  58. *,
  59. service: FindAgentV2Service,
  60. runner: NodeRunner,
  61. observer=None,
  62. max_actions: int = 16,
  63. max_search_actions: int = 3,
  64. ) -> None:
  65. self.service = service
  66. self.runner = runner
  67. self.observer = observer or NullObserver()
  68. self.max_actions = max(4, int(max_actions))
  69. self.max_search_actions = max(1, int(max_search_actions))
  70. self.app = self._build_graph()
  71. self.obagent_spec = graph_spec_for(self.app)
  72. @staticmethod
  73. def _assignment_input(assignment, *, source: str) -> tuple[str, tuple[InputSlot, ...]]:
  74. return render_assignment(assignment), assignment_slots(assignment, source=source)
  75. def _supervisor_state(self, state: FindAgentGraphState) -> dict[str, Any]:
  76. """Compact progress projection; routing never needs full candidate payloads."""
  77. run = self.service.require_run(state["run_id"])
  78. return {
  79. "run": {
  80. key: run.get(key)
  81. for key in (
  82. "status", "outcome_status", "current_round", "search_count",
  83. "candidate_count", "valid_primary_count",
  84. )
  85. },
  86. "searches": [
  87. {
  88. key: item.get(key)
  89. for key in (
  90. "keyword", "provider", "status", "result_count", "has_more",
  91. )
  92. }
  93. for item in self.service.get_search_summaries(state["run_id"])
  94. ],
  95. "candidate_progress": self.service.get_candidate_progress(state["run_id"]),
  96. }
  97. def _deterministic_supervisor_decision(
  98. self, state: FindAgentGraphState,
  99. ) -> SupervisorDecision | None:
  100. """Route state-machine steps in code; reserve the LLM for exploration choices."""
  101. progress = self.service.get_candidate_progress(state["run_id"])
  102. pending = int(progress.get("pending_count") or 0)
  103. if not pending:
  104. return None
  105. detail_pending = int(progress.get("detail_pending_count") or 0)
  106. portrait_pending = int(progress.get("portrait_pending_count") or 0)
  107. worker_count = max(1, min(8, int(state.get("worker_count") or 4)))
  108. if detail_pending or portrait_pending:
  109. scope = (
  110. "both" if detail_pending and portrait_pending
  111. else "detail" if detail_pending
  112. else "portrait"
  113. )
  114. return SupervisorDecision(
  115. next_action="evidence",
  116. reason="宿主检测到待补证候选,执行确定性证据路由",
  117. worker_count=worker_count,
  118. evidence_scope=scope,
  119. )
  120. return SupervisorDecision(
  121. next_action="evaluator",
  122. reason="宿主确认全部证据尝试已结束,执行确定性评估路由",
  123. worker_count=worker_count,
  124. evidence_scope="both",
  125. )
  126. @staticmethod
  127. def _supervisor_plan_projection(execution_plan: ExecutionPlan) -> dict[str, Any]:
  128. """Exclude executed SearchTask details already represented by search summaries."""
  129. return {
  130. "schema_version": execution_plan.schema_version,
  131. "demand_brief": execution_plan.demand_brief.model_dump(mode="json"),
  132. "evaluation_brief": execution_plan.evaluation_brief.model_dump(mode="json"),
  133. }
  134. @staticmethod
  135. def _evaluation_candidate_projection(item: dict[str, Any]) -> dict[str, Any]:
  136. keys = (
  137. "candidate_id", "aweme_id", "title", "video_url", "source_keywords", "tags",
  138. "publish_at", "duration_seconds", "play_count", "like_count", "comment_count",
  139. "collect_count", "share_count", "content_50_plus_ratio", "account_50_plus_ratio",
  140. "detail_status", "portrait_status",
  141. )
  142. return {key: item.get(key) for key in keys if item.get(key) is not None}
  143. def _video_understanding_ids(
  144. self,
  145. state: FindAgentGraphState,
  146. items: list[dict[str, Any]],
  147. ) -> list[int]:
  148. """Only candidates passing every deterministic hard gate may use video understanding."""
  149. run = self.service.require_run(state["run_id"])
  150. rules = run.get("rule_config") or {}
  151. selected: list[int] = []
  152. for item in items:
  153. gate = evaluate_candidate_gate(item, rules)
  154. checks = list(gate.get("checks") or [])
  155. hard_gate_passed = gate.get("status") == "pass" and all(
  156. check.get("status") == "pass" and not check.get("compensated") for check in checks
  157. )
  158. if str(item.get("video_url") or "").strip() and hard_gate_passed:
  159. selected.append(int(item["candidate_id"]))
  160. return selected
  161. @staticmethod
  162. def _parse_supervisor(content: str) -> dict[str, Any]:
  163. text = content.strip()
  164. fenced = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.S)
  165. if fenced:
  166. text = fenced.group(1)
  167. else:
  168. start, end = text.find("{"), text.rfind("}")
  169. if start >= 0 and end > start:
  170. text = text[start : end + 1]
  171. try:
  172. value = json.loads(text)
  173. return value if isinstance(value, dict) else {}
  174. except (TypeError, ValueError):
  175. return {}
  176. def _approve_action(
  177. self,
  178. state: FindAgentGraphState,
  179. proposal: dict[str, Any],
  180. ) -> tuple[str, str, int, str]:
  181. """Turn an LLM proposal into a safe, executable transition."""
  182. progress = self.service.get_candidate_progress(state["run_id"])
  183. pending_count = int(progress.get("pending_count") or 0)
  184. missing_detail = int(progress.get("detail_pending_count") or 0) > 0
  185. missing_portrait = int(progress.get("portrait_pending_count") or 0) > 0
  186. proposed = str(proposal.get("next_action") or "").lower()
  187. reason = str(proposal.get("reason") or "")
  188. searches = int(state.get("search_actions") or 0)
  189. actions = int(state.get("action_count") or 0)
  190. try:
  191. worker_count = max(1, min(8, int(proposal.get("worker_count") or 4)))
  192. except (TypeError, ValueError):
  193. worker_count = 4
  194. scope = str(proposal.get("evidence_scope") or "both").lower()
  195. if scope not in {"detail", "portrait", "both"}:
  196. scope = "both"
  197. if actions > self.max_actions:
  198. if not pending_count:
  199. return "finish", "安全收敛动作已完成", worker_count, scope
  200. raise RuntimeError(
  201. f"Supervisor 安全收敛动作未产生进展:actions={actions}, pending={pending_count}"
  202. )
  203. if actions == self.max_actions:
  204. if pending_count and not (missing_detail or missing_portrait):
  205. return "evaluator", "动作预算耗尽,强制消费待评估候选", worker_count, scope
  206. if pending_count:
  207. return "evidence", "动作预算耗尽,强制补齐缺失证据", worker_count, "both"
  208. return "finish", "达到单轮动作安全上限", worker_count, scope
  209. if pending_count:
  210. if missing_detail or missing_portrait:
  211. if scope == "detail" and not missing_detail:
  212. scope = "portrait"
  213. elif scope == "portrait" and not missing_portrait:
  214. scope = "detail"
  215. return "evidence", reason or "存在缺失证据,框架要求先补证", worker_count, scope
  216. return "evaluator", reason or "候选证据已处理,进入评估", worker_count, scope
  217. if searches == 0:
  218. return "search", reason or "本轮尚未搜索,框架要求先建立候选池", worker_count, scope
  219. if proposed == "search" and searches < self.max_search_actions:
  220. return "search", reason or "Supervisor 判断继续搜索仍有信息增益", worker_count, scope
  221. return "finish", reason or "没有待处理候选,结束本轮", worker_count, scope
  222. async def _supervisor(self, state: FindAgentGraphState) -> dict[str, Any]:
  223. execution_plan = state.get("execution_plan")
  224. observed_decision: dict[str, Any] = {}
  225. if execution_plan is None:
  226. assignment = PlannerAssignment(
  227. run_id=state["run_id"],
  228. round_index=state["round_index"],
  229. raw_demand=state["user_input"],
  230. )
  231. user_content, slots = self._assignment_input(
  232. assignment,
  233. source="find_agent_v2_run.input_json",
  234. )
  235. structured_runner = getattr(self.runner, "run_planning", None)
  236. if callable(structured_runner):
  237. run, planning = await structured_runner(
  238. round_index=state["round_index"],
  239. system_prompt=PLANNER_PROMPT,
  240. user_content=user_content,
  241. slots=slots,
  242. )
  243. else:
  244. run = await self.runner.run_node(
  245. node="supervisor",
  246. round_index=state["round_index"],
  247. system_prompt=PLANNER_PROMPT,
  248. user_content=user_content,
  249. tools=(),
  250. max_iterations=2,
  251. slots=slots,
  252. allow_delegation=False,
  253. )
  254. planning = PlanningDecision.model_validate(self._parse_supervisor(run.content))
  255. execution_plan = planning.execution_plan
  256. proposal = planning.model_dump(mode="json", exclude={"execution_plan"})
  257. else:
  258. assignment = SupervisorAssignment(
  259. run_id=state["run_id"],
  260. round_index=state["round_index"],
  261. execution_plan=self._supervisor_plan_projection(execution_plan),
  262. execution_state=self._supervisor_state(state),
  263. )
  264. user_content, slots = self._assignment_input(
  265. assignment,
  266. source="ExecutionPlan compact projection + database aggregates",
  267. )
  268. structured_runner = getattr(self.runner, "run_supervision", None)
  269. host_runner = getattr(self.runner, "run_host_supervision", None)
  270. def approve_for_observation(supervision: SupervisorDecision) -> dict[str, Any]:
  271. proposal_payload = supervision.model_dump(mode="json")
  272. approved, approved_reason, approved_workers, approved_scope = (
  273. self._approve_action(state, proposal_payload)
  274. )
  275. decision_payload = {
  276. "step": int(state.get("supervisor_step") or 0) + 1,
  277. "proposed_action": proposal_payload.get("next_action"),
  278. "approved_action": approved,
  279. "overridden": proposal_payload.get("next_action") != approved,
  280. "reason": approved_reason,
  281. "worker_count": approved_workers,
  282. "evidence_scope": approved_scope,
  283. }
  284. observed_decision.update(decision_payload)
  285. return {"Supervisor决策校验": decision_payload}
  286. deterministic = (
  287. self._deterministic_supervisor_decision(state)
  288. if callable(host_runner)
  289. else None
  290. )
  291. if deterministic is not None:
  292. run, supervision = await host_runner(
  293. round_index=state["round_index"],
  294. decision=deterministic,
  295. system_prompt=SUPERVISOR_PROMPT,
  296. user_content=user_content,
  297. slots=slots,
  298. output_enricher=approve_for_observation,
  299. )
  300. elif callable(structured_runner):
  301. run, supervision = await structured_runner(
  302. round_index=state["round_index"],
  303. system_prompt=SUPERVISOR_PROMPT,
  304. user_content=user_content,
  305. slots=slots,
  306. output_enricher=approve_for_observation,
  307. )
  308. else:
  309. run = await self.runner.run_node(
  310. node="supervisor",
  311. round_index=state["round_index"],
  312. system_prompt=SUPERVISOR_PROMPT,
  313. user_content=user_content,
  314. tools=(),
  315. max_iterations=2,
  316. slots=slots,
  317. allow_delegation=False,
  318. )
  319. supervision = SupervisorDecision.model_validate(
  320. self._parse_supervisor(run.content),
  321. )
  322. proposal = supervision.model_dump(mode="json")
  323. if supervision.additional_search_tasks:
  324. known = {item.task_id for item in execution_plan.search_tasks}
  325. merged = list(execution_plan.search_tasks)
  326. for item in supervision.additional_search_tasks:
  327. if item.task_id not in known and len(merged) < 24:
  328. merged.append(item)
  329. known.add(item.task_id)
  330. if len(merged) != len(execution_plan.search_tasks):
  331. execution_plan = type(execution_plan).model_validate(
  332. {
  333. **execution_plan.model_dump(),
  334. "search_tasks": merged,
  335. }
  336. )
  337. if observed_decision:
  338. decision = observed_decision
  339. action = str(decision["approved_action"])
  340. reason = str(decision["reason"])
  341. workers = int(decision["worker_count"])
  342. scope = str(decision["evidence_scope"])
  343. else:
  344. action, reason, workers, scope = self._approve_action(state, proposal)
  345. decision = {
  346. "step": int(state.get("supervisor_step") or 0) + 1,
  347. "proposed_action": proposal.get("next_action"),
  348. "approved_action": action,
  349. "overridden": proposal.get("next_action") != action,
  350. "reason": reason,
  351. "worker_count": workers,
  352. "evidence_scope": scope,
  353. }
  354. self.service.update_round(
  355. state["run_id"],
  356. state["round_index"],
  357. phase="planning",
  358. plan=execution_plan.model_dump_json(exclude_none=True),
  359. )
  360. return {
  361. "phase": "planning",
  362. "execution_plan": execution_plan,
  363. "approved_action": action,
  364. "supervisor_step": decision["step"],
  365. "worker_count": workers,
  366. "evidence_scope": scope,
  367. "decision_history": [*state.get("decision_history", []), decision],
  368. "node_runs": [*state.get("node_runs", []), run],
  369. }
  370. async def _search(self, state: FindAgentGraphState) -> dict[str, Any]:
  371. self.service.update_round(state["run_id"], state["round_index"], phase="searching")
  372. execution_plan = state.get("execution_plan")
  373. if execution_plan is None:
  374. raise RuntimeError("Search 缺少已校验的 ExecutionPlan")
  375. existing = {
  376. (
  377. str(item.get("keyword") or ""),
  378. str(item.get("provider") or ""),
  379. str(item.get("query_reason") or ""),
  380. )
  381. for item in self.service.get_search_summaries(state["run_id"])
  382. }
  383. tasks = [
  384. item
  385. for item in execution_plan.search_tasks
  386. if (item.keyword, item.provider, item.query_reason) not in existing
  387. ][:6]
  388. assignment = SearchAssignment(
  389. run_id=state["run_id"],
  390. round_index=state["round_index"],
  391. tasks=tasks,
  392. )
  393. if not tasks:
  394. return {
  395. "phase": "searching",
  396. "search_actions": int(state.get("search_actions") or 0) + 1,
  397. "action_count": int(state.get("action_count") or 0) + 1,
  398. "node_runs": [
  399. *state.get("node_runs", []),
  400. NodeRun("search", state["round_index"], "没有未执行的搜索任务", 0, 0),
  401. ],
  402. }
  403. user_content, slots = self._assignment_input(
  404. assignment,
  405. source="ExecutionPlan.search_tasks",
  406. )
  407. host_runner = getattr(self.runner, "run_search_assignment", None)
  408. if callable(host_runner):
  409. run = await host_runner(
  410. round_index=state["round_index"],
  411. assignment=assignment,
  412. system_prompt=SEARCH_PROMPT,
  413. user_content=user_content,
  414. slots=slots,
  415. )
  416. else:
  417. run = await self.runner.run_node(
  418. node="search",
  419. round_index=state["round_index"],
  420. system_prompt=SEARCH_PROMPT,
  421. user_content=user_content,
  422. tools=SEARCH_TOOLS,
  423. max_iterations=10,
  424. slots=slots,
  425. )
  426. return {
  427. "phase": "searching",
  428. "search_actions": int(state.get("search_actions") or 0) + 1,
  429. "action_count": int(state.get("action_count") or 0) + 1,
  430. "node_runs": [*state.get("node_runs", []), run],
  431. }
  432. async def _evidence(self, state: FindAgentGraphState) -> dict[str, Any]:
  433. self.service.update_round(state["run_id"], state["round_index"], phase="evidence")
  434. scope = state.get("evidence_scope", "both")
  435. worker_count = max(1, min(8, int(state.get("worker_count") or 4)))
  436. batch_limit = worker_count * 8
  437. evidence_types = (
  438. ("detail",) if scope == "detail"
  439. else ("portrait",) if scope == "portrait"
  440. else ("detail", "portrait")
  441. )
  442. semaphore = asyncio.Semaphore(worker_count)
  443. node_runs = list(state.get("node_runs", []))
  444. batch_index = 0
  445. async def run_shard(
  446. branch_key: str,
  447. evidence_type: str,
  448. items: list[dict[str, Any]],
  449. ) -> NodeRun:
  450. candidate_ids = [int(item["candidate_id"]) for item in items]
  451. assignment = EvidenceAssignment(
  452. run_id=state["run_id"],
  453. round_index=state["round_index"],
  454. candidate_ids=candidate_ids,
  455. evidence_type=evidence_type,
  456. candidates=items,
  457. )
  458. user_content, slots = self._assignment_input(
  459. assignment,
  460. source="host evidence shard",
  461. )
  462. selected = (
  463. (EVIDENCE_TOOLS[0], EVIDENCE_TOOLS[2])
  464. if evidence_type == "detail"
  465. else (EVIDENCE_TOOLS[1], EVIDENCE_TOOLS[2])
  466. )
  467. async with semaphore:
  468. host_runner = getattr(self.runner, "run_evidence_assignment", None)
  469. if callable(host_runner):
  470. return await host_runner(
  471. round_index=state["round_index"],
  472. assignment=assignment,
  473. system_prompt=EVIDENCE_PROMPT,
  474. user_content=user_content,
  475. slots=slots,
  476. branch_key=branch_key,
  477. )
  478. return await self.runner.run_node(
  479. node="evidence",
  480. round_index=state["round_index"],
  481. system_prompt=EVIDENCE_PROMPT,
  482. user_content=user_content,
  483. tools=bound_candidate_tools(
  484. selected,
  485. run_id=state["run_id"],
  486. candidate_ids=candidate_ids,
  487. ),
  488. max_iterations=12,
  489. slots=slots,
  490. branch_key=branch_key,
  491. allow_delegation=False,
  492. )
  493. while True:
  494. batch_index += 1
  495. jobs: list[tuple[str, list[dict[str, Any]]]] = []
  496. selected_ids: dict[str, list[int]] = {}
  497. for evidence_type in evidence_types:
  498. candidate_ids = self.service.list_pending_evidence_ids(
  499. state["run_id"], evidence_type, limit=batch_limit,
  500. )
  501. selected_ids[evidence_type] = candidate_ids
  502. candidates = self.service.candidate_inputs(state["run_id"], candidate_ids)
  503. jobs.extend(
  504. (evidence_type, candidates[index : index + 8])
  505. for index in range(0, len(candidates), 8)
  506. )
  507. if not jobs:
  508. break
  509. runs = await asyncio.gather(*(
  510. run_shard(
  511. f"{evidence_type}-batch-{batch_index}-shard-{index}",
  512. evidence_type,
  513. items,
  514. )
  515. for index, (evidence_type, items) in enumerate(jobs, start=1)
  516. ))
  517. node_runs.extend(runs)
  518. for evidence_type, candidate_ids in selected_ids.items():
  519. status_key = f"{evidence_type}_status"
  520. remaining = [
  521. int(item["candidate_id"])
  522. for item in self.service.candidate_inputs(state["run_id"], candidate_ids)
  523. if item.get(status_key) == "pending"
  524. ]
  525. if remaining:
  526. raise RuntimeError(
  527. f"证据分批未完整消费:type={evidence_type}, pending={remaining}"
  528. )
  529. return {
  530. "phase": "evidence",
  531. "action_count": int(state.get("action_count") or 0) + 1,
  532. "node_runs": node_runs,
  533. }
  534. async def _evaluator(self, state: FindAgentGraphState) -> dict[str, Any]:
  535. self.service.update_round(state["run_id"], state["round_index"], phase="evaluating")
  536. node_runs = list(state.get("node_runs", []))
  537. run = self.service.require_run(state["run_id"])
  538. rules = run.get("rule_config") or {}
  539. worker_count = max(1, min(8, int(state.get("worker_count") or 4)))
  540. batch_limit = worker_count * 8
  541. semaphore = asyncio.Semaphore(worker_count)
  542. batch_index = 0
  543. async def run_shard(
  544. batch_number: int,
  545. index: int,
  546. items: list[dict[str, Any]],
  547. ) -> NodeRun:
  548. candidate_ids = [int(item["candidate_id"]) for item in items]
  549. video_understanding_ids = self._video_understanding_ids(state, items)
  550. execution_plan = state.get("execution_plan")
  551. if execution_plan is None:
  552. raise RuntimeError("Evaluator 缺少已校验的 ExecutionPlan")
  553. assignment = EvaluationAssignment(
  554. run_id=state["run_id"],
  555. round_index=state["round_index"],
  556. candidate_ids=candidate_ids,
  557. evaluation_brief=execution_plan.evaluation_brief,
  558. quality_gate_rules=rules,
  559. current_datetime=str(rules.get("current_datetime") or ""),
  560. timezone=str(rules.get("timezone") or ""),
  561. video_understanding_candidate_ids=video_understanding_ids,
  562. candidates=[self._evaluation_candidate_projection(item) for item in items],
  563. )
  564. user_content, slots = self._assignment_input(
  565. assignment,
  566. source="ExecutionPlan.evaluation_brief + host candidate shard",
  567. )
  568. video_tools = (
  569. bound_candidate_tools(
  570. (EVALUATION_TOOLS[0],),
  571. run_id=state["run_id"],
  572. candidate_ids=video_understanding_ids,
  573. )
  574. if video_understanding_ids
  575. else ()
  576. )
  577. async with semaphore:
  578. structured_runner = getattr(self.runner, "run_evaluation", None)
  579. if callable(structured_runner):
  580. run, proposed = await structured_runner(
  581. round_index=state["round_index"],
  582. system_prompt=EVALUATOR_PROMPT,
  583. user_content=user_content,
  584. slots=slots,
  585. branch_key=(
  586. f"step-{state.get('supervisor_step', 0)}-"
  587. f"batch-{batch_number}-shard-{index}"
  588. ),
  589. tools=video_tools,
  590. )
  591. normalized = normalize_evaluation_items(
  592. proposed,
  593. allowed_candidates=items,
  594. )
  595. updated = self.service.evaluate(state["run_id"], normalized)
  596. updated_ids = {int(item["candidate_id"]) for item in updated}
  597. if updated_ids != set(candidate_ids):
  598. raise RuntimeError(
  599. "评估写入结果不完整:"
  600. f"expected={candidate_ids}, updated={sorted(updated_ids)}"
  601. )
  602. return run
  603. return await self.runner.run_node(
  604. node="evaluator",
  605. round_index=state["round_index"],
  606. system_prompt=EVALUATOR_PROMPT,
  607. user_content=user_content,
  608. tools=(
  609. *video_tools,
  610. *bound_candidate_tools(
  611. EVALUATION_TOOLS[1:],
  612. run_id=state["run_id"],
  613. candidate_ids=candidate_ids,
  614. ),
  615. ),
  616. max_iterations=12,
  617. slots=slots,
  618. branch_key=(
  619. f"step-{state.get('supervisor_step', 0)}-"
  620. f"batch-{batch_number}-shard-{index}"
  621. ),
  622. allow_delegation=False,
  623. )
  624. while True:
  625. batch_index += 1
  626. candidate_ids = self.service.list_ready_evaluation_ids(
  627. state["run_id"], limit=batch_limit,
  628. )
  629. if not candidate_ids:
  630. break
  631. items = self.service.candidate_inputs(state["run_id"], candidate_ids)
  632. eligible: list[dict[str, Any]] = []
  633. gate_failures: list[tuple[int, dict[str, Any]]] = []
  634. for item in items:
  635. gate = evaluate_candidate_gate(item, rules)
  636. if gate.get("status") == "pass":
  637. eligible.append(item)
  638. else:
  639. gate_failures.append((int(item["candidate_id"]), gate))
  640. self.service.reject_failed_gates(state["run_id"], gate_failures)
  641. shards = [eligible[index : index + 8] for index in range(0, len(eligible), 8)]
  642. runs = await asyncio.gather(*(
  643. run_shard(batch_index, index, shard)
  644. for index, shard in enumerate(shards, start=1)
  645. )) if shards else []
  646. node_runs.extend(runs)
  647. remaining = [
  648. int(item["candidate_id"])
  649. for item in self.service.candidate_inputs(state["run_id"], candidate_ids)
  650. if item.get("decision_bucket") == "pending_evaluation"
  651. ]
  652. if remaining:
  653. raise RuntimeError(f"评估分批未完整消费:pending={remaining}")
  654. self.service.recount_valid_primary(state["run_id"])
  655. return {
  656. "phase": "evaluating",
  657. "node_runs": node_runs,
  658. "action_count": int(state.get("action_count") or 0) + 1,
  659. "evaluator_stagnation": 0,
  660. }
  661. @staticmethod
  662. def _route(state: FindAgentGraphState) -> str:
  663. return state.get("approved_action", "finish")
  664. def _build_graph(self):
  665. builder = StateGraph(FindAgentGraphState)
  666. builder.add_node("supervisor", self._supervisor)
  667. builder.add_node("search", self._search)
  668. builder.add_node("evidence", self._evidence)
  669. builder.add_node("evaluator", self._evaluator)
  670. builder.add_edge(START, "supervisor")
  671. builder.add_conditional_edges(
  672. "supervisor",
  673. self._route,
  674. {
  675. "search": "search",
  676. "evidence": "evidence",
  677. "evaluator": "evaluator",
  678. "finish": END,
  679. },
  680. )
  681. builder.add_edge("search", "supervisor")
  682. builder.add_edge("evidence", "supervisor")
  683. builder.add_edge("evaluator", "supervisor")
  684. return builder.compile()
  685. async def invoke(self, state: FindAgentState) -> FindAgentState:
  686. graph_state: FindAgentGraphState = {
  687. "run_id": state.run_id,
  688. "user_input": state.user_input,
  689. "round_index": state.round_index,
  690. "execution_plan": state.execution_plan,
  691. "phase": state.phase,
  692. "node_runs": [],
  693. "snapshot": state.snapshot,
  694. "supervisor_step": 0,
  695. "action_count": 0,
  696. "search_actions": 0,
  697. "worker_count": 4,
  698. "evidence_scope": "both",
  699. "decision_history": [],
  700. "evaluator_stagnation": 0,
  701. }
  702. with self.observer.round(
  703. round_index=state.round_index,
  704. spec=self.obagent_spec,
  705. ) as round_observation:
  706. output = await self.app.ainvoke(
  707. graph_state,
  708. config={"recursion_limit": max(64, self.max_actions * 4)},
  709. )
  710. state.execution_plan = output.get("execution_plan")
  711. state.node_runs.extend(output.get("node_runs") or [])
  712. state.snapshot = self.service.snapshot(state.run_id)
  713. state.phase = "done"
  714. self.service.update_round(
  715. state.run_id,
  716. state.round_index,
  717. phase="done",
  718. status="done",
  719. snapshot=state.snapshot,
  720. )
  721. round_observation.set_output(
  722. {
  723. "状态快照": state.snapshot.__dict__,
  724. "本轮计划": (
  725. state.execution_plan.model_dump(mode="json")
  726. if state.execution_plan is not None
  727. else {}
  728. ),
  729. "Supervisor决策轨迹": output.get("decision_history") or [],
  730. },
  731. ok=True,
  732. )
  733. return state