readthrough / dashboard /sentiment_evolution.py
Viney's picture
Claude Opus 5 (1M context)
feat: the heatmap says what management was actually naming
1bc4598
Raw History Blame Contribute Delete
9.12 kB
"""Visual, quote-free rendering of multi-quarter thematic sentiment."""
from __future__ import annotations
from html import escape
import streamlit as st
from dashboard.components import section_header
from dashboard.i18n import t
from dashboard.theme import (
AMBER,
BG_MUTED,
BORDER,
GREEN,
RED,
TEXT,
TEXT_FAINT,
TEXT_MUTED,
FS_META,
)
def _signed(value: int | float | None) -> str:
if value is None:
return "—"
return f"{value:+.0f}"
def _signed_rate(value: float | None) -> str:
"""A signed rate keeps its decimal; _signed() rounds to whole index points."""
if value is None:
return "—"
return f"{value:+.1f}"
def _band(score: int | None) -> str:
if score is None:
return t("sent_band_na")
if score >= 25:
return t("sent_band_positive")
if score <= -25:
return t("sent_band_negative")
return t("sent_band_neutral")
def _summary_card(label: str, value: str, note: str, color: str) -> str:
return (
f'<div style="flex:1;min-width:180px;border:1px solid {BORDER};'
f'border-top:3px solid {color};border-radius:9px;padding:12px 14px;background:white;">'
f'<div style="font-size:0.61rem;font-weight:700;letter-spacing:0.08em;'
f'text-transform:uppercase;color:{TEXT_FAINT};">{escape(label)}</div>'
f'<div style="font-size:1.05rem;font-weight:700;color:{TEXT};margin-top:4px;">'
f'{escape(value)}</div>'
f'<div style="font-size:{FS_META};color:{TEXT_MUTED};margin-top:3px;">'
f'{escape(note)}</div></div>'
)
def _render_summary(data: dict) -> None:
improvement = data.get("biggest_improvement")
deterioration = data.get("biggest_deterioration")
emerging = data.get("emerging_themes") or []
cards: list[str] = []
if deterioration:
cards.append(_summary_card(
t("sent_deterioration"), deterioration["theme"],
t("sent_points").format(value=_signed(deterioration["change"])), RED,
))
if improvement:
cards.append(_summary_card(
t("sent_improvement"), improvement["theme"],
t("sent_points").format(value=_signed(improvement["change"])), GREEN,
))
if emerging:
names = ", ".join(item["theme"] for item in emerging[:3])
cards.append(_summary_card(
t("sent_new_themes"), names,
t("sent_new_count").format(count=len(emerging)), AMBER,
))
if cards:
st.markdown(
f'<div style="display:flex;gap:10px;flex-wrap:wrap;margin:0 0 14px;">'
f'{"".join(cards)}</div>',
unsafe_allow_html=True,
)
def _render_heatmap(data: dict) -> None:
import plotly.graph_objects as go
periods = data["periods"]
themes = data["themes"]
z = [[point["score"] for point in row["periods"]] for row in themes]
custom = [[
[point["attention_per_10k"], point["evidence_sentences"], point["confidence"]]
for point in row["periods"]
] for row in themes]
text = [["—" if score is None else f"{score:+d}" for score in values] for values in z]
fig = go.Figure(go.Heatmap(
z=z,
x=periods,
y=[t(f"sent_theme_{row['key']}") for row in themes],
zmin=-100,
zmax=100,
zmid=0,
colorscale=[[0, "#A63A2B"], [0.5, "#F5F6F3"], [1, "#1E6B55"]],
colorbar=dict(title=t("sent_index"), thickness=12, len=0.8),
customdata=custom,
text=text,
texttemplate="%{text}",
hovertemplate=(
"%{y} · %{x}<br>"
+ t("sent_hover_score") + ": %{z}<br>"
+ t("sent_hover_attention") + ": %{customdata[0]}<br>"
+ t("sent_hover_evidence") + ": %{customdata[1]}<br>"
+ t("sent_hover_confidence") + ": %{customdata[2]}<extra></extra>"
),
hoverongaps=False,
))
fig.update_layout(
height=330,
margin=dict(l=10, r=10, t=20, b=10),
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
font=dict(family="IBM Plex Sans", color=TEXT_MUTED, size=11),
xaxis=dict(side="top", showgrid=False),
yaxis=dict(autorange="reversed", showgrid=False),
)
st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False})
def _render_latest_metrics(data: dict) -> None:
period = data["periods"][-1]
rows = []
for row in data["themes"]:
latest = row["periods"][-1]
rows.append({
t("sent_col_theme"): t(f"sent_theme_{row['key']}"),
t("sent_col_level"): _band(row["latest_score"]),
t("sent_col_index"): _signed(row["latest_score"]),
t("sent_col_change"): _signed(row["change"]),
t("sent_col_attention"): latest["attention_per_10k"],
t("sent_col_evidence"): latest["evidence_sentences"],
t("sent_col_confidence"): t(f"sent_conf_{latest['confidence'].lower()}"),
})
st.caption(t("sent_latest_caption").format(period=period))
st.dataframe(rows, use_container_width=True, hide_index=True)
def _render_emerging(data: dict) -> None:
emerging = data.get("emerging_themes") or []
st.markdown(section_header(t("sent_emerging_title"), accent=AMBER), unsafe_allow_html=True)
if not emerging:
st.caption(t("sent_emerging_none"))
return
rows = [{
t("sent_col_theme"): item["theme"],
t("sent_col_status"): t(f"sent_status_{item['status'].lower()}"),
t("sent_col_attention"): item["attention_per_10k"],
t("sent_col_prior_attention"): item["prior_attention_per_10k"],
t("sent_col_change"): f"{item['change_per_10k']:+.1f}",
} for item in emerging]
st.dataframe(rows, use_container_width=True, hide_index=True)
def _render_terms(data: dict) -> None:
"""The named things behind each theme, one tab per theme.
A heatmap cell on its own cannot be checked. This is the audit trail for it:
what the company was actually calling a product when the Products row was
scored, how often, and whether it volunteered the subject or was asked.
"""
themes = [row for row in data.get("themes", []) if row.get("terms")]
st.markdown(section_header(t("terms_title"), accent=GREEN), unsafe_allow_html=True)
if not themes:
st.caption(t("terms_none"))
return
st.caption(t("terms_subtitle").format(period=data["periods"][-1]))
for tab, row in zip(st.tabs([t(f"sent_theme_{r['key']}") for r in themes]), themes):
with tab:
table = [{
t("terms_col_term"): item["term"],
t("terms_col_source"): (
t("terms_source_filed") if item["filed"] else t("terms_source_call")
),
t("terms_col_mentions"): item["mentions"],
t("terms_col_trend"): item["counts"],
t("terms_col_rate"): item["rate"],
# Pre-signed text rather than numbers: a term with too little
# evidence has no tone at all, and a numeric column prints the
# word "None" in the cell where the reader expects an em dash.
t("terms_col_change"): _signed_rate(item["change"]),
t("terms_col_prepared"): item["prepared"],
t("terms_col_qa"): item["answers"],
t("terms_col_tone"): _signed(item["score"]),
} for item in row["terms"]]
st.dataframe(
table,
use_container_width=True,
hide_index=True,
column_config={
t("terms_col_term"): st.column_config.TextColumn(
t("terms_col_term"), width="medium",
),
t("terms_col_trend"): st.column_config.LineChartColumn(
t("terms_col_trend"), width="small",
),
t("terms_col_rate"): st.column_config.NumberColumn(
t("terms_col_rate"), format="%.1f",
),
},
)
st.caption(t("terms_caveat"))
def render(ticker: str) -> None:
"""Render thematic sentiment for *ticker*, with no source quotations."""
from analysis.theme_sentiment import compute
data = compute(ticker)
st.markdown(section_header(t("sent_evolution_title"), accent=GREEN), unsafe_allow_html=True)
st.caption(t("sent_evolution_subtitle"))
if not data:
st.info(t("sent_evolution_empty"))
return
_render_summary(data)
_render_heatmap(data)
_render_latest_metrics(data)
_render_terms(data)
_render_emerging(data)
with st.expander(t("sent_method_title"), expanded=False):
st.markdown(t("sent_method_body"))
st.markdown(
f'<div style="background:{BG_MUTED};border:1px solid {BORDER};border-radius:7px;'
f'padding:9px 11px;color:{TEXT_MUTED};font-size:{FS_META};">'
f'{escape(t("sent_no_quotes"))}</div>',
unsafe_allow_html=True,
)