File size: 16,418 Bytes
35676b4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cf19608
 
 
 
 
 
 
 
35676b4
 
 
 
 
cf19608
35676b4
cf19608
35676b4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5fcc2c6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cf19608
 
 
35676b4
cf19608
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35676b4
 
 
cf19608
35676b4
cf19608
 
35676b4
 
5fcc2c6
 
 
 
 
cf19608
5fcc2c6
cf19608
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35676b4
 
5fcc2c6
 
 
 
 
 
 
 
 
 
 
 
 
cf19608
 
 
 
5fcc2c6
 
 
cf19608
 
5fcc2c6
 
 
 
 
 
 
 
 
cf19608
 
5fcc2c6
cf19608
5fcc2c6
cf19608
 
5fcc2c6
cf19608
 
 
 
 
 
 
 
35676b4
cf19608
5fcc2c6
 
cf19608
5fcc2c6
cf19608
5fcc2c6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cf19608
 
 
 
 
 
 
 
 
 
 
 
 
35676b4
cf19608
 
 
 
35676b4
cf19608
 
 
 
35676b4
 
5fcc2c6
cf19608
 
5fcc2c6
cf19608
 
 
 
 
 
 
 
 
 
5fcc2c6
cf19608
 
5fcc2c6
 
cf19608
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35676b4
5fcc2c6
 
35676b4
 
 
5fcc2c6
 
 
 
cf19608
 
5fcc2c6
cf19608
5fcc2c6
cf19608
 
35676b4
 
 
 
 
 
cf19608
 
35676b4
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
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
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
"""dashboard/reasoning.py β€” render the agent's full reasoning trace.

The trace is a list of step dicts, built incrementally during graph streaming
and rendered (live during generation, persistently afterwards) inside an
expander on the Verdict tab.

Step shapes:
    {"kind": "assistant_text", "text": str}
    {"kind": "tool_call",      "id": str, "name": str, "args": dict, "status": "pending"|"done"}
    {"kind": "tool_result",    "tool_call_id": str, "name": str, "snippet": str, "full": str}
    {"kind": "synthesis",      "status": "in_progress"|"done", "error": str | None}
"""
from __future__ import annotations

import json
from typing import Any

import streamlit as st

from dashboard.theme import (
    BG, BG_MUTED, BORDER, BORDER_STRONG,
    GREEN, RED, AMBER, BLUE,
    TEXT, TEXT_MUTED,
    INFO_BG, INFO_BORDER,
    WARN_BG, WARN_BORDER,
)
from dashboard.components import source_badge


SNIPPET_LEN = 200


# ──────────────────────────────────────────────────────────────────────────────
# Trace builder helpers β€” called from app.py during graph.stream
# ──────────────────────────────────────────────────────────────────────────────

def _stringify(content: Any) -> str:
    if isinstance(content, str):
        return content
    if isinstance(content, list):
        parts = []
        for block in content:
            if isinstance(block, dict) and block.get("type") == "text":
                parts.append(block.get("text", ""))
            elif isinstance(block, str):
                parts.append(block)
        return "\n".join(p for p in parts if p)
    return str(content)


def absorb_agent_messages(trace: list[dict], messages: list) -> None:
    """Append assistant_text + tool_call steps from a batch of agent messages."""
    for msg in messages:
        text = _stringify(getattr(msg, "content", "")).strip()
        if text:
            trace.append({"kind": "assistant_text", "text": text})
        for tc in getattr(msg, "tool_calls", None) or []:
            trace.append({
                "kind": "tool_call",
                "id": tc.get("id", ""),
                "name": tc.get("name", ""),
                "args": tc.get("args") or {},
                "status": "pending",
            })


