| |
| import streamlit as st |
| import redis |
| import json |
| import pandas as pd |
| import plotly.graph_objects as go |
| import plotly.express as px |
| import time |
| import re |
| import sys |
| import os |
| import subprocess |
| from datetime import datetime, timedelta |
| from collections import defaultdict |
|
|
| sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'backend')) |
| from config import REDIS_HOST, REDIS_PORT, REDIS_DB |
|
|
| st.set_page_config( |
| page_title="LivePulse", |
| layout="wide", |
| page_icon="π‘", |
| initial_sidebar_state="expanded" |
| ) |
|
|
| r = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, db=REDIS_DB, decode_responses=True) |
|
|
| TOPIC_LABELS = ["Appreciation", "Question", "Request/Feedback", "Promo", "Spam", "General", "MCQ Answer"] |
| TOPIC_COLOR = { |
| "Appreciation": "#f59e0b", "Question": "#3b82f6", |
| "Request/Feedback": "#8b5cf6", |
| "Promo": "#ec4899", "Spam": "#ef4444", "General": "#6b7280", |
| "MCQ Answer": "#10b981" |
| } |
| SENT_COLORS = {"Positive": "#22c55e", "Neutral": "#eab308", "Negative": "#ef4444"} |
|
|
| |
| THEME_JS = """<script> |
| (function() { |
| function applyTheme() { |
| const html = window.parent.document.documentElement; |
| const style = window.parent.getComputedStyle(html); |
| const bg = style.getPropertyValue('--background-color').trim(); |
| let isDark = true; |
| const m = bg.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); |
| if (m) { isDark = (0.299*m[1] + 0.587*m[2] + 0.114*m[3]) < 128; } |
| else { |
| const bodyBg = window.parent.getComputedStyle(window.parent.document.body).backgroundColor; |
| const m2 = bodyBg.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); |
| if (m2) { isDark = (0.299*m2[1] + 0.587*m2[2] + 0.114*m2[3]) < 128; } |
| } |
| html.setAttribute('data-livepulse', isDark ? 'dark' : 'light'); |
| } |
| applyTheme(); |
| const obs = new MutationObserver(applyTheme); |
| obs.observe(window.parent.document.documentElement, { attributes: true, attributeFilter: ['style','class'] }); |
| obs.observe(window.parent.document.body, { attributes: true, attributeFilter: ['style','class'] }); |
| })(); |
| </script>""" |
|
|
| CSS = """<style> |
| @import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700;800&display=swap'); |
| |
| :root, [data-livepulse="dark"] { |
| --bg:#07070f; --bg-card:#0f0f1e; --border:rgba(255,255,255,0.07); |
| --text-1:#f1f5f9; --text-2:#94a3b8; --text-3:#475569; |
| --accent:#7c3aed; --accent2:#4f46e5; --accent-text:#a78bfa; |
| --live:#22c55e; --input-bg:rgba(255,255,255,0.04); --input-border:rgba(255,255,255,0.1); |
| --divider:rgba(255,255,255,0.06); --badge-bg:rgba(255,255,255,0.05); |
| --shadow:0 4px 24px rgba(0,0,0,0.4); --shadow-sm:0 2px 8px rgba(0,0,0,0.3); |
| --pill-bg:rgba(124,58,237,0.15); --pill-border:rgba(124,58,237,0.3); --pill-text:#a78bfa; |
| --plotly-paper:rgba(0,0,0,0); --plotly-plot:rgba(255,255,255,0.015); --plotly-grid:rgba(255,255,255,0.05); --plotly-text:#94a3b8; |
| --alert-bg:rgba(239,68,68,0.1); --alert-border:rgba(239,68,68,0.3); |
| --pin-bg:rgba(234,179,8,0.1); --pin-border:rgba(234,179,8,0.35); |
| } |
| [data-livepulse="light"] { |
| --bg:#f4f6ff; --bg-card:#ffffff; --border:rgba(99,102,241,0.12); |
| --text-1:#0f172a; --text-2:#475569; --text-3:#94a3b8; |
| --accent:#6d28d9; --accent2:#4338ca; --accent-text:#6d28d9; |
| --live:#16a34a; --input-bg:#ffffff; --input-border:rgba(99,102,241,0.2); |
| --divider:rgba(99,102,241,0.1); --badge-bg:rgba(99,102,241,0.06); |
| --shadow:0 4px 24px rgba(99,102,241,0.12); --shadow-sm:0 2px 8px rgba(99,102,241,0.08); |
| --pill-bg:rgba(109,40,217,0.08); --pill-border:rgba(109,40,217,0.2); --pill-text:#6d28d9; |
| --plotly-paper:rgba(0,0,0,0); --plotly-plot:rgba(255,255,255,0.7); --plotly-grid:rgba(0,0,0,0.06); --plotly-text:#475569; |
| --alert-bg:rgba(239,68,68,0.07); --alert-border:rgba(239,68,68,0.25); |
| --pin-bg:rgba(234,179,8,0.08); --pin-border:rgba(234,179,8,0.3); |
| } |
| |
| html,body,[data-testid="stAppViewContainer"],[data-testid="stMain"],.main .block-container { |
| background:var(--bg)!important; color:var(--text-1)!important; |
| font-family:'Space Grotesk',sans-serif!important; transition:background 0.3s,color 0.3s; |
| } |
| [data-testid="stSidebar"] { background:var(--bg-card)!important; border-right:1px solid var(--border)!important; transition:background 0.3s; } |
| [data-testid="stHeader"] { background:transparent!important; } |
| ::-webkit-scrollbar{width:4px;} ::-webkit-scrollbar-track{background:var(--bg);} |
| ::-webkit-scrollbar-thumb{background:linear-gradient(var(--accent),var(--accent2));border-radius:4px;} |
| |
| [data-testid="metric-container"] { |
| background:var(--bg-card)!important; border:1px solid var(--border)!important; |
| border-radius:16px!important; padding:18px!important; box-shadow:var(--shadow-sm)!important; transition:background 0.3s; |
| } |
| [data-testid="stMetricLabel"]{color:var(--text-2)!important;font-size:0.8rem!important;} |
| [data-testid="stMetricValue"]{color:var(--text-1)!important;font-weight:700!important;} |
| [data-testid="stMetricDelta"]{color:var(--accent-text)!important;} |
| |
| .stTextInput input { background:var(--input-bg)!important; border:1px solid var(--input-border)!important; border-radius:10px!important; color:var(--text-1)!important; } |
| .stTextInput input::placeholder { color:var(--text-3)!important; opacity:1!important; } |
| [data-testid="stSidebar"] .stTextInput input { background:#1a1a2e!important; border:1px solid rgba(124,58,237,0.4)!important; color:#f1f5f9!important; font-weight:500!important; } |
| [data-testid="stSidebar"] .stTextInput input::placeholder { color:#64748b!important; } |
| [data-testid="stSidebar"] .stTextInput input:focus { border-color:var(--accent)!important; box-shadow:0 0 0 2px rgba(124,58,237,0.2)!important; outline:none!important; } |
| [data-testid="stSidebar"] label { color:var(--text-2)!important; } |
| [data-baseweb="select"]>div { background:var(--input-bg)!important; border:1px solid var(--input-border)!important; border-radius:10px!important; color:var(--text-1)!important; } |
| .stButton>button { background:linear-gradient(135deg,var(--accent),var(--accent2))!important; color:#fff!important; border:none!important; border-radius:10px!important; font-weight:600!important; font-family:'Space Grotesk',sans-serif!important; box-shadow:0 4px 16px rgba(124,58,237,0.3)!important; transition:all 0.2s!important; } |
| .stButton>button:hover{transform:translateY(-2px)!important;} |
| hr{border:none!important;border-top:1px solid var(--divider)!important;margin:1.2rem 0!important;} |
| [data-testid="stSidebar"] label,[data-testid="stSidebar"] .stMarkdown p{color:var(--text-2)!important;font-size:0.83rem!important;} |
| |
| [data-testid="stDownloadButton"]>button { background:var(--bg-card)!important; color:var(--text-2)!important; border:1px solid var(--border)!important; border-radius:8px!important; font-size:0.75rem!important; box-shadow:none!important; } |
| [data-testid="stDownloadButton"]>button:hover { background:var(--pill-bg)!important; color:var(--accent-text)!important; border-color:var(--pill-border)!important; } |
| |
| [data-testid="stCheckbox"] label, [data-testid="stCheckbox"] span { color:var(--text-2)!important; font-size:0.82rem!important; } |
| [data-testid="stCheckbox"] [data-testid="stWidgetLabel"] { color:var(--text-2)!important; } |
| |
| @keyframes pulse{0%{box-shadow:0 0 0 0 rgba(34,197,94,0.7);}70%{box-shadow:0 0 0 10px rgba(34,197,94,0);}100%{box-shadow:0 0 0 0 rgba(34,197,94,0);}} |
| .live-dot{display:inline-block;width:9px;height:9px;background:var(--live);border-radius:50%;animation:pulse 1.8s infinite;margin-right:6px;vertical-align:middle;} |
| |
| @keyframes alertPulse{0%{opacity:1;}50%{opacity:0.7;}100%{opacity:1;}} |
| .alert-banner{background:var(--alert-bg);border:1px solid var(--alert-border);border-radius:14px;padding:14px 18px;margin:12px 0;display:flex;align-items:center;gap:12px;animation:alertPulse 2s infinite;} |
| .alert-icon{font-size:1.4rem;} |
| .alert-text{font-size:0.88rem;font-weight:600;color:#ef4444;} |
| .alert-sub{font-size:0.75rem;color:var(--text-3);margin-top:2px;} |
| |
| .stat-grid{display:flex;gap:12px;margin:10px 0 18px;flex-wrap:wrap;} |
| .stat-card{flex:1;min-width:130px;background:var(--bg-card);border:1px solid var(--border);border-radius:20px;padding:22px 18px;text-align:center;transition:transform 0.2s,box-shadow 0.2s,background 0.3s;position:relative;overflow:hidden;box-shadow:var(--shadow-sm);} |
| .stat-card:hover{transform:translateY(-4px);box-shadow:var(--shadow);} |
| .stat-accent{position:absolute;top:0;left:0;right:0;height:3px;border-radius:20px 20px 0 0;} |
| .stat-number{font-size:2.6rem;font-weight:800;line-height:1;margin-bottom:6px;letter-spacing:-0.03em;} |
| .stat-label{font-size:0.82rem;color:var(--text-2);font-weight:600;text-transform:uppercase;letter-spacing:0.06em;} |
| .stat-sub{font-size:0.7rem;color:var(--text-3);margin-top:4px;} |
| |
| .velocity-card{background:var(--bg-card);border:1px solid var(--border);border-radius:20px;padding:18px 22px;box-shadow:var(--shadow-sm);display:flex;align-items:center;gap:16px;} |
| .velocity-arrow{font-size:2rem;line-height:1;} |
| .velocity-val{font-size:1.6rem;font-weight:800;letter-spacing:-0.03em;} |
| .velocity-label{font-size:0.75rem;color:var(--text-3);font-weight:600;text-transform:uppercase;letter-spacing:0.06em;margin-top:2px;} |
| |
| .sec-hdr{display:flex;align-items:center;gap:10px;margin:6px 0 14px;} |
| .sec-ttl{font-size:1rem;font-weight:700;color:var(--text-1);letter-spacing:-0.01em;} |
| .sec-pill{background:var(--pill-bg);border:1px solid var(--pill-border);border-radius:20px;padding:2px 10px;font-size:0.68rem;color:var(--pill-text);font-weight:700;text-transform:uppercase;letter-spacing:0.08em;} |
| |
| .chart-wrap{background:var(--bg-card);border:1px solid var(--border);border-radius:20px;padding:14px 14px 6px;box-shadow:var(--shadow-sm);transition:background 0.3s,border 0.3s;} |
| .chart-title{font-size:0.88rem;font-weight:700;color:var(--text-1);margin-bottom:2px;} |
| .chart-sub{font-size:0.72rem;color:var(--text-3);margin-bottom:10px;} |
| |
| .topic-grid{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:18px;} |
| .topic-pill{background:var(--bg-card);border-radius:16px;padding:14px 20px;text-align:center;min-width:110px;box-shadow:var(--shadow-sm);transition:transform 0.2s,box-shadow 0.2s;} |
| .topic-pill:hover{transform:translateY(-3px);box-shadow:var(--shadow);} |
| .topic-count{font-size:1.4rem;font-weight:800;letter-spacing:-0.02em;} |
| .topic-name{font-size:0.7rem;color:var(--text-3);margin-top:3px;font-weight:600;text-transform:uppercase;letter-spacing:0.06em;} |
| |
| @keyframes slideIn{from{opacity:0;transform:translateY(6px);}to{opacity:1;transform:translateY(0);}} |
| .chat-card{background:var(--bg-card);border:1px solid var(--border);border-radius:16px;padding:14px 16px;margin-bottom:10px;border-left:3px solid transparent;animation:slideIn 0.2s ease;transition:background 0.2s,transform 0.15s,box-shadow 0.2s;box-shadow:var(--shadow-sm);} |
| .chat-card:hover{transform:translateX(4px);box-shadow:var(--shadow);} |
| .chat-positive{border-left-color:#22c55e;} .chat-negative{border-left-color:#ef4444;} .chat-neutral{border-left-color:#eab308;} |
| .chat-pinned{border-left-color:#eab308!important;background:var(--pin-bg)!important;border-color:var(--pin-border)!important;} |
| .chat-author{font-weight:700;font-size:0.83rem;color:var(--accent-text);margin-bottom:5px;} |
| .chat-text{font-size:0.92rem;color:var(--text-2);line-height:1.55;margin-bottom:9px;} |
| .chat-badges{display:flex;gap:6px;flex-wrap:wrap;} |
| .badge{display:inline-flex;align-items:center;background:var(--badge-bg);border:1px solid var(--border);border-radius:20px;padding:3px 10px;font-size:0.7rem;font-weight:600;color:var(--text-2);} |
| .pin-badge{background:rgba(234,179,8,0.15);border-color:rgba(234,179,8,0.4);color:#eab308;} |
| |
| .compare-label{font-size:0.72rem;font-weight:700;text-transform:uppercase;letter-spacing:0.08em;padding:3px 10px;border-radius:20px;display:inline-block;margin-bottom:8px;} |
| |
| .engage-card{background:var(--bg-card);border:1px solid var(--border);border-radius:20px;padding:20px 24px;box-shadow:var(--shadow-sm);position:relative;overflow:hidden;} |
| .engage-score{font-size:3rem;font-weight:800;letter-spacing:-0.04em;line-height:1;} |
| .engage-label{font-size:0.75rem;color:var(--text-3);font-weight:600;text-transform:uppercase;letter-spacing:0.08em;margin-top:4px;} |
| .engage-bar-bg{background:var(--border);border-radius:99px;height:6px;margin-top:12px;overflow:hidden;} |
| .engage-bar-fill{height:6px;border-radius:99px;transition:width 0.6s ease;} |
| .engage-breakdown{display:flex;gap:16px;margin-top:10px;flex-wrap:wrap;} |
| .engage-item{font-size:0.72rem;color:var(--text-3);} |
| .engage-item span{font-weight:700;color:var(--text-2);} |
| |
| .leaderboard-row{display:flex;align-items:center;gap:12px;padding:10px 14px;background:var(--bg-card);border:1px solid var(--border);border-radius:14px;margin-bottom:8px;transition:transform 0.15s,box-shadow 0.15s;} |
| .leaderboard-row:hover{transform:translateX(4px);box-shadow:var(--shadow);} |
| .lb-rank{font-size:1rem;font-weight:800;color:var(--text-3);min-width:28px;} |
| .lb-rank.gold{color:#f59e0b;} .lb-rank.silver{color:#94a3b8;} .lb-rank.bronze{color:#b45309;} |
| .lb-author{font-size:0.85rem;font-weight:700;color:var(--text-1);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} |
| .lb-count{font-size:0.78rem;color:var(--text-3);min-width:40px;text-align:right;} |
| .lb-bar{flex:2;height:5px;background:var(--border);border-radius:99px;overflow:hidden;} |
| .lb-bar-fill{height:5px;border-radius:99px;} |
| .lb-sent{display:flex;gap:4px;min-width:80px;justify-content:flex-end;} |
| .lb-dot{width:8px;height:8px;border-radius:50%;display:inline-block;} |
| |
| .spam-alert{background:rgba(239,68,68,0.08);border:1px solid rgba(239,68,68,0.25);border-radius:14px;padding:14px 18px;margin:12px 0;display:flex;align-items:center;gap:12px;} |
| .spam-alert-text{font-size:0.88rem;font-weight:600;color:#ef4444;} |
| .spam-alert-sub{font-size:0.75rem;color:var(--text-3);margin-top:2px;} |
| |
| .empty-state{text-align:center;padding:80px 20px;background:var(--bg-card);border:1px solid var(--border);border-radius:24px;margin:40px 0;box-shadow:var(--shadow-sm);} |
| .empty-icon{font-size:3.5rem;margin-bottom:16px;} |
| .empty-title{font-size:1.1rem;color:var(--text-2);font-weight:700;} |
| .empty-sub{font-size:0.84rem;color:var(--text-3);margin-top:6px;} |
| |
| [data-testid="stSidebar"] [role="radiogroup"] { display:flex; flex-direction:row; flex-wrap:nowrap; gap:4px; } |
| [data-testid="stSidebar"] [role="radiogroup"] label { flex:1; display:flex; align-items:center; justify-content:center; background:var(--bg-card); border:1px solid var(--pill-border); border-radius:8px; padding:6px 2px; cursor:pointer; transition:background 0.15s,border 0.15s; } |
| [data-testid="stSidebar"] [role="radiogroup"] label:hover { background:var(--pill-bg); border-color:var(--accent); } |
| [data-testid="stSidebar"] [role="radiogroup"] label[data-checked="true"], |
| [data-testid="stSidebar"] [role="radiogroup"] label:has(input:checked) { background:linear-gradient(135deg,var(--accent),var(--accent2)); border-color:var(--accent); } |
| [data-testid="stSidebar"] [role="radiogroup"] label p, |
| [data-testid="stSidebar"] [role="radiogroup"] label span { font-size:0.82rem !important; font-weight:700 !important; color:var(--text-1) !important; white-space:nowrap !important; } |
| [data-testid="stSidebar"] [role="radiogroup"] label:has(input:checked) p, |
| [data-testid="stSidebar"] [role="radiogroup"] label:has(input:checked) span { color:#fff !important; } |
| [data-testid="stSidebar"] [role="radiogroup"] input[type="radio"] { display:none !important; } |
| [data-testid="stSidebar"] [data-testid="stWidgetLabel"]:has(+ [role="radiogroup"]) { color:var(--text-2) !important; font-size:0.75rem !important; margin-bottom:4px; } |
| </style>""" |
|
|
| st.markdown(THEME_JS, unsafe_allow_html=True) |
| st.markdown(CSS, unsafe_allow_html=True) |
|
|
|
|
| |
| def extract_video_id(url_or_id): |
| url_or_id = url_or_id.strip() |
| match = re.search(r"(?:v=|/live/|youtu\.be/)([A-Za-z0-9_-]{11})", url_or_id) |
| if match: |
| return match.group(1) |
| if re.match(r"^[A-Za-z0-9_-]{11}$", url_or_id): |
| return url_or_id |
| return url_or_id |
|
|
| def update_config_video_id(video_id): |
| config_path = os.path.join(os.path.dirname(__file__), '..', 'backend', 'config.py') |
| with open(config_path, 'r') as f: |
| content = f.read() |
| content = re.sub(r'VIDEO_ID\s*=\s*".*?"', f'VIDEO_ID = "{video_id}"', content) |
| with open(config_path, 'w') as f: |
| f.write(content) |
|
|
| def fetch_video_title(video_id): |
| try: |
| import urllib.request |
| url = f"https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v={video_id}&format=json" |
| with urllib.request.urlopen(url, timeout=5) as resp: |
| return json.loads(resp.read())["title"] |
| except Exception: |
| return None |
|
|
| def clean_topic(val): |
| if pd.isna(val) or str(val).strip() == "" or str(val).strip().lower() == "nan": |
| return "General" |
| return str(val).strip() |
|
|
| def clean_sentiment(val): |
| if str(val).strip() in ("Positive", "Negative", "Neutral"): |
| return str(val).strip() |
| return "Neutral" |
|
|
| def plotly_layout(height=280): |
| return dict( |
| paper_bgcolor="rgba(0,0,0,0)", |
| plot_bgcolor="rgba(0,0,0,0)", |
| height=height, |
| margin=dict(l=10, r=10, t=10, b=10), |
| font=dict(family="Space Grotesk"), |
| xaxis=dict(showgrid=False, zeroline=False, showline=False, |
| tickfont=dict(size=11), title=None), |
| yaxis=dict(showgrid=True, gridcolor="rgba(128,128,128,0.12)", |
| zeroline=False, showline=False, tickfont=dict(size=11), title=None), |
| showlegend=False, |
| hoverlabel=dict(font_family="Space Grotesk", font_size=12), |
| ) |
|
|
| def csv_download(df_export, label, filename): |
| csv = df_export.to_csv(index=False).encode("utf-8") |
| st.download_button(label=f"β¬ {label}", data=csv, |
| file_name=filename, mime="text/csv", key=filename) |
|
|
| @st.cache_data(ttl=5, show_spinner=False) |
| def load_stream_data(redis_key: str, limit: int | None = None): |
| """Load and parse messages from a Redis key. Cached for 5s to avoid redundant reads.""" |
| if limit: |
| raws = r.lrange(redis_key, -limit, -1) |
| else: |
| raws = r.lrange(redis_key, 0, -1) |
| data = [] |
| for raw in raws: |
| try: |
| data.append(json.loads(raw)) |
| except Exception: |
| pass |
| return data |
|
|
| @st.cache_data(ttl=10, show_spinner=False) |
| def compute_velocity(df_all_json: str, window: int = 20) -> dict: |
| """ |
| Compute sentiment velocity. Accepts JSON string for cache key compatibility. |
| """ |
| import json as _json |
| sentiments = [m.get("sentiment", "Neutral") for m in _json.loads(df_all_json)] |
| n = len(sentiments) |
| if n < window * 2: |
| return {"direction": "β", "delta": 0.0, "label": "Stable", "color": "#eab308"} |
| recent = sentiments[-window:] |
| prev = sentiments[-window*2:-window] |
| r_pos = sum(1 for s in recent if s == "Positive") / window |
| p_pos = sum(1 for s in prev if s == "Positive") / window |
| delta = r_pos - p_pos |
| if delta > 0.08: |
| return {"direction": "β", "delta": delta, "label": "Rising", "color": "#22c55e"} |
| elif delta < -0.08: |
| return {"direction": "β", "delta": delta, "label": "Falling", "color": "#ef4444"} |
| return {"direction": "β", "delta": delta, "label": "Stable", "color": "#eab308"} |
|
|
|
|
| @st.cache_data(ttl=10, show_spinner=False) |
| def build_heatmap_data(df_all_json: str, bucket_minutes: int = 1) -> pd.DataFrame: |
| """ |
| Bucket messages into time intervals. Accepts JSON string for cache key compatibility. |
| """ |
| import json as _json |
| records = _json.loads(df_all_json) |
| if not records: |
| return pd.DataFrame() |
| df_t = pd.DataFrame(records) |
| if "time" not in df_t.columns: |
| return pd.DataFrame() |
| df_t["time"] = pd.to_datetime(df_t["time"], errors="coerce") |
| df_t = df_t.dropna(subset=["time"]) |
| if df_t.empty: |
| return pd.DataFrame() |
| df_t["bucket"] = df_t["time"].dt.floor(f"{bucket_minutes}min") |
| grouped = df_t.groupby(["bucket", "sentiment"]).size().unstack(fill_value=0) |
| for col in ["Positive", "Neutral", "Negative"]: |
| if col not in grouped.columns: |
| grouped[col] = 0 |
| grouped = grouped.reset_index() |
| grouped.columns.name = None |
| return grouped[["bucket", "Positive", "Neutral", "Negative"]] |
|
|
| def check_alert(df_all: pd.DataFrame, threshold: float = 0.4, window: int = 15) -> dict | None: |
| """Return alert info if negative ratio in last `window` messages exceeds threshold.""" |
| if len(df_all) < window: |
| return None |
| recent = df_all.iloc[-window:] |
| neg_ratio = (recent["sentiment"] == "Negative").mean() |
| if neg_ratio >= threshold: |
| return { |
| "neg_ratio": neg_ratio, |
| "count": int((recent["sentiment"] == "Negative").sum()), |
| "window": window, |
| } |
| return None |
|
|
|
|
| @st.cache_data(ttl=10, show_spinner=False) |
| def compute_engagement(all_data_json: str, window: int = 50) -> dict: |
| """ |
| Engagement score (0β100) = weighted combo of: |
| - message rate (msgs per minute, last window) |
| - positive ratio (last window) |
| - question density (last window) |
| """ |
| import json as _j |
| msgs = _j.loads(all_data_json) |
| if not msgs: |
| return {"score": 0, "rate": 0.0, "pos_ratio": 0.0, "q_density": 0.0, "grade": "β"} |
|
|
| recent = msgs[-window:] |
| n = len(recent) |
|
|
| |
| rate = 0.0 |
| try: |
| t0 = datetime.fromisoformat(recent[0]["time"]) |
| t1 = datetime.fromisoformat(recent[-1]["time"]) |
| elapsed = max((t1 - t0).total_seconds() / 60, 0.1) |
| rate = round(n / elapsed, 1) |
| except Exception: |
| rate = float(n) |
|
|
| pos_ratio = sum(1 for m in recent if m.get("sentiment") == "Positive") / max(n, 1) |
| q_density = sum(1 for m in recent if m.get("topic") == "Question") / max(n, 1) |
|
|
| |
| rate_norm = min(rate / 60, 1.0) |
| score = round((rate_norm * 0.4 + pos_ratio * 0.4 + q_density * 0.2) * 100) |
|
|
| if score >= 70: grade = "π₯ High" |
| elif score >= 40: grade = "β‘ Medium" |
| else: grade = "π€ Low" |
|
|
| return {"score": score, "rate": rate, "pos_ratio": pos_ratio, "q_density": q_density, "grade": grade} |
|
|
|
|
| @st.cache_data(ttl=10, show_spinner=False) |
| def compute_top_contributors(all_data_json: str, top_n: int = 10) -> list[dict]: |
| """Return top N authors by message count with sentiment + topic breakdown.""" |
| import json as _j |
| from collections import Counter |
| msgs = _j.loads(all_data_json) |
| if not msgs: |
| return [] |
|
|
| TOPICS = ["Appreciation", "Question", "Request/Feedback", "Promo", "Spam", "General", "MCQ Answer"] |
| author_data: dict[str, dict] = {} |
| for m in msgs: |
| a = m.get("author", "Unknown") |
| if a not in author_data: |
| author_data[a] = { |
| "count": 0, |
| "Positive": 0, "Neutral": 0, "Negative": 0, |
| **{t: 0 for t in TOPICS}, |
| } |
| author_data[a]["count"] += 1 |
| s = m.get("sentiment", "Neutral") |
| if s in ("Positive", "Neutral", "Negative"): |
| author_data[a][s] += 1 |
| t = m.get("topic", "General") |
| if t not in TOPICS: |
| t = "General" |
| author_data[a][t] += 1 |
|
|
| sorted_authors = sorted(author_data.items(), key=lambda x: x[1]["count"], reverse=True)[:top_n] |
| result = [] |
| for author, d in sorted_authors: |
| total = max(d["count"], 1) |
| result.append({ |
| "author": author, |
| "count": d["count"], |
| "pos_pct": round(d["Positive"] / total * 100), |
| "neu_pct": round(d["Neutral"] / total * 100), |
| "neg_pct": round(d["Negative"] / total * 100), |
| "t_appr": round(d["Appreciation"] / total * 100), |
| "t_ques": round(d["Question"] / total * 100), |
| "t_rf": round(d["Request/Feedback"] / total * 100), |
| "t_promo": round(d["Promo"] / total * 100), |
| "t_spam": round(d["Spam"] / total * 100), |
| "t_gen": round(d["General"] / total * 100), |
| "t_mcq": round(d["MCQ Answer"] / total * 100), |
| }) |
| return result |
|
|
|
|
| @st.cache_data(ttl=10, show_spinner=False) |
| def compute_word_freq(all_data_json: str, sentiment_filter: str = "All", |
| topic_filter: str = "All", top_n: int = 60) -> list[tuple[str, int]]: |
| """Return top N (word, count) pairs after filtering stopwords.""" |
| import json as _j |
| from collections import Counter |
|
|
| STOPWORDS = { |
| "the","a","an","is","it","in","on","at","to","of","and","or","but","for", |
| "with","this","that","are","was","be","as","by","from","have","has","had", |
| "not","no","so","if","do","did","will","can","just","i","you","he","she", |
| "we","they","my","your","his","her","our","their","me","him","us","them", |
| "what","how","why","when","where","who","which","there","here","been", |
| "would","could","should","may","might","shall","than","then","now","also", |
| "more","very","too","up","out","about","into","over","after","before", |
| "yaar","bhi","hai","hain","ho","kar","ke","ki","ka","ko","se","ne","ye", |
| "vo","woh","aur","nahi","nhi","toh","toh","koi","kuch","ab","ek","hi", |
| } |
|
|
| msgs = _j.loads(all_data_json) |
| words: list[str] = [] |
| for m in msgs: |
| if sentiment_filter != "All" and m.get("sentiment") != sentiment_filter: |
| continue |
| if topic_filter != "All" and m.get("topic") != topic_filter: |
| continue |
| text = re.sub(r"[^\w\s]", " ", m.get("text", "").lower()) |
| for w in text.split(): |
| if len(w) > 2 and w not in STOPWORDS and not w.isdigit(): |
| words.append(w) |
|
|
| return Counter(words).most_common(top_n) |
|
|
|
|
| def check_spam_alert(df_all: pd.DataFrame, threshold: float = 0.3, window: int = 20) -> dict | None: |
| """Return alert if spam ratio in last `window` messages exceeds threshold.""" |
| if "topic" not in df_all.columns or len(df_all) < window: |
| return None |
| recent = df_all.iloc[-window:] |
| spam_ratio = (recent["topic"] == "Spam").mean() |
| if spam_ratio >= threshold: |
| return { |
| "spam_ratio": spam_ratio, |
| "count": int((recent["topic"] == "Spam").sum()), |
| "window": window, |
| } |
| return None |
|
|
|
|
| @st.cache_data(ttl=10, show_spinner=False) |
| def detect_repeat_spammers(all_data_json: str, window_sec: int = 15, min_repeats: int = 2) -> list[dict]: |
| """ |
| Detect users who send the same (or near-identical) message multiple times |
| within `window_sec` seconds. Returns list of spam burst dicts sorted by |
| repeat count descending. |
| """ |
| import json as _j |
| import re as _re |
|
|
| msgs = _j.loads(all_data_json) |
| if not msgs: |
| return [] |
|
|
| def _normalize(t: str) -> str: |
| return _re.sub(r"[^\w]", "", t.lower().strip()) |
|
|
| bursts: dict[tuple, dict] = {} |
| for m in msgs: |
| author = m.get("author", "Unknown") |
| text = m.get("text", "").strip() |
| if not text: |
| continue |
| norm = _normalize(text) |
| if len(norm) < 4: |
| continue |
| ts_str = m.get("time", "") |
| try: |
| ts = datetime.fromisoformat(ts_str) |
| except Exception: |
| continue |
| key = (author, norm) |
| if key not in bursts: |
| bursts[key] = { |
| "author": author, |
| "text": text, |
| "topic": m.get("topic", "General"), |
| "sentiment": m.get("sentiment", "Neutral"), |
| "timestamps": [], |
| } |
| bursts[key]["timestamps"].append(ts) |
|
|
| results = [] |
| for key, burst in bursts.items(): |
| times = sorted(burst["timestamps"]) |
| max_in_window = 1 |
| for i in range(len(times)): |
| count_in_window = sum( |
| 1 for t in times[i:] |
| if (t - times[i]).total_seconds() <= window_sec |
| ) |
| max_in_window = max(max_in_window, count_in_window) |
|
|
| if max_in_window >= min_repeats: |
| results.append({ |
| "author": burst["author"], |
| "text": burst["text"], |
| "topic": burst["topic"], |
| "sentiment": burst["sentiment"], |
| "count": len(times), |
| "max_burst": max_in_window, |
| "first_seen": times[0].strftime("%H:%M:%S"), |
| "last_seen": times[-1].strftime("%H:%M:%S"), |
| }) |
|
|
| return sorted(results, key=lambda x: x["max_burst"], reverse=True) |
|
|
|
|
| |
| MAX_STREAMS = 5 |
| STREAM_COLORS = ["#7c3aed", "#10b981", "#f59e0b", "#3b82f6", "#ec4899"] |
| STREAM_NAMES = ["A", "B", "C", "D", "E"] |
|
|
| if "pinned_messages" not in st.session_state: |
| st.session_state.pinned_messages = [] |
| if "alert_dismissed" not in st.session_state: |
| st.session_state.alert_dismissed = False |
| if "last_alert_count" not in st.session_state: |
| st.session_state.last_alert_count = 0 |
| if "last_view" not in st.session_state: |
| st.session_state.last_view = "π¬ Comments" |
| |
| if "streams" not in st.session_state: |
| st.session_state.streams = [ |
| {"video_id": "", "redis_key": "chat_messages", "label": "Stream A", "proc": None} |
| ] |
|
|
| |
| with st.sidebar: |
| st.markdown( |
| '<div style="padding:12px 0 20px;">' |
| '<div style="font-size:1.35rem;font-weight:800;color:var(--text-1);letter-spacing:-0.02em;">π‘ LivePulse</div>' |
| '<div style="font-size:0.75rem;color:var(--text-3);margin-top:2px;">YouTube Chat Analytics</div>' |
| '</div>', unsafe_allow_html=True |
| ) |
| st.divider() |
|
|
| |
| st.markdown('<p style="font-size:0.68rem;font-weight:700;color:var(--accent);text-transform:uppercase;letter-spacing:0.1em;margin-bottom:8px;">Display Settings</p>', unsafe_allow_html=True) |
| active_view = st.radio( |
| "View", |
| options=["π¬ Comments", "π Stats & Info"], |
| index=0, |
| horizontal=True, |
| key="active_view", |
| ) |
| refresh_rate = st.radio( |
| "Refresh interval (s)", |
| options=[10, 20, 30, 40, 50, 60], |
| index=0, |
| horizontal=True, |
| key="refresh_rate", |
| ) |
| msg_limit = st.slider("Message window", 10, 400, 50, step=10) |
| auto_refresh = st.toggle("Live auto-refresh", value=True) |
| st.divider() |
|
|
| |
| st.markdown('<p style="font-size:0.68rem;font-weight:700;color:var(--accent);text-transform:uppercase;letter-spacing:0.1em;margin-bottom:8px;">Alert Settings</p>', unsafe_allow_html=True) |
| alert_enabled = st.toggle("Negative spike alerts", value=True) |
| alert_threshold = st.slider("Neg alert threshold (%)", 20, 80, 40) / 100 |
| alert_window = st.slider("Alert window (msgs)", 5, 30, 15) |
| spam_alert_on = st.toggle("Spam rate alerts", value=True) |
| spam_threshold = st.slider("Spam alert threshold (%)", 10, 60, 30) / 100 |
| st.divider() |
|
|
| |
| st.markdown('<p style="font-size:0.68rem;font-weight:700;color:var(--accent);text-transform:uppercase;letter-spacing:0.1em;margin-bottom:8px;">Stream Control</p>', unsafe_allow_html=True) |
|
|
| import importlib |
| import config as _cfg |
| importlib.reload(_cfg) |
|
|
| |
| if st.session_state.streams[0]["video_id"] == "": |
| st.session_state.streams[0]["video_id"] = _cfg.VIDEO_ID |
|
|
| for idx, stream in enumerate(st.session_state.streams): |
| color = STREAM_COLORS[idx] |
| label = STREAM_NAMES[idx] |
| st.markdown( |
| f'<div style="font-size:0.72rem;font-weight:700;color:{color};text-transform:uppercase;' |
| f'letter-spacing:0.08em;margin:10px 0 4px;border-left:3px solid {color};padding-left:8px;">' |
| f'Stream {label}</div>', |
| unsafe_allow_html=True |
| ) |
| |
| vid_skey = f"vid_{idx}" |
| rkey_skey = f"rkey_{idx}" |
| if vid_skey not in st.session_state: |
| st.session_state[vid_skey] = stream["video_id"] |
| if rkey_skey not in st.session_state: |
| st.session_state[rkey_skey] = stream["redis_key"] |
|
|
| st.text_input("Video ID / URL", placeholder="e.g. eFSK2-QRB0A", key=vid_skey) |
| st.text_input("Redis key", placeholder=f"chat_messages_{label.lower()}", key=rkey_skey) |
|
|
| sc1, sc2 = st.columns(2) |
| with sc1: |
| if st.button("βΆ Start", key=f"start_{idx}", width='stretch'): |
| vid = extract_video_id(st.session_state[vid_skey]) |
| rkey = st.session_state[rkey_skey].strip() or f"chat_messages_{label.lower()}" |
| if vid: |
| |
| old_proc = st.session_state.streams[idx].get("proc") |
| if old_proc and old_proc.poll() is None: |
| old_proc.terminate() |
| proc = subprocess.Popen( |
| [sys.executable, "-m", "backend.scraper", |
| "--video_id", vid, "--redis_key", rkey], |
| cwd=os.path.abspath(os.path.join(os.path.dirname(__file__), "..")), |
| stdout=subprocess.DEVNULL, |
| stderr=subprocess.DEVNULL, |
| ) |
| st.session_state.streams[idx]["proc"] = proc |
| st.session_state.streams[idx]["video_id"] = vid |
| st.session_state.streams[idx]["redis_key"] = rkey |
| |
| _title = fetch_video_title(vid) |
| st.session_state.streams[idx]["video_title"] = _title or vid |
| if idx == 0: |
| update_config_video_id(vid) |
| r.set("video_title", _title) if _title else r.delete("video_title") |
| st.session_state.alert_dismissed = False |
| st.success(f"Stream {label} started β `{rkey}`") |
| else: |
| st.error("Invalid video ID") |
| with sc2: |
| if st.button("βΉ Stop", key=f"stop_{idx}", width='stretch'): |
| proc = st.session_state.streams[idx].get("proc") |
| if proc and proc.poll() is None: |
| proc.terminate() |
| st.session_state.streams[idx]["proc"] = None |
| st.success(f"Stream {label} stopped") |
| else: |
| st.warning("Not running") |
|
|
| proc = st.session_state.streams[idx].get("proc") |
| running = proc is not None and proc.poll() is None |
| dot_color = "#22c55e" if running else "#ef4444" |
| status = "running" if running else "stopped" |
| st.markdown(f'<div style="font-size:0.72rem;color:{dot_color};margin-bottom:4px;">β {status}</div>', unsafe_allow_html=True) |
|
|
| st.divider() |
|
|
| |
| add_col, rem_col = st.columns(2) |
| with add_col: |
| if len(st.session_state.streams) < MAX_STREAMS: |
| if st.button("οΌ Add stream", width='stretch'): |
| n = len(st.session_state.streams) |
| st.session_state.streams.append({ |
| "video_id": "", |
| "redis_key": f"chat_messages_{STREAM_NAMES[n].lower()}", |
| "label": f"Stream {STREAM_NAMES[n]}", |
| "proc": None, |
| }) |
| st.rerun() |
| with rem_col: |
| if len(st.session_state.streams) > 1: |
| if st.button("οΌ Remove last", width='stretch'): |
| removed = st.session_state.streams.pop() |
| proc = removed.get("proc") |
| if proc and proc.poll() is None: |
| proc.terminate() |
| st.rerun() |
|
|
| st.divider() |
|
|
| |
| st.markdown('<p style="font-size:0.68rem;font-weight:700;color:var(--accent);text-transform:uppercase;letter-spacing:0.1em;margin-bottom:8px;">Pinned Messages</p>', unsafe_allow_html=True) |
| pin_count = len(st.session_state.pinned_messages) |
| st.markdown(f'<div style="font-size:0.78rem;color:var(--text-3);">{pin_count} message{"s" if pin_count != 1 else ""} pinned</div>', unsafe_allow_html=True) |
| if pin_count > 0 and st.button("π Clear pins", width='stretch'): |
| st.session_state.pinned_messages = [] |
| st.rerun() |
| st.divider() |
|
|
| |
| st.markdown('<p style="font-size:0.68rem;font-weight:700;color:var(--accent);text-transform:uppercase;letter-spacing:0.1em;margin-bottom:8px;">Download Data</p>', unsafe_allow_html=True) |
| _active_streams = [s for s in st.session_state.streams if s.get("redis_key")] |
| if _active_streams: |
| for _s in _active_streams: |
| _rkey = _s["redis_key"] |
| _slabel = _s["label"] |
| _all_raws = r.lrange(_rkey, 0, -1) |
| _dl_rows = [] |
| for _raw in _all_raws: |
| try: |
| _dl_rows.append(json.loads(_raw)) |
| except Exception: |
| pass |
| if _dl_rows: |
| _dl_df = pd.DataFrame(_dl_rows) |
| _ts = datetime.now().strftime("%Y%m%d_%H%M%S") |
| _fname = f"livepulse_{_rkey}_{_ts}.csv" |
| _csv_bytes = _dl_df.to_csv(index=False).encode("utf-8") |
| st.download_button( |
| label=f"β¬ {_slabel} ({len(_dl_rows)} msgs)", |
| data=_csv_bytes, |
| file_name=_fname, |
| mime="text/csv", |
| key=f"dl_{_rkey}", |
| ) |
| |
| else: |
| st.markdown(f'<div style="font-size:0.72rem;color:var(--text-3);">{_slabel}: no data yet</div>', unsafe_allow_html=True) |
| else: |
| st.markdown('<div style="font-size:0.72rem;color:var(--text-3);">No active streams</div>', unsafe_allow_html=True) |
| st.divider() |
|
|
| |
| st.markdown('<p style="font-size:0.68rem;font-weight:700;color:var(--accent);text-transform:uppercase;letter-spacing:0.1em;margin-bottom:8px;">Export</p>', unsafe_allow_html=True) |
| st.markdown( |
| '<div style="font-size:0.7rem;color:var(--text-3);margin-bottom:6px;">' |
| '\u26a0\ufe0f Go to <b style="color:var(--accent-text);">Stats & Info</b> tab first, then click.</div>', |
| unsafe_allow_html=True |
| ) |
| import streamlit.components.v1 as _comp2 |
| _comp2.html(""" |
| <div style="padding:2px 0;"> |
| <button id="sidebarScreenshotBtn2" style=" |
| width:100%; background:linear-gradient(135deg,#7c3aed,#4f46e5); |
| color:#fff; border:none; border-radius:10px; padding:8px 12px; |
| font-size:13px; font-weight:600; cursor:pointer; |
| box-shadow:0 4px 16px rgba(124,58,237,0.3); transition:transform 0.2s;" |
| onmouseover="this.style.transform='translateY(-2px)'" |
| onmouseout="this.style.transform='translateY(0)'" |
| onclick="sidebarCapture2()"> |
| 📷 Download Stats as PDF |
| </button> |
| <div id="sidebarMsg2" style="margin-top:6px;font-size:11px;color:#94a3b8;text-align:center;"></div> |
| </div> |
| <script> |
| async function sidebarCapture2() { |
| const btn = document.getElementById('sidebarScreenshotBtn2'); |
| const msg = document.getElementById('sidebarMsg2'); |
| btn.disabled = true; btn.textContent = 'Capturing...'; |
| msg.textContent = 'Please wait...'; |
| try { |
| const target = window.parent.document.querySelector('[data-testid="stMain"]') |
| || window.parent.document.querySelector('.main') |
| || window.parent.document.body; |
| const canvas = await window.parent.html2canvas(target, { |
| scale:1.5, useCORS:true, allowTaint:true, |
| backgroundColor:'#07070f', logging:false, |
| windowWidth:target.scrollWidth, windowHeight:target.scrollHeight, |
| scrollX:0, scrollY:0, |
| }); |
| const imgData = canvas.toDataURL('image/png', 0.95); |
| const { jsPDF } = window.parent.jspdf; |
| const pdf = new jsPDF({ |
| orientation: canvas.width > canvas.height ? 'l' : 'p', |
| unit:'px', format:[canvas.width, canvas.height], compress:true, |
| }); |
| pdf.addImage(imgData, 'PNG', 0, 0, canvas.width, canvas.height); |
| const ts = new Date().toISOString().slice(0,16).replace('T','_').replace(':','-'); |
| pdf.save('livepulse_stats_' + ts + '.pdf'); |
| btn.textContent = 'Download Stats as PDF'; btn.disabled = false; |
| msg.textContent = 'Done!'; |
| setTimeout(() => { msg.textContent = ''; }, 3000); |
| } catch(e) { |
| btn.textContent = 'Download Stats as PDF'; btn.disabled = false; |
| msg.textContent = 'Error: ' + e.message; |
| } |
| } |
| function loadScript2(src, name) { |
| return new Promise(r => { |
| if (window.parent[name]) { r(); return; } |
| const s = window.parent.document.createElement('script'); |
| s.src = src; s.onload = r; |
| window.parent.document.head.appendChild(s); |
| }); |
| } |
| (async () => { |
| await loadScript2('https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js','html2canvas'); |
| await loadScript2('https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js','jspdf'); |
| })(); |
| </script> |
| """, height=75) |
| st.divider() |
|
|
| |
| st.markdown('<p style="font-size:0.68rem;font-weight:700;color:#ef4444;text-transform:uppercase;letter-spacing:0.1em;margin-bottom:8px;">Danger Zone</p>', unsafe_allow_html=True) |
| if st.button("π Clear all data", width='stretch'): |
| for s in st.session_state.streams: |
| r.delete(s["redis_key"]) |
| st.session_state.pinned_messages = [] |
| st.session_state.alert_dismissed = False |
| st.success("All stream data cleared.") |
| st.divider() |
| st.markdown( |
| '<div style="font-size:0.72rem;color:var(--text-3);text-align:center;line-height:1.6;">' |
| 'Theme follows Streamlit settings<br>' |
| '<span style="font-size:0.65rem;">β° β Settings β Theme</span>' |
| '</div>', unsafe_allow_html=True |
| ) |
|
|
|
|
| |
| _video_title = r.get("video_title") |
|
|
| |
| _all_titles = [] |
| for _si, _ss in enumerate(st.session_state.streams): |
| _st = _ss.get("video_title") or _ss.get("video_id") |
| _sk = _ss.get("redis_key", "") |
| _sp = _ss.get("proc") |
| _sr = _sp is not None and _sp.poll() is None |
| if _st and (r.llen(_sk) > 0 or _sr): |
| _all_titles.append(f"βΆ {_st}") |
| if _all_titles: |
| _subtitle = " Β· ".join(_all_titles) |
| else: |
| _subtitle = "Real-time sentiment Β· topic classification Β· engagement insights" |
|
|
| |
| _active_stream_pills = "" |
| for _hi, _hs in enumerate(st.session_state.streams): |
| _hkey = _hs.get("redis_key", "") |
| _hproc = _hs.get("proc") |
| _hrunning = _hproc is not None and _hproc.poll() is None |
| if r.llen(_hkey) > 0 or _hrunning: |
| _hcolor = STREAM_COLORS[_hi] |
| _hlabel = STREAM_NAMES[_hi] |
| _htitle = ( |
| _hs.get("video_title") |
| or _hs.get("video_id") |
| or _hkey |
| or f"Stream {_hlabel}" |
| ) |
| _hdot = f'<span style="display:inline-block;width:7px;height:7px;background:{"#22c55e" if _hrunning else "#ef4444"};border-radius:50%;margin-right:5px;vertical-align:middle;"></span>' |
| _active_stream_pills += ( |
| f'<span style="display:inline-flex;align-items:center;background:{_hcolor}18;' |
| f'border:1px solid {_hcolor}44;border-radius:20px;padding:3px 12px;' |
| f'font-size:0.75rem;font-weight:700;color:{_hcolor};margin-right:8px;">' |
| f'{_hdot}Stream {_hlabel} Β· {str(_htitle)[:22]}</span>' |
| ) |
|
|
| col_title, col_live = st.columns([7, 1]) |
| with col_title: |
| st.markdown( |
| '<div style="padding:8px 0 4px;">' |
| '<div style="font-size:2rem;font-weight:800;color:var(--text-1);letter-spacing:-0.04em;">YouTube Live Chat Analytics</div>' |
| f'<div style="font-size:1.25rem;color:var(--accent-text);font-weight:600;margin-top:6px;">{_subtitle}</div>' |
| + (f'<div style="margin-top:10px;">{_active_stream_pills}</div>' if _active_stream_pills else '') + |
| '</div>', unsafe_allow_html=True |
| ) |
| with col_live: |
| st.markdown( |
| '<div style="text-align:right;padding-top:22px;">' |
| '<span class="live-dot"></span>' |
| '<span style="font-size:0.78rem;color:var(--live);font-weight:700;letter-spacing:0.05em;">LIVE</span>' |
| '</div>', unsafe_allow_html=True |
| ) |
|
|
| st.divider() |
|
|
| |
| _streams_with_data = [ |
| s for s in st.session_state.streams |
| if r.llen(s.get("redis_key", "")) > 0 or (s.get("proc") is not None and s.get("proc").poll() is None) |
| ] |
| if len(_streams_with_data) > 1: |
| _ps_options = {} |
| for _pss in _streams_with_data: |
| _psi_real = st.session_state.streams.index(_pss) |
| _pst = _pss.get("video_title") or _pss.get("video_id") or _pss.get("redis_key") |
| _psl = f"Stream {STREAM_NAMES[_psi_real]} β {str(_pst)[:35]}" |
| _ps_options[_psl] = _pss["redis_key"] |
| _ps_col, _ = st.columns([2, 3]) |
| with _ps_col: |
| _selected_primary_label = st.selectbox( |
| "π Dashboard data source", |
| list(_ps_options.keys()), |
| key="primary_stream_select", |
| help="Switch which stream's data powers the main dashboard stats and charts" |
| ) |
| _primary_key = _ps_options[_selected_primary_label] |
| else: |
| _primary_key = st.session_state.streams[0]["redis_key"] |
|
|
| |
| _current_len = r.llen(_primary_key) |
| |
| _CUMULATIVE_CAP = 50_000 |
| all_data = load_stream_data(_primary_key, limit=_CUMULATIVE_CAP if _current_len > _CUMULATIVE_CAP else None) |
| data = all_data[-msg_limit:] if len(all_data) > msg_limit else all_data |
|
|
| if not all_data: |
| st.markdown( |
| '<div class="empty-state">' |
| '<div class="empty-icon">π</div>' |
| '<div class="empty-title">No messages yet</div>' |
| '<div class="empty-sub">Set a video ID in the sidebar, then click βΆ Start</div>' |
| '</div>', unsafe_allow_html=True |
| ) |
| if auto_refresh: |
| time.sleep(refresh_rate) |
| st.rerun() |
| st.stop() |
|
|
| df = pd.DataFrame(data) |
| all_df = pd.DataFrame(all_data) |
|
|
| df["sentiment"] = df["sentiment"].apply(clean_sentiment) |
| df["topic"] = df["topic"].apply(clean_topic) if "topic" in df.columns else "General" |
| all_df["sentiment"] = all_df["sentiment"].apply(clean_sentiment) |
| all_df["topic"] = all_df["topic"].apply(clean_topic) if "topic" in all_df.columns else "General" |
|
|
| |
| |
| _active_view = st.session_state.get("active_view", "π¬ Comments") |
| _show_stats = _active_view == "π Stats & Info" |
| _show_comments = _active_view == "π¬ Comments" |
|
|
| if _show_comments: |
| st.markdown('<div class="sec-hdr"><span class="sec-ttl">Live Chat Feed</span></div>', unsafe_allow_html=True) |
|
|
| |
| if st.session_state.pinned_messages: |
| st.markdown( |
| '<div class="sec-hdr"><span class="sec-ttl">π Pinned Messages</span>' |
| f'<span class="sec-pill">{len(st.session_state.pinned_messages)} pinned</span></div>', |
| unsafe_allow_html=True |
| ) |
| for _pidx, _pmsg in enumerate(st.session_state.pinned_messages): |
| _ps = _pmsg.get("sentiment", "Neutral") |
| _ps_color = SENT_COLORS.get(_ps, "#6b7280") |
| _pt_color = TOPIC_COLOR.get(_pmsg.get("topic", "General"), "#6b7280") |
| _pcol1, _pcol2 = st.columns([10, 1]) |
| with _pcol1: |
| st.markdown( |
| f'<div class="chat-card chat-pinned">' |
| f'<div class="chat-author">π {_pmsg.get("author", "Unknown")}</div>' |
| f'<div class="chat-text">{_pmsg.get("text", "")}</div>' |
| f'<div class="chat-badges">' |
| f'<span class="badge pin-badge">Pinned</span>' |
| f'<span class="badge" style="color:{_ps_color};">{_ps}</span>' |
| f'<span class="badge" style="color:{_pt_color};">{_pmsg.get("topic","General")}</span>' |
| f'<span class="badge">{_pmsg.get("time","")[:19]}</span>' |
| f'</div></div>', |
| unsafe_allow_html=True |
| ) |
| with _pcol2: |
| if st.button("\u2715", key=f"unpin_top_{_pidx}", width='stretch'): |
| st.session_state.pinned_messages.pop(_pidx) |
| st.rerun() |
| st.divider() |
|
|
| |
| _feed_stream_options = {} |
| for _fs in st.session_state.streams: |
| _fkey = _fs.get("redis_key", "") |
| _flen = r.llen(_fkey) |
| if _flen > 0: |
| _fidx = st.session_state.streams.index(_fs) |
| _flabel = f"Stream {STREAM_NAMES[_fidx]} β {_fs.get('video_id', _fkey)[:20]}" |
| _feed_stream_options[_flabel] = _fkey |
|
|
| _cf0, _cf1, _cf2, _cf3, _cf4 = st.columns([1, 1, 1, 1, 2]) |
| with _cf0: |
| if len(_feed_stream_options) > 1: |
| _selected_stream_label = st.selectbox( |
| "Stream", list(_feed_stream_options.keys()), key="feed_stream_select" |
| ) |
| _feed_key = _feed_stream_options[_selected_stream_label] |
| else: |
| _feed_key = st.session_state.streams[0]["redis_key"] |
| if _feed_stream_options: |
| st.markdown( |
| f'<div style="font-size:0.75rem;color:var(--text-2);padding-top:28px;">' |
| f'{list(_feed_stream_options.keys())[0]}</div>', |
| unsafe_allow_html=True |
| ) |
|
|
| if _feed_key == st.session_state.streams[0]["redis_key"]: |
| _feed_df = df.copy() |
| else: |
| _feed_raw = load_stream_data(_feed_key, limit=msg_limit) |
| _feed_df = pd.DataFrame(_feed_raw) if _feed_raw else pd.DataFrame() |
| if not _feed_df.empty: |
| _feed_df["sentiment"] = _feed_df["sentiment"].apply(clean_sentiment) |
| _feed_df["topic"] = _feed_df["topic"].apply(clean_topic) if "topic" in _feed_df.columns else "General" |
|
|
| with _cf1: |
| _sentiment_filter = st.selectbox("Sentiment", ["All", "Positive", "Neutral", "Negative"]) |
| with _cf2: |
| _topic_filter = st.selectbox("Topic", ["All"] + TOPIC_LABELS) |
| with _cf3: |
| _all_action_types = [ |
| "General Appreciation", "Testimonials", "Faculty Request", "Faculty Feedback", |
| "Content requests", "Content Feedback", "Academic / Lecture / Concept Doubts", |
| "Academic requests", "Study Materials, Deliverables & Learning Resources", |
| "Access & Support", "Batch details / structure / offerings (incl faculty)", |
| "Schedule & logistics (Batch)", "Information- Exam", "Information- Post Exam", |
| "Eligibility & audience fit - Can I take this?", "Suitability & Sufficiency (Is this enough?)", |
| "Guidance- What should I take/do?", "Language Request", "Language medium", |
| "Pricing, discounts, scholarships, offer validity", "Fees + Financial Queries", |
| "Product/feature requests (non-content)", "Offline expansion & event-city requests", |
| "Offers + Events", "General Feedback", "Others", "N/A", |
| ] |
| _action_type_filter = st.selectbox("Action Type", ["All"] + _all_action_types) |
| with _cf4: |
| _search_term = st.text_input("Search messages", placeholder="Filter by keyword...") |
|
|
| _filtered = _feed_df.copy() if not _feed_df.empty else pd.DataFrame() |
| _any_filter = ( |
| _sentiment_filter != "All" |
| or _topic_filter != "All" |
| or _action_type_filter != "All" |
| or bool(_search_term) |
| ) |
|
|
| if _any_filter: |
| _full_raw = load_stream_data(_feed_key) |
| if _full_raw: |
| _full_df = pd.DataFrame(_full_raw) |
| _full_df["sentiment"] = _full_df["sentiment"].apply(clean_sentiment) |
| _full_df["topic"] = _full_df["topic"].apply(clean_topic) if "topic" in _full_df.columns else "General" |
| _filtered = _full_df.copy() |
| if _sentiment_filter != "All": |
| _filtered = _filtered[_filtered["sentiment"] == _sentiment_filter] |
| if _topic_filter != "All": |
| _filtered = _filtered[_filtered["topic"] == _topic_filter] |
| if _action_type_filter != "All": |
| if "action_type" in _filtered.columns: |
| _filtered = _filtered[_filtered["action_type"] == _action_type_filter] |
| if _search_term: |
| _filtered = _filtered[_filtered["text"].str.contains(_search_term, case=False, na=False)] |
| if len(_filtered) > msg_limit: |
| _filtered = _filtered.iloc[-msg_limit:] |
| else: |
| _filtered = pd.DataFrame() |
| _total_scanned = len(_full_raw) if _full_raw else 0 |
| else: |
| if not _filtered.empty: |
| if _sentiment_filter != "All": |
| _filtered = _filtered[_filtered["sentiment"] == _sentiment_filter] |
| if _topic_filter != "All": |
| _filtered = _filtered[_filtered["topic"] == _topic_filter] |
| if _action_type_filter != "All": |
| if "action_type" in _filtered.columns: |
| _filtered = _filtered[_filtered["action_type"] == _action_type_filter] |
| if _search_term: |
| _filtered = _filtered[_filtered["text"].str.contains(_search_term, case=False, na=False)] |
| _total_scanned = len(_feed_df) |
|
|
| _feed_hdr, _feed_dl = st.columns([3, 1]) |
| with _feed_hdr: |
| if _any_filter: |
| st.markdown( |
| f'<div style="font-size:0.78rem;color:var(--text-3);margin-bottom:12px;">' |
| f'Showing {len(_filtered)} matching messages (scanned all {_total_scanned}, capped at {msg_limit})</div>', |
| unsafe_allow_html=True |
| ) |
| else: |
| st.markdown( |
| f'<div style="font-size:0.78rem;color:var(--text-3);margin-bottom:12px;">' |
| f'Showing {len(_filtered)} of {len(_feed_df)} messages</div>', |
| unsafe_allow_html=True |
| ) |
| with _feed_dl: |
| if not _filtered.empty: |
| _export_cols = [c for c in ["author", "text", "sentiment", "confidence", "topic", "time"] if c in _filtered.columns] |
| csv_download(_filtered[_export_cols], "Download Feed CSV", "chat_feed.csv") |
|
|
| _SENT_ICON = {"Positive": "π’", "Negative": "π΄", "Neutral": "π‘"} |
| _pinned_texts = {m.get("text", "") for m in st.session_state.pinned_messages} |
|
|
| for _i, (_, _row) in enumerate(_filtered.iloc[::-1].iterrows()): |
| _s = _row.get("sentiment", "Neutral") |
| _conf_pct = int(_row.get("confidence", 0) * 100) |
| _topic = clean_topic(_row.get("topic", "General")) |
| _t_color = TOPIC_COLOR.get(_topic, "#6b7280") |
| _s_color = SENT_COLORS.get(_s, "#6b7280") |
| _s_icon = _SENT_ICON.get(_s, "βͺ") |
| _conf_color = "#22c55e" if _conf_pct >= 70 else "#eab308" if _conf_pct >= 40 else "#ef4444" |
| _msg_text = _row.get("text", "") |
| import re as _re2 |
| _display_text = _re2.sub(r":[a-zA-Z0-9_\-]+:", "", _msg_text).strip() or _msg_text |
| _is_pinned = _msg_text in _pinned_texts |
| _action_type = _row.get("action_type", "N/A") or "N/A" |
| _card_class = f"chat-card chat-{_s.lower()}" + (" chat-pinned" if _is_pinned else "") |
|
|
| _msg_col, _pin_col = st.columns([11, 1]) |
| with _msg_col: |
| _ab = ( |
| f'<span class="badge" style="color:#a78bfa;border-color:#a78bfa33;">π· {_action_type}</span>' |
| if _action_type not in ("N/A", "", None) else "" |
| ) |
| st.markdown( |
| f'<div class="{_card_class}">' |
| f'<div class="chat-author">{_s_icon} {_row.get("author", "Unknown")}' |
| + (' <span style="font-size:0.7rem;color:#eab308;">π</span>' if _is_pinned else '') + |
| f'</div>' |
| f'<div class="chat-text">{_display_text}</div>' |
| f'<div class="chat-badges">' |
| f'<span class="badge" style="color:{_s_color};border-color:{_s_color}33;">{_s}</span>' |
| f'<span class="badge" style="color:{_conf_color};">Confidence: {_conf_pct}%</span>' |
| f'<span class="badge" style="color:{_t_color};border-color:{_t_color}33;">{_topic}</span>' |
| f'{_ab}' |
| f'</div></div>', |
| unsafe_allow_html=True |
| ) |
| with _pin_col: |
| if _is_pinned: |
| if st.button("π", key=f"unpin_feed_{_i}", help="Unpin this message"): |
| st.session_state.pinned_messages = [ |
| m for m in st.session_state.pinned_messages if m.get("text") != _msg_text |
| ] |
| st.rerun() |
| else: |
| if st.button("π", key=f"pin_{_i}", help="Pin this message"): |
| _msg_dict = _row.to_dict() |
| if _msg_dict not in st.session_state.pinned_messages: |
| st.session_state.pinned_messages.append(_msg_dict) |
| st.rerun() |
|
|
| if auto_refresh: |
| time.sleep(refresh_rate) |
| st.rerun() |
| st.stop() |
|
|
| |
| if alert_enabled: |
| alert = check_alert(all_df, threshold=alert_threshold, window=alert_window) |
| total_now = len(all_df) |
| if total_now != st.session_state.last_alert_count: |
| st.session_state.last_alert_count = total_now |
| if alert: |
| st.session_state.alert_dismissed = False |
|
|
| if alert and not st.session_state.alert_dismissed: |
| a1, a2 = st.columns([8, 1]) |
| with a1: |
| st.markdown( |
| f'<div class="alert-banner">' |
| f'<span class="alert-icon">π¨</span>' |
| f'<div>' |
| f'<div class="alert-text">Negative sentiment spike β {alert["neg_ratio"]*100:.0f}% negative in last {alert["window"]} messages</div>' |
| f'<div class="alert-sub">{alert["count"]} of {alert["window"]} messages are negative. Consider moderating.</div>' |
| f'</div></div>', |
| unsafe_allow_html=True |
| ) |
| with a2: |
| if st.button("β Dismiss", key="dismiss_alert"): |
| st.session_state.alert_dismissed = True |
| st.rerun() |
|
|
| if spam_alert_on: |
| spam_alert = check_spam_alert(all_df, threshold=spam_threshold, window=alert_window) |
| if spam_alert and not st.session_state.get("spam_dismissed", False): |
| s1, s2 = st.columns([8, 1]) |
| with s1: |
| st.markdown( |
| f'<div class="spam-alert">' |
| f'<span class="alert-icon">π‘οΈ</span>' |
| f'<div>' |
| f'<div class="spam-alert-text">Spam surge detected β {spam_alert["spam_ratio"]*100:.0f}% spam in last {spam_alert["window"]} messages</div>' |
| f'<div class="spam-alert-sub">{spam_alert["count"]} spam messages detected. Chat may be under flood attack.</div>' |
| f'</div></div>', |
| unsafe_allow_html=True |
| ) |
| with s2: |
| if st.button("β", key="dismiss_spam"): |
| st.session_state.spam_dismissed = True |
| st.rerun() |
| elif not spam_alert: |
| st.session_state.spam_dismissed = False |
|
|
| |
| all_counts = all_df["sentiment"].value_counts().to_dict() |
| c_pos = all_counts.get("Positive", 0) |
| c_neu = all_counts.get("Neutral", 0) |
| c_neg = all_counts.get("Negative", 0) |
| c_total = max(c_pos + c_neu + c_neg, 1) |
|
|
| |
| velocity = compute_velocity(json.dumps([{"sentiment": m.get("sentiment","Neutral")} for m in all_data])) |
|
|
| st.markdown( |
| '<div class="sec-hdr"><span class="sec-ttl">Cumulative Sentiment</span><span class="sec-pill">All Time</span></div>', |
| unsafe_allow_html=True |
| ) |
|
|
| v1, v2, v3, v4, v5 = st.columns([1, 1, 1, 1, 1]) |
| with v1: |
| st.markdown( |
| f'<div class="stat-card"><div class="stat-accent" style="background:linear-gradient(90deg,#22c55e,#16a34a);"></div>' |
| f'<div class="stat-number" style="color:#22c55e;">{c_pos}</div><div class="stat-label">Positive</div><div class="stat-sub">{c_pos/c_total*100:.1f}% of total</div></div>', |
| unsafe_allow_html=True |
| ) |
| with v2: |
| st.markdown( |
| f'<div class="stat-card"><div class="stat-accent" style="background:linear-gradient(90deg,#eab308,#ca8a04);"></div>' |
| f'<div class="stat-number" style="color:#eab308;">{c_neu}</div><div class="stat-label">Neutral</div><div class="stat-sub">{c_neu/c_total*100:.1f}% of total</div></div>', |
| unsafe_allow_html=True |
| ) |
| with v3: |
| st.markdown( |
| f'<div class="stat-card"><div class="stat-accent" style="background:linear-gradient(90deg,#ef4444,#dc2626);"></div>' |
| f'<div class="stat-number" style="color:#ef4444;">{c_neg}</div><div class="stat-label">Negative</div><div class="stat-sub">{c_neg/c_total*100:.1f}% of total</div></div>', |
| unsafe_allow_html=True |
| ) |
| with v4: |
| st.markdown( |
| f'<div class="stat-card"><div class="stat-accent" style="background:linear-gradient(90deg,#7c3aed,#4f46e5);"></div>' |
| f'<div class="stat-number" style="color:var(--accent-text);">{c_total}</div><div class="stat-label">Total</div><div class="stat-sub">all time</div></div>', |
| unsafe_allow_html=True |
| ) |
| with v5: |
| |
| vc = velocity["color"] |
| st.markdown( |
| f'<div class="velocity-card" style="border-color:{vc}44;">' |
| f'<div class="velocity-arrow" style="color:{vc};">{velocity["direction"]}</div>' |
| f'<div>' |
| f'<div class="velocity-val" style="color:{vc};">{velocity["label"]}</div>' |
| f'<div class="velocity-label">Sentiment Velocity<br>' |
| f'<span style="color:{vc};">{velocity["delta"]:+.0%} pos shift</span></div>' |
| f'</div></div>', |
| unsafe_allow_html=True |
| ) |
|
|
| |
| st.divider() |
| st.markdown( |
| '<div class="sec-hdr"><span class="sec-ttl">Cumulative Topic</span><span class="sec-pill">All Time</span></div>', |
| unsafe_allow_html=True |
| ) |
| _topic_colors_list = ["#f59e0b", "#3b82f6", "#ec4899", "#ef4444", "#6b7280", "#10b981"] |
| _ct_cols = st.columns(len(TOPIC_LABELS)) |
| for _ci, (_lbl, _clr) in enumerate(zip(TOPIC_LABELS, _topic_colors_list)): |
| _cnt = int((all_df["topic"] == _lbl).sum()) if "topic" in all_df.columns else 0 |
| _pct = _cnt / max(c_total, 1) * 100 |
| with _ct_cols[_ci]: |
| st.markdown( |
| f'<div class="stat-card"><div class="stat-accent" style="background:{_clr};"></div>' |
| f'<div class="stat-number" style="color:{_clr};font-size:1.8rem;">{_cnt}</div>' |
| f'<div class="stat-label">{_lbl}</div>' |
| f'<div class="stat-sub">{_pct:.1f}% of msgs</div></div>', |
| unsafe_allow_html=True |
| ) |
|
|
|
|
| |
| _eng_json = json.dumps([{"sentiment": m.get("sentiment","Neutral"), "topic": m.get("topic","General"), "time": m.get("time","")} for m in all_data]) |
| eng = compute_engagement(_eng_json) |
|
|
| st.divider() |
| st.markdown( |
| '<div class="sec-hdr"><span class="sec-ttl">Engagement Score</span><span class="sec-pill">Live</span></div>', |
| unsafe_allow_html=True |
| ) |
| ec1, ec2, ec3, ec4 = st.columns([2, 1, 1, 1]) |
| with ec1: |
| score_color = "#22c55e" if eng["score"] >= 70 else "#eab308" if eng["score"] >= 40 else "#ef4444" |
| bar_w = eng["score"] |
| st.markdown( |
| f'<div class="engage-card" style="border-color:{score_color}44;">' |
| f'<div class="engage-score" style="color:{score_color};">{eng["score"]}</div>' |
| f'<div class="engage-label">Engagement Score / 100 \u2014 {eng["grade"]}</div>' |
| f'<div class="engage-bar-bg"><div class="engage-bar-fill" style="width:{bar_w}%;background:{score_color};"></div></div>' |
| f'<div class="engage-breakdown">' |
| f'<div class="engage-item">Msg rate <span>{eng["rate"]}/min</span></div>' |
| f'<div class="engage-item">Positive <span>{eng["pos_ratio"]*100:.0f}%</span></div>' |
| f'<div class="engage-item">Questions <span>{eng["q_density"]*100:.0f}%</span></div>' |
| f'</div></div>', |
| unsafe_allow_html=True |
| ) |
| with ec2: |
| st.metric("Msgs/min", f"{eng['rate']:.1f}") |
| with ec3: |
| st.metric("Positive ratio", f"{eng['pos_ratio']*100:.0f}%") |
| with ec4: |
| st.metric("Question density", f"{eng['q_density']*100:.0f}%") |
|
|
| |
| st.divider() |
| counts = df["sentiment"].value_counts().to_dict() |
| pos = counts.get("Positive", 0) |
| neu = counts.get("Neutral", 0) |
| neg = counts.get("Negative", 0) |
| total = max(pos + neu + neg, 1) |
|
|
| st.markdown( |
| f'<div class="sec-hdr"><span class="sec-ttl">Window Snapshot</span><span class="sec-pill">Last {msg_limit} msgs</span></div>', |
| unsafe_allow_html=True |
| ) |
| c1, c2, c3, c4 = st.columns(4) |
| c1.metric("Messages", total) |
| c2.metric("Positive", pos, f"{pos/total*100:.1f}%") |
| c3.metric("Neutral", neu, f"{neu/total*100:.1f}%") |
| c4.metric("Negative", neg, f"{neg/total*100:.1f}%") |
|
|
| |
| st.divider() |
| col_s1, col_s2, col_t1, col_t2 = st.columns(4) |
|
|
| with col_s1: |
| st.markdown('<div class="chart-wrap">', unsafe_allow_html=True) |
| st.markdown('<div class="chart-title">Sentiment Distribution</div><div class="chart-sub">All-time message count by sentiment class</div>', unsafe_allow_html=True) |
| fig_bar = go.Figure(go.Bar( |
| x=["Positive", "Neutral", "Negative"], |
| y=[c_pos, c_neu, c_neg], |
| marker_color=["#22c55e", "#eab308", "#ef4444"], |
| marker_line_width=0, |
| text=[c_pos, c_neu, c_neg], |
| textposition="outside", |
| textfont=dict(size=12), |
| hovertemplate="<b>%{x}</b><br>Count: %{y}<extra></extra>", |
| )) |
| fig_bar.update_layout(**plotly_layout(260)) |
| st.plotly_chart(fig_bar, width='stretch', config={"displayModeBar": False}) |
| bar_hdr, bar_dl = st.columns([1, 1]) |
| with bar_hdr: |
| show_bar_data = st.checkbox("View data", key="show_bar") |
| with bar_dl: |
| bar_df = pd.DataFrame({"Sentiment": ["Positive", "Neutral", "Negative"], "Count": [c_pos, c_neu, c_neg]}) |
| csv_download(bar_df, "Download CSV", "sentiment_distribution.csv") |
| if show_bar_data: |
| st.dataframe(bar_df, width='stretch', hide_index=True) |
| st.markdown('</div>', unsafe_allow_html=True) |
|
|
| with col_s2: |
| st.markdown('<div class="chart-wrap">', unsafe_allow_html=True) |
| st.markdown('<div class="chart-title">Sentiment Donut</div><div class="chart-sub">All-time proportional share per class</div>', unsafe_allow_html=True) |
| fig_pie = go.Figure(go.Pie( |
| labels=["Positive", "Neutral", "Negative"], |
| values=[c_pos, c_neu, c_neg], |
| marker_colors=["#22c55e", "#eab308", "#ef4444"], |
| hole=0.58, |
| textinfo="percent", |
| hovertemplate="<b>%{label}</b><br>%{value} messages (%{percent})<extra></extra>", |
| )) |
| fig_pie.update_layout( |
| **{**plotly_layout(260), |
| "showlegend": True, |
| "legend": dict(orientation="h", y=-0.08, font=dict(size=11, color="#f1f5f9"))} |
| ) |
| st.plotly_chart(fig_pie, width='stretch', config={"displayModeBar": False}) |
| pie_hdr, pie_dl = st.columns([1, 1]) |
| with pie_hdr: |
| show_pie_data = st.checkbox("View data", key="show_pie") |
| with pie_dl: |
| pie_df = pd.DataFrame({ |
| "Sentiment": ["Positive", "Neutral", "Negative"], |
| "Count": [c_pos, c_neu, c_neg], |
| "Percentage": [f"{c_pos/c_total*100:.1f}%", f"{c_neu/c_total*100:.1f}%", f"{c_neg/c_total*100:.1f}%"] |
| }) |
| csv_download(pie_df, "Download CSV", "sentiment_breakdown.csv") |
| if show_pie_data: |
| st.dataframe(pie_df, width='stretch', hide_index=True) |
| st.markdown('</div>', unsafe_allow_html=True) |
|
|
| with col_t1: |
| st.markdown('<div class="chart-wrap">', unsafe_allow_html=True) |
| st.markdown('<div class="chart-title">Topic Distribution</div><div class="chart-sub">Message count by topic class</div>', unsafe_allow_html=True) |
| _tc_vals = [int((all_df["topic"] == l).sum()) if "topic" in all_df.columns else 0 for l in TOPIC_LABELS] |
| _tc_colors = ["#f59e0b", "#3b82f6", "#ec4899", "#ef4444", "#6b7280", "#10b981"] |
| fig_tbar = go.Figure(go.Bar( |
| x=TOPIC_LABELS, |
| y=_tc_vals, |
| marker_color=_tc_colors, |
| marker_line_width=0, |
| text=_tc_vals, |
| textposition="outside", |
| textfont=dict(size=11), |
| hovertemplate="<b>%{x}</b><br>Count: %{y}<extra></extra>", |
| )) |
| _tbar_layout = plotly_layout(260) |
| _tbar_layout["xaxis"]["tickfont"] = dict(size=9) |
| fig_tbar.update_layout(**_tbar_layout) |
| st.plotly_chart(fig_tbar, width='stretch', config={"displayModeBar": False}) |
| st.markdown('</div>', unsafe_allow_html=True) |
|
|
| with col_t2: |
| st.markdown('<div class="chart-wrap">', unsafe_allow_html=True) |
| st.markdown('<div class="chart-title">Topic Donut</div><div class="chart-sub">Proportional share per topic</div>', unsafe_allow_html=True) |
| fig_tpie = go.Figure(go.Pie( |
| labels=TOPIC_LABELS, |
| values=_tc_vals, |
| marker_colors=_tc_colors, |
| hole=0.58, |
| textinfo="percent", |
| hovertemplate="<b>%{label}</b><br>%{value} messages (%{percent})<extra></extra>", |
| )) |
| fig_tpie.update_layout( |
| **{**plotly_layout(260), |
| "showlegend": True, |
| "legend": dict(orientation="h", y=-0.08, font=dict(size=10, color="#f1f5f9"))} |
| ) |
| st.plotly_chart(fig_tpie, width='stretch', config={"displayModeBar": False}) |
| st.markdown('</div>', unsafe_allow_html=True) |
|
|
| |
| st.divider() |
| st.markdown( |
| '<div class="sec-hdr"><span class="sec-ttl">Sentiment Heatmap</span><span class="sec-pill">Over Time</span></div>', |
| unsafe_allow_html=True |
| ) |
| heatmap_data = build_heatmap_data(json.dumps([{"time": m.get("time",""), "sentiment": m.get("sentiment","Neutral")} for m in all_data]), bucket_minutes=1) |
|
|
| if not heatmap_data.empty: |
| st.markdown('<div class="chart-wrap">', unsafe_allow_html=True) |
| st.markdown('<div class="chart-title">Sentiment Over Time</div><div class="chart-sub">Message volume per sentiment per minute bucket</div>', unsafe_allow_html=True) |
|
|
| fig_heat = go.Figure() |
| for sent, color in [("Positive", "#22c55e"), ("Neutral", "#eab308"), ("Negative", "#ef4444")]: |
| fig_heat.add_trace(go.Scatter( |
| x=heatmap_data["bucket"], |
| y=heatmap_data[sent], |
| name=sent, |
| mode="lines+markers", |
| line=dict(color=color, width=2), |
| marker=dict(size=4), |
| hovertemplate=f"<b>{sent}</b><br>%{{x}}<br>Count: %{{y}}<extra></extra>", |
| )) |
|
|
| layout = plotly_layout(220) |
| layout["showlegend"] = True |
| layout["legend"] = dict(orientation="h", y=1.08, font=dict(size=11)) |
| layout["xaxis"]["tickformat"] = "%H:%M" |
| fig_heat.update_layout(**layout) |
| st.plotly_chart(fig_heat, width='stretch', config={"displayModeBar": False}) |
|
|
| heat_hdr, heat_dl = st.columns([1, 1]) |
| with heat_hdr: |
| show_heat_data = st.checkbox("View data", key="show_heat") |
| with heat_dl: |
| csv_download(heatmap_data.rename(columns={"bucket": "time_bucket"}), "Download CSV", "sentiment_heatmap.csv") |
| if show_heat_data: |
| st.dataframe(heatmap_data.rename(columns={"bucket": "time_bucket"}), width='stretch', hide_index=True) |
| st.markdown('</div>', unsafe_allow_html=True) |
| else: |
| st.info("Not enough timestamped data for heatmap yet.") |
|
|
| |
| st.divider() |
| st.markdown( |
| '<div class="sec-hdr"><span class="sec-ttl">Topic Distribution</span><span class="sec-pill">All Time</span></div>', |
| unsafe_allow_html=True |
| ) |
|
|
| topic_counts = { |
| label: int((all_df["topic"] == label).sum()) |
| for label in TOPIC_LABELS |
| } |
|
|
| pills = '<div class="topic-grid">' |
| for label in TOPIC_LABELS: |
| color = TOPIC_COLOR[label] |
| count = topic_counts[label] |
| pills += ( |
| f'<div class="topic-pill" style="border:1px solid {color}44;">' |
| f'<div class="topic-count" style="color:{color};">{count}</div>' |
| f'<div class="topic-name">{label}</div>' |
| f'</div>' |
| ) |
| pills += '</div>' |
| st.markdown(pills, unsafe_allow_html=True) |
|
|
| st.markdown('<div class="chart-wrap">', unsafe_allow_html=True) |
| st.markdown('<div class="chart-title">Topic Breakdown</div><div class="chart-sub">All-time message count per topic category</div>', unsafe_allow_html=True) |
| fig_topic = go.Figure(go.Bar( |
| x=TOPIC_LABELS, |
| y=[topic_counts[l] for l in TOPIC_LABELS], |
| marker_color=[TOPIC_COLOR[l] for l in TOPIC_LABELS], |
| marker_line_width=0, |
| text=[topic_counts[l] for l in TOPIC_LABELS], |
| textposition="outside", |
| textfont=dict(size=11), |
| hovertemplate="<b>%{x}</b><br>Count: %{y}<extra></extra>", |
| )) |
| fig_topic.update_layout(**plotly_layout(250)) |
| st.plotly_chart(fig_topic, width='stretch', config={"displayModeBar": False}) |
| topic_hdr, topic_dl = st.columns([1, 1]) |
| with topic_hdr: |
| show_topic_data = st.checkbox("View data", key="show_topic") |
| with topic_dl: |
| topic_df = pd.DataFrame({"Topic": TOPIC_LABELS, "Count": [topic_counts[l] for l in TOPIC_LABELS]}) |
| csv_download(topic_df, "Download CSV", "topic_distribution.csv") |
| if show_topic_data: |
| st.dataframe(topic_df, width='stretch', hide_index=True) |
| st.markdown('</div>', unsafe_allow_html=True) |
|
|
| |
| st.markdown('<div class="chart-wrap" style="margin-top:16px;">', unsafe_allow_html=True) |
| st.markdown('<div class="chart-title">Sentiment by Topic</div><div class="chart-sub">% positive / neutral / negative within each topic category</div>', unsafe_allow_html=True) |
| _topic_sent_data = [] |
| for _lbl in TOPIC_LABELS: |
| _mask = all_df["topic"] == _lbl |
| _total = int(_mask.sum()) |
| if _total == 0: |
| _topic_sent_data.append({"topic": _lbl, "pos": 0, "neu": 0, "neg": 0}) |
| continue |
| _sub = all_df[_mask] |
| _topic_sent_data.append({ |
| "topic": _lbl, |
| "pos": round((_sub["sentiment"] == "Positive").sum() / _total * 100), |
| "neu": round((_sub["sentiment"] == "Neutral").sum() / _total * 100), |
| "neg": round((_sub["sentiment"] == "Negative").sum() / _total * 100), |
| }) |
| fig_ts = go.Figure() |
| for _sk, _sl, _sc in [("neg", "Neg", "#ef4444"), ("neu", "Neu", "#eab308"), ("pos", "Pos", "#22c55e")]: |
| fig_ts.add_trace(go.Bar( |
| y=[d["topic"] for d in _topic_sent_data], |
| x=[d[_sk] for d in _topic_sent_data], |
| name=_sl, |
| orientation="h", |
| marker_color=_sc, |
| hovertemplate="<b>%{y}</b><br>" + _sl + ": %{x}%<extra></extra>", |
| )) |
| _layout_ts = plotly_layout(260) |
| _layout_ts["barmode"] = "stack" |
| _layout_ts["showlegend"] = True |
| _layout_ts["legend"] = dict(orientation="h", y=1.08, x=0.35, font=dict(size=11)) |
| _layout_ts["xaxis"]["range"] = [0, 100] |
| _layout_ts["xaxis"]["ticksuffix"] = "%" |
| _layout_ts["yaxis"]["autorange"] = "reversed" |
| fig_ts.update_layout(**_layout_ts) |
| st.plotly_chart(fig_ts, width='stretch', config={"displayModeBar": False}) |
| st.markdown('</div>', unsafe_allow_html=True) |
|
|
|
|
| |
| st.divider() |
| st.markdown( |
| '<div class="sec-hdr"><span class="sec-ttl">Action Type Analysis</span><span class="sec-pill">Last 100 msgs</span></div>', |
| unsafe_allow_html=True |
| ) |
|
|
| |
| _QUESTION_ACTIONS = [ |
| "Access & Support", |
| "Academic / Lecture / Concept Doubts", |
| "Study Materials, Deliverables & Learning Resources", |
| "Batch details / structure / offerings (incl faculty)", |
| "Schedule & logistics (Batch)", |
| "Guidance- What should I take/do?", |
| "Suitability & Sufficiency (Is this enough?)", |
| "Eligibility & audience fit - Can I take this?", |
| "Information- Exam", |
| "Information- Post Exam", |
| ] |
| _REQUEST_ACTIONS = [ |
| "Content requests", |
| "Content Feedback", |
| "Faculty Request", |
| "Faculty Feedback", |
| "Academic requests", |
| "Language Request", |
| "Language medium", |
| "Product/feature requests (non-content)", |
| "Offline expansion & event-city requests", |
| "General Feedback", |
| "Others", |
| ] |
| _SHORT_ACTION = { |
| "Access & Support": "Access & Support", |
| "Academic / Lecture / Concept Doubts": "Academic Doubts", |
| "Study Materials, Deliverables & Learning Resources": "Study Materials & Learning Resources", |
| "Batch details / structure / offerings (incl faculty)": "Batch Details & Offerings", |
| "Schedule & logistics (Batch)": "Batch Schedule & Logistics", |
| "Guidance- What should I take/do?": "Guidance (What Should I Take/Do?)", |
| "Suitability & Sufficiency (Is this enough?)": "Suitability & Sufficiency (Is This Enough?)", |
| "Eligibility & audience fit - Can I take this?": "Eligibility (Can I Take This?)", |
| "Information- Exam": "Exam Information", |
| "Information- Post Exam": "Post Exam Information", |
| "Content requests": "Content requests", |
| "Content Feedback": "Content Feedback", |
| "Faculty Request": "Faculty Request", |
| "Faculty Feedback": "Faculty Feedback", |
| "Academic requests": "Academic requests", |
| "Language Request": "Language Request", |
| "Language medium": "Language Medium", |
| "Product/feature requests (non-content)": "Non Content Product Requests", |
| "Offline expansion & event-city requests": "Offline Expansion & Event Requests", |
| "General Feedback": "General Feedback", |
| "Others": "Others", |
| } |
|
|
| |
| _at_counts: dict[str, int] = {} |
| if "action_type" in all_df.columns: |
| for _at in _QUESTION_ACTIONS + _REQUEST_ACTIONS: |
| _at_counts[_at] = int((all_df.tail(100)["action_type"] == _at).sum()) |
| else: |
| _at_counts = {_at: 0 for _at in _QUESTION_ACTIONS + _REQUEST_ACTIONS} |
|
|
| _q_data = {k: _at_counts.get(k, 0) for k in _QUESTION_ACTIONS if _at_counts.get(k, 0) > 0} |
| _rf_data = {k: _at_counts.get(k, 0) for k in _REQUEST_ACTIONS if _at_counts.get(k, 0) > 0} |
| _q_total = sum(_q_data.values()) |
| _rf_total = sum(_rf_data.values()) |
|
|
| _at_col1, _at_col2 = st.columns(2) |
|
|
| with _at_col1: |
| st.markdown( |
| f'<div class="chart-wrap"><div class="chart-title">Type of Questions</div>' |
| f'<div class="chart-sub">({_q_total} comments)</div>', |
| unsafe_allow_html=True |
| ) |
| if _q_data: |
| _q_sorted = sorted(_q_data.items(), key=lambda x: x[1], reverse=True) |
| _q_labels = [_SHORT_ACTION.get(k, k) for k, _ in _q_sorted] |
| _q_vals = [v for _, v in _q_sorted] |
| fig_q = go.Figure(go.Bar( |
| x=_q_labels, y=_q_vals, |
| marker_color="#4a90d9", |
| marker_line_width=0, |
| text=_q_vals, textposition="outside", |
| textfont=dict(size=11, color="#fff"), |
| hovertemplate="<b>%{x}</b><br>Comments: %{y}<extra></extra>", |
| )) |
| fig_q.update_layout(**plotly_layout(280)) |
| st.plotly_chart(fig_q, width='stretch', config={"displayModeBar": False}) |
| else: |
| st.markdown('<div style="text-align:center;padding:40px;color:var(--text-3);">No data yet</div>', unsafe_allow_html=True) |
| st.markdown('</div>', unsafe_allow_html=True) |
|
|
| with _at_col2: |
| st.markdown( |
| f'<div class="chart-wrap"><div class="chart-title">Type of Requests & Feedback</div>' |
| f'<div class="chart-sub">({_rf_total} comments)</div>', |
| unsafe_allow_html=True |
| ) |
| if _rf_data: |
| _rf_sorted = sorted(_rf_data.items(), key=lambda x: x[1], reverse=True) |
| _rf_labels = [_SHORT_ACTION.get(k, k) for k, _ in _rf_sorted] |
| _rf_vals = [v for _, v in _rf_sorted] |
| fig_rf = go.Figure(go.Bar( |
| x=_rf_labels, y=_rf_vals, |
| marker_color="#f5a623", |
| marker_line_width=0, |
| text=_rf_vals, textposition="outside", |
| textfont=dict(size=11, color="#fff"), |
| hovertemplate="<b>%{x}</b><br>Comments: %{y}<extra></extra>", |
| )) |
| fig_rf.update_layout(**plotly_layout(280)) |
| st.plotly_chart(fig_rf, width='stretch', config={"displayModeBar": False}) |
| else: |
| st.markdown('<div style="text-align:center;padding:40px;color:var(--text-3);">No data yet</div>', unsafe_allow_html=True) |
| st.markdown('</div>', unsafe_allow_html=True) |
|
|
| |
| _top5_col1, _top5_col2 = st.columns(2) |
|
|
| def _hbar_rows_html(data: dict, color: str, max_val: int) -> str: |
| html = "" |
| for cat, count in sorted(data.items(), key=lambda x: x[1], reverse=True)[:5]: |
| pct = round(count / max(max_val, 1) * 100) |
| label = _SHORT_ACTION.get(cat, cat) |
| html += ( |
| f'<div style="display:flex;align-items:center;gap:10px;margin-bottom:10px;">' |
| f'<div style="width:170px;font-size:12px;text-align:right;opacity:0.85;line-height:1.3;">{label}</div>' |
| f'<div style="flex:1;height:22px;border-radius:4px;background:rgba(255,255,255,0.06);overflow:hidden;">' |
| f'<div style="width:{pct}%;height:100%;background:{color};border-radius:4px;' |
| f'display:flex;align-items:center;justify-content:flex-end;padding-right:6px;' |
| f'font-size:11px;font-weight:700;color:#fff;">{pct}%</div>' |
| f'</div></div>' |
| ) |
| return html |
|
|
| with _top5_col1: |
| st.markdown( |
| '<div class="chart-wrap">' |
| '<div class="chart-title">Top 5 <span style="color:#60a5fa;">Questions</span> Students Ask</div>' |
| '<div class="chart-sub">Type of action count for Questions across tagged videos.</div>', |
| unsafe_allow_html=True |
| ) |
| if _q_data: |
| st.markdown(_hbar_rows_html(_q_data, "#f87171", max(_q_data.values(), default=1)), unsafe_allow_html=True) |
| else: |
| st.markdown('<div style="text-align:center;padding:20px;color:var(--text-3);">No data yet</div>', unsafe_allow_html=True) |
| st.markdown('</div>', unsafe_allow_html=True) |
|
|
| with _top5_col2: |
| st.markdown( |
| '<div class="chart-wrap">' |
| '<div class="chart-title">Top 5 Types of <span style="color:#f87171;">Requests & Feedback</span> Students Give</div>' |
| '<div class="chart-sub">Type of action count for Request/Feedback across tagged videos.</div>', |
| unsafe_allow_html=True |
| ) |
| if _rf_data: |
| st.markdown(_hbar_rows_html(_rf_data, "#f87171", max(_rf_data.values(), default=1)), unsafe_allow_html=True) |
| else: |
| st.markdown('<div style="text-align:center;padding:20px;color:var(--text-3);">No data yet</div>', unsafe_allow_html=True) |
| st.markdown('</div>', unsafe_allow_html=True) |
|
|
|
|
| |
| st.divider() |
| st.markdown( |
| '<div class="sec-hdr"><span class="sec-ttl">Top Contributors</span><span class="sec-pill">All Time</span></div>', |
| unsafe_allow_html=True |
| ) |
|
|
| _contrib_json = json.dumps([{"author": m.get("author",""), "sentiment": m.get("sentiment","Neutral"), "topic": m.get("topic","General")} for m in all_data]) |
| contributors = compute_top_contributors(_contrib_json) |
|
|
| if contributors: |
| max_count = contributors[0]["count"] |
| rank_icons = {1: "π₯", 2: "π₯", 3: "π₯"} |
| rank_classes = {1: "gold", 2: "silver", 3: "bronze"} |
| for rank, c in enumerate(contributors, 1): |
| bar_pct = int(c["count"] / max(max_count, 1) * 100) |
| rank_cls = rank_classes.get(rank, "") |
| rank_icon = rank_icons.get(rank, f"#{rank}") |
| author = c["author"] |
| count = c["count"] |
| pos_pct = c["pos_pct"] |
| neu_pct = c["neu_pct"] |
| neg_pct = c["neg_pct"] |
| html = ( |
| f'<div class="leaderboard-row">' |
| f'<div class="lb-rank {rank_cls}">{rank_icon}</div>' |
| f'<div class="lb-author">{author}</div>' |
| f'<div class="lb-bar"><div class="lb-bar-fill" style="width:{bar_pct}%;background:var(--accent);"></div></div>' |
| f'<div class="lb-sent">' |
| f'<span class="lb-dot" style="background:#22c55e;" title="Positive {pos_pct}%"></span>' |
| f'<span class="lb-dot" style="background:#eab308;" title="Neutral {neu_pct}%"></span>' |
| f'<span class="lb-dot" style="background:#ef4444;" title="Negative {neg_pct}%"></span>' |
| f'</div>' |
| f'<div class="lb-count">{count} msgs</div>' |
| f'</div>' |
| ) |
| st.markdown(html, unsafe_allow_html=True) |
|
|
| |
| st.markdown('<div class="chart-wrap" style="margin-top:16px;">', unsafe_allow_html=True) |
| st.markdown( |
| '<div class="chart-title">Sentiment & Topic Breakdown β Top Contributors</div>' |
| '<div class="chart-sub">Top bar = sentiment (Neg/Neu/Pos) Β· Bottom bar = topic mix Β· right = message count</div>', |
| unsafe_allow_html=True |
| ) |
|
|
| |
| |
| n = len(contributors) |
| y_sent_num = [i * 2 + 0.3 for i in range(n)] |
| y_topic_num = [i * 2 - 0.3 for i in range(n)] |
| tick_vals = [i * 2 for i in range(n)] |
| tick_text = [c["author"][:22] for c in contributors] |
|
|
| fig_combo = go.Figure() |
|
|
| |
| for key, label, color in [ |
| ("neg_pct", "Neg", "#ef4444"), |
| ("neu_pct", "Neu", "#eab308"), |
| ("pos_pct", "Pos", "#22c55e"), |
| ]: |
| fig_combo.add_trace(go.Bar( |
| name=label, |
| y=y_sent_num, |
| x=[c[key] for c in contributors], |
| orientation="h", |
| marker_color=color, |
| legendgroup="sent", |
| legendgrouptitle_text="Sentiment" if key == "neg_pct" else None, |
| width=0.5, |
| hovertemplate="<b>" + label + "</b>: %{x}%<extra></extra>", |
| )) |
|
|
| |
| for key, label, color in [ |
| ("t_appr", "Appreciation", "#f59e0b"), |
| ("t_ques", "Question", "#3b82f6"), |
| ("t_rf", "Request/Feedback","#8b5cf6"), |
| ("t_promo", "Promo", "#ec4899"), |
| ("t_spam", "Spam", "#ef4444"), |
| ("t_gen", "General", "#6b7280"), |
| ("t_mcq", "MCQ Answer", "#10b981"), |
| ]: |
| fig_combo.add_trace(go.Bar( |
| name=label, |
| y=y_topic_num, |
| x=[c[key] for c in contributors], |
| orientation="h", |
| marker_color=color, |
| legendgroup="topic", |
| legendgrouptitle_text="Topic" if key == "t_appr" else None, |
| width=0.5, |
| hovertemplate="<b>" + label + "</b>: %{x}%<extra></extra>", |
| )) |
|
|
| |
| annotations = [] |
| for i, c in enumerate(contributors): |
| annotations.append(dict( |
| x=102, y=y_sent_num[i], |
| text=f"<b>{c['count']} msgs</b>", |
| showarrow=False, |
| xanchor="left", |
| font=dict(size=10, color="#94a3b8"), |
| xref="x", yref="y", |
| )) |
|
|
| chart_h = max(400, n * 56) |
| layout_combo = plotly_layout(chart_h) |
| layout_combo["barmode"] = "stack" |
| layout_combo["bargap"] = 0.1 |
| layout_combo["showlegend"] = True |
| layout_combo["legend"] = dict( |
| orientation="h", y=1.0, x=0, |
| font=dict(size=12, color="#f1f5f9"), |
| title_font=dict(size=12, color="#a78bfa"), |
| groupclick="toggleitem", |
| yanchor="bottom", |
| xanchor="left", |
| bgcolor="rgba(0,0,0,0)", |
| ) |
| layout_combo["margin"] = dict(l=10, r=80, t=80, b=10) |
| layout_combo["xaxis"]["range"] = [0, 115] |
| layout_combo["xaxis"]["ticksuffix"] = "%" |
| layout_combo["yaxis"] = dict( |
| tickvals=tick_vals, |
| ticktext=tick_text, |
| tickfont=dict(size=10), |
| autorange="reversed", |
| showgrid=False, |
| zeroline=False, |
| showline=False, |
| ) |
| layout_combo["annotations"] = annotations |
| fig_combo.update_layout(**layout_combo) |
|
|
| st.plotly_chart(fig_combo, width='stretch', config={"displayModeBar": False}) |
| st.markdown('</div>', unsafe_allow_html=True) |
|
|
| contrib_df = pd.DataFrame(contributors) |
| csv_download(contrib_df, "Download CSV", "top_contributors.csv") |
| else: |
| st.info("Not enough data yet.") |
|
|
| |
| st.divider() |
| st.markdown( |
| '<div class="sec-hdr"><span class="sec-ttl">Repeat Spammers</span><span class="sec-pill">All Time</span></div>', |
| unsafe_allow_html=True |
| ) |
| rs_col1, rs_col2 = st.columns([1, 1]) |
| with rs_col1: |
| rs_window = st.slider("Time window (sec)", 5, 60, 15, key="rs_window") |
| with rs_col2: |
| rs_min = st.slider("Min repeats to flag", 2, 10, 2, key="rs_min") |
|
|
| _rs_json = json.dumps([{ |
| "author": m.get("author",""), "text": m.get("text",""), |
| "topic": m.get("topic","General"), "sentiment": m.get("sentiment","Neutral"), |
| "time": m.get("time","") |
| } for m in all_data]) |
| repeat_spammers = detect_repeat_spammers(_rs_json, window_sec=rs_window, min_repeats=rs_min) |
|
|
| if repeat_spammers: |
| st.markdown( |
| f'<div style="font-size:0.78rem;color:var(--text-3);margin-bottom:12px;">' |
| f'Found <b style="color:var(--text-1);">{len(repeat_spammers)}</b> users repeating the same message ' |
| f'β₯{rs_min}Γ within {rs_window}s</div>', |
| unsafe_allow_html=True |
| ) |
| for rs in repeat_spammers: |
| _t_color = TOPIC_COLOR.get(rs["topic"], "#6b7280") |
| _s_color = SENT_COLORS.get(rs["sentiment"], "#6b7280") |
| _burst = rs["max_burst"] |
| _total = rs["count"] |
| _severity = "#ef4444" if _burst >= 5 else "#eab308" if _burst >= 3 else "#f59e0b" |
| st.markdown( |
| f'<div class="chat-card" style="border-left:3px solid {_severity};">' |
| f'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;">' |
| f'<div class="chat-author">β οΈ {rs["author"]}</div>' |
| f'<div style="display:flex;gap:6px;">' |
| f'<span class="badge" style="color:{_severity};border-color:{_severity}44;">' |
| f'π {_burst}Γ in {rs_window}s</span>' |
| f'<span class="badge" style="color:var(--text-3);">{_total} total</span>' |
| f'</div></div>' |
| f'<div class="chat-text">"{rs["text"]}"</div>' |
| f'<div class="chat-badges">' |
| f'<span class="badge" style="color:{_s_color};border-color:{_s_color}33;">{rs["sentiment"]}</span>' |
| f'<span class="badge" style="color:{_t_color};border-color:{_t_color}33;">{rs["topic"]}</span>' |
| f'<span class="badge">First: {rs["first_seen"]}</span>' |
| f'<span class="badge">Last: {rs["last_seen"]}</span>' |
| f'</div></div>', |
| unsafe_allow_html=True |
| ) |
| rs_df = pd.DataFrame(repeat_spammers) |
| csv_download(rs_df, "Download CSV", "repeat_spammers.csv") |
| else: |
| st.markdown( |
| '<div style="font-size:0.84rem;color:var(--text-3);padding:12px 0;">No repeat spammers detected in current window.</div>', |
| unsafe_allow_html=True |
| ) |
|
|
| |
| active_streams = [s for s in st.session_state.streams if r.llen(s["redis_key"]) > 0] |
|
|
| if len(active_streams) > 1: |
| st.divider() |
| n_streams = len(active_streams) |
| st.markdown( |
| f'<div class="sec-hdr"><span class="sec-ttl">Multi-Stream Comparison</span>' |
| f'<span class="sec-pill">{n_streams} streams</span></div>', |
| unsafe_allow_html=True |
| ) |
|
|
| |
| _stream_cache: dict[str, dict] = {} |
| for _s in active_streams: |
| _rkey = _s["redis_key"] |
| _raw = load_stream_data(_rkey) |
| if not _raw: |
| continue |
| _sdf = pd.DataFrame(_raw) |
| _sdf["sentiment"] = _sdf["sentiment"].apply(clean_sentiment) |
| _sdf["topic"] = _sdf["topic"].apply(clean_topic) if "topic" in _sdf.columns else "General" |
| _sc = _sdf["sentiment"].value_counts().to_dict() |
| _p = _sc.get("Positive", 0) |
| _n = _sc.get("Neutral", 0) |
| _g = _sc.get("Negative", 0) |
| _t = max(_p + _n + _g, 1) |
| _tc = {lbl: int((_sdf["topic"] == lbl).sum()) for lbl in TOPIC_LABELS} |
| _top_topic = max(_tc, key=_tc.get) |
| _eng_json = json.dumps([ |
| {"sentiment": m.get("sentiment","Neutral"), |
| "topic": m.get("topic","General"), |
| "time": m.get("time","")} for m in _raw |
| ]) |
| _eng = compute_engagement(_eng_json) |
| _title = _s.get("video_title") or _s.get("video_id") or _rkey |
| _stream_cache[_rkey] = { |
| "df": _sdf, "raw": _raw, |
| "p": _p, "n": _n, "g": _g, "t": _t, |
| "tc": _tc, "top_topic": _top_topic, |
| "eng": _eng, "title": _title, |
| "sidx": st.session_state.streams.index(_s), |
| } |
|
|
| |
| st.markdown('<div class="chart-wrap" style="margin-bottom:16px;">', unsafe_allow_html=True) |
| st.markdown('<div class="chart-title">Head-to-Head Summary</div><div class="chart-sub">All active streams at a glance</div>', unsafe_allow_html=True) |
| _hth_rows = [] |
| for _s in active_streams: |
| _rkey = _s["redis_key"] |
| if _rkey not in _stream_cache: |
| continue |
| _c = _stream_cache[_rkey] |
| _sidx = _c["sidx"] |
| _hth_rows.append({ |
| "Stream": f"Stream {STREAM_NAMES[_sidx]}", |
| "Title": _c["title"][:30], |
| "Messages": _c["t"], |
| "Positive %": f"{_c['p']/_c['t']*100:.1f}%", |
| "Neutral %": f"{_c['n']/_c['t']*100:.1f}%", |
| "Negative %": f"{_c['g']/_c['t']*100:.1f}%", |
| "Top Topic": _c["top_topic"], |
| "Engagement": f"{_c['eng']['score']}/100 {_c['eng']['grade']}", |
| }) |
| if _hth_rows: |
| st.dataframe(pd.DataFrame(_hth_rows), hide_index=True, use_container_width=True) |
| st.markdown('</div>', unsafe_allow_html=True) |
|
|
| |
| chunk_size = 2 |
| _cached_keys = [_s["redis_key"] for _s in active_streams if _s["redis_key"] in _stream_cache] |
| for row_start in range(0, len(_cached_keys), chunk_size): |
| row_keys = _cached_keys[row_start:row_start + chunk_size] |
| cols = st.columns(len(row_keys)) |
| for col, _rkey in zip(cols, row_keys): |
| _c = _stream_cache[_rkey] |
| _sidx = _c["sidx"] |
| color = STREAM_COLORS[_sidx] |
| slabel = STREAM_NAMES[_sidx] |
| _p, _n, _g, _t = _c["p"], _c["n"], _c["g"], _c["t"] |
| _eng = _c["eng"] |
| _tc = _c["tc"] |
| with col: |
| st.markdown( |
| f'<span class="compare-label" style="background:{color}18;color:{color};border:1px solid {color}44;">' |
| f'Stream {slabel} Β· {_c["title"][:25]}</span>', |
| unsafe_allow_html=True |
| ) |
| _ec = "#22c55e" if _eng["score"] >= 70 else "#eab308" if _eng["score"] >= 40 else "#ef4444" |
| st.markdown( |
| f'<div style="display:flex;gap:10px;margin:6px 0 10px;flex-wrap:wrap;">' |
| f'<div style="background:var(--bg-card);border:1px solid {_ec}44;border-radius:12px;padding:8px 14px;">' |
| f'<div style="font-size:1.4rem;font-weight:800;color:{_ec};">{_eng["score"]}</div>' |
| f'<div style="font-size:0.68rem;color:var(--text-3);text-transform:uppercase;">Engagement</div>' |
| f'</div>' |
| f'<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:12px;padding:8px 14px;">' |
| f'<div style="font-size:1.4rem;font-weight:800;color:var(--text-1);">{_t}</div>' |
| f'<div style="font-size:0.68rem;color:var(--text-3);text-transform:uppercase;">Messages</div>' |
| f'</div>' |
| f'<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:12px;padding:8px 14px;">' |
| f'<div style="font-size:1.4rem;font-weight:800;color:#22c55e;">{_p/_t*100:.0f}%</div>' |
| f'<div style="font-size:0.68rem;color:var(--text-3);text-transform:uppercase;">Positive</div>' |
| f'</div>' |
| f'</div>', |
| unsafe_allow_html=True |
| ) |
| st.markdown('<div class="chart-wrap" style="margin-bottom:8px;">', unsafe_allow_html=True) |
| st.markdown('<div class="chart-title" style="font-size:0.78rem;">Sentiment</div>', unsafe_allow_html=True) |
| fig_s = go.Figure(go.Bar( |
| x=["Pos", "Neu", "Neg"], |
| y=[_p, _n, _g], |
| marker_color=["#22c55e", "#eab308", "#ef4444"], |
| marker_line_width=0, |
| text=[_p, _n, _g], |
| textposition="outside", |
| hovertemplate="<b>%{x}</b>: %{y}<extra></extra>", |
| )) |
| fig_s.update_layout(**plotly_layout(180)) |
| st.plotly_chart(fig_s, width='stretch', config={"displayModeBar": False}) |
| st.markdown('</div>', unsafe_allow_html=True) |
| st.markdown('<div class="chart-wrap">', unsafe_allow_html=True) |
| st.markdown('<div class="chart-title" style="font-size:0.78rem;">Topic Breakdown</div>', unsafe_allow_html=True) |
| _tc_colors = ["#f59e0b","#3b82f6","#ec4899","#ef4444","#6b7280","#10b981"] |
| fig_t = go.Figure(go.Bar( |
| x=TOPIC_LABELS, |
| y=[_tc[l] for l in TOPIC_LABELS], |
| marker_color=_tc_colors, |
| marker_line_width=0, |
| text=[_tc[l] for l in TOPIC_LABELS], |
| textposition="outside", |
| hovertemplate="<b>%{x}</b>: %{y}<extra></extra>", |
| )) |
| _tl = plotly_layout(180) |
| _tl["xaxis"]["tickfont"] = dict(size=8) |
| fig_t.update_layout(**_tl) |
| st.plotly_chart(fig_t, width='stretch', config={"displayModeBar": False}) |
| st.markdown('</div>', unsafe_allow_html=True) |
|
|
| |
| st.markdown('<div class="chart-wrap" style="margin-top:14px;">', unsafe_allow_html=True) |
| st.markdown('<div class="chart-title">Positive Ratio Over Time</div><div class="chart-sub">Rolling positive % per stream (synced refresh)</div>', unsafe_allow_html=True) |
| fig_overlay = go.Figure() |
| for _rkey, _c in _stream_cache.items(): |
| _sidx = _c["sidx"] |
| color = STREAM_COLORS[_sidx] |
| slabel = STREAM_NAMES[_sidx] |
| _sdf = _c["df"].copy() |
| _sdf["is_pos"] = (_sdf["sentiment"] == "Positive").astype(int) |
| _sdf["rolling"] = _sdf["is_pos"].rolling(10, min_periods=1).mean() * 100 |
| fig_overlay.add_trace(go.Scatter( |
| x=list(range(len(_sdf))), |
| y=_sdf["rolling"], |
| mode="lines", |
| name=f"Stream {slabel} Β· {_c['title'][:20]}", |
| line=dict(color=color, width=2), |
| hovertemplate=f"Stream {slabel} msg %{{x}}: %{{y:.1f}}%<extra></extra>", |
| )) |
| layout_ov = plotly_layout(220) |
| layout_ov["showlegend"] = True |
| layout_ov["legend"] = dict(orientation="h", y=1.08, font=dict(size=11, color="#f1f5f9")) |
| layout_ov["yaxis"]["range"] = [0, 100] |
| fig_overlay.update_layout(**layout_ov) |
| st.plotly_chart(fig_overlay, width='stretch', config={"displayModeBar": False}) |
| st.markdown('</div>', unsafe_allow_html=True) |
|
|
| elif len(st.session_state.streams) > 1: |
| st.divider() |
| st.info("Add video IDs to your extra stream slots and click βΆ Start to enable multi-stream comparison.") |
|
|
| |
| if auto_refresh: |
| time.sleep(refresh_rate) |
| st.rerun() |
|
|