eloukas's picture
Disable rereading
7043798 verified
raw
history blame
80.6 kB
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)