eloukas commited on
Commit
1b75011
·
1 Parent(s): 338fa25

Add files for HF deployment

Browse files
Files changed (2) hide show
  1. Dockerfile +1 -1
  2. app.py +1519 -0
Dockerfile CHANGED
@@ -12,4 +12,4 @@ COPY --chown=user . .
12
 
13
  EXPOSE 7860
14
 
15
- CMD ["gunicorn", "dashboard:server", "--bind", "0.0.0.0:7860", "--workers", "4"]
 
12
 
13
  EXPOSE 7860
14
 
15
+ CMD ["gunicorn", "app:server", "--bind", "0.0.0.0:7860", "--workers", "4"]
app.py ADDED
@@ -0,0 +1,1519 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import io
3
+ import random
4
+
5
+ import dash
6
+ import numpy as np
7
+ import pandas as pd
8
+ import plotly.express as px
9
+ import plotly.graph_objects as go
10
+ from dash import Input, Output, State, callback, dcc, html
11
+
12
+ # Initialize the Dash app
13
+ app = dash.Dash(__name__, suppress_callback_exceptions=True)
14
+ server = app.server
15
+
16
+
17
+ # Define app layout
18
+ app.layout = html.Div(
19
+ [
20
+ # Header
21
+ html.Div(
22
+ [
23
+ html.H1(
24
+ "Sessions Observatory by helvia.ai 🔭📊",
25
+ className="app-header",
26
+ ),
27
+ html.P(
28
+ "Upload a CSV/Excel file to visualize the chatbot's dialog topics.",
29
+ className="app-description",
30
+ ),
31
+ ],
32
+ className="header-container",
33
+ ),
34
+ # File Upload Component
35
+ html.Div(
36
+ [
37
+ dcc.Upload(
38
+ id="upload-data",
39
+ children=html.Div(
40
+ [
41
+ html.Div("Drag and Drop", className="upload-text"),
42
+ html.Div("or", className="upload-divider"),
43
+ html.Div(
44
+ html.Button("Select a File", className="upload-button")
45
+ ),
46
+ ],
47
+ className="upload-content",
48
+ ),
49
+ style={
50
+ "width": "100%",
51
+ "height": "120px",
52
+ "lineHeight": "60px",
53
+ "borderWidth": "1px",
54
+ "borderStyle": "dashed",
55
+ "borderRadius": "0.5rem",
56
+ "textAlign": "center",
57
+ "margin": "10px 0",
58
+ "backgroundColor": "hsl(210, 40%, 98%)",
59
+ "borderColor": "hsl(214.3, 31.8%, 91.4%)",
60
+ "cursor": "pointer",
61
+ },
62
+ multiple=False,
63
+ ),
64
+ # Status message with more padding and emphasis
65
+ html.Div(
66
+ id="upload-status",
67
+ className="upload-status-message",
68
+ style={"display": "none"}, # Initially hidden
69
+ ),
70
+ ],
71
+ className="upload-container",
72
+ ),
73
+ # Main Content Area (hidden until file is uploaded)
74
+ html.Div(
75
+ [
76
+ # Dashboard layout with flexible grid
77
+ html.Div(
78
+ [
79
+ # Left side: Bubble chart
80
+ html.Div(
81
+ [
82
+ html.H3(
83
+ id="topic-distribution-header",
84
+ children="Sessions Observatory",
85
+ className="section-header",
86
+ ),
87
+ # dcc.Graph(id="bubble-chart", style={"height": "80vh"}),
88
+ dcc.Graph(
89
+ id="bubble-chart",
90
+ style={"height": "calc(100% - 154px)"},
91
+ ), # this does not work for some reason
92
+ html.Div(
93
+ [
94
+ # Only keep Color by
95
+ html.Div(
96
+ [
97
+ html.Div(
98
+ html.Label(
99
+ "Color by:",
100
+ className="control-label",
101
+ ),
102
+ className="control-label-container",
103
+ ),
104
+ ],
105
+ className="control-labels-row",
106
+ ),
107
+ # Only keep Color by options
108
+ html.Div(
109
+ [
110
+ html.Div(
111
+ dcc.RadioItems(
112
+ id="color-metric",
113
+ options=[
114
+ {
115
+ "label": "Sentiment",
116
+ "value": "negative_rate",
117
+ },
118
+ {
119
+ "label": "Resolution",
120
+ "value": "unresolved_rate",
121
+ },
122
+ {
123
+ "label": "Urgency",
124
+ "value": "urgent_rate",
125
+ },
126
+ ],
127
+ value="negative_rate",
128
+ inline=True,
129
+ className="radio-group",
130
+ inputClassName="radio-input",
131
+ labelClassName="radio-label",
132
+ ),
133
+ className="radio-container",
134
+ ),
135
+ ],
136
+ className="control-options-row",
137
+ ),
138
+ ],
139
+ className="chart-controls",
140
+ ),
141
+ ],
142
+ className="chart-container",
143
+ ),
144
+ # Right side: Interactive sidebar with topic details
145
+ html.Div(
146
+ [
147
+ html.Div(
148
+ [
149
+ html.H3(
150
+ "Topic Details", className="section-header"
151
+ ),
152
+ html.Div(
153
+ id="topic-title", className="topic-title"
154
+ ),
155
+ html.Div(
156
+ [
157
+ html.Div(
158
+ [
159
+ html.H4(
160
+ "Metadata",
161
+ className="subsection-header",
162
+ ),
163
+ html.Div(
164
+ id="topic-metadata",
165
+ className="metadata-container",
166
+ ),
167
+ ],
168
+ className="metadata-section",
169
+ ),
170
+ html.Div(
171
+ [
172
+ html.H4(
173
+ "Key Metrics",
174
+ className="subsection-header",
175
+ ),
176
+ html.Div(
177
+ id="topic-metrics",
178
+ className="metrics-container",
179
+ ),
180
+ ],
181
+ className="metrics-section",
182
+ ),
183
+ # Added Tags section
184
+ html.Div(
185
+ [
186
+ html.H4(
187
+ "Tags",
188
+ className="subsection-header",
189
+ ),
190
+ html.Div(
191
+ id="important-tags",
192
+ className="tags-container",
193
+ ),
194
+ ]
195
+ ),
196
+ ],
197
+ className="details-section",
198
+ ),
199
+ html.Div(
200
+ [
201
+ html.H4(
202
+ "Sample Dialogs (Summary)",
203
+ className="subsection-header",
204
+ ),
205
+ html.Div(
206
+ id="sample-dialogs",
207
+ className="sample-dialogs-container",
208
+ ),
209
+ ],
210
+ className="samples-section",
211
+ ),
212
+ ],
213
+ className="topic-details-content",
214
+ ),
215
+ html.Div(
216
+ id="no-topic-selected",
217
+ children=[
218
+ html.Div(
219
+ [
220
+ html.I(
221
+ className="fas fa-info-circle info-icon"
222
+ ),
223
+ html.H3("No topic selected"),
224
+ html.P(
225
+ "Click or hover on a bubble to view topic details."
226
+ ),
227
+ ],
228
+ className="no-selection-message",
229
+ )
230
+ ],
231
+ className="no-selection-container",
232
+ ),
233
+ ],
234
+ className="sidebar-container",
235
+ ),
236
+ ],
237
+ className="dashboard-container",
238
+ )
239
+ ],
240
+ id="main-content",
241
+ style={"display": "none"},
242
+ ),
243
+ # Store the processed data
244
+ dcc.Store(id="stored-data"),
245
+ ],
246
+ className="app-container",
247
+ )
248
+
249
+ # Define CSS for the app
250
+ app.index_string = """
251
+ <!DOCTYPE html>
252
+ <html>
253
+ <head>
254
+ {%metas%}
255
+ <title>Sessions Observatory by helvia.ai 🔭📊</title>
256
+ {%favicon%}
257
+ {%css%}
258
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
259
+ <style>
260
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
261
+
262
+ :root {
263
+ --background: hsl(210, 20%, 95%);
264
+ --foreground: hsl(222.2, 84%, 4.9%);
265
+ --card: hsl(0, 0%, 100%);
266
+ --card-foreground: hsl(222.2, 84%, 4.9%);
267
+ --popover: hsl(0, 0%, 100%);
268
+ --popover-foreground: hsl(222.2, 84%, 4.9%);
269
+ --primary: hsl(222.2, 47.4%, 11.2%);
270
+ --primary-foreground: hsl(210, 40%, 98%);
271
+ --secondary: hsl(210, 40%, 96.1%);
272
+ --secondary-foreground: hsl(222.2, 47.4%, 11.2%);
273
+ --muted: hsl(210, 40%, 96.1%);
274
+ --muted-foreground: hsl(215.4, 16.3%, 46.9%);
275
+ --accent: hsl(210, 40%, 96.1%);
276
+ --accent-foreground: hsl(222.2, 47.4%, 11.2%);
277
+ --destructive: hsl(0, 84.2%, 60.2%);
278
+ --destructive-foreground: hsl(210, 40%, 98%);
279
+ --border: hsl(214.3, 31.8%, 91.4%);
280
+ --input: hsl(214.3, 31.8%, 91.4%);
281
+ --ring: hsl(222.2, 84%, 4.9%);
282
+ --radius: 0.5rem;
283
+ }
284
+
285
+ * {
286
+ margin: 0;
287
+ padding: 0;
288
+ box-sizing: border-box;
289
+ font-family: 'Inter', sans-serif;
290
+ }
291
+
292
+ body {
293
+ background-color: var(--background);
294
+ color: var(--foreground);
295
+ font-feature-settings: "rlig" 1, "calt" 1;
296
+ }
297
+
298
+ .app-container {
299
+ max-width: 2500px;
300
+ margin: 0 auto;
301
+ padding: 1.5rem;
302
+ background-color: var(--background);
303
+ min-height: 100vh;
304
+ display: flex;
305
+ flex-direction: column;
306
+ }
307
+
308
+ .header-container {
309
+ margin-bottom: 2rem;
310
+ text-align: center;
311
+ }
312
+
313
+ .app-header {
314
+ color: var(--foreground);
315
+ margin-bottom: 0.75rem;
316
+ font-weight: 600;
317
+ font-size: 2rem;
318
+ line-height: 1.2;
319
+ }
320
+
321
+ .app-description {
322
+ color: var(--muted-foreground);
323
+ font-size: 1rem;
324
+ line-height: 1.5;
325
+ }
326
+
327
+ .upload-container {
328
+ margin-bottom: 2rem;
329
+ max-width: 800px;
330
+ margin-left: auto;
331
+ margin-right: auto;
332
+ }
333
+
334
+ .upload-content {
335
+ display: flex;
336
+ flex-direction: column;
337
+ align-items: center;
338
+ justify-content: center;
339
+ height: 80%;
340
+ padding: 1.5rem;
341
+ position: relative;
342
+ }
343
+
344
+ .upload-text {
345
+ font-size: 1rem;
346
+ color: var(--primary);
347
+ font-weight: 500;
348
+ }
349
+
350
+ .upload-divider {
351
+ color: var(--muted-foreground);
352
+ margin: 0.5rem 0;
353
+ font-size: 0.875rem;
354
+ }
355
+
356
+ .upload-button {
357
+ background-color: var(--primary);
358
+ color: var(--primary-foreground);
359
+ border: none;
360
+ padding: 0.5rem 1rem;
361
+ border-radius: var(--radius);
362
+ font-size: 0.875rem;
363
+ cursor: pointer;
364
+ transition: opacity 0.2s;
365
+ font-weight: 500;
366
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
367
+ height: 2.5rem;
368
+ }
369
+
370
+ .upload-button:hover {
371
+ opacity: 0.9;
372
+ }
373
+
374
+ /* Status message styling */
375
+ .upload-status-message {
376
+ margin-top: 1rem;
377
+ padding: 0.75rem;
378
+ font-weight: 500;
379
+ text-align: center;
380
+ border-radius: var(--radius);
381
+ font-size: 0.875rem;
382
+ transition: all 0.3s ease;
383
+ background-color: var(--secondary);
384
+ color: var(--secondary-foreground);
385
+ }
386
+
387
+ /* Chart controls styling */
388
+ .chart-controls {
389
+ margin-top: 1rem;
390
+ display: flex;
391
+ flex-direction: column;
392
+ gap: 0.75rem;
393
+ padding: 1rem;
394
+ background-color: var(--card);
395
+ border-radius: var(--radius);
396
+ border: 1px solid var(--border);
397
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
398
+ }
399
+
400
+ .control-labels-row {
401
+ display: flex;
402
+ width: 100%;
403
+ }
404
+
405
+ .control-options-row {
406
+ display: flex;
407
+ width: 100%;
408
+ }
409
+
410
+ .control-label-container {
411
+ padding: 0 0.5rem;
412
+ text-align: left;
413
+ }
414
+
415
+ .control-label {
416
+ font-weight: 500;
417
+ color: var(--foreground);
418
+ font-size: 0.875rem;
419
+ line-height: 1.25rem;
420
+ }
421
+
422
+ .radio-container {
423
+ padding: 0 0.5rem;
424
+ width: 100%;
425
+ }
426
+
427
+ .radio-group {
428
+ display: flex;
429
+ gap: 1rem;
430
+ }
431
+
432
+ .radio-input {
433
+ margin-right: 0.375rem;
434
+ cursor: pointer;
435
+ height: 1rem;
436
+ width: 1rem;
437
+ border-radius: 9999px;
438
+ border: 1px solid var(--border);
439
+ appearance: none;
440
+ -webkit-appearance: none;
441
+ background-color: var(--background);
442
+ transition: border-color 0.2s;
443
+ }
444
+
445
+ .radio-input:checked {
446
+ border-color: var(--primary);
447
+ background-color: var(--primary);
448
+ 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");
449
+ background-size: 100% 100%;
450
+ background-position: center;
451
+ background-repeat: no-repeat;
452
+ }
453
+
454
+ .radio-label {
455
+ font-weight: 400;
456
+ color: var(--foreground);
457
+ display: flex;
458
+ align-items: center;
459
+ cursor: pointer;
460
+ font-size: 0.875rem;
461
+ line-height: 1.25rem;
462
+ }
463
+
464
+ /* Dashboard container */
465
+ .dashboard-container {
466
+ display: flex;
467
+ flex-wrap: wrap;
468
+ gap: 1.5rem;
469
+ flex: 1;
470
+ height: 100%;
471
+ }
472
+
473
+ .chart-container {
474
+ flex: 2.75;
475
+ min-width: 400px;
476
+ background: var(--card);
477
+ border-radius: var(--radius);
478
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
479
+ padding: 1rem;
480
+ border: 0.75px solid var(--border);
481
+ height: 100%;
482
+ }
483
+
484
+ .sidebar-container {
485
+ flex: 1;
486
+ min-width: 300px;
487
+ background: var(--card);
488
+ border-radius: var(--radius);
489
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
490
+ padding: 1rem;
491
+ position: relative;
492
+ height: 100vh;
493
+ overflow-y: auto;
494
+ border: 1px solid var(--border);
495
+ height: 100%;
496
+ }
497
+
498
+ .section-header {
499
+ margin-bottom: 1rem;
500
+ color: var(--foreground);
501
+ border-bottom: 1px solid var(--border);
502
+ padding-bottom: 0.75rem;
503
+ font-weight: 600;
504
+ font-size: 1.25rem;
505
+ }
506
+
507
+ .subsection-header {
508
+ margin: 1rem 0 0.75rem;
509
+ color: var(--foreground);
510
+ font-size: 1rem;
511
+ font-weight: 600;
512
+ }
513
+
514
+ .topic-title {
515
+ font-size: 1.25rem;
516
+ font-weight: 600;
517
+ color: var(--foreground);
518
+ margin-bottom: 1rem;
519
+ padding: 0.5rem 0.75rem;
520
+ background-color: var(--secondary);
521
+ border-radius: var(--radius);
522
+ }
523
+
524
+ .metadata-container {
525
+ display: flex;
526
+ flex-wrap: wrap;
527
+ gap: 0.75rem;
528
+ margin-bottom: 1rem;
529
+ }
530
+
531
+ .metadata-item {
532
+ background-color: var(--secondary);
533
+ padding: 0.5rem 0.75rem;
534
+ border-radius: var(--radius);
535
+ font-size: 0.875rem;
536
+ display: flex;
537
+ align-items: center;
538
+ color: var(--secondary-foreground);
539
+ }
540
+
541
+ .metadata-icon {
542
+ margin-right: 0.5rem;
543
+ color: var(--primary);
544
+ }
545
+
546
+ .metrics-container {
547
+ display: flex;
548
+ justify-content: space-between;
549
+ gap: 0.75rem;
550
+ margin-bottom: 0.75rem;
551
+ }
552
+
553
+ .metric-box {
554
+ background-color: var(--card);
555
+ border-radius: var(--radius);
556
+ padding: 0.75rem;
557
+ text-align: center;
558
+ flex: 1;
559
+ border: 1px solid var(--border);
560
+ }
561
+
562
+ .metric-box.negative {
563
+ border-left: 3px solid var(--destructive);
564
+ }
565
+
566
+ .metric-box.unresolved {
567
+ border-left: 3px solid hsl(47.9, 95.8%, 53.1%);
568
+ }
569
+
570
+ .metric-box.urgent {
571
+ border-left: 3px solid hsl(217.2, 91.2%, 59.8%);
572
+ }
573
+
574
+ .metric-value {
575
+ font-size: 1.5rem;
576
+ font-weight: 600;
577
+ margin-bottom: 0.25rem;
578
+ color: var(--foreground);
579
+ line-height: 1;
580
+ }
581
+
582
+ .metric-label {
583
+ font-size: 0.75rem;
584
+ color: var(--muted-foreground);
585
+ }
586
+
587
+ .sample-dialogs-container {
588
+ margin-top: 0.75rem;
589
+ }
590
+
591
+ .dialog-item {
592
+ background-color: var(--secondary);
593
+ border-radius: var(--radius);
594
+ padding: 1rem;
595
+ margin-bottom: 0.75rem;
596
+ border-left: 3px solid var(--primary);
597
+ }
598
+
599
+ .dialog-summary {
600
+ font-size: 0.875rem;
601
+ line-height: 1.5;
602
+ margin-bottom: 0.5rem;
603
+ color: var(--foreground);
604
+ }
605
+
606
+ .dialog-metadata {
607
+ display: flex;
608
+ flex-wrap: wrap;
609
+ gap: 0.5rem;
610
+ margin-top: 0.5rem;
611
+ font-size: 0.75rem;
612
+ }
613
+
614
+ .dialog-tag {
615
+ padding: 0.25rem 0.5rem;
616
+ border-radius: var(--radius);
617
+ font-size: 0.7rem;
618
+ font-weight: 500;
619
+ }
620
+
621
+ .tag-sentiment {
622
+ background-color: var(--destructive);
623
+ color: var(--destructive-foreground);
624
+ }
625
+
626
+ .tag-resolution {
627
+ background-color: hsl(47.9, 95.8%, 53.1%);
628
+ color: hsl(222.2, 84%, 4.9%);
629
+ }
630
+
631
+ .tag-urgency {
632
+ background-color: hsl(217.2, 91.2%, 59.8%);
633
+ color: hsl(210, 40%, 98%);
634
+ }
635
+
636
+ .tag-chat-id {
637
+ background-color: hsl(215.4, 16.3%, 46.9%);
638
+ color: hsl(210, 40%, 98%);
639
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
640
+ font-weight: 500;
641
+ }
642
+
643
+ .no-selection-container {
644
+ position: absolute;
645
+ top: 0;
646
+ left: 0;
647
+ right: 0;
648
+ bottom: 0;
649
+ display: flex;
650
+ align-items: center;
651
+ justify-content: center;
652
+ background-color: hsla(0, 0%, 100%, 0.95);
653
+ z-index: 10;
654
+ border-radius: var(--radius);
655
+ }
656
+
657
+ .no-selection-message {
658
+ text-align: center;
659
+ color: var(--muted-foreground);
660
+ padding: 1.5rem;
661
+ }
662
+
663
+ .info-icon {
664
+ font-size: 2rem;
665
+ margin-bottom: 0.75rem;
666
+ color: var(--muted);
667
+ }
668
+
669
+ /* Tags container */
670
+ .tags-container {
671
+ display: flex;
672
+ flex-wrap: wrap;
673
+ gap: 8px;
674
+ margin-top: 5px;
675
+ margin-bottom: 15px;
676
+ padding: 6px;
677
+ border-radius: 8px;
678
+ background-color: #f8f9fa;
679
+ }
680
+
681
+
682
+ .topic-tag {
683
+ padding: 0.375rem 0.75rem;
684
+ border-radius: var(--radius);
685
+ font-size: 0.75rem;
686
+ display: inline-flex;
687
+ align-items: center;
688
+ transition: all 0.2s ease;
689
+ font-weight: 500;
690
+ margin-bottom: 0.25rem;
691
+ cursor: default;
692
+ background-color: var(--muted);
693
+ color: var(--muted-foreground);
694
+ border: 1px solid var(--border);
695
+ }
696
+
697
+ .topic-tag {
698
+ padding: 6px 12px;
699
+ border-radius: 15px;
700
+ font-size: 0.8rem;
701
+ display: inline-flex;
702
+ align-items: center;
703
+ box-shadow: 0 1px 3px rgba(0,0,0,0.12);
704
+ transition: all 0.2s ease;
705
+ font-weight: 500;
706
+ margin-bottom: 5px;
707
+ cursor: default;
708
+ border: 1px solid rgba(0,0,0,0.08);
709
+ background-color: #6c757d; /* Consistent medium gray color */
710
+ color: white;
711
+ }
712
+
713
+ .topic-tag:hover {
714
+ transform: translateY(-1px);
715
+ box-shadow: 0 3px 5px rgba(0,0,0,0.15);
716
+ background-color: #5a6268; /* Slightly darker on hover */
717
+ }
718
+
719
+ .topic-tag-icon {
720
+ margin-right: 5px;
721
+ font-size: 0.7rem;
722
+ opacity: 0.8;
723
+ color: rgba(255, 255, 255, 0.9);
724
+ }
725
+
726
+ .no-tags-message {
727
+ color: var(--muted-foreground);
728
+ font-style: italic;
729
+ padding: 0.75rem;
730
+ text-align: center;
731
+ width: 100%;
732
+ }
733
+
734
+ /* Responsive adjustments */
735
+ @media (max-width: 768px) {
736
+ .dashboard-container {
737
+ flex-direction: column;
738
+ }
739
+ .chart-container, .sidebar-container {
740
+ width: 100%;
741
+ }
742
+ .app-header {
743
+ font-size: 1.5rem;
744
+ }
745
+ }
746
+ </style>
747
+ </head>
748
+ <body>
749
+ {%app_entry%}
750
+ <footer>
751
+ {%config%}
752
+ {%scripts%}
753
+ {%renderer%}
754
+ </footer>
755
+ </body>
756
+ </html>
757
+ """
758
+
759
+
760
+ @callback(
761
+ Output("topic-distribution-header", "children"),
762
+ Input("stored-data", "data"),
763
+ )
764
+ def update_topic_distribution_header(data):
765
+ if not data:
766
+ return "Sessions Observatory" # Default when no data is available
767
+
768
+ df = pd.DataFrame(data)
769
+ total_dialogs = df["count"].sum() # Sum up the 'count' column
770
+ return f"Sessions Observatory ({total_dialogs} dialogs)"
771
+
772
+
773
+ # Define callback to process uploaded file
774
+ @callback(
775
+ [
776
+ Output("stored-data", "data"),
777
+ Output("upload-status", "children"),
778
+ Output("upload-status", "style"), # Add style output for visibility
779
+ Output("main-content", "style"),
780
+ ],
781
+ [Input("upload-data", "contents")],
782
+ [State("upload-data", "filename")],
783
+ )
784
+ def process_upload(contents, filename):
785
+ if contents is None:
786
+ return None, "", {"display": "none"}, {"display": "none"} # Keep hidden
787
+
788
+ try:
789
+ # Parse uploaded file
790
+ content_type, content_string = contents.split(",")
791
+ decoded = base64.b64decode(content_string)
792
+
793
+ if "csv" in filename.lower():
794
+ df = pd.read_csv(io.StringIO(decoded.decode("utf-8")))
795
+ elif "xls" in filename.lower():
796
+ df = pd.read_excel(io.BytesIO(decoded))
797
+ else:
798
+ return (
799
+ None,
800
+ html.Div(
801
+ [
802
+ html.I(
803
+ className="fas fa-exclamation-circle",
804
+ style={"color": "var(--destructive)", "marginRight": "8px"},
805
+ ),
806
+ "Please upload a CSV or Excel file.",
807
+ ],
808
+ style={"color": "var(--destructive)"},
809
+ ),
810
+ {"display": "block"}, # Make visible after error
811
+ {"display": "none"},
812
+ )
813
+
814
+ # Process the dataframe to get topic statistics
815
+ topic_stats = analyze_topics(df)
816
+
817
+ return (
818
+ topic_stats.to_dict("records"),
819
+ html.Div(
820
+ [
821
+ html.I(
822
+ className="fas fa-check-circle",
823
+ style={
824
+ "color": "hsl(142.1, 76.2%, 36.3%)",
825
+ "marginRight": "8px",
826
+ },
827
+ ),
828
+ f'Successfully uploaded "{filename}"',
829
+ ],
830
+ style={"color": "hsl(142.1, 76.2%, 36.3%)"},
831
+ ),
832
+ {"display": "block"}, # maybe add the above line here too #TODO
833
+ {
834
+ "display": "block",
835
+ "height": "calc(100vh - 40px)",
836
+ }, # Make visible after successful upload
837
+ )
838
+
839
+ except Exception as e:
840
+ return (
841
+ None,
842
+ html.Div(
843
+ [
844
+ html.I(
845
+ className="fas fa-exclamation-triangle",
846
+ style={"color": "var(--destructive)", "marginRight": "8px"},
847
+ ),
848
+ f"Error processing file: {str(e)}",
849
+ ],
850
+ style={"color": "var(--destructive)"},
851
+ ),
852
+ {"display": "block"}, # Make visible after error
853
+ {"display": "none"},
854
+ )
855
+
856
+
857
+ # Function to analyze the topics and create statistics
858
+ def analyze_topics(df):
859
+ # Group by topic name and calculate metrics
860
+ topic_stats = (
861
+ df.groupby("deduplicated_topic_name")
862
+ .agg(
863
+ count=("id", "count"),
864
+ negative_count=("Sentiment", lambda x: (x == "negative").sum()),
865
+ unresolved_count=("Resolution", lambda x: (x == "unresolved").sum()),
866
+ urgent_count=("Urgency", lambda x: (x == "urgent").sum()),
867
+ )
868
+ .reset_index()
869
+ )
870
+
871
+ # Calculate rates
872
+ topic_stats["negative_rate"] = (
873
+ topic_stats["negative_count"] / topic_stats["count"] * 100
874
+ ).round(1)
875
+ topic_stats["unresolved_rate"] = (
876
+ topic_stats["unresolved_count"] / topic_stats["count"] * 100
877
+ ).round(1)
878
+ topic_stats["urgent_rate"] = (
879
+ topic_stats["urgent_count"] / topic_stats["count"] * 100
880
+ ).round(1)
881
+
882
+ # Apply binned layout
883
+ topic_stats = apply_binned_layout(topic_stats)
884
+
885
+ return topic_stats
886
+
887
+
888
+ # New binned layout function
889
+
890
+
891
+ def apply_binned_layout(df, padding=0, bin_config=None, max_items_per_row=6):
892
+ """
893
+ Apply a binned layout where bubbles are grouped into rows based on dialog count.
894
+ Bubbles in each row will be centered horizontally.
895
+
896
+ Args:
897
+ df: DataFrame containing the topic data
898
+ padding: Padding from edges as percentage
899
+ bin_config: List of tuples defining bin ranges and descriptions.
900
+ Example: [(300, None, "300+ dialogs"), (250, 299, "250-299 dialogs"), ...]
901
+ max_items_per_row: Maximum number of items to display in a single row
902
+
903
+ Returns:
904
+ DataFrame with updated x, y positions
905
+ """
906
+ # Create a copy of the dataframe to avoid modifying the original
907
+ df_sorted = df.copy()
908
+
909
+ # Default bin configuration if none is provided
910
+ # 8 rows x 6 bubbles is usually good
911
+ if bin_config is None:
912
+ bin_config = [
913
+ (100, None, "100+ dialogs"),
914
+ (50, 99, "50-99 dialogs"),
915
+ (25, 49, "25-49 dialogs"),
916
+ (9, 24, "9-24 dialogs"),
917
+ (7, 8, "7-8 dialogs"),
918
+ (5, 7, "5-6 dialogs"),
919
+ (4, 4, "4 dialogs"),
920
+ (0, 3, "0-3 dialogs"),
921
+ ]
922
+
923
+ # Generate bin descriptions and conditions dynamically
924
+ bin_descriptions = {}
925
+ conditions = []
926
+ bin_values = []
927
+
928
+ for i, (lower, upper, description) in enumerate(bin_config):
929
+ bin_name = f"Bin {i + 1}"
930
+ bin_descriptions[bin_name] = description
931
+ bin_values.append(bin_name)
932
+
933
+ if upper is None: # No upper limit
934
+ conditions.append(df_sorted["count"] >= lower)
935
+ else:
936
+ conditions.append(
937
+ (df_sorted["count"] >= lower) & (df_sorted["count"] <= upper)
938
+ )
939
+
940
+ # Apply the conditions to create the bin column
941
+ df_sorted["bin"] = np.select(conditions, bin_values, default="Bin 8")
942
+ df_sorted["bin_description"] = df_sorted["bin"].map(bin_descriptions)
943
+
944
+ # Sort by bin (ascending to get Bin 1 first) and by count (descending) within each bin
945
+ df_sorted = df_sorted.sort_values(by=["bin", "count"], ascending=[True, False])
946
+
947
+ # Now split bins that have more than max_items_per_row items
948
+ original_bins = df_sorted["bin"].unique()
949
+ new_rows = []
950
+ new_bin_descriptions = bin_descriptions.copy()
951
+
952
+ for bin_name in original_bins:
953
+ bin_mask = df_sorted["bin"] == bin_name
954
+ bin_group = df_sorted[bin_mask]
955
+ bin_size = len(bin_group)
956
+
957
+ # If bin has more items than max_items_per_row, split it
958
+ if bin_size > max_items_per_row:
959
+ # Calculate how many sub-bins we need
960
+ num_sub_bins = (bin_size + max_items_per_row - 1) // max_items_per_row
961
+
962
+ # Calculate items per sub-bin (distribute evenly)
963
+ items_per_sub_bin = [bin_size // num_sub_bins] * num_sub_bins
964
+
965
+ # Distribute the remainder one by one to achieve balance
966
+ remainder = bin_size % num_sub_bins
967
+ for i in range(remainder):
968
+ items_per_sub_bin[i] += 1
969
+
970
+ # Original bin description
971
+ original_description = bin_descriptions[bin_name]
972
+
973
+ # Create new row entries and update bin assignments
974
+ start_idx = 0
975
+ for i in range(num_sub_bins):
976
+ # Create new bin name with sub-bin index
977
+ new_bin_name = f"{bin_name}_{i + 1}"
978
+
979
+ # Create new bin description with sub-bin index
980
+ new_description = f"{original_description} ({i + 1}/{num_sub_bins})"
981
+ new_bin_descriptions[new_bin_name] = new_description
982
+
983
+ # Get slice of dataframe for this sub-bin
984
+ end_idx = start_idx + items_per_sub_bin[i]
985
+ sub_bin_rows = bin_group.iloc[start_idx:end_idx].copy()
986
+
987
+ # Update bin name and description
988
+ sub_bin_rows["bin"] = new_bin_name
989
+ sub_bin_rows["bin_description"] = new_description
990
+
991
+ # Add to new rows
992
+ new_rows.append(sub_bin_rows)
993
+
994
+ # Update start index for next iteration
995
+ start_idx = end_idx
996
+
997
+ # Remove the original bin from df_sorted
998
+ df_sorted = df_sorted[~bin_mask]
999
+
1000
+ # Combine the original dataframe (with small bins) and the new split bins
1001
+ if new_rows:
1002
+ df_sorted = pd.concat([df_sorted] + new_rows)
1003
+
1004
+ # Re-sort with the new bin names
1005
+ df_sorted = df_sorted.sort_values(by=["bin", "count"], ascending=[True, False])
1006
+
1007
+ # Calculate the vertical positions for each row (bin)
1008
+ bins_with_topics = sorted(df_sorted["bin"].unique())
1009
+ num_rows = len(bins_with_topics)
1010
+
1011
+ available_height = 100 - (2 * padding)
1012
+ row_height = available_height / num_rows
1013
+
1014
+ # Calculate and assign y-positions (vertical positions)
1015
+ row_positions = {}
1016
+ for i, bin_name in enumerate(bins_with_topics):
1017
+ # Calculate row position (centered within its allocated space)
1018
+ row_pos = padding + i * row_height + (row_height / 2)
1019
+ row_positions[bin_name] = row_pos
1020
+
1021
+ df_sorted["y"] = df_sorted["bin"].map(row_positions)
1022
+
1023
+ # Center the bubbles in each row horizontally
1024
+ center_point = 50 # Middle of the chart (0-100 scale)
1025
+ for bin_name in bins_with_topics:
1026
+ # Get topics in this bin
1027
+ bin_mask = df_sorted["bin"] == bin_name
1028
+ num_topics_in_bin = bin_mask.sum()
1029
+
1030
+ if num_topics_in_bin == 1:
1031
+ # If there's only one bubble, place it in the center
1032
+ df_sorted.loc[bin_mask, "x"] = center_point
1033
+ else:
1034
+ if num_topics_in_bin < max_items_per_row:
1035
+ # For fewer bubbles, add a little bit of spacing between them
1036
+ # Calculate the total width needed
1037
+ total_width = (num_topics_in_bin - 1) * 17.5 # 10 units between bubbles
1038
+ # Calculate starting position (to center the group)
1039
+ start_pos = center_point - (total_width / 2)
1040
+ # Assign positions
1041
+ positions = [start_pos + (i * 17.5) for i in range(num_topics_in_bin)]
1042
+ df_sorted.loc[bin_mask, "x"] = positions
1043
+ else:
1044
+ # For multiple bubbles, distribute them evenly around the center
1045
+ # Calculate the total width needed
1046
+ total_width = (num_topics_in_bin - 1) * 15 # 15 units between bubbles
1047
+
1048
+ # Calculate starting position (to center the group)
1049
+ start_pos = center_point - (total_width / 2)
1050
+
1051
+ # Assign positions
1052
+ positions = [start_pos + (i * 15) for i in range(num_topics_in_bin)]
1053
+ df_sorted.loc[bin_mask, "x"] = positions
1054
+
1055
+ # Add original rank for reference
1056
+ df_sorted["size_rank"] = range(1, len(df_sorted) + 1)
1057
+
1058
+ return df_sorted
1059
+
1060
+
1061
+ # New function to update positions based on selected size metric
1062
+ def update_bubble_positions(df: pd.DataFrame) -> pd.DataFrame:
1063
+ # For the main chart, we always use the binned layout
1064
+ return apply_binned_layout(df)
1065
+
1066
+
1067
+ # Callback to update the bubble chart
1068
+ @callback(
1069
+ Output("bubble-chart", "figure"),
1070
+ [
1071
+ Input("stored-data", "data"),
1072
+ Input("color-metric", "value"),
1073
+ ],
1074
+ )
1075
+ def update_bubble_chart(data, color_metric):
1076
+ if not data:
1077
+ return go.Figure()
1078
+
1079
+ df = pd.DataFrame(data)
1080
+
1081
+ # Update positions using binned layout
1082
+ df = update_bubble_positions(df)
1083
+
1084
+ # Always use count for sizing
1085
+ size_values = df["count"]
1086
+ raw_sizes = df["count"]
1087
+ size_title = "Dialog Count"
1088
+
1089
+ # Apply log scaling to the size values for better visualization
1090
+ # To make the smallest bubble bigger, increase the min_size value (currently 2.5).
1091
+ min_size = 1 # Minimum bubble size
1092
+ if size_values.max() > size_values.min():
1093
+ # Log-scale the sizes
1094
+ log_sizes = np.log1p(size_values)
1095
+ # Scale to a reasonable range for visualization
1096
+ # To make the biggest bubble smaller, reduce the multiplier (currently 50).
1097
+ size_values = (
1098
+ min_size
1099
+ + (log_sizes - log_sizes.min()) / (log_sizes.max() - log_sizes.min()) * 50
1100
+ )
1101
+ else:
1102
+ # If all values are the same, use a default size
1103
+ size_values = np.ones(len(df)) * 12.5
1104
+
1105
+ # DEBUG: Print sizes of bubbles in the first and second bins
1106
+ bins = sorted(df["bin"].unique())
1107
+ if len(bins) >= 1:
1108
+ first_bin = bins[0]
1109
+ print(f"DEBUG - First bin '{first_bin}' bubble sizes:")
1110
+ first_bin_df = df[df["bin"] == first_bin]
1111
+ for idx, row in first_bin_df.iterrows():
1112
+ print(
1113
+ f" Topic: {row['deduplicated_topic_name']}, Raw size: {row['count']}, Displayed size: {size_values[idx]}"
1114
+ )
1115
+
1116
+ if len(bins) >= 2:
1117
+ second_bin = bins[1]
1118
+ print(f"DEBUG - Second bin '{second_bin}' bubble sizes:")
1119
+ second_bin_df = df[df["bin"] == second_bin]
1120
+ for idx, row in second_bin_df.iterrows():
1121
+ print(
1122
+ f" Topic: {row['deduplicated_topic_name']}, Raw size: {row['count']}, Displayed size: {size_values[idx]}"
1123
+ )
1124
+
1125
+ # Determine color based on selected metric
1126
+ if color_metric == "negative_rate":
1127
+ color_values = df["negative_rate"]
1128
+ # color_title = "Negative Sentiment (%)"
1129
+ color_title = "Negativity (%)"
1130
+ # color_scale = "RdBu" # no ice, RdBu - og is Reds - matter is good too
1131
+ # color_scale = "Portland"
1132
+ # color_scale = "RdYlGn_r"
1133
+ # color_scale = "Teal"
1134
+ color_scale = "Teal"
1135
+
1136
+ elif color_metric == "unresolved_rate":
1137
+ color_values = df["unresolved_rate"]
1138
+ color_title = "Unresolved (%)"
1139
+ # color_scale = "Burg" # og is YlOrRd
1140
+ # color_scale = "Temps"
1141
+ # color_scale = "Armyrose"
1142
+ # color_scale = "YlOrRd"
1143
+ color_scale = "Teal"
1144
+ else:
1145
+ color_values = df["urgent_rate"]
1146
+ color_title = "Urgency (%)"
1147
+ # color_scale = "Magenta" # og is Blues
1148
+ # color_scale = "Tealrose"
1149
+ # color_scale = "Portland"
1150
+ color_scale = "Teal"
1151
+
1152
+ # Set all text positions to bottom for consistent layout
1153
+ text_positions = ["bottom center"] * len(df)
1154
+
1155
+ # Create enhanced hover text that includes bin information
1156
+ hover_text = [
1157
+ f"Topic: {topic}<br>{size_title}: {raw:.1f}<br>{color_title}: {color:.1f}<br>Group: {bin_desc}"
1158
+ for topic, raw, color, bin_desc in zip(
1159
+ df["deduplicated_topic_name"],
1160
+ raw_sizes,
1161
+ color_values,
1162
+ df["bin_description"],
1163
+ )
1164
+ ]
1165
+
1166
+ # Create bubble chart
1167
+ fig = px.scatter(
1168
+ df,
1169
+ x="x",
1170
+ y="y",
1171
+ size=size_values,
1172
+ color=color_values,
1173
+ # text="deduplicated_topic_name", # Remove text here
1174
+ hover_name="deduplicated_topic_name",
1175
+ hover_data={
1176
+ "x": False,
1177
+ "y": False,
1178
+ "bin_description": True,
1179
+ },
1180
+ size_max=42.5, # Maximum size of the bubbles, change this to adjust the size
1181
+ color_continuous_scale=color_scale,
1182
+ custom_data=[
1183
+ "deduplicated_topic_name",
1184
+ "count",
1185
+ "negative_rate",
1186
+ "unresolved_rate",
1187
+ "urgent_rate",
1188
+ "bin_description",
1189
+ ],
1190
+ )
1191
+
1192
+ # Update traces: Remove text related properties
1193
+ fig.update_traces(
1194
+ mode="markers", # Remove '+text'
1195
+ marker=dict(sizemode="area", opacity=0.8, line=dict(width=1, color="white")),
1196
+ hovertemplate="%{hovertext}<extra></extra>",
1197
+ hovertext=hover_text,
1198
+ )
1199
+
1200
+ # Create annotations for the bubbles
1201
+ annotations = []
1202
+ for i, row in df.iterrows():
1203
+ # Wrap text every 2 words
1204
+ words = row["deduplicated_topic_name"].split()
1205
+ wrapped_text = "<br>".join(
1206
+ [" ".join(words[i : i + 4]) for i in range(0, len(words), 4)]
1207
+ )
1208
+
1209
+ # Calculate size for vertical offset (approximately based on the bubble size)
1210
+ # Add vertical offset based on bubble size to place text below the bubble
1211
+ marker_size = (
1212
+ size_values[i] / 20 # type: ignore # FIXME: size_values[df.index.get_loc(i)] / 20
1213
+ ) # Adjust this divisor as needed to get proper spacing
1214
+
1215
+ annotations.append(
1216
+ dict(
1217
+ x=row["x"],
1218
+ y=row["y"]
1219
+ + 0.125 # Adding this so in a row with maximum bubbles, the left one does not overlap with the bin label
1220
+ + marker_size, # Add vertical offset to position text below the bubble
1221
+ text=wrapped_text,
1222
+ showarrow=False,
1223
+ textangle=0,
1224
+ font=dict(
1225
+ size=10,
1226
+ # size=8,
1227
+ color="var(--foreground)",
1228
+ family="Arial, sans-serif",
1229
+ weight="bold",
1230
+ ),
1231
+ xanchor="center",
1232
+ yanchor="top", # Anchor to top of text box so it hangs below the bubble
1233
+ bgcolor="rgba(255,255,255,0.7)", # Add semi-transparent background for better readability
1234
+ bordercolor="rgba(0,0,0,0.1)", # Add a subtle border color
1235
+ borderwidth=1,
1236
+ borderpad=1,
1237
+ # TODO: Radius for rounded corners
1238
+ )
1239
+ )
1240
+
1241
+ # Add bin labels and separator lines
1242
+ unique_bins = sorted(df["bin"].unique())
1243
+ bin_y_positions = [
1244
+ df[df["bin"] == bin_name]["y"].mean() for bin_name in unique_bins
1245
+ ]
1246
+
1247
+ # Dynamically extract bin descriptions
1248
+ bin_descriptions = df.set_index("bin")["bin_description"].to_dict()
1249
+
1250
+ for bin_name, bin_y in zip(unique_bins, bin_y_positions):
1251
+ # Add horizontal line
1252
+ fig.add_shape(
1253
+ type="line",
1254
+ x0=0,
1255
+ y0=bin_y,
1256
+ x1=100,
1257
+ y1=bin_y,
1258
+ line=dict(color="rgba(0,0,0,0.1)", width=1, dash="dot"),
1259
+ layer="below",
1260
+ )
1261
+
1262
+ # Add subtle lines for each bin and bin labels
1263
+ for bin_name, bin_y in zip(unique_bins, bin_y_positions):
1264
+ # Add horizontal line
1265
+ fig.add_shape(
1266
+ type="line",
1267
+ x0=0,
1268
+ y0=bin_y,
1269
+ x1=100,
1270
+ y1=bin_y,
1271
+ line=dict(color="rgba(0,0,0,0.1)", width=1, dash="dot"),
1272
+ layer="below",
1273
+ )
1274
+
1275
+ # Add bin label annotation
1276
+ annotations.append(
1277
+ dict(
1278
+ x=0, # Position the label on the left side
1279
+ y=bin_y,
1280
+ xref="x",
1281
+ yref="y",
1282
+ text=bin_descriptions[bin_name],
1283
+ showarrow=False,
1284
+ font=dict(size=8.25, color="var(--muted-foreground)"),
1285
+ align="left",
1286
+ xanchor="left",
1287
+ yanchor="middle",
1288
+ bgcolor="rgba(255,255,255,0.7)",
1289
+ borderpad=1,
1290
+ )
1291
+ )
1292
+
1293
+ fig.update_layout(
1294
+ title=None,
1295
+ xaxis=dict(
1296
+ showgrid=False,
1297
+ zeroline=False,
1298
+ showticklabels=False,
1299
+ title=None,
1300
+ range=[0, 100],
1301
+ ),
1302
+ yaxis=dict(
1303
+ showgrid=False,
1304
+ zeroline=False,
1305
+ showticklabels=False,
1306
+ title=None,
1307
+ range=[0, 100],
1308
+ autorange="reversed", # Keep largest at top
1309
+ ),
1310
+ hovermode="closest",
1311
+ margin=dict(l=0, r=0, t=10, b=10),
1312
+ coloraxis_colorbar=dict(
1313
+ title=color_title,
1314
+ title_font=dict(size=9),
1315
+ tickfont=dict(size=8),
1316
+ thickness=10,
1317
+ len=0.6,
1318
+ yanchor="middle",
1319
+ y=0.5,
1320
+ xpad=0,
1321
+ ),
1322
+ legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
1323
+ paper_bgcolor="rgba(0,0,0,0)",
1324
+ plot_bgcolor="rgba(0,0,0,0)",
1325
+ hoverlabel=dict(bgcolor="white", font_size=12, font_family="Inter"),
1326
+ annotations=annotations, # Add bin labels as annotations
1327
+ )
1328
+
1329
+ return fig
1330
+
1331
+
1332
+ # Update the update_topic_details callback to use grayscale colors for tags based on frequency
1333
+ @callback(
1334
+ [
1335
+ Output("topic-title", "children"),
1336
+ Output("topic-metadata", "children"),
1337
+ Output("topic-metrics", "children"),
1338
+ Output("important-tags", "children"),
1339
+ Output("sample-dialogs", "children"),
1340
+ Output("no-topic-selected", "style"),
1341
+ ],
1342
+ [Input("bubble-chart", "hoverData"), Input("bubble-chart", "clickData")],
1343
+ [State("stored-data", "data"), State("upload-data", "contents")],
1344
+ )
1345
+ def update_topic_details(hover_data, click_data, stored_data, file_contents):
1346
+ # Determine which data to use (prioritize click over hover)
1347
+ hover_info = hover_data or click_data
1348
+
1349
+ if not hover_info or not stored_data or not file_contents:
1350
+ return "", [], [], "", [], {"display": "flex"}
1351
+
1352
+ # Extract topic name from the hover data
1353
+ topic_name = hover_info["points"][0]["customdata"][0]
1354
+
1355
+ # Get stored data for this topic
1356
+ df_stored = pd.DataFrame(stored_data)
1357
+ topic_data = df_stored[df_stored["deduplicated_topic_name"] == topic_name].iloc[0]
1358
+
1359
+ # Get original data to sample conversations
1360
+ content_type, content_string = file_contents.split(",")
1361
+ decoded = base64.b64decode(content_string)
1362
+
1363
+ if (
1364
+ content_type
1365
+ == "data:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;base64"
1366
+ ):
1367
+ df_full = pd.read_excel(io.BytesIO(decoded))
1368
+ else: # Assume CSV
1369
+ df_full = pd.read_csv(io.StringIO(decoded.decode("utf-8")))
1370
+
1371
+ # Filter to this topic
1372
+ topic_conversations = df_full[df_full["deduplicated_topic_name"] == topic_name]
1373
+
1374
+ # Create the title
1375
+ title = html.Div([html.Span(topic_name)])
1376
+
1377
+ # Create metadata items
1378
+ metadata_items = [
1379
+ html.Div(
1380
+ [
1381
+ html.I(className="fas fa-comments metadata-icon"),
1382
+ html.Span(f"{int(topic_data['count'])} dialogs"),
1383
+ ],
1384
+ className="metadata-item",
1385
+ ),
1386
+ ]
1387
+
1388
+ # Create metrics boxes
1389
+ metrics_boxes = [
1390
+ html.Div(
1391
+ [
1392
+ html.Div(f"{topic_data['negative_rate']}%", className="metric-value"),
1393
+ html.Div("Negative Sentiment", className="metric-label"),
1394
+ ],
1395
+ className="metric-box negative",
1396
+ ),
1397
+ html.Div(
1398
+ [
1399
+ html.Div(f"{topic_data['unresolved_rate']}%", className="metric-value"),
1400
+ html.Div("Unresolved", className="metric-label"),
1401
+ ],
1402
+ className="metric-box unresolved",
1403
+ ),
1404
+ html.Div(
1405
+ [
1406
+ html.Div(f"{topic_data['urgent_rate']}%", className="metric-value"),
1407
+ html.Div("Urgent", className="metric-label"),
1408
+ ],
1409
+ className="metric-box urgent",
1410
+ ),
1411
+ ]
1412
+
1413
+ # New: Extract and process consolidated_tags with improved styling
1414
+ tags_list = []
1415
+ for _, row in topic_conversations.iterrows():
1416
+ tags_str = row.get("consolidated_tags", "")
1417
+ if pd.notna(tags_str):
1418
+ tags = [tag.strip() for tag in tags_str.split(",") if tag.strip()]
1419
+ tags_list.extend(tags)
1420
+
1421
+ # Count tag frequencies for better insight
1422
+ tag_counts = {}
1423
+ for tag in tags_list:
1424
+ tag_counts[tag] = tag_counts.get(tag, 0) + 1
1425
+
1426
+ # Sort by frequency (most common first) and then alphabetically for ties
1427
+ sorted_tags = sorted(tag_counts.items(), key=lambda x: (-x[1], x[0]))
1428
+
1429
+ # Keep only the top K tags
1430
+ TOP_K = 15
1431
+ sorted_tags = sorted_tags[:TOP_K]
1432
+
1433
+ if sorted_tags:
1434
+ # Create beautifully styled tags with count indicators and consistent color
1435
+ tags_output = html.Div(
1436
+ [
1437
+ html.Div(
1438
+ [
1439
+ html.I(className="fas fa-tag topic-tag-icon"),
1440
+ html.Span(f"{tag} ({count})"),
1441
+ ],
1442
+ className="topic-tag",
1443
+ )
1444
+ for tag, count in sorted_tags
1445
+ ],
1446
+ className="tags-container",
1447
+ )
1448
+ else:
1449
+ tags_output = html.Div(
1450
+ [
1451
+ html.I(className="fas fa-info-circle", style={"marginRight": "5px"}),
1452
+ "No tags found for this topic",
1453
+ ],
1454
+ className="no-tags-message",
1455
+ )
1456
+
1457
+ # Sample up to 5 random dialogs
1458
+ sample_size = min(5, len(topic_conversations))
1459
+ if sample_size > 0:
1460
+ sample_indices = random.sample(range(len(topic_conversations)), sample_size)
1461
+ samples = topic_conversations.iloc[sample_indices]
1462
+
1463
+ dialog_items = []
1464
+ for _, row in samples.iterrows():
1465
+ # Create dialog item with tags
1466
+ sentiment_tag = html.Span(
1467
+ row["Sentiment"], className="dialog-tag tag-sentiment"
1468
+ )
1469
+ resolution_tag = html.Span(
1470
+ row["Resolution"], className="dialog-tag tag-resolution"
1471
+ )
1472
+ urgency_tag = html.Span(row["Urgency"], className="dialog-tag tag-urgency")
1473
+
1474
+ # Add Chat ID tag if 'id' column exists
1475
+ chat_id_tag = None
1476
+ if "id" in row:
1477
+ chat_id_tag = html.Span(
1478
+ f"Chat ID: {row['id']}", className="dialog-tag tag-chat-id"
1479
+ )
1480
+
1481
+ # Compile all tags, including the new Chat ID tag if available
1482
+ tags = [sentiment_tag, resolution_tag, urgency_tag]
1483
+ if chat_id_tag:
1484
+ tags.append(chat_id_tag)
1485
+
1486
+ dialog_items.append(
1487
+ html.Div(
1488
+ [
1489
+ html.Div(row["Summary"], className="dialog-summary"),
1490
+ html.Div(
1491
+ tags,
1492
+ className="dialog-metadata",
1493
+ ),
1494
+ ],
1495
+ className="dialog-item",
1496
+ )
1497
+ )
1498
+
1499
+ sample_dialogs = dialog_items
1500
+ else:
1501
+ sample_dialogs = [
1502
+ html.Div(
1503
+ "No sample dialogs available for this topic.",
1504
+ style={"color": "var(--muted-foreground)"},
1505
+ )
1506
+ ]
1507
+
1508
+ return (
1509
+ title,
1510
+ metadata_items,
1511
+ metrics_boxes,
1512
+ tags_output,
1513
+ sample_dialogs,
1514
+ {"display": "none"},
1515
+ )
1516
+
1517
+
1518
+ if __name__ == "__main__":
1519
+ app.run_server(debug=False)