graph.py 32 KB

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