File size: 8,129 Bytes
de15094
 
 
 
 
 
 
 
 
 
7880373
de15094
 
 
 
 
7880373
de15094
 
 
 
 
 
e6496c0
de15094
 
7880373
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
de15094
 
7880373
de15094
 
 
7880373
 
de15094
 
 
 
 
 
 
 
7880373
de15094
 
e6496c0
de15094
 
 
 
 
 
7880373
 
 
 
de15094
 
 
 
e6496c0
de15094
 
 
 
 
 
 
 
 
 
 
 
 
7880373
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e6496c0
7880373
 
de15094
 
 
7880373
de15094
7880373
de15094
 
7880373
de15094
bd2c14a
de15094
 
 
 
7880373
de15094
 
7880373
de15094
7880373
 
de15094
 
 
 
 
7880373
 
de15094
 
7880373
de15094
 
 
 
 
7880373
 
 
 
 
 
 
 
 
 
 
de15094
 
 
 
7880373
de15094
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Chat Q&A section β€” free-form questions over stored documents for the current ticker.

The chat answers are grounded exclusively in SEC filings, earnings transcripts, and
structured metrics (no live news / web search).  The agent loop is bounded at 5 rounds
and every response must cite its sources via chunk_context headers.
"""
from __future__ import annotations

import streamlit as st

from agent.llm import RunConfig, classify_llm_error
from dashboard.theme import (
    BORDER,
    BG_MUTED,
    TEXT,
    TEXT_MUTED,
    AI_COLOR,
    FS_PAGE,
    FS_META,
    SPACE_4,
    SPACE_6,
)
from storage import metrics_db
from dashboard.i18n import t


# Tool β†’ human label, used both for suggested-question chips (n/a) and the
# "Searched: ..." line shown under each answer.
_TOOL_LABELS: dict[str, str] = {
    "search_filing": "Filings (10-K/10-Q)",
    "search_transcript": "Transcript",
    "get_financial_metrics": "Metrics",
    "get_analyst_expectations": "Analyst data",
}


def _watch_to_question(item: str) -> str:
    """Reword a `what_to_watch` item into a chat question (pure, no LLM call)."""
    item = item.strip().rstrip(".")
    return f'What do the filings and transcript say about: "{item}"?'


def _searched_labels(sources: list[dict]) -> list[str]:
    called = {s.get("tool_name") for s in sources}
    return [label for name, label in _TOOL_LABELS.items() if name in called]


def _suggested_questions(ticker: str, brief: dict | None) -> list[str]:
    questions: list[str] = []
    if brief:
        for item in (brief.get("what_to_watch") or [])[:3]:
            if isinstance(item, str) and item.strip():
                questions.append(_watch_to_question(item))
            elif isinstance(item, dict):
                text = item.get("text") or item.get("watch") or ""
                if text:
                    questions.append(_watch_to_question(text))
    questions.append(t("chat_q_changed"))
    questions.append(t("chat_q_risks"))
    return questions[:4]


# ── Public entry point ────────────────────────────────────────────────────────

def render(ticker: str, brief: dict | None = None, config: RunConfig | None = None) -> None:
    """Render the chat Q&A tab for *ticker*.

    Requires: the ticker must have been ingested (``metrics_db`` has rows for it).
    Does NOT require a brief to have been generated (though *brief*, if passed,
    seeds the suggested-question chips).
    """
    # Header β€” mirrors quality_tone.py:179-200 pattern.
    st.markdown(
        f"""
        <div style="border-bottom:1px solid {BORDER};padding-bottom:{SPACE_4};
                    margin-bottom:{SPACE_6};">
            <div style="font-size:{FS_PAGE};font-weight:700;color:{TEXT};
                        letter-spacing:-0.02em;">
                Ask about {ticker}
            </div>
            <div style="font-size:{FS_META};color:{TEXT_MUTED};margin-top:4px;">
                {t("chat_subtitle")}
            </div>
        </div>
        """,
        unsafe_allow_html=True,
    )

    if config is None:
        st.warning(t("model_blocked_chat"), icon="⚠️")
        return

    # Guard: ticker must be ingested.
    rows = metrics_db.get_all_metrics(ticker)
    if not rows:
        st.warning(
            t("chat_not_ingested").format(ticker=ticker),
            icon="⚠️",
        )
        return

    # Per-ticker chat history stored in session_state (mirrors reasoning_trace at app.py:233).
    chat_history = st.session_state.setdefault("chat_history", {})
    history: list[dict] = chat_history.setdefault(ticker, [])

    # Render existing messages.
    for msg in history:
        role = msg["role"]
        with st.chat_message(role):
            st.markdown(msg["content"])
            if role == "assistant":
                if msg.get("sources"):
                    _render_sources_expander(msg["sources"])
                if msg.get("searched"):
                    _render_searched_line(msg["searched"])

    # Suggested-question chips β€” only when the thread is empty, so returning
    # visitors aren't shown stale prompts mid-conversation.
    clicked_chip: str | None = None
    if not history:
        st.caption(t("chat_suggested_label"))
        chips = _suggested_questions(ticker, brief)
        cols = st.columns(len(chips))
        for i, (col, chip_text) in enumerate(zip(cols, chips)):
            with col:
                if st.button(chip_text, key=f"chat_chip_{i}", use_container_width=True):
                    clicked_chip = chip_text

    # Capture new user input; question resolution order: typed input > CTA
    # prefill from another page > a clicked suggested-question chip.
    user_input = st.chat_input(t("chat_input_placeholder").format(ticker=ticker))
    question = user_input or st.session_state.pop("chat_prefill", None) or clicked_chip
    if not question:
        return

    # Display the user message immediately.
    history.append({"role": "user", "content": question})
    with st.chat_message("user"):
        st.markdown(question)

    # Call the chat agent and stream the answer.
    searched: list[str] = []
    with st.chat_message("assistant"):
        with st.spinner(t("chat_searching")):
            try:
                from agent.chat_agent import answer_question
                # Pass history BEFORE the current question (history[-1] is the just-added
                # user message; exclude it since it's passed separately as `question`).
                result = answer_question(ticker, question, history[:-1], config=config)
                answer = result["answer"]
                sources = result["sources"]
                searched = _searched_labels(sources)
            except Exception as exc:
                friendly = classify_llm_error(exc, config.provider)
                answer = f'⚠️ {friendly or t("chat_error").format(error=f"`{exc}`")}'
                sources = []

        st.markdown(answer)
        if sources:
            _render_sources_expander(sources)
        if searched:
            _render_searched_line(searched)

    # Persist the assistant reply (with sources for later re-render).
    history.append({"role": "assistant", "content": answer, "sources": sources, "searched": searched})
    st.rerun()


# ── Private helpers ───────────────────────────────────────────────────────────

def _render_searched_line(searched: list[str]) -> None:
    """Show which document collections were queried for this answer."""
    if not searched:
        return
    st.markdown(
        f"<div style='font-size:{FS_META};color:{AI_COLOR};margin-top:4px;'>"
        f"{t('chat_searched_prefix')} {' Β· '.join(searched)}</div>",
        unsafe_allow_html=True,
    )


def _render_sources_expander(sources: list[dict]) -> None:
    """Render a collapsible block showing the raw tool outputs used to build the answer."""
    if not sources:
        return
    label = t("chat_sources").format(n=len(sources))
    with st.expander(label, expanded=False):
        for i, src in enumerate(sources, 1):
            st.markdown(
                f"<div style='font-size:{FS_META};font-weight:600;color:{TEXT_MUTED};"
                f"margin-bottom:4px;'>{i}. {src['tool_name']}({_fmt_args(src['args'])})</div>",
                unsafe_allow_html=True,
            )
            st.markdown(
                f"<pre style='background:{BG_MUTED};border:1px solid {BORDER};"
                f"border-radius:6px;padding:8px 10px;font-size:0.72rem;"
                f"white-space:pre-wrap;overflow-x:auto;color:{TEXT_MUTED};"
                f"margin-bottom:10px;'>{src['output']}</pre>",
                unsafe_allow_html=True,
            )


def _fmt_args(args: dict) -> str:
    """Format tool call args for the sources expander header."""
    parts = [f"{k}={v!r}" for k, v in args.items() if v not in (None, "")]
    return ", ".join(parts)