Spaces:
Sleeping
Sleeping
Download dashboard/sentiment_evolution.py from Viney/readthrough: direct link, hf CLI and curl.
- Browser
- Download file 9.12 kB
-
https://huggingface.co/spaces/Viney/readthrough/resolve/main/dashboard/sentiment_evolution.py
- Command line
-
hf download hf://spaces/Viney/readthrough/dashboard/sentiment_evolution.py
-
curl -L -o sentiment_evolution.py https://huggingface.co/spaces/Viney/readthrough/resolve/main/dashboard/sentiment_evolution.py
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, | |
| ) | |