| """ |
| HelpScout Dashboard Page |
| Full dedicated dashboard for HelpScout customer support conversation analysis. |
| """ |
| import sys |
| from pathlib import Path |
|
|
| import pandas as pd |
| import streamlit as st |
|
|
| parent_dir = Path(__file__).resolve().parent.parent |
| sys.path.append(str(parent_dir)) |
|
|
| from utils.helpscout_utils import boolean_flag_counts, topic_label, load_topic_taxonomy |
| from visualizations.helpscout_charts import HelpScoutCharts |
| from visualizations.demographic_charts import DemographicCharts |
| from utils.data_processor import SentimentDataProcessor |
|
|
|
|
| def _sentiment_score(df) -> float: |
| """Compute average sentiment score on a -2 to +2 scale.""" |
| score_map = {"very_positive": 2, "positive": 1, "neutral": 0, |
| "negative": -1, "very_negative": -2} |
| if "sentiment_polarity" not in df.columns or df.empty: |
| return 0.0 |
| scores = df["sentiment_polarity"].map(score_map).fillna(0) |
| return float(scores.mean()) |
|
|
|
|
| def render_helpscout_dashboard(data_loader, date_range=None): |
| """ |
| Render the full HelpScout Dashboard page. |
| |
| Args: |
| data_loader: HelpScoutDataLoader instance |
| date_range: optional (start_date, end_date) tuple from global sidebar filters |
| """ |
| st.title("π§ HelpScout Support Dashboard") |
| st.markdown("Customer support conversation analysis from HelpScout.") |
|
|
| hs_df = st.session_state.get("helpscout_df") |
| if hs_df is None or hs_df.empty: |
| st.warning("No HelpScout data available. Please check your Snowflake connection.") |
| return |
|
|
| if date_range and len(date_range) == 2 and "first_message_at" in hs_df.columns: |
| hs_df = hs_df[ |
| (hs_df["first_message_at"] >= pd.Timestamp(date_range[0])) & |
| (hs_df["first_message_at"] <= pd.Timestamp(date_range[1])) |
| ] |
| if hs_df.empty: |
| st.warning("No HelpScout conversations match the selected date range.") |
| return |
| st.info(f"Showing **{len(hs_df):,}** conversations filtered by date range " |
| f"({date_range[0]} β {date_range[1]})") |
|
|
| charts = HelpScoutCharts() |
| taxonomy = load_topic_taxonomy() |
|
|
| |
| has_member_data = "is_member" in hs_df.columns |
| if has_member_data: |
| member_filter = st.radio( |
| "Show conversations for:", |
| options=["All Customers", "Members Only", "Non-Members Only"], |
| horizontal=True, |
| key="hs_dash_member_filter", |
| ) |
| if member_filter == "Members Only": |
| hs_df = hs_df[hs_df["is_member"]] |
| elif member_filter == "Non-Members Only": |
| hs_df = hs_df[~hs_df["is_member"]] |
| if member_filter != "All Customers" and hs_df.empty: |
| st.warning(f"No conversations found for {member_filter.lower().replace(' only', '')}.") |
| return |
| if member_filter != "All Customers": |
| st.info(f"Filtered to **{len(hs_df):,}** {member_filter.lower().replace(' only', '')} conversations.") |
| else: |
| st.info("βΉοΈ Member data not available β customer emails could not be matched to Musora user records.") |
|
|
| |
| with st.expander("π Export PDF Report", expanded=False): |
| st.markdown( |
| "Generate a comprehensive HelpScout support report. " |
| "Covers sentiment, topics, billing flags, timelines, and demographics." |
| ) |
| if st.button("Generate HelpScout PDF Report", type="primary", |
| use_container_width=True, key="hs_dash_pdf_btn"): |
| with st.spinner("Generating HelpScout PDF reportβ¦"): |
| try: |
| from utils.helpscout_pdf import HelpScoutDashboardPDF |
| exporter = HelpScoutDashboardPDF() |
| pdf_bytes = exporter.generate_report(hs_df) |
| import datetime |
| filename = f"helpscout_dashboard_{datetime.datetime.now().strftime('%Y%m%d_%H%M')}.pdf" |
| st.success("Report generated successfully!") |
| st.download_button( |
| label="Download HelpScout Dashboard PDF", |
| data=pdf_bytes, |
| file_name=filename, |
| mime="application/pdf", |
| use_container_width=True, |
| ) |
| except Exception as e: |
| st.error(f"Failed to generate report: {e}") |
| st.exception(e) |
|
|
| st.markdown("---") |
|
|
| |
| total = len(hs_df) |
| escalation_count = int(hs_df["is_escalation"].sum()) if "is_escalation" in hs_df.columns else 0 |
| flags = boolean_flag_counts(hs_df) |
| neg_pct = (hs_df["sentiment_polarity"].isin(["negative", "very_negative"]).sum() / total * 100) if total else 0 |
| avg_duration = float(hs_df["duration_hours"].mean()) if "duration_hours" in hs_df.columns else 0.0 |
|
|
| k1, k2, k3, k4, k5, k6 = st.columns(6) |
| k1.metric("Total Conversations", f"{total:,}") |
| k2.metric("Avg Duration (h)", f"{avg_duration:.1f}") |
| k3.metric("Escalations", f"{escalation_count:,}", delta=f"{escalation_count/total*100:.1f}% of total" if total else None, delta_color="inverse") |
| k4.metric("Refund Requests", f"{flags['is_refund_request']:,}") |
| k5.metric("Cancellations", f"{flags['is_cancellation']:,}") |
| k6.metric("Membership Joins",f"{flags['is_membership']:,}") |
|
|
| st.markdown("---") |
|
|
| |
| st.markdown("## π― Sentiment Distribution") |
| col1, col2 = st.columns(2) |
| with col1: |
| st.plotly_chart(charts.create_sentiment_pie_chart(hs_df), use_container_width=True) |
| with col2: |
| avg_score = _sentiment_score(hs_df) |
| st.plotly_chart(charts.create_sentiment_score_gauge(avg_score), use_container_width=True) |
| m1, m2 = st.columns(2) |
| pos_pct = hs_df["sentiment_polarity"].isin(["positive", "very_positive"]).sum() / total * 100 if total else 0 |
| m1.metric("Positive %", f"{pos_pct:.1f}%") |
| m2.metric("Negative %", f"{neg_pct:.1f}%") |
|
|
| st.markdown("---") |
|
|
| |
| st.markdown("## π·οΈ Topic Analysis") |
| col1, col2 = st.columns(2) |
| with col1: |
| st.plotly_chart(charts.create_topic_bar_chart(hs_df, title="Conversations by Topic"), |
| use_container_width=True) |
| with col2: |
| st.plotly_chart(charts.create_topic_pie_chart(hs_df, title="Topic Share"), |
| use_container_width=True) |
|
|
| st.plotly_chart(charts.create_topic_sentiment_heatmap(hs_df), use_container_width=True) |
|
|
| st.markdown("---") |
|
|
| |
| if "emotions" in hs_df.columns and hs_df["emotions"].notna().any(): |
| st.markdown("## π Emotion Analysis") |
| col1, col2 = st.columns(2) |
| with col1: |
| st.plotly_chart(charts.create_emotion_bar_chart(hs_df, title="Emotion Distribution"), |
| use_container_width=True) |
| with col2: |
| |
| from visualizations.distribution_charts import DistributionCharts |
| dist_charts = DistributionCharts() |
| st.plotly_chart(dist_charts.create_emotion_pie_chart(hs_df, title="Emotion Share"), |
| use_container_width=True) |
| st.markdown("---") |
|
|
| |
| st.markdown("## π³ Billing & Membership Flags") |
| col1, col2 = st.columns(2) |
| with col1: |
| st.plotly_chart(charts.create_boolean_flags_chart(hs_df), use_container_width=True) |
| with col2: |
| st.plotly_chart(charts.create_escalation_breakdown(hs_df), use_container_width=True) |
|
|
| st.markdown("---") |
|
|
| |
| st.markdown("## π¬ Status & Source Distribution") |
| col1, col2 = st.columns(2) |
| with col1: |
| st.plotly_chart(charts.create_status_distribution(hs_df), use_container_width=True) |
| with col2: |
| st.plotly_chart(charts.create_source_distribution(hs_df), use_container_width=True) |
|
|
| st.markdown("---") |
|
|
| |
| with st.expander("π Volume & Trends", expanded=False): |
| freq_col, _ = st.columns([1, 3]) |
| with freq_col: |
| freq = st.selectbox("Time Granularity", ["D", "W", "M"], |
| format_func=lambda x: {"D": "Daily", "W": "Weekly", "M": "Monthly"}[x], |
| index=1, key="hs_dash_freq") |
| st.plotly_chart(charts.create_volume_timeline(hs_df, freq=freq), use_container_width=True) |
| st.plotly_chart(charts.create_sentiment_timeline(hs_df, freq=freq), use_container_width=True) |
|
|
| all_topics_ranked = charts.get_all_topics_ranked(hs_df) |
| topic_options = {t: topic_label(t, charts.taxonomy) for t in all_topics_ranked} |
| default_topics = all_topics_ranked[:5] |
| selected_topics = st.multiselect( |
| "Topics to display", |
| options=list(topic_options.keys()), |
| default=default_topics, |
| format_func=lambda t: topic_options[t], |
| key="hs_dash_topic_select", |
| ) |
| st.plotly_chart( |
| charts.create_topic_timeline(hs_df, freq=freq, selected_topics=selected_topics or default_topics), |
| use_container_width=True, |
| ) |
| st.plotly_chart(charts.create_refund_cancel_timeline(hs_df, freq=freq), use_container_width=True) |
|
|
| |
| with st.expander("π Conversation Depth", expanded=False): |
| col1, col2 = st.columns(2) |
| with col1: |
| st.plotly_chart(charts.create_duration_histogram(hs_df), use_container_width=True) |
| with col2: |
| st.plotly_chart(charts.create_thread_count_histogram(hs_df), use_container_width=True) |
|
|
| |
| if "is_member" in hs_df.columns: |
| st.markdown("---") |
| st.markdown("## π€ Member vs Non-Member") |
| st.caption( |
| "Conversations are classified as **Member** when the customer email matches " |
| "a Musora user account, and **Non-Member** otherwise." |
| ) |
|
|
| member_count = int(hs_df["is_member"].sum()) |
| non_member_count = total - member_count |
| match_pct = member_count / total * 100 if total else 0 |
|
|
| mv1, mv2, mv3 = st.columns(3) |
| mv1.metric("Members", f"{member_count:,}", |
| f"{match_pct:.1f}% of conversations" if total else None) |
| mv2.metric("Non-Members", f"{non_member_count:,}", |
| f"{100 - match_pct:.1f}% of conversations" if total else None) |
| mv3.metric("Email Match Rate", f"{match_pct:.1f}%") |
|
|
| mem_col1, mem_col2 = st.columns(2) |
| with mem_col1: |
| st.plotly_chart(charts.create_member_status_chart(hs_df), |
| use_container_width=True, key="hs_dash_member_pie") |
| with mem_col2: |
| st.plotly_chart(charts.create_member_sentiment_chart(hs_df), |
| use_container_width=True, key="hs_dash_member_sentiment") |
|
|
| st.plotly_chart(charts.create_member_topic_chart(hs_df), |
| use_container_width=True, key="hs_dash_member_topics") |
|
|
| |
| has_demographics = ( |
| "age_group" in hs_df.columns |
| and "timezone_region" in hs_df.columns |
| and (hs_df["age_group"] != "Unknown").any() |
| ) |
| if has_demographics: |
| st.markdown("---") |
| st.markdown("## π₯ Customer Demographics") |
| st.info(f"Demographics available for customers whose email matched Musora user records.") |
|
|
| processor = SentimentDataProcessor() |
| demo_charts = DemographicCharts() |
|
|
| demo_col1, demo_col2, demo_col3, demo_col4 = st.columns(4) |
| known_demo = int((hs_df["age_group"] != "Unknown").sum()) |
| demo_col1.metric("With Demographics", f"{known_demo:,}", f"{known_demo/total*100:.1f}% matched") |
|
|
| avg_age = hs_df["age"].mean() if "age" in hs_df.columns else None |
| demo_col2.metric("Average Age", f"{avg_age:.1f}" if avg_age else "N/A") |
|
|
| top_region = hs_df["timezone_region"].value_counts().index[0] if "timezone_region" in hs_df.columns and not hs_df.empty else "N/A" |
| demo_col3.metric("Top Region", str(top_region)) |
|
|
| avg_exp = hs_df["experience_level"].mean() if "experience_level" in hs_df.columns else None |
| demo_col4.metric("Avg Experience", f"{avg_exp:.1f}/10" if avg_exp else "N/A") |
|
|
| st.markdown("---") |
| age_dist = processor.get_demographics_distribution(hs_df, "age_group") |
| if not age_dist.empty: |
| st.markdown("### Age Distribution") |
| col1, col2 = st.columns(2) |
| with col1: |
| st.plotly_chart(demo_charts.create_age_distribution_chart(age_dist), use_container_width=True) |
| with col2: |
| age_sent = processor.get_demographics_by_sentiment(hs_df, "age_group") |
| if not age_sent.empty: |
| st.plotly_chart(demo_charts.create_age_sentiment_chart(age_sent), use_container_width=True) |
|
|
| region_dist = processor.get_timezone_regions_distribution(hs_df) |
| if not region_dist.empty: |
| st.markdown("### Geographic Distribution") |
| col1, col2 = st.columns(2) |
| with col1: |
| st.plotly_chart(demo_charts.create_region_distribution_chart(region_dist), use_container_width=True) |
| with col2: |
| region_sent = processor.get_demographics_by_sentiment(hs_df, "timezone_region") |
| if not region_sent.empty: |
| st.plotly_chart(demo_charts.create_region_sentiment_chart(region_sent), use_container_width=True) |
|
|
| st.markdown("---") |
| st.caption( |
| "Data source: SOCIAL_MEDIA_DB.ML_FEATURES.HELPSCOUT_CONVERSATION_FEATURES | " |
| f"Last processed: {hs_df['processed_at'].max().strftime('%Y-%m-%d %H:%M') if 'processed_at' in hs_df.columns and not hs_df.empty else 'Unknown'}" |
| ) |
|
|
|
|
| |
| |
| |
|
|
| def render_helpscout_compact_summary(hs_df): |
| """ |
| A one-screen HelpScout summary section embedded at the bottom of the |
| main Sentiment Dashboard. Kept purposely brief. |
| """ |
| st.markdown("---") |
| st.markdown("## π§ HelpScout Support β Quick View") |
| st.caption(f"{len(hs_df):,} processed customer conversations") |
|
|
| total = len(hs_df) |
| if total == 0: |
| st.info("No HelpScout conversations available.") |
| return |
|
|
| charts = HelpScoutCharts() |
| flags = boolean_flag_counts(hs_df) |
| escalation_count = int(hs_df["is_escalation"].sum()) if "is_escalation" in hs_df.columns else 0 |
| avg_dur = float(hs_df["duration_hours"].mean()) if "duration_hours" in hs_df.columns else 0.0 |
|
|
| k1, k2, k3, k4 = st.columns(4) |
| k1.metric("Conversations", f"{total:,}") |
| k2.metric("Escalations", f"{escalation_count:,}", delta=f"{escalation_count/total*100:.1f}%", delta_color="inverse") |
| k3.metric("Refund Requests", f"{flags['is_refund_request']:,}") |
| k4.metric("Avg Duration (h)", f"{avg_dur:.1f}") |
|
|
| col1, col2 = st.columns(2) |
| with col1: |
| st.plotly_chart( |
| charts.create_sentiment_pie_chart(hs_df, title="HelpScout Sentiment"), |
| use_container_width=True, |
| key="hs_compact_sentiment_pie", |
| ) |
| with col2: |
| st.plotly_chart( |
| charts.create_topic_bar_chart(hs_df, title="Top Topics", top_n=5), |
| use_container_width=True, |
| key="hs_compact_topic_bar", |
| ) |
|
|
| st.info("π Navigate to **π§ HelpScout Dashboard** for the full analysis.") |