| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567 |
- from __future__ import annotations
- import json
- import re
- from collections import Counter
- from datetime import datetime, timezone
- from typing import Any
- from zoneinfo import ZoneInfo
- from .business_detail_text import clean_report
- from .runtime_event_index import (
- RuntimeEventIndex,
- event_input,
- event_order,
- event_output,
- event_summary,
- integer,
- )
- from .runtime_tool_catalog import (
- MAIN_DECISION_READ_TOOLS,
- RETRIEVAL_AGENT_LABELS,
- business_tool_label,
- )
- _AGENT_CONTEXT_TOOLS = {"think_and_plan", "get_script_snapshot"}
- _DATABASE_TIMEZONE = ZoneInfo("Asia/Shanghai")
- class RetrievalEventProjector:
- """Project implementer retrieval activity from an explicit event tree."""
- def project(
- self,
- index: RuntimeEventIndex,
- *,
- valid_rounds: set[int],
- valid_branches: set[int],
- captured_at: datetime,
- run_status: str | None = None,
- ) -> dict[str, Any]:
- ordered = index.ordered
- stages_by_branch: dict[int, dict[str, Any]] = {}
- unassigned: list[dict[str, Any]] = []
- implementers: list[dict[str, Any]] = []
- for event in ordered:
- if (
- str(event.get("event_type") or "") != "agent_invoke"
- or str(event.get("event_name") or "") != "script_implementer"
- ):
- continue
- round_index = integer(event.get("round_index"))
- branch_id = integer(event.get("branch_id"))
- if round_index in valid_rounds and branch_id in valid_branches:
- implementers.append(event)
- else:
- unassigned.append(event_summary(event, "missing-business-context"))
- retrieval_agents = [
- event
- for event in ordered
- if str(event.get("event_type") or "") == "agent_invoke"
- and str(event.get("event_name") or "") in RETRIEVAL_AGENT_LABELS
- ]
- attached_retrieval_ids: set[int] = set()
- for implementer in implementers:
- implementer_id = integer(implementer.get("id"))
- branch_id = integer(implementer.get("branch_id"))
- round_index = integer(implementer.get("round_index"))
- if implementer_id is None or branch_id is None or round_index is None:
- continue
- child_agents = [
- event
- for event in retrieval_agents
- if integer(event.get("parent_event_id")) == implementer_id
- and integer(event.get("branch_id")) == branch_id
- and integer(event.get("round_index")) == round_index
- ]
- attached_retrieval_ids.update(
- value
- for event in child_agents
- if (value := integer(event.get("id"))) is not None
- )
- direct_groups = _direct_tool_groups(index, implementer, run_status)
- agent_runs = [
- _retrieval_agent_run(event, index, run_status)
- for event in sorted(child_agents, key=event_order)
- ]
- operations: list[dict[str, Any]] = [*direct_groups, *agent_runs]
- waves, observed_mode = _assign_waves(operations, captured_at)
- status = _stage_status(operations, implementer, run_status)
- stage = {
- "id": f"round:{round_index}:branch:{branch_id}:retrieval",
- "roundIndex": round_index,
- "branchId": branch_id,
- "implementerEventId": implementer_id,
- "observedMode": observed_mode,
- "waves": waves,
- "directToolGroups": direct_groups,
- "agentRuns": agent_runs,
- "status": status,
- "startedAt": _iso_value(
- min(
- (_start(item) for item in operations if _start(item)),
- default=None,
- )
- ),
- "endedAt": _iso_value(
- max(
- (
- _end(item, captured_at)
- for item in operations
- if _end(item, captured_at)
- ),
- default=None,
- )
- ),
- "detailRef": f"round:{round_index}:branch:{branch_id}:retrieval",
- }
- previous = stages_by_branch.get(branch_id)
- if previous is None:
- stages_by_branch[branch_id] = stage
- continue
- # Multiple implementer invocations for one branch are rare but
- # valid. Preserve every operation instead of overwriting history.
- combined = [
- *previous.get("directToolGroups", []),
- *previous.get("agentRuns", []),
- *direct_groups,
- *agent_runs,
- ]
- waves, mode = _assign_waves(combined, captured_at)
- previous["directToolGroups"].extend(direct_groups)
- previous["agentRuns"].extend(agent_runs)
- previous["waves"] = waves
- previous["observedMode"] = mode
- previous["status"] = _combined_stage_status(previous["status"], status)
- for event in retrieval_agents:
- event_id = integer(event.get("id"))
- if event_id not in attached_retrieval_ids:
- unassigned.append(event_summary(event, "missing-parent-implementer"))
- return {
- "retrievalStagesByBranch": stages_by_branch,
- "unassigned": unassigned,
- }
- def _direct_tool_groups(
- index: RuntimeEventIndex,
- implementer: dict[str, Any],
- run_status: str | None,
- ) -> list[dict[str, Any]]:
- implementer_id = integer(implementer.get("id"))
- if implementer_id is None:
- return []
- scoped = [
- event
- for event in index.events_in_scope(implementer_id)
- if integer(event.get("id")) != implementer_id
- ]
- groups: list[list[dict[str, Any]]] = []
- pending: list[dict[str, Any]] = []
- for event in sorted(scoped, key=event_order):
- is_direct = (
- str(event.get("event_type") or "") == "tool_call"
- and str(event.get("agent_role") or "") == "script_implementer"
- and str(event.get("event_name") or "") in MAIN_DECISION_READ_TOOLS
- )
- if is_direct:
- pending.append(event)
- continue
- if pending:
- groups.append(pending)
- pending = []
- if pending:
- groups.append(pending)
- return [_direct_group(group, run_status) for group in groups]
- def _direct_group(
- events: list[dict[str, Any]], run_status: str | None
- ) -> dict[str, Any]:
- first_id = integer(events[0].get("id")) or 0
- calls = [_direct_call(event, run_status) for event in events]
- counts = Counter(call["status"] for call in calls)
- labels = Counter(call["businessLabel"] for call in calls)
- result = {
- "id": f"retrieval-direct:{first_id}",
- "kind": "direct-tools",
- "owner": "script_implementer",
- "callCount": len(calls),
- "successCount": counts["success"],
- "failureCount": counts["failure"],
- "runningCount": counts["running"],
- "sources": [
- {"businessLabel": label, "callCount": count}
- for label, count in labels.items()
- ],
- "calls": calls,
- "startedAt": _iso_value(
- min((_date_value(item.get("started_at")) for item in events), default=None)
- ),
- "endedAt": _iso_value(
- max((_date_value(item.get("ended_at")) for item in events), default=None)
- ),
- "detailRef": f"retrieval-direct:{first_id}",
- }
- if counts["interrupted"]:
- result["interruptedCount"] = counts["interrupted"]
- return result
- def _direct_call(event: dict[str, Any], run_status: str | None) -> dict[str, Any]:
- event_id = integer(event.get("id")) or 0
- status = _operation_status(event, run_status)
- return {
- "id": f"event:{event_id}",
- "eventId": event_id,
- "businessLabel": business_tool_label(
- str(event.get("event_name") or "")
- ),
- "status": status,
- "resultSummary": (
- "读取失败"
- if status == "failure"
- else "构建中断"
- if status == "interrupted"
- else "正在读取"
- if status == "running"
- else "已读取"
- ),
- "detailRef": f"event:{event_id}",
- }
- def _retrieval_agent_run(
- agent: dict[str, Any], index: RuntimeEventIndex, run_status: str | None
- ) -> dict[str, Any]:
- event_id = integer(agent.get("id")) or 0
- agent_type = str(agent.get("event_name") or "")
- child_tools = [
- event
- for event in index.ordered
- if str(event.get("event_type") or "") == "tool_call"
- and (
- integer(event.get("scope_event_id")) == event_id
- or integer(event.get("parent_event_id")) == event_id
- )
- ]
- attempts = [
- _query_attempt(event, run_status)
- for event in sorted(child_tools, key=event_order)
- if str(event.get("event_name") or "") not in _AGENT_CONTEXT_TOOLS
- ]
- counts = Counter(attempt["status"] for attempt in attempts)
- screening = clean_report(_agent_summary(agent))
- status = _agent_status(agent, run_status)
- result = {
- "id": f"retrieval-agent:{event_id}",
- "kind": "retrieval-agent",
- "eventId": event_id,
- "agentType": agent_type,
- "businessLabel": RETRIEVAL_AGENT_LABELS.get(agent_type, "取数 Agent"),
- "taskPreview": _preview(_agent_task(agent), 180),
- "status": status,
- "querySummary": {
- "total": len(attempts),
- "hit": counts["hit"],
- "empty": counts["empty"],
- "failure": counts["failure"],
- "unknown": counts["unknown"] + counts["running"],
- },
- "attempts": attempts,
- "screening": {
- "state": (
- "available"
- if screening
- else "running"
- if status == "running"
- else "missing"
- ),
- "preview": _preview(screening, 220),
- },
- "startedAt": _iso_value(_date_value(agent.get("started_at"))),
- "endedAt": _iso_value(_date_value(agent.get("ended_at"))),
- "durationMs": integer(agent.get("duration_ms")),
- "detailRef": f"retrieval-agent:{event_id}",
- }
- if counts["interrupted"]:
- result["querySummary"]["interrupted"] = counts["interrupted"]
- return result
- def _query_attempt(event: dict[str, Any], run_status: str | None) -> dict[str, Any]:
- event_id = integer(event.get("id")) or 0
- status, count = _query_status(event, run_status)
- return {
- "id": f"event:{event_id}",
- "eventId": event_id,
- "sequence": integer(event.get("event_seq")) or 0,
- "queryLabel": _query_label(event_input(event)),
- "status": status,
- "resultCount": count,
- "detailRef": f"event:{event_id}",
- }
- def _query_status(
- event: dict[str, Any], run_status: str | None = None
- ) -> tuple[str, int | None]:
- summary = event.get("retrievalOutcome")
- if isinstance(summary, dict) and summary.get("state"):
- state = str(summary.get("state"))
- if (
- str(run_status or "").lower()
- in {"success", "partial", "failed", "stopped", "completed", "cancelled", "cancel", "cancle"}
- and (str(event.get("status") or "").lower() == "running" or not event.get("ended_at"))
- ):
- state = "interrupted"
- return state, integer(summary.get("count"))
- raw_status = str(event.get("status") or "").lower()
- if (
- str(run_status or "").lower()
- in {"success", "partial", "failed", "stopped", "completed", "cancelled", "cancel", "cancle"}
- and (raw_status == "running" or not event.get("ended_at"))
- ):
- return "interrupted", None
- if raw_status in {"error", "failed", "failure"}:
- return "failure", None
- if raw_status == "running" or not event.get("ended_at"):
- return "running", None
- output = event_output(event)
- if isinstance(output, dict) and _output_reports_failure(output):
- return "failure", None
- count = _result_count(output)
- if count is not None:
- return ("hit" if count > 0 else "empty"), count
- text = str(event.get("output_preview") or "").strip()
- if text == "[]":
- return "empty", 0
- return "unknown", None
- def _output_reports_failure(value: dict[str, Any]) -> bool:
- success = value.get("success")
- if success is False or (isinstance(success, str) and success.lower() == "false"):
- return True
- error = value.get("error")
- return error not in (None, "", False, [], {})
- def _result_count(value: Any) -> int | None:
- parsed = value
- if isinstance(value, str):
- try:
- parsed = json.loads(value)
- except json.JSONDecodeError:
- match = re.search(r'"count"\s*:\s*(\d+)', value)
- return int(match.group(1)) if match else None
- if isinstance(parsed, list):
- return len(parsed)
- if not isinstance(parsed, dict):
- return None
- for key in ("count", "returned_count", "result_count"):
- number = integer(parsed.get(key))
- if number is not None:
- return number
- for key in ("results", "data", "items"):
- items = parsed.get(key)
- if isinstance(items, list):
- return len(items)
- return None
- def _query_label(value: Any) -> str:
- if not isinstance(value, dict):
- return "查询内容详见详情"
- parts: list[str] = []
- keyword = value.get("keyword") or value.get("query") or value.get("topic_name")
- if isinstance(keyword, str) and keyword.strip():
- parts.append(keyword.strip())
- if not parts and isinstance(value.get("account_name"), str):
- parts.append(f"账号 {value['account_name'].strip()}")
- for key in ("names", "category_names"):
- names = value.get(key)
- if isinstance(names, list) and names:
- parts.append("、".join(str(item) for item in names[:4]))
- return_field = value.get("return_field")
- if isinstance(return_field, str) and return_field.strip():
- parts.append(f"返回{return_field.strip()}")
- platform = value.get("platform_channel")
- if isinstance(platform, str) and platform.strip():
- parts.append(platform.strip())
- return " · ".join(parts) or "查询内容详见详情"
- def _assign_waves(
- operations: list[dict[str, Any]], captured_at: datetime
- ) -> tuple[list[dict[str, Any]], str]:
- if not operations:
- return [], "unknown"
- if any(_start(item) is None for item in operations):
- for item in operations:
- item["waveIndex"] = None
- return [], "unknown"
- ordered = sorted(operations, key=lambda item: (_start(item), item.get("id")))
- waves: list[dict[str, Any]] = []
- current: list[dict[str, Any]] = []
- current_end: datetime | None = None
- for operation in ordered:
- start = _start(operation)
- end = _end(operation, captured_at) or start
- if current and current_end is not None and start is not None and start >= current_end:
- waves.append(_wave(len(waves), current, current_end))
- current = []
- current_end = None
- current.append(operation)
- current_end = max(filter(None, (current_end, end)), default=None)
- if current:
- waves.append(_wave(len(waves), current, current_end))
- by_id = {item.get("id"): item for item in operations}
- for wave in waves:
- for operation_id in wave["operationIds"]:
- by_id[operation_id]["waveIndex"] = wave["index"]
- if len(operations) == 1:
- mode = "single"
- elif len(waves) == 1:
- mode = "parallel"
- elif any(len(wave["operationIds"]) > 1 for wave in waves):
- mode = "mixed"
- else:
- mode = "sequential"
- return waves, mode
- def _wave(
- index: int, operations: list[dict[str, Any]], end: datetime | None
- ) -> dict[str, Any]:
- return {
- "index": index,
- "operationIds": [item.get("id") for item in operations],
- "startedAt": _iso_value(
- min((_start(item) for item in operations), default=None)
- ),
- "endedAt": _iso_value(end),
- }
- def _stage_status(
- operations: list[dict[str, Any]],
- implementer: dict[str, Any],
- run_status: str | None = None,
- ) -> str:
- implementer_status = _operation_status(implementer, run_status)
- if not operations:
- return "partial" if implementer_status == "interrupted" else implementer_status if implementer_status == "running" else "missing"
- statuses = {_operation_status(item, run_status) for item in operations}
- if "running" in statuses or implementer_status == "running":
- return "running"
- if "failure" in statuses or "interrupted" in statuses or implementer_status == "interrupted":
- return "partial"
- return "completed"
- def _combined_stage_status(left: str, right: str) -> str:
- if "running" in {left, right}:
- return "running"
- if "partial" in {left, right}:
- return "partial"
- if "completed" in {left, right}:
- return "completed"
- return "missing"
- def _operation_status(
- value: dict[str, Any], run_status: str | None = None
- ) -> str:
- if int(value.get("failureCount") or 0) > 0:
- return "failure"
- if int(value.get("runningCount") or 0) > 0:
- return "running"
- status = str(value.get("status") or "").lower()
- if status == "interrupted":
- return "interrupted"
- if (
- str(run_status or "").lower()
- in {"success", "partial", "failed", "stopped", "completed", "cancelled", "cancel", "cancle"}
- and (status == "running" or not value.get("ended_at") and not value.get("endedAt"))
- ):
- return "interrupted"
- if status in {"error", "failed", "failure"}:
- return "failure"
- if status == "running" or not value.get("ended_at") and not value.get("endedAt"):
- return "running"
- return "success"
- def _agent_status(value: dict[str, Any], run_status: str | None = None) -> str:
- status = _operation_status(value, run_status)
- return (
- "failed"
- if status == "failure"
- else "interrupted"
- if status == "interrupted"
- else "running"
- if status == "running"
- else "completed"
- )
- def _agent_task(event: dict[str, Any]) -> str | None:
- value = event_input(event)
- if isinstance(value, dict) and isinstance(value.get("task"), str):
- return value["task"]
- return None
- def _agent_summary(event: dict[str, Any]) -> str | None:
- value = event_output(event)
- if isinstance(value, dict) and isinstance(value.get("summary"), str):
- return value["summary"]
- return None
- def _start(item: dict[str, Any]) -> datetime | None:
- return _date_value(item.get("startedAt") or item.get("started_at"))
- def _end(item: dict[str, Any], captured_at: datetime) -> datetime | None:
- value = _date_value(item.get("endedAt") or item.get("ended_at"))
- if value is None and _operation_status(item) == "running":
- return captured_at
- return value
- def _date_value(value: Any) -> datetime | None:
- if isinstance(value, datetime):
- parsed = value
- if parsed.tzinfo is None:
- parsed = parsed.replace(tzinfo=_DATABASE_TIMEZONE)
- return parsed.astimezone(timezone.utc)
- if isinstance(value, str) and value:
- try:
- parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
- if parsed.tzinfo is None:
- parsed = parsed.replace(tzinfo=_DATABASE_TIMEZONE)
- return parsed.astimezone(timezone.utc)
- except ValueError:
- return None
- return None
- def _iso_value(value: datetime | None) -> str | None:
- return value.isoformat() if value is not None else None
- def _preview(value: Any, limit: int) -> str | None:
- text = str(value or "").strip()
- if not text:
- return None
- text = re.sub(r"\s+", " ", text)
- return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"
|