"""Evidence & Deltas — source-backed signals in one ranked, filterable stream. Replaces the former MD&A / Earnings Call / Risks / Quality & Tone tabs: their content is normalized by dashboard/signal_feed.py and rendered with the single components.signal_card renderer. Also hosts the one global semantic search (filings + transcripts) and the on-demand news loader. """ from __future__ import annotations import streamlit as st from dashboard import fmt_period from dashboard.i18n import t from dashboard.theme import ( GREEN, RED, GRAY, AMBER, BG_MUTED, BORDER, TEXT, TEXT_MUTED, TEXT_FAINT, BULL_BG, BEAR_BG, FS_PAGE, FS_META, FS_EYEBROW, ) from dashboard.components import signal_card, section_header from dashboard import provenance from dashboard.signal_feed import ( KIND_PRIORITY, Signal, aggregate_sentiment, build_feed, global_band, rank_feed, ) _FILTER_KEYS = ( "signals_filter_type", "signals_filter_stance", "signals_filter_source", "signals_high_sig", "signals_search", ) _SENT_SECTION_LABELS = { "metrics": "sent_metrics", "guidance": "sent_guidance", "mda": "sent_mda", "earnings_call": "sent_call", "news": "sent_news", } _TONE_STYLES = { "bull": (GREEN, BULL_BG), "bear": (RED, BEAR_BG), "neutral": (GRAY, BG_MUTED), } # ── Sentiment strip ─────────────────────────────────────────────────────────── def _gauge_html(score: int) -> str: """Red→green gradient bar with a dot at `score` (-2..+2).""" pct = (score + 2) / 4 * 90 + 5 dot_color = GREEN if score > 0 else (RED if score < 0 else GRAY) return ( f'
' f'
' f'
' ) def _render_sentiment_strip(sentiment: dict | None) -> None: if not sentiment: return agg = aggregate_sentiment(sentiment) if agg is None: return band_key, tone = global_band(agg) band_color, band_bg = _TONE_STYLES[tone] sign = "+" if agg >= 0 else "" cells_html = "" for key, label_key in _SENT_SECTION_LABELS.items(): label = t(label_key) section = sentiment.get(key) if section is None or section.get("score") is None: cells_html += ( f'
' f'
{label}
' f'
' f'
' ) else: score = section["score"] fg = GREEN if score > 0 else (RED if score < 0 else GRAY) rationale = section.get("rationale", "") cells_html += ( f'
' f'
{label}
' f'{_gauge_html(score)}' f'
' f'{section.get("label", "")}
' f'
{rationale}
' f'
' ) st.markdown( f'
' f'
' f'
{t("print_sentiment")}
' f'
' f'{sign}{agg:.1f}
' f'
' f'{t(band_key)}
' f'
' f'{cells_html}' f'
', unsafe_allow_html=True, ) def _sentiment_display_allowed(brief: dict) -> bool: """Fail closed until aggregate sentiment has an explicit calibration flag.""" policy = brief.get("display_policy") or {} return isinstance(policy, dict) and policy.get("sentiment_calibrated") is True def _render_experimental_notice() -> None: st.markdown( f'
' f'Validation required. ' f'AI hypotheses are experimental; delta detectors are heuristic. ' f'Validate both against the cited source text.
', unsafe_allow_html=True, ) # ── Filters ─────────────────────────────────────────────────────────────────── def _reset_filters_on_ticker_change(ticker: str) -> None: """Pills options are feed-derived; clear stale selections when the active ticker changes (must run before the filter widgets instantiate).""" if st.session_state.get("_signals_ticker") != ticker: for key in _FILTER_KEYS: st.session_state.pop(key, None) st.session_state["_signals_ticker"] = ticker def _options_with_selection(present: list[str], state_key: str) -> list[str]: """Feed-derived options ∪ current selection — a selected value absent from the new options would otherwise raise inside st.pills.""" selected = st.session_state.get(state_key) or [] extras = [s for s in selected if s not in present] return present + extras def _render_filters(feed: list[Signal]) -> tuple[list[str], list[str], list[str], bool]: kinds_present = sorted( {s.kind for s in feed}, key=lambda k: KIND_PRIORITY.get(k, 99) ) stances_present = [ s for s in ("bull", "bear", "neutral", "mixed") if any(sig.stance == s for sig in feed) ] sources_present = sorted({s.source for s in feed if s.source}) sel_kinds = st.pills( t("filter_type"), options=_options_with_selection(kinds_present, "signals_filter_type"), selection_mode="multi", format_func=lambda k: t(f"kind_{k}"), key="signals_filter_type", ) col_stance, col_source, col_sig = st.columns([2, 2, 1]) with col_stance: sel_stances = st.pills( t("filter_stance"), options=_options_with_selection(stances_present, "signals_filter_stance"), selection_mode="multi", format_func=lambda s: t(f"stance_{s}"), key="signals_filter_stance", ) with col_source: sel_sources = st.pills( t("filter_source"), options=_options_with_selection(sources_present, "signals_filter_source"), selection_mode="multi", key="signals_filter_source", ) with col_sig: high_only = st.toggle(t("filter_high_sig"), key="signals_high_sig") return sel_kinds or [], sel_stances or [], sel_sources or [], bool(high_only) def _apply_filters( feed: list[Signal], kinds: list[str], stances: list[str], sources: list[str], high_only: bool, ) -> list[Signal]: out = feed if kinds: out = [s for s in out if s.kind in kinds] if stances: out = [s for s in out if s.stance in stances] if sources: out = [s for s in out if s.source in sources] if high_only: out = [s for s in out if s.significance == "HIGH"] return out # ── Global semantic search ──────────────────────────────────────────────────── def _render_search(ticker: str) -> None: st.markdown(section_header(t("search_label"), accent=AMBER), unsafe_allow_html=True) from storage.metrics_db import get_all_metrics rows = get_all_metrics(ticker) periods = [r.get("period", "") for r in rows if r.get("period")] col_q, col_p = st.columns([3, 2]) with col_q: query = st.text_input( t("search_label"), placeholder=t("search_placeholder"), key="signals_search", label_visibility="collapsed", ) with col_p: period_options = [""] + periods selected_period = st.selectbox( t("filter_source"), options=period_options, format_func=lambda p: fmt_period(p) if p else "—", key="signals_search_period", label_visibility="collapsed", ) if not query: return from storage import vector_store from storage.reranker import rerank with st.spinner(t("chat_searching")): union: list[dict] = [] for collection in ("filings", "transcripts"): hits = vector_store.search( collection, query, ticker, n_results=4, period=selected_period or None, ) for h in hits: h["collection"] = collection union += hits # vector_store results carry no scores — a second cross-encoder pass # over the union is the only way to merge the two collections. merged = rerank(query, union, top_k=6) if not merged: st.warning(t("search_no_results")) return for r in merged: m = r.get("metadata", {}) icon = "📄" if r.get("collection") == "filings" else "🎙️" context = m.get("chunk_context") or " · ".join( str(v) for v in ( m.get("source", r.get("collection", "")), m.get("section", ""), m.get("filing_date", "") or m.get("date", ""), ) if v ) with st.expander(f"{icon} {context}", expanded=False): st.markdown( f'
{r["text"]}
', unsafe_allow_html=True, ) # ── News (on-demand, Tavily) ────────────────────────────────────────────────── def _load_news(ticker: str) -> None: import os try: from tavily import TavilyClient from storage.metrics_db import get_all_metrics api_key = os.environ.get("TAVILY_API_KEY") if not api_key: st.error("TAVILY_API_KEY not set.") return rows = get_all_metrics(ticker) company_name = rows[0]["company_name"] if rows else ticker latest_filing_date = rows[0]["filing_date"] if rows else None with st.spinner(t("chat_searching")): client = TavilyClient(api_key=api_key) results = client.search( query=f"{ticker} {company_name} earnings results guidance", max_results=6, search_depth="basic", days=60, topic="news", ).get("results", []) if latest_filing_date: results = [r for r in results if r.get("published_date", "") >= latest_filing_date] if not results: st.info(t("news_none")) return for item in results: signal_card(Signal( kind="news", headline=item.get("title", ""), body=(item.get("content", "") or "")[:300], source="news", significance="LOW", extra={ "url": item.get("url", ""), "date": (item.get("published_date", "") or "")[:10], }, )) except Exception as e: st.error(f"Failed to load news: {e}") # ── Main render entrypoint ──────────────────────────────────────────────────── def render(brief: dict, ticker: str) -> None: filing_date = brief.get("filing_date", "") company_name = brief.get("company_name", "") subtitle_parts: list[str] = [] if company_name: subtitle_parts.append(company_name) if ticker and ticker not in company_name: subtitle_parts.append(ticker) if filing_date: subtitle_parts.append(filing_date) subtitle_parts.append(t("signals_subtitle")) subtitle = " · ".join(subtitle_parts) # ── 1. Page header ─────────────────────────────────────────────────────── st.markdown( f'
' f'
' f'Evidence & Deltas
' f'
{subtitle}
' f'
', unsafe_allow_html=True, ) # ── 2. Validation notice; aggregate sentiment remains fail-closed ──────── provenance.render_card(ticker, brief) _render_experimental_notice() if _sentiment_display_allowed(brief): _render_sentiment_strip(brief.get("sentiment")) # ── 3. Filters + ranked stream ─────────────────────────────────────────── _reset_filters_on_ticker_change(ticker) feed = rank_feed(build_feed(brief)) if feed: kinds, stances, sources, high_only = _render_filters(feed) filtered = _apply_filters(feed, kinds, stances, sources, high_only) st.caption(f"{len(filtered)} / {len(feed)}") if filtered: for sig in filtered: signal_card(sig) else: st.info(t("signals_empty")) # ── 4. Global semantic search ──────────────────────────────────────────── _render_search(ticker) # ── 5. Latest news (on demand) ─────────────────────────────────────────── st.markdown(section_header(t("news_title"), accent=AMBER), unsafe_allow_html=True) if st.button(t("load_news"), key="signals_news_btn"): _load_news(ticker)