def absorb_tool_messages(trace: list[dict], messages: list) -> None:
    """Append tool_result steps and mark matching tool_call steps done."""
    for msg in messages:
        tcid = getattr(msg, "tool_call_id", "")
        name = getattr(msg, "name", "") or ""
        full = _stringify(getattr(msg, "content", ""))
        snippet = full.strip().replace("\n", " ")
        if len(snippet) > SNIPPET_LEN:
            snippet = snippet[:SNIPPET_LEN] + "…"
        trace.append({
            "kind": "tool_result",
            "tool_call_id": tcid,
            "name": name,
            "snippet": snippet,
            "full": full,
        })
        for step in trace:
            if step["kind"] == "tool_call" and step["id"] == tcid:
                step["status"] = "done"


def mark_synthesis(trace: list[dict], status: str, error: str | None = None) -> None:
    """Add or update a synthesis step."""
    for step in trace:
        if step["kind"] == "synthesis":
            step["status"] = status
            step["error"] = error
            return
    trace.append({"kind": "synthesis", "status": status, "error": error})


# ──────────────────────────────────────────────────────────────────────────────
# Progress estimation β€” called from app.py live fragment
# ──────────────────────────────────────────────────────────────────────────────

def estimate_progress(trace: list[dict]) -> tuple[float, str]:
    """Return (0.0–1.0, status label) based on current trace state."""
    if not trace:
        return 0.02, "Starting…"
    for step in trace:
        if step["kind"] == "synthesis":
            if step["status"] == "done":
                return 1.0, "Brief ready β€” switching to Verdict tab"
            return 0.92, "Synthesizing brief…"
    completed = sum(1 for s in trace if s["kind"] == "tool_call" and s["status"] == "done")
    pending   = sum(1 for s in trace if s["kind"] == "tool_call" and s["status"] == "pending")
    if completed == 0 and pending > 0:
        return 0.06, "Running first queries…"
    value = 0.10 + min(completed / 10, 1.0) * 0.78
    return min(value, 0.88), f"Round {completed} β€” investigating…"


# ──────────────────────────────────────────────────────────────────────────────
# Pre-rendering: pair tool_call + tool_result into unified "tool_round" steps
# ──────────────────────────────────────────────────────────────────────────────

def _pair_steps(trace: list[dict]) -> list[dict]:
    """Walk the flat trace and merge each tool_call with its matching tool_result."""
    results_by_id: dict[str, dict] = {}
    for step in trace:
        if step["kind"] == "tool_result":
            results_by_id[step["tool_call_id"]] = step

    paired: list[dict] = []
    for step in trace:
        kind = step["kind"]
        if kind == "assistant_text":
            paired.append(step)
        elif kind == "tool_call":
            paired.append({
                "kind": "tool_round",
                "call": step,
                "result": results_by_id.get(step["id"]),
            })
        elif kind == "tool_result":
            pass  # consumed above
        elif kind == "synthesis":
            paired.append(step)
    return paired


# ──────────────────────────────────────────────────────────────────────────────
# Helpers
# ──────────────────────────────────────────────────────────────────────────────

_TOOL_CATEGORIES: dict[str, str] = {
    "search_filing": "filing",
    "get_financial_metrics": "filing",
    "get_analyst": "filing",
    "search_transcript": "transcript",
    "get_news": "news",
    "search_news": "news",
}


def _tool_category(name: str) -> str:
    for key, cat in _TOOL_CATEGORIES.items():
        if key in name:
            return cat
    return "tool"


def _fmt_args(args: dict, max_len: int = 120) -> str:
    if not args:
        return ""
    try:
        s = json.dumps(args, ensure_ascii=False, separators=(", ", ": "), default=str).strip("{}")
    except Exception:
        s = str(args)
    return s if len(s) <= max_len else s[: max_len - 1] + "…"


def _trunc(text: str, n: int = 75) -> str:
    text = text.strip().replace("\n", " ")
    return text if len(text) <= n else text[:n - 1] + "…"


