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": "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> | |
| """ | |
| 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 | |
| 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) | |
| 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 | |
| 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 | |
| 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) | |
| def close_conversation_modal(n_clicks): | |
| if n_clicks: | |
| return {"display": "none"} | |
| return dash.no_update | |
| # NEW: Updated to use raw-data store | |
| 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) | |
| def close_dialogs_table_modal(n_clicks): | |
| if n_clicks: | |
| return {"display": "none"} | |
| return dash.no_update | |
| # NEW: Updated to use raw-data store | |
| 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 | |
| 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) | |
| def close_root_cause_modal(n_clicks): | |
| if n_clicks: | |
| return {"display": "none"} | |
| return dash.no_update | |
| # NEW: Updated to use raw-data store | |
| 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) |