| import streamlit as st |
| import pandas as pd |
| import plotly.express as px |
| from sqlalchemy import create_engine, text |
| from sshtunnel import SSHTunnelForwarder |
| import os |
| import tempfile |
|
|
| |
| st.set_page_config(layout="wide") |
|
|
| |
| |
| flag_style = """ |
| <style> |
| .flag-container { |
| display: flex; |
| justify-content: center; |
| gap: 10px; |
| } |
| .flag { |
| cursor: pointer; |
| width: 40px; |
| height: 30px; |
| } |
| </style> |
| """ |
| st.markdown(flag_style, unsafe_allow_html=True) |
|
|
| flag_selection = st.radio( |
| "", |
| ["🇬🇧", "🇧🇬"], |
| horizontal=True, |
| label_visibility="collapsed" |
| ) |
| LANG = "English" if flag_selection == "🇬🇧" else "Български" |
|
|
| T = { |
| "English": { |
| "title": "📊 Sentiment Analysis Dashboard", |
| "select_entities": "Select Entities", |
| "select_domains": "Select Domains (News Sites)", |
| "score_type": "Select Score Type", |
| "group_by_domain": "Group by Domain", |
| "all_scores": "Show All Score Types", |
| "no_data": "No data matches the selected filters.", |
| "avg_over_time": "Average {} Over Time", |
| "scores_over_time": "Sentiment Scores Over Time", |
| "select_entities_prompt": "Please select at least one entity to view the graph." |
| }, |
| "Български": { |
| "title": "📊 Табло за анализ на настроенията", |
| "select_entities": "Изберете обекти", |
| "select_domains": "Изберете източници (сайтове)", |
| "score_type": "Изберете тип оценка", |
| "group_by_domain": "Групирай по сайт", |
| "all_scores": "Покажи всички типове оценки", |
| "no_data": "Няма данни за избраните филтри.", |
| "avg_over_time": "Средна стойност на {} във времето", |
| "scores_over_time": "Оценки на настроенията във времето", |
| "select_entities_prompt": "Моля, изберете поне един обект, за да видите графиката." |
| } |
| } |
|
|
| |
| SSH_KEY = st.secrets["SSH_KEY"] |
| SSH_USER = st.secrets["SSH_USER"] |
| SSH_HOST = st.secrets["DB_HOST"] |
| REMOTE_BIND_HOST = st.secrets["REMOTE_BIND_HOST"] |
| LOCAL_BIND_HOST = st.secrets["LOCAL_BIND_HOST"] |
| MYSQL_USER = st.secrets["MYSQL_USER"] |
| MYSQL_PASSWORD = st.secrets["MYSQL_PASSWORD"] |
| MYSQL_DB = st.secrets["DB_NAME"] |
| MYSQL_PORT = int(st.secrets["MYSQL_PORT"]) |
|
|
| |
| with tempfile.NamedTemporaryFile(delete=False, suffix=".pem") as temp_file: |
| temp_file.write(SSH_KEY.encode()) |
| temp_file_path = temp_file.name |
| os.chmod(temp_file_path, 0o600) |
|
|
| |
| @st.cache_resource |
| def start_ssh_tunnel(): |
| try: |
| tunnel = SSHTunnelForwarder( |
| (SSH_HOST, 22), |
| ssh_username=SSH_USER, |
| ssh_pkey=temp_file_path, |
| remote_bind_address=(REMOTE_BIND_HOST, MYSQL_PORT), |
| local_bind_address=(LOCAL_BIND_HOST, MYSQL_PORT) |
| ) |
| if not tunnel.is_active: |
| tunnel.start() |
| return tunnel |
| except Exception as e: |
| st.error(f"Failed to start SSH tunnel: {e}") |
| st.stop() |
|
|
| try: |
| server = start_ssh_tunnel() |
| if not server.is_active: |
| server.start() |
| except Exception as e: |
| st.error(f"SSH tunnel error: {e}") |
| st.stop() |
|
|
| |
| try: |
| MYSQL_URL = f"mysql+pymysql://{MYSQL_USER}:{MYSQL_PASSWORD}@{LOCAL_BIND_HOST}:{MYSQL_PORT}/{MYSQL_DB}" |
| engine = create_engine(MYSQL_URL) |
| except Exception as e: |
| st.error(f"Failed to connect to the database: {e}") |
| st.stop() |
|
|
| @st.cache_data(ttl=300) |
| def get_data(): |
| try: |
| query = text(""" |
| SELECT id, entity, entity_score, domain, title_score, overall_score, created_at |
| FROM sentiment_analysis |
| """) |
| df = pd.read_sql(query, engine) |
| df["created_at"] = pd.to_datetime(df["created_at"], unit="s") |
| return df |
| except Exception as e: |
| st.error(f"Failed to fetch data: {e}") |
| return pd.DataFrame() |
|
|
| df = get_data() |
| if df.empty: |
| st.warning("No data available.") |
| st.stop() |
|
|
| |
| st.title(T[LANG]["title"]) |
|
|
| |
| with st.sidebar: |
| |
| entity_counts = df["entity"].value_counts() |
| entity_labels = [f"{ent} ({count})" for ent, count in entity_counts.items()] |
| entity_lookup = {f"{ent} ({count})": ent for ent, count in entity_counts.items()} |
| selected_labels = st.multiselect(T[LANG]["select_entities"], entity_labels) |
| entities = [entity_lookup[label] for label in selected_labels] |
|
|
| |
| domain_counts = df["domain"].value_counts() |
| domain_labels = [f"{dom} ({count})" for dom, count in domain_counts.items()] |
| domain_lookup = {f"{dom} ({count})": dom for dom, count in domain_counts.items()} |
| selected_domain_labels = st.multiselect(T[LANG]["select_domains"], domain_labels) |
| domains = [domain_lookup[label] for label in selected_domain_labels] |
|
|
| |
| score_type = st.selectbox(T[LANG]["score_type"], ["entity_score", "title_score", "overall_score"]) |
| group_by_domain = st.checkbox(T[LANG]["group_by_domain"]) |
| group_by_score_type = st.checkbox(T[LANG]["all_scores"]) |
|
|
| |
| timeframes = ["All", "Last 7 Days", "Last 30 Days", "Last Year", "Custom"] |
| selected_timeframe = st.selectbox("Select Timeframe", timeframes) |
|
|
| |
| start_date, end_date = None, None |
| if selected_timeframe == "Custom": |
| col1, col2 = st.columns(2) |
| with col1: |
| start_date = st.date_input("Start Date") |
| with col2: |
| end_date = st.date_input("End Date") |
| if start_date > end_date: |
| st.error("Start date must be before or equal to the end date.") |
| st.stop() |
|
|
| |
| filtered_df = df.copy() |
| if entities: |
| filtered_df = filtered_df[filtered_df["entity"].isin(entities)] |
| if domains: |
| filtered_df = filtered_df[filtered_df["domain"].isin(domains)] |
|
|
| if selected_timeframe != "All": |
| now = pd.Timestamp.now() |
| if selected_timeframe == "Last 7 Days": |
| start_date = now - pd.Timedelta(days=7) |
| end_date = now |
| elif selected_timeframe == "Last 30 Days": |
| start_date = now - pd.Timedelta(days=30) |
| end_date = now |
| elif selected_timeframe == "Last Year": |
| start_date = now - pd.Timedelta(days=365) |
| end_date = now |
| if start_date and end_date: |
| filtered_df = filtered_df[ |
| (filtered_df["created_at"] >= pd.Timestamp(start_date)) & |
| (filtered_df["created_at"] <= pd.Timestamp(end_date)) |
| ] |
|
|
| |
| if not entities: |
| st.info(T[LANG]["select_entities_prompt"]) |
| st.stop() |
|
|
| |
| if not filtered_df.empty: |
| filtered_df["date"] = filtered_df["created_at"].dt.floor("D") |
|
|
| if group_by_score_type: |
| score_cols = ["entity_score", "title_score", "overall_score"] |
| melted = filtered_df.melt( |
| id_vars=["date", "entity", "domain"], |
| value_vars=score_cols, |
| var_name="score_type", |
| value_name="score" |
| ) |
|
|
| group_cols = ["date", "entity"] |
| if group_by_domain: |
| group_cols.append("domain") |
| group_cols.append("score_type") |
|
|
| grouped = ( |
| melted.groupby(group_cols)["score"] |
| .mean() |
| .reset_index() |
| ) |
|
|
| stats_group = ["entity", "score_type"] |
| if group_by_domain: |
| stats_group.append("domain") |
|
|
| stats = ( |
| melted.groupby(stats_group)["score"] |
| .agg(["mean", "count"]) |
| .round(2) |
| .reset_index() |
| ) |
|
|
| stat_lookup = { |
| tuple(row[col] for col in stats_group): f"{row['mean']:.2f}, n={int(row['count'])}" |
| for _, row in stats.iterrows() |
| } |
|
|
| def build_label(row): |
| key = tuple(row[col] for col in stats_group) |
| label = f"{row['entity']}" |
| if group_by_domain: |
| label += f" | {row['domain']}" |
| label += f" | {row['score_type'].replace('_', ' ').title()}" |
| label += f" (avg: {stat_lookup.get(key, '')})" |
| return label |
|
|
| grouped["label"] = grouped.apply(build_label, axis=1) |
| label_avg = grouped.groupby("label")["score"].mean().reset_index(name="avg_score") |
| grouped = grouped.merge(label_avg, on="label") |
| grouped = grouped.sort_values(by=["avg_score", "date"], ascending=[False, True]) |
|
|
| fig = px.line( |
| grouped, |
| x="date", |
| y="score", |
| color="label", |
| labels={"date": "Date", "score": "Score", "label": "Legend"}, |
| title=T[LANG]["scores_over_time"] |
| ) |
|
|
| else: |
| group_cols = ["date", "entity"] |
| if group_by_domain: |
| group_cols.append("domain") |
|
|
| grouped = ( |
| filtered_df.groupby(group_cols)[score_type] |
| .mean() |
| .reset_index() |
| ) |
|
|
| stats_group = ["entity"] |
| if group_by_domain: |
| stats_group.append("domain") |
| stats = ( |
| filtered_df.groupby(stats_group)[score_type] |
| .agg(["mean", "count"]) |
| .round(2) |
| .reset_index() |
| ) |
|
|
| stat_lookup = { |
| tuple(row[col] for col in stats_group): f"{row['mean']:.2f}, n={int(row['count'])}" |
| for _, row in stats.iterrows() |
| } |
|
|
| def build_label(row): |
| key = tuple(row[col] for col in stats_group) |
| label = f"{row['entity']}" |
| if group_by_domain: |
| label += f" | {row['domain']}" |
| label += f" (avg: {stat_lookup.get(key, '')})" |
| return label |
|
|
| grouped["label"] = grouped.apply(build_label, axis=1) |
| label_avg = grouped.groupby("label")[score_type].mean().reset_index(name="avg_score") |
| grouped = grouped.merge(label_avg, on="label") |
| grouped = grouped.sort_values(by=["avg_score", "date"], ascending=[False, True]) |
|
|
| fig = px.line( |
| grouped, |
| x="date", |
| y=score_type, |
| color="label", |
| labels={"date": "Date", score_type: "Score", "label": "Legend"}, |
| title=T[LANG]["avg_over_time"].format(score_type.replace("_", " ").title()) |
| ) |
|
|
| |
| shapes = [] |
| for y in range(-10, 11): |
| shapes.append({ |
| "type": "line", |
| "xref": "paper", |
| "x0": 0, |
| "x1": 1, |
| "yref": "y", |
| "y0": y, |
| "y1": y, |
| "line": { |
| "color": "Black", |
| "width": 1 if y in [-5, 5] else 0.2, |
| "dash": "solid" |
| } |
| }) |
|
|
| fig.update_layout( |
| xaxis=dict(type="date"), |
| yaxis=dict(range=[-10, 10]), |
| plot_bgcolor="white", |
| shapes=shapes, |
| legend=dict( |
| orientation="h", |
| yanchor="top", |
| y=-0.3, |
| xanchor="center", |
| x=0.5, |
| title="", |
| traceorder="normal" |
| ) |
| ) |
|
|
| st.plotly_chart(fig, use_container_width=True) |
| else: |
| st.warning(T[LANG]["no_data"]) |