"""Shared UI component helpers used across dashboard tabs.""" from __future__ import annotations import re from html import escape import streamlit as st from typing import Callable from dashboard.i18n import t from dashboard.theme import ( GREEN, RED, AMBER, GRAY, BORDER, TEXT_FAINT, BG, BG_MUTED, TEXT, TEXT_MUTED, BULL_BG, BULL_BORDER, BEAR_BG, BEAR_BORDER, PURPLE, AI_BORDER, AI_COLOR, AI_BADGE_BG, IMPACT_HIGH_BG, IMPACT_MED_BG, IMPACT_LOW_TEXT, FS_BODY, FS_META, FS_EYEBROW, FS_SECTION_LABEL, ) # Span de code CommonMark : run de backticks ouvrant (fence), contenu quelconque, # fermé par un run de backticks de MÊME longueur, ni précédé ni suivi d'un backtick. # Alternative : un $ non déjà échappé. Un unique re.sub arbitre les deux cas : # un span matché est restitué tel quel, un $ hors span devient \$. _CODE_SPAN_OR_DOLLAR = re.compile( r"(?P(?P`+)[\s\S]*?(? str: """Escape $ so st.markdown does not render inline LaTeX. Code spans untouched.""" if not text: return text return _CODE_SPAN_OR_DOLLAR.sub( lambda m: m.group("code") if m.group("code") is not None else "\\$", text, ) # ── Impact badge ───────────────────────────────────────────────────────────── _IMPACT_DOT_COLORS = { "HIGH": (IMPACT_HIGH_BG, 3), "MEDIUM": (IMPACT_MED_BG, 2), "LOW": (IMPACT_LOW_TEXT, 1), } def impact_badge(level: str) -> str: spec = _IMPACT_DOT_COLORS.get(level) if not spec: return "" color, filled = spec dots = "".join( f'●' for i in range(3) ) return ( f'' f'impact' f'{dots}' ) # ── AI interpretation badge ─────────────────────────────────────────────────── def ai_badge(label: str = "AI Synthesis") -> str: return ( f'✦ {label}' ) # ── AI interpretation card wrapper ──────────────────────────────────────────── def interpretation_card( header_label: str, content_html: str, badge_label: str = "AI Synthesis", ) -> str: return ( f'
' f'
' f'{header_label}' f'{ai_badge(badge_label)}' f'
' f'{content_html}' f'
' ) # ── Source type badge ───────────────────────────────────────────────────────── _SOURCE_STYLES: dict[str, tuple[str, str, str]] = { "transcript": ("#DCEBE3", "#1E6B55", "#134A3B"), "10-q": ("#ECEEE9", "#D5DAD4", "#3E4A44"), "10-k": ("#ECEEE9", "#D5DAD4", "#3E4A44"), "filing": ("#ECEEE9", "#D5DAD4", "#3E4A44"), "news": ("#F4E8D2", "#B8791F", "#B8791F"), } def source_badge(s: str) -> str: if not s: return "" key = s.lower().strip() for pattern, (bg, border, color) in _SOURCE_STYLES.items(): if pattern in key: return ( f'{s}' ) return ( f'{s}' ) # ── Eyebrow label ───────────────────────────────────────────────────────────── def eyebrow_label(text: str, color: str = TEXT_MUTED) -> str: """Uppercase micro-label used as a section/card eyebrow.""" return ( f'{text}' ) # ── Importance marker ───────────────────────────────────────────────────────── _IMPORTANCE_MAP: dict[str, tuple[str, str, str, str, str]] = { # impact → (dot_char, dot_color, label_i18n_key, text_color, font_weight) "HIGH": ("●", "#15201B", "imp_critical", "#15201B", "600"), "MEDIUM": ("●", "#8C968F", "imp_important", "#8C968F", "500"), "LOW": ("●", "#8C968F", "imp_context", "#8C968F", "500"), } def importance_marker(impact: str) -> str: """Monochrome dot + word marker for impact level. No green/red — never competes with bull/bear.""" spec = _IMPORTANCE_MAP.get((impact or "").upper()) if not spec: return "" dot_char, dot_color, label_key, text_color, fw = spec text_label = t(label_key) return ( f'' f'{dot_char}' f'{text_label}' f'' ) # ── Evidence link ──────────────────────────────────────────────────────────── def evidence_link(ref: dict | None, label: str = "") -> str: """Anchor to the document a fact was verified against, or "". Returns "" rather than a bare whenever no usable URL exists. A dead link is worse than no link: it claims a traceability the data does not support. `trends`, `sentiment` and `what_to_watch` carry no evidence_ref at all and therefore always render unlinked. """ if not isinstance(ref, dict): return "" url = str(ref.get("source_url") or "").strip() # Scheme allowlist. escape(url, quote=True) prevents attribute breakout but # leaves the scheme intact, so `javascript:alert(1)` would survive escaping # unchanged. Refs now flow in from model output and Tavily news results, so # only http(s) is rendered; everything else -- including a non-string # source_url, whose str() repr cannot start with a scheme -- yields "". if not url.lower().startswith(("http://", "https://")): return "" text = label or t("evidence_view_source") return ( f'{escape(text)}' ) # ── Meta row ───────────────────────────────────────────────────────────────── _RELIABILITY_LABEL_KEYS: dict[str, str] = { "HIGH": "rel_high", "MEDIUM": "rel_med", "LOW": "rel_low", } def meta_row( reliability: str, source: str, impact: str, evidence_ref: dict | None = None, ) -> str: """Single subtle attribution line replacing the 3-badge stack. Example output: ● Critical · 10-Q · High confidence · View source `evidence_ref` is optional and additive: when it is absent, or carries no `source_url`, the returned HTML is byte-identical to the pre-traceability output, so no existing call site changes behaviour. Returns "" if all of reliability/source/impact are empty/None. """ if not reliability and not source and not impact: return "" parts: list[str] = [] impact_str = (impact or "").upper() marker_html = importance_marker(impact_str) if marker_html: parts.append(marker_html) if source: parts.append( f'{source}' ) if reliability: rel_key = _RELIABILITY_LABEL_KEYS.get(reliability.upper()) rel_label = t(rel_key) if rel_key else reliability.title() parts.append( f'{rel_label}' ) link_html = evidence_link(evidence_ref) if link_html: parts.append(link_html) if not parts: return "" sep = f'·' inner = sep.join(parts) return ( f'
' f'{inner}' f'
' ) # ── Sort by impact ──────────────────────────────────────────────────────────── _IMPACT_ORDER: dict[str, int] = {"HIGH": 0, "MEDIUM": 1, "LOW": 2} def sort_by_impact(items: list[dict]) -> list[dict]: """Stable sort: HIGH → MEDIUM → LOW → None/unknown. Returns a new list.""" def _key(item: dict) -> int: return _IMPACT_ORDER.get((item.get("impact") or "").upper(), 3) return sorted(items, key=_key) def _chunk(items: list, size: int) -> list[list]: """Split *items* into consecutive groups of at most *size*, order preserved.""" return [items[i:i + size] for i in range(0, len(items), size)] def weighted_grid(cards: list[Callable[[], None]], columns: int) -> None: """Render *cards* (each a zero-arg callable) into rows of *columns*. A trailing partial row renders its single leftover card full-width instead of narrowed into an oversized empty st.columns() call. Callers are responsible for pre-filtering `cards` to only callables that will actually render content -- this function arranges callables, it does not inspect brief data (see has_asymmetry/has_swing_factor in dashboard/verdict.py for the established pattern of that filtering). """ for row in _chunk(cards, columns): if len(row) == 1: row[0]() continue cols = st.columns(len(row), gap="large") for col, card in zip(cols, row): with col: card() # ── Evidence blockquote ─────────────────────────────────────────────────────── def evidence_quote(snippet: str, bg: str = "#F5F6F3", border_color: str = "#D5DAD4") -> str: """Renders a collapsible evidence snippet.""" if not snippet: return "" return ( f'
' f'' f'▶ {t("view_quote")}' f'' f'
' f'"' f'{snippet}' f'
' f'
' ) # ── Earnings quality card helpers ───────────────────────────────────────────── _DIMENSION_LABELS: dict[str, str] = { "consensus_beat_mix": "Beat Mix", "guidance_dynamics": "Guidance", "narrative_vs_numbers": "Narrative", "segment_mix": "Segment Mix", "capital_allocation": "Capital", } _ASSESSMENT_STYLES: dict[str, tuple[str, str, str]] = { "positive": (GREEN, BULL_BG, BULL_BORDER), "neutral": (GRAY, BG_MUTED, BORDER), "concerning": (RED, BEAR_BG, BEAR_BORDER), } # ── Analyst Edge components ─────────────────────────────────────────────────── _DELTA_KIND_META: dict[str, tuple[str, str, str]] = { # kind → (label, fg_color, bg_color) "risk_added": ("NEW RISK", "#A63A2B", "#F3DDD8"), "risk_removed": ("REMOVED RISK", "#3E4A44", "#ECEEE9"), "risk_reworded": ("REWORDED RISK", "#B8791F", "#F4E8D2"), "guidance_language_shift": ("GUIDANCE SHIFT", "#1E6B55", "#DCEBE3"), "term_frequency": ("FREQUENCY SHIFT", "#3E4A44", "#ECEEE9"), "kpi_dropped": ("DROPPED KPI", "#3E4A44", "#ECEEE9"), "tone_trend": ("TONE TREND", "#1E6B55", "#DCEBE3"), "topic_arc": ("TOPIC ARC", "#3E4A44", "#ECEEE9"), "recurring_evasion": ("RECURRING EVASION", "#A63A2B", "#F3DDD8"), "topic_fade": ("TOPIC FADE", "#3E4A44", "#ECEEE9"), } _SIG_COLORS: dict[str, str] = {"HIGH": "#A63A2B", "MEDIUM": "#B8791F", "LOW": "#8C968F"} def significance_badge(sig: str) -> str: color = _SIG_COLORS.get(sig, "#8C968F") return ( f'{sig}' ) # Filing prose runs long — a risk-factor paragraph is routinely 800 characters. # Anything past this is folded behind a disclosure so the card stays scannable. _REDLINE_PREVIEW_CHARS = 230 def _clamped_passage(text: str, color: str) -> str: """Quote `text`, folding everything past the preview behind a disclosure. The preview is the and only the remainder lives in the body, so the passage appears exactly once whether the card is open or closed. """ if len(text) <= _REDLINE_PREVIEW_CHARS: return ( f'
“{text}”
' ) # Cut on a word boundary so the preview never ends mid-word. cut = text.rfind(" ", 0, _REDLINE_PREVIEW_CHARS) if cut <= 0: cut = _REDLINE_PREVIEW_CHARS head, tail = text[:cut], text[cut:].lstrip() return ( f'
' f'' f'“{head}…' f'{t("show_full_passage")}' f'' f'
{tail}”
' f'
' ) # A reworded filing passage usually opens with an identical boilerplate lead — # risk-factor sections in particular share a long preamble and diverge deep # inside. Quoting from the top then meant both columns previewed the *same* # words, which reads as a rendering fault rather than as "nothing changed here # yet". Past this many identical leading characters, the quote starts near the # divergence instead. _COMMON_PREFIX_TRIM = 120 # Words of shared lead kept before the divergence, so the change stays in context. _DIVERGENCE_CONTEXT = 45 def _focus_divergence(before: str, after: str) -> tuple[str, str]: """Drop a long identical opening from both passages, marking the elision. Returns the passages unchanged when they diverge early enough to be compared as-is, or when either side is empty (an addition or a removal has nothing to align against). """ if not before or not after: return before, after limit = min(len(before), len(after)) index = 0 while index < limit and before[index] == after[index]: index += 1 if index < _COMMON_PREFIX_TRIM: return before, after start = max(0, index - _DIVERGENCE_CONTEXT) boundary = before.rfind(" ", 0, start) if boundary != -1: start = boundary + 1 return "…" + before[start:], "…" + after[start:] def _redline_block(before: str, after: str) -> str: """Render before→after text with redline-style color coding. The two passages sit side by side rather than stacked: they are meant to be compared, and stacking two long quotes pushed the next card off-screen. They wrap back to one column when the card is too narrow to split. """ before, after = _focus_divergence(before, after) sides: list[str] = [] for text, bg, border, label_color, body_color, label in ( (before, "#F3DDD8", "#A63A2B", "#A63A2B", "#A63A2B", "before"), (after, "#DCEBE3", "#134A3B", "#1E6B55", "#134A3B", "after"), ): if not text: continue sides.append( f'
' f'{label}' f'{_clamped_passage(text, body_color)}' f'
' ) if not sides: return "" return ( f'
' f'{"".join(sides)}
' ) def section_header(label: str, accent: str = TEXT_MUTED, icon: str = "", compact: bool = False) -> str: """Section header: accent tick + semibold dark label + hairline rule. compact=True trims the top margin for use inside a half-width grid cell (see weighted_grid) where the page-level 36px top margin looks disproportionate next to a column neighbor. """ prefix = f"{icon}  " if icon else "" margin = "4px 0 10px" if compact else "36px 0 14px" return ( f'
' f'' f'{prefix}{label}' f'
' f'
' ) def section_divider(label: str, icon: str = "") -> str: """Backward-compatible delegate → section_header.""" return section_header(label, icon=icon) def numbered_list( items: list[str], max_items: int | None = None, more_label: str = "", ) -> str: """Render a numbered list with circular badges. Args: items: List of text items to render. max_items: If set, only the first N items are shown. more_label: If max_items is set and there are more items, show this as a footer line. Returns: HTML string (to be rendered with unsafe_allow_html=True). """ visible = items[:max_items] if max_items else items rows_html = "" for i, item in enumerate(visible, 1): rows_html += ( f'
' f'{i}' f'{item}' f'
' ) footer_html = "" if more_label and max_items and len(items) > max_items: footer_html = ( f'
' f'{more_label}
' ) return ( f'
' f'{rows_html}{footer_html}' f'
' ) # ── Between-the-lines signal-type colors (shared with verdict's Tell card) ─── _SIGNAL_TYPE_COLORS: dict[str, tuple[str, str]] = { # signal_type → (fg_color, bg_color); labels resolve via t(f"sig_{type}") "language_drift": ("#1E6B55", "#DCEBE3"), # indigo "qa_evasion": ("#A63A2B", "#F3DDD8"), # red "omission": ("#B8791F", "#F4E8D2"), # amber "emphasis_shift": ("#3E4A44", "#ECEEE9"), # gray "accounting_quality": ("#3E4A44", "#ECEEE9"), # sky } # ── Unified signal card (Decision Stack) ───────────────────────────────────── # One renderer for every Signal kind; the body adapts per kind. Replaces # fact_card / tension_card / delta_card / subtext_card / earnings_quality_card. _KIND_LABEL_KEYS: dict[str, str] = { "tell": "kind_tell", "tension": "kind_tension", "delta": "kind_delta", "change": "kind_change", "risk": "kind_risk", "quality": "kind_quality", "point": "kind_point", "commentary": "kind_commentary", "note": "kind_note", "news": "kind_news", } # Stance is the only colored dimension of the card frame: bull/bear/mixed. _STANCE_ACCENTS: dict[str, str] = {"bull": GREEN, "bear": RED, "mixed": AMBER} def _pill(label: str, fg: str, bg: str) -> str: return ( f'{label}' ) def _signal_category_chip(sig) -> str: """Taxonomy chip: tell signal-types and delta kinds keep their forensic colors; risk categories and quality dimensions stay neutral gray.""" cat = sig.category if not cat: return "" if sig.kind == "tell" and cat in _SIGNAL_TYPE_COLORS: fg, bg = _SIGNAL_TYPE_COLORS[cat] return _pill(t(f"sig_{cat}"), fg, bg) if sig.kind == "delta" and cat in _DELTA_KIND_META: _, fg, bg = _DELTA_KIND_META[cat] return _pill(t(f"dk_{cat}"), fg, bg) if sig.kind == "risk": return _pill(cat, GRAY, "#ECEEE9") if sig.kind == "quality": return _pill(_DIMENSION_LABELS.get(cat, cat.replace("_", " ").title()), GRAY, "#ECEEE9") return "" def _tension_body(sig) -> str: extra = sig.extra or {} sides = [] for key_label, fg, reading_key, ev_key in ( ("tension_surface", "#134A3B", "bullish_reading", "bullish_evidence"), ("tension_deeper", "#A63A2B", "bearish_reading", "bearish_evidence"), ): ev = extra.get(ev_key) or {} ev = ev if isinstance(ev, dict) else {} sides.append( f'
' f'
{eyebrow_label(t(key_label), fg)}
' f'
' f'{extra.get(reading_key, "")}
' f'{evidence_quote(ev.get("evidence_snippet", ""), bg=BG, border_color=BORDER)}' f'{meta_row(ev.get("reliability", ""), ev.get("source", ""), ev.get("impact", "") or "", evidence_ref=ev.get("evidence_ref"))}' f'
' ) return f'
{"".join(sides)}
' def _delta_body(sig) -> str: metric_html = ( f'
' f'' f'{t("computed_label")} · {sig.body}' f'
' ) if sig.body else "" return metric_html + _redline_block(sig.before_text, sig.after_text) def _tell_body(sig) -> str: parts = [] if sig.body: parts.append( f'
{sig.body}
' ) implication = (sig.extra or {}).get("implication", "") if implication: parts.append( f'
' f'{eyebrow_label(t("btl_implication"), AMBER)}' f'
' f'{implication}
' f'
' ) return "".join(parts) def _news_body(sig) -> str: extra = sig.extra or {} url = extra.get("url", "") date = extra.get("date", "") date_html = ( f'
{date}
' if date else "" ) title_html = ( f'{sig.headline}' if url else f'
{sig.headline}
' ) body_html = ( f'
' f'{sig.body}
' if sig.body else "" ) return f"{date_html}{title_html}{body_html}" def signal_card(sig) -> None: """Render a normalized Signal (see dashboard/signal_feed.py) as one card.""" from dashboard.signal_feed import EXPERIMENTAL_AI_KINDS, HEURISTIC_KINDS accent = _STANCE_ACCENTS.get(sig.stance or "", BORDER) # ── Header row: taxonomy chips left, period + AI badge right ─────────── left_bits = [ _pill(t(_KIND_LABEL_KEYS.get(sig.kind, "kind_note")), GRAY, "#ECEEE9"), _signal_category_chip(sig), ] if sig.is_new: left_bits.append(_pill(t("new_badge"), RED, BEAR_BG)) if sig.significance == "HIGH": # Preserve item-level ranking without exposing an uncalibrated HIGH / MED / LOW scale. left_bits.append(_pill("Material", TEXT, BG_MUTED)) right_bits = [] if sig.period_range: right_bits.append( f'{sig.period_range}' ) if sig.kind in EXPERIMENTAL_AI_KINDS: right_bits.append(ai_badge("AI · experimental")) elif sig.kind in HEURISTIC_KINDS: right_bits.append(_pill("Heuristic · validate", GRAY, "#ECEEE9")) header = ( f'
' f'
' f'{"".join(b for b in left_bits if b)}
' f'
' f'{"".join(right_bits)}
' f'
' ) # ── Headline ──────────────────────────────────────────────────────────── headline_html = "" if sig.headline and sig.kind != "news": headline_html = ( f'
{sig.headline}
' ) # ── Quality assessment chip rides next to the headline ───────────────── if sig.kind == "quality": assessment = (sig.extra or {}).get("assessment", "neutral") color, bg, _border = _ASSESSMENT_STYLES.get(assessment, _ASSESSMENT_STYLES["neutral"]) icon = {"positive": "▲", "neutral": "—", "concerning": "▼"}.get(assessment, "—") label = t(f"quality_{assessment}") if assessment in ("positive", "neutral", "concerning") else assessment.title() headline_html = ( f'
' f'' f'{icon} {label}' f'
' ) + headline_html # ── Body by kind ──────────────────────────────────────────────────────── if sig.kind == "tension": body_html = _tension_body(sig) elif sig.kind == "delta": body_html = _delta_body(sig) elif sig.kind == "tell": body_html = _tell_body(sig) elif sig.kind == "news": body_html = _news_body(sig) elif sig.body: body_html = ( f'
{sig.body}
' ) else: body_html = "" # ── Footer: evidence + attribution (tension carries these per side) ──── footer_html = "" if sig.kind != "tension": footer_html = ( evidence_quote(sig.evidence_snippet, bg=BG_MUTED, border_color=BORDER) + meta_row(sig.reliability, sig.source, sig.impact or "") ) # ── Dedup cross-reference — this canonical card absorbed near-duplicate # facts from other sections; note where else the same fact was classified # instead of showing it as 3-4 separate cards. also_in = (sig.extra or {}).get("also_in") or [] if also_in: labels = " · ".join(t(_KIND_LABEL_KEYS.get(k, "kind_note")).upper() for k in also_in) footer_html += ( f'
' f'{t("also_classified_as")} {labels}
' ) st.markdown( f'
' f'{header}{headline_html}{body_html}{footer_html}' f'
', unsafe_allow_html=True, ) # ── History table ───────────────────────────────────────────────────────────── # The one device the dashboard did not have. _verdict_strip (quality_view) can # say a streak happened; it cannot say the cushion behind that streak fell from # +20% to +4%. This shows the figures per row, which is the only way a trend in # magnitude becomes visible. _TONE_COLOR = {"up": GREEN, "down": RED, "flag": AMBER, "dim": TEXT_FAINT} def history_table( headers: list[str], rows: list[list[tuple[str, str | None]]], caption: str = "", source: str = "", ) -> str: """Render a compact numeric history. Each cell is ``(text, tone)`` where tone is None or one of up / down / flag / dim. The first column is left-aligned as a label; every other column is right-aligned with tabular figures so digits line up down the column. Wrapped in its own horizontal scroller: a wide table must never make the whole page scroll sideways. """ if not rows: return "" head = "".join( f'{escape(str(h))}' for i, h in enumerate(headers) ) body = "" for row in rows: cells = "" for i, cell in enumerate(row): text, tone = cell if isinstance(cell, (tuple, list)) else (cell, None) color = _TONE_COLOR.get(tone or "", TEXT) align = "left" if i == 0 else "right" pad = "7px 0 7px 0" if i == 0 else "7px 0 7px 16px" cells += ( f'' f'{escape(str(text))}' ) body += f"{cells}" caption_html = ( f'
' f'{escape(caption)}
' if caption else "" ) source_html = ( f'
' f'{escape(source)}
' if source else "" ) return ( f'{caption_html}' f'
' f'' f'{head}{body}
' f'
{source_html}' ) def rail_card(body: str, accent: str, meta: str = "") -> str: """A statement with an accent rail down its left edge. Promoted out of quality_view.render_expectations, which had this shape inline. The rail means the block is a reading of the figures rather than one of them -- so it stays reserved for that, and does not become a decoration for arbitrary panels. """ meta_html = ( f'
{meta}
' if meta else "" ) return ( f'
' f'
{body}
' f'{meta_html}
' )