Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- README.md +78 -10
- __pycache__/app.cpython-313.pyc +0 -0
- app.py +1264 -0
- requirements.txt +3 -0
README.md
CHANGED
|
@@ -1,10 +1,78 @@
|
|
| 1 |
-
---
|
| 2 |
-
title: Herald Customer
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk:
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|