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)