eloukas's picture
Disable rereading
7043798 verified
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 = """
<!DOCTYPE html>
<html>
<head>
{%metas%}
<title>Sessions Observatory by helvia.ai πŸ”­πŸ“Š</title>
{%favicon%}
{%css%}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
:root {
--background: hsl(210, 20%, 95%);
--foreground: hsl(222.2, 84%, 4.9%);
--card: hsl(0, 0%, 100%);
--card-foreground: hsl(222.2, 84%, 4.9%);
--popover: hsl(0, 0%, 100%);
--popover-foreground: hsl(222.2, 84%, 4.9%);
--primary: hsl(222.2, 47.4%, 11.2%);
--primary-foreground: hsl(210, 40%, 98%);
--secondary: hsl(210, 40%, 96.1%);
--secondary-foreground: hsl(222.2, 47.4%, 11.2%);
--muted: hsl(210, 40%, 96.1%);
--muted-foreground: hsl(215.4, 16.3%, 46.9%);
--accent: hsl(210, 40%, 96.1%);
--accent-foreground: hsl(222.2, 47.4%, 11.2%);
--destructive: hsl(0, 84.2%, 60.2%);
--destructive-foreground: hsl(210, 40%, 98%);
--border: hsl(214.3, 31.8%, 91.4%);
--input: hsl(214.3, 31.8%, 91.4%);
--ring: hsl(222.2, 84%, 4.9%);
--radius: 0.5rem;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Inter', sans-serif;
}
body {
background-color: var(--background);
color: var(--foreground);
font-feature-settings: "rlig" 1, "calt" 1;
}
.app-container {
max-width: 2500px;
margin: 0 auto;
padding: 1.5rem;
background-color: var(--background);
min-height: 100vh;
display: flex;
flex-direction: column;
}
.header-container {
margin-bottom: 2rem;
text-align: center;
}
.app-header {
color: var(--foreground);
margin-bottom: 0.75rem;
font-weight: 600;
font-size: 2rem;
line-height: 1.2;
}
.app-description {
color: var(--muted-foreground);
font-size: 1rem;
line-height: 1.5;
}
.upload-container {
margin-bottom: 2rem;
max-width: 800px;
margin-left: auto;
margin-right: auto;
}
.upload-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 80%;
padding: 1.5rem;
position: relative;
}
.upload-text {
font-size: 1rem;
color: var(--primary);
font-weight: 500;
}
.upload-divider {
color: var(--muted-foreground);
margin: 0.5rem 0;
font-size: 0.875rem;
}
.upload-button {
background-color: var(--primary);
color: var(--primary-foreground);
border: none;
padding: 0.5rem 1rem;
border-radius: var(--radius);
font-size: 0.875rem;
cursor: pointer;
transition: opacity 0.2s;
font-weight: 500;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
height: 2.5rem;
}
.upload-button:hover {
opacity: 0.9;
}
/* Status message styling */
.upload-status-message {
margin-top: 1rem;
padding: 0.75rem;
font-weight: 500;
text-align: center;
border-radius: var(--radius);
font-size: 0.875rem;
transition: all 0.3s ease;
background-color: var(--secondary);
color: var(--secondary-foreground);
}
/* Chart controls styling */
.chart-controls {
margin-top: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
background-color: var(--card);
border-radius: var(--radius);
border: 1px solid var(--border);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.control-labels-row {
display: flex;
width: 100%;
}
.control-options-row {
display: flex;
width: 100%;
}
.control-label-container {
padding: 0 0.5rem;
text-align: left;
}
.control-label {
font-weight: 500;
color: var(--foreground);
font-size: 0.875rem;
line-height: 1.25rem;
}
.radio-container {
padding: 0 0.5rem;
width: 100%;
}
.radio-group {
display: flex;
gap: 1rem;
}
.radio-input {
margin-right: 0.375rem;
cursor: pointer;
height: 1rem;
width: 1rem;
border-radius: 9999px;
border: 1px solid var(--border);
appearance: none;
-webkit-appearance: none;
background-color: var(--background);
transition: border-color 0.2s;
}
.radio-input:checked {
border-color: var(--primary);
background-color: var(--primary);
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e");
background-size: 100% 100%;
background-position: center;
background-repeat: no-repeat;
}
.radio-label {
font-weight: 400;
color: var(--foreground);
display: flex;
align-items: center;
cursor: pointer;
font-size: 0.875rem;
line-height: 1.25rem;
}
/* Dashboard container */
.dashboard-container {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
flex: 1;
height: 100%;
}
.chart-container {
flex: 2.75;
min-width: 400px;
background: var(--card);
border-radius: var(--radius);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
padding: 1rem;
border: 0.75px solid var(--border);
height: 100%;
}
.sidebar-container {
flex: 1;
min-width: 300px;
background: var(--card);
border-radius: var(--radius);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
padding: 1rem;
position: relative;
height: 100vh;
overflow-y: auto;
border: 1px solid var(--border);
height: 100%;
}
.section-header {
margin-bottom: 1rem;
color: var(--foreground);
border-bottom: 1px solid var(--border);
padding-bottom: 0.75rem;
font-weight: 600;
font-size: 1.25rem;
}
.subsection-header {
margin: 1rem 0 0.75rem;
color: var(--foreground);
font-size: 1rem;
font-weight: 600;
}
.topic-title {
font-size: 1.25rem;
font-weight: 600;
color: var(--foreground);
margin-bottom: 1rem;
padding: 0.5rem 0.75rem;
background-color: var(--secondary);
border-radius: var(--radius);
}
.metadata-container {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 1rem;
}
.metadata-item {
background-color: var(--secondary);
padding: 0.5rem 0.75rem;
border-radius: var(--radius);
font-size: 0.875rem;
display: flex;
align-items: center;
color: var(--secondary-foreground);
}
.metadata-icon {
margin-right: 0.5rem;
color: var(--primary);
}
.metrics-container {
display: flex;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.metric-box {
background-color: var(--card);
border-radius: var(--radius);
padding: 0.75rem;
text-align: center;
flex: 1;
border: 1px solid var(--border);
}
.metric-box.negative {
border-left: 3px solid var(--destructive);
}
.metric-box.unresolved {
border-left: 3px solid hsl(47.9, 95.8%, 53.1%);
}
.metric-box.urgent {
border-left: 3px solid hsl(217.2, 91.2%, 59.8%);
}
.metric-value {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 0.25rem;
color: var(--foreground);
line-height: 1;
}
.metric-label {
font-size: 0.75rem;
color: var(--muted-foreground);
}
.sample-dialogs-container {
margin-top: 0.75rem;
}
.dialog-item {
background-color: var(--secondary);
border-radius: var(--radius);
padding: 1rem;
margin-bottom: 0.75rem;
border-left: 3px solid var(--primary);
}
.dialog-summary {
font-size: 0.875rem;
line-height: 1.5;
margin-bottom: 0.5rem;
color: var(--foreground);
}
.dialog-metadata {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
font-size: 0.75rem;
}
.dialog-tag {
padding: 0.25rem 0.5rem;
border-radius: var(--radius);
font-size: 0.7rem;
font-weight: 500;
}
.tag-sentiment {
background-color: var(--destructive);
color: var(--destructive-foreground);
}
.tag-resolution {
background-color: hsl(47.9, 95.8%, 53.1%);
color: hsl(222.2, 84%, 4.9%);
}
.tag-urgency {
background-color: hsl(217.2, 91.2%, 59.8%);
color: hsl(210, 40%, 98%);
}
.tag-chat-id {
background-color: hsl(215.4, 16.3%, 46.9%);
color: hsl(210, 40%, 98%);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-weight: 500;
}
.tag-root-cause {
background-color: #8B4513;
color: hsl(0, 0%, 98%);
font-weight: 500;
}
.refresh-button {
background-color: hsl(210, 40%, 98%);
border: 1px solid hsl(214.3, 31.8%, 91.4%);
border-radius: 0.25rem;
padding: 0.25rem;
cursor: pointer;
color: hsl(222.2, 84%, 4.9%);
font-size: 0.75rem;
transition: all 0.15s ease-in-out;
display: flex;
align-items: center;
justify-content: center;
min-width: 1.5rem;
height: 1.5rem;
margin-left: 0.5rem;
}
.refresh-button:hover {
background-color: hsl(210, 40%, 96%);
border-color: hsl(214.3, 31.8%, 81.4%);
}
.refresh-button:active {
background-color: hsl(210, 40%, 94%);
transform: scale(0.98);
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.modal-overlay-conversation {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.7);
z-index: 1100;
display: flex;
align-items: center;
justify-content: center;
}
.modal-content {
background-color: white;
border-radius: 0.5rem;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
max-width: 80%;
max-height: 80%;
width: 600px;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
border-bottom: 1px solid hsl(214.3, 31.8%, 91.4%);
}
.close-modal-btn {
background: none;
border: none;
cursor: pointer;
color: hsl(215.4, 16.3%, 46.9%);
font-size: 1.2rem;
padding: 0.5rem;
border-radius: 0.25rem;
transition: all 0.15s ease-in-out;
}
.close-modal-btn:hover {
background-color: hsl(210, 40%, 96%);
color: hsl(222.2, 84%, 4.9%);
}
.conversation-subheader {
padding: 0.75rem 1rem;
border-bottom: 1px solid hsl(214.3, 31.8%, 91.4%);
background-color: hsl(210, 40%, 98%);
font-size: 0.875rem;
color: hsl(215.4, 16.3%, 46.9%);
margin: 0 1rem;
border-radius: 0.25rem 0.25rem 0 0;
}
.conversation-content {
padding: 1rem;
overflow-y: auto;
max-height: 60vh;
white-space: pre-wrap;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 0.875rem;
line-height: 1.5;
color: hsl(222.2, 84%, 4.9%);
background-color: hsl(210, 40%, 98%);
border-radius: 0.25rem;
margin: 0 1rem 1rem 1rem;
}
.conversation-icon {
margin-left: 0.5rem;
cursor: pointer;
color: hsl(210, 40%, 98%);
font-size: 0.875rem;
padding: 0.25rem;
border-radius: 0.25rem;
transition: all 0.15s ease-in-out;
}
.conversation-icon:hover {
background-color: rgba(255, 255, 255, 0.2);
color: hsl(210, 40%, 98%);
}
.no-selection-container {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: hsla(0, 0%, 100%, 0.95);
z-index: 10;
border-radius: var(--radius);
}
.no-selection-message {
text-align: center;
color: var(--muted-foreground);
padding: 1.5rem;
}
.info-icon {
font-size: 2rem;
margin-bottom: 0.75rem;
color: var(--muted);
}
/* Tags container */
.tags-container {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 5px;
margin-bottom: 15px;
padding: 6px;
border-radius: 8px;
background-color: #f8f9fa;
}
/* Root Causes container */
.root-causes-container {
display: flex;
flex-wrap: wrap;
gap: 3px;
margin-top: 3px;
margin-bottom: 10px;
padding: 4px;
border-radius: 6px;
background-color: #f8f9fa;
}
.topic-tag {
padding: 0.375rem 0.75rem;
border-radius: var(--radius);
font-size: 0.75rem;
display: inline-flex;
align-items: center;
transition: all 0.2s ease;
font-weight: 500;
margin-bottom: 0.25rem;
cursor: default;
background-color: var(--muted);
color: var(--muted-foreground);
border: 1px solid var(--border);
}
.topic-tag {
padding: 6px 12px;
border-radius: 15px;
font-size: 0.8rem;
display: inline-flex;
align-items: center;
box-shadow: 0 1px 3px rgba(0,0,0,0.12);
transition: all 0.2s ease;
font-weight: 500;
margin-bottom: 5px;
cursor: default;
border: 1px solid rgba(0,0,0,0.08);
background-color: #6c757d; /* Consistent medium gray color */
color: white;
}
.topic-tag:hover {
transform: translateY(-1px);
box-shadow: 0 3px 5px rgba(0,0,0,0.15);
background-color: #5a6268; /* Slightly darker on hover */
}
.topic-tag-icon {
margin-right: 5px;
font-size: 0.7rem;
opacity: 0.8;
color: rgba(255, 255, 255, 0.9);
}
.root-cause-tag {
padding: 3px 8px;
border-radius: 12px;
font-size: 0.7rem;
display: inline-flex;
align-items: center;
box-shadow: 0 1px 2px rgba(0,0,0,0.08);
transition: all 0.2s ease;
font-weight: 500;
margin: 2px 3px 2px 0;
cursor: default;
border: 1px solid rgba(0,0,0,0.06);
background-color: #8b6f47; /* Muted brown/amber color for root causes */
color: white;
line-height: 1.2;
}
.root-cause-tag:hover {
transform: translateY(-1px);
box-shadow: 0 3px 5px rgba(0,0,0,0.15);
background-color: #7a5f3d; /* Slightly darker on hover */
}
.root-cause-tag-icon {
margin-right: 3px;
font-size: 0.6rem;
opacity: 0.8;
color: rgba(255, 255, 255, 0.9);
}
.root-cause-click-icon {
transition: all 0.2s ease;
color: rgba(255, 255, 255, 0.8);
}
.root-cause-click-icon:hover {
opacity: 1 !important;
transform: scale(1.1);
color: rgba(255, 255, 255, 1);
}
.no-tags-message {
color: var(--muted-foreground);
font-style: italic;
padding: 0.75rem;
text-align: center;
width: 100%;
}
.no-root-causes-message {
color: var(--muted-foreground);
font-style: italic;
padding: 0.75rem;
text-align: center;
width: 100%;
}
/* Show All Dialogs Button */
.show-dialogs-btn {
background-color: var(--primary);
color: var(--primary-foreground);
border: none;
padding: 0.5rem 0.75rem;
border-radius: var(--radius);
font-size: 0.75rem;
cursor: pointer;
transition: all 0.2s ease;
font-weight: 500;
margin-left: 0.5rem;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.show-dialogs-btn:hover {
background-color: var(--primary);
opacity: 0.9;
transform: translateY(-1px);
}
/* Dialogs Table Modal */
.modal-content-large {
background-color: white;
border-radius: 0.5rem;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
max-width: 90%;
max-height: 90%;
width: 1200px;
display: flex;
flex-direction: column;
}
.dialogs-table-content {
padding: 1rem;
overflow-y: auto;
max-height: 70vh;
background-color: hsl(210, 40%, 98%);
border-radius: 0.25rem;
margin: 0 1rem 1rem 1rem;
}
.dialogs-table {
width: 100%;
border-collapse: collapse;
background-color: white;
border-radius: 0.5rem;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.dialogs-table th {
background-color: var(--secondary);
color: var(--secondary-foreground);
padding: 0.75rem;
text-align: left;
font-weight: 600;
font-size: 0.875rem;
border-bottom: 1px solid var(--border);
}
.dialogs-table td {
padding: 0.75rem;
border-bottom: 1px solid var(--border);
font-size: 0.875rem;
vertical-align: top;
}
.dialogs-table tr:hover {
background-color: var(--secondary);
}
.dialog-summary-cell {
max-width: 23.5rem;
word-wrap: break-word;
line-height: 1.4;
}
.dialog-tags-cell {
max-width: 200px;
}
.dialog-tag-small {
display: inline-block;
padding: 0.125rem 0.375rem;
margin: 0.125rem;
border-radius: 0.25rem;
font-size: 0.625rem;
font-weight: 500;
}
.open-chat-btn {
background-color: var(--primary);
color: var(--primary-foreground);
border: none;
padding: 0.375rem 0.5rem;
border-radius: var(--radius);
font-size: 0.75rem;
cursor: pointer;
transition: all 0.2s ease;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.open-chat-btn:hover {
opacity: 0.9;
transform: translateY(-1px);
}
/* Responsive adjustments */
@media (max-width: 768px) {
.dashboard-container {
flex-direction: column;
}
.chart-container, .sidebar-container {
width: 100%;
}
.app-header {
font-size: 1.5rem;
}
}
</style>
</head>
<body>
{%app_entry%}
<footer>
{%config%}
{%scripts%}
{%renderer%}
</footer>
</body>
</html>
"""
@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}<br>{size_title}: {raw:.1f}<br>{color_title}: {color:.1f}<br>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}<extra></extra>",
hovertext=hover_text,
)
annotations = []
for i, row in df.iterrows():
words = row["deduplicated_topic_name"].split()
wrapped_text = "<br>".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)