| """ |
| HelpScout-specific Plotly chart functions. |
| All functions accept a HelpScout conversations DataFrame and return a |
| plotly.graph_objects.Figure. |
| """ |
| import json |
| import sys |
| from pathlib import Path |
|
|
| import pandas as pd |
| import plotly.graph_objects as go |
|
|
| |
| _root = Path(__file__).resolve().parent.parent.parent |
| if str(_root) not in sys.path: |
| sys.path.insert(0, str(_root)) |
|
|
| from visualization.utils.helpscout_utils import ( |
| explode_topics, parse_topics, topic_label, load_topic_taxonomy |
| ) |
|
|
|
|
| class HelpScoutCharts: |
| """Plotly chart factory for HelpScout conversation data.""" |
|
|
| def __init__(self, config_path=None): |
| if config_path is None: |
| config_path = Path(__file__).parent.parent / "config" / "viz_config.json" |
| with open(config_path, "r") as f: |
| config = json.load(f) |
|
|
| hs_colors = config.get("color_schemes_helpscout", {}) |
| self.topic_colors = hs_colors.get("topics", {}) |
| self.status_colors = hs_colors.get("status", {}) |
| self.flag_colors = hs_colors.get("boolean_flags", {}) |
| self.sentiment_colors = config.get("color_schemes", {}).get("sentiment_polarity", {}) |
| self.sentiment_order = config.get("sentiment_order", []) |
| self.chart_height = config.get("dashboard", {}).get("chart_height", 400) |
| self.taxonomy = load_topic_taxonomy() |
|
|
| |
| |
| |
|
|
| def create_sentiment_pie_chart(self, df, title="Sentiment Distribution"): |
| counts = df["sentiment_polarity"].value_counts() |
| ordered = [s for s in self.sentiment_order if s in counts.index] |
| counts = counts[ordered] |
| colors = [self.sentiment_colors.get(s, "#CCCCCC") for s in counts.index] |
|
|
| fig = go.Figure(go.Pie( |
| labels=counts.index, |
| values=counts.values, |
| marker=dict(colors=colors), |
| textinfo="label+percent", |
| hovertemplate="<b>%{label}</b><br>Count: %{value}<br>%{percent}<extra></extra>", |
| )) |
| fig.update_layout(title=title, height=self.chart_height, |
| legend=dict(orientation="v", yanchor="middle", y=0.5)) |
| return fig |
|
|
| def create_sentiment_score_gauge(self, avg_score, title="Sentiment Score"): |
| normalized = ((avg_score + 2) / 4) * 100 |
| fig = go.Figure(go.Indicator( |
| mode="gauge+number", |
| value=normalized, |
| title={"text": title, "font": {"size": 18}}, |
| number={"font": {"size": 36}}, |
| gauge={ |
| "axis": {"range": [0, 100]}, |
| "bar": {"color": "darkblue"}, |
| "steps": [ |
| {"range": [0, 20], "color": "#D32F2F"}, |
| {"range": [20, 40], "color": "#FF6F00"}, |
| {"range": [40, 60], "color": "#FFB300"}, |
| {"range": [60, 80], "color": "#7CB342"}, |
| {"range": [80, 100],"color": "#00C851"}, |
| ], |
| }, |
| )) |
| fig.update_layout(height=300, margin=dict(l=20, r=20, t=60, b=20)) |
| return fig |
|
|
| def create_sentiment_timeline(self, df, title="Sentiment Over Time", freq="W"): |
| if "first_message_at" not in df.columns: |
| return self._empty_fig(title, "No timestamp data") |
| df_t = df.copy() |
| df_t["date"] = pd.to_datetime(df_t["first_message_at"]).dt.to_period(freq).dt.to_timestamp() |
| agg = df_t.groupby(["date", "sentiment_polarity"]).size().reset_index(name="count") |
| fig = go.Figure() |
| for s in self.sentiment_order: |
| d = agg[agg["sentiment_polarity"] == s] |
| if not d.empty: |
| fig.add_trace(go.Scatter( |
| x=d["date"], y=d["count"], name=s, mode="lines+markers", |
| line=dict(color=self.sentiment_colors.get(s, "#CCCCCC"), width=2), |
| hovertemplate="<b>%{x}</b><br>%{y}<extra></extra>", |
| )) |
| fig.update_layout(title=title, xaxis_title="Date", |
| yaxis_title="Conversations", height=self.chart_height, |
| hovermode="x unified") |
| return fig |
|
|
| |
| |
| |
|
|
| def create_topic_bar_chart(self, df, title="Topic Distribution", |
| orientation="h", top_n=None): |
| exploded = explode_topics(df) |
| if exploded.empty: |
| return self._empty_fig(title, "No topic data") |
| counts = exploded["topic_id"].value_counts() |
| if top_n: |
| counts = counts.head(top_n) |
| labels = [topic_label(t, self.taxonomy) for t in counts.index] |
| colors = [self.topic_colors.get(t, "#607D8B") for t in counts.index] |
|
|
| if orientation == "h": |
| fig = go.Figure(go.Bar( |
| y=labels, x=counts.values, orientation="h", |
| marker=dict(color=colors), |
| text=counts.values, textposition="auto", |
| hovertemplate="<b>%{y}</b><br>%{x} conversations<extra></extra>", |
| )) |
| fig.update_layout(title=title, xaxis_title="Conversations", |
| yaxis_title="Topic", height=self.chart_height, |
| yaxis={"categoryorder": "total ascending"}) |
| else: |
| fig = go.Figure(go.Bar( |
| x=labels, y=counts.values, |
| marker=dict(color=colors), |
| text=counts.values, textposition="auto", |
| hovertemplate="<b>%{x}</b><br>%{y}<extra></extra>", |
| )) |
| fig.update_layout(title=title, xaxis_title="Topic", |
| yaxis_title="Conversations", height=self.chart_height) |
| return fig |
|
|
| def create_topic_pie_chart(self, df, title="Topic Distribution"): |
| exploded = explode_topics(df) |
| if exploded.empty: |
| return self._empty_fig(title, "No topic data") |
| counts = exploded["topic_id"].value_counts() |
| labels = [topic_label(t, self.taxonomy) for t in counts.index] |
| colors = [self.topic_colors.get(t, "#607D8B") for t in counts.index] |
| fig = go.Figure(go.Pie( |
| labels=labels, values=counts.values, |
| marker=dict(colors=colors), |
| textinfo="label+percent", |
| hovertemplate="<b>%{label}</b><br>%{value}<br>%{percent}<extra></extra>", |
| )) |
| fig.update_layout(title=title, height=self.chart_height) |
| return fig |
|
|
| def create_topic_sentiment_heatmap(self, df, title="Topic Γ Sentiment Heatmap"): |
| exploded = explode_topics(df) |
| if exploded.empty or "sentiment_polarity" not in exploded.columns: |
| return self._empty_fig(title, "No data") |
| pivot = pd.crosstab(exploded["topic_id"], exploded["sentiment_polarity"]) |
| pivot.index = [topic_label(t, self.taxonomy) for t in pivot.index] |
| ordered_cols = [s for s in self.sentiment_order if s in pivot.columns] |
| pivot = pivot[ordered_cols] if ordered_cols else pivot |
|
|
| fig = go.Figure(go.Heatmap( |
| z=pivot.values, |
| x=pivot.columns.tolist(), |
| y=pivot.index.tolist(), |
| colorscale="Blues", |
| text=pivot.values, |
| texttemplate="%{text}", |
| hovertemplate="<b>%{y} β %{x}</b><br>%{z}<extra></extra>", |
| colorbar=dict(title="Conversations"), |
| )) |
| fig.update_layout(title=title, xaxis_title="Sentiment", |
| yaxis_title="Topic", height=self.chart_height + 100) |
| return fig |
|
|
| def get_all_topics_ranked(self, df): |
| """Return all topic_ids sorted by total volume (descending).""" |
| exploded = explode_topics(df) |
| if exploded.empty: |
| return [] |
| return exploded["topic_id"].value_counts().index.tolist() |
|
|
| def create_topic_timeline(self, df, title="Topic Volume Over Time", |
| freq="W", top_n=5, selected_topics=None): |
| if "first_message_at" not in df.columns: |
| return self._empty_fig(title, "No timestamp data") |
| exploded = explode_topics(df) |
| if exploded.empty: |
| return self._empty_fig(title, "No topic data") |
|
|
| all_ranked = exploded["topic_id"].value_counts().index.tolist() |
| if selected_topics is not None: |
| topics = [t for t in all_ranked if t in selected_topics] |
| else: |
| topics = all_ranked[:top_n] |
|
|
| if not topics: |
| return self._empty_fig(title, "No topics selected") |
|
|
| exploded = exploded[exploded["topic_id"].isin(topics)].copy() |
| exploded["date"] = pd.to_datetime(exploded["first_message_at"]).dt.to_period(freq).dt.to_timestamp() |
| agg = exploded.groupby(["date", "topic_id"]).size().reset_index(name="count") |
|
|
| fig = go.Figure() |
| for t in topics: |
| d = agg[agg["topic_id"] == t] |
| if not d.empty: |
| fig.add_trace(go.Scatter( |
| x=d["date"], y=d["count"], |
| name=topic_label(t, self.taxonomy), mode="lines+markers", |
| line=dict(color=self.topic_colors.get(t, "#607D8B"), width=2), |
| hovertemplate="<b>%{x}</b><br>%{y}<extra></extra>", |
| )) |
| fig.update_layout(title=title, xaxis_title="Date", |
| yaxis_title="Conversations", height=self.chart_height, |
| hovermode="x unified") |
| return fig |
|
|
| |
| |
| |
|
|
| def create_volume_timeline(self, df, title="Conversation Volume Over Time", |
| freq="W"): |
| if "first_message_at" not in df.columns: |
| return self._empty_fig(title, "No timestamp data") |
| df_t = df.copy() |
| df_t["date"] = pd.to_datetime(df_t["first_message_at"]).dt.to_period(freq).dt.to_timestamp() |
| agg = df_t.groupby("date").size().reset_index(name="count") |
| fig = go.Figure(go.Bar( |
| x=agg["date"], y=agg["count"], |
| marker_color="#1982C4", |
| hovertemplate="<b>%{x}</b><br>%{y} conversations<extra></extra>", |
| )) |
| fig.update_layout(title=title, xaxis_title="Date", |
| yaxis_title="Conversations", height=self.chart_height) |
| return fig |
|
|
| def create_refund_cancel_timeline(self, df, title="Refund & Cancellation Over Time", |
| freq="W"): |
| if "first_message_at" not in df.columns: |
| return self._empty_fig(title, "No timestamp data") |
| df_t = df.copy() |
| df_t["date"] = pd.to_datetime(df_t["first_message_at"]).dt.to_period(freq).dt.to_timestamp() |
|
|
| fig = go.Figure() |
| for col, label, color in [ |
| ("is_refund_request", "Refund Requests", "#D32F2F"), |
| ("is_cancellation", "Cancellations", "#FF6F00"), |
| ("is_membership", "Membership Joins", "#00C851"), |
| ]: |
| if col in df_t.columns: |
| agg = df_t[df_t[col] == True].groupby("date").size().reset_index(name="count") |
| if not agg.empty: |
| fig.add_trace(go.Scatter( |
| x=agg["date"], y=agg["count"], name=label, |
| mode="lines+markers", line=dict(color=color, width=2), |
| hovertemplate="<b>%{x}</b><br>%{y}<extra></extra>", |
| )) |
| fig.update_layout(title=title, xaxis_title="Date", |
| yaxis_title="Conversations", height=self.chart_height, |
| hovermode="x unified") |
| return fig |
|
|
| |
| |
| |
|
|
| def create_status_distribution(self, df, title="Conversations by Status"): |
| if "status" not in df.columns: |
| return self._empty_fig(title, "No status data") |
| counts = df["status"].value_counts() |
| colors = [self.status_colors.get(s, self.status_colors.get("default", "#607D8B")) |
| for s in counts.index] |
| fig = go.Figure(go.Bar( |
| x=counts.index, y=counts.values, |
| marker=dict(color=colors), |
| text=counts.values, textposition="auto", |
| hovertemplate="<b>%{x}</b><br>%{y}<extra></extra>", |
| )) |
| fig.update_layout(title=title, xaxis_title="Status", |
| yaxis_title="Conversations", height=self.chart_height) |
| return fig |
|
|
| def create_source_distribution(self, df, title="Conversations by Source Type"): |
| if "source_type" not in df.columns: |
| return self._empty_fig(title, "No source data") |
| counts = df["source_type"].value_counts() |
| fig = go.Figure(go.Bar( |
| x=counts.index, y=counts.values, |
| marker_color="#1982C4", |
| text=counts.values, textposition="auto", |
| hovertemplate="<b>%{x}</b><br>%{y}<extra></extra>", |
| )) |
| fig.update_layout(title=title, xaxis_title="Source", |
| yaxis_title="Conversations", height=self.chart_height) |
| return fig |
|
|
| def create_boolean_flags_chart(self, df, title="Key Billing & Membership Flags"): |
| labels, values, colors = [], [], [] |
| for col, label in [("is_refund_request", "Refund Requests"), |
| ("is_cancellation", "Cancellations"), |
| ("is_membership", "Membership Joins")]: |
| if col in df.columns: |
| labels.append(label) |
| values.append(int(df[col].sum())) |
| colors.append(self.flag_colors.get(col, "#607D8B")) |
|
|
| if not values: |
| return self._empty_fig(title, "No flag data") |
|
|
| fig = go.Figure(go.Bar( |
| x=labels, y=values, |
| marker=dict(color=colors), |
| text=values, textposition="auto", |
| hovertemplate="<b>%{x}</b><br>%{y}<extra></extra>", |
| )) |
| fig.update_layout(title=title, xaxis_title="Flag", |
| yaxis_title="Conversations", height=self.chart_height) |
| return fig |
|
|
| def create_escalation_breakdown(self, df, title="Escalation Queue by Topic"): |
| if "is_escalation" not in df.columns: |
| return self._empty_fig(title, "No escalation data") |
|
|
| exploded = explode_topics(df) |
| if exploded.empty: |
| return self._empty_fig(title, "No topic data") |
|
|
| pivot = pd.crosstab(exploded["topic_id"], exploded["is_escalation"]) |
| pivot.index = [topic_label(t, self.taxonomy) for t in pivot.index] |
|
|
| fig = go.Figure() |
| for flag, label, color in [(False, "Normal", "#4CAF50"), (True, "Escalation", "#D32F2F")]: |
| if flag in pivot.columns: |
| fig.add_trace(go.Bar( |
| name=label, y=pivot.index, x=pivot[flag], |
| orientation="h", marker_color=color, |
| hovertemplate="<b>%{y}</b><br>%{x}<extra></extra>", |
| )) |
| fig.update_layout(title=title, barmode="stack", xaxis_title="Conversations", |
| yaxis_title="Topic", height=self.chart_height, |
| yaxis={"categoryorder": "total ascending"}) |
| return fig |
|
|
| |
| |
| |
|
|
| def create_duration_histogram(self, df, title="Conversation Duration Distribution"): |
| if "duration_hours" not in df.columns: |
| return self._empty_fig(title, "No duration data") |
| d = df["duration_hours"].dropna() |
| fig = go.Figure(go.Histogram( |
| x=d, nbinsx=40, marker_color="#1982C4", |
| hovertemplate="Duration: %{x:.1f}h<br>Count: %{y}<extra></extra>", |
| )) |
| fig.update_layout(title=title, xaxis_title="Duration (hours)", |
| yaxis_title="Conversations", height=self.chart_height) |
| return fig |
|
|
| def create_thread_count_histogram(self, df, title="Thread Count Distribution"): |
| if "thread_count" not in df.columns: |
| return self._empty_fig(title, "No thread data") |
| t = df["thread_count"].dropna() |
| fig = go.Figure(go.Histogram( |
| x=t, nbinsx=30, marker_color="#9C27B0", |
| hovertemplate="Threads: %{x}<br>Count: %{y}<extra></extra>", |
| )) |
| fig.update_layout(title=title, xaxis_title="Number of Threads", |
| yaxis_title="Conversations", height=self.chart_height) |
| return fig |
|
|
| |
| |
| |
|
|
| def create_emotion_bar_chart(self, df, title="Emotion Distribution", |
| orientation="h"): |
| if "emotions" not in df.columns or df["emotions"].isna().all(): |
| return self._empty_fig(title, "No emotion data") |
|
|
| emotion_colors = { |
| "joy": "#FFD700", "excitement": "#FF6B35", "gratitude": "#4CAF50", |
| "admiration": "#2196F3", "curiosity": "#00BCD4", "humor": "#9C27B0", |
| "frustration": "#FF9800", "disappointment": "#795548", |
| "sadness": "#607D8B", "anger": "#D32F2F", "neutral": "#9E9E9E", |
| } |
| df_e = df.copy() |
| df_e["emotions"] = df_e["emotions"].str.split(",") |
| df_e = df_e.explode("emotions") |
| df_e["emotions"] = df_e["emotions"].str.strip().str.lower() |
| counts = df_e["emotions"].dropna().value_counts() |
| colors = [emotion_colors.get(e, "#CCCCCC") for e in counts.index] |
|
|
| if orientation == "h": |
| fig = go.Figure(go.Bar( |
| y=counts.index, x=counts.values, orientation="h", |
| marker=dict(color=colors), text=counts.values, textposition="auto", |
| hovertemplate="<b>%{y}</b><br>%{x}<extra></extra>", |
| )) |
| fig.update_layout(title=title, xaxis_title="Conversations", |
| yaxis_title="Emotion", height=self.chart_height, |
| yaxis={"categoryorder": "total ascending"}) |
| else: |
| fig = go.Figure(go.Bar( |
| x=counts.index, y=counts.values, |
| marker=dict(color=colors), text=counts.values, textposition="auto", |
| hovertemplate="<b>%{x}</b><br>%{y}<extra></extra>", |
| )) |
| fig.update_layout(title=title, xaxis_title="Emotion", |
| yaxis_title="Conversations", height=self.chart_height) |
| return fig |
|
|
| |
| |
| |
|
|
| def create_member_status_chart(self, df, title="Member vs Non-Member"): |
| """Pie chart: proportion of conversations from Musora members vs non-members.""" |
| if "is_member" not in df.columns: |
| return self._empty_fig(title, "No member data available") |
| label_map = {True: "Member", False: "Non-Member"} |
| counts = df["is_member"].map(label_map).value_counts() |
| color_map = {"Member": "#1982C4", "Non-Member": "#FF6B35"} |
| colors = [color_map.get(l, "#CCCCCC") for l in counts.index] |
| fig = go.Figure(go.Pie( |
| labels=counts.index, values=counts.values, |
| marker=dict(colors=colors), |
| textinfo="label+percent", |
| hovertemplate="<b>%{label}</b><br>Count: %{value}<br>%{percent}<extra></extra>", |
| )) |
| fig.update_layout(title=title, height=self.chart_height, |
| legend=dict(orientation="v", yanchor="middle", y=0.5)) |
| return fig |
|
|
| def create_member_sentiment_chart(self, df, title="Sentiment by Member Status"): |
| """Stacked bar: sentiment distribution split by member vs non-member.""" |
| if "is_member" not in df.columns or "sentiment_polarity" not in df.columns: |
| return self._empty_fig(title, "No member/sentiment data available") |
| df_c = df.copy() |
| df_c["member_status"] = df_c["is_member"].map({True: "Member", False: "Non-Member"}) |
| pivot = pd.crosstab(df_c["member_status"], df_c["sentiment_polarity"]) |
| ordered_cols = [s for s in self.sentiment_order if s in pivot.columns] |
| pivot = pivot[ordered_cols] if ordered_cols else pivot |
| fig = go.Figure() |
| for s in (ordered_cols or pivot.columns.tolist()): |
| fig.add_trace(go.Bar( |
| name=s, x=pivot.index, y=pivot[s], |
| marker_color=self.sentiment_colors.get(s, "#CCCCCC"), |
| hovertemplate="<b>%{x}</b><br>%{y}<extra></extra>", |
| )) |
| fig.update_layout(title=title, barmode="stack", xaxis_title="Customer Type", |
| yaxis_title="Conversations", height=self.chart_height) |
| return fig |
|
|
| def create_member_topic_chart(self, df, title="Top Topics by Member Status"): |
| """Grouped bar: top-10 topics split by member vs non-member.""" |
| if "is_member" not in df.columns: |
| return self._empty_fig(title, "No member data available") |
| exploded = explode_topics(df) |
| if exploded.empty: |
| return self._empty_fig(title, "No topic data") |
| exploded["member_status"] = exploded["is_member"].map({True: "Member", False: "Non-Member"}) |
| top_topics = exploded["topic_id"].value_counts().head(10).index.tolist() |
| exploded = exploded[exploded["topic_id"].isin(top_topics)] |
| pivot = pd.crosstab(exploded["topic_id"], exploded["member_status"]) |
| pivot.index = [topic_label(t, self.taxonomy) for t in pivot.index] |
| fig = go.Figure() |
| color_map = {"Member": "#1982C4", "Non-Member": "#FF6B35"} |
| for col in pivot.columns: |
| fig.add_trace(go.Bar( |
| name=col, y=pivot.index, x=pivot[col], orientation="h", |
| marker_color=color_map.get(col, "#CCCCCC"), |
| hovertemplate="<b>%{y}</b><br>%{x}<extra></extra>", |
| )) |
| fig.update_layout(title=title, barmode="group", xaxis_title="Conversations", |
| yaxis_title="Topic", height=self.chart_height + 80, |
| yaxis={"categoryorder": "total ascending"}) |
| return fig |
|
|
| |
| |
| |
|
|
| @staticmethod |
| def _empty_fig(title, message): |
| fig = go.Figure() |
| fig.add_annotation(text=message, xref="paper", yref="paper", |
| x=0.5, y=0.5, showarrow=False, font=dict(size=14)) |
| fig.update_layout(title=title, height=300) |
| return fig |