Spaces:
Sleeping
Sleeping
| 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": "80vh"}), | |
| dcc.Graph( | |
| id="bubble-chart", | |
| style={"height": "calc(100% - 154px)"}, | |
| ), # this does not work for some reason | |
| html.Div( | |
| [ | |
| # Only keep Color by | |
| html.Div( | |
| [ | |
| html.Div( | |
| html.Label( | |
| "Color by:", | |
| className="control-label", | |
| ), | |
| className="control-label-container", | |
| ), | |
| ], | |
| className="control-labels-row", | |
| ), | |
| # Only keep Color by options | |
| 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.", | |
| # Added title for info icon | |
| style={ | |
| "marginLeft": "0.2rem", | |
| "color": "#6c757d", # General gray | |
| "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" | |
| }, # Initially hidden | |
| ), | |
| # 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" | |
| }, # Initially hidden | |
| ), | |
| ], | |
| 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 or hover on 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"), | |
| # 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 | |
| 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> | |
| """ | |
| def update_topic_distribution_header(data): | |
| if not data: | |
| return "Sessions Observatory" # Default when no data is available | |
| df = pd.DataFrame(data) | |
| total_dialogs = df["count"].sum() # Sum up the 'count' column | |
| return f"Sessions Observatory ({total_dialogs} dialogs)" | |
| # Define callback to process uploaded file | |
| def process_upload(contents, filename): | |
| if contents is None: | |
| return None, "", {"display": "none"}, {"display": "none"} # Keep hidden | |
| try: | |
| # Parse uploaded file | |
| 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"))) | |
| elif "xls" in filename.lower(): | |
| df = pd.read_excel(io.BytesIO(decoded)) | |
| # DEBUG | |
| # --- Print unique root_cause_subcluster values for each deduplicated_topic_name --- | |
| if ( | |
| "deduplicated_topic_name" in df.columns | |
| and "root_cause_subcluster" in df.columns | |
| ): | |
| print( | |
| "\n[INFO] Unique root_cause_subcluster values for each deduplicated_topic_name:" | |
| ) | |
| for topic in df["deduplicated_topic_name"].unique(): | |
| subclusters = ( | |
| df[df["deduplicated_topic_name"] == topic]["root_cause_subcluster"] | |
| .dropna() | |
| .unique() | |
| ) | |
| print(f"- {topic}:") | |
| for sub in subclusters: | |
| print(f" - {sub}") | |
| print() | |
| # --- End of DEBUG --- | |
| # Hardcoded flag to exclude 'Unclustered' topics | |
| EXCLUDE_UNCLUSTERED = True | |
| if EXCLUDE_UNCLUSTERED and "deduplicated_topic_name" in df.columns: | |
| df = df[df["deduplicated_topic_name"] != "Unclustered"].copy() | |
| # If we strip leading and trailing `"` or `'` from the topic name here, then | |
| # we will have a problem with the deduplicated names, as they will not match the | |
| # original topic names in the dataset. | |
| # Better do it in the first script. | |
| else: | |
| return ( | |
| None, | |
| html.Div( | |
| [ | |
| html.I( | |
| className="fas fa-exclamation-circle", | |
| style={"color": "var(--destructive)", "marginRight": "8px"}, | |
| ), | |
| "Please upload a CSV or Excel file.", | |
| ], | |
| style={"color": "var(--destructive)"}, | |
| ), | |
| {"display": "block"}, # Make visible after error | |
| {"display": "none"}, | |
| ) | |
| # Process the dataframe to get topic statistics | |
| topic_stats = analyze_topics(df) | |
| return ( | |
| topic_stats.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"}, # maybe add the above line here too #TODO | |
| { | |
| "display": "block", | |
| "height": "calc(100vh - 40px)", | |
| }, # Make visible after successful upload | |
| ) | |
| except Exception as e: | |
| return ( | |
| None, | |
| html.Div( | |
| [ | |
| html.I( | |
| className="fas fa-exclamation-triangle", | |
| style={"color": "var(--destructive)", "marginRight": "8px"}, | |
| ), | |
| f"Error processing file: {str(e)}", | |
| ], | |
| style={"color": "var(--destructive)"}, | |
| ), | |
| {"display": "block"}, # Make visible after error | |
| {"display": "none"}, | |
| ) | |
| # Function to analyze the topics and create statistics | |
| def analyze_topics(df): | |
| # Group by topic name and calculate metrics | |
| topic_stats = ( | |
| # IMPORTANT! | |
| # As deduplicated_topic_name, we have either the deduplicated names (if enabled by the process), | |
| # either the kmeans_reclustered name (where available) and the ClusterNames. | |
| 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() | |
| ) | |
| # Calculate rates | |
| 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) | |
| # Apply binned layout | |
| topic_stats = apply_binned_layout(topic_stats) | |
| return topic_stats | |
| # New binned layout function | |
| def apply_binned_layout(df, padding=0, bin_config=None, max_items_per_row=6): | |
| """ | |
| Apply a binned layout where bubbles are grouped into rows based on dialog count. | |
| Bubbles in each row will be centered horizontally. | |
| Args: | |
| df: DataFrame containing the topic data | |
| padding: Padding from edges as percentage | |
| bin_config: List of tuples defining bin ranges and descriptions. | |
| Example: [(300, None, "300+ dialogs"), (250, 299, "250-299 dialogs"), ...] | |
| max_items_per_row: Maximum number of items to display in a single row | |
| Returns: | |
| DataFrame with updated x, y positions | |
| """ | |
| # Create a copy of the dataframe to avoid modifying the original | |
| df_sorted = df.copy() | |
| # Default bin configuration if none is provided | |
| # 8 rows x 6 bubbles is usually good | |
| 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, 7, "5-6 dialogs"), | |
| (4, 4, "4 dialogs"), | |
| (0, 3, "0-3 dialogs"), | |
| ] | |
| # Generate bin descriptions and conditions dynamically | |
| 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: # No upper limit | |
| conditions.append(df_sorted["count"] >= lower) | |
| else: | |
| conditions.append( | |
| (df_sorted["count"] >= lower) & (df_sorted["count"] <= upper) | |
| ) | |
| # Apply the conditions to create the bin column | |
| df_sorted["bin"] = np.select(conditions, bin_values, default="Bin 8") | |
| df_sorted["bin_description"] = df_sorted["bin"].map(bin_descriptions) | |
| # Sort by bin (ascending to get Bin 1 first) and by count (descending) within each bin | |
| df_sorted = df_sorted.sort_values(by=["bin", "count"], ascending=[True, False]) | |
| # Now split bins that have more than max_items_per_row items | |
| 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 has more items than max_items_per_row, split it | |
| if bin_size > max_items_per_row: | |
| # Calculate how many sub-bins we need | |
| num_sub_bins = (bin_size + max_items_per_row - 1) // max_items_per_row | |
| # Calculate items per sub-bin (distribute evenly) | |
| items_per_sub_bin = [bin_size // num_sub_bins] * num_sub_bins | |
| # Distribute the remainder one by one to achieve balance | |
| remainder = bin_size % num_sub_bins | |
| for i in range(remainder): | |
| items_per_sub_bin[i] += 1 | |
| # Original bin description | |
| original_description = bin_descriptions[bin_name] | |
| # Create new row entries and update bin assignments | |
| start_idx = 0 | |
| for i in range(num_sub_bins): | |
| # Create new bin name with sub-bin index | |
| new_bin_name = f"{bin_name}_{i + 1}" | |
| # Create new bin description with sub-bin index | |
| new_description = f"{original_description} ({i + 1}/{num_sub_bins})" | |
| new_bin_descriptions[new_bin_name] = new_description | |
| # Get slice of dataframe for this sub-bin | |
| end_idx = start_idx + items_per_sub_bin[i] | |
| sub_bin_rows = bin_group.iloc[start_idx:end_idx].copy() | |
| # Update bin name and description | |
| sub_bin_rows["bin"] = new_bin_name | |
| sub_bin_rows["bin_description"] = new_description | |
| # Add to new rows | |
| new_rows.append(sub_bin_rows) | |
| # Update start index for next iteration | |
| start_idx = end_idx | |
| # Remove the original bin from df_sorted | |
| df_sorted = df_sorted[~bin_mask] | |
| # Combine the original dataframe (with small bins) and the new split bins | |
| if new_rows: | |
| df_sorted = pd.concat([df_sorted] + new_rows) | |
| # Re-sort with the new bin names | |
| df_sorted = df_sorted.sort_values(by=["bin", "count"], ascending=[True, False]) | |
| # Calculate the vertical positions for each row (bin) | |
| 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 | |
| # Calculate and assign y-positions (vertical positions) | |
| row_positions = {} | |
| for i, bin_name in enumerate(bins_with_topics): | |
| # Calculate row position (centered within its allocated space) | |
| row_pos = padding + i * row_height + (row_height / 2) | |
| row_positions[bin_name] = row_pos | |
| df_sorted["y"] = df_sorted["bin"].map(row_positions) | |
| # Center the bubbles in each row horizontally | |
| center_point = 50 # Middle of the chart (0-100 scale) | |
| for bin_name in bins_with_topics: | |
| # Get topics in this bin | |
| bin_mask = df_sorted["bin"] == bin_name | |
| num_topics_in_bin = bin_mask.sum() | |
| if num_topics_in_bin == 1: | |
| # If there's only one bubble, place it in the center | |
| df_sorted.loc[bin_mask, "x"] = center_point | |
| else: | |
| if num_topics_in_bin < max_items_per_row: | |
| # For fewer bubbles, add a little bit of spacing between them | |
| # Calculate the total width needed | |
| total_width = (num_topics_in_bin - 1) * 17.5 # 10 units between bubbles | |
| # Calculate starting position (to center the group) | |
| start_pos = center_point - (total_width / 2) | |
| # Assign positions | |
| positions = [start_pos + (i * 17.5) for i in range(num_topics_in_bin)] | |
| df_sorted.loc[bin_mask, "x"] = positions | |
| else: | |
| # For multiple bubbles, distribute them evenly around the center | |
| # Calculate the total width needed | |
| total_width = (num_topics_in_bin - 1) * 15 # 15 units between bubbles | |
| # Calculate starting position (to center the group) | |
| start_pos = center_point - (total_width / 2) | |
| # Assign positions | |
| positions = [start_pos + (i * 15) for i in range(num_topics_in_bin)] | |
| df_sorted.loc[bin_mask, "x"] = positions | |
| # Add original rank for reference | |
| df_sorted["size_rank"] = range(1, len(df_sorted) + 1) | |
| return df_sorted | |
| # New function to update positions based on selected size metric | |
| def update_bubble_positions(df: pd.DataFrame) -> pd.DataFrame: | |
| # For the main chart, we always use the binned layout | |
| return apply_binned_layout(df) | |
| # Callback to update the bubble chart | |
| def update_bubble_chart(data, color_metric): | |
| if not data: | |
| return go.Figure() | |
| df = pd.DataFrame(data) | |
| # Update positions using binned layout | |
| df = update_bubble_positions(df) | |
| # Always use count for sizing | |
| size_values = df["count"] | |
| raw_sizes = df["count"] | |
| size_title = "Dialog Count" | |
| # Apply log scaling to the size values for better visualization | |
| # To make the smallest bubble bigger, increase the min_size value (currently 2.5). | |
| min_size = 1 # Minimum bubble size | |
| if size_values.max() > size_values.min(): | |
| # Log-scale the sizes | |
| log_sizes = np.log1p(size_values) | |
| # Scale to a reasonable range for visualization | |
| # To make the biggest bubble smaller, reduce the multiplier (currently 50). | |
| size_values = ( | |
| min_size | |
| + (log_sizes - log_sizes.min()) / (log_sizes.max() - log_sizes.min()) * 50 | |
| ) | |
| else: | |
| # If all values are the same, use a default size | |
| size_values = np.ones(len(df)) * 12.5 | |
| # DEBUG: Print sizes of bubbles in the first and second bins | |
| bins = sorted(df["bin"].unique()) | |
| if len(bins) >= 1: | |
| # first_bin = bins[0] | |
| # print(f"DEBUG - First bin '{first_bin}' bubble sizes:") | |
| # first_bin_df = df[df["bin"] == first_bin] | |
| # for idx, row in first_bin_df.iterrows(): | |
| # print( | |
| # f" Topic: {row['deduplicated_topic_name']}, Raw size: {row['count']}, Displayed size: {size_values[idx]}" | |
| # ) | |
| pass | |
| if len(bins) >= 2: | |
| # second_bin = bins[1] | |
| # print(f"DEBUG - Second bin '{second_bin}' bubble sizes:") | |
| # second_bin_df = df[df["bin"] == second_bin] | |
| # for idx, row in second_bin_df.iterrows(): | |
| # print( | |
| # f" Topic: {row['deduplicated_topic_name']}, Raw size: {row['count']}, Displayed size: {size_values[idx]}" | |
| # ) | |
| pass | |
| # Determine color based on selected metric | |
| if color_metric == "negative_rate": | |
| color_values = df["negative_rate"] | |
| # color_title = "Negative Sentiment (%)" | |
| color_title = "Negativity (%)" | |
| # color_scale = "RdBu" # no ice, RdBu - og is Reds - matter is good too | |
| # color_scale = "Portland" | |
| # color_scale = "RdYlGn_r" | |
| # color_scale = "Teal" | |
| color_scale = "Teal" | |
| elif color_metric == "unresolved_rate": | |
| color_values = df["unresolved_rate"] | |
| color_title = "Unresolved (%)" | |
| # color_scale = "Burg" # og is YlOrRd | |
| # color_scale = "Temps" | |
| # color_scale = "Armyrose" | |
| # color_scale = "YlOrRd" | |
| color_scale = "Teal" | |
| else: | |
| color_values = df["urgent_rate"] | |
| color_title = "Urgency (%)" | |
| # color_scale = "Magenta" # og is Blues | |
| # color_scale = "Tealrose" | |
| # color_scale = "Portland" | |
| color_scale = "Teal" | |
| # Create enhanced hover text that includes bin information | |
| 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"], | |
| ) | |
| ] | |
| # Create bubble chart | |
| fig = px.scatter( | |
| df, | |
| x="x", | |
| y="y", | |
| size=size_values, | |
| color=color_values, | |
| # text="deduplicated_topic_name", # Remove text here | |
| hover_name="deduplicated_topic_name", | |
| hover_data={ | |
| "x": False, | |
| "y": False, | |
| "bin_description": True, | |
| }, | |
| size_max=42.5, # Maximum size of the bubbles, change this to adjust the size | |
| color_continuous_scale=color_scale, | |
| custom_data=[ | |
| "deduplicated_topic_name", | |
| "count", | |
| "negative_rate", | |
| "unresolved_rate", | |
| "urgent_rate", | |
| "bin_description", | |
| ], | |
| ) | |
| # Update traces: Remove text related properties | |
| fig.update_traces( | |
| mode="markers", # Remove '+text' | |
| marker=dict(sizemode="area", opacity=0.8, line=dict(width=1, color="white")), | |
| hovertemplate="%{hovertext}<extra></extra>", | |
| hovertext=hover_text, | |
| ) | |
| # Create annotations for the bubbles | |
| annotations = [] | |
| for i, row in df.iterrows(): | |
| # Wrap text every 2 words | |
| words = row["deduplicated_topic_name"].split() | |
| wrapped_text = "<br>".join( | |
| [" ".join(words[i : i + 4]) for i in range(0, len(words), 4)] | |
| ) | |
| # Calculate size for vertical offset (approximately based on the bubble size) | |
| # Add vertical offset based on bubble size to place text below the bubble | |
| marker_size = ( | |
| size_values[i] / 20 # type: ignore # FIXME: size_values[df.index.get_loc(i)] / 20 | |
| ) # Adjust this divisor as needed to get proper spacing | |
| annotations.append( | |
| dict( | |
| x=row["x"], | |
| y=row["y"] | |
| + 0.125 # Adding this so in a row with maximum bubbles, the left one does not overlap with the bin label | |
| + marker_size, # Add vertical offset to position text below the bubble | |
| text=wrapped_text, | |
| showarrow=False, | |
| textangle=0, | |
| font=dict( | |
| # size=10, | |
| # size=15, | |
| size=9, | |
| color="var(--foreground)", | |
| family="Arial, sans-serif", | |
| weight="bold", | |
| ), | |
| xanchor="center", | |
| yanchor="top", # Anchor to top of text box so it hangs below the bubble | |
| bgcolor="rgba(255,255,255,0.7)", # Add semi-transparent background for better readability | |
| bordercolor="rgba(0,0,0,0.1)", # Add a subtle border color | |
| borderwidth=1, | |
| borderpad=1, | |
| # TODO: Radius for rounded corners | |
| ) | |
| ) | |
| # Add bin labels and separator lines | |
| unique_bins = sorted(df["bin"].unique()) | |
| bin_y_positions = [ | |
| df[df["bin"] == bin_name]["y"].mean() for bin_name in unique_bins | |
| ] | |
| # Dynamically extract bin descriptions | |
| bin_descriptions = df.set_index("bin")["bin_description"].to_dict() | |
| for bin_name, bin_y in zip(unique_bins, bin_y_positions): | |
| # Add horizontal line | |
| 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", | |
| ) | |
| # Add subtle lines for each bin and bin labels | |
| for bin_name, bin_y in zip(unique_bins, bin_y_positions): | |
| # Add horizontal line | |
| 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", | |
| ) | |
| # Add bin label annotation | |
| annotations.append( | |
| dict( | |
| x=0, # Position the label on the left side | |
| 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", # Keep largest at top | |
| ), | |
| 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, # Add bin labels as annotations | |
| ) | |
| return fig | |
| # Update the update_topic_details callback to use grayscale colors for tags based on frequency | |
| def update_topic_details( | |
| hover_data, click_data, refresh_clicks, stored_data, file_contents | |
| ): | |
| # Determine which data to use (prioritize click over hover) | |
| hover_info = hover_data or click_data | |
| if not hover_info or not stored_data or not file_contents: | |
| return ( | |
| "", | |
| [], | |
| [], | |
| "", | |
| {"display": "none"}, | |
| "", | |
| {"display": "none"}, | |
| [], | |
| {"display": "flex"}, | |
| None, | |
| ) | |
| # Extract topic name from the hover data | |
| topic_name = hover_info["points"][0]["customdata"][0] | |
| # Get stored data for this topic | |
| df_stored = pd.DataFrame(stored_data) | |
| topic_data = df_stored[df_stored["deduplicated_topic_name"] == topic_name].iloc[0] | |
| # Get original data to sample conversations | |
| content_type, content_string = file_contents.split(",") | |
| decoded = base64.b64decode(content_string) | |
| if ( | |
| content_type | |
| == "data:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;base64" | |
| ): | |
| df_full = pd.read_excel(io.BytesIO(decoded), dtype={"Root_Cause": str}) | |
| else: # Assume CSV | |
| df_full = pd.read_csv( | |
| io.StringIO(decoded.decode("utf-8")), dtype={"Root_Cause": str} | |
| ) | |
| # Filter to this topic | |
| topic_conversations = df_full[df_full["deduplicated_topic_name"] == topic_name] | |
| # Create the title | |
| title = html.Div([html.Span(topic_name)]) | |
| # Create metadata items | |
| 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 inside", | |
| ], | |
| id="show-all-dialogs-btn", | |
| className="show-dialogs-btn", | |
| n_clicks=0, | |
| ), | |
| ], | |
| className="metadata-item", | |
| style={"display": "flex", "alignItems": "center", "width": "100%"}, | |
| ), | |
| ] | |
| # Create metrics boxes | |
| 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", | |
| ), | |
| ] | |
| # Extract and process root causes | |
| root_causes_output = "" | |
| root_causes_section_style = {"display": "none"} | |
| # Check if root_cause_subcluster column exists in the data | |
| if "root_cause_subcluster" in topic_conversations.columns: | |
| # Get unique root causes for this specific cluster | |
| root_causes = topic_conversations["root_cause_subcluster"].dropna().unique() | |
| # Filter out common non-informative values including "Unclustered" | |
| filtered_root_causes = [ | |
| rc | |
| for rc in root_causes | |
| if rc | |
| not in [ | |
| "Sub-clustering disabled", | |
| "Not eligible for sub-clustering", | |
| "No valid root causes", | |
| "No Subcluster", | |
| "Unclustered", | |
| "", | |
| ] | |
| ] | |
| # Debug: Print the unique root causes for this cluster | |
| print(f"\n[DEBUG] Root causes for cluster '{topic_name}':") | |
| print(f" All root causes: {list(root_causes)}") | |
| print(f" Filtered root causes: {filtered_root_causes}") | |
| if filtered_root_causes: | |
| # Create beautifully styled root cause tags with clickable icons | |
| 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"} | |
| # Extract and process consolidated_tags with improved styling | |
| tags_list = [] | |
| for _, row in topic_conversations.iterrows(): | |
| tags_str = row.get("consolidated_tags", "") | |
| if pd.notna(tags_str): | |
| tags = [tag.strip() for tag in tags_str.split(",") if tag.strip()] | |
| tags_list.extend(tags) | |
| # Count tag frequencies for better insight | |
| tag_counts = {} | |
| for tag in tags_list: | |
| tag_counts[tag] = tag_counts.get(tag, 0) + 1 | |
| # Sort by frequency (most common first) and then alphabetically for ties | |
| sorted_tags = sorted(tag_counts.items(), key=lambda x: (-x[1], x[0])) | |
| # Keep only the top K tags | |
| TOP_K = 15 | |
| sorted_tags = sorted_tags[:TOP_K] | |
| # Set tags section visibility and output | |
| tags_section_style = {"display": "none"} | |
| if sorted_tags: | |
| # Create beautifully styled tags with count indicators and consistent color | |
| 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 up to 5 random dialogs | |
| sample_size = min(5, len(topic_conversations)) | |
| if sample_size > 0: | |
| sample_indices = random.sample(range(len(topic_conversations)), sample_size) | |
| samples = topic_conversations.iloc[sample_indices] | |
| dialog_items = [] | |
| for _, row in samples.iterrows(): | |
| # Create dialog item with tags | |
| sentiment_tag = html.Span( | |
| row["Sentiment"], className="dialog-tag tag-sentiment" | |
| ) | |
| resolution_tag = html.Span( | |
| row["Resolution"], className="dialog-tag tag-resolution" | |
| ) | |
| urgency_tag = html.Span(row["Urgency"], className="dialog-tag tag-urgency") | |
| # Add Chat ID tag if 'id' column exists | |
| chat_id_tag = None | |
| if "id" in row: | |
| chat_id_tag = 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"}, | |
| ) | |
| # Add Root Cause tag if 'Root Cause' column exists | |
| root_cause_tag = None | |
| if ( | |
| "Root_Cause" in row | |
| and pd.notna(row["Root_Cause"]) | |
| and row["Root_Cause"] != "na" | |
| ): | |
| root_cause_tag = html.Span( | |
| f"Root Cause: {row['Root_Cause']}", | |
| className="dialog-tag tag-root-cause", | |
| ) | |
| # Compile all tags, including the new Chat ID and Root Cause tags if available | |
| tags = [sentiment_tag, resolution_tag, urgency_tag] | |
| if chat_id_tag: | |
| tags.append(chat_id_tag) | |
| if root_cause_tag: | |
| tags.append(root_cause_tag) | |
| 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, "file_contents": file_contents}, | |
| ) | |
| # Callback to open modal when conversation icon is clicked | |
| def open_conversation_modal(n_clicks_list, file_contents): | |
| # Check if any icon was clicked | |
| if not any(n_clicks_list) or not file_contents: | |
| return {"display": "none"}, "", "" | |
| # Get which icon was clicked | |
| ctx = dash.callback_context | |
| if not ctx.triggered: | |
| return ( | |
| {"display": "none"}, | |
| "", | |
| "", | |
| ) # Extract the chat ID from the triggered input | |
| triggered_id = ctx.triggered[0]["prop_id"] | |
| chat_id = json.loads(triggered_id.split(".")[0])["index"] | |
| # Get the full conversation from the uploaded file | |
| content_type, content_string = file_contents.split(",") | |
| decoded = base64.b64decode(content_string) | |
| if ( | |
| content_type | |
| == "data:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;base64" | |
| ): | |
| df_full = pd.read_excel(io.BytesIO(decoded), dtype={"Root_Cause": str}) | |
| else: # Assume CSV | |
| df_full = pd.read_csv( | |
| io.StringIO(decoded.decode("utf-8")), dtype={"Root_Cause": str} | |
| ) | |
| # Find the conversation with this chat ID | |
| 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.") | |
| # Get cluster name if available | |
| cluster_name = row.get("deduplicated_topic_name", "Unknown cluster") | |
| # Create subheader with both Chat ID and cluster name | |
| 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 | |
| def close_conversation_modal(n_clicks): | |
| if n_clicks: | |
| return {"display": "none"} | |
| return {"display": "none"} | |
| # Callback to open dialogs table modal when "Show all dialogs inside" button is clicked | |
| def open_dialogs_table_modal(n_clicks, selected_topic_data): | |
| if not n_clicks or not selected_topic_data: | |
| return {"display": "none"}, "", "" | |
| topic_name = selected_topic_data["topic_name"] | |
| file_contents = selected_topic_data["file_contents"] | |
| # Get the full data | |
| content_type, content_string = file_contents.split(",") | |
| decoded = base64.b64decode(content_string) | |
| if ( | |
| content_type | |
| == "data:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;base64" | |
| ): | |
| df_full = pd.read_excel(io.BytesIO(decoded), dtype={"Root_Cause": str}) | |
| else: # Assume CSV | |
| df_full = pd.read_csv( | |
| io.StringIO(decoded.decode("utf-8")), dtype={"Root_Cause": str} | |
| ) | |
| # Filter to this topic | |
| topic_conversations = df_full[df_full["deduplicated_topic_name"] == topic_name] | |
| # Create the table | |
| table_rows = [] | |
| # Header row | |
| table_rows.append( | |
| 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"), | |
| ] | |
| ) | |
| ) | |
| # Data rows | |
| for _, row in topic_conversations.iterrows(): | |
| # Process tags | |
| tags_str = row.get("consolidated_tags", "") | |
| if pd.notna(tags_str): | |
| tags = [tag.strip() for tag in tags_str.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] # Show only first 3 tags | |
| ] | |
| + ( | |
| [ | |
| html.Span( | |
| f"+{len(tags) - 3}", | |
| className="dialog-tag-small", | |
| style={"backgroundColor": "#6c757d", "color": "white"}, | |
| ) | |
| ] | |
| if len(tags) > 3 | |
| else [] | |
| ), | |
| className="dialog-tags-cell", | |
| ) | |
| else: | |
| tags_display = html.Span( | |
| "No tags", | |
| style={"color": "var(--muted-foreground)", "fontStyle": "italic"}, | |
| ) | |
| 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 not pd.isna(row.get("Root_Cause")) | |
| else "Unknown", | |
| className="dialog-tag-small", | |
| style={ | |
| "backgroundColor": "#8B4513", # Brown color for root cause | |
| "color": "white", | |
| }, | |
| ) | |
| ), | |
| html.Td( | |
| html.Span( # if sentiment is negative, color it red, otherwise grey | |
| 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( # if resolution is unresolved, color it red, otherwise grey | |
| 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( # if urgency is urgent, color it red, otherwise grey | |
| 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), | |
| html.Td( | |
| html.Button( | |
| [ | |
| html.I( | |
| className="fas fa-eye", | |
| style={"marginRight": "0.25rem"}, | |
| ), | |
| "View chat session", | |
| ], | |
| id={"type": "open-chat-btn", "index": row["id"]}, | |
| className="open-chat-btn", | |
| n_clicks=0, | |
| ) | |
| ), | |
| ] | |
| ) | |
| ) | |
| 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 | |
| def close_dialogs_table_modal(n_clicks): | |
| if n_clicks: | |
| return {"display": "none"} | |
| return {"display": "none"} | |
| # Callback to open conversation modal from dialogs table | |
| def open_conversation_from_table(n_clicks_list, file_contents): | |
| # Check if any button was clicked | |
| if not any(n_clicks_list) or not file_contents: | |
| return {"display": "none"}, "", "" | |
| # Get which button was clicked | |
| ctx = dash.callback_context | |
| if not ctx.triggered: | |
| return {"display": "none"}, "", "" | |
| # Extract the chat ID from the triggered input | |
| triggered_id = ctx.triggered[0]["prop_id"] | |
| chat_id = json.loads(triggered_id.split(".")[0])["index"] | |
| # Debug: print the chat_id to understand its type and value | |
| print(f"DEBUG: Looking for chat_id: {chat_id} (type: {type(chat_id)})") | |
| # Get the full conversation from the uploaded file | |
| content_type, content_string = file_contents.split(",") | |
| decoded = base64.b64decode(content_string) | |
| if ( | |
| content_type | |
| == "data:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;base64" | |
| ): | |
| df_full = pd.read_excel(io.BytesIO(decoded), dtype={"Root_Cause": str}) | |
| else: # Assume CSV | |
| df_full = pd.read_csv( | |
| io.StringIO(decoded.decode("utf-8")), dtype={"Root_Cause": str} | |
| ) | |
| # Debug: print some info about the dataframe | |
| print(f"DEBUG: DataFrame shape: {df_full.shape}") | |
| print(f"DEBUG: Available chat IDs (first 5): {df_full['id'].head().tolist()}") | |
| print(f"DEBUG: Chat ID types in df: {df_full['id'].dtype}") | |
| # Try to match with different data type conversions | |
| conversation_row = df_full[df_full["id"] == chat_id] | |
| # If not found, try converting types | |
| if len(conversation_row) == 0: | |
| # Try converting chat_id to string | |
| conversation_row = df_full[df_full["id"].astype(str) == str(chat_id)] | |
| # If still not found, try converting df id to int | |
| if len(conversation_row) == 0: | |
| try: | |
| conversation_row = df_full[df_full["id"] == int(chat_id)] | |
| except (ValueError, TypeError): | |
| pass | |
| if len(conversation_row) == 0: | |
| conversation_text = f"Conversation not found for Chat ID: {chat_id}. Available IDs: {df_full['id'].head(10).tolist()}" | |
| subheader_content = f"Chat ID: {chat_id} (Not Found)" | |
| else: | |
| conversation_row = conversation_row.iloc[0] | |
| conversation_text = conversation_row.get( | |
| "conversation", | |
| "No conversation available, oopsie.", # fix here the conversation status | |
| ) | |
| # Create subheader with metadata | |
| subheader_content = f"Chat ID: {chat_id} | Topic: {conversation_row.get('deduplicated_topic_name', 'Unknown')} | Sentiment: {conversation_row.get('Sentiment', 'Unknown')} | Resolution: {conversation_row.get('Resolution', 'Unknown')}" | |
| return {"display": "flex"}, conversation_text, subheader_content | |
| # Callback to open root cause modal when root cause icon is clicked | |
| def open_root_cause_modal(n_clicks_list, selected_topic_data): | |
| # Check if any icon was clicked | |
| if not any(n_clicks_list) or not selected_topic_data: | |
| return {"display": "none"}, "", "" | |
| # Get which icon was clicked | |
| 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"] | |
| file_contents = selected_topic_data["file_contents"] | |
| # Get the full data | |
| content_type, content_string = file_contents.split(",") | |
| decoded = base64.b64decode(content_string) | |
| if ( | |
| content_type | |
| == "data:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;base64" | |
| ): | |
| df_full = pd.read_excel(io.BytesIO(decoded), dtype={"Root_Cause": str}) | |
| else: # Assume CSV | |
| df_full = pd.read_csv( | |
| io.StringIO(decoded.decode("utf-8")), dtype={"Root_Cause": str} | |
| ) | |
| # Filter to this topic and root cause | |
| filtered_conversations = df_full[ | |
| (df_full["deduplicated_topic_name"] == topic_name) | |
| & (df_full["root_cause_subcluster"] == root_cause) | |
| ] | |
| # Create the table | |
| table_rows = [] | |
| # Header row | |
| table_rows.append( | |
| html.Tr( | |
| [ | |
| html.Th("Chat ID"), | |
| html.Th("Summary"), | |
| html.Th("Sentiment"), | |
| html.Th("Resolution"), | |
| html.Th("Urgency"), | |
| html.Th("Tags"), | |
| html.Th("Action"), | |
| ] | |
| ) | |
| ) | |
| # Data rows | |
| for _, row in filtered_conversations.iterrows(): | |
| # Process tags | |
| tags_str = row.get("consolidated_tags", "") | |
| if pd.notna(tags_str): | |
| tags = [tag.strip() for tag in tags_str.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] # Show only first 3 tags | |
| ] | |
| + ( | |
| [ | |
| html.Span( | |
| f"+{len(tags) - 3}", | |
| className="dialog-tag-small", | |
| style={"backgroundColor": "#6c757d", "color": "white"}, | |
| ) | |
| ] | |
| if len(tags) > 3 | |
| else [] | |
| ), | |
| className="dialog-tags-cell", | |
| ) | |
| else: | |
| tags_display = html.Span( | |
| "No tags", | |
| style={"color": "var(--muted-foreground)", "fontStyle": "italic"}, | |
| ) | |
| 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), | |
| 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", | |
| n_clicks=0, | |
| ) | |
| ), | |
| ] | |
| ) | |
| ) | |
| table = html.Table(table_rows, className="dialogs-table") | |
| modal_title = f"Dialogs with Root Cause: {root_cause} (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 | |
| def close_root_cause_modal(n_clicks): | |
| if n_clicks: | |
| return {"display": "none"} | |
| return {"display": "none"} | |
| # Callback to open conversation modal from root cause table | |
| def open_conversation_from_root_cause_table(n_clicks_list, file_contents): | |
| # Check if any button was clicked | |
| if not any(n_clicks_list) or not file_contents: | |
| return {"display": "none"}, "", "" | |
| # Get which button was clicked | |
| 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"] | |
| # Get the full conversation from the uploaded file | |
| content_type, content_string = file_contents.split(",") | |
| decoded = base64.b64decode(content_string) | |
| if ( | |
| content_type | |
| == "data:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;base64" | |
| ): | |
| df_full = pd.read_excel(io.BytesIO(decoded), dtype={"Root_Cause": str}) | |
| else: # Assume CSV | |
| df_full = pd.read_csv( | |
| io.StringIO(decoded.decode("utf-8")), dtype={"Root_Cause": str} | |
| ) | |
| # Find the conversation with this chat ID | |
| conversation_row = df_full[df_full["id"] == chat_id] | |
| # If not found, try converting types | |
| if len(conversation_row) == 0: | |
| conversation_row = df_full[df_full["id"].astype(str) == str(chat_id)] | |
| if len(conversation_row) == 0: | |
| try: | |
| conversation_row = df_full[df_full["id"] == int(chat_id)] | |
| except (ValueError, TypeError): | |
| pass | |
| 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.") | |
| # Get additional metadata | |
| root_cause = row.get("root_cause_subcluster", "Unknown") | |
| cluster_name = row.get("deduplicated_topic_name", "Unknown cluster") | |
| # Create subheader with metadata including root cause | |
| 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 | |
| if __name__ == "__main__": | |
| app.run(debug=False) |