feat: redesign reasoning trace — paired tool cards, color-coded steps, collapsible results
Browse files- dashboard/reasoning.py +183 -73
dashboard/reasoning.py
CHANGED
|
@@ -17,21 +17,27 @@ from typing import Any
|
|
| 17 |
|
| 18 |
import streamlit as st
|
| 19 |
|
| 20 |
-
from dashboard.theme import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
|
| 23 |
SNIPPET_LEN = 200
|
| 24 |
|
| 25 |
|
| 26 |
-
#
|
| 27 |
# Trace builder helpers — called from app.py during graph.stream
|
| 28 |
-
#
|
| 29 |
|
| 30 |
def _stringify(content: Any) -> str:
|
| 31 |
if isinstance(content, str):
|
| 32 |
return content
|
| 33 |
if isinstance(content, list):
|
| 34 |
-
# Anthropic content blocks: [{"type": "text", "text": "..."}]
|
| 35 |
parts = []
|
| 36 |
for block in content:
|
| 37 |
if isinstance(block, dict) and block.get("type") == "text":
|
|
@@ -74,7 +80,6 @@ def absorb_tool_messages(trace: list[dict], messages: list) -> None:
|
|
| 74 |
"snippet": snippet,
|
| 75 |
"full": full,
|
| 76 |
})
|
| 77 |
-
# Mark the matching pending call as done
|
| 78 |
for step in trace:
|
| 79 |
if step["kind"] == "tool_call" and step["id"] == tcid:
|
| 80 |
step["status"] = "done"
|
|
@@ -90,99 +95,204 @@ def mark_synthesis(trace: list[dict], status: str, error: str | None = None) ->
|
|
| 90 |
trace.append({"kind": "synthesis", "status": status, "error": error})
|
| 91 |
|
| 92 |
|
| 93 |
-
#
|
| 94 |
-
#
|
| 95 |
-
#
|
| 96 |
|
| 97 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
if not args:
|
| 99 |
return ""
|
| 100 |
try:
|
| 101 |
-
|
| 102 |
except Exception:
|
| 103 |
-
|
|
|
|
| 104 |
|
| 105 |
|
| 106 |
-
def
|
| 107 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
|
| 109 |
-
if kind == "assistant_text":
|
| 110 |
-
st.markdown(
|
| 111 |
-
f'<div style="border-left:3px solid {BLUE};background:{BG_MUTED};'
|
| 112 |
-
f'padding:8px 12px;margin:4px 0 8px;border-radius:0 6px 6px 0;">'
|
| 113 |
-
f'<div style="font-size:0.6rem;font-weight:700;text-transform:uppercase;'
|
| 114 |
-
f'letter-spacing:0.08em;color:{TEXT_FAINT};margin-bottom:4px;">💭 Thinking</div>'
|
| 115 |
-
f'<div style="font-size:0.82rem;line-height:1.5;color:{TEXT};white-space:pre-wrap;">'
|
| 116 |
-
f'{step["text"]}</div></div>',
|
| 117 |
-
unsafe_allow_html=True,
|
| 118 |
-
)
|
| 119 |
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
f'<div style="font-size:0.6rem;font-weight:700;text-transform:uppercase;'
|
| 146 |
-
f'letter-spacing:0.08em;color:{
|
| 147 |
-
f'<div style="font-size:0.78rem;color:{TEXT_MUTED};line-height:1.45;">'
|
| 148 |
-
f'{
|
| 149 |
-
|
| 150 |
)
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
elif kind == "synthesis":
|
| 156 |
-
if step.get("error"):
|
| 157 |
-
icon, label, color = "❌", f"Synthesis failed: {step['error']}", RED
|
| 158 |
-
elif step["status"] == "in_progress":
|
| 159 |
-
icon, label, color = "🔄", "Synthesizing brief…", AMBER
|
| 160 |
-
else:
|
| 161 |
-
icon, label, color = "✅", "Brief synthesized", GREEN
|
| 162 |
-
st.markdown(
|
| 163 |
-
f'<div style="border:1px solid {BORDER};background:{BG_MUTED};'
|
| 164 |
-
f'padding:8px 12px;margin:8px 0;border-radius:6px;'
|
| 165 |
-
f'border-left:3px solid {color};">'
|
| 166 |
-
f'<div style="font-size:0.85rem;font-weight:600;color:{TEXT};">'
|
| 167 |
-
f'{icon} {label}</div></div>',
|
| 168 |
-
unsafe_allow_html=True,
|
| 169 |
)
|
| 170 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
|
| 172 |
def render_trace_body(trace: list[dict]) -> None:
|
| 173 |
"""Render every step of the trace, top to bottom."""
|
| 174 |
if not trace:
|
| 175 |
st.caption("No reasoning steps yet.")
|
| 176 |
return
|
| 177 |
-
for
|
| 178 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
|
| 180 |
|
| 181 |
def render(trace: list[dict]) -> None:
|
| 182 |
"""Render the reasoning expander on the Verdict tab (collapsed by default)."""
|
| 183 |
if not trace:
|
| 184 |
return
|
| 185 |
-
|
| 186 |
-
label = f"🧠 Reasoning trace — {
|
| 187 |
with st.expander(label, expanded=False):
|
| 188 |
render_trace_body(trace)
|
|
|
|
| 17 |
|
| 18 |
import streamlit as st
|
| 19 |
|
| 20 |
+
from dashboard.theme import (
|
| 21 |
+
BG, BG_MUTED, BORDER, BORDER_STRONG,
|
| 22 |
+
GREEN, RED, AMBER, BLUE,
|
| 23 |
+
TEXT, TEXT_MUTED,
|
| 24 |
+
INFO_BG, INFO_BORDER,
|
| 25 |
+
WARN_BG, WARN_BORDER,
|
| 26 |
+
)
|
| 27 |
+
from dashboard.components import source_badge
|
| 28 |
|
| 29 |
|
| 30 |
SNIPPET_LEN = 200
|
| 31 |
|
| 32 |
|
| 33 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 34 |
# Trace builder helpers — called from app.py during graph.stream
|
| 35 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 36 |
|
| 37 |
def _stringify(content: Any) -> str:
|
| 38 |
if isinstance(content, str):
|
| 39 |
return content
|
| 40 |
if isinstance(content, list):
|
|
|
|
| 41 |
parts = []
|
| 42 |
for block in content:
|
| 43 |
if isinstance(block, dict) and block.get("type") == "text":
|
|
|
|
| 80 |
"snippet": snippet,
|
| 81 |
"full": full,
|
| 82 |
})
|
|
|
|
| 83 |
for step in trace:
|
| 84 |
if step["kind"] == "tool_call" and step["id"] == tcid:
|
| 85 |
step["status"] = "done"
|
|
|
|
| 95 |
trace.append({"kind": "synthesis", "status": status, "error": error})
|
| 96 |
|
| 97 |
|
| 98 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 99 |
+
# Pre-rendering: pair tool_call + tool_result into unified "tool_round" steps
|
| 100 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 101 |
|
| 102 |
+
def _pair_steps(trace: list[dict]) -> list[dict]:
|
| 103 |
+
"""Walk the flat trace and merge each tool_call with its matching tool_result."""
|
| 104 |
+
results_by_id: dict[str, dict] = {}
|
| 105 |
+
for step in trace:
|
| 106 |
+
if step["kind"] == "tool_result":
|
| 107 |
+
results_by_id[step["tool_call_id"]] = step
|
| 108 |
+
|
| 109 |
+
paired: list[dict] = []
|
| 110 |
+
for step in trace:
|
| 111 |
+
kind = step["kind"]
|
| 112 |
+
if kind == "assistant_text":
|
| 113 |
+
paired.append(step)
|
| 114 |
+
elif kind == "tool_call":
|
| 115 |
+
paired.append({
|
| 116 |
+
"kind": "tool_round",
|
| 117 |
+
"call": step,
|
| 118 |
+
"result": results_by_id.get(step["id"]),
|
| 119 |
+
})
|
| 120 |
+
elif kind == "tool_result":
|
| 121 |
+
pass # consumed above
|
| 122 |
+
elif kind == "synthesis":
|
| 123 |
+
paired.append(step)
|
| 124 |
+
return paired
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 128 |
+
# Helpers
|
| 129 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 130 |
+
|
| 131 |
+
_TOOL_CATEGORIES: dict[str, str] = {
|
| 132 |
+
"search_filing": "filing",
|
| 133 |
+
"get_financial_metrics": "filing",
|
| 134 |
+
"get_analyst": "filing",
|
| 135 |
+
"search_transcript": "transcript",
|
| 136 |
+
"get_news": "news",
|
| 137 |
+
"search_news": "news",
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def _tool_category(name: str) -> str:
|
| 142 |
+
for key, cat in _TOOL_CATEGORIES.items():
|
| 143 |
+
if key in name:
|
| 144 |
+
return cat
|
| 145 |
+
return "tool"
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def _fmt_args(args: dict, max_len: int = 120) -> str:
|
| 149 |
if not args:
|
| 150 |
return ""
|
| 151 |
try:
|
| 152 |
+
s = json.dumps(args, ensure_ascii=False, separators=(", ", ": "), default=str).strip("{}")
|
| 153 |
except Exception:
|
| 154 |
+
s = str(args)
|
| 155 |
+
return s if len(s) <= max_len else s[: max_len - 1] + "…"
|
| 156 |
|
| 157 |
|
| 158 |
+
def _details_block(full: str) -> str:
|
| 159 |
+
"""HTML <details> collapsible for full tool result text. Empty if content is short."""
|
| 160 |
+
if not full or len(full.strip()) <= SNIPPET_LEN:
|
| 161 |
+
return ""
|
| 162 |
+
escaped = full.replace("&", "&").replace("<", "<").replace(">", ">")
|
| 163 |
+
return (
|
| 164 |
+
f'<details style="margin-top:7px;">'
|
| 165 |
+
f'<summary style="cursor:pointer;font-size:0.72rem;color:{TEXT_MUTED};font-weight:500;'
|
| 166 |
+
f'list-style:none;display:inline-flex;align-items:center;gap:4px;user-select:none;">'
|
| 167 |
+
f'<span style="font-size:0.58rem;">▶</span> Show full result'
|
| 168 |
+
f'</summary>'
|
| 169 |
+
f'<div style="margin-top:6px;padding:8px 10px;background:{BG_MUTED};'
|
| 170 |
+
f'border:1px solid {BORDER};border-radius:6px;'
|
| 171 |
+
f'font-family:ui-monospace,SFMono-Regular,Menlo,monospace;'
|
| 172 |
+
f'font-size:0.72rem;line-height:1.5;color:{TEXT_MUTED};'
|
| 173 |
+
f'white-space:pre-wrap;word-break:break-all;'
|
| 174 |
+
f'max-height:300px;overflow-y:auto;">{escaped}</div>'
|
| 175 |
+
f'</details>'
|
| 176 |
+
)
|
| 177 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
|
| 179 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 180 |
+
# Step renderers
|
| 181 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 182 |
+
|
| 183 |
+
def _render_assistant_text(step: dict) -> None:
|
| 184 |
+
st.markdown(
|
| 185 |
+
f'<div style="background:{INFO_BG};border:1px solid {INFO_BORDER};'
|
| 186 |
+
f'border-left:4px solid {BLUE};border-radius:0 10px 10px 0;'
|
| 187 |
+
f'padding:10px 14px;margin:6px 0;">'
|
| 188 |
+
f'<div style="font-size:0.6rem;font-weight:700;text-transform:uppercase;'
|
| 189 |
+
f'letter-spacing:0.09em;color:{BLUE};margin-bottom:5px;">💭 Thinking</div>'
|
| 190 |
+
f'<div style="font-size:0.82rem;line-height:1.55;color:{TEXT};white-space:pre-wrap;">'
|
| 191 |
+
f'{step["text"]}</div>'
|
| 192 |
+
f'</div>',
|
| 193 |
+
unsafe_allow_html=True,
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def _render_tool_round(step: dict) -> None:
|
| 198 |
+
call = step["call"]
|
| 199 |
+
result = step["result"]
|
| 200 |
+
pending = result is None
|
| 201 |
+
|
| 202 |
+
if pending:
|
| 203 |
+
bg, border_color, accent, icon = WARN_BG, WARN_BORDER, AMBER, "🔄"
|
| 204 |
+
else:
|
| 205 |
+
bg, border_color, accent, icon = BG_MUTED, BORDER, GREEN, "✅"
|
| 206 |
|
| 207 |
+
name = call["name"]
|
| 208 |
+
badge_html = source_badge(_tool_category(name))
|
| 209 |
+
args_str = _fmt_args(call.get("args") or {})
|
| 210 |
+
|
| 211 |
+
args_html = (
|
| 212 |
+
f'<div style="margin-top:6px;padding:4px 9px;background:{BG};'
|
| 213 |
+
f'border:1px solid {border_color};border-radius:5px;'
|
| 214 |
+
f'font-family:ui-monospace,SFMono-Regular,Menlo,monospace;'
|
| 215 |
+
f'font-size:0.71rem;color:{TEXT_MUTED};word-break:break-all;">'
|
| 216 |
+
f'{args_str}</div>'
|
| 217 |
+
) if args_str else ""
|
| 218 |
+
|
| 219 |
+
if result:
|
| 220 |
+
snippet = result.get("snippet") or "(empty)"
|
| 221 |
+
details = _details_block(result.get("full") or "")
|
| 222 |
+
result_html = (
|
| 223 |
+
f'<div style="margin-top:9px;padding-top:8px;border-top:1px solid {border_color};">'
|
| 224 |
f'<div style="font-size:0.6rem;font-weight:700;text-transform:uppercase;'
|
| 225 |
+
f'letter-spacing:0.08em;color:{GREEN};margin-bottom:4px;">↩ Result</div>'
|
| 226 |
+
f'<div style="font-size:0.78rem;color:{TEXT_MUTED};line-height:1.45;">{snippet}</div>'
|
| 227 |
+
f'{details}'
|
| 228 |
+
f'</div>'
|
| 229 |
)
|
| 230 |
+
else:
|
| 231 |
+
result_html = (
|
| 232 |
+
f'<div style="margin-top:8px;padding-top:8px;border-top:1px solid {border_color};'
|
| 233 |
+
f'font-size:0.75rem;color:{AMBER};font-style:italic;">Waiting for result…</div>'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
)
|
| 235 |
|
| 236 |
+
st.markdown(
|
| 237 |
+
f'<div style="background:{bg};border:1px solid {border_color};'
|
| 238 |
+
f'border-left:4px solid {accent};border-radius:0 10px 10px 0;'
|
| 239 |
+
f'padding:10px 14px;margin:6px 0;">'
|
| 240 |
+
f'<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;">'
|
| 241 |
+
f'<div style="font-size:0.82rem;color:{TEXT};display:flex;align-items:center;gap:7px;">'
|
| 242 |
+
f'{icon}'
|
| 243 |
+
f'<code style="background:{BG};border:1px solid {BORDER_STRONG};'
|
| 244 |
+
f'padding:1px 7px;border-radius:5px;font-size:0.77rem;">{name}</code>'
|
| 245 |
+
f'</div>'
|
| 246 |
+
f'{badge_html}'
|
| 247 |
+
f'</div>'
|
| 248 |
+
f'{args_html}'
|
| 249 |
+
f'{result_html}'
|
| 250 |
+
f'</div>',
|
| 251 |
+
unsafe_allow_html=True,
|
| 252 |
+
)
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def _render_synthesis(step: dict) -> None:
|
| 256 |
+
if step.get("error"):
|
| 257 |
+
icon, label, color = "❌", f"Synthesis failed: {step['error']}", RED
|
| 258 |
+
elif step["status"] == "in_progress":
|
| 259 |
+
icon, label, color = "🔄", "Synthesizing brief…", AMBER
|
| 260 |
+
else:
|
| 261 |
+
icon, label, color = "✅", "Brief synthesized", GREEN
|
| 262 |
+
st.markdown(
|
| 263 |
+
f'<div style="background:{BG_MUTED};border:1px solid {BORDER};'
|
| 264 |
+
f'border-left:4px solid {color};border-radius:0 10px 10px 0;'
|
| 265 |
+
f'padding:10px 14px;margin:6px 0;">'
|
| 266 |
+
f'<div style="font-size:0.85rem;font-weight:600;color:{TEXT};">'
|
| 267 |
+
f'{icon} {label}</div></div>',
|
| 268 |
+
unsafe_allow_html=True,
|
| 269 |
+
)
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 273 |
+
# Public API
|
| 274 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 275 |
|
| 276 |
def render_trace_body(trace: list[dict]) -> None:
|
| 277 |
"""Render every step of the trace, top to bottom."""
|
| 278 |
if not trace:
|
| 279 |
st.caption("No reasoning steps yet.")
|
| 280 |
return
|
| 281 |
+
for step in _pair_steps(trace):
|
| 282 |
+
kind = step["kind"]
|
| 283 |
+
if kind == "assistant_text":
|
| 284 |
+
_render_assistant_text(step)
|
| 285 |
+
elif kind == "tool_round":
|
| 286 |
+
_render_tool_round(step)
|
| 287 |
+
elif kind == "synthesis":
|
| 288 |
+
_render_synthesis(step)
|
| 289 |
|
| 290 |
|
| 291 |
def render(trace: list[dict]) -> None:
|
| 292 |
"""Render the reasoning expander on the Verdict tab (collapsed by default)."""
|
| 293 |
if not trace:
|
| 294 |
return
|
| 295 |
+
n_rounds = sum(1 for s in trace if s["kind"] == "tool_call")
|
| 296 |
+
label = f"🧠 Reasoning trace — {n_rounds} tool round{'s' if n_rounds != 1 else ''}, {len(trace)} steps"
|
| 297 |
with st.expander(label, expanded=False):
|
| 298 |
render_trace_body(trace)
|