def _details_block(full: str) -> str:
    """HTML <details> collapsible for full tool result. Empty if content fits in snippet."""
    if not full or len(full.strip()) <= SNIPPET_LEN:
        return ""
    escaped = full.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
    return (
        f'<details style="margin-top:7px;">'
        f'<summary style="cursor:pointer;font-size:0.72rem;color:{TEXT_MUTED};font-weight:500;'
        f'list-style:none;display:inline-flex;align-items:center;gap:4px;user-select:none;">'
        f'<span style="font-size:0.58rem;">β–Ά</span> Show full result'
        f'</summary>'
        f'<div style="margin-top:6px;padding:8px 10px;background:{BG_MUTED};'
        f'border:1px solid {BORDER};border-radius:6px;'
        f'font-family:ui-monospace,SFMono-Regular,Menlo,monospace;'
        f'font-size:0.72rem;line-height:1.5;color:{TEXT_MUTED};'
        f'white-space:pre-wrap;word-break:break-all;'
        f'max-height:300px;overflow-y:auto;">{escaped}</div>'
        f'</details>'
    )


def _details_wrap(summary_html: str, body_html: str, is_open: bool) -> str:
    """Wrap body_html in a <details> block with the given summary."""
    open_attr = " open" if is_open else ""
    return (
        f'<details{open_attr} style="margin:4px 0;">'
        f'<summary style="list-style:none;cursor:pointer;user-select:none;">'
        f'{summary_html}'
        f'</summary>'
        f'{body_html}'
        f'</details>'
    )


# ──────────────────────────────────────────────────────────────────────────────
# Step renderers
# ──────────────────────────────────────────────────────────────────────────────

def _render_assistant_text(step: dict, is_recent: bool = True) -> None:
    preview = _trunc(step["text"])
    summary = (
        f'<div style="background:{INFO_BG};border:1px solid {INFO_BORDER};'
        f'border-left:4px solid {BLUE};border-radius:0 10px 10px 0;'
        f'padding:7px 14px;font-size:0.8rem;color:{TEXT_MUTED};">'
        f'πŸ’­ <em style="color:{BLUE};font-weight:600;">Thinking</em>'
        f'<span style="margin-left:8px;color:{TEXT_MUTED};">{preview}</span>'
        f'</div>'
    )
    body = (
        f'<div style="background:{INFO_BG};border:1px solid {INFO_BORDER};'
        f'border-left:4px solid {BLUE};border-radius:0 10px 10px 0;'
        f'padding:10px 14px;margin-top:2px;">'
        f'<div style="font-size:0.82rem;line-height:1.55;color:{TEXT};white-space:pre-wrap;">'
        f'{step["text"]}</div>'
        f'</div>'
    )
    st.markdown(_details_wrap(summary, body, is_recent), unsafe_allow_html=True)


