graph.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464
  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 build_node_slots, render_node_context
  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 EVALUATOR_PROMPT, EVIDENCE_PROMPT, SEARCH_PROMPT, SUPERVISOR_PROMPT
  12. from find_agent_v2.service import FindAgentV2Service
  13. from find_agent_v2.state import FindAgentGraphState, FindAgentState, NodeRun
  14. from find_agent_v2.tools import (
  15. EVALUATION_TOOLS,
  16. EVIDENCE_TOOLS,
  17. SEARCH_TOOLS,
  18. ToolFn,
  19. bound_candidate_tools,
  20. normalize_evaluation_items,
  21. )
  22. class NodeRunner(Protocol):
  23. async def run_node(
  24. self,
  25. *,
  26. node: str,
  27. round_index: int,
  28. system_prompt: str,
  29. user_content: str,
  30. tools: tuple[ToolFn, ...] = (),
  31. max_iterations: int = 12,
  32. slots: tuple[InputSlot, ...] = (),
  33. branch_key: str = "",
  34. allow_delegation: bool = True,
  35. ) -> NodeRun: ...
  36. class FindAgentRoundGraph:
  37. """Supervisor-directed graph with deterministic policy approval."""
  38. def __init__(
  39. self, *, service: FindAgentV2Service, runner: NodeRunner, observer=None,
  40. max_actions: int = 16, max_search_actions: int = 3,
  41. ) -> None:
  42. self.service = service
  43. self.runner = runner
  44. self.observer = observer or NullObserver()
  45. self.max_actions = max(4, int(max_actions))
  46. self.max_search_actions = max(1, int(max_search_actions))
  47. self.app = self._build_graph()
  48. self.obagent_spec = graph_spec_for(self.app)
  49. def _full_state(self, state: FindAgentGraphState, *, pending_only: bool = False):
  50. return self.service.get_full_state(
  51. state["run_id"], pending_only=pending_only,
  52. )
  53. def _context(self, state: FindAgentGraphState, *, pending_only: bool = False) -> str:
  54. return render_node_context(
  55. user_input=state["user_input"],
  56. full_state=self._full_state(state, pending_only=pending_only),
  57. round_index=state["round_index"],
  58. plan=state.get("plan", ""),
  59. )
  60. def _slots(
  61. self, state: FindAgentGraphState, *, pending_only: bool = False,
  62. ) -> tuple[InputSlot, ...]:
  63. return build_node_slots(
  64. user_input=state["user_input"],
  65. full_state=self._full_state(state, pending_only=pending_only),
  66. round_index=state["round_index"],
  67. plan=state.get("plan", ""),
  68. )
  69. def _shard_state(
  70. self, state: FindAgentGraphState, items: list[dict[str, Any]],
  71. ) -> dict[str, Any]:
  72. full_state = self._full_state(state, pending_only=True)
  73. return {**full_state, "candidates": items}
  74. def _shard_context(
  75. self, state: FindAgentGraphState, items: list[dict[str, Any]],
  76. ) -> str:
  77. return render_node_context(
  78. user_input=state["user_input"],
  79. full_state=self._shard_state(state, items),
  80. round_index=state["round_index"],
  81. plan=state.get("plan", ""),
  82. )
  83. def _shard_slots(
  84. self, state: FindAgentGraphState, items: list[dict[str, Any]],
  85. ) -> tuple[InputSlot, ...]:
  86. return build_node_slots(
  87. user_input=state["user_input"],
  88. full_state=self._shard_state(state, items),
  89. round_index=state["round_index"],
  90. plan=state.get("plan", ""),
  91. )
  92. def _video_understanding_ids(
  93. self, state: FindAgentGraphState, items: list[dict[str, Any]],
  94. ) -> list[int]:
  95. """Only candidates passing every deterministic hard gate may use video understanding."""
  96. run = self._full_state(state).get("run") or {}
  97. rules = run.get("rule_config") or {}
  98. selected: list[int] = []
  99. for item in items:
  100. gate = evaluate_candidate_gate(item, rules)
  101. checks = list(gate.get("checks") or [])
  102. hard_gate_passed = gate.get("status") == "pass" and all(
  103. check.get("status") == "pass" and not check.get("compensated")
  104. for check in checks
  105. )
  106. if str(item.get("video_url") or "").strip() and hard_gate_passed:
  107. selected.append(int(item["candidate_id"]))
  108. return selected
  109. @staticmethod
  110. def _parse_supervisor(content: str) -> dict[str, Any]:
  111. text = content.strip()
  112. fenced = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.S)
  113. if fenced:
  114. text = fenced.group(1)
  115. else:
  116. start, end = text.find("{"), text.rfind("}")
  117. if start >= 0 and end > start:
  118. text = text[start:end + 1]
  119. try:
  120. value = json.loads(text)
  121. return value if isinstance(value, dict) else {}
  122. except (TypeError, ValueError):
  123. return {}
  124. def _approve_action(
  125. self, state: FindAgentGraphState, proposal: dict[str, Any],
  126. ) -> tuple[str, str, int, str]:
  127. """Turn an LLM proposal into a safe, executable transition."""
  128. pending = self._full_state(state, pending_only=True).get("candidates") or []
  129. missing_detail = any(item.get("detail_status") == "pending" for item in pending)
  130. missing_portrait = any(item.get("portrait_status") == "pending" for item in pending)
  131. proposed = str(proposal.get("next_action") or "").lower()
  132. reason = str(proposal.get("reason") or "")
  133. searches = int(state.get("search_actions") or 0)
  134. actions = int(state.get("action_count") or 0)
  135. try:
  136. worker_count = max(1, min(8, int(proposal.get("worker_count") or 4)))
  137. except (TypeError, ValueError):
  138. worker_count = 4
  139. scope = str(proposal.get("evidence_scope") or "both").lower()
  140. if scope not in {"detail", "portrait", "both"}:
  141. scope = "both"
  142. if actions > self.max_actions:
  143. if not pending:
  144. return "finish", "安全收敛动作已完成", worker_count, scope
  145. raise RuntimeError(
  146. f"Supervisor 安全收敛动作未产生进展:actions={actions}, pending={len(pending)}"
  147. )
  148. if actions == self.max_actions:
  149. if pending and not (missing_detail or missing_portrait):
  150. return "evaluator", "动作预算耗尽,强制消费待评估候选", worker_count, scope
  151. if pending:
  152. return "evidence", "动作预算耗尽,强制补齐缺失证据", worker_count, "both"
  153. return "finish", "达到单轮动作安全上限", worker_count, scope
  154. if pending:
  155. if missing_detail or missing_portrait:
  156. if scope == "detail" and not missing_detail:
  157. scope = "portrait"
  158. elif scope == "portrait" and not missing_portrait:
  159. scope = "detail"
  160. return "evidence", reason or "存在缺失证据,框架要求先补证", worker_count, scope
  161. return "evaluator", reason or "候选证据已处理,进入评估", worker_count, scope
  162. if searches == 0:
  163. return "search", reason or "本轮尚未搜索,框架要求先建立候选池", worker_count, scope
  164. if proposed == "search" and searches < self.max_search_actions:
  165. return "search", reason or "Supervisor 判断继续搜索仍有信息增益", worker_count, scope
  166. return "finish", reason or "没有待处理候选,结束本轮", worker_count, scope
  167. async def _supervisor(self, state: FindAgentGraphState) -> dict[str, Any]:
  168. run = await self.runner.run_node(
  169. node="supervisor",
  170. round_index=state["round_index"],
  171. system_prompt=SUPERVISOR_PROMPT,
  172. user_content=self._context(state),
  173. tools=(),
  174. max_iterations=2,
  175. slots=self._slots(state),
  176. allow_delegation=False,
  177. )
  178. proposal = self._parse_supervisor(run.content)
  179. action, reason, workers, scope = self._approve_action(state, proposal)
  180. proposed_plan = proposal.get("plan")
  181. plan = (
  182. json.dumps(proposed_plan, ensure_ascii=False)
  183. if isinstance(proposed_plan, dict)
  184. else state.get("plan", "")
  185. )
  186. decision = {
  187. "step": int(state.get("supervisor_step") or 0) + 1,
  188. "proposed_action": proposal.get("next_action"),
  189. "approved_action": action,
  190. "reason": reason,
  191. "worker_count": workers,
  192. "evidence_scope": scope,
  193. }
  194. self.service.update_round(
  195. state["run_id"], state["round_index"], phase="planning", plan=plan,
  196. )
  197. return {
  198. "phase": "planning", "plan": plan, "approved_action": action,
  199. "supervisor_step": decision["step"], "worker_count": workers,
  200. "evidence_scope": scope,
  201. "decision_history": [*state.get("decision_history", []), decision],
  202. "node_runs": [*state.get("node_runs", []), run],
  203. }
  204. async def _search(self, state: FindAgentGraphState) -> dict[str, Any]:
  205. self.service.update_round(state["run_id"], state["round_index"], phase="searching")
  206. run = await self.runner.run_node(
  207. node="search",
  208. round_index=state["round_index"],
  209. system_prompt=SEARCH_PROMPT,
  210. user_content=self._context(state),
  211. tools=SEARCH_TOOLS,
  212. max_iterations=10,
  213. slots=self._slots(state),
  214. )
  215. return {
  216. "phase": "searching",
  217. "search_actions": int(state.get("search_actions") or 0) + 1,
  218. "action_count": int(state.get("action_count") or 0) + 1,
  219. "node_runs": [*state.get("node_runs", []), run],
  220. }
  221. async def _evidence(self, state: FindAgentGraphState) -> dict[str, Any]:
  222. self.service.update_round(state["run_id"], state["round_index"], phase="evidence")
  223. pending = self._full_state(state, pending_only=True)["candidates"]
  224. detail_items = [item for item in pending if item.get("detail_status") == "pending"]
  225. portrait_items = [item for item in pending if item.get("portrait_status") == "pending"]
  226. scope = state.get("evidence_scope", "both")
  227. if scope == "detail":
  228. portrait_items = []
  229. elif scope == "portrait":
  230. detail_items = []
  231. jobs = [
  232. ("detail", detail_items[index:index + 8])
  233. for index in range(0, len(detail_items), 8)
  234. ] + [
  235. ("portrait", portrait_items[index:index + 8])
  236. for index in range(0, len(portrait_items), 8)
  237. ]
  238. semaphore = asyncio.Semaphore(max(1, min(8, int(state.get("worker_count") or 4))))
  239. async def run_shard(
  240. index: int, evidence_type: str, items: list[dict[str, Any]],
  241. ) -> NodeRun:
  242. candidate_ids = [int(item["candidate_id"]) for item in items]
  243. selected = (
  244. (EVIDENCE_TOOLS[0], EVIDENCE_TOOLS[2])
  245. if evidence_type == "detail"
  246. else (EVIDENCE_TOOLS[1], EVIDENCE_TOOLS[2])
  247. )
  248. async with semaphore:
  249. return await self.runner.run_node(
  250. node="evidence",
  251. round_index=state["round_index"],
  252. system_prompt=EVIDENCE_PROMPT,
  253. user_content=(
  254. f"你只负责当前 {evidence_type} 分片 candidate_ids={candidate_ids}。"
  255. f"必须为这些候选补齐 {evidence_type},不得访问其他候选。\n\n"
  256. + self._shard_context(state, items)
  257. ),
  258. tools=bound_candidate_tools(
  259. selected, run_id=state["run_id"], candidate_ids=candidate_ids,
  260. ),
  261. max_iterations=12,
  262. slots=self._shard_slots(state, items),
  263. branch_key=f"{evidence_type}-shard-{index}",
  264. allow_delegation=False,
  265. )
  266. runs = await asyncio.gather(*(
  267. run_shard(index, evidence_type, items)
  268. for index, (evidence_type, items) in enumerate(jobs, start=1)
  269. )) if jobs else []
  270. return {
  271. "phase": "evidence",
  272. "action_count": int(state.get("action_count") or 0) + 1,
  273. "node_runs": [*state.get("node_runs", []), *runs],
  274. }
  275. async def _evaluator(self, state: FindAgentGraphState) -> dict[str, Any]:
  276. self.service.update_round(state["run_id"], state["round_index"], phase="evaluating")
  277. node_runs = list(state.get("node_runs", []))
  278. before = self.service.snapshot(state["run_id"])
  279. pending = self._full_state(state, pending_only=True)["candidates"]
  280. run = self._full_state(state).get("run") or {}
  281. rules = run.get("rule_config") or {}
  282. eligible: list[dict[str, Any]] = []
  283. gate_failures: list[tuple[int, dict[str, Any]]] = []
  284. for item in pending:
  285. gate = evaluate_candidate_gate(item, rules)
  286. if gate.get("status") == "pass":
  287. eligible.append(item)
  288. else:
  289. gate_failures.append((int(item["candidate_id"]), gate))
  290. self.service.reject_failed_gates(state["run_id"], gate_failures)
  291. shards = [eligible[index:index + 8] for index in range(0, len(eligible), 8)]
  292. semaphore = asyncio.Semaphore(max(1, min(8, int(state.get("worker_count") or 4))))
  293. async def run_shard(index: int, items: list[dict[str, Any]]) -> NodeRun:
  294. candidate_ids = [int(item["candidate_id"]) for item in items]
  295. video_understanding_ids = self._video_understanding_ids(state, items)
  296. video_tools = (
  297. bound_candidate_tools(
  298. (EVALUATION_TOOLS[0],),
  299. run_id=state["run_id"],
  300. candidate_ids=video_understanding_ids,
  301. )
  302. if video_understanding_ids else ()
  303. )
  304. evaluation_instruction = (
  305. f"你只负责当前分片 candidate_ids={candidate_ids}。"
  306. f"这些候选均已通过硬门禁;有播放地址、允许按需视频理解的 "
  307. f"candidate_ids={video_understanding_ids}。"
  308. "视频理解不是硬性要求,可根据已有证据决定是否调用;只允许对这个列表中的候选调用。"
  309. )
  310. async with semaphore:
  311. structured_runner = getattr(self.runner, "run_evaluation", None)
  312. if callable(structured_runner):
  313. run, proposed = await structured_runner(
  314. round_index=state["round_index"],
  315. system_prompt=EVALUATOR_PROMPT,
  316. user_content=(
  317. evaluation_instruction
  318. + "必须为分片内每个 candidate_id 各输出一次结构化评估。\n\n"
  319. + self._shard_context(state, items)
  320. ),
  321. slots=self._shard_slots(state, items),
  322. branch_key=f"step-{state.get('supervisor_step', 0)}-shard-{index}",
  323. tools=video_tools,
  324. )
  325. normalized = normalize_evaluation_items(
  326. proposed, allowed_candidates=items,
  327. )
  328. updated = self.service.evaluate(state["run_id"], normalized)
  329. updated_ids = {int(item["candidate_id"]) for item in updated}
  330. if updated_ids != set(candidate_ids):
  331. raise RuntimeError(
  332. "评估写入结果不完整:"
  333. f"expected={candidate_ids}, updated={sorted(updated_ids)}"
  334. )
  335. return run
  336. return await self.runner.run_node(
  337. node="evaluator",
  338. round_index=state["round_index"],
  339. system_prompt=EVALUATOR_PROMPT,
  340. user_content=(
  341. evaluation_instruction
  342. + "必须把这些候选全部分池,不得评估其他候选。\n\n"
  343. + self._shard_context(state, items)
  344. ),
  345. tools=(
  346. *video_tools,
  347. *bound_candidate_tools(
  348. EVALUATION_TOOLS[1:],
  349. run_id=state["run_id"],
  350. candidate_ids=candidate_ids,
  351. ),
  352. ),
  353. max_iterations=12,
  354. slots=self._shard_slots(state, items),
  355. branch_key=f"step-{state.get('supervisor_step', 0)}-shard-{index}",
  356. allow_delegation=False,
  357. )
  358. runs = await asyncio.gather(*(
  359. run_shard(index, items) for index, items in enumerate(shards, start=1)
  360. )) if shards else []
  361. node_runs.extend(runs)
  362. self.service.recount_valid_primary(state["run_id"])
  363. after = self.service.snapshot(state["run_id"])
  364. stagnant = int(state.get("evaluator_stagnation") or 0)
  365. stagnant = stagnant + 1 if after.pending_count >= before.pending_count else 0
  366. if stagnant >= 3:
  367. raise RuntimeError(
  368. "评估节点连续 3 次未消费 pending_evaluation 候选:"
  369. f"remaining={after.pending_count}"
  370. )
  371. return {
  372. "phase": "evaluating", "node_runs": node_runs,
  373. "action_count": int(state.get("action_count") or 0) + 1,
  374. "evaluator_stagnation": stagnant,
  375. }
  376. @staticmethod
  377. def _route(state: FindAgentGraphState) -> str:
  378. return state.get("approved_action", "finish")
  379. def _build_graph(self):
  380. builder = StateGraph(FindAgentGraphState)
  381. builder.add_node("supervisor", self._supervisor)
  382. builder.add_node("search", self._search)
  383. builder.add_node("evidence", self._evidence)
  384. builder.add_node("evaluator", self._evaluator)
  385. builder.add_edge(START, "supervisor")
  386. builder.add_conditional_edges("supervisor", self._route, {
  387. "search": "search", "evidence": "evidence",
  388. "evaluator": "evaluator", "finish": END,
  389. })
  390. builder.add_edge("search", "supervisor")
  391. builder.add_edge("evidence", "supervisor")
  392. builder.add_edge("evaluator", "supervisor")
  393. return builder.compile()
  394. async def invoke(self, state: FindAgentState) -> FindAgentState:
  395. graph_state: FindAgentGraphState = {
  396. "run_id": state.run_id,
  397. "user_input": state.user_input,
  398. "round_index": state.round_index,
  399. "plan": state.plan,
  400. "phase": state.phase,
  401. "node_runs": [],
  402. "snapshot": state.snapshot,
  403. "supervisor_step": 0,
  404. "action_count": 0,
  405. "search_actions": 0,
  406. "worker_count": 4,
  407. "evidence_scope": "both",
  408. "decision_history": [],
  409. "evaluator_stagnation": 0,
  410. }
  411. with self.observer.round(
  412. round_index=state.round_index, spec=self.obagent_spec,
  413. ) as round_observation:
  414. output = await self.app.ainvoke(
  415. graph_state, config={"recursion_limit": max(64, self.max_actions * 4)},
  416. )
  417. state.plan = str(output.get("plan") or "")
  418. state.node_runs.extend(output.get("node_runs") or [])
  419. state.snapshot = self.service.snapshot(state.run_id)
  420. state.phase = "done"
  421. self.service.update_round(
  422. state.run_id,
  423. state.round_index,
  424. phase="done",
  425. status="done",
  426. snapshot=state.snapshot,
  427. )
  428. round_observation.set_output({
  429. "状态快照": state.snapshot.__dict__,
  430. "本轮计划": state.plan,
  431. "Supervisor决策轨迹": output.get("decision_history") or [],
  432. }, ok=True)
  433. return state