Spaces:
Sleeping
Sleeping
Upload 4 files
Browse files- app.py +722 -333
- nlu.py +332 -469
- orchestrator.py +1 -1
- test_regressions.py +124 -0
app.py
CHANGED
|
@@ -1,340 +1,729 @@
|
|
| 1 |
"""
|
| 2 |
-
|
| 3 |
-
==========================================================
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
- Returns per-task confidence so destructive intents can be gated
|
| 10 |
-
- Distinguishes "ask about X" from "do X" (branch location β block card)
|
| 11 |
-
|
| 12 |
-
Backend chain (first available wins):
|
| 13 |
-
1. LLM_API β HF Serverless Inference (set HF_TOKEN) β best quality
|
| 14 |
-
2. LLM_LOCAL β Qwen2.5-1.5B-Instruct loaded in-process β good, slower
|
| 15 |
-
3. RULES β improved keyword rules β degraded but never crashes
|
| 16 |
-
|
| 17 |
-
All backends return the same schema:
|
| 18 |
-
|
| 19 |
-
{
|
| 20 |
-
"tasks": [
|
| 21 |
-
{
|
| 22 |
-
"intent": "send_money",
|
| 23 |
-
"confidence": 0.93,
|
| 24 |
-
"slots": {"recipient": "abu", "amount": "350000"},
|
| 25 |
-
"utterance_span": "send 350000 to abu"
|
| 26 |
-
},
|
| 27 |
-
...
|
| 28 |
-
],
|
| 29 |
-
"backend": "llm_api"
|
| 30 |
-
}
|
| 31 |
"""
|
| 32 |
|
| 33 |
-
import
|
| 34 |
-
import
|
|
|
|
| 35 |
import json
|
| 36 |
-
import
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
"
|
| 51 |
-
|
| 52 |
-
"
|
| 53 |
-
|
| 54 |
-
"
|
| 55 |
-
|
| 56 |
-
"
|
| 57 |
-
"
|
| 58 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
}
|
| 60 |
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
)
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
PlotWeaver Hausa Voice AI Agent β HuggingFace Spaces Demo
|
| 3 |
+
==========================================================
|
| 4 |
+
Full pipeline: Whisper ASR β NLLB translation β Dialogue Manager β MMS-TTS
|
| 5 |
+
|
| 6 |
+
Run locally:
|
| 7 |
+
pip install -r requirements.txt
|
| 8 |
+
python app.py
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
"""
|
| 10 |
|
| 11 |
+
import gradio as gr
|
| 12 |
+
import numpy as np
|
| 13 |
+
import uuid
|
| 14 |
import json
|
| 15 |
+
from datetime import datetime
|
| 16 |
+
|
| 17 |
+
from pipeline import HausaVoiceAIPipeline
|
| 18 |
+
from nlu import NLU
|
| 19 |
+
from orchestrator import Orchestrator
|
| 20 |
+
from integrations.crm import CRMClient
|
| 21 |
+
|
| 22 |
+
# ββ Singletons (lazy-loaded inside pipeline) βββββββββββββββββββββββββββββββββ
|
| 23 |
+
ai_pipeline = HausaVoiceAIPipeline()
|
| 24 |
+
# NLU backend chain: HF Inference API (if HF_TOKEN set) β local LLM β rules
|
| 25 |
+
dm = Orchestrator(crm=CRMClient(), nlu=NLU())
|
| 26 |
+
|
| 27 |
+
# ββ Demo phrases (for visitors who don't speak Hausa) ββββββββββββββββββββββββ
|
| 28 |
+
DEMO_PROMPTS = [
|
| 29 |
+
("Compound: balance + transfer",
|
| 30 |
+
"Duba asusuna sannan ka aika naira dubu talatin da biyar zuwa Amina"),
|
| 31 |
+
("Entity prefill: send to Abu",
|
| 32 |
+
"Ina son aika kuΙi zuwa Abu yanzu"),
|
| 33 |
+
("Branch + card (not block!)",
|
| 34 |
+
"Ina ne reshenku mafi kusa domin in karΙi katin ATM"),
|
| 35 |
+
("Report a problem", "Ina da matsala"),
|
| 36 |
+
("Talk to human agent", "Ina son magana da mutum"),
|
| 37 |
+
]
|
| 38 |
+
|
| 39 |
+
# ββ Custom CSS ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 40 |
+
CSS = """
|
| 41 |
+
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=Syne:wght@700;800&family=JetBrains+Mono:wght@400;500&display=swap');
|
| 42 |
+
|
| 43 |
+
:root {
|
| 44 |
+
--amber: #F59E0B;
|
| 45 |
+
--ember: #DC2626;
|
| 46 |
+
--sand: #FDE68A;
|
| 47 |
+
--dark: #0C0A09;
|
| 48 |
+
--panel: #1C1917;
|
| 49 |
+
--border: #292524;
|
| 50 |
+
--text: #E7E5E4;
|
| 51 |
+
--muted: #78716C;
|
| 52 |
+
--success: #22C55E;
|
| 53 |
+
--info: #38BDF8;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
body, .gradio-container {
|
| 57 |
+
background: var(--dark) !important;
|
| 58 |
+
font-family: 'Space Grotesk', sans-serif !important;
|
| 59 |
+
color: var(--text) !important;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
/* ββ Header ββ */
|
| 63 |
+
.pw-header {
|
| 64 |
+
background: linear-gradient(135deg, #1C1917 0%, #292524 50%, #1C1917 100%);
|
| 65 |
+
border-bottom: 1px solid var(--border);
|
| 66 |
+
padding: 28px 40px 24px;
|
| 67 |
+
position: relative;
|
| 68 |
+
overflow: hidden;
|
| 69 |
+
}
|
| 70 |
+
.pw-header::before {
|
| 71 |
+
content: '';
|
| 72 |
+
position: absolute;
|
| 73 |
+
top: -60px; right: -60px;
|
| 74 |
+
width: 300px; height: 300px;
|
| 75 |
+
background: radial-gradient(circle, rgba(245,158,11,0.15) 0%, transparent 70%);
|
| 76 |
+
pointer-events: none;
|
| 77 |
+
}
|
| 78 |
+
.pw-logo {
|
| 79 |
+
font-family: 'Syne', sans-serif;
|
| 80 |
+
font-weight: 800;
|
| 81 |
+
font-size: 28px;
|
| 82 |
+
color: var(--amber);
|
| 83 |
+
letter-spacing: -0.5px;
|
| 84 |
+
margin: 0;
|
| 85 |
+
}
|
| 86 |
+
.pw-logo span { color: var(--text); }
|
| 87 |
+
.pw-tagline {
|
| 88 |
+
color: var(--muted);
|
| 89 |
+
font-size: 13px;
|
| 90 |
+
margin: 4px 0 0;
|
| 91 |
+
letter-spacing: 0.5px;
|
| 92 |
+
text-transform: uppercase;
|
| 93 |
}
|
| 94 |
|
| 95 |
+
/* ββ Pill badges ββ */
|
| 96 |
+
.pill {
|
| 97 |
+
display: inline-flex;
|
| 98 |
+
align-items: center;
|
| 99 |
+
gap: 6px;
|
| 100 |
+
background: rgba(245,158,11,0.12);
|
| 101 |
+
border: 1px solid rgba(245,158,11,0.3);
|
| 102 |
+
color: var(--amber);
|
| 103 |
+
padding: 4px 12px;
|
| 104 |
+
border-radius: 100px;
|
| 105 |
+
font-size: 11px;
|
| 106 |
+
font-weight: 600;
|
| 107 |
+
letter-spacing: 0.8px;
|
| 108 |
+
text-transform: uppercase;
|
| 109 |
+
}
|
| 110 |
+
.pill-green {
|
| 111 |
+
background: rgba(34,197,94,0.12);
|
| 112 |
+
border-color: rgba(34,197,94,0.3);
|
| 113 |
+
color: var(--success);
|
| 114 |
+
}
|
| 115 |
+
.pill-blue {
|
| 116 |
+
background: rgba(56,189,248,0.12);
|
| 117 |
+
border-color: rgba(56,189,248,0.3);
|
| 118 |
+
color: var(--info);
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
/* ββ Panel cards ββ */
|
| 122 |
+
.pw-card {
|
| 123 |
+
background: var(--panel);
|
| 124 |
+
border: 1px solid var(--border);
|
| 125 |
+
border-radius: 12px;
|
| 126 |
+
padding: 20px;
|
| 127 |
+
margin-bottom: 12px;
|
| 128 |
+
}
|
| 129 |
+
.pw-card-title {
|
| 130 |
+
font-family: 'Syne', sans-serif;
|
| 131 |
+
font-size: 13px;
|
| 132 |
+
font-weight: 700;
|
| 133 |
+
color: var(--amber);
|
| 134 |
+
letter-spacing: 1px;
|
| 135 |
+
text-transform: uppercase;
|
| 136 |
+
margin-bottom: 14px;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
/* ββ Conversation bubbles ββ */
|
| 140 |
+
.conversation-box {
|
| 141 |
+
background: var(--panel);
|
| 142 |
+
border: 1px solid var(--border);
|
| 143 |
+
border-radius: 12px;
|
| 144 |
+
padding: 16px;
|
| 145 |
+
height: 360px;
|
| 146 |
+
overflow-y: auto;
|
| 147 |
+
font-family: 'Space Grotesk', sans-serif;
|
| 148 |
+
scroll-behavior: smooth;
|
| 149 |
+
}
|
| 150 |
+
.bubble {
|
| 151 |
+
max-width: 85%;
|
| 152 |
+
padding: 10px 14px;
|
| 153 |
+
border-radius: 14px;
|
| 154 |
+
margin-bottom: 10px;
|
| 155 |
+
line-height: 1.5;
|
| 156 |
+
font-size: 14px;
|
| 157 |
+
}
|
| 158 |
+
.bubble-user {
|
| 159 |
+
background: rgba(245,158,11,0.15);
|
| 160 |
+
border: 1px solid rgba(245,158,11,0.25);
|
| 161 |
+
margin-left: auto;
|
| 162 |
+
border-bottom-right-radius: 4px;
|
| 163 |
+
}
|
| 164 |
+
.bubble-agent {
|
| 165 |
+
background: rgba(255,255,255,0.05);
|
| 166 |
+
border: 1px solid var(--border);
|
| 167 |
+
border-bottom-left-radius: 4px;
|
| 168 |
+
}
|
| 169 |
+
.bubble-label {
|
| 170 |
+
font-size: 10px;
|
| 171 |
+
font-weight: 600;
|
| 172 |
+
letter-spacing: 0.8px;
|
| 173 |
+
text-transform: uppercase;
|
| 174 |
+
opacity: 0.6;
|
| 175 |
+
margin-bottom: 4px;
|
| 176 |
+
}
|
| 177 |
+
.bubble-hausa {
|
| 178 |
+
font-size: 12px;
|
| 179 |
+
color: var(--amber);
|
| 180 |
+
margin-top: 4px;
|
| 181 |
+
font-style: italic;
|
| 182 |
+
}
|
| 183 |
+
.bubble-time {
|
| 184 |
+
font-size: 10px;
|
| 185 |
+
color: var(--muted);
|
| 186 |
+
margin-top: 3px;
|
| 187 |
+
font-family: 'JetBrains Mono', monospace;
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
/* ββ Pipeline status ββ */
|
| 191 |
+
.pipeline-step {
|
| 192 |
+
display: flex;
|
| 193 |
+
align-items: center;
|
| 194 |
+
gap: 10px;
|
| 195 |
+
padding: 8px 0;
|
| 196 |
+
border-bottom: 1px solid var(--border);
|
| 197 |
+
font-size: 13px;
|
| 198 |
+
}
|
| 199 |
+
.pipeline-step:last-child { border-bottom: none; }
|
| 200 |
+
.step-icon {
|
| 201 |
+
width: 28px; height: 28px;
|
| 202 |
+
border-radius: 8px;
|
| 203 |
+
display: flex;
|
| 204 |
+
align-items: center;
|
| 205 |
+
justify-content: center;
|
| 206 |
+
font-size: 14px;
|
| 207 |
+
flex-shrink: 0;
|
| 208 |
+
}
|
| 209 |
+
.step-active { background: rgba(245,158,11,0.2); }
|
| 210 |
+
.step-done { background: rgba(34,197,94,0.2); }
|
| 211 |
+
.step-idle { background: rgba(255,255,255,0.05); }
|
| 212 |
+
|
| 213 |
+
/* ββ Gradio overrides ββ */
|
| 214 |
+
.gr-button-primary {
|
| 215 |
+
background: var(--amber) !important;
|
| 216 |
+
color: var(--dark) !important;
|
| 217 |
+
font-weight: 700 !important;
|
| 218 |
+
border: none !important;
|
| 219 |
+
font-family: 'Space Grotesk', sans-serif !important;
|
| 220 |
+
}
|
| 221 |
+
.gr-button-secondary {
|
| 222 |
+
background: var(--panel) !important;
|
| 223 |
+
color: var(--text) !important;
|
| 224 |
+
border: 1px solid var(--border) !important;
|
| 225 |
+
}
|
| 226 |
+
label, .gr-form > div > label {
|
| 227 |
+
color: var(--muted) !important;
|
| 228 |
+
font-size: 12px !important;
|
| 229 |
+
font-weight: 500 !important;
|
| 230 |
+
letter-spacing: 0.5px !important;
|
| 231 |
+
text-transform: uppercase !important;
|
| 232 |
+
}
|
| 233 |
+
.gr-box, .gr-input, textarea, .gr-text-input {
|
| 234 |
+
background: var(--panel) !important;
|
| 235 |
+
border-color: var(--border) !important;
|
| 236 |
+
color: var(--text) !important;
|
| 237 |
+
border-radius: 8px !important;
|
| 238 |
+
font-family: 'Space Grotesk', sans-serif !important;
|
| 239 |
+
}
|
| 240 |
+
.tabitem { background: var(--dark) !important; }
|
| 241 |
+
.tab-nav button {
|
| 242 |
+
background: transparent !important;
|
| 243 |
+
color: var(--muted) !important;
|
| 244 |
+
border-bottom: 2px solid transparent !important;
|
| 245 |
+
font-family: 'Space Grotesk', sans-serif !important;
|
| 246 |
+
font-weight: 600 !important;
|
| 247 |
+
}
|
| 248 |
+
.tab-nav button.selected {
|
| 249 |
+
color: var(--amber) !important;
|
| 250 |
+
border-bottom-color: var(--amber) !important;
|
| 251 |
+
}
|
| 252 |
+
footer { display: none !important; }
|
| 253 |
+
"""
|
| 254 |
+
|
| 255 |
+
# ββ Architecture diagram HTML βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 256 |
+
ARCH_HTML = """
|
| 257 |
+
<div style="font-family:'Space Grotesk',sans-serif; color:#E7E5E4; padding:16px;">
|
| 258 |
+
<div style="font-family:'Syne',sans-serif; font-size:13px; font-weight:700;
|
| 259 |
+
color:#F59E0B; letter-spacing:1px; text-transform:uppercase;
|
| 260 |
+
margin-bottom:20px;">System Architecture</div>
|
| 261 |
+
|
| 262 |
+
<div style="display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:20px;">
|
| 263 |
+
|
| 264 |
+
<!-- Input channels -->
|
| 265 |
+
<div style="background:#1C1917;border:1px solid #292524;border-radius:10px;
|
| 266 |
+
padding:12px 16px; min-width:100px; text-align:center;">
|
| 267 |
+
<div style="font-size:20px;">π€</div>
|
| 268 |
+
<div style="font-size:11px; color:#78716C; margin-top:4px;">MICROPHONE</div>
|
| 269 |
+
<div style="font-size:10px; color:#22C55E;">Gradio</div>
|
| 270 |
+
</div>
|
| 271 |
+
<div style="background:#1C1917;border:1px solid #292524;border-radius:10px;
|
| 272 |
+
padding:12px 16px; min-width:100px; text-align:center;">
|
| 273 |
+
<div style="font-size:20px;">π¬</div>
|
| 274 |
+
<div style="font-size:11px; color:#78716C; margin-top:4px;">WHATSAPP</div>
|
| 275 |
+
<div style="font-size:10px; color:#22C55E;">Cloud API</div>
|
| 276 |
+
</div>
|
| 277 |
+
<div style="background:#1C1917;border:1px solid #292524;border-radius:10px;
|
| 278 |
+
padding:12px 16px; min-width:100px; text-align:center;">
|
| 279 |
+
<div style="font-size:20px;">π</div>
|
| 280 |
+
<div style="font-size:11px; color:#78716C; margin-top:4px;">PHONE/SIP</div>
|
| 281 |
+
<div style="font-size:10px; color:#22C55E;">Twilio</div>
|
| 282 |
+
</div>
|
| 283 |
+
|
| 284 |
+
<div style="color:#F59E0B; font-size:20px; margin:0 4px;">β</div>
|
| 285 |
+
|
| 286 |
+
<!-- Pipeline -->
|
| 287 |
+
<div style="display:flex; flex-direction:column; gap:8px;">
|
| 288 |
+
<div style="background:rgba(245,158,11,0.1);border:1px solid rgba(245,158,11,0.3);
|
| 289 |
+
border-radius:8px; padding:10px 20px; text-align:center;">
|
| 290 |
+
<div style="font-size:11px; font-weight:700; color:#F59E0B;">WHISPER LARGE-V3</div>
|
| 291 |
+
<div style="font-size:10px; color:#78716C;">Hausa ASR Β· openai/whisper-large-v3</div>
|
| 292 |
+
</div>
|
| 293 |
+
<div style="background:rgba(56,189,248,0.1);border:1px solid rgba(56,189,248,0.3);
|
| 294 |
+
border-radius:8px; padding:10px 20px; text-align:center;">
|
| 295 |
+
<div style="font-size:11px; font-weight:700; color:#38BDF8;">NLLB-200 (600M)</div>
|
| 296 |
+
<div style="font-size:10px; color:#78716C;">hau_Latn β eng_Latn</div>
|
| 297 |
+
</div>
|
| 298 |
+
<div style="background:rgba(168,85,247,0.1);border:1px solid rgba(168,85,247,0.3);
|
| 299 |
+
border-radius:8px; padding:10px 20px; text-align:center;">
|
| 300 |
+
<div style="font-size:11px; font-weight:700; color:#A855F7;">DIALOGUE MANAGER</div>
|
| 301 |
+
<div style="font-size:10px; color:#78716C;">FSM + Intent Β· Multi-turn</div>
|
| 302 |
+
</div>
|
| 303 |
+
<div style="background:rgba(34,197,94,0.1);border:1px solid rgba(34,197,94,0.3);
|
| 304 |
+
border-radius:8px; padding:10px 20px; text-align:center;">
|
| 305 |
+
<div style="font-size:11px; font-weight:700; color:#22C55E;">MMS-TTS (HAU)</div>
|
| 306 |
+
<div style="font-size:10px; color:#78716C;">facebook/mms-tts-hau Β· VITS</div>
|
| 307 |
+
</div>
|
| 308 |
+
</div>
|
| 309 |
+
|
| 310 |
+
<div style="color:#F59E0B; font-size:20px; margin:0 4px;">β</div>
|
| 311 |
+
|
| 312 |
+
<!-- Integrations -->
|
| 313 |
+
<div style="display:flex; flex-direction:column; gap:8px;">
|
| 314 |
+
<div style="background:#1C1917;border:1px solid #292524;border-radius:10px;
|
| 315 |
+
padding:12px 16px; min-width:110px; text-align:center;">
|
| 316 |
+
<div style="font-size:20px;">ποΈ</div>
|
| 317 |
+
<div style="font-size:11px; color:#78716C; margin-top:4px;">CRM / ZENDESK</div>
|
| 318 |
+
<div style="font-size:10px; color:#F59E0B;">Auto-tickets</div>
|
| 319 |
+
</div>
|
| 320 |
+
<div style="background:#1C1917;border:1px solid #292524;border-radius:10px;
|
| 321 |
+
padding:12px 16px; min-width:110px; text-align:center;">
|
| 322 |
+
<div style="font-size:20px;">π€</div>
|
| 323 |
+
<div style="font-size:11px; color:#78716C; margin-top:4px;">HUMAN AGENT</div>
|
| 324 |
+
<div style="font-size:10px; color:#DC2626;">Fallback</div>
|
| 325 |
+
</div>
|
| 326 |
+
</div>
|
| 327 |
+
</div>
|
| 328 |
+
|
| 329 |
+
<div style="display:flex; gap:20px; flex-wrap:wrap; margin-top:16px; padding-top:16px;
|
| 330 |
+
border-top:1px solid #292524;">
|
| 331 |
+
<div><span style="color:#F59E0B; font-weight:700;">Latency target:</span>
|
| 332 |
+
<span style="color:#78716C;"> ASR<2s Β· MT<0.5s Β· TTS<1s Β· Total<4s</span></div>
|
| 333 |
+
<div><span style="color:#F59E0B; font-weight:700;">Languages:</span>
|
| 334 |
+
<span style="color:#78716C;"> Hausa (primary) Β· English pivot Β· French (roadmap)</span></div>
|
| 335 |
+
<div><span style="color:#F59E0B; font-weight:700;">Deployment:</span>
|
| 336 |
+
<span style="color:#78716C;"> HF Spaces (POC) β Docker / K8s (prod)</span></div>
|
| 337 |
+
</div>
|
| 338 |
+
</div>
|
| 339 |
+
"""
|
| 340 |
+
|
| 341 |
+
# ββ State helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 342 |
+
|
| 343 |
+
def _init_state():
|
| 344 |
+
return {
|
| 345 |
+
"conv": dm.new_session(),
|
| 346 |
+
"history": [], # [{role, hausa, english, time}]
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
def _render_conversation(history: list) -> str:
|
| 350 |
+
if not history:
|
| 351 |
+
return (
|
| 352 |
+
'<div style="color:#78716C; text-align:center; margin-top:60px; '
|
| 353 |
+
'font-size:13px;">π€ Speak or type in Hausa to begin β¦</div>'
|
| 354 |
+
)
|
| 355 |
+
html = ""
|
| 356 |
+
for msg in history:
|
| 357 |
+
role = msg["role"]
|
| 358 |
+
is_user = role == "user"
|
| 359 |
+
label = "YOU" if is_user else "AGENT"
|
| 360 |
+
cls = "bubble-user" if is_user else "bubble-agent"
|
| 361 |
+
en = msg.get("english", "")
|
| 362 |
+
ha = msg.get("hausa", "")
|
| 363 |
+
t = msg.get("time", "")
|
| 364 |
+
primary = ha if ha else en
|
| 365 |
+
secondary = en if ha else ""
|
| 366 |
+
|
| 367 |
+
html += f"""
|
| 368 |
+
<div style="display:flex; flex-direction:column;
|
| 369 |
+
align-items:{'flex-end' if is_user else 'flex-start'}; margin-bottom:12px;">
|
| 370 |
+
<div class="bubble {cls}">
|
| 371 |
+
<div class="bubble-label">{label}</div>
|
| 372 |
+
<div>{primary}</div>
|
| 373 |
+
{'<div class="bubble-hausa">EN: ' + secondary + '</div>' if secondary else ''}
|
| 374 |
+
<div class="bubble-time">{t}</div>
|
| 375 |
+
</div>
|
| 376 |
+
</div>"""
|
| 377 |
+
return html
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
# ββ Core processing function ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 381 |
+
|
| 382 |
+
def process_voice(audio, text_input, state):
|
| 383 |
+
"""
|
| 384 |
+
Entry: either audio or text (fallback for demo without mic).
|
| 385 |
+
Returns: (audio_out, conversation_html, status_text, updated_state)
|
| 386 |
+
"""
|
| 387 |
+
if state is None:
|
| 388 |
+
state = _init_state()
|
| 389 |
+
|
| 390 |
+
hausa_text = ""
|
| 391 |
+
asr_status = "β"
|
| 392 |
+
|
| 393 |
+
# 1. ASR
|
| 394 |
+
if audio is not None:
|
| 395 |
+
sample_rate, audio_array = audio
|
| 396 |
+
audio_array = audio_array.astype(np.float32) / 32768.0
|
| 397 |
+
if audio_array.ndim > 1:
|
| 398 |
+
audio_array = audio_array.mean(axis=1)
|
| 399 |
+
hausa_text = ai_pipeline.audio_to_hausa_text(audio_array, sample_rate)
|
| 400 |
+
asr_status = f"β {hausa_text[:60]}β¦" if len(hausa_text) > 60 else f"β {hausa_text}"
|
| 401 |
+
elif text_input and text_input.strip():
|
| 402 |
+
hausa_text = text_input.strip()
|
| 403 |
+
asr_status = "(text input)"
|
| 404 |
+
else:
|
| 405 |
+
return None, _render_conversation(state["history"]), "β No input provided", state
|
| 406 |
+
|
| 407 |
+
# 2. Hausa β English
|
| 408 |
+
english_text = ai_pipeline.hausa_to_english(hausa_text)
|
| 409 |
+
|
| 410 |
+
# 3. Dialogue
|
| 411 |
+
english_response, conv_state, escalate = dm.respond(
|
| 412 |
+
english_text, hausa_text, state["conv"]
|
| 413 |
+
)
|
| 414 |
+
state["conv"] = conv_state
|
| 415 |
+
|
| 416 |
+
# 4. English β Hausa
|
| 417 |
+
hausa_response = ai_pipeline.english_to_hausa(english_response)
|
| 418 |
+
|
| 419 |
+
# 5. TTS
|
| 420 |
+
sr, audio_out = ai_pipeline.hausa_text_to_audio(hausa_response)
|
| 421 |
+
|
| 422 |
+
# 6. Update history
|
| 423 |
+
now = datetime.now().strftime("%H:%M:%S")
|
| 424 |
+
state["history"].append({
|
| 425 |
+
"role": "user", "hausa": hausa_text,
|
| 426 |
+
"english": english_text, "time": now
|
| 427 |
+
})
|
| 428 |
+
state["history"].append({
|
| 429 |
+
"role": "agent", "hausa": hausa_response,
|
| 430 |
+
"english": english_response, "time": now
|
| 431 |
+
})
|
| 432 |
+
|
| 433 |
+
open_tasks = [t for t in conv_state.tasks
|
| 434 |
+
if t.status in ("pending", "collecting", "confirming")]
|
| 435 |
+
status = (f"Turn {conv_state.turn} Β· "
|
| 436 |
+
f"queue: {len(open_tasks)} open / "
|
| 437 |
+
f"{sum(1 for t in conv_state.tasks if t.status == 'done')} done")
|
| 438 |
+
if conv_state.active_task:
|
| 439 |
+
status += f" Β· active: {conv_state.active_task.intent}"
|
| 440 |
+
if escalate:
|
| 441 |
+
status += " β ESCALATED TO HUMAN"
|
| 442 |
+
|
| 443 |
+
return (sr, audio_out), _render_conversation(state["history"]), status, state
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
def reset_session(state):
|
| 447 |
+
state = _init_state()
|
| 448 |
+
return None, _render_conversation([]), "New session started.", state
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
def use_demo_prompt(prompt_ha, state):
|
| 452 |
+
"""Inject a demo Hausa phrase as text input."""
|
| 453 |
+
return prompt_ha, state
|
| 454 |
+
|
| 455 |
+
|
| 456 |
+
# ββ Build the Gradio app ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 457 |
+
|
| 458 |
+
with gr.Blocks(css=CSS, title="PlotWeaver Β· Hausa Voice AI") as demo:
|
| 459 |
+
|
| 460 |
+
# Shared state
|
| 461 |
+
app_state = gr.State(None)
|
| 462 |
+
|
| 463 |
+
# ββ Header βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 464 |
+
gr.HTML("""
|
| 465 |
+
<div class="pw-header">
|
| 466 |
+
<p class="pw-logo">Plot<span>Weaver</span></p>
|
| 467 |
+
<p class="pw-tagline">Hausa Voice AI Agent Β· Investor Demo Β· v0.1-poc</p>
|
| 468 |
+
<div style="display:flex; gap:8px; margin-top:14px; flex-wrap:wrap;">
|
| 469 |
+
<span class="pill">π€ Whisper v3</span>
|
| 470 |
+
<span class="pill">π NLLB-200</span>
|
| 471 |
+
<span class="pill">π MMS-TTS</span>
|
| 472 |
+
<span class="pill-green">β‘ Real-time</span>
|
| 473 |
+
<span class="pill-blue">π’ Enterprise-ready</span>
|
| 474 |
+
</div>
|
| 475 |
+
</div>
|
| 476 |
+
""")
|
| 477 |
+
|
| 478 |
+
with gr.Tabs():
|
| 479 |
+
|
| 480 |
+
# ββ Tab 1 : Live Demo βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 481 |
+
with gr.TabItem("ποΈ Live Demo"):
|
| 482 |
+
with gr.Row():
|
| 483 |
+
# Left column
|
| 484 |
+
with gr.Column(scale=2):
|
| 485 |
+
gr.HTML('<div class="pw-card-title" '
|
| 486 |
+
'style="margin-bottom:10px;">Conversation</div>')
|
| 487 |
+
conversation_display = gr.HTML(
|
| 488 |
+
_render_conversation([]),
|
| 489 |
+
elem_classes=["conversation-box"]
|
| 490 |
+
)
|
| 491 |
+
status_box = gr.Textbox(
|
| 492 |
+
label="Pipeline Status",
|
| 493 |
+
value="Ready. Speak or type in Hausa.",
|
| 494 |
+
interactive=False,
|
| 495 |
+
lines=1,
|
| 496 |
+
)
|
| 497 |
+
|
| 498 |
+
# Right column
|
| 499 |
+
with gr.Column(scale=1):
|
| 500 |
+
gr.HTML('<div class="pw-card-title" '
|
| 501 |
+
'style="margin-bottom:10px;">Input</div>')
|
| 502 |
+
audio_in = gr.Audio(
|
| 503 |
+
sources=["microphone"],
|
| 504 |
+
type="numpy",
|
| 505 |
+
label="Voice Input (Hausa)",
|
| 506 |
+
streaming=False,
|
| 507 |
+
)
|
| 508 |
+
text_in = gr.Textbox(
|
| 509 |
+
label="Text fallback (Hausa)",
|
| 510 |
+
placeholder="Sannu, ina son sanin asusun kuΙinβ¦",
|
| 511 |
+
lines=2,
|
| 512 |
+
)
|
| 513 |
+
|
| 514 |
+
with gr.Row():
|
| 515 |
+
submit_btn = gr.Button("βΆ Send", variant="primary")
|
| 516 |
+
reset_btn = gr.Button("βΊ Reset", variant="secondary")
|
| 517 |
+
|
| 518 |
+
audio_out = gr.Audio(
|
| 519 |
+
label="Agent Response (Hausa audio)",
|
| 520 |
+
autoplay=True,
|
| 521 |
+
)
|
| 522 |
+
|
| 523 |
+
# Demo quick prompts
|
| 524 |
+
gr.HTML('<div class="pw-card-title" '
|
| 525 |
+
'style="margin-top:16px; margin-bottom:10px;">'
|
| 526 |
+
'Quick Demo Prompts</div>')
|
| 527 |
+
for label_en, phrase_ha in DEMO_PROMPTS:
|
| 528 |
+
btn = gr.Button(f"{label_en} β {phrase_ha}",
|
| 529 |
+
variant="secondary", size="sm")
|
| 530 |
+
btn.click(
|
| 531 |
+
fn=lambda p=phrase_ha, s=None: (p, s),
|
| 532 |
+
inputs=[app_state],
|
| 533 |
+
outputs=[text_in, app_state],
|
| 534 |
+
)
|
| 535 |
+
|
| 536 |
+
# ββ Events βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 537 |
+
submit_btn.click(
|
| 538 |
+
fn=process_voice,
|
| 539 |
+
inputs=[audio_in, text_in, app_state],
|
| 540 |
+
outputs=[audio_out, conversation_display, status_box, app_state],
|
| 541 |
)
|
| 542 |
+
reset_btn.click(
|
| 543 |
+
fn=reset_session,
|
| 544 |
+
inputs=[app_state],
|
| 545 |
+
outputs=[audio_out, conversation_display, status_box, app_state],
|
| 546 |
+
)
|
| 547 |
+
|
| 548 |
+
# ββ Tab 2 : Architecture ββββββββββββββββββββββββββββββββββββββββββββ
|
| 549 |
+
with gr.TabItem("ποΈ Architecture"):
|
| 550 |
+
gr.HTML(ARCH_HTML)
|
| 551 |
+
|
| 552 |
+
# ββ Tab 3 : Integrations ββββββββββββββββββββββββββββββββββββββββββββ
|
| 553 |
+
with gr.TabItem("π Integrations"):
|
| 554 |
+
gr.HTML("""
|
| 555 |
+
<div style="font-family:'Space Grotesk',sans-serif; color:#E7E5E4; padding:16px;">
|
| 556 |
+
<div style="font-family:'Syne',sans-serif; font-size:13px; font-weight:700;
|
| 557 |
+
color:#F59E0B; letter-spacing:1px; text-transform:uppercase;
|
| 558 |
+
margin-bottom:20px;">Enterprise Integration Matrix</div>
|
| 559 |
+
|
| 560 |
+
<div style="display:grid; grid-template-columns:repeat(auto-fill,minmax(220px,1fr)); gap:16px;">
|
| 561 |
+
|
| 562 |
+
<div style="background:#1C1917;border:1px solid #292524;border-radius:12px;padding:18px;">
|
| 563 |
+
<div style="font-size:24px; margin-bottom:8px;">π¬</div>
|
| 564 |
+
<div style="font-weight:700; margin-bottom:4px;">WhatsApp Business</div>
|
| 565 |
+
<div style="font-size:12px; color:#78716C; margin-bottom:10px;">
|
| 566 |
+
Meta Cloud API v18+. Inbound voice notes β ASR pipeline.
|
| 567 |
+
Quick-reply buttons. Media download.
|
| 568 |
+
</div>
|
| 569 |
+
<span style="font-size:10px; background:rgba(34,197,94,0.12);
|
| 570 |
+
border:1px solid rgba(34,197,94,0.3); color:#22C55E;
|
| 571 |
+
padding:2px 8px; border-radius:100px;">READY</span>
|
| 572 |
+
</div>
|
| 573 |
+
|
| 574 |
+
<div style="background:#1C1917;border:1px solid #292524;border-radius:12px;padding:18px;">
|
| 575 |
+
<div style="font-size:24px; margin-bottom:8px;">π</div>
|
| 576 |
+
<div style="font-weight:700; margin-bottom:4px;">Twilio / SIP</div>
|
| 577 |
+
<div style="font-size:12px; color:#78716C; margin-bottom:10px;">
|
| 578 |
+
Twilio Media Streams WebSocket. Inbound + outbound IVR.
|
| 579 |
+
Warm transfer to human agent. Bandwidth BXML also supported.
|
| 580 |
+
</div>
|
| 581 |
+
<span style="font-size:10px; background:rgba(34,197,94,0.12);
|
| 582 |
+
border:1px solid rgba(34,197,94,0.3); color:#22C55E;
|
| 583 |
+
padding:2px 8px; border-radius:100px;">READY</span>
|
| 584 |
+
</div>
|
| 585 |
+
|
| 586 |
+
<div style="background:#1C1917;border:1px solid #292524;border-radius:12px;padding:18px;">
|
| 587 |
+
<div style="font-size:24px; margin-bottom:8px;">ποΈ</div>
|
| 588 |
+
<div style="font-weight:700; margin-bottom:4px;">CRM / Ticketing</div>
|
| 589 |
+
<div style="font-size:12px; color:#78716C; margin-bottom:10px;">
|
| 590 |
+
Zendesk native adapter. Generic REST adapter for Freshdesk,
|
| 591 |
+
HubSpot, Salesforce. Auto-ticket on issue report.
|
| 592 |
+
</div>
|
| 593 |
+
<span style="font-size:10px; background:rgba(34,197,94,0.12);
|
| 594 |
+
border:1px solid rgba(34,197,94,0.3); color:#22C55E;
|
| 595 |
+
padding:2px 8px; border-radius:100px;">READY</span>
|
| 596 |
+
</div>
|
| 597 |
+
|
| 598 |
+
<div style="background:#1C1917;border:1px solid #292524;border-radius:12px;padding:18px;">
|
| 599 |
+
<div style="font-size:24px; margin-bottom:8px;">π€</div>
|
| 600 |
+
<div style="font-weight:700; margin-bottom:4px;">Human Fallback</div>
|
| 601 |
+
<div style="font-size:12px; color:#78716C; margin-bottom:10px;">
|
| 602 |
+
Threshold-based escalation: low confidence, explicit request,
|
| 603 |
+
or max-turns. SIP REFER transfer + CRM context handoff.
|
| 604 |
+
</div>
|
| 605 |
+
<span style="font-size:10px; background:rgba(34,197,94,0.12);
|
| 606 |
+
border:1px solid rgba(34,197,94,0.3); color:#22C55E;
|
| 607 |
+
padding:2px 8px; border-radius:100px;">READY</span>
|
| 608 |
+
</div>
|
| 609 |
+
|
| 610 |
+
<div style="background:#1C1917;border:1px solid #292524;border-radius:12px;padding:18px;">
|
| 611 |
+
<div style="font-size:24px; margin-bottom:8px;">π</div>
|
| 612 |
+
<div style="font-weight:700; margin-bottom:4px;">More Languages</div>
|
| 613 |
+
<div style="font-size:12px; color:#78716C; margin-bottom:10px;">
|
| 614 |
+
YorΓΉbΓ‘, Igbo, Fulfulde, Kanuri roadmap.
|
| 615 |
+
NLLB covers 200 languages. MMS covers 1,000+ TTS languages.
|
| 616 |
+
</div>
|
| 617 |
+
<span style="font-size:10px; background:rgba(245,158,11,0.12);
|
| 618 |
+
border:1px solid rgba(245,158,11,0.3); color:#F59E0B;
|
| 619 |
+
padding:2px 8px; border-radius:100px;">ROADMAP</span>
|
| 620 |
+
</div>
|
| 621 |
+
|
| 622 |
+
<div style="background:#1C1917;border:1px solid #292524;border-radius:12px;padding:18px;">
|
| 623 |
+
<div style="font-size:24px; margin-bottom:8px;">β‘</div>
|
| 624 |
+
<div style="font-weight:700; margin-bottom:4px;">Fine-tuned Models</div>
|
| 625 |
+
<div style="font-size:12px; color:#78716C; margin-bottom:10px;">
|
| 626 |
+
Custom Whisper fine-tune for Hausa dialectal variance.
|
| 627 |
+
OuteTTS / MMS fine-tune on domain vocabulary. NLLB domain adaptation.
|
| 628 |
+
</div>
|
| 629 |
+
<span style="font-size:10px; background:rgba(245,158,11,0.12);
|
| 630 |
+
border:1px solid rgba(245,158,11,0.3); color:#F59E0B;
|
| 631 |
+
padding:2px 8px; border-radius:100px;">IN PROGRESS</span>
|
| 632 |
+
</div>
|
| 633 |
+
|
| 634 |
+
</div>
|
| 635 |
+
|
| 636 |
+
<div style="margin-top:24px; padding:16px; background:rgba(245,158,11,0.05);
|
| 637 |
+
border:1px solid rgba(245,158,11,0.2); border-radius:10px;">
|
| 638 |
+
<div style="font-size:12px; font-weight:700; color:#F59E0B;
|
| 639 |
+
margin-bottom:8px; letter-spacing:0.5px;">β CONFIGURATION</div>
|
| 640 |
+
<div style="font-family:'JetBrains Mono',monospace; font-size:11px;
|
| 641 |
+
color:#78716C; line-height:2;">
|
| 642 |
+
WHATSAPP_TOKEN=<meta-token> WHATSAPP_PHONE_ID=<phone-id><br>
|
| 643 |
+
TWILIO_ACCOUNT_SID=ACxxxx TWILIO_AUTH_TOKEN=xxxx<br>
|
| 644 |
+
CRM_PROVIDER=zendesk ZENDESK_SUBDOMAIN=yourco ZENDESK_API_TOKEN=xxxx<br>
|
| 645 |
+
SIP_PROVIDER=twilio (or bandwidth)
|
| 646 |
+
</div>
|
| 647 |
+
</div>
|
| 648 |
+
</div>
|
| 649 |
+
""")
|
| 650 |
+
|
| 651 |
+
# ββ Tab 4 : Business Case ββββββββββββββββββββββββββββββββββββββββββββ
|
| 652 |
+
with gr.TabItem("π Market"):
|
| 653 |
+
gr.HTML("""
|
| 654 |
+
<div style="font-family:'Space Grotesk',sans-serif; color:#E7E5E4; padding:16px;">
|
| 655 |
+
<div style="font-family:'Syne',sans-serif; font-size:13px; font-weight:700;
|
| 656 |
+
color:#F59E0B; letter-spacing:1px; text-transform:uppercase;
|
| 657 |
+
margin-bottom:20px;">Why Hausa Voice AI Β· Now</div>
|
| 658 |
+
|
| 659 |
+
<div style="display:grid; grid-template-columns:repeat(auto-fill,minmax(180px,1fr));
|
| 660 |
+
gap:16px; margin-bottom:24px;">
|
| 661 |
+
<div style="background:#1C1917;border:1px solid #292524;border-radius:12px;
|
| 662 |
+
padding:18px; text-align:center;">
|
| 663 |
+
<div style="font-family:'Syne',sans-serif; font-size:32px; font-weight:800;
|
| 664 |
+
color:#F59E0B;">100M+</div>
|
| 665 |
+
<div style="font-size:12px; color:#78716C; margin-top:4px;">Hausa speakers</div>
|
| 666 |
+
<div style="font-size:11px; color:#22C55E;">#1 language in West Africa</div>
|
| 667 |
+
</div>
|
| 668 |
+
<div style="background:#1C1917;border:1px solid #292524;border-radius:12px;
|
| 669 |
+
padding:18px; text-align:center;">
|
| 670 |
+
<div style="font-family:'Syne',sans-serif; font-size:32px; font-weight:800;
|
| 671 |
+
color:#F59E0B;">63%</div>
|
| 672 |
+
<div style="font-size:12px; color:#78716C; margin-top:4px;">Low literacy rate</div>
|
| 673 |
+
<div style="font-size:11px; color:#38BDF8;">Voice is the UX</div>
|
| 674 |
+
</div>
|
| 675 |
+
<div style="background:#1C1917;border:1px solid #292524;border-radius:12px;
|
| 676 |
+
padding:18px; text-align:center;">
|
| 677 |
+
<div style="font-family:'Syne',sans-serif; font-size:32px; font-weight:800;
|
| 678 |
+
color:#F59E0B;">$4.2B</div>
|
| 679 |
+
<div style="font-size:12px; color:#78716C; margin-top:4px;">Africa call-centre spend</div>
|
| 680 |
+
<div style="font-size:11px; color:#F59E0B;">2027 projection</div>
|
| 681 |
+
</div>
|
| 682 |
+
<div style="background:#1C1917;border:1px solid #292524;border-radius:12px;
|
| 683 |
+
padding:18px; text-align:center;">
|
| 684 |
+
<div style="font-family:'Syne',sans-serif; font-size:32px; font-weight:800;
|
| 685 |
+
color:#F59E0B;">0</div>
|
| 686 |
+
<div style="font-size:12px; color:#78716C; margin-top:4px;">Production Hausa VoiceBots</div>
|
| 687 |
+
<div style="font-size:11px; color:#DC2626;">Whitespace opportunity</div>
|
| 688 |
+
</div>
|
| 689 |
+
</div>
|
| 690 |
+
|
| 691 |
+
<div style="display:grid; grid-template-columns:1fr 1fr; gap:16px;">
|
| 692 |
+
<div style="background:#1C1917;border:1px solid #292524;border-radius:12px;padding:18px;">
|
| 693 |
+
<div style="font-weight:700; color:#F59E0B; margin-bottom:12px;">π― Target Verticals</div>
|
| 694 |
+
<div style="font-size:13px; line-height:2; color:#A8A29E;">
|
| 695 |
+
π± Telecoms (MTN, Airtel Nigeria, Glo)<br>
|
| 696 |
+
π¦ Fintech / Mobile money (Kuda, PalmPay)<br>
|
| 697 |
+
π₯ Health (NHIS, telemedicine IVR)<br>
|
| 698 |
+
π Government services (NIMC, NIN)<br>
|
| 699 |
+
π E-commerce (Jumia, Konga)
|
| 700 |
+
</div>
|
| 701 |
+
</div>
|
| 702 |
+
<div style="background:#1C1917;border:1px solid #292524;border-radius:12px;padding:18px;">
|
| 703 |
+
<div style="font-weight:700; color:#F59E0B; margin-bottom:12px;">π Competitive Moat</div>
|
| 704 |
+
<div style="font-size:13px; line-height:2; color:#A8A29E;">
|
| 705 |
+
β Open-source stack (no API lock-in)<br>
|
| 706 |
+
β Fine-tuned Hausa models (PlotWeaver IP)<br>
|
| 707 |
+
β On-premise deployable (data sovereignty)<br>
|
| 708 |
+
β Multi-channel from day one<br>
|
| 709 |
+
β Academic NLP + production engineering
|
| 710 |
+
</div>
|
| 711 |
+
</div>
|
| 712 |
+
</div>
|
| 713 |
+
</div>
|
| 714 |
+
""")
|
| 715 |
+
|
| 716 |
+
# ββ Footer ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 717 |
+
gr.HTML("""
|
| 718 |
+
<div style="text-align:center; padding:20px; color:#44403C; font-size:11px;
|
| 719 |
+
border-top:1px solid #1C1917; margin-top:8px;">
|
| 720 |
+
PlotWeaver Β· Hausa Voice AI Agent POC |
|
| 721 |
+
Whisper large-v3 Β· NLLB-200 Β· MMS-TTS-hau |
|
| 722 |
+
<a href="https://plotweaver.ai" style="color:#F59E0B; text-decoration:none;">
|
| 723 |
+
plotweaver.ai</a>
|
| 724 |
+
</div>
|
| 725 |
+
""")
|
| 726 |
+
|
| 727 |
+
|
| 728 |
+
if __name__ == "__main__":
|
| 729 |
+
demo.launch(share=False)
|
nlu.py
CHANGED
|
@@ -1,477 +1,340 @@
|
|
| 1 |
"""
|
| 2 |
-
NLU β
|
| 3 |
-
=========================================
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
-
|
| 7 |
-
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
"""
|
| 32 |
-
|
|
|
|
| 33 |
import re
|
|
|
|
| 34 |
import logging
|
| 35 |
from typing import Optional
|
| 36 |
|
| 37 |
-
logger = logging.getLogger(
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
"
|
| 45 |
-
"
|
| 46 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
}
|
| 48 |
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
def
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
def
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
#
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
"
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
"
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
"
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
"
|
| 231 |
-
"
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
"
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
"
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
"
|
| 261 |
-
|
| 262 |
-
"
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
"
|
| 267 |
-
|
| 268 |
-
"
|
| 269 |
-
|
| 270 |
-
"
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
"
|
| 274 |
-
|
| 275 |
-
"
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
"
|
| 280 |
-
|
| 281 |
-
"
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
"
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
"
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
global _encoder, _intent_centroids, _embed_failed
|
| 330 |
-
if _embed_failed:
|
| 331 |
-
return None
|
| 332 |
-
if _encoder is not None:
|
| 333 |
-
return _encoder
|
| 334 |
-
try:
|
| 335 |
-
import numpy as np
|
| 336 |
-
from sentence_transformers import SentenceTransformer
|
| 337 |
-
logger.info(f"Loading embedding model {EMBEDDING_MODEL_ID}β¦")
|
| 338 |
-
_encoder = SentenceTransformer(EMBEDDING_MODEL_ID, device="cpu")
|
| 339 |
-
logger.info("Computing intent centroidsβ¦")
|
| 340 |
-
_intent_centroids = {}
|
| 341 |
-
for intent, phrases in INTENT_EXAMPLES.items():
|
| 342 |
-
# normalize_embeddings=True β unit vectors β dot product = cosine sim
|
| 343 |
-
embeddings = _encoder.encode(phrases, normalize_embeddings=True)
|
| 344 |
-
centroid = embeddings.mean(axis=0)
|
| 345 |
-
# Re-normalize the centroid so cosine math stays clean
|
| 346 |
-
centroid = centroid / np.linalg.norm(centroid)
|
| 347 |
-
_intent_centroids[intent] = centroid
|
| 348 |
-
logger.info(f"Encoder ready, {len(_intent_centroids)} intents.")
|
| 349 |
-
return _encoder
|
| 350 |
-
except Exception as e:
|
| 351 |
-
logger.warning(f"Encoder load failed: {e}")
|
| 352 |
-
_embed_failed = True
|
| 353 |
-
return None
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
def _classify_with_embedding(text: str) -> Optional[tuple[str, float]]:
|
| 357 |
-
"""Cosine similarity vs all intent centroids. Returns (intent, confidence)
|
| 358 |
-
or None on failure.
|
| 359 |
-
|
| 360 |
-
Note: we deliberately score against every intent rather than constraining by
|
| 361 |
-
the dialogue's expected slot. This is what lets a caller pivot mid-flow
|
| 362 |
-
(e.g. say "transfer money" while we're asking for account digits). The
|
| 363 |
-
expected-slot constraint is enforced upstream by the structural extractors
|
| 364 |
-
in parse(), not here."""
|
| 365 |
-
encoder = _load_encoder()
|
| 366 |
-
if encoder is None or _intent_centroids is None:
|
| 367 |
-
return None
|
| 368 |
-
try:
|
| 369 |
-
import numpy as np
|
| 370 |
-
query = encoder.encode(text, normalize_embeddings=True)
|
| 371 |
-
scores = {intent: float(np.dot(query, centroid))
|
| 372 |
-
for intent, centroid in _intent_centroids.items()}
|
| 373 |
-
best_intent = max(scores, key=scores.get)
|
| 374 |
-
best_score = scores[best_intent]
|
| 375 |
-
top3 = {k: round(v, 3) for k, v in sorted(scores.items(), key=lambda x: -x[1])[:3]}
|
| 376 |
-
logger.info(f"NLU embedding: top match {best_intent}@{best_score:.3f}, top3: {top3}")
|
| 377 |
-
return best_intent, best_score
|
| 378 |
-
except Exception as e:
|
| 379 |
-
logger.warning(f"Embedding classification failed: {e}")
|
| 380 |
-
return None
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
# ---------------------------------------------------------------------------
|
| 384 |
-
# Public API
|
| 385 |
-
# ---------------------------------------------------------------------------
|
| 386 |
-
def parse(text: str, expected: Optional[str] = None,
|
| 387 |
-
use_llm: bool = True) -> tuple[str, dict, str]:
|
| 388 |
-
"""
|
| 389 |
-
NLU entry point. Returns (intent, entities, source) where source is:
|
| 390 |
-
- 'structural': digit/amount/yes-no/name/date/text/bundle matched
|
| 391 |
-
- 'keyword': keyword fast-path matched
|
| 392 |
-
- 'embedding': sentence encoder matched above threshold
|
| 393 |
-
- 'human_keyword': escape-hatch keyword caught
|
| 394 |
-
- 'unknown': nothing matched
|
| 395 |
-
`use_llm` is a misnomer kept for backward compat with the legacy module's
|
| 396 |
-
signature β here it means "use the embedding layer". Set False to test
|
| 397 |
-
rule-only behavior.
|
| 398 |
-
"""
|
| 399 |
-
entities: dict = {}
|
| 400 |
-
if not text or not text.strip():
|
| 401 |
-
return "unknown", entities, "unknown"
|
| 402 |
-
|
| 403 |
-
# Layer 0: Always-on human-agent escape
|
| 404 |
-
if _contains_human_keyword(text):
|
| 405 |
-
return "human_agent", entities, "human_keyword"
|
| 406 |
-
|
| 407 |
-
# Layer 1: Structural extractors for slot-filling states
|
| 408 |
-
if expected == "digits":
|
| 409 |
-
d = _extract_digits(text)
|
| 410 |
-
if d:
|
| 411 |
-
entities["digits"] = d
|
| 412 |
-
return "provide_digits", entities, "structural"
|
| 413 |
-
|
| 414 |
-
if expected == "amount":
|
| 415 |
-
a = _extract_amount(text)
|
| 416 |
-
if a is not None:
|
| 417 |
-
entities["amount"] = a
|
| 418 |
-
return "provide_amount", entities, "structural"
|
| 419 |
-
|
| 420 |
-
if expected == "yesno":
|
| 421 |
-
yn = _match_yesno(text)
|
| 422 |
-
if yn:
|
| 423 |
-
return yn, entities, "structural"
|
| 424 |
-
|
| 425 |
-
if expected == "name":
|
| 426 |
-
# NOTE: naive β takes the last token, so "Musa Ibrahim" β "Ibrahim".
|
| 427 |
-
# Fine for single-name demos; add a recipient-confirmation turn before
|
| 428 |
-
# using this for real money movement.
|
| 429 |
-
name = text.strip().split()[-1] if text.strip() else ""
|
| 430 |
-
if name:
|
| 431 |
-
entities["name"] = name
|
| 432 |
-
return "provide_name", entities, "structural"
|
| 433 |
-
|
| 434 |
-
if expected == "date":
|
| 435 |
-
entities["date"] = text.strip()
|
| 436 |
-
return "provide_date", entities, "structural"
|
| 437 |
-
|
| 438 |
-
if expected == "text":
|
| 439 |
-
# Free-text capture (complaint body, return reason). Layer 0 already
|
| 440 |
-
# handled an explicit human-agent request, so anything else is content.
|
| 441 |
-
entities["text"] = text.strip()
|
| 442 |
-
return "provide_text", entities, "structural"
|
| 443 |
-
|
| 444 |
-
if expected == "bundle":
|
| 445 |
-
t = text.lower()
|
| 446 |
-
for b in BUNDLE_TYPES:
|
| 447 |
-
if f" {b} " in f" {t} " or t.strip() == b:
|
| 448 |
-
entities["bundle"] = b
|
| 449 |
-
return "provide_bundle", entities, "structural"
|
| 450 |
-
# No recognized bundle word β fall through so the user can still pivot
|
| 451 |
-
# (e.g. change their mind to airtime) or get a fallback re-prompt.
|
| 452 |
-
|
| 453 |
-
# Layer 1.5: Keyword fast-path (cheap, runs in any state so users can
|
| 454 |
-
# pivot intent mid-flow).
|
| 455 |
-
kw_intent = _match_intent_keyword(text)
|
| 456 |
-
if kw_intent:
|
| 457 |
-
logger.info(f"NLU keyword: matched {text!r} β {kw_intent}")
|
| 458 |
-
return kw_intent, entities, "keyword"
|
| 459 |
-
|
| 460 |
-
# Layer 2: Embedding similarity
|
| 461 |
-
if not use_llm:
|
| 462 |
-
logger.info(f"NLU: use_llm=False, returning unknown for {text!r}")
|
| 463 |
-
return "unknown", entities, "unknown"
|
| 464 |
-
|
| 465 |
-
embed_result = _classify_with_embedding(text)
|
| 466 |
-
if embed_result is None:
|
| 467 |
-
logger.warning(f"NLU embedding unavailable, returning unknown for {text!r}")
|
| 468 |
-
return "unknown", entities, "unknown"
|
| 469 |
-
|
| 470 |
-
intent, confidence = embed_result
|
| 471 |
-
if confidence < CONFIDENCE_THRESHOLD:
|
| 472 |
-
logger.info(f"NLU embedding: {intent}@{confidence:.3f} below threshold "
|
| 473 |
-
f"{CONFIDENCE_THRESHOLD}, returning unknown")
|
| 474 |
-
return "unknown", entities, "unknown"
|
| 475 |
-
|
| 476 |
-
logger.info(f"NLU embedding accepted: {text!r} β {intent} (conf={confidence:.3f})")
|
| 477 |
-
return intent, entities, "embedding"
|
|
|
|
| 1 |
"""
|
| 2 |
+
NLU Module β Multi-Intent Decomposition + Entity Extraction
|
| 3 |
+
=============================================================
|
| 4 |
+
Fixes P0/P1 from the feedback:
|
| 5 |
+
|
| 6 |
+
- Decomposes ONE user message into a LIST of tasks (compound requests)
|
| 7 |
+
- Extracts ALL entities present in the message (slot prefill β
|
| 8 |
+
"send money to abu" never re-asks for the recipient)
|
| 9 |
+
- Returns per-task confidence so destructive intents can be gated
|
| 10 |
+
- Distinguishes "ask about X" from "do X" (branch location β block card)
|
| 11 |
+
|
| 12 |
+
Backend chain (first available wins):
|
| 13 |
+
1. LLM_API β HF Serverless Inference (set HF_TOKEN) β best quality
|
| 14 |
+
2. LLM_LOCAL β Qwen2.5-1.5B-Instruct loaded in-process β good, slower
|
| 15 |
+
3. RULES β improved keyword rules β degraded but never crashes
|
| 16 |
+
|
| 17 |
+
All backends return the same schema:
|
| 18 |
+
|
| 19 |
+
{
|
| 20 |
+
"tasks": [
|
| 21 |
+
{
|
| 22 |
+
"intent": "send_money",
|
| 23 |
+
"confidence": 0.93,
|
| 24 |
+
"slots": {"recipient": "abu", "amount": "350000"},
|
| 25 |
+
"utterance_span": "send 350000 to abu"
|
| 26 |
+
},
|
| 27 |
+
...
|
| 28 |
+
],
|
| 29 |
+
"backend": "llm_api"
|
| 30 |
+
}
|
| 31 |
"""
|
| 32 |
+
|
| 33 |
+
import os
|
| 34 |
import re
|
| 35 |
+
import json
|
| 36 |
import logging
|
| 37 |
from typing import Optional
|
| 38 |
|
| 39 |
+
logger = logging.getLogger(__name__)
|
| 40 |
+
|
| 41 |
+
# ββ Intent catalogue (shared by all backends) ββββββββββββββββββββββββββββββββ
|
| 42 |
+
INTENT_SCHEMA = {
|
| 43 |
+
"greeting": {"slots": [], "destructive": False},
|
| 44 |
+
"balance_inquiry": {"slots": ["account_id"], "destructive": False},
|
| 45 |
+
"send_money": {"slots": ["recipient", "amount", "account_id"], "destructive": True},
|
| 46 |
+
"bill_payment": {"slots": ["account_id", "amount"], "destructive": True},
|
| 47 |
+
"block_card": {"slots": ["account_id"], "destructive": True},
|
| 48 |
+
"branch_info": {"slots": [], "destructive": False},
|
| 49 |
+
"card_request": {"slots": [], "destructive": False},
|
| 50 |
+
"report_issue": {"slots": ["issue_desc"], "destructive": False},
|
| 51 |
+
"track_order": {"slots": ["order_id"], "destructive": False},
|
| 52 |
+
"return_item": {"slots": ["order_id", "return_reason"], "destructive": False},
|
| 53 |
+
"human_agent": {"slots": [], "destructive": False},
|
| 54 |
+
"confirmation_yes": {"slots": [], "destructive": False},
|
| 55 |
+
"confirmation_no": {"slots": [], "destructive": False},
|
| 56 |
+
"cancel": {"slots": [], "destructive": False},
|
| 57 |
+
"goodbye": {"slots": [], "destructive": False},
|
| 58 |
+
"unknown": {"slots": [], "destructive": False},
|
| 59 |
}
|
| 60 |
|
| 61 |
+
NLU_SYSTEM_PROMPT = """You are the NLU module of a customer-service voice agent.
|
| 62 |
+
Decompose the user's message into ALL tasks it contains, in order.
|
| 63 |
+
Extract every entity present. Never invent entities that are not in the text.
|
| 64 |
+
|
| 65 |
+
Intents: greeting, balance_inquiry, send_money, bill_payment, block_card,
|
| 66 |
+
branch_info, card_request, report_issue, track_order, return_item,
|
| 67 |
+
human_agent, confirmation_yes, confirmation_no, cancel, goodbye, unknown.
|
| 68 |
+
|
| 69 |
+
Slots: recipient, amount, account_id, location, issue_desc, order_id, return_reason.
|
| 70 |
+
|
| 71 |
+
CRITICAL disambiguation rules:
|
| 72 |
+
- "where is your branch so I can get my card" = branch_info + card_request.
|
| 73 |
+
It is NOT block_card. Only choose block_card if the user explicitly wants to
|
| 74 |
+
BLOCK, FREEZE, or DEACTIVATE a card.
|
| 75 |
+
- A message can contain multiple tasks joined by "and", "also", "then".
|
| 76 |
+
Output one task per action. "check my balance and send 5000 to musa"
|
| 77 |
+
= [balance_inquiry, send_money{recipient: musa, amount: 5000}].
|
| 78 |
+
- If the user answers a question (e.g. gives a reason like "too small"),
|
| 79 |
+
map it to the slot of the pending task, intent = the pending intent.
|
| 80 |
+
- Confidence in [0,1]: how sure you are of the INTENT (not the slots).
|
| 81 |
+
|
| 82 |
+
Respond with ONLY valid JSON, no markdown, no commentary:
|
| 83 |
+
{"tasks":[{"intent":"...","confidence":0.0,"slots":{},"utterance_span":"..."}]}"""
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
class NLU:
|
| 87 |
+
|
| 88 |
+
def __init__(self, prefer: str = "auto"):
|
| 89 |
+
self.hf_token = os.getenv("HF_TOKEN", "")
|
| 90 |
+
self.api_model = os.getenv(
|
| 91 |
+
"NLU_API_MODEL", "Qwen/Qwen2.5-72B-Instruct")
|
| 92 |
+
self.local_model_id = os.getenv(
|
| 93 |
+
"NLU_LOCAL_MODEL", "Qwen/Qwen2.5-1.5B-Instruct")
|
| 94 |
+
self._local_pipe = None
|
| 95 |
+
self.prefer = prefer
|
| 96 |
+
|
| 97 |
+
# ββ Public API ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 98 |
+
|
| 99 |
+
def parse(self, text: str, pending_intent: Optional[str] = None,
|
| 100 |
+
pending_slot: Optional[str] = None) -> dict:
|
| 101 |
+
"""
|
| 102 |
+
text : English pivot text of the user turn
|
| 103 |
+
pending_intent : intent currently awaiting a slot (context for the LLM)
|
| 104 |
+
pending_slot : which slot we asked for last turn
|
| 105 |
+
"""
|
| 106 |
+
context = ""
|
| 107 |
+
if pending_intent and pending_slot:
|
| 108 |
+
context = (f"\nContext: you previously asked the user for the "
|
| 109 |
+
f"'{pending_slot}' of a '{pending_intent}' task. "
|
| 110 |
+
f"A short answer likely fills that slot.")
|
| 111 |
+
|
| 112 |
+
for backend in self._backend_order():
|
| 113 |
+
try:
|
| 114 |
+
result = backend(text, context)
|
| 115 |
+
if result and result.get("tasks"):
|
| 116 |
+
result = self._sanitize(result)
|
| 117 |
+
logger.info(f"NLU[{result['backend']}]: "
|
| 118 |
+
f"{json.dumps(result['tasks'])[:200]}")
|
| 119 |
+
return result
|
| 120 |
+
except Exception as e:
|
| 121 |
+
logger.warning(f"NLU backend failed ({backend.__name__}): {e}")
|
| 122 |
+
# Absolute last resort
|
| 123 |
+
return {"tasks": [{"intent": "unknown", "confidence": 0.0,
|
| 124 |
+
"slots": {}, "utterance_span": text}],
|
| 125 |
+
"backend": "none"}
|
| 126 |
+
|
| 127 |
+
# ββ Backend chain βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 128 |
+
|
| 129 |
+
def _backend_order(self):
|
| 130 |
+
if self.prefer == "rules":
|
| 131 |
+
return [self._rules_backend]
|
| 132 |
+
chain = []
|
| 133 |
+
if self.hf_token:
|
| 134 |
+
chain.append(self._api_backend)
|
| 135 |
+
chain.append(self._local_backend)
|
| 136 |
+
chain.append(self._rules_backend)
|
| 137 |
+
return chain
|
| 138 |
+
|
| 139 |
+
# ββ 1. HF Serverless Inference API βββββββββββββββββββββββββββββββββββββββ
|
| 140 |
+
|
| 141 |
+
def _api_backend(self, text: str, context: str) -> Optional[dict]:
|
| 142 |
+
import requests
|
| 143 |
+
url = f"https://api-inference.huggingface.co/models/{self.api_model}/v1/chat/completions"
|
| 144 |
+
payload = {
|
| 145 |
+
"model": self.api_model,
|
| 146 |
+
"messages": [
|
| 147 |
+
{"role": "system", "content": NLU_SYSTEM_PROMPT + context},
|
| 148 |
+
{"role": "user", "content": text},
|
| 149 |
+
],
|
| 150 |
+
"max_tokens": 400,
|
| 151 |
+
"temperature": 0.1,
|
| 152 |
+
}
|
| 153 |
+
r = requests.post(url, json=payload, timeout=20,
|
| 154 |
+
headers={"Authorization": f"Bearer {self.hf_token}"})
|
| 155 |
+
r.raise_for_status()
|
| 156 |
+
raw = r.json()["choices"][0]["message"]["content"]
|
| 157 |
+
parsed = self._extract_json(raw)
|
| 158 |
+
if parsed:
|
| 159 |
+
parsed["backend"] = "llm_api"
|
| 160 |
+
return parsed
|
| 161 |
+
|
| 162 |
+
# ββ 2. Local small LLM ββββββββββββββββββββββββββββββββββοΏ½οΏ½οΏ½βββββββββββββββββ
|
| 163 |
+
|
| 164 |
+
def _local_backend(self, text: str, context: str) -> Optional[dict]:
|
| 165 |
+
if self._local_pipe is None:
|
| 166 |
+
logger.info(f"Loading local NLU model {self.local_model_id} β¦")
|
| 167 |
+
from transformers import pipeline as hf_pipeline
|
| 168 |
+
import torch
|
| 169 |
+
self._local_pipe = hf_pipeline(
|
| 170 |
+
"text-generation",
|
| 171 |
+
model=self.local_model_id,
|
| 172 |
+
torch_dtype=torch.float16 if torch.cuda.is_available() else torch.float32,
|
| 173 |
+
device_map="auto",
|
| 174 |
+
)
|
| 175 |
+
messages = [
|
| 176 |
+
{"role": "system", "content": NLU_SYSTEM_PROMPT + context},
|
| 177 |
+
{"role": "user", "content": text},
|
| 178 |
+
]
|
| 179 |
+
out = self._local_pipe(messages, max_new_tokens=400,
|
| 180 |
+
do_sample=False, temperature=None, top_p=None)
|
| 181 |
+
raw = out[0]["generated_text"][-1]["content"]
|
| 182 |
+
parsed = self._extract_json(raw)
|
| 183 |
+
if parsed:
|
| 184 |
+
parsed["backend"] = "llm_local"
|
| 185 |
+
return parsed
|
| 186 |
+
|
| 187 |
+
# ββ 3. Improved rules (never fails) βββββββββββββββββββββββββββββββββββββββ
|
| 188 |
+
|
| 189 |
+
def _rules_backend(self, text: str, context: str) -> dict:
|
| 190 |
+
"""
|
| 191 |
+
Better than the old FSM keywords:
|
| 192 |
+
- splits on conjunctions to find MULTIPLE tasks
|
| 193 |
+
- extracts entities per clause
|
| 194 |
+
- branch_info vs block_card disambiguation
|
| 195 |
+
"""
|
| 196 |
+
t = text.lower().strip()
|
| 197 |
+
|
| 198 |
+
# Split compound message into clauses
|
| 199 |
+
clauses = re.split(r'\b(?:and also|and then|then|and|also|;|\. )\b', t)
|
| 200 |
+
clauses = [c.strip() for c in clauses if c.strip()]
|
| 201 |
+
|
| 202 |
+
tasks = []
|
| 203 |
+
for clause in clauses:
|
| 204 |
+
task = self._rules_classify_clause(clause)
|
| 205 |
+
if task:
|
| 206 |
+
tasks.append(task)
|
| 207 |
+
|
| 208 |
+
# Merge duplicate consecutive intents (e.g. "and" split an entity off)
|
| 209 |
+
merged = []
|
| 210 |
+
for task in tasks:
|
| 211 |
+
if merged and merged[-1]["intent"] == task["intent"]:
|
| 212 |
+
merged[-1]["slots"].update(task["slots"])
|
| 213 |
+
merged[-1]["utterance_span"] += " " + task["utterance_span"]
|
| 214 |
+
else:
|
| 215 |
+
merged.append(task)
|
| 216 |
+
|
| 217 |
+
if not merged:
|
| 218 |
+
merged = [{"intent": "unknown", "confidence": 0.3,
|
| 219 |
+
"slots": {}, "utterance_span": t}]
|
| 220 |
+
|
| 221 |
+
return {"tasks": merged, "backend": "rules"}
|
| 222 |
+
|
| 223 |
+
def _rules_classify_clause(self, clause: str) -> Optional[dict]:
|
| 224 |
+
slots = {}
|
| 225 |
+
|
| 226 |
+
# ββ Entity extraction (always, regardless of intent) ββββββββββββββββ
|
| 227 |
+
# In a money-action clause ("send/transfer/pay X to Y"), the number is
|
| 228 |
+
# an AMOUNT. Only treat 6-12 digit numbers as account_id when the
|
| 229 |
+
# clause is about the account itself, or there is no money verb.
|
| 230 |
+
money_verb = any(v in clause for v in ("send", "transfer", "pay"))
|
| 231 |
+
account_ctx = any(v in clause for v in ("account", "acct", "number is"))
|
| 232 |
+
numbers = re.findall(r'\b\d[\d,\.]*\b', clause)
|
| 233 |
+
for num in numbers:
|
| 234 |
+
digits = num.replace(",", "").replace(".", "")
|
| 235 |
+
if money_verb and "amount" not in slots and len(digits) <= 7:
|
| 236 |
+
slots["amount"] = digits
|
| 237 |
+
elif (account_ctx or not money_verb) and 6 <= len(digits) <= 12 \
|
| 238 |
+
and "account_id" not in slots:
|
| 239 |
+
slots["account_id"] = digits
|
| 240 |
+
elif "amount" not in slots and len(digits) <= 7:
|
| 241 |
+
slots["amount"] = digits
|
| 242 |
+
# recipient: "to <name>" β take the LAST valid match, skipping verbs
|
| 243 |
+
# ("I want to send money to abu" must yield 'abu', not 'send')
|
| 244 |
+
RECIPIENT_STOPWORDS = {
|
| 245 |
+
"my", "the", "a", "an", "me", "you", "check", "send", "transfer",
|
| 246 |
+
"pay", "get", "make", "do", "know", "see", "block", "return",
|
| 247 |
+
"track", "him", "her", "them", "it", "confirm", "cancel"}
|
| 248 |
+
for m in re.finditer(r'\bto\s+([a-z]{2,20})\b', clause):
|
| 249 |
+
name = m.group(1)
|
| 250 |
+
if name not in RECIPIENT_STOPWORDS:
|
| 251 |
+
slots["recipient"] = name
|
| 252 |
+
# order id
|
| 253 |
+
m = re.search(r'\border\s*#?\s*([a-z0-9\-]{4,20})\b', clause)
|
| 254 |
+
if m:
|
| 255 |
+
slots["order_id"] = m.group(1)
|
| 256 |
+
|
| 257 |
+
# ββ Intent (order matters: destructive intents need explicit verbs) ββ
|
| 258 |
+
def has(*kws):
|
| 259 |
+
return any(kw in clause for kw in kws)
|
| 260 |
+
|
| 261 |
+
# branch/location questions BEFORE block_card β fixes P0 #2
|
| 262 |
+
if has("branch", "closest", "nearest", "location", "where is", "address"):
|
| 263 |
+
intent, conf = "branch_info", 0.85
|
| 264 |
+
if has("card", "atm"):
|
| 265 |
+
# compound: they also want a card β but NOT to block it
|
| 266 |
+
return {"intent": "branch_info", "confidence": 0.85,
|
| 267 |
+
"slots": slots, "utterance_span": clause}
|
| 268 |
+
elif has("block my card", "block card", "freeze", "deactivate", "stolen", "lost my card"):
|
| 269 |
+
intent, conf = "block_card", 0.8
|
| 270 |
+
elif has("send", "transfer") and (slots.get("recipient") or slots.get("amount")):
|
| 271 |
+
intent, conf = "send_money", 0.85
|
| 272 |
+
elif has("send money", "transfer money"):
|
| 273 |
+
intent, conf = "send_money", 0.75
|
| 274 |
+
elif has("balance", "how much", "asusun"):
|
| 275 |
+
intent, conf = "balance_inquiry", 0.85
|
| 276 |
+
elif has("pay", "bill", "recharge", "invoice"):
|
| 277 |
+
intent, conf = "bill_payment", 0.75
|
| 278 |
+
elif has("track", "where is my order", "delivery", "shipment"):
|
| 279 |
+
intent, conf = "track_order", 0.8
|
| 280 |
+
elif has("return", "refund", "send back"):
|
| 281 |
+
intent, conf = "return_item", 0.8
|
| 282 |
+
elif has("problem", "issue", "complaint", "not working", "error"):
|
| 283 |
+
intent, conf = "report_issue", 0.7
|
| 284 |
+
slots["issue_desc"] = clause
|
| 285 |
+
elif has("human", "agent", "person", "operator", "representative"):
|
| 286 |
+
intent, conf = "human_agent", 0.9
|
| 287 |
+
elif has("yes", "yep", "correct", "confirm", "sure", "okay", "ok"):
|
| 288 |
+
intent, conf = "confirmation_yes", 0.8
|
| 289 |
+
elif has("no", "nope", "wrong", "cancel that"):
|
| 290 |
+
intent, conf = "confirmation_no", 0.8
|
| 291 |
+
elif has("hello", "hi ", "good morning", "sannu", "salam"):
|
| 292 |
+
intent, conf = "greeting", 0.9
|
| 293 |
+
elif has("bye", "goodbye", "thank"):
|
| 294 |
+
intent, conf = "goodbye", 0.85
|
| 295 |
+
else:
|
| 296 |
+
return {"intent": "unknown", "confidence": 0.3,
|
| 297 |
+
"slots": slots, "utterance_span": clause}
|
| 298 |
+
|
| 299 |
+
return {"intent": intent, "confidence": conf,
|
| 300 |
+
"slots": slots, "utterance_span": clause}
|
| 301 |
+
|
| 302 |
+
# ββ Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 303 |
+
|
| 304 |
+
@staticmethod
|
| 305 |
+
def _extract_json(raw: str) -> Optional[dict]:
|
| 306 |
+
"""Robustly pull the first JSON object out of LLM output."""
|
| 307 |
+
raw = raw.strip()
|
| 308 |
+
raw = re.sub(r'^```(?:json)?|```$', '', raw, flags=re.MULTILINE).strip()
|
| 309 |
+
# find first { β¦ matching last }
|
| 310 |
+
start = raw.find("{")
|
| 311 |
+
end = raw.rfind("}")
|
| 312 |
+
if start == -1 or end == -1:
|
| 313 |
+
return None
|
| 314 |
+
try:
|
| 315 |
+
return json.loads(raw[start:end + 1])
|
| 316 |
+
except json.JSONDecodeError:
|
| 317 |
+
return None
|
| 318 |
+
|
| 319 |
+
@staticmethod
|
| 320 |
+
def _sanitize(result: dict) -> dict:
|
| 321 |
+
"""Validate schema, clamp confidence, drop hallucinated slots."""
|
| 322 |
+
valid_slots = {"recipient", "amount", "account_id", "location",
|
| 323 |
+
"issue_desc", "order_id", "return_reason"}
|
| 324 |
+
clean_tasks = []
|
| 325 |
+
for task in result.get("tasks", []):
|
| 326 |
+
intent = task.get("intent", "unknown")
|
| 327 |
+
if intent not in INTENT_SCHEMA:
|
| 328 |
+
intent = "unknown"
|
| 329 |
+
conf = float(task.get("confidence", 0.5))
|
| 330 |
+
conf = max(0.0, min(1.0, conf))
|
| 331 |
+
slots = {k: str(v).strip() for k, v in (task.get("slots") or {}).items()
|
| 332 |
+
if k in valid_slots and v not in (None, "", "null", "None")}
|
| 333 |
+
clean_tasks.append({
|
| 334 |
+
"intent": intent, "confidence": conf, "slots": slots,
|
| 335 |
+
"utterance_span": str(task.get("utterance_span", ""))[:200],
|
| 336 |
+
})
|
| 337 |
+
result["tasks"] = clean_tasks or [
|
| 338 |
+
{"intent": "unknown", "confidence": 0.0, "slots": {},
|
| 339 |
+
"utterance_span": ""}]
|
| 340 |
+
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
orchestrator.py
CHANGED
|
@@ -511,4 +511,4 @@ class Orchestrator:
|
|
| 511 |
return res["ticket_id"]
|
| 512 |
except Exception as e:
|
| 513 |
logger.warning(f"CRM ticket failed: {e}")
|
| 514 |
-
return f"TKT-{session.session_id}-{str(uuid.uuid4())[:4].upper()}"
|
|
|
|
| 511 |
return res["ticket_id"]
|
| 512 |
except Exception as e:
|
| 513 |
logger.warning(f"CRM ticket failed: {e}")
|
| 514 |
+
return f"TKT-{session.session_id}-{str(uuid.uuid4())[:4].upper()}"
|
test_regressions.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Regression tests β one test per failure in the feedback.
|
| 3 |
+
Runs with the RULES backend (no model download) so it's fast and CI-able.
|
| 4 |
+
The LLM backends only improve on these results.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import sys
|
| 8 |
+
import logging
|
| 9 |
+
logging.basicConfig(level=logging.WARNING)
|
| 10 |
+
|
| 11 |
+
from nlu import NLU
|
| 12 |
+
from orchestrator import Orchestrator, fmt_amount
|
| 13 |
+
|
| 14 |
+
PASS, FAIL = "\033[92mPASS\033[0m", "\033[91mFAIL\033[0m"
|
| 15 |
+
results = []
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def check(name: str, cond: bool, detail: str = ""):
|
| 19 |
+
results.append((name, cond))
|
| 20 |
+
print(f" [{PASS if cond else FAIL}] {name}" + (f" β {detail}" if detail else ""))
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def new_orch():
|
| 24 |
+
return Orchestrator(nlu=NLU(prefer="rules"))
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def turn(orch, session, text):
|
| 28 |
+
resp, session, esc = orch.respond(text, text, session)
|
| 29 |
+
return resp, session, esc
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
print("\nββ P1: 'send money to abu now' must NOT re-ask for recipient ββ")
|
| 33 |
+
orch = new_orch()
|
| 34 |
+
s = orch.new_session()
|
| 35 |
+
resp, s, _ = turn(orch, s, "I want to send money to abu now")
|
| 36 |
+
check("recipient 'abu' prefilled (no 'who do you want to send to')",
|
| 37 |
+
"who" not in resp.lower() and "abu" not in resp.lower().replace("abu", "") or
|
| 38 |
+
("recipient" not in resp.lower() and "who would you like to send" not in resp.lower()),
|
| 39 |
+
f"agent said: {resp[:90]}")
|
| 40 |
+
check("asks for the AMOUNT instead (the actually-missing slot)",
|
| 41 |
+
"how much" in resp.lower(), f"agent said: {resp[:90]}")
|
| 42 |
+
|
| 43 |
+
print("\nββ P0: compound 'check my balance and also send 350000 to amina' ββ")
|
| 44 |
+
orch = new_orch()
|
| 45 |
+
s = orch.new_session()
|
| 46 |
+
resp, s, _ = turn(orch, s, "check my balance and also send 350000 to amina")
|
| 47 |
+
check("acknowledges BOTH tasks", "one at a time" in resp.lower() or
|
| 48 |
+
("balance" in resp.lower() and ("transfer" in resp.lower() or "amina" in resp.lower())),
|
| 49 |
+
f"agent said: {resp[:120]}")
|
| 50 |
+
# give account number β balance executes, transfer flow continues
|
| 51 |
+
resp, s, _ = turn(orch, s, "1234567890")
|
| 52 |
+
check("balance is delivered", "balance is" in resp.lower(), f"{resp[:90]}")
|
| 53 |
+
check("transfer to amina NOT dropped (asks to confirm or continues it)",
|
| 54 |
+
"amina" in resp.lower() or "send" in resp.lower() or "confirm" in resp.lower()
|
| 55 |
+
or "yes" in resp.lower(),
|
| 56 |
+
f"{resp[:140]}")
|
| 57 |
+
|
| 58 |
+
print("\nββ P0: 'where is your closest branch so I can get my ATM card' ββ")
|
| 59 |
+
orch = new_orch()
|
| 60 |
+
s = orch.new_session()
|
| 61 |
+
resp, s, _ = turn(orch, s, "where is your closest branch so I can get my ATM card")
|
| 62 |
+
check("NOT classified as block_card (no 'block' confirmation)",
|
| 63 |
+
"block" not in resp.lower(), f"{resp[:120]}")
|
| 64 |
+
check("gives branch info", "branch" in resp.lower() or "open" in resp.lower(),
|
| 65 |
+
f"{resp[:120]}")
|
| 66 |
+
|
| 67 |
+
print("\nββ P1: return reason 'too small' must not dead-end ββ")
|
| 68 |
+
orch = new_orch()
|
| 69 |
+
s = orch.new_session()
|
| 70 |
+
resp, s, _ = turn(orch, s, "I want to return my order")
|
| 71 |
+
resp, s, _ = turn(orch, s, "ORD-4521")
|
| 72 |
+
resp, s, _ = turn(orch, s, "too small")
|
| 73 |
+
check("'too small' accepted as return reason",
|
| 74 |
+
"too small" in resp.lower() or "return" in resp.lower(),
|
| 75 |
+
f"{resp[:120]}")
|
| 76 |
+
check("no 'did not understand' dead-end",
|
| 77 |
+
"didn't understand" not in resp.lower() and "did not understand" not in resp.lower(),
|
| 78 |
+
f"{resp[:90]}")
|
| 79 |
+
|
| 80 |
+
print("\nββ P1: unknown input β clarify once, then human WITH context ββ")
|
| 81 |
+
orch = new_orch()
|
| 82 |
+
s = orch.new_session()
|
| 83 |
+
resp, s, esc = turn(orch, s, "florble the wumbus")
|
| 84 |
+
check("first unknown β clarifying question, not menu dump",
|
| 85 |
+
"are you asking" in resp.lower() or "balance" in resp.lower(), f"{resp[:110]}")
|
| 86 |
+
check("not escalated yet", not esc)
|
| 87 |
+
resp, s, esc = turn(orch, s, "zorp zorp quux")
|
| 88 |
+
check("second unknown β human handoff", esc, f"{resp[:110]}")
|
| 89 |
+
check("handoff mentions context is preserved",
|
| 90 |
+
"won't have to repeat" in resp.lower() or "conversation" in resp.lower(),
|
| 91 |
+
f"{resp[:140]}")
|
| 92 |
+
|
| 93 |
+
print("\nββ P2: balance guard β 350,000 against smaller balance ββ")
|
| 94 |
+
orch = new_orch()
|
| 95 |
+
s = orch.new_session()
|
| 96 |
+
resp, s, _ = turn(orch, s, "check my balance")
|
| 97 |
+
resp, s, _ = turn(orch, s, "1111111") # deterministic mock balance
|
| 98 |
+
bal = s.balance
|
| 99 |
+
too_much = bal + 100_000
|
| 100 |
+
resp, s, _ = turn(orch, s, f"send {too_much} to musa")
|
| 101 |
+
check("over-balance transfer is refused, not confirmed",
|
| 102 |
+
"can't process" in resp.lower() or "balance is" in resp.lower(),
|
| 103 |
+
f"balance={fmt_amount(bal)}, asked={fmt_amount(too_much)} β {resp[:130]}")
|
| 104 |
+
|
| 105 |
+
print("\nββ P2: consistent β¦ formatting ββ")
|
| 106 |
+
check("fmt_amount('350000') == 'β¦350,000'", fmt_amount("350000") == "β¦350,000")
|
| 107 |
+
check("fmt_amount('134200') == 'β¦134,200'", fmt_amount("134200") == "β¦134,200")
|
| 108 |
+
check("fmt_amount(245000) == 'β¦245,000'", fmt_amount(245000) == "β¦245,000")
|
| 109 |
+
|
| 110 |
+
print("\nββ Destructive confirmation gate ββ")
|
| 111 |
+
orch = new_orch()
|
| 112 |
+
s = orch.new_session()
|
| 113 |
+
resp, s, _ = turn(orch, s, "send 5000 to fatima")
|
| 114 |
+
resp, s, _ = turn(orch, s, "1234567890") # account slot
|
| 115 |
+
check("transfer requires explicit yes/no before executing",
|
| 116 |
+
"yes" in resp.lower() and "confirm" in resp.lower(), f"{resp[:130]}")
|
| 117 |
+
resp, s, _ = turn(orch, s, "no")
|
| 118 |
+
check("'no' cancels cleanly", "cancelled" in resp.lower(), f"{resp[:90]}")
|
| 119 |
+
|
| 120 |
+
# ββ Summary ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 121 |
+
passed = sum(1 for _, ok in results if ok)
|
| 122 |
+
total = len(results)
|
| 123 |
+
print(f"\n{'='*60}\n {passed}/{total} checks passed\n{'='*60}")
|
| 124 |
+
sys.exit(0 if passed == total else 1)
|