Toadoum commited on
Commit
d402333
Β·
verified Β·
1 Parent(s): 6d23228

Upload 4 files

Browse files
Files changed (4) hide show
  1. app.py +722 -333
  2. nlu.py +332 -469
  3. orchestrator.py +1 -1
  4. test_regressions.py +124 -0
app.py CHANGED
@@ -1,340 +1,729 @@
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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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&lt;2s Β· MT&lt;0.5s Β· TTS&lt;1s Β· Total&lt;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=&lt;meta-token&gt; &nbsp; WHATSAPP_PHONE_ID=&lt;phone-id&gt;<br>
643
+ TWILIO_ACCOUNT_SID=ACxxxx &nbsp; TWILIO_AUTH_TOKEN=xxxx<br>
644
+ CRM_PROVIDER=zendesk &nbsp; ZENDESK_SUBDOMAIN=yourco &nbsp; ZENDESK_API_TOKEN=xxxx<br>
645
+ SIP_PROVIDER=twilio &nbsp; (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 &nbsp;|&nbsp;
721
+ Whisper large-v3 Β· NLLB-200 Β· MMS-TTS-hau &nbsp;|&nbsp;
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 β€” Embedding similarity architecture.
3
- =========================================
4
- Replaces the legacy NLLB+Qwen pipeline (preserved in nlu_legacy.py).
5
- Why embeddings?
6
- - Latency: ~200ms vs ~10s on CPU for the legacy stack
7
- - Memory: ~420MB vs ~8GB
8
- - Hausa coverage: paraphrase-multilingual-MiniLM-L12-v2 was trained on 50+
9
- languages including Hausa, so we no longer need a translation step
10
- - Confidence comes for free: cosine similarity IS a calibrated confidence
11
- Pipeline (in order):
12
- Layer 0: Human-keyword escape ("wakili", "agent") β†’ always wins
13
- Layer 1: Structural extractors (digits, amounts, yes/no, name, date, free
14
- text, bundle) when the dialogue state sets an expected slot
15
- Layer 1.5: Keyword fast-path for ultra-common phrases ("duba ma'auni")
16
- β€” sub-millisecond, no model call
17
- Layer 2: Sentence-embedding similarity vs per-intent centroids
18
- β€” cosine sim β‰₯ threshold (0.4) β†’ that intent, else unknown
19
- The dialogue manager receives the same (intent, entities, source) tuple
20
- as before, so app.py needs no changes.
21
-
22
- Fixes vs the POC
23
- ----------------
24
- * _match_yesno no longer treats English "I" (inside any sentence) as Hausa
25
- "i"=yes via substring containment; short tokens match exactly.
26
- * _match_intent_keyword matches keywords as whole whitespace-delimited tokens,
27
- so short keywords ("ba", "data", "oda") can't fire inside unrelated words.
28
- * expected == "text" / "bundle" now emit the provide_text / provide_bundle
29
- intents the FSM transitions expect β€” previously these slots were never
30
- satisfied, silently breaking the complaint, bundle, and return flows.
31
  """
32
- from __future__ import annotations
 
33
  import re
 
34
  import logging
35
  from typing import Optional
36
 
37
- logger = logging.getLogger("plotweaver.nlu")
38
-
39
-
40
- # ---------------------------------------------------------------------------
41
- # Deterministic structural extractors (run on raw Hausa text)
42
- # ---------------------------------------------------------------------------
43
- WORD_DIGITS = {
44
- "sifili": "0", "daya": "1", "Ι—aya": "1", "biyu": "2", "uku": "3",
45
- "hudu": "4", "huΙ—u": "4", "biyar": "5", "shida": "6", "bakwai": "7",
46
- "takwas": "8", "tara": "9",
 
 
 
 
 
 
 
 
 
 
47
  }
48
 
49
- WORD_AMOUNTS = {
50
- "dubu goma": 10000, "dubu biyar": 5000, "dubu biyu": 2000,
51
- "dubu": 1000, "Ι—ari biyar": 500, "dari biyar": 500,
52
- "Ι—ari": 100, "dari": 100,
53
- }
54
-
55
- # Short yes/no tokens: matched EXACTLY (whole utterance) to avoid substring
56
- # false positives. Multi-word cues are matched as whitespace-bounded phrases.
57
- HAUSA_YES_EXACT = {"i", "eh", "ok", "okay", "yes"}
58
- HAUSA_YES_PHRASE = {"haka ne", "haka"}
59
- HAUSA_NO_EXACT = {"a'a", "a'aa", "ba", "no"}
60
- HAUSA_NO_PHRASE = {"ba haka"}
61
-
62
- HUMAN_KEYWORDS = {"mutum", "wakili", "agent", "human"}
63
-
64
- BUNDLE_TYPES = ("rana", "mako", "wata")
65
-
66
-
67
- def _extract_digits(text: str) -> Optional[str]:
68
- m = re.findall(r"\d+", text)
69
- if m:
70
- return "".join(m)
71
- tokens = text.lower().split()
72
- d = [WORD_DIGITS[tok] for tok in tokens if tok in WORD_DIGITS]
73
- return "".join(d) if d else None
74
-
75
-
76
- def _extract_amount(text: str) -> Optional[int]:
77
- m = re.search(r"\d+", text)
78
- if m:
79
- return int(m.group())
80
- t = text.lower()
81
- for phrase in sorted(WORD_AMOUNTS.keys(), key=len, reverse=True):
82
- if phrase in t:
83
- return WORD_AMOUNTS[phrase]
84
- return None
85
-
86
-
87
- def _match_yesno(text: str) -> Optional[str]:
88
- t = text.lower().strip()
89
- if t in HAUSA_YES_EXACT:
90
- return "yes"
91
- if t in HAUSA_NO_EXACT:
92
- return "no"
93
- padded = f" {t} "
94
- if any(f" {kw} " in padded for kw in HAUSA_YES_PHRASE):
95
- return "yes"
96
- if any(f" {kw} " in padded for kw in HAUSA_NO_PHRASE):
97
- return "no"
98
- return None
99
-
100
-
101
- def _contains_human_keyword(text: str) -> bool:
102
- padded = f" {text.lower().strip()} "
103
- return any(f" {kw} " in padded for kw in HUMAN_KEYWORDS)
104
-
105
-
106
- # ---------------------------------------------------------------------------
107
- # Keyword fast-path β€” instant matches for common scripted phrases
108
- # ---------------------------------------------------------------------------
109
- INTENT_KEYWORDS = {
110
- "check_balance": [
111
- "duba ma'auni", "ma'auni", "balance", "check balance",
112
- "account balance", "how much", "kudin asusu",
113
- ],
114
- "block_card": [
115
- "toshe kati", "block card", "cancel card", "freeze card",
116
- "toshe", "lost card", "Ι“atar da kati",
117
- ],
118
- "transfer_money": [
119
- "canjin kuΙ—i", "canjin kudi", "transfer", "transfer money",
120
- "send money", "aiki kuΙ—i", "aiki kudi",
121
- ],
122
- "buy_airtime": [
123
- "saya airtime", "airtime", "buy airtime", "top up", "topup",
124
- "recharge", "karΙ“i airtime",
125
- ],
126
- "buy_bundle": [
127
- "saya bundle", "bundle", "buy bundle", "buy data", "data",
128
- "internet", "megabyte",
129
- ],
130
- "complaint": [
131
- "yin korafi", "korafi", "complaint", "complain", "problem",
132
- "matsala", "file complaint",
133
- ],
134
- "check_order": [
135
- "bincika oda", "oda", "check order", "order status", "my order",
136
- "where is my order", "track order",
137
- ],
138
- "reschedule": [
139
- "sake tsara", "reschedule", "change time", "another day",
140
- "later", "tomorrow",
141
- ],
142
- "return_item": [
143
- "mayar da kaya", "return", "return item", "send back", "mayar",
144
- ],
145
- }
146
-
147
-
148
- def _match_intent_keyword(text: str) -> Optional[str]:
149
- # Whitespace-bounded match: the keyword must appear as a whole token (or
150
- # token sequence), never as a fragment inside another word.
151
- padded = f" {text.lower().strip()} "
152
- all_kw = [(intent, kw) for intent, kws in INTENT_KEYWORDS.items() for kw in kws]
153
- all_kw.sort(key=lambda x: len(x[1]), reverse=True)
154
- for intent, kw in all_kw:
155
- if f" {kw} " in padded:
156
- return intent
157
- return None
158
-
159
-
160
- # ---------------------------------------------------------------------------
161
- # Intent example dataset β€” the heart of the embedding NLU.
162
- # These phrases are encoded once into centroids; at inference, user input is
163
- # compared (cosine similarity) against each centroid. More examples = better
164
- # coverage of paraphrases. Hausa + English mixed deliberately so cross-lingual
165
- # matches work via the multilingual encoder.
166
- # ---------------------------------------------------------------------------
167
- INTENT_EXAMPLES = {
168
- "check_balance": [
169
- "duba ma'auni",
170
- "ina son sanin kuΙ—in asusuna",
171
- "nawa ne a asusuna",
172
- "menene ma'aunin asusuna",
173
- "yi mini bayanin asusuna",
174
- "ina son ganin kuΙ—ina",
175
- "check my balance",
176
- "what is my account balance",
177
- "how much money do I have",
178
- "show me my balance",
179
- "tell me my balance",
180
- "how much is in my account",
181
- ],
182
- "block_card": [
183
- "toshe kati",
184
- "ina son toshe katina",
185
- "Ι“atar da kati na",
186
- "katina ya Ι“ace",
187
- "yi mini taimako, kati na ya Ι“ace",
188
- "in toshe ATM card",
189
- "block my card",
190
- "I lost my card",
191
- "freeze my debit card",
192
- "I need to cancel my card",
193
- "my card was stolen",
194
- "please block my ATM card",
195
- ],
196
- "transfer_money": [
197
- "canjin kuΙ—i",
198
- "ina son aika kuΙ—i",
199
- "tura kuΙ—i zuwa wani",
200
- "yi canji",
201
- "in turawa abokina kuΙ—i",
202
- "aiki kuΙ—i ga abokina",
203
- "transfer money",
204
- "send money to someone",
205
- "I want to make a transfer",
206
- "wire money to my friend",
207
- "send naira to another account",
208
- "make a payment",
209
- ],
210
- "buy_airtime": [
211
- "saya airtime",
212
- "ina son saya airtime",
213
- "kunna waya",
214
- "in saya credit",
215
- "saya credit na waya",
216
- "recharge waya na",
217
- "buy airtime",
218
- "top up my phone",
219
- "recharge my phone",
220
- "I need airtime",
221
- "load credit",
222
- "add credit to my phone",
223
- ],
224
- "buy_bundle": [
225
- "saya bundle",
226
- "ina son saya data",
227
- "kunna intanet",
228
- "in saya data bundle",
229
- "saya megabyte",
230
- "buy data",
231
- "buy internet bundle",
232
- "I want a data plan",
233
- "purchase data bundle",
234
- "get me a megabyte plan",
235
- "subscribe to data",
236
- "renew my data",
237
- ],
238
- "complaint": [
239
- "yin korafi",
240
- "ina da matsala",
241
- "in yi koka",
242
- "akwai matsala da hidima",
243
- "ina son in kawo matsala",
244
- "ba na gamsuwa",
245
- "I want to file a complaint",
246
- "I have a problem",
247
- "report an issue",
248
- "something is wrong",
249
- "the service is bad",
250
- "I'm not satisfied",
251
- ],
252
- "check_order": [
253
- "bincika oda",
254
- "ina oda na yake",
255
- "tabbatar oda",
256
- "yaushe za a kawo oda na",
257
- "in san halin oda na",
258
- "track order",
259
- "where is my order",
260
- "check order status",
261
- "when will my order arrive",
262
- "is my order ready",
263
- "I want to know about my order",
264
- ],
265
- "reschedule": [
266
- "sake tsara",
267
- "ina son sake tsara lokaci",
268
- "canjin ranar isar",
269
- "in canza ranar kawowa",
270
- "rana ta dabam",
271
- "reschedule delivery",
272
- "change delivery date",
273
- "I want a different day",
274
- "deliver tomorrow instead",
275
- "postpone the delivery",
276
- "move the delivery to later",
277
- ],
278
- "return_item": [
279
- "mayar da kaya",
280
- "ina son mayar da kaya",
281
- "ba na son kaya",
282
- "ina son mayarwa",
283
- "kaya ba shi da kyau",
284
- "return this item",
285
- "I want to return my order",
286
- "send it back",
287
- "I want a refund",
288
- "I don't want this anymore",
289
- "the item is broken",
290
- ],
291
- "human_agent": [
292
- "ina son magana da mutum",
293
- "ka kawo mutum",
294
- "wakili",
295
- "in yi magana da wakilin",
296
- "ba zan iya da bot ba",
297
- "I want to speak to a human",
298
- "connect me to an agent",
299
- "transfer me to a person",
300
- "I need to talk to someone",
301
- "real person please",
302
- "agent please",
303
- ],
304
- }
305
-
306
-
307
- # Confidence threshold: cosine similarities below this become 'unknown'.
308
- # Tuned by hand at 0.4 β€” re-tune with eval_nlu.py (threshold sweep) once you
309
- # have real Hausa traffic from the turn logs.
310
- CONFIDENCE_THRESHOLD = 0.4
311
-
312
- # Embedding model. Multilingual (50+ languages), 420MB, CPU-fast.
313
- EMBEDDING_MODEL_ID = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
314
-
315
-
316
- # ---------------------------------------------------------------------------
317
- # Embedding model + centroid cache (lazy-loaded)
318
- # ---------------------------------------------------------------------------
319
- _encoder = None
320
- _intent_centroids: Optional[dict] = None # intent_name -> np.ndarray
321
- _embed_failed = False
322
-
323
-
324
- def _load_encoder():
325
- """Lazy-load the sentence encoder + compute intent centroids.
326
- Pinned to CPU: on ZeroGPU the GPU isn't attached at import time, and the
327
- encoder is fast enough on CPU (~200ms) that a GPU round-trip would be a net
328
- loss β€” only ASR/TTS belong on the GPU."""
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)