def _render_tool_round(step: dict, is_recent: bool = True) -> None:
    call = step["call"]
    result = step["result"]
    pending = result is None

    if pending:
        bg, border_color, accent, icon = WARN_BG, WARN_BORDER, AMBER, "πŸ”„"
    else:
        bg, border_color, accent, icon = BG_MUTED, BORDER, GREEN, "βœ…"

    name = call["name"]
    category = _tool_category(name)
    badge_html = source_badge(category)
    args_str = _fmt_args(call.get("args") or {})
    snippet_preview = _trunc(result["snippet"] if result else "") if result else ""

    # Summary row (shown when collapsed)
    status_text = f'<em style="color:{AMBER};">running…</em>' if pending else (
        f'<span style="color:{TEXT_MUTED};font-size:0.75rem;">{snippet_preview}</span>' if snippet_preview else ""
    )
    summary = (
        f'<div style="background:{bg};border:1px solid {border_color};'
        f'border-left:4px solid {accent};border-radius:0 10px 10px 0;'
        f'padding:7px 14px;display:flex;align-items:center;justify-content:space-between;gap:8px;">'
        f'<div style="display:flex;align-items:center;gap:6px;font-size:0.8rem;color:{TEXT};min-width:0;">'
        f'{icon} '
        f'<code style="background:{BG};border:1px solid {BORDER_STRONG};'
        f'padding:1px 6px;border-radius:4px;font-size:0.75rem;">{name}</code>'
        f'<span style="color:{TEXT_MUTED};font-size:0.75rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">'
        f'{status_text}</span>'
        f'</div>'
        f'{badge_html}'
        f'</div>'
    )

    # Full body (shown when open)
    args_html = (
        f'<div style="margin-top:6px;padding:4px 9px;background:{BG};'
        f'border:1px solid {border_color};border-radius:5px;'
        f'font-family:ui-monospace,SFMono-Regular,Menlo,monospace;'
        f'font-size:0.71rem;color:{TEXT_MUTED};word-break:break-all;">'
        f'{args_str}</div>'
    ) if args_str else ""

    if result:
        snippet = result.get("snippet") or "(empty)"
        details = _details_block(result.get("full") or "")
        result_html = (
            f'<div style="margin-top:9px;padding-top:8px;border-top:1px solid {border_color};">'
            f'<div style="font-size:0.6rem;font-weight:700;text-transform:uppercase;'
            f'letter-spacing:0.08em;color:{GREEN};margin-bottom:4px;">↩ Result</div>'
            f'<div style="font-size:0.78rem;color:{TEXT_MUTED};line-height:1.45;">{snippet}</div>'
            f'{details}'
            f'</div>'
        )
    else:
        result_html = (
            f'<div style="margin-top:8px;padding-top:8px;border-top:1px solid {border_color};'
            f'font-size:0.75rem;color:{AMBER};font-style:italic;">Waiting for result…</div>'
        )

    body = (
        f'<div style="background:{bg};border:1px solid {border_color};'
        f'border-left:4px solid {accent};border-radius:0 10px 10px 0;'
        f'padding:10px 14px;margin-top:2px;">'
        f'<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;">'
        f'<div style="font-size:0.82rem;color:{TEXT};display:flex;align-items:center;gap:7px;">'
        f'{icon}'
        f'<code style="background:{BG};border:1px solid {BORDER_STRONG};'
        f'padding:1px 7px;border-radius:5px;font-size:0.77rem;">{name}</code>'
        f'</div>'
        f'{badge_html}'
        f'</div>'
        f'{args_html}'
        f'{result_html}'
        f'</div>'
    )

    st.markdown(_details_wrap(summary, body, is_recent), unsafe_allow_html=True)


def _render_synthesis(step: dict) -> None:
    if step.get("error"):
        icon, label, color = "❌", f"Synthesis failed: {step['error']}", RED
    elif step["status"] == "in_progress":
        icon, label, color = "πŸ”„", "Synthesizing brief…", AMBER
    else:
        icon, label, color = "βœ…", "Brief synthesized", GREEN
    st.markdown(
        f'<div style="background:{BG_MUTED};border:1px solid {BORDER};'
        f'border-left:4px solid {color};border-radius:0 10px 10px 0;'
        f'padding:10px 14px;margin:6px 0;">'
        f'<div style="font-size:0.85rem;font-weight:600;color:{TEXT};">'
        f'{icon} {label}</div></div>',
        unsafe_allow_html=True,
    )


# ──────────────────────────────────────────────────────────────────────────────
# Public API
# ──────────────────────────────────────────────────────────────────────────────

def render_trace_body(trace: list[dict], recent_n: int = 2) -> None:
    """Render every step of the trace. Older steps are collapsed, last recent_n are expanded."""
    if not trace:
        st.caption("No reasoning steps yet.")
        return
    paired = _pair_steps(trace)
    n = len(paired)
    for i, step in enumerate(paired):
        is_recent = i >= n - recent_n
        kind = step["kind"]
        if kind == "assistant_text":
            _render_assistant_text(step, is_recent=is_recent)
        elif kind == "tool_round":
            _render_tool_round(step, is_recent=is_recent)
        elif kind == "synthesis":
            _render_synthesis(step)


def render(trace: list[dict]) -> None:
    """Render the reasoning expander on the Verdict tab (collapsed by default)."""
    if not trace:
        return
    n_rounds = sum(1 for s in trace if s["kind"] == "tool_call")
    label = f"🧠 Reasoning trace β€” {n_rounds} tool round{'s' if n_rounds != 1 else ''}, {len(trace)} steps"
    with st.expander(label, expanded=False):
        render_trace_body(trace)