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)
|