import base64
import io
import json
import random
import dash
import numpy as np
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
from dash import Input, Output, State, callback, dcc, html
# Initialize the Dash app
app = dash.Dash(__name__, suppress_callback_exceptions=True)
# Define app layout
app.layout = html.Div(
[
# Header
html.Div(
[
html.H1(
"Sessions Observatory by helvia.ai ðŸ”📊",
className="app-header",
),
html.P(
"Upload a CSV/Excel file to visualize the chatbot's dialog topics.",
className="app-description",
),
],
className="header-container",
),
# File Upload Component
html.Div(
[
dcc.Upload(
id="upload-data",
children=html.Div(
[
html.Div("Drag and Drop", className="upload-text"),
html.Div("or", className="upload-divider"),
html.Div(
html.Button("Select a File", className="upload-button")
),
],
className="upload-content",
),
style={
"width": "100%",
"height": "120px",
"lineHeight": "60px",
"borderWidth": "1px",
"borderStyle": "dashed",
"borderRadius": "0.5rem",
"textAlign": "center",
"margin": "10px 0",
"backgroundColor": "hsl(210, 40%, 98%)",
"borderColor": "hsl(214.3, 31.8%, 91.4%)",
"cursor": "pointer",
},
multiple=False,
),
# Status message with more padding and emphasis
html.Div(
id="upload-status",
className="upload-status-message",
style={"display": "none"}, # Initially hidden
),
],
className="upload-container",
),
# Main Content Area (hidden until file is uploaded)
html.Div(
[
# Dashboard layout with flexible grid
html.Div(
[
# Left side: Bubble chart
html.Div(
[
html.H3(
id="topic-distribution-header",
children="Sessions Observatory",
className="section-header",
),
dcc.Graph(
id="bubble-chart",
style={"height": "calc(100% - 154px)"},
),
html.Div(
[
html.Div(
[
html.Div(
html.Label(
"Color by:",
className="control-label",
),
className="control-label-container",
),
],
className="control-labels-row",
),
html.Div(
[
html.Div(
dcc.RadioItems(
id="color-metric",
options=[
{
"label": "Sentiment",
"value": "negative_rate",
},
{
"label": "Resolution",
"value": "unresolved_rate",
},
{
"label": "Urgency",
"value": "urgent_rate",
},
],
value="negative_rate",
inline=True,
className="radio-group",
inputClassName="radio-input",
labelClassName="radio-label",
),
className="radio-container",
),
],
className="control-options-row",
),
],
className="chart-controls",
),
],
className="chart-container",
),
# Right side: Interactive sidebar with topic details
html.Div(
[
html.Div(
[
html.H3(
"Topic Details", className="section-header"
),
html.Div(
id="topic-title", className="topic-title"
),
html.Div(
[
html.Div(
[
html.H4(
"Metadata",
className="subsection-header",
),
html.Div(
id="topic-metadata",
className="metadata-container",
),
],
className="metadata-section",
),
html.Div(
[
html.H4(
"Key Metrics",
className="subsection-header",
),
html.Div(
id="topic-metrics",
className="metrics-container",
),
],
className="metrics-section",
),
# Added Root Causes section
html.Div(
[
html.H4(
[
"Root Causes",
html.I(
className="fas fa-info-circle",
title="Root cause detection is experimental and may require manual review since it is generated by AI models. Root causes are only shown in clusters with identifiable root causes.",
style={
"marginLeft": "0.2rem",
"color": "#6c757d",
"fontSize": "0.9rem",
"cursor": "pointer",
"verticalAlign": "middle",
},
),
],
className="subsection-header",
),
html.Div(
id="root-causes",
className="root-causes-container",
),
],
id="root-causes-section",
style={"display": "none"},
),
# Added Tags section
html.Div(
[
html.H4(
"Tags",
className="subsection-header",
),
html.Div(
id="important-tags",
className="tags-container",
),
],
id="tags-section",
style={"display": "none"},
),
],
className="details-section",
),
html.Div(
[
html.Div(
[
html.H4(
[
"Sample Dialogs (Summary)",
html.Button(
html.I(
className="fas fa-sync-alt"
),
id="refresh-dialogs-btn",
className="refresh-button",
title="Refresh dialogs",
n_clicks=0,
),
],
className="subsection-header",
style={
"margin": "0",
"display": "flex",
"alignItems": "center",
},
),
],
),
html.Div(
id="sample-dialogs",
className="sample-dialogs-container",
),
],
className="samples-section",
),
],
className="topic-details-content",
),
html.Div(
id="no-topic-selected",
children=[
html.Div(
[
html.I(
className="fas fa-info-circle info-icon"
),
html.H3("No topic selected"),
html.P(
"Click a bubble to view topic details."
),
],
className="no-selection-message",
)
],
className="no-selection-container",
),
],
className="sidebar-container",
),
],
className="dashboard-container",
)
],
id="main-content",
style={"display": "none"},
),
# Conversation Modal
html.Div(
id="conversation-modal",
children=[
html.Div(
children=[
html.Div(
[
html.H3(
"Full Conversation",
style={"margin": "0", "flex": "1"},
),
html.Button(
html.I(className="fas fa-times"),
id="close-modal-btn",
className="close-modal-btn",
title="Close",
),
],
className="modal-header",
),
html.Div(
id="conversation-subheader",
className="conversation-subheader",
),
html.Div(
id="conversation-content", className="conversation-content"
),
],
className="modal-content",
),
],
className="modal-overlay-conversation",
style={"display": "none"},
),
# Dialogs Table Modal
html.Div(
id="dialogs-table-modal",
children=[
html.Div(
children=[
html.Div(
[
html.H3(
id="dialogs-modal-title",
style={"margin": "0", "flex": "1"},
),
html.Button(
html.I(className="fas fa-times"),
id="close-dialogs-modal-btn",
className="close-modal-btn",
title="Close",
),
],
className="modal-header",
),
html.Div(
id="dialogs-table-content",
className="dialogs-table-content",
),
],
className="modal-content-large",
),
],
className="modal-overlay",
style={"display": "none"},
),
# Root Cause Dialogs Modal
html.Div(
id="root-cause-modal",
children=[
html.Div(
children=[
html.Div(
[
html.H3(
id="root-cause-modal-title",
style={"margin": "0", "flex": "1"},
),
html.Button(
html.I(className="fas fa-times"),
id="close-root-cause-modal-btn",
className="close-modal-btn",
title="Close",
),
],
className="modal-header",
),
html.Div(
id="root-cause-table-content",
className="dialogs-table-content",
),
],
className="modal-content-large",
),
],
className="modal-overlay",
style={"display": "none"},
),
# Store the processed data
dcc.Store(id="stored-data"),
# NEW: Store for the minimal raw dataframe
dcc.Store(id="raw-data"),
# Store the current selected topic for dialogs modal
dcc.Store(id="selected-topic-store"),
# Store the current selected root cause for root cause modal
dcc.Store(id="selected-root-cause-store"),
],
className="app-container",
)
# Define CSS for the app (no changes needed here, so it's omitted for brevity)
app.index_string = """
{%metas%}
Sessions Observatory by helvia.ai ðŸ”📊
{%favicon%}
{%css%}
{%app_entry%}
"""
@callback(
Output("topic-distribution-header", "children"),
Input("stored-data", "data"),
)
def update_topic_distribution_header(data):
if not data:
return "Sessions Observatory"
df = pd.DataFrame(data)
total_dialogs = df["count"].sum()
return f"Sessions Observatory ({total_dialogs} dialogs)"
# Define callback to process uploaded file
@callback(
[
Output("stored-data", "data"),
Output("raw-data", "data"),
Output("upload-status", "children"),
Output("upload-status", "style"),
Output("main-content", "style"),
],
[Input("upload-data", "contents")],
[State("upload-data", "filename")],
)
def process_upload(contents, filename):
if contents is None:
return None, None, "", {"display": "none"}, {"display": "none"}
try:
content_type, content_string = contents.split(",")
decoded = base64.b64decode(content_string)
if "csv" in filename.lower():
df = pd.read_csv(io.StringIO(decoded.decode("utf-8")), dtype={"Root_Cause": str})
elif "xls" in filename.lower():
df = pd.read_excel(io.BytesIO(decoded), dtype={"Root_Cause": str})
else:
return (
None,
None,
html.Div(
["Unsupported file. Please upload a CSV or Excel file."],
style={"color": "var(--destructive)"},
),
{"display": "block"},
{"display": "none"},
)
EXCLUDE_UNCLUSTERED = True
if EXCLUDE_UNCLUSTERED and "deduplicated_topic_name" in df.columns:
df = df[df["deduplicated_topic_name"] != "Unclustered"].copy()
else:
return (
None,
None,
html.Div(
["Please upload a CSV or Excel file with a 'deduplicated_topic_name' column."],
style={"color": "var(--destructive)"},
),
{"display": "block"},
{"display": "none"},
)
# Compute aggregated topic stats once
topic_stats = analyze_topics(df)
# Store only the columns you use elsewhere to keep payload smaller
needed_cols = [
"id",
"conversation",
"deduplicated_topic_name",
"consolidated_tags",
"Root_Cause",
"root_cause_subcluster",
"Sentiment",
"Resolution",
"Urgency",
"Summary",
]
df_min = df[[c for c in needed_cols if c in df.columns]].copy()
return (
topic_stats.to_dict("records"),
df_min.to_dict("records"),
html.Div(
[
html.I(
className="fas fa-check-circle",
style={"color": "hsl(142.1, 76.2%, 36.3%)", "marginRight": "8px"},
),
f'Successfully uploaded "{filename}"',
],
style={"color": "hsl(142.1, 76.2%, 36.3%)"},
),
{"display": "block"},
{"display": "block", "height": "calc(100vh - 40px)"},
)
except Exception as e:
return (
None,
None,
html.Div(
[
html.I(
className="fas fa-exclamation-triangle",
style={"color": "var(--destructive)", "marginRight": "8px"},
),
f"Error: {e}",
],
style={"color": "var(--destructive)"},
),
{"display": "block"},
{"display": "none"},
)
# Function to analyze the topics and create statistics
def analyze_topics(df):
topic_stats = (
df.groupby("deduplicated_topic_name")
.agg(
count=("id", "count"),
negative_count=("Sentiment", lambda x: (x == "negative").sum()),
unresolved_count=("Resolution", lambda x: (x == "unresolved").sum()),
urgent_count=("Urgency", lambda x: (x == "urgent").sum()),
)
.reset_index()
)
topic_stats["negative_rate"] = (topic_stats["negative_count"] / topic_stats["count"] * 100).round(1)
topic_stats["unresolved_rate"] = (topic_stats["unresolved_count"] / topic_stats["count"] * 100).round(1)
topic_stats["urgent_rate"] = (topic_stats["urgent_count"] / topic_stats["count"] * 100).round(1)
topic_stats = apply_binned_layout(topic_stats)
return topic_stats
# New binned layout function (no changes needed)
def apply_binned_layout(df, padding=0, bin_config=None, max_items_per_row=6):
df_sorted = df.copy()
if bin_config is None:
bin_config = [
(100, None, "100+ dialogs"), (50, 99, "50-99 dialogs"),
(25, 49, "25-49 dialogs"), (9, 24, "9-24 dialogs"),
(7, 8, "7-8 dialogs"), (5, 6, "5-6 dialogs"),
(4, 4, "4 dialogs"), (0, 3, "0-3 dialogs"),
]
bin_descriptions = {}
conditions = []
bin_values = []
for i, (lower, upper, description) in enumerate(bin_config):
bin_name = f"Bin {i + 1}"
bin_descriptions[bin_name] = description
bin_values.append(bin_name)
if upper is None:
conditions.append(df_sorted["count"] >= lower)
else:
conditions.append((df_sorted["count"] >= lower) & (df_sorted["count"] <= upper))
df_sorted["bin"] = np.select(conditions, bin_values, default=f"Bin {len(bin_config)}")
df_sorted["bin_description"] = df_sorted["bin"].map(bin_descriptions)
df_sorted = df_sorted.sort_values(by=["bin", "count"], ascending=[True, False])
original_bins = df_sorted["bin"].unique()
new_rows = []
new_bin_descriptions = bin_descriptions.copy()
for bin_name in original_bins:
bin_mask = df_sorted["bin"] == bin_name
bin_group = df_sorted[bin_mask]
bin_size = len(bin_group)
if bin_size > max_items_per_row:
num_sub_bins = (bin_size + max_items_per_row - 1) // max_items_per_row
items_per_sub_bin = [bin_size // num_sub_bins] * num_sub_bins
remainder = bin_size % num_sub_bins
for i in range(remainder):
items_per_sub_bin[i] += 1
original_description = bin_descriptions[bin_name]
start_idx = 0
for i in range(num_sub_bins):
new_bin_name = f"{bin_name}_{i + 1}"
new_description = f"{original_description} ({i + 1}/{num_sub_bins})"
new_bin_descriptions[new_bin_name] = new_description
end_idx = start_idx + items_per_sub_bin[i]
sub_bin_rows = bin_group.iloc[start_idx:end_idx].copy()
sub_bin_rows["bin"] = new_bin_name
sub_bin_rows["bin_description"] = new_description
new_rows.append(sub_bin_rows)
start_idx = end_idx
df_sorted = df_sorted[~bin_mask]
if new_rows:
df_sorted = pd.concat([df_sorted] + new_rows)
df_sorted = df_sorted.sort_values(by=["bin", "count"], ascending=[True, False])
bins_with_topics = sorted(df_sorted["bin"].unique())
num_rows = len(bins_with_topics)
available_height = 100 - (2 * padding)
row_height = available_height / num_rows
row_positions = {bin_name: padding + i * row_height + (row_height / 2) for i, bin_name in enumerate(bins_with_topics)}
df_sorted["y"] = df_sorted["bin"].map(row_positions)
center_point = 50
for bin_name in bins_with_topics:
bin_mask = df_sorted["bin"] == bin_name
num_topics_in_bin = bin_mask.sum()
if num_topics_in_bin == 1:
df_sorted.loc[bin_mask, "x"] = center_point
else:
spacing = 17.5 if num_topics_in_bin < max_items_per_row else 15
total_width = (num_topics_in_bin - 1) * spacing
start_pos = center_point - (total_width / 2)
positions = [start_pos + (i * spacing) for i in range(num_topics_in_bin)]
df_sorted.loc[bin_mask, "x"] = positions
df_sorted["size_rank"] = range(1, len(df_sorted) + 1)
return df_sorted
# function to update positions based on selected size metric (no changes needed)
def update_bubble_positions(df: pd.DataFrame) -> pd.DataFrame:
return apply_binned_layout(df)
# Callback to update the bubble chart (no changes needed)
@callback(
Output("bubble-chart", "figure"),
[
Input("stored-data", "data"),
Input("color-metric", "value"),
],
)
def update_bubble_chart(data, color_metric):
if not data:
return go.Figure()
df = pd.DataFrame(data)
# Note: `update_bubble_positions` is now called inside `analyze_topics` once
# and the results are stored. We don't call it here anymore.
# The 'x' and 'y' values are already in the `data`.
# df = update_bubble_positions(df) # This line can be removed if positions are pre-calculated
size_values = df["count"]
raw_sizes = df["count"]
size_title = "Dialog Count"
min_size = 1
if size_values.max() > size_values.min():
log_sizes = np.log1p(size_values)
size_values = (min_size + (log_sizes - log_sizes.min()) / (log_sizes.max() - log_sizes.min()) * 50)
else:
size_values = np.ones(len(df)) * 12.5
if color_metric == "negative_rate":
color_values = df["negative_rate"]
color_title = "Negativity (%)"
color_scale = "Teal"
elif color_metric == "unresolved_rate":
color_values = df["unresolved_rate"]
color_title = "Unresolved (%)"
color_scale = "Teal"
else: # urgent_rate
color_values = df["urgent_rate"]
color_title = "Urgency (%)"
color_scale = "Teal"
hover_text = [
f"Topic: {topic}
{size_title}: {raw:.1f}
{color_title}: {color:.1f}
Group: {bin_desc}"
for topic, raw, color, bin_desc in zip(df["deduplicated_topic_name"], raw_sizes, color_values, df["bin_description"])
]
fig = px.scatter(
df,
x="x", y="y",
size=size_values,
color=color_values,
hover_name="deduplicated_topic_name",
hover_data={"x": False, "y": False, "bin_description": True},
size_max=42.5,
color_continuous_scale=color_scale,
custom_data=["deduplicated_topic_name", "count", "negative_rate", "unresolved_rate", "urgent_rate", "bin_description"],
)
fig.update_traces(
mode="markers",
marker=dict(sizemode="area", opacity=0.8, line=dict(width=1, color="white")),
hovertemplate="%{hovertext}",
hovertext=hover_text,
)
annotations = []
for i, row in df.iterrows():
words = row["deduplicated_topic_name"].split()
wrapped_text = "
".join([" ".join(words[i : i + 4]) for i in range(0, len(words), 4)])
# Use df.index.get_loc(i) to safely get the index position for size_values
marker_size = (size_values[df.index.get_loc(i)] / 20)
annotations.append(
dict(
x=row["x"], y=row["y"] + 0.125 + marker_size,
text=wrapped_text, showarrow=False, textangle=0,
font=dict(size=9, color="var(--foreground)", family="Arial, sans-serif", weight="bold"),
xanchor="center", yanchor="top",
bgcolor="rgba(255,255,255,0.7)", bordercolor="rgba(0,0,0,0.1)",
borderwidth=1, borderpad=1,
)
)
unique_bins = sorted(df["bin"].unique())
bin_y_positions = [df[df["bin"] == bin_name]["y"].mean() for bin_name in unique_bins]
bin_descriptions = df.set_index("bin")["bin_description"].to_dict()
for bin_name, bin_y in zip(unique_bins, bin_y_positions):
fig.add_shape(type="line", x0=0, y0=bin_y, x1=100, y1=bin_y, line=dict(color="rgba(0,0,0,0.1)", width=1, dash="dot"), layer="below")
annotations.append(
dict(
x=0, y=bin_y, xref="x", yref="y",
text=bin_descriptions[bin_name], showarrow=False,
font=dict(size=8.25, color="var(--muted-foreground)"),
align="left", xanchor="left", yanchor="middle",
bgcolor="rgba(255,255,255,0.7)", borderpad=1,
)
)
fig.update_layout(
title=None,
xaxis=dict(showgrid=False, zeroline=False, showticklabels=False, title=None, range=[0, 100]),
yaxis=dict(showgrid=False, zeroline=False, showticklabels=False, title=None, range=[0, 100], autorange="reversed"),
hovermode="closest",
margin=dict(l=0, r=0, t=10, b=10),
coloraxis_colorbar=dict(title=color_title, title_font=dict(size=9), tickfont=dict(size=8), thickness=10, len=0.6, yanchor="middle", y=0.5, xpad=0),
legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
hoverlabel=dict(bgcolor="white", font_size=12, font_family="Inter"),
annotations=annotations,
)
return fig
# NEW: Update the topic details callback to be CLICK-ONLY and use the raw-data store
@callback(
[
Output("topic-title", "children"),
Output("topic-metadata", "children"),
Output("topic-metrics", "children"),
Output("root-causes", "children"),
Output("root-causes-section", "style"),
Output("important-tags", "children"),
Output("tags-section", "style"),
Output("sample-dialogs", "children"),
Output("no-topic-selected", "style"),
Output("selected-topic-store", "data"),
],
[
Input("bubble-chart", "clickData"), # Changed from hoverData
Input("refresh-dialogs-btn", "n_clicks"),
],
[State("stored-data", "data"), State("raw-data", "data")],
)
def update_topic_details(click_data, refresh_clicks, stored_data, raw_data):
# This callback now only fires on click or refresh
ctx = dash.callback_context
triggered_id = ctx.triggered[0]["prop_id"].split(".")[0]
# If nothing triggered this, or data is missing, show the initial message
if not triggered_id or not stored_data or not raw_data:
return "", [], [], "", {"display": "none"}, "", {"display": "none"}, [], {"display": "flex"}, None
# We need to know which topic is currently selected if we are refreshing
if triggered_id == "refresh-dialogs-btn":
# To refresh, we would need to know the current topic. This requires
# getting it from a store. For simplicity, we can just use the last clickData.
# A more robust solution would use another dcc.Store for the *active* topic.
# For now, if there is no click_data, a refresh does nothing.
if not click_data:
return dash.no_update
topic_name = click_data["points"][0]["customdata"][0]
df_stored = pd.DataFrame(stored_data)
topic_data = df_stored[df_stored["deduplicated_topic_name"] == topic_name].iloc[0]
# Use the pre-processed data from the store - this is the fast part!
df_full = pd.DataFrame(raw_data)
topic_conversations = df_full[df_full["deduplicated_topic_name"] == topic_name]
# --- From here, all the UI building code is the same ---
title = html.Div([html.Span(topic_name)])
metadata_items = [
html.Div(
[
html.I(className="fas fa-comments metadata-icon"),
html.Span(f"{int(topic_data['count'])} dialogs"),
html.Button(
[
html.I(className="fas fa-table", style={"marginRight": "0.25rem"}),
"Show all dialogs",
],
id="show-all-dialogs-btn",
className="show-dialogs-btn",
n_clicks=0,
),
],
className="metadata-item",
style={"display": "flex", "alignItems": "center", "width": "100%"},
),
]
metrics_boxes = [
html.Div(
[
html.Div(f"{topic_data['negative_rate']}%", className="metric-value"),
html.Div("Negative Sentiment", className="metric-label"),
],
className="metric-box negative",
),
html.Div(
[
html.Div(f"{topic_data['unresolved_rate']}%", className="metric-value"),
html.Div("Unresolved", className="metric-label"),
],
className="metric-box unresolved",
),
html.Div(
[
html.Div(f"{topic_data['urgent_rate']}%", className="metric-value"),
html.Div("Urgent", className="metric-label"),
],
className="metric-box urgent",
),
]
root_causes_output = ""
root_causes_section_style = {"display": "none"}
if "root_cause_subcluster" in topic_conversations.columns:
filtered_root_causes = [
rc for rc in topic_conversations["root_cause_subcluster"].dropna().unique()
if rc not in ["Sub-clustering disabled", "Not eligible for sub-clustering", "No valid root causes", "No Subcluster", "Unclustered", ""]
]
if filtered_root_causes:
root_causes_output = html.Div(
[
html.Div(
[
html.I(className="fas fa-exclamation-triangle root-cause-tag-icon"),
html.Span(root_cause, style={"marginRight": "6px"}),
html.I(
className="fas fa-external-link-alt root-cause-click-icon",
id={"type": "root-cause-icon", "index": root_cause},
title="Click to see specific chats assigned with this root cause.",
style={"cursor": "pointer", "fontSize": "0.55rem", "opacity": "0.8"},
),
],
className="root-cause-tag",
style={"display": "inline-flex", "alignItems": "center"},
)
for root_cause in filtered_root_causes
],
className="root-causes-container",
)
root_causes_section_style = {"display": "block"}
tags_list = []
if "consolidated_tags" in topic_conversations.columns:
for tags_str in topic_conversations["consolidated_tags"].dropna():
tags_list.extend([tag.strip() for tag in tags_str.split(",") if tag.strip()])
tag_counts = {}
for tag in tags_list:
tag_counts[tag] = tag_counts.get(tag, 0) + 1
sorted_tags = sorted(tag_counts.items(), key=lambda x: (-x[1], x[0]))[:15]
tags_section_style = {"display": "none"}
if sorted_tags:
tags_output = html.Div(
[
html.Div(
[
html.I(className="fas fa-tag topic-tag-icon"),
html.Span(f"{tag} ({count})"),
],
className="topic-tag",
)
for tag, count in sorted_tags
],
className="tags-container",
)
tags_section_style = {"display": "block"}
else:
tags_output = html.Div(
[html.I(className="fas fa-info-circle", style={"marginRight": "5px"}), "No tags found for this topic"],
className="no-tags-message",
)
sample_size = min(5, len(topic_conversations))
if sample_size > 0:
samples = topic_conversations.sample(n=sample_size)
dialog_items = []
for _, row in samples.iterrows():
tags = [
html.Span(row["Sentiment"], className="dialog-tag tag-sentiment"),
html.Span(row["Resolution"], className="dialog-tag tag-resolution"),
html.Span(row["Urgency"], className="dialog-tag tag-urgency"),
]
if "id" in row:
tags.append(html.Span(
[f"Chat ID: {row['id']} ", html.I(className="fas fa-arrow-up-right-from-square conversation-icon", id={"type": "conversation-icon", "index": row["id"]}, title="View full conversation", style={"marginLeft": "0.25rem"})],
className="dialog-tag tag-chat-id", style={"display": "inline-flex", "alignItems": "center"}
))
if "Root_Cause" in row and pd.notna(row["Root_Cause"]) and row["Root_Cause"] != "na":
tags.append(html.Span(f"Root Cause: {row['Root_Cause']}", className="dialog-tag tag-root-cause"))
dialog_items.append(
html.Div(
[html.Div(row["Summary"], className="dialog-summary"), html.Div(tags, className="dialog-metadata")],
className="dialog-item",
)
)
sample_dialogs = dialog_items
else:
sample_dialogs = [html.Div("No sample dialogs available for this topic.", style={"color": "var(--muted-foreground)"})]
return (
title,
metadata_items,
metrics_boxes,
root_causes_output,
root_causes_section_style,
tags_output,
tags_section_style,
sample_dialogs,
{"display": "none"},
{"topic_name": topic_name}, # Pass only the topic name
)
# NEW: Updated to use raw-data store
@callback(
[
Output("conversation-modal", "style"),
Output("conversation-content", "children"),
Output("conversation-subheader", "children"),
],
[Input({"type": "conversation-icon", "index": dash.dependencies.ALL}, "n_clicks")],
[State("raw-data", "data")],
prevent_initial_call=True,
)
def open_conversation_modal(n_clicks_list, raw_data):
if not any(n_clicks_list) or not raw_data:
return {"display": "none"}, "", ""
ctx = dash.callback_context
if not ctx.triggered:
return {"display": "none"}, "", ""
triggered_id = ctx.triggered[0]["prop_id"]
chat_id = json.loads(triggered_id.split(".")[0])["index"]
df_full = pd.DataFrame(raw_data)
conversation_row = df_full[df_full["id"] == chat_id]
if len(conversation_row) == 0:
conversation_text = "Conversation not found."
subheader_content = f"Chat ID: {chat_id}"
else:
row = conversation_row.iloc[0]
conversation_text = row.get("conversation", "No conversation data available.")
cluster_name = row.get("deduplicated_topic_name", "Unknown cluster")
subheader_content = html.Div(
[
html.Span(f"Chat ID: {chat_id}", style={"fontWeight": "600", "marginRight": "1rem"}),
html.Span(f"Cluster: {cluster_name}", style={"color": "hsl(215.4, 16.3%, 46.9%)"}),
]
)
return {"display": "flex"}, conversation_text, subheader_content
# Callback to close modal (no changes needed)
@callback(
Output("conversation-modal", "style", allow_duplicate=True),
[Input("close-modal-btn", "n_clicks")],
prevent_initial_call=True,
)
def close_conversation_modal(n_clicks):
if n_clicks:
return {"display": "none"}
return dash.no_update
# NEW: Updated to use raw-data store
@callback(
[
Output("dialogs-table-modal", "style"),
Output("dialogs-modal-title", "children"),
Output("dialogs-table-content", "children"),
],
[Input("show-all-dialogs-btn", "n_clicks")],
[State("selected-topic-store", "data"), State("raw-data", "data")],
prevent_initial_call=True,
)
def open_dialogs_table_modal(n_clicks, selected_topic_data, raw_data):
if not n_clicks or not selected_topic_data or not raw_data:
return {"display": "none"}, "", ""
topic_name = selected_topic_data["topic_name"]
df_full = pd.DataFrame(raw_data)
topic_conversations = df_full[df_full["deduplicated_topic_name"] == topic_name]
table_rows = [
html.Tr([
html.Th("Chat ID"), html.Th("Summary"), html.Th("Root Cause"),
html.Th("Sentiment"), html.Th("Resolution"), html.Th("Urgency"),
html.Th("Tags"), html.Th("Action"),
])
]
for _, row in topic_conversations.iterrows():
tags_display = "No tags"
if "consolidated_tags" in row and pd.notna(row["consolidated_tags"]):
tags = [tag.strip() for tag in row["consolidated_tags"].split(",") if tag.strip()]
tags_display = html.Div([
html.Span(tag, className="dialog-tag-small", style={"backgroundColor": "#6c757d", "color": "white"}) for tag in tags[:3]
] + ([html.Span(f"+{len(tags) - 3}", className="dialog-tag-small", style={"backgroundColor": "#6c757d", "color": "white"})] if len(tags) > 3 else []))
table_rows.append(
html.Tr([
html.Td(row["id"], style={"fontFamily": "monospace", "fontSize": "0.8rem"}),
html.Td(row.get("Summary", "No summary"), className="dialog-summary-cell"),
html.Td(html.Span(str(row.get("Root_Cause", "Unknown")).capitalize() if pd.notna(row.get("Root_Cause")) else "Unknown", className="dialog-tag-small", style={"backgroundColor": "#8B4513", "color": "white"})),
html.Td(html.Span(row.get("Sentiment", "Unknown").capitalize(), className="dialog-tag-small", style={"backgroundColor": "#dc3545" if row.get("Sentiment") == "negative" else "#6c757d", "color": "white"})),
html.Td(html.Span(row.get("Resolution", "Unknown").capitalize(), className="dialog-tag-small", style={"backgroundColor": "#dc3545" if row.get("Resolution") == "unresolved" else "#6c757d", "color": "white"})),
html.Td(html.Span(row.get("Urgency", "Unknown").capitalize(), className="dialog-tag-small", style={"backgroundColor": "#dc3545" if row.get("Urgency") == "urgent" else "#6c757d", "color": "white"})),
html.Td(tags_display, className="dialog-tags-cell"),
html.Td(html.Button([html.I(className="fas fa-eye", style={"marginRight": "0.25rem"}), "View chat"], id={"type": "open-chat-btn", "index": row["id"]}, className="open-chat-btn")),
])
)
table = html.Table(table_rows, className="dialogs-table")
modal_title = f"All dialogs in Topic: {topic_name} ({len(topic_conversations)} dialogs)"
return {"display": "flex"}, modal_title, table
# Callback to close dialogs table modal (no changes needed)
@callback(
Output("dialogs-table-modal", "style", allow_duplicate=True),
[Input("close-dialogs-modal-btn", "n_clicks")],
prevent_initial_call=True,
)
def close_dialogs_table_modal(n_clicks):
if n_clicks:
return {"display": "none"}
return dash.no_update
# NEW: Updated to use raw-data store
@callback(
[
Output("conversation-modal", "style", allow_duplicate=True),
Output("conversation-content", "children", allow_duplicate=True),
Output("conversation-subheader", "children", allow_duplicate=True),
],
[Input({"type": "open-chat-btn", "index": dash.dependencies.ALL}, "n_clicks")],
[State("raw-data", "data")],
prevent_initial_call=True,
)
def open_conversation_from_table(n_clicks_list, raw_data):
if not any(n_clicks_list) or not raw_data:
return {"display": "none"}, "", ""
ctx = dash.callback_context
if not ctx.triggered:
return {"display": "none"}, "", ""
triggered_id = ctx.triggered[0]["prop_id"]
chat_id = json.loads(triggered_id.split(".")[0])["index"]
df_full = pd.DataFrame(raw_data)
conversation_row = df_full[df_full["id"] == chat_id]
if len(conversation_row) == 0:
conversation_text = f"Conversation not found for Chat ID: {chat_id}"
subheader_content = f"Chat ID: {chat_id} (Not Found)"
else:
row = conversation_row.iloc[0]
conversation_text = row.get("conversation", "No conversation data available.")
subheader_content = f"Chat ID: {chat_id} | Topic: {row.get('deduplicated_topic_name', 'Unknown')} | Sentiment: {row.get('Sentiment', 'Unknown')} | Resolution: {row.get('Resolution', 'Unknown')}"
return {"display": "flex"}, conversation_text, subheader_content
# NEW: Updated to use raw-data store
@callback(
[
Output("root-cause-modal", "style"),
Output("root-cause-modal-title", "children"),
Output("root-cause-table-content", "children"),
],
[Input({"type": "root-cause-icon", "index": dash.dependencies.ALL}, "n_clicks")],
[State("selected-topic-store", "data"), State("raw-data", "data")],
prevent_initial_call=True,
)
def open_root_cause_modal(n_clicks_list, selected_topic_data, raw_data):
if not any(n_clicks_list) or not selected_topic_data or not raw_data:
return {"display": "none"}, "", ""
ctx = dash.callback_context
if not ctx.triggered:
return {"display": "none"}, "", ""
triggered_id = ctx.triggered[0]["prop_id"]
root_cause = json.loads(triggered_id.split(".")[0])["index"]
topic_name = selected_topic_data["topic_name"]
df_full = pd.DataFrame(raw_data)
filtered_conversations = df_full[
(df_full["deduplicated_topic_name"] == topic_name)
& (df_full["root_cause_subcluster"] == root_cause)
]
table_rows = [
html.Tr([
html.Th("Chat ID"), html.Th("Summary"), html.Th("Sentiment"),
html.Th("Resolution"), html.Th("Urgency"), html.Th("Tags"), html.Th("Action"),
])
]
for _, row in filtered_conversations.iterrows():
tags_display = "No tags"
if "consolidated_tags" in row and pd.notna(row["consolidated_tags"]):
tags = [tag.strip() for tag in row["consolidated_tags"].split(",") if tag.strip()]
tags_display = html.Div([
html.Span(tag, className="dialog-tag-small", style={"backgroundColor": "#6c757d", "color": "white"}) for tag in tags[:3]
] + ([html.Span(f"+{len(tags) - 3}", className="dialog-tag-small", style={"backgroundColor": "#6c757d", "color": "white"})] if len(tags) > 3 else []))
table_rows.append(
html.Tr([
html.Td(row["id"], style={"fontFamily": "monospace", "fontSize": "0.8rem"}),
html.Td(row.get("Summary", "No summary"), className="dialog-summary-cell"),
html.Td(html.Span(row.get("Sentiment", "Unknown").capitalize(), className="dialog-tag-small", style={"backgroundColor": "#dc3545" if row.get("Sentiment") == "negative" else "#6c757d", "color": "white"})),
html.Td(html.Span(row.get("Resolution", "Unknown").capitalize(), className="dialog-tag-small", style={"backgroundColor": "#dc3545" if row.get("Resolution") == "unresolved" else "#6c757d", "color": "white"})),
html.Td(html.Span(row.get("Urgency", "Unknown").capitalize(), className="dialog-tag-small", style={"backgroundColor": "#dc3545" if row.get("Urgency") == "urgent" else "#6c757d", "color": "white"})),
html.Td(tags_display, className="dialog-tags-cell"),
html.Td(html.Button([html.I(className="fas fa-eye", style={"marginRight": "0.25rem"}), "View chat"], id={"type": "open-chat-btn-rc", "index": row["id"]}, className="open-chat-btn")),
])
)
table = html.Table(table_rows, className="dialogs-table")
modal_title = f"Dialogs for Root Cause: {root_cause} (in Topic: {topic_name})"
count_info = html.P(
f"Found {len(filtered_conversations)} dialogs with this root cause.",
style={"margin": "0 0 1rem 0", "color": "var(--muted-foreground)", "fontSize": "0.875rem"},
)
content = html.Div([count_info, table])
return {"display": "flex"}, modal_title, content
# Callback to close root cause modal (no changes needed)
@callback(
Output("root-cause-modal", "style", allow_duplicate=True),
[Input("close-root-cause-modal-btn", "n_clicks")],
prevent_initial_call=True,
)
def close_root_cause_modal(n_clicks):
if n_clicks:
return {"display": "none"}
return dash.no_update
# NEW: Updated to use raw-data store
@callback(
[
Output("conversation-modal", "style", allow_duplicate=True),
Output("conversation-content", "children", allow_duplicate=True),
Output("conversation-subheader", "children", allow_duplicate=True),
],
[Input({"type": "open-chat-btn-rc", "index": dash.dependencies.ALL}, "n_clicks")],
[State("raw-data", "data")],
prevent_initial_call=True,
)
def open_conversation_from_root_cause_table(n_clicks_list, raw_data):
if not any(n_clicks_list) or not raw_data:
return {"display": "none"}, "", ""
ctx = dash.callback_context
if not ctx.triggered:
return {"display": "none"}, "", ""
triggered_id = ctx.triggered[0]["prop_id"]
chat_id = json.loads(triggered_id.split(".")[0])["index"]
df_full = pd.DataFrame(raw_data)
conversation_row = df_full[df_full["id"] == chat_id]
if len(conversation_row) == 0:
conversation_row = df_full[df_full["id"].astype(str) == str(chat_id)]
if len(conversation_row) == 0:
conversation_text = f"Conversation not found for Chat ID: {chat_id}"
subheader_content = f"Chat ID: {chat_id} (Not Found)"
else:
row = conversation_row.iloc[0]
conversation_text = row.get("conversation", "No conversation data available.")
root_cause = row.get("root_cause_subcluster", "Unknown")
cluster_name = row.get("deduplicated_topic_name", "Unknown cluster")
subheader_content = html.Div([
html.Span(f"Chat ID: {chat_id}", style={"fontWeight": "600", "marginRight": "1rem"}),
html.Span(f"Cluster: {cluster_name}", style={"color": "hsl(215.4, 16.3%, 46.9%)", "marginRight": "1rem"}),
html.Span(f"Root Cause: {root_cause}", style={"color": "#8b6f47", "fontWeight": "500"}),
])
return {"display": "flex"}, conversation_text, subheader_content
# IMPORTANT: Expose the server for Gunicorn
server = app.server
if __name__ == "__main__":
app.run_server(debug=True)