Viney commited on
Commit
cf19608
·
1 Parent(s): 76325e2

feat: redesign reasoning trace — paired tool cards, color-coded steps, collapsible results

Browse files
Files changed (1) hide show
  1. 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 BG, BG_MUTED, BORDER, GREEN, RED, TEXT, TEXT_MUTED, TEXT_FAINT, AMBER, BLUE
 
 
 
 
 
 
 
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
- # Renderers
95
- # ---------------------------------------------------------------------------
96
 
97
- def _fmt_args(args: dict) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  if not args:
99
  return ""
100
  try:
101
- return json.dumps(args, ensure_ascii=False, default=str)
102
  except Exception:
103
- return str(args)
 
104
 
105
 
106
- def _render_step(step: dict, idx: int) -> None:
107
- kind = step["kind"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- elif kind == "tool_call":
121
- icon = "✅" if step["status"] == "done" else "🔄"
122
- args_str = _fmt_args(step["args"])
123
- args_html = (
124
- f'<div style="font-size:0.72rem;color:{TEXT_MUTED};margin-top:3px;'
125
- f'font-family:ui-monospace,SFMono-Regular,Menlo,monospace;word-break:break-all;">'
126
- f'{args_str}</div>'
127
- if args_str else ""
128
- )
129
- st.markdown(
130
- f'<div style="border:1px solid {BORDER};background:{BG};'
131
- f'padding:8px 12px;margin:4px 0;border-radius:6px;">'
132
- f'<div style="font-size:0.82rem;color:{TEXT};">'
133
- f'{icon} <strong>tool</strong> · '
134
- f'<code style="background:{BG_MUTED};padding:1px 6px;border-radius:4px;'
135
- f'font-size:0.78rem;">{step["name"]}</code></div>'
136
- f'{args_html}</div>',
137
- unsafe_allow_html=True,
138
- )
 
 
 
 
 
 
 
 
139
 
140
- elif kind == "tool_result":
141
- snippet = step["snippet"] or "(empty)"
142
- st.markdown(
143
- f'<div style="border-left:3px solid {GREEN};background:{BG};'
144
- f'padding:6px 12px;margin:0 0 8px 16px;border-radius:0 6px 6px 0;">'
 
 
 
 
 
 
 
 
 
 
 
 
145
  f'<div style="font-size:0.6rem;font-weight:700;text-transform:uppercase;'
146
- f'letter-spacing:0.08em;color:{TEXT_FAINT};margin-bottom:3px;">↩ result</div>'
147
- f'<div style="font-size:0.78rem;color:{TEXT_MUTED};line-height:1.45;">'
148
- f'{snippet}</div></div>',
149
- unsafe_allow_html=True,
150
  )
151
- full = step.get("full") or ""
152
- if full and len(full) > len(step["snippet"]):
153
- st.code(full, language="text")
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 i, step in enumerate(trace):
178
- _render_step(step, i)
 
 
 
 
 
 
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
- n_calls = sum(1 for s in trace if s["kind"] == "tool_call")
186
- label = f"🧠 Reasoning trace — {n_calls} tool call{'s' if n_calls != 1 else ''}, {len(trace)} steps"
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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)