adm / app.py
bigroll's picture
prev perfect
1ff6d5a verified
Raw
History Blame Contribute Delete
12.7 kB
import streamlit as st
import pandas as pd
import plotly.express as px
import duckdb
import os
# --- Set page layout to wide ---
st.set_page_config(layout="wide")
# --- Language toggle ---
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": "Моля, изберете поне един обект, за да видите графиката."
}
}
# --- Secrets ---
if "MOTHERDUCK_TOKEN" not in st.secrets:
st.error("MOTHERDUCK_TOKEN not found in secrets.")
st.stop()
MOTHERDUCK_TOKEN = st.secrets["MOTHERDUCK_TOKEN"]
# --- Data Fetching (MotherDuck) ---
@st.cache_data(ttl=300)
def get_data():
try:
# Connect to MotherDuck using the token
con = duckdb.connect(f'md:?token={MOTHERDUCK_TOKEN}')
# Query based on your schema
query = """
SELECT entity, entity_score, domain, title_score, overall_score, created_at
FROM sentiment_analysis
"""
df = con.sql(query).df()
# 1. Convert to Datetime (handle numeric or string inputs)
if pd.api.types.is_numeric_dtype(df["created_at"]):
df["created_at"] = pd.to_datetime(df["created_at"], unit="s")
else:
df["created_at"] = pd.to_datetime(df["created_at"])
# 2. FIX: Remove Timezone Information (Make it Naive)
if pd.api.types.is_datetime64_any_dtype(df["created_at"]):
if df["created_at"].dt.tz is not None:
df["created_at"] = df["created_at"].dt.tz_localize(None)
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()
# --- UI ---
st.title(T[LANG]["title"])
# --- Sidebar for filters ---
with st.sidebar:
# Entity dropdown with counts
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 dropdown with counts
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 and grouping options
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"])
# Timeframe selection
timeframes = ["All", "Last 7 Days", "Last 30 Days", "Last Year", "Custom"]
selected_timeframe = st.selectbox("Select Timeframe", timeframes)
# Custom date range selection
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()
# --- Filters ---
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))
]
# --- Stop early if no entity selected ---
if not entities:
st.info(T[LANG]["select_entities_prompt"])
st.stop()
# --- Continue only if data exists ---
if not filtered_df.empty:
# --- LEGEND SORTING LOGIC ---
# We calculate the average "entity_score" for each entity/domain combo.
# This ranking is used to sort the legend, regardless of what score is currently plotted.
rank_group_cols = ["entity"]
if group_by_domain:
rank_group_cols.append("domain")
# Calculate average entity_score for ranking
rank_df = filtered_df.groupby(rank_group_cols)["entity_score"].mean().reset_index()
# Create a dictionary for mapping: key -> score
rank_lookup = {
tuple(row[col] for col in rank_group_cols): row["entity_score"]
for _, row in rank_df.iterrows()
}
# --- SMART DATE BINNING (Max ~20 Dots) ---
min_date = filtered_df["created_at"].min()
max_date = filtered_df["created_at"].max()
days_span = (max_date - min_date).days + 1
if days_span <= 20:
freq = "D"
else:
step = int(days_span / 20)
step = max(1, step)
freq = f"{step}D"
filtered_df["date"] = filtered_df["created_at"].dt.floor(freq)
# --- END BINNING ---
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()
)
# FIX: Round scores to 2 decimals for the hover tooltip
grouped["score"] = grouped["score"].round(2)
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)
# Apply Sorting: Get rank score based on entity/domain
def get_rank_score(row):
key_cols = [row["entity"]]
if group_by_domain:
key_cols.append(row["domain"])
return rank_lookup.get(tuple(key_cols), -999) # Default to low if missing
grouped["rank_score"] = grouped.apply(get_rank_score, axis=1)
# Sort by Rank (Entity Score), then Score Type, then Date
grouped = grouped.sort_values(by=["rank_score", "label", "date"], ascending=[False, True, True])
fig = px.line(
grouped,
x="date",
y="score",
color="label",
labels={"date": "Date", "score": "Score", "label": "Legend"},
title=T[LANG]["scores_over_time"]
)
fig.update_traces(mode="lines+markers")
else:
group_cols = ["date", "entity"]
if group_by_domain:
group_cols.append("domain")
grouped = (
filtered_df.groupby(group_cols)[score_type]
.mean()
.reset_index()
)
# FIX: Round scores to 2 decimals for the hover tooltip
grouped[score_type] = grouped[score_type].round(2)
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)
# Apply Sorting: Get rank score based on entity/domain
def get_rank_score(row):
key_cols = [row["entity"]]
if group_by_domain:
key_cols.append(row["domain"])
return rank_lookup.get(tuple(key_cols), -999)
grouped["rank_score"] = grouped.apply(get_rank_score, axis=1)
# Sort by Rank (Entity Score), then Date
grouped = grouped.sort_values(by=["rank_score", "label", "date"], ascending=[False, True, 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())
)
fig.update_traces(mode="lines+markers")
# Grid lines at every integer, thicker at -5 and 5
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"])