dbhavery commited on
Commit
a5aebe6
·
verified ·
1 Parent(s): 79ad3a9

Upload folder using huggingface_hub

Browse files
Files changed (4) hide show
  1. README.md +78 -10
  2. __pycache__/app.cpython-313.pyc +0 -0
  3. app.py +1264 -0
  4. requirements.txt +3 -0
README.md CHANGED
@@ -1,10 +1,78 @@
1
- ---
2
- title: Herald Customer Ops
3
- emoji: 🐢
4
- colorFrom: green
5
- colorTo: yellow
6
- sdk: static
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Herald -- Customer Operations Platform
3
+ emoji: "\U0001F4CB"
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: gradio
7
+ sdk_version: 5.29.0
8
+ app_file: app.py
9
+ pinned: false
10
+ license: mit
11
+ short_description: AI customer ops -- chat, sentiment, health, tickets
12
+ ---
13
+
14
+ # Herald -- Customer Operations Platform
15
+
16
+ Herald is an AI-powered customer operations platform that unifies support chat, sentiment analysis, customer health scoring, and ticket categorization into a single system. Built with FastAPI and React, Herald gives support teams and customer success managers the tools to resolve issues faster, predict churn before it happens, and route tickets to the right team automatically.
17
+
18
+ This Hugging Face Space is an interactive demo showcasing Herald's core capabilities.
19
+
20
+ **GitHub:** [github.com/dbhavery/herald](https://github.com/dbhavery/herald)
21
+
22
+ ---
23
+
24
+ ## Demo Features
25
+
26
+ ### AI Support Chat
27
+ Simulated multi-turn customer support conversations demonstrating how Herald's AI assistant responds to different customer scenarios (general questions, billing inquiries, technical issues, escalations) with context-aware answers and knowledge-base citations.
28
+
29
+ ### Sentiment Analyzer
30
+ Paste any customer message to analyze its sentiment across five dimensions: positive, neutral, frustrated, negative, and angry. The analyzer uses keyword and pattern matching (capitalization, repeated punctuation, intensity markers) to produce confidence-weighted scores displayed on a gauge chart.
31
+
32
+ ### Customer Health Score
33
+ Input customer engagement signals (login recency, support ticket volume, NPS score, usage trend) to calculate a weighted health score from 0 to 100 with churn risk classification (Low / Medium / High / Critical). The full scoring formula and component breakdown are displayed alongside the result.
34
+
35
+ ### Ticket Categorizer
36
+ Paste a support ticket to automatically classify it into one of five categories: Billing, Technical Support, Account Management, Feature Request, or Bug Report. The categorizer shows confidence scores for each category with matched keyword details.
37
+
38
+ ---
39
+
40
+ ## Architecture Overview
41
+
42
+ ```
43
+ Herald Platform
44
+ |
45
+ |-- Frontend (React + TypeScript)
46
+ | |-- Support Chat Interface
47
+ | |-- Customer Dashboard
48
+ | |-- Analytics Views
49
+ | |-- Ticket Management
50
+ |
51
+ |-- Backend (FastAPI + Python)
52
+ | |-- AI Chat Engine (LLM + RAG knowledge base)
53
+ | |-- Sentiment Analysis Pipeline
54
+ | |-- Customer Health Scoring Engine
55
+ | |-- Ticket Categorization Service
56
+ | |-- Support Rep Copilot
57
+ |
58
+ |-- Data Layer
59
+ | |-- Customer Profiles + Engagement History
60
+ | |-- Knowledge Base (vector search)
61
+ | |-- Ticket Store + Categorization Index
62
+ | |-- Analytics Aggregations
63
+ ```
64
+
65
+ ---
66
+
67
+ ## Running Locally
68
+
69
+ ```bash
70
+ pip install -r requirements.txt
71
+ python app.py
72
+ ```
73
+
74
+ ---
75
+
76
+ ## License
77
+
78
+ MIT
__pycache__/app.cpython-313.pyc ADDED
Binary file (45.1 kB). View file
 
app.py ADDED
@@ -0,0 +1,1264 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Herald -- Customer Operations Platform
3
+ Gradio demo showcasing AI-powered customer support tooling.
4
+
5
+ Tabs:
6
+ 1. AI Support Chat - Simulated support chatbot with knowledge-base citations
7
+ 2. Sentiment Analyzer - Keyword/pattern sentiment scoring with gauge visualization
8
+ 3. Customer Health Score - Weighted formula for churn-risk prediction
9
+ 4. Ticket Categorizer - Keyword-based support ticket classification
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import math
15
+ import re
16
+ from dataclasses import dataclass
17
+ from typing import Optional
18
+
19
+ import gradio as gr
20
+ import matplotlib
21
+ import matplotlib.pyplot as plt
22
+ import numpy as np
23
+ from packaging.version import Version
24
+
25
+ matplotlib.use("Agg")
26
+
27
+ _GRADIO_VERSION = Version(gr.__version__)
28
+ _GRADIO_6_PLUS = _GRADIO_VERSION >= Version("6.0.0")
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Constants
32
+ # ---------------------------------------------------------------------------
33
+
34
+ APP_TITLE = "Herald -- Customer Operations Platform"
35
+
36
+ SENTIMENT_LABELS = ("positive", "negative", "neutral", "frustrated", "angry")
37
+
38
+ TICKET_CATEGORIES = (
39
+ "billing",
40
+ "technical",
41
+ "account",
42
+ "feature_request",
43
+ "bug_report",
44
+ )
45
+
46
+ HEALTH_WEIGHTS = {
47
+ "recency": 0.30,
48
+ "ticket_load": 0.25,
49
+ "nps": 0.25,
50
+ "usage_trend": 0.20,
51
+ }
52
+
53
+ CHURN_THRESHOLDS = {
54
+ "critical": 30,
55
+ "high": 50,
56
+ "medium": 70,
57
+ }
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Tab 1 -- AI Support Chat (pre-scripted demo)
61
+ # ---------------------------------------------------------------------------
62
+
63
+ CUSTOMER_PROFILES = {
64
+ "returning_user": {
65
+ "label": "Returning User -- General Question",
66
+ "greeting": (
67
+ "Hi, I logged in after a few weeks and noticed the dashboard "
68
+ "looks different. Where did the export button go?"
69
+ ),
70
+ },
71
+ "billing_question": {
72
+ "label": "Billing Question -- Plan Upgrade",
73
+ "greeting": (
74
+ "I need to upgrade from the Starter plan to Business but I'm "
75
+ "mid-cycle. Will I be charged the full price or prorated?"
76
+ ),
77
+ },
78
+ "technical_issue": {
79
+ "label": "Technical Issue -- API Integration",
80
+ "greeting": (
81
+ "Our webhook endpoint is returning 422 errors from your API "
82
+ "since yesterday. Payload looks the same as before. What changed?"
83
+ ),
84
+ },
85
+ "angry_customer": {
86
+ "label": "Angry Customer -- Service Outage",
87
+ "greeting": (
88
+ "This is the THIRD time this month your service has gone down. "
89
+ "We lost actual revenue because of this. I want a refund and "
90
+ "I want to talk to someone in charge."
91
+ ),
92
+ },
93
+ }
94
+
95
+ # Each scenario maps to a list of (user_msg, assistant_response) pairs.
96
+ # The first pair uses the greeting above; subsequent pairs continue the
97
+ # conversation so the demo feels multi-turn.
98
+ SCRIPTED_CONVERSATIONS: dict[str, list[tuple[str, str]]] = {
99
+ "returning_user": [
100
+ (
101
+ CUSTOMER_PROFILES["returning_user"]["greeting"],
102
+ (
103
+ "Welcome back! In the February update (v4.2) we moved bulk "
104
+ "actions -- including Export -- into the top-right toolbar so "
105
+ "they are accessible from every view.\n\n"
106
+ "To export your data:\n"
107
+ "1. Open the view you want to export (Customers, Tickets, etc.)\n"
108
+ "2. Click the toolbar icon (three horizontal lines) in the top-right\n"
109
+ "3. Select 'Export' and choose CSV or JSON\n\n"
110
+ "[Knowledge Base: UI Changes in v4.2 -- doc/release-notes/4.2.md]\n"
111
+ "[Knowledge Base: Data Export Guide -- doc/guides/export.md]"
112
+ ),
113
+ ),
114
+ (
115
+ "Got it, thanks. Can I export filtered results or only the full dataset?",
116
+ (
117
+ "You can export filtered results. Apply any filters first "
118
+ "(date range, status, tags), then use Export. The output "
119
+ "includes only the rows matching your active filters.\n\n"
120
+ "If you need scheduled exports, the Business plan includes "
121
+ "automated CSV delivery via email or S3.\n\n"
122
+ "[Knowledge Base: Filtered Exports -- doc/guides/export.md#filters]\n"
123
+ "[Knowledge Base: Scheduled Exports -- doc/guides/export.md#automation]"
124
+ ),
125
+ ),
126
+ ],
127
+ "billing_question": [
128
+ (
129
+ CUSTOMER_PROFILES["billing_question"]["greeting"],
130
+ (
131
+ "Great question. Mid-cycle upgrades are always prorated. "
132
+ "Here is how it works:\n\n"
133
+ "- We calculate the unused portion of your current Starter "
134
+ "plan and credit it to your account.\n"
135
+ "- The Business plan charge starts immediately, prorated "
136
+ "for the remaining days in your billing cycle.\n"
137
+ "- Your next full-price Business invoice will be on your "
138
+ "normal renewal date.\n\n"
139
+ "You can preview the exact amounts on the Billing page "
140
+ "before confirming the upgrade.\n\n"
141
+ "[Knowledge Base: Plan Upgrades and Proration -- doc/billing/proration.md]\n"
142
+ "[Knowledge Base: Billing FAQ -- doc/billing/faq.md]"
143
+ ),
144
+ ),
145
+ (
146
+ "What if I want to downgrade again before the cycle ends?",
147
+ (
148
+ "Downgrades take effect at the end of your current billing "
149
+ "cycle -- you keep Business features until then, and the "
150
+ "lower rate applies on renewal. No additional proration "
151
+ "charge for the downgrade.\n\n"
152
+ "One thing to note: Business-only data (custom dashboards, "
153
+ "advanced analytics history) is retained for 30 days after "
154
+ "downgrade in case you change your mind.\n\n"
155
+ "[Knowledge Base: Downgrades -- doc/billing/proration.md#downgrades]\n"
156
+ "[Knowledge Base: Data Retention on Plan Change -- doc/billing/retention.md]"
157
+ ),
158
+ ),
159
+ ],
160
+ "technical_issue": [
161
+ (
162
+ CUSTOMER_PROFILES["technical_issue"]["greeting"],
163
+ (
164
+ "I can see the issue. On March 3 we released API v2.4 which "
165
+ "tightened webhook payload validation. Two fields that were "
166
+ "previously optional are now required:\n\n"
167
+ "- `event_timestamp` (ISO 8601 format)\n"
168
+ "- `idempotency_key` (string, max 64 chars)\n\n"
169
+ "If your payload omits either field, the API returns 422 "
170
+ "with error code `VALIDATION_MISSING_FIELDS`.\n\n"
171
+ "Add those two fields and the 422s should resolve "
172
+ "immediately. No other payload changes are needed.\n\n"
173
+ "[Knowledge Base: API v2.4 Breaking Changes -- doc/api/changelog.md#v2.4]\n"
174
+ "[Knowledge Base: Webhook Payload Schema -- doc/api/webhooks.md]"
175
+ ),
176
+ ),
177
+ (
178
+ "Why wasn't this communicated before the release?",
179
+ (
180
+ "You are right to flag that -- the migration notice was sent "
181
+ "on February 25 but only to account owners, not to technical "
182
+ "contacts. I have filed an internal ticket to ensure API "
183
+ "change notices go to all contacts with Developer role.\n\n"
184
+ "For now, you can subscribe to our API changelog RSS feed "
185
+ "or enable the 'API Updates' notification channel in your "
186
+ "account settings so your engineering team gets direct "
187
+ "alerts.\n\n"
188
+ "[Knowledge Base: Notification Preferences -- doc/account/notifications.md]\n"
189
+ "[Knowledge Base: API Changelog RSS -- doc/api/changelog.md#rss]"
190
+ ),
191
+ ),
192
+ ],
193
+ "angry_customer": [
194
+ (
195
+ CUSTOMER_PROFILES["angry_customer"]["greeting"],
196
+ (
197
+ "I completely understand your frustration, and I am sorry "
198
+ "for the impact this has had on your business. Three "
199
+ "incidents in one month is not acceptable.\n\n"
200
+ "Here is what I am doing right now:\n\n"
201
+ "1. I have escalated your case to our Customer Success "
202
+ "Director, who will reach out within 2 hours to discuss "
203
+ "the refund and your account status.\n"
204
+ "2. I have flagged your account for SLA credit review -- "
205
+ "our policy covers service credits for downtime exceeding "
206
+ "99.9% availability.\n"
207
+ "3. The engineering post-mortem for the March 5 incident "
208
+ "is published and linked below. The root cause was a "
209
+ "database failover timeout; we have since reduced failover "
210
+ "time from 90 seconds to under 10.\n\n"
211
+ "Your case ID is ESC-2026-4471. You will hear from the "
212
+ "director shortly.\n\n"
213
+ "[Knowledge Base: SLA and Service Credits -- doc/legal/sla.md]\n"
214
+ "[Knowledge Base: March 5 Post-Mortem -- doc/incidents/2026-03-05.md]"
215
+ ),
216
+ ),
217
+ (
218
+ "I want to make sure this actually gets followed up on. "
219
+ "Last time I was told someone would call and nobody did.",
220
+ (
221
+ "That is a valid concern and I apologize for the previous "
222
+ "follow-up failure. I have done two things to make sure "
223
+ "this does not happen again:\n\n"
224
+ "1. Your escalation is now tracked in our internal system "
225
+ "with a hard 2-hour SLA. If the director has not contacted "
226
+ "you by then, it auto-escalates to the VP of Customer "
227
+ "Success.\n"
228
+ "2. I am sending you a confirmation email right now with "
229
+ "the case ID (ESC-2026-4471), the director's direct email, "
230
+ "and the committed timeline.\n\n"
231
+ "You also have my direct support line in that email if you "
232
+ "need to follow up.\n\n"
233
+ "[Knowledge Base: Escalation SLA Policy -- doc/internal/escalation.md]\n"
234
+ "[Knowledge Base: Customer Advocacy Program -- doc/cs/advocacy.md]"
235
+ ),
236
+ ),
237
+ ],
238
+ }
239
+
240
+
241
+ def _build_chat_history(
242
+ scenario_key: str,
243
+ turn_index: int,
244
+ ) -> list[dict[str, str]]:
245
+ """Return Gradio messages-format history up to *turn_index* (inclusive)."""
246
+ turns = SCRIPTED_CONVERSATIONS[scenario_key]
247
+ capped = min(turn_index, len(turns) - 1)
248
+ history: list[dict[str, str]] = []
249
+ for user_msg, bot_msg in turns[: capped + 1]:
250
+ history.append({"role": "user", "content": user_msg})
251
+ history.append({"role": "assistant", "content": bot_msg})
252
+ return history
253
+
254
+
255
+ def start_chat(scenario_key: str) -> tuple[list[dict[str, str]], int, str]:
256
+ """Load the first turn of the selected scenario."""
257
+ history = _build_chat_history(scenario_key, turn_index=0)
258
+ next_index = 1
259
+ max_turns = len(SCRIPTED_CONVERSATIONS[scenario_key])
260
+ if next_index >= max_turns:
261
+ status = "Demo conversation complete."
262
+ else:
263
+ status = f"Turn 1 of {max_turns}. Click 'Next Response' to continue."
264
+ return history, next_index, status
265
+
266
+
267
+ def next_turn(
268
+ scenario_key: str,
269
+ current_index: int,
270
+ current_history: list[dict[str, str]],
271
+ ) -> tuple[list[dict[str, str]], int, str]:
272
+ """Advance the demo conversation by one turn."""
273
+ turns = SCRIPTED_CONVERSATIONS[scenario_key]
274
+ if current_index >= len(turns):
275
+ return (
276
+ current_history,
277
+ current_index,
278
+ "Demo conversation complete -- all turns shown.",
279
+ )
280
+ history = _build_chat_history(scenario_key, current_index)
281
+ next_index = current_index + 1
282
+ max_turns = len(turns)
283
+ if next_index >= max_turns:
284
+ status = "Demo conversation complete."
285
+ else:
286
+ status = f"Turn {next_index} of {max_turns}. Click 'Next Response' to continue."
287
+ return history, next_index, status
288
+
289
+
290
+ # ---------------------------------------------------------------------------
291
+ # Tab 2 -- Sentiment Analyzer
292
+ # ---------------------------------------------------------------------------
293
+
294
+ # Weighted keyword lists. Each word carries a base weight; surrounding
295
+ # context (caps, exclamation marks, repeated punctuation) applies multipliers.
296
+
297
+ SENTIMENT_KEYWORDS: dict[str, list[tuple[str, float]]] = {
298
+ "positive": [
299
+ ("thank", 0.25), ("thanks", 0.25), ("great", 0.30),
300
+ ("excellent", 0.35), ("awesome", 0.30), ("love", 0.30),
301
+ ("helpful", 0.25), ("appreciate", 0.30), ("amazing", 0.30),
302
+ ("perfect", 0.35), ("fantastic", 0.30), ("wonderful", 0.30),
303
+ ("pleased", 0.25), ("happy", 0.25), ("impressed", 0.30),
304
+ ("well done", 0.30), ("good job", 0.25), ("smooth", 0.20),
305
+ ("intuitive", 0.20), ("reliable", 0.20), ("fast", 0.15),
306
+ ("easy", 0.15), ("resolved", 0.20), ("fixed", 0.20),
307
+ ("works", 0.10), ("satisfied", 0.25), ("recommend", 0.25),
308
+ ],
309
+ "negative": [
310
+ ("bad", 0.25), ("terrible", 0.35), ("awful", 0.35),
311
+ ("horrible", 0.35), ("worst", 0.40), ("hate", 0.35),
312
+ ("useless", 0.30), ("broken", 0.30), ("failure", 0.30),
313
+ ("disappointed", 0.30), ("poor", 0.25), ("unacceptable", 0.35),
314
+ ("pathetic", 0.35), ("disaster", 0.35), ("trash", 0.30),
315
+ ("garbage", 0.30), ("rubbish", 0.30), ("waste", 0.25),
316
+ ("never works", 0.35), ("down again", 0.30),
317
+ ],
318
+ "frustrated": [
319
+ ("frustrat", 0.35), ("annoying", 0.30), ("annoyed", 0.30),
320
+ ("still not", 0.25), ("again", 0.15), ("keeps", 0.15),
321
+ ("another", 0.10), ("still", 0.15), ("waiting", 0.20),
322
+ ("how many times", 0.35), ("yet again", 0.30),
323
+ ("come on", 0.25), ("seriously", 0.20), ("ridiculous", 0.30),
324
+ ("unbelievable", 0.25), ("sick of", 0.30), ("tired of", 0.30),
325
+ ("over and over", 0.30), ("same issue", 0.25),
326
+ ("not resolved", 0.25), ("still broken", 0.30),
327
+ ],
328
+ "angry": [
329
+ ("furious", 0.45), ("livid", 0.45), ("outraged", 0.40),
330
+ ("scam", 0.40), ("sue", 0.40), ("lawyer", 0.35),
331
+ ("legal action", 0.40), ("demand", 0.30), ("refund", 0.20),
332
+ ("cancel", 0.20), ("immediately", 0.15), ("unacceptable", 0.25),
333
+ ("stealing", 0.40), ("fraud", 0.40), ("rip off", 0.35),
334
+ ("ripoff", 0.35), ("incompetent", 0.35),
335
+ ],
336
+ }
337
+
338
+ # Patterns that amplify scores.
339
+ _CAPS_RATIO_THRESHOLD = 0.5 # if > 50% of alpha chars are uppercase
340
+ _EXCLAMATION_PATTERN = re.compile(r"!{2,}")
341
+ _QUESTION_FLOOD_PATTERN = re.compile(r"\?{2,}")
342
+ _ELLIPSIS_PATTERN = re.compile(r"\.{3,}")
343
+
344
+
345
+ @dataclass(frozen=True)
346
+ class SentimentResult:
347
+ positive: float
348
+ negative: float
349
+ neutral: float
350
+ frustrated: float
351
+ angry: float
352
+ dominant: str
353
+ dominant_confidence: float
354
+
355
+
356
+ def _clamp(value: float, low: float = 0.0, high: float = 1.0) -> float:
357
+ return max(low, min(high, value))
358
+
359
+
360
+ def analyze_sentiment(text: str) -> SentimentResult:
361
+ """Score customer message sentiment using keyword/pattern matching."""
362
+ if not text or not text.strip():
363
+ return SentimentResult(
364
+ positive=0.0,
365
+ negative=0.0,
366
+ neutral=1.0,
367
+ frustrated=0.0,
368
+ angry=0.0,
369
+ dominant="neutral",
370
+ dominant_confidence=1.0,
371
+ )
372
+
373
+ text_lower = text.lower()
374
+ alpha_chars = [c for c in text if c.isalpha()]
375
+ caps_ratio = (
376
+ sum(1 for c in alpha_chars if c.isupper()) / len(alpha_chars)
377
+ if alpha_chars
378
+ else 0.0
379
+ )
380
+
381
+ # Context-based multiplier: shouting (caps), repeated punctuation
382
+ intensity_multiplier = 1.0
383
+ if caps_ratio > _CAPS_RATIO_THRESHOLD:
384
+ intensity_multiplier += 0.3
385
+ if _EXCLAMATION_PATTERN.search(text):
386
+ intensity_multiplier += 0.2
387
+ if _QUESTION_FLOOD_PATTERN.search(text):
388
+ intensity_multiplier += 0.1
389
+
390
+ raw_scores: dict[str, float] = {}
391
+ for sentiment, keywords in SENTIMENT_KEYWORDS.items():
392
+ score = 0.0
393
+ for keyword, weight in keywords:
394
+ if keyword in text_lower:
395
+ score += weight
396
+ raw_scores[sentiment] = score * intensity_multiplier
397
+
398
+ # Neutral is the absence of other signals.
399
+ total_signal = sum(raw_scores.values())
400
+ if total_signal < 0.15:
401
+ raw_scores["neutral"] = 0.8
402
+ elif total_signal < 0.4:
403
+ raw_scores["neutral"] = 0.4 - total_signal * 0.5
404
+ else:
405
+ raw_scores["neutral"] = max(0.0, 0.2 - total_signal * 0.1)
406
+
407
+ # Normalize to sum = 1.
408
+ all_sentiments = {
409
+ "positive": raw_scores.get("positive", 0.0),
410
+ "negative": raw_scores.get("negative", 0.0),
411
+ "neutral": raw_scores.get("neutral", 0.0),
412
+ "frustrated": raw_scores.get("frustrated", 0.0),
413
+ "angry": raw_scores.get("angry", 0.0),
414
+ }
415
+ total = sum(all_sentiments.values())
416
+ if total > 0:
417
+ for key in all_sentiments:
418
+ all_sentiments[key] /= total
419
+
420
+ dominant = max(all_sentiments, key=lambda k: all_sentiments[k])
421
+ return SentimentResult(
422
+ positive=round(all_sentiments["positive"], 3),
423
+ negative=round(all_sentiments["negative"], 3),
424
+ neutral=round(all_sentiments["neutral"], 3),
425
+ frustrated=round(all_sentiments["frustrated"], 3),
426
+ angry=round(all_sentiments["angry"], 3),
427
+ dominant=dominant,
428
+ dominant_confidence=round(all_sentiments[dominant], 3),
429
+ )
430
+
431
+
432
+ def _render_sentiment_gauge(result: SentimentResult) -> plt.Figure:
433
+ """Render a semicircular gauge chart for overall sentiment."""
434
+ fig, ax = plt.subplots(figsize=(6, 3.5), subplot_kw={"projection": "polar"})
435
+ fig.patch.set_facecolor("#0b0f19")
436
+
437
+ # Map dominant sentiment to a position on the gauge.
438
+ # Left (pi) = very negative, right (0) = very positive.
439
+ sentiment_positions = {
440
+ "angry": 0.05,
441
+ "negative": 0.20,
442
+ "frustrated": 0.35,
443
+ "neutral": 0.50,
444
+ "positive": 0.90,
445
+ }
446
+ sentiment_colors = {
447
+ "angry": "#dc2626",
448
+ "negative": "#f97316",
449
+ "frustrated": "#eab308",
450
+ "neutral": "#6b7280",
451
+ "positive": "#22c55e",
452
+ }
453
+
454
+ # Draw the arc segments.
455
+ segment_data = [
456
+ (0.00, 0.15, "#dc2626", "Angry"),
457
+ (0.15, 0.30, "#f97316", "Negative"),
458
+ (0.30, 0.45, "#eab308", "Frustrated"),
459
+ (0.45, 0.65, "#6b7280", "Neutral"),
460
+ (0.65, 1.00, "#22c55e", "Positive"),
461
+ ]
462
+ for start_frac, end_frac, color, _ in segment_data:
463
+ theta_start = np.pi * (1 - end_frac)
464
+ theta_end = np.pi * (1 - start_frac)
465
+ theta_range = np.linspace(theta_start, theta_end, 50)
466
+ ax.fill_between(theta_range, 0.6, 1.0, color=color, alpha=0.35)
467
+
468
+ # Needle position.
469
+ needle_frac = sentiment_positions.get(result.dominant, 0.5)
470
+ # Blend with confidence: low confidence pulls toward center.
471
+ blended_frac = 0.5 + (needle_frac - 0.5) * result.dominant_confidence
472
+ needle_theta = np.pi * (1 - blended_frac)
473
+ needle_color = sentiment_colors.get(result.dominant, "#6b7280")
474
+
475
+ ax.plot(
476
+ [needle_theta, needle_theta],
477
+ [0, 0.95],
478
+ color=needle_color,
479
+ linewidth=3,
480
+ solid_capstyle="round",
481
+ )
482
+ ax.scatter([needle_theta], [0.0], color=needle_color, s=80, zorder=5)
483
+
484
+ # Labels on the arc.
485
+ for start_frac, end_frac, color, label in segment_data:
486
+ mid_frac = (start_frac + end_frac) / 2
487
+ mid_theta = np.pi * (1 - mid_frac)
488
+ ax.text(
489
+ mid_theta,
490
+ 1.18,
491
+ label,
492
+ ha="center",
493
+ va="center",
494
+ fontsize=8,
495
+ fontweight="bold",
496
+ color=color,
497
+ )
498
+
499
+ ax.set_ylim(0, 1.4)
500
+ ax.set_thetamin(0)
501
+ ax.set_thetamax(180)
502
+ ax.set_axis_off()
503
+
504
+ # Title below the gauge.
505
+ fig.text(
506
+ 0.5,
507
+ 0.10,
508
+ f"{result.dominant.upper()} ({result.dominant_confidence:.0%} confidence)",
509
+ ha="center",
510
+ va="center",
511
+ fontsize=14,
512
+ fontweight="bold",
513
+ color=needle_color,
514
+ )
515
+
516
+ fig.tight_layout(pad=1.0)
517
+ return fig
518
+
519
+
520
+ def run_sentiment_analysis(
521
+ text: str,
522
+ ) -> tuple[plt.Figure, str]:
523
+ """Entry point for the Sentiment Analyzer tab."""
524
+ result = analyze_sentiment(text)
525
+ gauge = _render_sentiment_gauge(result)
526
+
527
+ breakdown_lines = [
528
+ "Sentiment Breakdown",
529
+ "---",
530
+ f" Positive: {result.positive:.1%}",
531
+ f" Neutral: {result.neutral:.1%}",
532
+ f" Frustrated: {result.frustrated:.1%}",
533
+ f" Negative: {result.negative:.1%}",
534
+ f" Angry: {result.angry:.1%}",
535
+ "",
536
+ f"Dominant sentiment: {result.dominant}",
537
+ f"Confidence: {result.dominant_confidence:.1%}",
538
+ ]
539
+ return gauge, "\n".join(breakdown_lines)
540
+
541
+
542
+ # ---------------------------------------------------------------------------
543
+ # Tab 3 -- Customer Health Score
544
+ # ---------------------------------------------------------------------------
545
+
546
+ @dataclass(frozen=True)
547
+ class HealthScoreResult:
548
+ overall_score: int
549
+ churn_risk: str
550
+ recency_score: float
551
+ ticket_score: float
552
+ nps_score: float
553
+ usage_score: float
554
+ breakdown_text: str
555
+
556
+
557
+ def _score_recency(days_since_login: int) -> float:
558
+ """Score recency: 100 for today, decays toward 0 at ~90+ days."""
559
+ if days_since_login <= 0:
560
+ return 100.0
561
+ if days_since_login >= 120:
562
+ return 0.0
563
+ # Exponential decay: half-life at ~14 days.
564
+ return 100.0 * math.exp(-0.03 * days_since_login)
565
+
566
+
567
+ def _score_tickets(ticket_count: int) -> float:
568
+ """Score ticket load: 0 tickets = 100, decays with more tickets."""
569
+ if ticket_count <= 0:
570
+ return 100.0
571
+ if ticket_count >= 20:
572
+ return 0.0
573
+ # Linear with a steep drop after 5.
574
+ if ticket_count <= 2:
575
+ return 100.0 - ticket_count * 5
576
+ return max(0.0, 100.0 - 10 * ticket_count)
577
+
578
+
579
+ def _score_nps(nps: int) -> float:
580
+ """Score NPS: direct 0-100 mapping from 0-10 scale."""
581
+ clamped = max(0, min(10, nps))
582
+ return clamped * 10.0
583
+
584
+
585
+ def _score_usage_trend(trend_pct: float) -> float:
586
+ """Score usage trend: +50% = 100, 0% = 50, -50% = 0."""
587
+ clamped = max(-100.0, min(100.0, trend_pct))
588
+ return 50.0 + clamped * 0.5
589
+
590
+
591
+ def _classify_churn_risk(score: int) -> str:
592
+ if score < CHURN_THRESHOLDS["critical"]:
593
+ return "CRITICAL"
594
+ if score < CHURN_THRESHOLDS["high"]:
595
+ return "HIGH"
596
+ if score < CHURN_THRESHOLDS["medium"]:
597
+ return "MEDIUM"
598
+ return "LOW"
599
+
600
+
601
+ def calculate_health_score(
602
+ days_since_login: int,
603
+ support_tickets_30d: int,
604
+ nps_score: int,
605
+ usage_trend_pct: float,
606
+ ) -> HealthScoreResult:
607
+ """Calculate weighted customer health score."""
608
+ recency = _score_recency(days_since_login)
609
+ tickets = _score_tickets(support_tickets_30d)
610
+ nps = _score_nps(nps_score)
611
+ usage = _score_usage_trend(usage_trend_pct)
612
+
613
+ weighted = (
614
+ recency * HEALTH_WEIGHTS["recency"]
615
+ + tickets * HEALTH_WEIGHTS["ticket_load"]
616
+ + nps * HEALTH_WEIGHTS["nps"]
617
+ + usage * HEALTH_WEIGHTS["usage_trend"]
618
+ )
619
+ overall = int(round(_clamp(weighted, 0.0, 100.0)))
620
+ churn_risk = _classify_churn_risk(overall)
621
+
622
+ breakdown = (
623
+ "SCORING FORMULA\n"
624
+ "===============\n"
625
+ f" Recency ({HEALTH_WEIGHTS['recency']:.0%} weight): "
626
+ f"{recency:5.1f} / 100\n"
627
+ f" Ticket Load ({HEALTH_WEIGHTS['ticket_load']:.0%} weight): "
628
+ f"{tickets:5.1f} / 100\n"
629
+ f" NPS ({HEALTH_WEIGHTS['nps']:.0%} weight): "
630
+ f"{nps:5.1f} / 100\n"
631
+ f" Usage Trend ({HEALTH_WEIGHTS['usage_trend']:.0%} weight): "
632
+ f"{usage:5.1f} / 100\n"
633
+ "---------------------------------------\n"
634
+ f" Weighted Total: {weighted:5.1f}\n"
635
+ f" Overall Score: {overall}\n"
636
+ f" Churn Risk: {churn_risk}\n"
637
+ "\n"
638
+ "THRESHOLDS\n"
639
+ "==========\n"
640
+ f" < {CHURN_THRESHOLDS['critical']} = CRITICAL "
641
+ f" < {CHURN_THRESHOLDS['high']} = HIGH\n"
642
+ f" < {CHURN_THRESHOLDS['medium']} = MEDIUM "
643
+ f" >= {CHURN_THRESHOLDS['medium']} = LOW\n"
644
+ "\n"
645
+ "COMPONENT DETAILS\n"
646
+ "=================\n"
647
+ f" Recency: Exponential decay (half-life ~23 days). "
648
+ f"Input: {days_since_login} days.\n"
649
+ f" Ticket Load: Linear penalty, steep after 5 tickets. "
650
+ f"Input: {support_tickets_30d} tickets.\n"
651
+ f" NPS: Direct 0-10 to 0-100 mapping. "
652
+ f"Input: {nps_score}.\n"
653
+ f" Usage Trend: Linear scale, 0% = 50pts, +/-100% = 100/0pts. "
654
+ f"Input: {usage_trend_pct:+.0f}%."
655
+ )
656
+
657
+ return HealthScoreResult(
658
+ overall_score=overall,
659
+ churn_risk=churn_risk,
660
+ recency_score=recency,
661
+ ticket_score=tickets,
662
+ nps_score=nps,
663
+ usage_score=usage,
664
+ breakdown_text=breakdown,
665
+ )
666
+
667
+
668
+ def _render_health_gauge(result: HealthScoreResult) -> plt.Figure:
669
+ """Render a horizontal bar showing overall health score."""
670
+ fig, ax = plt.subplots(figsize=(8, 2.0))
671
+ fig.patch.set_facecolor("#0b0f19")
672
+ ax.set_facecolor("#0b0f19")
673
+
674
+ # Gradient background bar.
675
+ gradient = np.linspace(0, 1, 256).reshape(1, -1)
676
+ red_to_green = matplotlib.colors.LinearSegmentedColormap.from_list(
677
+ "health", ["#dc2626", "#f97316", "#eab308", "#22c55e"]
678
+ )
679
+ ax.imshow(
680
+ gradient,
681
+ aspect="auto",
682
+ cmap=red_to_green,
683
+ extent=[0, 100, 0, 1],
684
+ alpha=0.4,
685
+ )
686
+
687
+ # Score marker.
688
+ score = result.overall_score
689
+ risk_colors = {
690
+ "CRITICAL": "#dc2626",
691
+ "HIGH": "#f97316",
692
+ "MEDIUM": "#eab308",
693
+ "LOW": "#22c55e",
694
+ }
695
+ marker_color = risk_colors.get(result.churn_risk, "#6b7280")
696
+ ax.axvline(x=score, color=marker_color, linewidth=4, ymin=0.1, ymax=0.9)
697
+ ax.scatter([score], [0.5], color=marker_color, s=200, zorder=5)
698
+
699
+ ax.text(
700
+ score,
701
+ 1.35,
702
+ f"{score}",
703
+ ha="center",
704
+ va="bottom",
705
+ fontsize=22,
706
+ fontweight="bold",
707
+ color=marker_color,
708
+ )
709
+ ax.text(
710
+ score,
711
+ -0.35,
712
+ f"Churn Risk: {result.churn_risk}",
713
+ ha="center",
714
+ va="top",
715
+ fontsize=11,
716
+ fontweight="bold",
717
+ color=marker_color,
718
+ )
719
+
720
+ # Threshold markers.
721
+ for label, threshold in CHURN_THRESHOLDS.items():
722
+ ax.axvline(x=threshold, color="#4b5563", linewidth=1, linestyle="--", alpha=0.6)
723
+ ax.text(
724
+ threshold,
725
+ 1.1,
726
+ str(threshold),
727
+ ha="center",
728
+ va="bottom",
729
+ fontsize=8,
730
+ color="#9ca3af",
731
+ )
732
+
733
+ ax.set_xlim(0, 100)
734
+ ax.set_ylim(-0.5, 1.8)
735
+ ax.set_axis_off()
736
+ fig.tight_layout(pad=0.5)
737
+ return fig
738
+
739
+
740
+ def run_health_score(
741
+ days_since_login: int,
742
+ support_tickets: int,
743
+ nps: int,
744
+ usage_trend: float,
745
+ ) -> tuple[plt.Figure, str]:
746
+ """Entry point for the Health Score tab."""
747
+ result = calculate_health_score(
748
+ int(days_since_login),
749
+ int(support_tickets),
750
+ int(nps),
751
+ float(usage_trend),
752
+ )
753
+ gauge = _render_health_gauge(result)
754
+ return gauge, result.breakdown_text
755
+
756
+
757
+ # ---------------------------------------------------------------------------
758
+ # Tab 4 -- Ticket Categorizer
759
+ # ---------------------------------------------------------------------------
760
+
761
+ CATEGORY_KEYWORDS: dict[str, list[tuple[str, float]]] = {
762
+ "billing": [
763
+ ("invoice", 0.35), ("charge", 0.30), ("payment", 0.35),
764
+ ("billing", 0.40), ("subscription", 0.30), ("refund", 0.35),
765
+ ("credit card", 0.35), ("plan", 0.20), ("upgrade", 0.25),
766
+ ("downgrade", 0.25), ("prorate", 0.35), ("proration", 0.35),
767
+ ("receipt", 0.30), ("overcharged", 0.35), ("pricing", 0.30),
768
+ ("cost", 0.20), ("fee", 0.25), ("renewal", 0.30),
769
+ ("trial", 0.25), ("coupon", 0.30), ("discount", 0.25),
770
+ ],
771
+ "technical": [
772
+ ("error", 0.25), ("crash", 0.35), ("api", 0.30),
773
+ ("timeout", 0.30), ("ssl", 0.35), ("certificate", 0.30),
774
+ ("endpoint", 0.30), ("server", 0.25), ("database", 0.30),
775
+ ("integration", 0.25), ("webhook", 0.30), ("latency", 0.30),
776
+ ("performance", 0.25), ("deploy", 0.25), ("configuration", 0.25),
777
+ ("dns", 0.30), ("404", 0.30), ("500", 0.30), ("502", 0.30),
778
+ ("connection", 0.20), ("sync", 0.20), ("load", 0.15),
779
+ ],
780
+ "account": [
781
+ ("password", 0.35), ("login", 0.30), ("account", 0.25),
782
+ ("access", 0.20), ("permission", 0.30), ("role", 0.25),
783
+ ("sso", 0.35), ("mfa", 0.35), ("two factor", 0.35),
784
+ ("locked out", 0.35), ("reset", 0.25), ("email change", 0.30),
785
+ ("profile", 0.20), ("team member", 0.25), ("invite", 0.25),
786
+ ("deactivate", 0.25), ("username", 0.30), ("sign in", 0.30),
787
+ ("authentication", 0.35), ("session", 0.25), ("token", 0.25),
788
+ ],
789
+ "feature_request": [
790
+ ("feature", 0.30), ("request", 0.20), ("would be nice", 0.35),
791
+ ("could you add", 0.35), ("suggestion", 0.30),
792
+ ("it would help", 0.30), ("wish", 0.25), ("roadmap", 0.30),
793
+ ("planned", 0.20), ("enhancement", 0.30), ("improve", 0.20),
794
+ ("add support for", 0.35), ("capability", 0.20),
795
+ ("missing feature", 0.35), ("need ability", 0.30),
796
+ ("want to be able", 0.35), ("should support", 0.30),
797
+ ],
798
+ "bug_report": [
799
+ ("bug", 0.40), ("broken", 0.30), ("not working", 0.35),
800
+ ("doesn't work", 0.35), ("does not work", 0.35),
801
+ ("unexpected", 0.25), ("wrong", 0.20), ("incorrect", 0.25),
802
+ ("should be", 0.20), ("supposed to", 0.25), ("regression", 0.40),
803
+ ("reproduce", 0.35), ("steps to", 0.20), ("intermittent", 0.30),
804
+ ("flaky", 0.30), ("glitch", 0.30), ("malfunction", 0.30),
805
+ ("behaving", 0.15), ("issue", 0.15), ("problem", 0.15),
806
+ ],
807
+ }
808
+
809
+ CATEGORY_DISPLAY_NAMES = {
810
+ "billing": "Billing",
811
+ "technical": "Technical Support",
812
+ "account": "Account Management",
813
+ "feature_request": "Feature Request",
814
+ "bug_report": "Bug Report",
815
+ }
816
+
817
+
818
+ @dataclass(frozen=True)
819
+ class CategorizationResult:
820
+ category: str
821
+ display_name: str
822
+ confidence: float
823
+ all_scores: dict[str, float]
824
+ reasoning: str
825
+
826
+
827
+ def categorize_ticket(text: str) -> CategorizationResult:
828
+ """Categorize a support ticket using keyword matching."""
829
+ if not text or not text.strip():
830
+ return CategorizationResult(
831
+ category="uncategorized",
832
+ display_name="Uncategorized",
833
+ confidence=0.0,
834
+ all_scores={name: 0.0 for name in TICKET_CATEGORIES},
835
+ reasoning="No ticket text provided.",
836
+ )
837
+
838
+ text_lower = text.lower()
839
+ raw_scores: dict[str, float] = {}
840
+
841
+ matched_keywords: dict[str, list[str]] = {}
842
+ for category, keywords in CATEGORY_KEYWORDS.items():
843
+ score = 0.0
844
+ matches: list[str] = []
845
+ for keyword, weight in keywords:
846
+ if keyword in text_lower:
847
+ score += weight
848
+ matches.append(keyword)
849
+ raw_scores[category] = score
850
+ matched_keywords[category] = matches
851
+
852
+ total = sum(raw_scores.values())
853
+ if total == 0:
854
+ normalized = {cat: 1.0 / len(TICKET_CATEGORIES) for cat in TICKET_CATEGORIES}
855
+ top_category = "technical" # default fallback
856
+ confidence = round(normalized[top_category], 3)
857
+ else:
858
+ normalized = {cat: raw_scores[cat] / total for cat in TICKET_CATEGORIES}
859
+ top_category = max(normalized, key=lambda k: normalized[k])
860
+ confidence = round(normalized[top_category], 3)
861
+
862
+ # Build reasoning text.
863
+ reasoning_lines = ["Category Scores", "---"]
864
+ for cat in TICKET_CATEGORIES:
865
+ display = CATEGORY_DISPLAY_NAMES[cat]
866
+ pct = normalized.get(cat, 0.0)
867
+ kw_list = matched_keywords.get(cat, [])
868
+ kw_display = ", ".join(kw_list) if kw_list else "(none)"
869
+ reasoning_lines.append(f" {display:.<25s} {pct:.1%}")
870
+ reasoning_lines.append(f" Matched keywords: {kw_display}")
871
+
872
+ reasoning_lines.extend([
873
+ "",
874
+ f"Assigned Category: {CATEGORY_DISPLAY_NAMES[top_category]}",
875
+ f"Confidence: {confidence:.1%}",
876
+ ])
877
+
878
+ return CategorizationResult(
879
+ category=top_category,
880
+ display_name=CATEGORY_DISPLAY_NAMES[top_category],
881
+ confidence=confidence,
882
+ all_scores={cat: round(normalized[cat], 3) for cat in TICKET_CATEGORIES},
883
+ reasoning="\n".join(reasoning_lines),
884
+ )
885
+
886
+
887
+ def _render_category_chart(result: CategorizationResult) -> plt.Figure:
888
+ """Horizontal bar chart of category confidence scores."""
889
+ fig, ax = plt.subplots(figsize=(7, 3))
890
+ fig.patch.set_facecolor("#0b0f19")
891
+ ax.set_facecolor("#0b0f19")
892
+
893
+ categories = list(result.all_scores.keys())
894
+ scores = [result.all_scores[c] for c in categories]
895
+ display_names = [CATEGORY_DISPLAY_NAMES[c] for c in categories]
896
+
897
+ # Sort by score descending.
898
+ sorted_indices = sorted(range(len(scores)), key=lambda i: scores[i])
899
+ display_names = [display_names[i] for i in sorted_indices]
900
+ scores = [scores[i] for i in sorted_indices]
901
+ categories_sorted = [categories[i] for i in sorted_indices]
902
+
903
+ bar_colors = [
904
+ "#3b82f6" if c != result.category else "#22c55e"
905
+ for c in categories_sorted
906
+ ]
907
+
908
+ bars = ax.barh(display_names, scores, color=bar_colors, height=0.6, alpha=0.85)
909
+
910
+ for bar, score in zip(bars, scores):
911
+ if score > 0.02:
912
+ ax.text(
913
+ bar.get_width() + 0.02,
914
+ bar.get_y() + bar.get_height() / 2,
915
+ f"{score:.0%}",
916
+ va="center",
917
+ ha="left",
918
+ fontsize=10,
919
+ color="#e5e7eb",
920
+ fontweight="bold",
921
+ )
922
+
923
+ ax.set_xlim(0, max(scores) * 1.3 if max(scores) > 0 else 1.0)
924
+ ax.tick_params(colors="#9ca3af", labelsize=10)
925
+ ax.spines["top"].set_visible(False)
926
+ ax.spines["right"].set_visible(False)
927
+ ax.spines["bottom"].set_color("#374151")
928
+ ax.spines["left"].set_color("#374151")
929
+ ax.xaxis.set_visible(False)
930
+
931
+ fig.tight_layout(pad=1.0)
932
+ return fig
933
+
934
+
935
+ def run_ticket_categorization(text: str) -> tuple[plt.Figure, str]:
936
+ """Entry point for the Ticket Categorizer tab."""
937
+ result = categorize_ticket(text)
938
+ chart = _render_category_chart(result)
939
+ return chart, result.reasoning
940
+
941
+
942
+ # ---------------------------------------------------------------------------
943
+ # Gradio UI
944
+ # ---------------------------------------------------------------------------
945
+
946
+ CUSTOM_CSS = """
947
+ .gradio-container {
948
+ max-width: 1100px !important;
949
+ margin: auto;
950
+ }
951
+ .app-header {
952
+ text-align: center;
953
+ padding: 1.5rem 0 0.5rem 0;
954
+ }
955
+ .app-header h1 {
956
+ font-size: 2rem;
957
+ font-weight: 700;
958
+ color: #e5e7eb;
959
+ margin-bottom: 0.25rem;
960
+ }
961
+ .app-header p {
962
+ color: #9ca3af;
963
+ font-size: 1rem;
964
+ }
965
+ footer {
966
+ display: none !important;
967
+ }
968
+ """
969
+
970
+
971
+ HERALD_THEME = gr.themes.Base(
972
+ primary_hue=gr.themes.colors.blue,
973
+ secondary_hue=gr.themes.colors.gray,
974
+ neutral_hue=gr.themes.colors.gray,
975
+ font=gr.themes.GoogleFont("Inter"),
976
+ ).set(
977
+ body_background_fill="#0b0f19",
978
+ body_background_fill_dark="#0b0f19",
979
+ block_background_fill="#111827",
980
+ block_background_fill_dark="#111827",
981
+ block_border_color="#1f2937",
982
+ block_border_color_dark="#1f2937",
983
+ block_label_text_color="#9ca3af",
984
+ block_label_text_color_dark="#9ca3af",
985
+ block_title_text_color="#e5e7eb",
986
+ block_title_text_color_dark="#e5e7eb",
987
+ body_text_color="#d1d5db",
988
+ body_text_color_dark="#d1d5db",
989
+ input_background_fill="#1f2937",
990
+ input_background_fill_dark="#1f2937",
991
+ input_border_color="#374151",
992
+ input_border_color_dark="#374151",
993
+ button_primary_background_fill="#2563eb",
994
+ button_primary_background_fill_dark="#2563eb",
995
+ button_primary_text_color="#ffffff",
996
+ button_primary_text_color_dark="#ffffff",
997
+ )
998
+
999
+
1000
+ def build_app() -> gr.Blocks:
1001
+ """Construct and return the Gradio Blocks application."""
1002
+ # Gradio 6+ moved theme/css from Blocks() to launch().
1003
+ # Gradio 5.x accepts them in the constructor.
1004
+ blocks_kwargs: dict[str, object] = {"title": APP_TITLE}
1005
+ if not _GRADIO_6_PLUS:
1006
+ blocks_kwargs["theme"] = HERALD_THEME
1007
+ blocks_kwargs["css"] = CUSTOM_CSS
1008
+
1009
+ with gr.Blocks(**blocks_kwargs) as app: # type: ignore[arg-type]
1010
+ gr.HTML(
1011
+ '<div class="app-header">'
1012
+ "<h1>Herald -- Customer Operations Platform</h1>"
1013
+ "<p>AI-powered customer support, sentiment analysis, "
1014
+ "health scoring, and ticket categorization</p>"
1015
+ "</div>"
1016
+ )
1017
+
1018
+ with gr.Tabs():
1019
+ # ----------------------------------------------------------
1020
+ # Tab 1: AI Support Chat
1021
+ # ----------------------------------------------------------
1022
+ with gr.Tab("AI Support Chat"):
1023
+ gr.Markdown(
1024
+ "Simulated customer support chatbot with knowledge-base "
1025
+ "citations. Select a customer scenario, then step through "
1026
+ "the conversation."
1027
+ )
1028
+ with gr.Row():
1029
+ scenario_dropdown = gr.Dropdown(
1030
+ choices=[
1031
+ (v["label"], k)
1032
+ for k, v in CUSTOMER_PROFILES.items()
1033
+ ],
1034
+ value="returning_user",
1035
+ label="Customer Scenario",
1036
+ interactive=True,
1037
+ )
1038
+ with gr.Row():
1039
+ start_btn = gr.Button("Start Conversation", variant="primary")
1040
+ next_btn = gr.Button("Next Response", variant="secondary")
1041
+
1042
+ # Gradio 6+ removed the type parameter (messages is default).
1043
+ # Gradio 5.x requires type="messages" for dict-format history.
1044
+ chatbot_kwargs: dict[str, object] = {
1045
+ "label": "Support Conversation",
1046
+ "height": 480,
1047
+ }
1048
+ if not _GRADIO_6_PLUS:
1049
+ chatbot_kwargs["type"] = "messages"
1050
+ chatbot = gr.Chatbot(**chatbot_kwargs) # type: ignore[arg-type]
1051
+ turn_state = gr.State(value=0)
1052
+ chat_status = gr.Textbox(
1053
+ label="Status",
1054
+ interactive=False,
1055
+ value="Select a scenario and click 'Start Conversation'.",
1056
+ )
1057
+
1058
+ start_btn.click(
1059
+ fn=start_chat,
1060
+ inputs=[scenario_dropdown],
1061
+ outputs=[chatbot, turn_state, chat_status],
1062
+ )
1063
+ next_btn.click(
1064
+ fn=next_turn,
1065
+ inputs=[scenario_dropdown, turn_state, chatbot],
1066
+ outputs=[chatbot, turn_state, chat_status],
1067
+ )
1068
+
1069
+ # ----------------------------------------------------------
1070
+ # Tab 2: Sentiment Analyzer
1071
+ # ----------------------------------------------------------
1072
+ with gr.Tab("Sentiment Analyzer"):
1073
+ gr.Markdown(
1074
+ "Paste a customer message to analyze its sentiment. "
1075
+ "Uses keyword and pattern matching to score across five "
1076
+ "dimensions: positive, neutral, frustrated, negative, angry."
1077
+ )
1078
+ sentiment_input = gr.Textbox(
1079
+ label="Customer Message",
1080
+ placeholder=(
1081
+ "e.g., This is the third time my order has been "
1082
+ "delayed. I'm really frustrated with the service."
1083
+ ),
1084
+ lines=5,
1085
+ )
1086
+ sentiment_btn = gr.Button("Analyze Sentiment", variant="primary")
1087
+ with gr.Row():
1088
+ sentiment_gauge = gr.Plot(label="Sentiment Gauge")
1089
+ sentiment_breakdown = gr.Textbox(
1090
+ label="Detailed Breakdown",
1091
+ lines=12,
1092
+ interactive=False,
1093
+ )
1094
+
1095
+ sentiment_btn.click(
1096
+ fn=run_sentiment_analysis,
1097
+ inputs=[sentiment_input],
1098
+ outputs=[sentiment_gauge, sentiment_breakdown],
1099
+ )
1100
+
1101
+ gr.Examples(
1102
+ examples=[
1103
+ [
1104
+ "Your team was incredibly helpful. The issue was "
1105
+ "resolved within minutes. Thank you so much!"
1106
+ ],
1107
+ [
1108
+ "I've been waiting 3 days for a response. This "
1109
+ "is the second time I've had to follow up. "
1110
+ "Seriously frustrating."
1111
+ ],
1112
+ [
1113
+ "THIS IS UNACCEPTABLE!!! I want a refund "
1114
+ "IMMEDIATELY. Your service is a complete scam "
1115
+ "and I will be contacting my lawyer."
1116
+ ],
1117
+ [
1118
+ "I have a question about the export feature. "
1119
+ "Can I schedule automated CSV exports?"
1120
+ ],
1121
+ ],
1122
+ inputs=[sentiment_input],
1123
+ label="Example Messages",
1124
+ )
1125
+
1126
+ # ----------------------------------------------------------
1127
+ # Tab 3: Customer Health Score
1128
+ # ----------------------------------------------------------
1129
+ with gr.Tab("Customer Health Score"):
1130
+ gr.Markdown(
1131
+ "Calculate a customer health score (0-100) with churn "
1132
+ "risk classification. The score is a weighted composite "
1133
+ "of four engagement signals."
1134
+ )
1135
+ with gr.Row():
1136
+ with gr.Column():
1137
+ days_input = gr.Slider(
1138
+ label="Days Since Last Login",
1139
+ minimum=0,
1140
+ maximum=180,
1141
+ value=5,
1142
+ step=1,
1143
+ )
1144
+ tickets_input = gr.Slider(
1145
+ label="Support Tickets (Last 30 Days)",
1146
+ minimum=0,
1147
+ maximum=25,
1148
+ value=1,
1149
+ step=1,
1150
+ )
1151
+ with gr.Column():
1152
+ nps_input = gr.Slider(
1153
+ label="NPS Score (0-10)",
1154
+ minimum=0,
1155
+ maximum=10,
1156
+ value=8,
1157
+ step=1,
1158
+ )
1159
+ usage_input = gr.Slider(
1160
+ label="Usage Trend (% change, last 30 days)",
1161
+ minimum=-100,
1162
+ maximum=100,
1163
+ value=10,
1164
+ step=5,
1165
+ )
1166
+ health_btn = gr.Button("Calculate Health Score", variant="primary")
1167
+ health_gauge = gr.Plot(label="Health Score")
1168
+ health_breakdown = gr.Textbox(
1169
+ label="Score Breakdown and Formula",
1170
+ lines=20,
1171
+ interactive=False,
1172
+ )
1173
+
1174
+ health_btn.click(
1175
+ fn=run_health_score,
1176
+ inputs=[days_input, tickets_input, nps_input, usage_input],
1177
+ outputs=[health_gauge, health_breakdown],
1178
+ )
1179
+
1180
+ # ----------------------------------------------------------
1181
+ # Tab 4: Ticket Categorizer
1182
+ # ----------------------------------------------------------
1183
+ with gr.Tab("Ticket Categorizer"):
1184
+ gr.Markdown(
1185
+ "Paste a support ticket to automatically categorize it. "
1186
+ "Uses keyword matching across five categories: Billing, "
1187
+ "Technical Support, Account Management, Feature Request, "
1188
+ "Bug Report."
1189
+ )
1190
+ ticket_input = gr.Textbox(
1191
+ label="Support Ticket",
1192
+ placeholder=(
1193
+ "e.g., Our API integration is returning 500 errors "
1194
+ "intermittently. The webhook endpoint times out "
1195
+ "after 30 seconds."
1196
+ ),
1197
+ lines=5,
1198
+ )
1199
+ ticket_btn = gr.Button("Categorize Ticket", variant="primary")
1200
+ with gr.Row():
1201
+ ticket_chart = gr.Plot(label="Category Confidence")
1202
+ ticket_reasoning = gr.Textbox(
1203
+ label="Classification Details",
1204
+ lines=16,
1205
+ interactive=False,
1206
+ )
1207
+
1208
+ ticket_btn.click(
1209
+ fn=run_ticket_categorization,
1210
+ inputs=[ticket_input],
1211
+ outputs=[ticket_chart, ticket_reasoning],
1212
+ )
1213
+
1214
+ gr.Examples(
1215
+ examples=[
1216
+ [
1217
+ "I was charged twice for my subscription this "
1218
+ "month. My invoice shows two payments of $49.99. "
1219
+ "I need a refund for the duplicate charge."
1220
+ ],
1221
+ [
1222
+ "The dashboard crashes every time I try to "
1223
+ "apply a date filter. Steps to reproduce: "
1224
+ "1) Open Analytics, 2) Click date picker, "
1225
+ "3) Select custom range. Browser: Chrome 122."
1226
+ ],
1227
+ [
1228
+ "It would be really helpful if Herald supported "
1229
+ "Slack integration for ticket notifications. "
1230
+ "Could you add this to the roadmap?"
1231
+ ],
1232
+ [
1233
+ "I can't log in to my account. I've tried "
1234
+ "resetting my password but the reset email "
1235
+ "never arrives. My username is jsmith@corp.com."
1236
+ ],
1237
+ ],
1238
+ inputs=[ticket_input],
1239
+ label="Example Tickets",
1240
+ )
1241
+
1242
+ gr.HTML(
1243
+ '<div style="text-align:center; padding:1rem; color:#6b7280; '
1244
+ 'font-size:0.85rem;">'
1245
+ "Herald -- Customer Operations Platform | "
1246
+ '<a href="https://github.com/dbhavery/herald" '
1247
+ 'style="color:#3b82f6;" target="_blank">GitHub</a>'
1248
+ "</div>"
1249
+ )
1250
+
1251
+ return app
1252
+
1253
+
1254
+ # ---------------------------------------------------------------------------
1255
+ # Entry point
1256
+ # ---------------------------------------------------------------------------
1257
+
1258
+ if __name__ == "__main__":
1259
+ application = build_app()
1260
+ launch_kwargs: dict[str, object] = {}
1261
+ if _GRADIO_6_PLUS:
1262
+ launch_kwargs["theme"] = HERALD_THEME
1263
+ launch_kwargs["css"] = CUSTOM_CSS
1264
+ application.launch(**launch_kwargs) # type: ignore[arg-type]
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ gradio==5.29.0
2
+ numpy==2.2.6
3
+ matplotlib==3.10.3