retrieval_event_projection.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567
  1. from __future__ import annotations
  2. import json
  3. import re
  4. from collections import Counter
  5. from datetime import datetime, timezone
  6. from typing import Any
  7. from zoneinfo import ZoneInfo
  8. from .business_detail_text import clean_report
  9. from .runtime_event_index import (
  10. RuntimeEventIndex,
  11. event_input,
  12. event_order,
  13. event_output,
  14. event_summary,
  15. integer,
  16. )
  17. from .runtime_tool_catalog import (
  18. MAIN_DECISION_READ_TOOLS,
  19. RETRIEVAL_AGENT_LABELS,
  20. business_tool_label,
  21. )
  22. _AGENT_CONTEXT_TOOLS = {"think_and_plan", "get_script_snapshot"}
  23. _DATABASE_TIMEZONE = ZoneInfo("Asia/Shanghai")
  24. class RetrievalEventProjector:
  25. """Project implementer retrieval activity from an explicit event tree."""
  26. def project(
  27. self,
  28. index: RuntimeEventIndex,
  29. *,
  30. valid_rounds: set[int],
  31. valid_branches: set[int],
  32. captured_at: datetime,
  33. run_status: str | None = None,
  34. ) -> dict[str, Any]:
  35. ordered = index.ordered
  36. stages_by_branch: dict[int, dict[str, Any]] = {}
  37. unassigned: list[dict[str, Any]] = []
  38. implementers: list[dict[str, Any]] = []
  39. for event in ordered:
  40. if (
  41. str(event.get("event_type") or "") != "agent_invoke"
  42. or str(event.get("event_name") or "") != "script_implementer"
  43. ):
  44. continue
  45. round_index = integer(event.get("round_index"))
  46. branch_id = integer(event.get("branch_id"))
  47. if round_index in valid_rounds and branch_id in valid_branches:
  48. implementers.append(event)
  49. else:
  50. unassigned.append(event_summary(event, "missing-business-context"))
  51. retrieval_agents = [
  52. event
  53. for event in ordered
  54. if str(event.get("event_type") or "") == "agent_invoke"
  55. and str(event.get("event_name") or "") in RETRIEVAL_AGENT_LABELS
  56. ]
  57. attached_retrieval_ids: set[int] = set()
  58. for implementer in implementers:
  59. implementer_id = integer(implementer.get("id"))
  60. branch_id = integer(implementer.get("branch_id"))
  61. round_index = integer(implementer.get("round_index"))
  62. if implementer_id is None or branch_id is None or round_index is None:
  63. continue
  64. child_agents = [
  65. event
  66. for event in retrieval_agents
  67. if integer(event.get("parent_event_id")) == implementer_id
  68. and integer(event.get("branch_id")) == branch_id
  69. and integer(event.get("round_index")) == round_index
  70. ]
  71. attached_retrieval_ids.update(
  72. value
  73. for event in child_agents
  74. if (value := integer(event.get("id"))) is not None
  75. )
  76. direct_groups = _direct_tool_groups(index, implementer, run_status)
  77. agent_runs = [
  78. _retrieval_agent_run(event, index, run_status)
  79. for event in sorted(child_agents, key=event_order)
  80. ]
  81. operations: list[dict[str, Any]] = [*direct_groups, *agent_runs]
  82. waves, observed_mode = _assign_waves(operations, captured_at)
  83. status = _stage_status(operations, implementer, run_status)
  84. stage = {
  85. "id": f"round:{round_index}:branch:{branch_id}:retrieval",
  86. "roundIndex": round_index,
  87. "branchId": branch_id,
  88. "implementerEventId": implementer_id,
  89. "observedMode": observed_mode,
  90. "waves": waves,
  91. "directToolGroups": direct_groups,
  92. "agentRuns": agent_runs,
  93. "status": status,
  94. "startedAt": _iso_value(
  95. min(
  96. (_start(item) for item in operations if _start(item)),
  97. default=None,
  98. )
  99. ),
  100. "endedAt": _iso_value(
  101. max(
  102. (
  103. _end(item, captured_at)
  104. for item in operations
  105. if _end(item, captured_at)
  106. ),
  107. default=None,
  108. )
  109. ),
  110. "detailRef": f"round:{round_index}:branch:{branch_id}:retrieval",
  111. }
  112. previous = stages_by_branch.get(branch_id)
  113. if previous is None:
  114. stages_by_branch[branch_id] = stage
  115. continue
  116. # Multiple implementer invocations for one branch are rare but
  117. # valid. Preserve every operation instead of overwriting history.
  118. combined = [
  119. *previous.get("directToolGroups", []),
  120. *previous.get("agentRuns", []),
  121. *direct_groups,
  122. *agent_runs,
  123. ]
  124. waves, mode = _assign_waves(combined, captured_at)
  125. previous["directToolGroups"].extend(direct_groups)
  126. previous["agentRuns"].extend(agent_runs)
  127. previous["waves"] = waves
  128. previous["observedMode"] = mode
  129. previous["status"] = _combined_stage_status(previous["status"], status)
  130. for event in retrieval_agents:
  131. event_id = integer(event.get("id"))
  132. if event_id not in attached_retrieval_ids:
  133. unassigned.append(event_summary(event, "missing-parent-implementer"))
  134. return {
  135. "retrievalStagesByBranch": stages_by_branch,
  136. "unassigned": unassigned,
  137. }
  138. def _direct_tool_groups(
  139. index: RuntimeEventIndex,
  140. implementer: dict[str, Any],
  141. run_status: str | None,
  142. ) -> list[dict[str, Any]]:
  143. implementer_id = integer(implementer.get("id"))
  144. if implementer_id is None:
  145. return []
  146. scoped = [
  147. event
  148. for event in index.events_in_scope(implementer_id)
  149. if integer(event.get("id")) != implementer_id
  150. ]
  151. groups: list[list[dict[str, Any]]] = []
  152. pending: list[dict[str, Any]] = []
  153. for event in sorted(scoped, key=event_order):
  154. is_direct = (
  155. str(event.get("event_type") or "") == "tool_call"
  156. and str(event.get("agent_role") or "") == "script_implementer"
  157. and str(event.get("event_name") or "") in MAIN_DECISION_READ_TOOLS
  158. )
  159. if is_direct:
  160. pending.append(event)
  161. continue
  162. if pending:
  163. groups.append(pending)
  164. pending = []
  165. if pending:
  166. groups.append(pending)
  167. return [_direct_group(group, run_status) for group in groups]
  168. def _direct_group(
  169. events: list[dict[str, Any]], run_status: str | None
  170. ) -> dict[str, Any]:
  171. first_id = integer(events[0].get("id")) or 0
  172. calls = [_direct_call(event, run_status) for event in events]
  173. counts = Counter(call["status"] for call in calls)
  174. labels = Counter(call["businessLabel"] for call in calls)
  175. result = {
  176. "id": f"retrieval-direct:{first_id}",
  177. "kind": "direct-tools",
  178. "owner": "script_implementer",
  179. "callCount": len(calls),
  180. "successCount": counts["success"],
  181. "failureCount": counts["failure"],
  182. "runningCount": counts["running"],
  183. "sources": [
  184. {"businessLabel": label, "callCount": count}
  185. for label, count in labels.items()
  186. ],
  187. "calls": calls,
  188. "startedAt": _iso_value(
  189. min((_date_value(item.get("started_at")) for item in events), default=None)
  190. ),
  191. "endedAt": _iso_value(
  192. max((_date_value(item.get("ended_at")) for item in events), default=None)
  193. ),
  194. "detailRef": f"retrieval-direct:{first_id}",
  195. }
  196. if counts["interrupted"]:
  197. result["interruptedCount"] = counts["interrupted"]
  198. return result
  199. def _direct_call(event: dict[str, Any], run_status: str | None) -> dict[str, Any]:
  200. event_id = integer(event.get("id")) or 0
  201. status = _operation_status(event, run_status)
  202. return {
  203. "id": f"event:{event_id}",
  204. "eventId": event_id,
  205. "businessLabel": business_tool_label(
  206. str(event.get("event_name") or "")
  207. ),
  208. "status": status,
  209. "resultSummary": (
  210. "读取失败"
  211. if status == "failure"
  212. else "构建中断"
  213. if status == "interrupted"
  214. else "正在读取"
  215. if status == "running"
  216. else "已读取"
  217. ),
  218. "detailRef": f"event:{event_id}",
  219. }
  220. def _retrieval_agent_run(
  221. agent: dict[str, Any], index: RuntimeEventIndex, run_status: str | None
  222. ) -> dict[str, Any]:
  223. event_id = integer(agent.get("id")) or 0
  224. agent_type = str(agent.get("event_name") or "")
  225. child_tools = [
  226. event
  227. for event in index.ordered
  228. if str(event.get("event_type") or "") == "tool_call"
  229. and (
  230. integer(event.get("scope_event_id")) == event_id
  231. or integer(event.get("parent_event_id")) == event_id
  232. )
  233. ]
  234. attempts = [
  235. _query_attempt(event, run_status)
  236. for event in sorted(child_tools, key=event_order)
  237. if str(event.get("event_name") or "") not in _AGENT_CONTEXT_TOOLS
  238. ]
  239. counts = Counter(attempt["status"] for attempt in attempts)
  240. screening = clean_report(_agent_summary(agent))
  241. status = _agent_status(agent, run_status)
  242. result = {
  243. "id": f"retrieval-agent:{event_id}",
  244. "kind": "retrieval-agent",
  245. "eventId": event_id,
  246. "agentType": agent_type,
  247. "businessLabel": RETRIEVAL_AGENT_LABELS.get(agent_type, "取数 Agent"),
  248. "taskPreview": _preview(_agent_task(agent), 180),
  249. "status": status,
  250. "querySummary": {
  251. "total": len(attempts),
  252. "hit": counts["hit"],
  253. "empty": counts["empty"],
  254. "failure": counts["failure"],
  255. "unknown": counts["unknown"] + counts["running"],
  256. },
  257. "attempts": attempts,
  258. "screening": {
  259. "state": (
  260. "available"
  261. if screening
  262. else "running"
  263. if status == "running"
  264. else "missing"
  265. ),
  266. "preview": _preview(screening, 220),
  267. },
  268. "startedAt": _iso_value(_date_value(agent.get("started_at"))),
  269. "endedAt": _iso_value(_date_value(agent.get("ended_at"))),
  270. "durationMs": integer(agent.get("duration_ms")),
  271. "detailRef": f"retrieval-agent:{event_id}",
  272. }
  273. if counts["interrupted"]:
  274. result["querySummary"]["interrupted"] = counts["interrupted"]
  275. return result
  276. def _query_attempt(event: dict[str, Any], run_status: str | None) -> dict[str, Any]:
  277. event_id = integer(event.get("id")) or 0
  278. status, count = _query_status(event, run_status)
  279. return {
  280. "id": f"event:{event_id}",
  281. "eventId": event_id,
  282. "sequence": integer(event.get("event_seq")) or 0,
  283. "queryLabel": _query_label(event_input(event)),
  284. "status": status,
  285. "resultCount": count,
  286. "detailRef": f"event:{event_id}",
  287. }
  288. def _query_status(
  289. event: dict[str, Any], run_status: str | None = None
  290. ) -> tuple[str, int | None]:
  291. summary = event.get("retrievalOutcome")
  292. if isinstance(summary, dict) and summary.get("state"):
  293. state = str(summary.get("state"))
  294. if (
  295. str(run_status or "").lower()
  296. in {"success", "partial", "failed", "stopped", "completed", "cancelled", "cancel", "cancle"}
  297. and (str(event.get("status") or "").lower() == "running" or not event.get("ended_at"))
  298. ):
  299. state = "interrupted"
  300. return state, integer(summary.get("count"))
  301. raw_status = str(event.get("status") or "").lower()
  302. if (
  303. str(run_status or "").lower()
  304. in {"success", "partial", "failed", "stopped", "completed", "cancelled", "cancel", "cancle"}
  305. and (raw_status == "running" or not event.get("ended_at"))
  306. ):
  307. return "interrupted", None
  308. if raw_status in {"error", "failed", "failure"}:
  309. return "failure", None
  310. if raw_status == "running" or not event.get("ended_at"):
  311. return "running", None
  312. output = event_output(event)
  313. if isinstance(output, dict) and _output_reports_failure(output):
  314. return "failure", None
  315. count = _result_count(output)
  316. if count is not None:
  317. return ("hit" if count > 0 else "empty"), count
  318. text = str(event.get("output_preview") or "").strip()
  319. if text == "[]":
  320. return "empty", 0
  321. return "unknown", None
  322. def _output_reports_failure(value: dict[str, Any]) -> bool:
  323. success = value.get("success")
  324. if success is False or (isinstance(success, str) and success.lower() == "false"):
  325. return True
  326. error = value.get("error")
  327. return error not in (None, "", False, [], {})
  328. def _result_count(value: Any) -> int | None:
  329. parsed = value
  330. if isinstance(value, str):
  331. try:
  332. parsed = json.loads(value)
  333. except json.JSONDecodeError:
  334. match = re.search(r'"count"\s*:\s*(\d+)', value)
  335. return int(match.group(1)) if match else None
  336. if isinstance(parsed, list):
  337. return len(parsed)
  338. if not isinstance(parsed, dict):
  339. return None
  340. for key in ("count", "returned_count", "result_count"):
  341. number = integer(parsed.get(key))
  342. if number is not None:
  343. return number
  344. for key in ("results", "data", "items"):
  345. items = parsed.get(key)
  346. if isinstance(items, list):
  347. return len(items)
  348. return None
  349. def _query_label(value: Any) -> str:
  350. if not isinstance(value, dict):
  351. return "查询内容详见详情"
  352. parts: list[str] = []
  353. keyword = value.get("keyword") or value.get("query") or value.get("topic_name")
  354. if isinstance(keyword, str) and keyword.strip():
  355. parts.append(keyword.strip())
  356. if not parts and isinstance(value.get("account_name"), str):
  357. parts.append(f"账号 {value['account_name'].strip()}")
  358. for key in ("names", "category_names"):
  359. names = value.get(key)
  360. if isinstance(names, list) and names:
  361. parts.append("、".join(str(item) for item in names[:4]))
  362. return_field = value.get("return_field")
  363. if isinstance(return_field, str) and return_field.strip():
  364. parts.append(f"返回{return_field.strip()}")
  365. platform = value.get("platform_channel")
  366. if isinstance(platform, str) and platform.strip():
  367. parts.append(platform.strip())
  368. return " · ".join(parts) or "查询内容详见详情"
  369. def _assign_waves(
  370. operations: list[dict[str, Any]], captured_at: datetime
  371. ) -> tuple[list[dict[str, Any]], str]:
  372. if not operations:
  373. return [], "unknown"
  374. if any(_start(item) is None for item in operations):
  375. for item in operations:
  376. item["waveIndex"] = None
  377. return [], "unknown"
  378. ordered = sorted(operations, key=lambda item: (_start(item), item.get("id")))
  379. waves: list[dict[str, Any]] = []
  380. current: list[dict[str, Any]] = []
  381. current_end: datetime | None = None
  382. for operation in ordered:
  383. start = _start(operation)
  384. end = _end(operation, captured_at) or start
  385. if current and current_end is not None and start is not None and start >= current_end:
  386. waves.append(_wave(len(waves), current, current_end))
  387. current = []
  388. current_end = None
  389. current.append(operation)
  390. current_end = max(filter(None, (current_end, end)), default=None)
  391. if current:
  392. waves.append(_wave(len(waves), current, current_end))
  393. by_id = {item.get("id"): item for item in operations}
  394. for wave in waves:
  395. for operation_id in wave["operationIds"]:
  396. by_id[operation_id]["waveIndex"] = wave["index"]
  397. if len(operations) == 1:
  398. mode = "single"
  399. elif len(waves) == 1:
  400. mode = "parallel"
  401. elif any(len(wave["operationIds"]) > 1 for wave in waves):
  402. mode = "mixed"
  403. else:
  404. mode = "sequential"
  405. return waves, mode
  406. def _wave(
  407. index: int, operations: list[dict[str, Any]], end: datetime | None
  408. ) -> dict[str, Any]:
  409. return {
  410. "index": index,
  411. "operationIds": [item.get("id") for item in operations],
  412. "startedAt": _iso_value(
  413. min((_start(item) for item in operations), default=None)
  414. ),
  415. "endedAt": _iso_value(end),
  416. }
  417. def _stage_status(
  418. operations: list[dict[str, Any]],
  419. implementer: dict[str, Any],
  420. run_status: str | None = None,
  421. ) -> str:
  422. implementer_status = _operation_status(implementer, run_status)
  423. if not operations:
  424. return "partial" if implementer_status == "interrupted" else implementer_status if implementer_status == "running" else "missing"
  425. statuses = {_operation_status(item, run_status) for item in operations}
  426. if "running" in statuses or implementer_status == "running":
  427. return "running"
  428. if "failure" in statuses or "interrupted" in statuses or implementer_status == "interrupted":
  429. return "partial"
  430. return "completed"
  431. def _combined_stage_status(left: str, right: str) -> str:
  432. if "running" in {left, right}:
  433. return "running"
  434. if "partial" in {left, right}:
  435. return "partial"
  436. if "completed" in {left, right}:
  437. return "completed"
  438. return "missing"
  439. def _operation_status(
  440. value: dict[str, Any], run_status: str | None = None
  441. ) -> str:
  442. if int(value.get("failureCount") or 0) > 0:
  443. return "failure"
  444. if int(value.get("runningCount") or 0) > 0:
  445. return "running"
  446. status = str(value.get("status") or "").lower()
  447. if status == "interrupted":
  448. return "interrupted"
  449. if (
  450. str(run_status or "").lower()
  451. in {"success", "partial", "failed", "stopped", "completed", "cancelled", "cancel", "cancle"}
  452. and (status == "running" or not value.get("ended_at") and not value.get("endedAt"))
  453. ):
  454. return "interrupted"
  455. if status in {"error", "failed", "failure"}:
  456. return "failure"
  457. if status == "running" or not value.get("ended_at") and not value.get("endedAt"):
  458. return "running"
  459. return "success"
  460. def _agent_status(value: dict[str, Any], run_status: str | None = None) -> str:
  461. status = _operation_status(value, run_status)
  462. return (
  463. "failed"
  464. if status == "failure"
  465. else "interrupted"
  466. if status == "interrupted"
  467. else "running"
  468. if status == "running"
  469. else "completed"
  470. )
  471. def _agent_task(event: dict[str, Any]) -> str | None:
  472. value = event_input(event)
  473. if isinstance(value, dict) and isinstance(value.get("task"), str):
  474. return value["task"]
  475. return None
  476. def _agent_summary(event: dict[str, Any]) -> str | None:
  477. value = event_output(event)
  478. if isinstance(value, dict) and isinstance(value.get("summary"), str):
  479. return value["summary"]
  480. return None
  481. def _start(item: dict[str, Any]) -> datetime | None:
  482. return _date_value(item.get("startedAt") or item.get("started_at"))
  483. def _end(item: dict[str, Any], captured_at: datetime) -> datetime | None:
  484. value = _date_value(item.get("endedAt") or item.get("ended_at"))
  485. if value is None and _operation_status(item) == "running":
  486. return captured_at
  487. return value
  488. def _date_value(value: Any) -> datetime | None:
  489. if isinstance(value, datetime):
  490. parsed = value
  491. if parsed.tzinfo is None:
  492. parsed = parsed.replace(tzinfo=_DATABASE_TIMEZONE)
  493. return parsed.astimezone(timezone.utc)
  494. if isinstance(value, str) and value:
  495. try:
  496. parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
  497. if parsed.tzinfo is None:
  498. parsed = parsed.replace(tzinfo=_DATABASE_TIMEZONE)
  499. return parsed.astimezone(timezone.utc)
  500. except ValueError:
  501. return None
  502. return None
  503. def _iso_value(value: datetime | None) -> str | None:
  504. return value.isoformat() if value is not None else None
  505. def _preview(value: Any, limit: int) -> str | None:
  506. text = str(value or "").strip()
  507. if not text:
  508. return None
  509. text = re.sub(r"\s+", " ", text)
  510. return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"