"""dashboard/agent_graph.py — live SVG graph of the LangGraph agent topology.
Rendered inside _live_trace_fragment() in app.py while a brief is being generated.
The graph shows the Signals → Orchestrator → Synthesis flow on the left, with the
5 tools fanning out to the right — nodes illuminate as the agent uses them.
Live state is derived from the streaming trace list (already available in app.py):
• active tool → green (pending tool_call this round)
• used tools → pale green (already called this run)
• synthesis → green when in_progress, pale green when done
• signals → green when trace is empty (startup)
No new dependencies — pure SVG injected via st.markdown(..., unsafe_allow_html=True).
Tool names mirror agent/tools.py; update TOOL_DISPLAY here if tools change.
"""
from __future__ import annotations
import streamlit as st
from dashboard.theme import (
BG, BG_MUTED, BORDER, BORDER_STRONG,
TEXT, TEXT_MUTED, TEXT_FAINT,
BRAND_GREEN, BRAND_GREEN_DARK,
GREEN, BULL_BG, BULL_BORDER,
BLUE,
AI_BG, AI_COLOR,
)
# ── Tool display metadata ─────────────────────────────────────────────────────
# Mirrors the 5 tools registered in agent/tools.py — update here if tools change.
TOOL_DISPLAY: dict[str, tuple[str, str]] = {
"get_financial_metrics": ("📊", "Financials"),
"search_filing": ("📄", "Filings"),
"search_transcript": ("🎙", "Transcript"),
"get_analyst_expectations": ("📈", "Analysts"),
"search_news": ("📰", "News"),
}
_TOOL_NAMES = list(TOOL_DISPLAY.keys())
# Phase display metadata
_PHASE_META: dict[str, tuple[str, str]] = {
"signals": ("⚡", "Starting up…"),
"thinking": ("💭", "Thinking…"),
"tool": ("🔧", "Using tools"),
"synthesis": ("✍️", "Synthesizing brief…"),
"done": ("✅", "Brief ready"),
}
# ── HTML escape helper ────────────────────────────────────────────────────────
def _he(s: str) -> str:
"""Minimal HTML escape for user-derived text injected into markup."""
return (
s.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace('"', """)
)
# ── State derivation (pure — no Streamlit dependency) ─────────────────────────
def derive_live_state(trace: list[dict]) -> dict:
"""Derive display state from the current trace snapshot.
Args:
trace: list of step dicts from dashboard/reasoning.py absorb_* helpers.
Returns dict with:
used_tools set[str] — tool names with status "done" this run
active_tools list[str] — tool names with status "pending" (current round)
phase str — "signals"|"thinking"|"tool"|"synthesis"|"done"
latest_thought str — last assistant_text, truncated to ~140 chars
latest_result dict|None — {name, line} of last tool_result, or None
active_args str — short first arg of active tool call (may be "")
"""
if not trace:
return {
"used_tools": set(),
"active_tools": [],
"phase": "signals",
"latest_thought": "",
"latest_result": None,
"active_args": "",
}
used_tools: set[str] = set()
active_tools: list[str] = []
active_args: str = ""
latest_thought: str = ""
latest_result: dict | None = None
phase: str = "thinking"
for step in trace:
kind = step["kind"]
if kind == "assistant_text":
text = step.get("text", "").strip().replace("\n", " ")
if text:
latest_thought = text[:140] + ("…" if len(text) > 140 else "")
elif kind == "tool_call":
name = step.get("name", "")
if step.get("status") == "done":
used_tools.add(name)
elif step.get("status") == "pending":
active_tools.append(name)
if not active_args:
args = step.get("args") or {}
for key in ("query", "ticker", "since", "period"):
val = args.get(key)
if val:
s = str(val).strip()
active_args = s[:50] + ("…" if len(s) > 50 else "")
break
elif kind == "tool_result":
snippet = step.get("snippet", "").strip().replace("\n", " ")
if snippet:
line = snippet[:110] + ("…" if len(snippet) > 110 else "")
latest_result = {"name": step.get("name", ""), "line": line}
elif kind == "synthesis":
status = step.get("status", "")
if status == "done":
phase = "done"
elif status == "in_progress" and phase != "done":
phase = "synthesis"
# Resolve phase from tool activity if not set by synthesis
if phase not in ("done", "synthesis"):
phase = "tool" if active_tools else "thinking"
return {
"used_tools": used_tools,
"active_tools": active_tools,
"phase": phase,
"latest_thought": latest_thought,
"latest_result": latest_result,
"active_args": active_args,
}
# ── SVG layout constants ──────────────────────────────────────────────────────
_VW, _VH = 510, 265 # SVG viewBox dimensions
# Orchestrator node (center of composition)
_ORCH_CX, _ORCH_CY = 155, 132
_ORCH_W, _ORCH_H = 118, 44
# Signals / synthesis pill nodes (vertically aligned with orchestrator)
_SIG_CX, _SIG_CY = 155, 24
_SYN_CX, _SYN_CY = 155, 242
_PILL_W, _PILL_H = 90, 26
# Tool nodes — right column
_TOOL_X = 360 # left edge x
_TOOL_W = 138 # width
_TOOL_H = 28 # height
_TOOL_ICON_X = _TOOL_X + 16 # emoji center x
_TOOL_LABEL_X = _TOOL_X + 32 # text start x
def _tool_cy(i: int) -> int:
"""Vertical center of tool node i (0-indexed, 5 tools total)."""
top = 22
step = (_VH - top * 2) // 4 # evenly spread across height
return top + i * step # → 22, 77, 132, 187, 242
# ── SVG primitive helpers ─────────────────────────────────────────────────────
def _rect(x: int, y: int, w: int, h: int, rx: int,
fill: str, stroke: str, stroke_w: float = 1.5) -> str:
return (
f'