Spaces:
Sleeping
Sleeping
Download dashboard/components.py from Viney/readthrough: direct link, hf CLI and curl.
- Browser
- Download file 35.7 kB
-
https://huggingface.co/spaces/Viney/readthrough/resolve/main/dashboard/components.py
- Command line
-
hf download hf://spaces/Viney/readthrough/dashboard/components.py
-
curl -L -o components.py https://huggingface.co/spaces/Viney/readthrough/resolve/main/dashboard/components.py
35.7 kB
| """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<code>(?P<fence>`+)[\s\S]*?(?<!`)(?P=fence)(?!`))|(?<!\\)\$" | |
| ) | |
| def escape_dollars(text: str) -> 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'<span style="color:{color if i < filled else "#8C968F"};font-size:0.55rem;line-height:1;">β</span>' | |
| for i in range(3) | |
| ) | |
| return ( | |
| f'<span title="Impact: {level}" style="display:inline-flex;align-items:center;gap:3px;' | |
| f'background:#ECEEE9;border:1px solid #D5DAD4;border-radius:4px;padding:1px 7px;">' | |
| f'<span style="font-size:0.6rem;font-weight:600;color:#8C968F;letter-spacing:0.05em;">impact</span>' | |
| f'{dots}</span>' | |
| ) | |
| # ββ AI interpretation badge βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def ai_badge(label: str = "AI Synthesis") -> str: | |
| return ( | |
| f'<span style="background:{AI_BADGE_BG};color:{AI_COLOR};' | |
| f'border:1px solid {AI_BORDER};border-radius:4px;' | |
| f'padding:1px 7px;font-size:0.62rem;font-weight:600;">β¦ {label}</span>' | |
| ) | |
| # ββ AI interpretation card wrapper ββββββββββββββββββββββββββββββββββββββββββββ | |
| def interpretation_card( | |
| header_label: str, | |
| content_html: str, | |
| badge_label: str = "AI Synthesis", | |
| ) -> str: | |
| return ( | |
| f'<div class="primer-card" style="background:{BG};border:1px solid {AI_BORDER};' | |
| f'border-left:4px solid {PURPLE};border-radius:0 12px 12px 0;' | |
| f'padding:20px 24px;margin-bottom:4px;">' | |
| f'<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">' | |
| f'<span style="font-size:{FS_EYEBROW};font-weight:700;letter-spacing:0.1em;' | |
| f'text-transform:uppercase;color:{TEXT_MUTED};">{header_label}</span>' | |
| f'{ai_badge(badge_label)}' | |
| f'</div>' | |
| f'{content_html}' | |
| f'</div>' | |
| ) | |
| # ββ 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'<span style="background:{bg};color:{color};border:1px solid {border};' | |
| f'border-radius:4px;padding:1px 8px;font-size:0.7rem;font-weight:500;">{s}</span>' | |
| ) | |
| return ( | |
| f'<span style="background:#ECEEE9;color:#3E4A44;border:1px solid #8C968F;' | |
| f'border-radius:4px;padding:1px 8px;font-size:0.7rem;font-weight:500;">{s}</span>' | |
| ) | |
| # ββ Eyebrow label βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def eyebrow_label(text: str, color: str = TEXT_MUTED) -> str: | |
| """Uppercase micro-label used as a section/card eyebrow.""" | |
| return ( | |
| f'<span style="font-size:{FS_EYEBROW};font-weight:700;text-transform:uppercase;' | |
| f'letter-spacing:0.1em;color:{color};">{text}</span>' | |
| ) | |
| # ββ 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'<span style="font-size:{FS_META};font-weight:{fw};color:{text_color};' | |
| f'display:inline-flex;align-items:center;gap:3px;">' | |
| f'<span style="color:{dot_color};">{dot_char}</span>' | |
| f'{text_label}' | |
| f'</span>' | |
| ) | |
| # ββ 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 <a> 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'<a href="{escape(url, quote=True)}" target="_blank" rel="noopener noreferrer" ' | |
| f'style="font-size:{FS_META};color:{TEXT_MUTED};text-decoration:underline;' | |
| f'text-underline-offset:2px;">{escape(text)}</a>' | |
| ) | |
| # ββ 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'<span style="font-size:{FS_META};color:{TEXT_MUTED};">{source}</span>' | |
| ) | |
| if reliability: | |
| rel_key = _RELIABILITY_LABEL_KEYS.get(reliability.upper()) | |
| rel_label = t(rel_key) if rel_key else reliability.title() | |
| parts.append( | |
| f'<span style="font-size:{FS_META};color:{TEXT_MUTED};">{rel_label}</span>' | |
| ) | |
| link_html = evidence_link(evidence_ref) | |
| if link_html: | |
| parts.append(link_html) | |
| if not parts: | |
| return "" | |
| sep = f'<span style="font-size:{FS_META};color:{TEXT_MUTED};">Β·</span>' | |
| inner = sep.join(parts) | |
| return ( | |
| f'<div style="font-size:{FS_META};color:{TEXT_MUTED};margin-top:8px;' | |
| f'display:flex;align-items:center;gap:4px;flex-wrap:wrap;">' | |
| f'{inner}' | |
| f'</div>' | |
| ) | |
| # ββ 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'<details style="margin:4px 0;">' | |
| f'<summary style="cursor:pointer;font-size:0.68rem;color:#8C968F;font-weight:500;' | |
| f'list-style:none;display:inline-flex;align-items:center;gap:4px;user-select:none;">' | |
| f'<span style="font-size:0.6rem;">βΆ</span> {t("view_quote")}' | |
| f'</summary>' | |
| f'<div style="margin-top:4px;padding:4px 10px;background:{bg};' | |
| f'border-left:3px solid {border_color};border-radius:0 4px 4px 0;">' | |
| f'<span style="font-size:0.95rem;line-height:1;color:#8C968F;' | |
| f'font-family:Georgia,serif;margin-right:3px;vertical-align:-2px;">"</span>' | |
| f'<span style="font-size:0.75rem;color:#3E4A44;font-style:italic;line-height:1.35;">{snippet}</span>' | |
| f'</div>' | |
| f'</details>' | |
| ) | |
| # ββ 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'<span style="background:{color}1a;color:{color};border:1px solid {color}44;' | |
| f'border-radius:4px;padding:1px 7px;font-size:0.65rem;font-weight:700;' | |
| f'text-transform:uppercase;letter-spacing:0.06em;">{sig}</span>' | |
| ) | |
| # 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 <summary> 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'<div style="font-size:0.78rem;font-style:italic;color:{color};' | |
| f'line-height:1.45;margin-top:3px;">“{text}”</div>' | |
| ) | |
| # 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'<details style="margin-top:3px;">' | |
| f'<summary style="cursor:pointer;list-style:none;">' | |
| f'<span style="font-size:0.78rem;font-style:italic;color:{color};' | |
| f'line-height:1.45;">“{head}…</span>' | |
| f'<span class="primer-more" style="font-size:0.62rem;font-style:normal;font-weight:600;' | |
| f'text-transform:uppercase;letter-spacing:0.06em;color:{TEXT_MUTED};' | |
| f'margin-left:6px;white-space:nowrap;">{t("show_full_passage")}</span>' | |
| f'</summary>' | |
| f'<div style="font-size:0.78rem;font-style:italic;color:{color};' | |
| f'line-height:1.45;">{tail}”</div>' | |
| f'</details>' | |
| ) | |
| # 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'<div style="flex:1 1 300px;min-width:0;padding:8px 10px;background:{bg};' | |
| f'border-left:3px solid {border};border-radius:0 4px 4px 0;">' | |
| f'<span style="font-size:0.6rem;font-weight:700;text-transform:uppercase;' | |
| f'color:{label_color};letter-spacing:0.07em;">{label}</span>' | |
| f'{_clamped_passage(text, body_color)}' | |
| f'</div>' | |
| ) | |
| if not sides: | |
| return "" | |
| return ( | |
| f'<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:flex-start;">' | |
| f'{"".join(sides)}</div>' | |
| ) | |
| 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'<div style="display:flex;align-items:center;gap:10px;margin:{margin};">' | |
| f'<span style="width:4px;height:15px;border-radius:2px;background:{accent};flex-shrink:0;"></span>' | |
| f'<span style="font-size:{FS_SECTION_LABEL};font-weight:700;text-transform:uppercase;' | |
| f'letter-spacing:0.08em;color:{TEXT};white-space:nowrap;">{prefix}{label}</span>' | |
| f'<div style="flex:1;height:1px;background:{BORDER};"></div>' | |
| f'</div>' | |
| ) | |
| 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'<div style="display:flex;align-items:flex-start;gap:12px;' | |
| f'padding:12px 0;border-bottom:1px solid {BORDER};">' | |
| f'<span style="display:inline-flex;align-items:center;justify-content:center;' | |
| f'width:22px;height:22px;border-radius:50%;flex-shrink:0;margin-top:1px;' | |
| f'background:{GRAY};color:{BG};font-size:0.7rem;font-weight:700;">{i}</span>' | |
| f'<span style="font-size:{FS_BODY};line-height:1.55;color:{TEXT};">{item}</span>' | |
| f'</div>' | |
| ) | |
| footer_html = "" | |
| if more_label and max_items and len(items) > max_items: | |
| footer_html = ( | |
| f'<div style="padding:10px 0 4px;font-size:0.75rem;color:{TEXT_MUTED};">' | |
| f'{more_label}</div>' | |
| ) | |
| return ( | |
| f'<div style="border:1px solid {BORDER};border-radius:12px;' | |
| f'padding:4px 16px;background:{BG};max-width:680px;">' | |
| f'{rows_html}{footer_html}' | |
| f'</div>' | |
| ) | |
| # ββ 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'<span style="background:{bg};color:{fg};border:1px solid {fg}33;' | |
| f'border-radius:4px;padding:1px 7px;font-size:0.65rem;font-weight:600;' | |
| f'text-transform:uppercase;letter-spacing:0.06em;">{label}</span>' | |
| ) | |
| 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'<div style="flex:1;padding:12px 14px;background:{BG_MUTED};border:1px solid {BORDER};' | |
| f'border-radius:8px;min-width:0;">' | |
| f'<div style="margin-bottom:6px;">{eyebrow_label(t(key_label), fg)}</div>' | |
| f'<div style="font-size:0.82rem;line-height:1.5;color:{TEXT};margin-bottom:8px;">' | |
| f'{extra.get(reading_key, "")}</div>' | |
| 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'</div>' | |
| ) | |
| return f'<div style="display:flex;gap:10px;">{"".join(sides)}</div>' | |
| def _delta_body(sig) -> str: | |
| metric_html = ( | |
| f'<div style="font-size:0.72rem;font-weight:600;color:#3E4A44;' | |
| f'background:#ECEEE9;border:1px solid #D5DAD4;border-radius:4px;' | |
| f'padding:4px 8px;margin-bottom:8px;">' | |
| f'<span style="color:{TEXT_MUTED};font-size:0.65rem;font-weight:500;">' | |
| f'{t("computed_label")} Β· </span>{sig.body}' | |
| f'</div>' | |
| ) 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'<div style="font-size:{FS_BODY};font-style:italic;color:{TEXT};' | |
| f'line-height:1.55;margin-bottom:10px;">{sig.body}</div>' | |
| ) | |
| implication = (sig.extra or {}).get("implication", "") | |
| if implication: | |
| parts.append( | |
| f'<div style="border-top:1px solid {BORDER};padding-top:10px;margin-bottom:6px;">' | |
| f'{eyebrow_label(t("btl_implication"), AMBER)}' | |
| f'<div style="font-size:{FS_BODY};color:{TEXT};line-height:1.55;margin-top:4px;">' | |
| f'{implication}</div>' | |
| f'</div>' | |
| ) | |
| return "".join(parts) | |
| def _news_body(sig) -> str: | |
| extra = sig.extra or {} | |
| url = extra.get("url", "") | |
| date = extra.get("date", "") | |
| date_html = ( | |
| f'<div style="font-size:{FS_META};color:{TEXT_FAINT};margin-bottom:4px;">{date}</div>' | |
| if date else "" | |
| ) | |
| title_html = ( | |
| f'<a href="{url}" target="_blank" style="font-size:{FS_BODY};font-weight:600;' | |
| f'color:{TEXT};text-decoration:none;border-bottom:1px solid {BORDER};">{sig.headline}</a>' | |
| if url else | |
| f'<div style="font-size:{FS_BODY};font-weight:600;color:{TEXT};">{sig.headline}</div>' | |
| ) | |
| body_html = ( | |
| f'<div style="font-size:{FS_META};color:{TEXT_MUTED};line-height:1.5;margin-top:6px;">' | |
| f'{sig.body}</div>' | |
| 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'<span style="font-size:0.65rem;color:{TEXT_MUTED};">{sig.period_range}</span>' | |
| ) | |
| 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'<div style="display:flex;align-items:center;justify-content:space-between;' | |
| f'gap:8px;margin-bottom:10px;flex-wrap:wrap;">' | |
| f'<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;">' | |
| f'{"".join(b for b in left_bits if b)}</div>' | |
| f'<div style="display:flex;align-items:center;gap:6px;">' | |
| f'{"".join(right_bits)}</div>' | |
| f'</div>' | |
| ) | |
| # ββ Headline ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| headline_html = "" | |
| if sig.headline and sig.kind != "news": | |
| headline_html = ( | |
| f'<div style="font-size:{FS_BODY};font-weight:600;color:{TEXT};' | |
| f'line-height:1.5;margin-bottom:6px;">{sig.headline}</div>' | |
| ) | |
| # ββ 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'<div style="margin-bottom:8px;">' | |
| f'<span style="display:inline-flex;align-items:center;gap:4px;' | |
| f'background:{color}1a;color:{color};border:1px solid {color}44;' | |
| f'border-radius:4px;padding:2px 8px;font-size:0.65rem;font-weight:700;">' | |
| f'{icon} {label}</span>' | |
| f'</div>' | |
| ) + 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'<div style="font-size:{FS_BODY};line-height:1.55;color:{TEXT};' | |
| f'margin-bottom:8px;">{sig.body}</div>' | |
| ) | |
| 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'<div style="font-size:0.65rem;color:{TEXT_MUTED};margin-top:6px;">' | |
| f'{t("also_classified_as")} {labels}</div>' | |
| ) | |
| st.markdown( | |
| f'<div class="primer-card" style="background:{BG};border:1px solid {BORDER};' | |
| f'border-left:3px solid {accent};border-radius:0 10px 10px 0;' | |
| f'padding:14px 18px;margin-bottom:10px;max-width:1000px;">' | |
| f'{header}{headline_html}{body_html}{footer_html}' | |
| f'</div>', | |
| 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'<th style="font-size:{FS_EYEBROW};font-weight:700;text-transform:uppercase;' | |
| f'letter-spacing:0.07em;color:{TEXT_MUTED};padding:0 0 8px 16px;' | |
| f'text-align:{"left" if i == 0 else "right"};border-bottom:1px solid {BORDER};' | |
| f'white-space:nowrap;">{escape(str(h))}</th>' | |
| 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'<td style="padding:{pad};text-align:{align};color:{color};' | |
| f'border-bottom:1px solid {BORDER};white-space:nowrap;">' | |
| f'{escape(str(text))}</td>' | |
| ) | |
| body += f"<tr>{cells}</tr>" | |
| caption_html = ( | |
| f'<div style="font-size:{FS_META};color:{TEXT_MUTED};padding-bottom:10px;">' | |
| f'{escape(caption)}</div>' | |
| if caption else "" | |
| ) | |
| source_html = ( | |
| f'<div style="font-size:{FS_META};color:{TEXT_FAINT};padding-top:9px;">' | |
| f'{escape(source)}</div>' | |
| if source else "" | |
| ) | |
| return ( | |
| f'{caption_html}' | |
| f'<div style="overflow-x:auto;">' | |
| f'<table style="width:100%;border-collapse:collapse;' | |
| f'font-variant-numeric:tabular-nums;font-size:0.82rem;' | |
| f'font-family:ui-monospace,SFMono-Regular,Consolas,monospace;">' | |
| f'<thead><tr>{head}</tr></thead><tbody>{body}</tbody></table>' | |
| f'</div>{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'<div style="font-size:{FS_META};color:{TEXT_FAINT};margin-top:8px;">{meta}</div>' | |
| if meta else "" | |
| ) | |
| return ( | |
| f'<div class="primer-card" style="background:{BG};border:1px solid {BORDER};' | |
| f'border-left:3px solid {accent};border-radius:0 10px 10px 0;' | |
| f'padding:16px 20px;max-width:820px;margin-bottom:6px;">' | |
| f'<div style="font-size:{FS_BODY};line-height:1.6;color:{TEXT};">{body}</div>' | |
| f'{meta_html}</div>' | |
| ) | |