Toadoum commited on
Commit
15fbd84
·
verified ·
1 Parent(s): de7fd77

Upload 12 files

Browse files
Files changed (7) hide show
  1. app.py +362 -789
  2. deploy.sh +75 -0
  3. nlu.py +17 -2
  4. pipeline.py +20 -7
  5. requirements.txt +25 -22
  6. test_app.py +155 -0
  7. verify_boot.py +85 -0
app.py CHANGED
@@ -1,853 +1,426 @@
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 os
14
- import uuid
15
- import json
16
  from datetime import datetime
17
 
 
 
 
 
 
 
 
 
18
  from pipeline import HausaVoiceAIPipeline
19
  from nlu import NLU
20
  from orchestrator import Orchestrator
21
- from integrations.crm import CRMClient
22
 
23
- # ── Singletons (lazy-loaded inside pipeline) ─────────────────────────────────
24
- ai_pipeline = HausaVoiceAIPipeline()
25
- # NLU backend chain: HF Inference API (if HF_TOKEN set) → local LLM → rules
26
- dm = Orchestrator(crm=CRMClient(), nlu=NLU())
 
 
 
 
27
 
28
- # ── Streaming config ─────────────────────────────────────────────────────────
29
- # Partials cost an extra Whisper decode every ~900ms. On a CPU-only Space that
30
- # can saturate the box, so allow turning them off via env var.
31
- SHOW_PARTIALS = os.getenv("SHOW_PARTIALS", "1") == "1"
 
 
 
32
 
33
- # ── Demo phrases (for visitors who don't speak Hausa) ────────────────────────
34
  DEMO_PROMPTS = [
35
- ("Compound: balance + transfer",
36
- "Duba asusuna sannan ka aika naira dubu talatin da biyar zuwa Amina"),
37
- ("Entity prefill: send to Abu",
38
- "Ina son aika kuɗi zuwa Abu yanzu"),
39
- ("Branch + card (not block!)",
40
  "Ina ne reshenku mafi kusa domin in karɓi katin ATM"),
41
- ("Report a problem", "Ina da matsala"),
42
- ("Talk to human agent", "Ina son magana da mutum"),
43
  ]
44
 
45
- # ── Custom CSS ────────────────────────────────────────────────────────────────
46
- CSS = """
47
- @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');
48
-
49
- :root {
50
- --amber: #F59E0B;
51
- --ember: #DC2626;
52
- --sand: #FDE68A;
53
- --dark: #0C0A09;
54
- --panel: #1C1917;
55
- --border: #292524;
56
- --text: #E7E5E4;
57
- --muted: #78716C;
58
- --success: #22C55E;
59
- --info: #38BDF8;
60
- }
61
-
62
- body, .gradio-container {
63
- background: var(--dark) !important;
64
- font-family: 'Space Grotesk', sans-serif !important;
65
- color: var(--text) !important;
66
- }
67
-
68
- /* ── Header ── */
69
- .pw-header {
70
- background: linear-gradient(135deg, #1C1917 0%, #292524 50%, #1C1917 100%);
71
- border-bottom: 1px solid var(--border);
72
- padding: 28px 40px 24px;
73
- position: relative;
74
- overflow: hidden;
75
- }
76
- .pw-header::before {
77
- content: '';
78
- position: absolute;
79
- top: -60px; right: -60px;
80
- width: 300px; height: 300px;
81
- background: radial-gradient(circle, rgba(245,158,11,0.15) 0%, transparent 70%);
82
- pointer-events: none;
83
- }
84
- .pw-logo {
85
- font-family: 'Syne', sans-serif;
86
- font-weight: 800;
87
- font-size: 28px;
88
- color: var(--amber);
89
- letter-spacing: -0.5px;
90
- margin: 0;
91
- }
92
- .pw-logo span { color: var(--text); }
93
- .pw-tagline {
94
- color: var(--muted);
95
- font-size: 13px;
96
- margin: 4px 0 0;
97
- letter-spacing: 0.5px;
98
- text-transform: uppercase;
99
- }
100
-
101
- /* ── Pill badges ── */
102
- .pill {
103
- display: inline-flex;
104
- align-items: center;
105
- gap: 6px;
106
- background: rgba(245,158,11,0.12);
107
- border: 1px solid rgba(245,158,11,0.3);
108
- color: var(--amber);
109
- padding: 4px 12px;
110
- border-radius: 100px;
111
- font-size: 11px;
112
- font-weight: 600;
113
- letter-spacing: 0.8px;
114
- text-transform: uppercase;
115
- }
116
- .pill-green {
117
- background: rgba(34,197,94,0.12);
118
- border-color: rgba(34,197,94,0.3);
119
- color: var(--success);
120
- }
121
- .pill-blue {
122
- background: rgba(56,189,248,0.12);
123
- border-color: rgba(56,189,248,0.3);
124
- color: var(--info);
125
- }
126
-
127
- /* ── Panel cards ── */
128
- .pw-card {
129
- background: var(--panel);
130
- border: 1px solid var(--border);
131
- border-radius: 12px;
132
- padding: 20px;
133
- margin-bottom: 12px;
134
- }
135
- .pw-card-title {
136
- font-family: 'Syne', sans-serif;
137
- font-size: 13px;
138
- font-weight: 700;
139
- color: var(--amber);
140
- letter-spacing: 1px;
141
- text-transform: uppercase;
142
- margin-bottom: 14px;
143
- }
144
-
145
- /* ── Conversation bubbles ── */
146
- .conversation-box {
147
- background: var(--panel);
148
- border: 1px solid var(--border);
149
- border-radius: 12px;
150
- padding: 16px;
151
- height: 360px;
152
- overflow-y: auto;
153
- font-family: 'Space Grotesk', sans-serif;
154
- scroll-behavior: smooth;
155
- }
156
- .bubble {
157
- max-width: 85%;
158
- padding: 10px 14px;
159
- border-radius: 14px;
160
- margin-bottom: 10px;
161
- line-height: 1.5;
162
- font-size: 14px;
163
- }
164
- .bubble-user {
165
- background: rgba(245,158,11,0.15);
166
- border: 1px solid rgba(245,158,11,0.25);
167
- margin-left: auto;
168
- border-bottom-right-radius: 4px;
169
- }
170
- .bubble-agent {
171
- background: rgba(255,255,255,0.05);
172
- border: 1px solid var(--border);
173
- border-bottom-left-radius: 4px;
174
- }
175
- .bubble-label {
176
- font-size: 10px;
177
- font-weight: 600;
178
- letter-spacing: 0.8px;
179
- text-transform: uppercase;
180
- opacity: 0.6;
181
- margin-bottom: 4px;
182
- }
183
- .bubble-hausa {
184
- font-size: 12px;
185
- color: var(--amber);
186
- margin-top: 4px;
187
- font-style: italic;
188
- }
189
- .bubble-time {
190
- font-size: 10px;
191
- color: var(--muted);
192
- margin-top: 3px;
193
- font-family: 'JetBrains Mono', monospace;
194
- }
195
-
196
- /* ── Pipeline status ── */
197
- .pipeline-step {
198
- display: flex;
199
- align-items: center;
200
- gap: 10px;
201
- padding: 8px 0;
202
- border-bottom: 1px solid var(--border);
203
- font-size: 13px;
204
- }
205
- .pipeline-step:last-child { border-bottom: none; }
206
- .step-icon {
207
- width: 28px; height: 28px;
208
- border-radius: 8px;
209
- display: flex;
210
- align-items: center;
211
- justify-content: center;
212
- font-size: 14px;
213
- flex-shrink: 0;
214
- }
215
- .step-active { background: rgba(245,158,11,0.2); }
216
- .step-done { background: rgba(34,197,94,0.2); }
217
- .step-idle { background: rgba(255,255,255,0.05); }
218
-
219
- /* ── Gradio overrides ── */
220
- .gr-button-primary {
221
- background: var(--amber) !important;
222
- color: var(--dark) !important;
223
- font-weight: 700 !important;
224
- border: none !important;
225
- font-family: 'Space Grotesk', sans-serif !important;
226
- }
227
- .gr-button-secondary {
228
- background: var(--panel) !important;
229
- color: var(--text) !important;
230
- border: 1px solid var(--border) !important;
231
- }
232
- label, .gr-form > div > label {
233
- color: var(--muted) !important;
234
- font-size: 12px !important;
235
- font-weight: 500 !important;
236
- letter-spacing: 0.5px !important;
237
- text-transform: uppercase !important;
238
- }
239
- .gr-box, .gr-input, textarea, .gr-text-input {
240
- background: var(--panel) !important;
241
- border-color: var(--border) !important;
242
- color: var(--text) !important;
243
- border-radius: 8px !important;
244
- font-family: 'Space Grotesk', sans-serif !important;
245
- }
246
- .tabitem { background: var(--dark) !important; }
247
- .tab-nav button {
248
- background: transparent !important;
249
- color: var(--muted) !important;
250
- border-bottom: 2px solid transparent !important;
251
- font-family: 'Space Grotesk', sans-serif !important;
252
- font-weight: 600 !important;
253
- }
254
- .tab-nav button.selected {
255
- color: var(--amber) !important;
256
- border-bottom-color: var(--amber) !important;
257
- }
258
- footer { display: none !important; }
259
- """
260
 
261
- # ── Architecture diagram HTML ─────────────────────────────────────────────────
262
- ARCH_HTML = """
263
- <div style="font-family:'Space Grotesk',sans-serif; color:#E7E5E4; padding:16px;">
264
- <div style="font-family:'Syne',sans-serif; font-size:13px; font-weight:700;
265
- color:#F59E0B; letter-spacing:1px; text-transform:uppercase;
266
- margin-bottom:20px;">System Architecture</div>
267
-
268
- <div style="display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:20px;">
269
-
270
- <!-- Input channels -->
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;">MICROPHONE</div>
275
- <div style="font-size:10px; color:#22C55E;">Gradio</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;">WHATSAPP</div>
281
- <div style="font-size:10px; color:#22C55E;">Cloud API</div>
282
- </div>
283
- <div style="background:#1C1917;border:1px solid #292524;border-radius:10px;
284
- padding:12px 16px; min-width:100px; text-align:center;">
285
- <div style="font-size:20px;">📞</div>
286
- <div style="font-size:11px; color:#78716C; margin-top:4px;">PHONE/SIP</div>
287
- <div style="font-size:10px; color:#22C55E;">Twilio</div>
288
- </div>
289
-
290
- <div style="color:#F59E0B; font-size:20px; margin:0 4px;">→</div>
291
-
292
- <!-- Pipeline -->
293
- <div style="display:flex; flex-direction:column; gap:8px;">
294
- <div style="background:rgba(220,38,38,0.1);border:1px solid rgba(220,38,38,0.3);
295
- border-radius:8px; padding:10px 20px; text-align:center;">
296
- <div style="font-size:11px; font-weight:700; color:#F87171;">VAD ENDPOINTING</div>
297
- <div style="font-size:10px; color:#78716C;">Silero VAD · preroll · hangover · barge-in</div>
298
- </div>
299
- <div style="background:rgba(245,158,11,0.1);border:1px solid rgba(245,158,11,0.3);
300
- border-radius:8px; padding:10px 20px; text-align:center;">
301
- <div style="font-size:11px; font-weight:700; color:#F59E0B;">WHISPER (DUAL)</div>
302
- <div style="font-size:10px; color:#78716C;">small → partials · large-v3 → final</div>
303
- </div>
304
- <div style="background:rgba(56,189,248,0.1);border:1px solid rgba(56,189,248,0.3);
305
- border-radius:8px; padding:10px 20px; text-align:center;">
306
- <div style="font-size:11px; font-weight:700; color:#38BDF8;">NLLB-200 (600M)</div>
307
- <div style="font-size:10px; color:#78716C;">hau_Latn ↔ eng_Latn</div>
308
- </div>
309
- <div style="background:rgba(168,85,247,0.1);border:1px solid rgba(168,85,247,0.3);
310
- border-radius:8px; padding:10px 20px; text-align:center;">
311
- <div style="font-size:11px; font-weight:700; color:#A855F7;">DIALOGUE MANAGER</div>
312
- <div style="font-size:10px; color:#78716C;">FSM + Intent · Multi-turn</div>
313
- </div>
314
- <div style="background:rgba(34,197,94,0.1);border:1px solid rgba(34,197,94,0.3);
315
- border-radius:8px; padding:10px 20px; text-align:center;">
316
- <div style="font-size:11px; font-weight:700; color:#22C55E;">MMS-TTS (HAU)</div>
317
- <div style="font-size:10px; color:#78716C;">facebook/mms-tts-hau · VITS</div>
318
- </div>
319
- </div>
320
-
321
- <div style="color:#F59E0B; font-size:20px; margin:0 4px;">→</div>
322
-
323
- <!-- Integrations -->
324
- <div style="display:flex; flex-direction:column; gap:8px;">
325
- <div style="background:#1C1917;border:1px solid #292524;border-radius:10px;
326
- padding:12px 16px; min-width:110px; text-align:center;">
327
- <div style="font-size:20px;">🗂️</div>
328
- <div style="font-size:11px; color:#78716C; margin-top:4px;">CRM / ZENDESK</div>
329
- <div style="font-size:10px; color:#F59E0B;">Auto-tickets</div>
330
- </div>
331
- <div style="background:#1C1917;border:1px solid #292524;border-radius:10px;
332
- padding:12px 16px; min-width:110px; text-align:center;">
333
- <div style="font-size:20px;">👤</div>
334
- <div style="font-size:11px; color:#78716C; margin-top:4px;">HUMAN AGENT</div>
335
- <div style="font-size:10px; color:#DC2626;">Fallback</div>
336
- </div>
337
- </div>
338
- </div>
339
-
340
- <div style="display:flex; gap:20px; flex-wrap:wrap; margin-top:16px; padding-top:16px;
341
- border-top:1px solid #292524;">
342
- <div><span style="color:#F59E0B; font-weight:700;">Latency target:</span>
343
- <span style="color:#78716C;"> ASR&lt;2s · MT&lt;0.5s · TTS&lt;1s · Total&lt;4s</span></div>
344
- <div><span style="color:#F59E0B; font-weight:700;">Languages:</span>
345
- <span style="color:#78716C;"> Hausa (primary) · English pivot · French (roadmap)</span></div>
346
- <div><span style="color:#F59E0B; font-weight:700;">Deployment:</span>
347
- <span style="color:#78716C;"> HF Spaces (POC) → Docker / K8s (prod)</span></div>
348
- </div>
349
- </div>
350
- """
351
 
352
- # ── State helpers ─────────────────────────────────────────────────────────────
 
 
353
 
354
- def _init_state():
355
- return {
356
- "conv": dm.new_session(),
357
- "history": [], # [{role, hausa, english, time}]
358
- "sasr": None, # StreamingASR — created on first streamed chunk
359
- "partial": "", # live (unconfirmed) transcript
360
- "metrics": [], # per-turn latency records
361
- }
362
 
363
- def _render_conversation(history: list) -> str:
364
  if not history:
365
- return (
366
- '<div style="color:#78716C; text-align:center; margin-top:60px; '
367
- 'font-size:13px;">🎤 Speak or type in Hausa to begin …</div>'
368
- )
369
- html = ""
370
- for msg in history:
371
- role = msg["role"]
372
- is_user = role == "user"
373
- label = "YOU" if is_user else "AGENT"
374
- cls = "bubble-user" if is_user else "bubble-agent"
375
- en = msg.get("english", "")
376
- ha = msg.get("hausa", "")
377
- t = msg.get("time", "")
378
- primary = ha if ha else en
379
- secondary = en if ha else ""
380
-
381
- html += f"""
382
- <div style="display:flex; flex-direction:column;
383
- align-items:{'flex-end' if is_user else 'flex-start'}; margin-bottom:12px;">
384
- <div class="bubble {cls}">
385
- <div class="bubble-label">{label}</div>
386
- <div>{primary}</div>
387
- {'<div class="bubble-hausa">EN: ' + secondary + '</div>' if secondary else ''}
388
- <div class="bubble-time">{t}</div>
389
- </div>
390
- </div>"""
391
- return html
392
-
393
-
394
- # ── Core processing function ──────────────────────────────────────────────────
395
-
396
- def process_voice(audio, text_input, state):
397
- """
398
- Entry: either audio or text (fallback for demo without mic).
399
- Returns: (audio_out, conversation_html, status_text, updated_state)
400
- """
401
- if state is None:
402
- state = _init_state()
403
-
404
- hausa_text = ""
405
- asr_status = "—"
406
-
407
- # 1. ASR
408
- if audio is not None:
409
- sample_rate, audio_array = audio
410
- audio_array = audio_array.astype(np.float32) / 32768.0
411
- if audio_array.ndim > 1:
412
- audio_array = audio_array.mean(axis=1)
413
- hausa_text = ai_pipeline.audio_to_hausa_text(audio_array, sample_rate)
414
- asr_status = f"✓ {hausa_text[:60]}…" if len(hausa_text) > 60 else f"✓ {hausa_text}"
415
- elif text_input and text_input.strip():
416
- hausa_text = text_input.strip()
417
- asr_status = "(text input)"
418
- else:
419
- return None, _render_conversation(state["history"]), "⚠ No input provided", state
420
-
421
- audio_out, status = _handle_turn(hausa_text, state)
422
- return audio_out, _render_conversation(state["history"]), status, state
423
-
424
-
425
- # ── Shared turn handler (used by both push-to-talk and streaming) ────────────
426
-
427
- def _handle_turn(hausa_text: str, state: dict, asr_ms: float = 0.0):
428
- """
429
- Runs MT → dialogue → MT → TTS for one finalized Hausa utterance.
430
- Mutates state['history'] and state['conv']. Returns (audio_out, status).
431
- """
432
- import time as _time
433
- t_mt0 = _time.perf_counter()
434
- english_text = ai_pipeline.hausa_to_english(hausa_text)
435
- mt_in_ms = (_time.perf_counter() - t_mt0) * 1000
436
-
437
- t_nlu0 = _time.perf_counter()
438
- english_response, conv_state, escalate = dm.respond(
439
- english_text, hausa_text, state["conv"]
440
- )
441
- state["conv"] = conv_state
442
- nlu_ms = (_time.perf_counter() - t_nlu0) * 1000
443
-
444
- t_mt1 = _time.perf_counter()
445
- hausa_response = ai_pipeline.english_to_hausa(english_response)
446
- mt_out_ms = (_time.perf_counter() - t_mt1) * 1000
447
-
448
- t_tts = _time.perf_counter()
449
- sr, audio_out = ai_pipeline.hausa_text_to_audio(hausa_response)
450
- tts_ms = (_time.perf_counter() - t_tts) * 1000
451
 
452
  now = datetime.now().strftime("%H:%M:%S")
453
- state["history"].append({"role": "user", "hausa": hausa_text,
454
- "english": english_text, "time": now})
455
- state["history"].append({"role": "agent", "hausa": hausa_response,
456
- "english": english_response, "time": now})
457
-
458
- total = asr_ms + mt_in_ms + nlu_ms + mt_out_ms + tts_ms
459
- state["metrics"].append({
460
- "asr": asr_ms, "mt_in": mt_in_ms, "nlu": nlu_ms,
461
- "mt_out": mt_out_ms, "tts": tts_ms, "total": total,
462
- })
463
-
464
- open_tasks = [t for t in conv_state.tasks
465
- if t.status in ("pending", "collecting", "confirming")]
466
- status = (f"Turn {conv_state.turn} · "
467
- f"queue: {len(open_tasks)} open / "
468
- f"{sum(1 for t in conv_state.tasks if t.status == 'done')} done")
469
- if conv_state.active_task:
470
- status += f" · active: {conv_state.active_task.intent}"
471
- status += (f" | ASR {asr_ms:.0f}ms · MT {mt_in_ms + mt_out_ms:.0f}ms · "
472
- f"NLU {nlu_ms:.0f}ms · TTS {tts_ms:.0f}ms · "
473
- f"total {total:.0f}ms")
474
- if escalate:
475
- status += " ⚠ ESCALATED TO HUMAN"
476
-
477
- return (sr, audio_out), status
478
-
479
-
480
- # ── Streaming handler: VAD endpointing, no button press ──────────────────────
481
-
482
- def process_stream(stream_chunk, state):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
483
  """
484
- Bound to gr.Audio(streaming=True).stream fires every ~0.3s with a new
485
- chunk of mic audio. The VAD decides when a turn has ended; no push-to-talk.
486
 
487
- Returns: (audio_out, conversation_html, status, live_partial, state)
 
488
  """
489
  if state is None:
490
- state = _init_state()
491
- if stream_chunk is None:
492
- return (None, _render_conversation(state["history"]),
493
- "Listening …", state.get("partial", ""), state)
494
 
495
  if state["sasr"] is None:
496
  state["sasr"] = ai_pipeline.make_streaming_session(
497
  emit_partials=SHOW_PARTIALS)
498
 
499
- sr, chunk = stream_chunk
500
- events = state["sasr"].accept_audio(chunk, sr)
 
 
 
 
501
 
502
- audio_out = None
503
- status = None
504
 
505
  for ev in events:
506
  if ev.kind == "speech_start":
507
- status = "🎙 Listening — speech detected"
508
-
509
  elif ev.kind == "partial":
510
  state["partial"] = ev.text
511
- status = f"🎙 ({ev.duration_ms/1000:.1f}s)"
512
-
513
  elif ev.kind == "bargein":
514
- # Caller interrupted the agent: stop playback immediately
515
  audio_out = None
516
- status = "✋ Barge-in — you interrupted, go ahead."
517
-
518
  elif ev.kind == "discarded":
519
  state["partial"] = ""
520
- status = "Listening …"
521
-
522
  elif ev.kind == "final":
523
  state["partial"] = ""
524
- state["sasr"].agent_speaking = True # arm barge-in for playback
525
- audio_out, status = _handle_turn(ev.text, state,
526
- asr_ms=ev.latency_ms)
527
 
528
- return (audio_out,
529
- _render_conversation(state["history"]),
530
- status or "Listening …",
531
- state.get("partial", ""),
532
- state)
533
 
534
 
535
- def reset_session(state):
536
- state = _init_state()
537
- return None, _render_conversation([]), "New session started.", "", state
538
 
539
 
540
- def use_demo_prompt(prompt_ha, state):
541
- """Inject a demo Hausa phrase as text input."""
542
- return prompt_ha, state
543
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
544
 
545
- # ── Build the Gradio app ──────────────────────────────────────────────────────
546
-
547
- with gr.Blocks(css=CSS, title="PlotWeaver · Hausa Voice AI") as demo:
548
-
549
- # Shared state
550
- app_state = gr.State(None)
551
-
552
- # ── Header ─────────────────────────────────────────────────────────────────
553
- gr.HTML("""
554
- <div class="pw-header">
555
- <p class="pw-logo">Plot<span>Weaver</span></p>
556
- <p class="pw-tagline">Hausa Voice AI Agent · Investor Demo · v0.1-poc</p>
557
- <div style="display:flex; gap:8px; margin-top:14px; flex-wrap:wrap;">
558
- <span class="pill">🎤 Whisper v3</span>
559
- <span class="pill">🌐 NLLB-200</span>
560
- <span class="pill">🔊 MMS-TTS</span>
561
- <span class="pill-green">⚡ Real-time</span>
562
- <span class="pill-blue">🏢 Enterprise-ready</span>
563
  </div>
564
- </div>
565
- """)
566
 
567
  with gr.Tabs():
 
 
 
 
 
 
568
 
569
- # ── Tab 1 : Live Demo ───────────────────────────────────────────────
570
- with gr.TabItem("🎙️ Live Demo"):
571
  with gr.Row():
572
- # Left column
 
 
 
 
 
 
 
 
 
573
  with gr.Column(scale=2):
574
- gr.HTML('<div class="pw-card-title" '
575
- 'style="margin-bottom:10px;">Conversation</div>')
576
- conversation_display = gr.HTML(
577
- _render_conversation([]),
578
- elem_classes=["conversation-box"]
579
- )
580
- partial_box = gr.Textbox(
581
- label="Live transcript (unconfirmed)",
582
- value="",
583
- interactive=False,
584
- lines=1,
585
- )
586
- status_box = gr.Textbox(
587
- label="Pipeline Status",
588
- value="Ready. Speak or type in Hausa.",
589
- interactive=False,
590
- lines=1,
591
- )
592
-
593
- # Right column
594
- with gr.Column(scale=1):
595
- gr.HTML('<div class="pw-card-title" '
596
- 'style="margin-bottom:10px;">Input</div>')
597
-
598
- # ── Streaming mic: VAD endpoints the turn, no button ──
599
- stream_in = gr.Audio(
600
- sources=["microphone"],
601
- type="numpy",
602
- label="🔴 Live Mic — just talk, VAD ends your turn",
603
- streaming=True,
604
- )
605
-
606
- with gr.Accordion("Push-to-talk (fallback)", open=False):
607
- audio_in = gr.Audio(
608
- sources=["microphone", "upload"],
609
- type="numpy",
610
- label="Record or upload, then press Send",
611
- streaming=False,
612
- )
613
-
614
- text_in = gr.Textbox(
615
- label="Text fallback (Hausa)",
616
- placeholder="Sannu, ina son sanin asusun kuɗin…",
617
- lines=2,
618
- )
619
-
620
- with gr.Row():
621
- submit_btn = gr.Button("▶ Send", variant="primary")
622
- reset_btn = gr.Button("↺ Reset", variant="secondary")
623
-
624
- audio_out = gr.Audio(
625
- label="Agent Response (Hausa audio)",
626
- autoplay=True,
627
- )
628
-
629
- # Demo quick prompts
630
- gr.HTML('<div class="pw-card-title" '
631
- 'style="margin-top:16px; margin-bottom:10px;">'
632
- 'Quick Demo Prompts</div>')
633
- for label_en, phrase_ha in DEMO_PROMPTS:
634
- btn = gr.Button(f"{label_en} → {phrase_ha}",
635
- variant="secondary", size="sm")
636
- btn.click(
637
- fn=lambda p=phrase_ha, s=None: (p, s),
638
- inputs=[app_state],
639
- outputs=[text_in, app_state],
640
- )
641
-
642
- # ── Events ─────────────────────────────────────────────────────
643
- # Streaming: fires continuously; VAD decides when a turn ends.
644
- stream_in.stream(
645
- fn=process_stream,
646
- inputs=[stream_in, app_state],
647
- outputs=[audio_out, conversation_display, status_box,
648
- partial_box, app_state],
649
- stream_every=0.3,
650
- show_progress="hidden",
651
- )
652
- # Flush any in-progress utterance when the mic stops.
653
- stream_in.stop_recording(
654
- fn=lambda s: reset_session(s)[1:],
655
- inputs=[app_state],
656
- outputs=[conversation_display, status_box, partial_box,
657
- app_state],
658
- )
659
-
660
- submit_btn.click(
661
- fn=process_voice,
662
- inputs=[audio_in, text_in, app_state],
663
- outputs=[audio_out, conversation_display, status_box, app_state],
664
- )
665
- reset_btn.click(
666
- fn=reset_session,
667
- inputs=[app_state],
668
- outputs=[audio_out, conversation_display, status_box,
669
- partial_box, app_state],
670
- )
671
-
672
- # ── Tab 2 : Architecture ────────────────────────────────────────────
673
- with gr.TabItem("🏗️ Architecture"):
674
- gr.HTML(ARCH_HTML)
675
-
676
- # ── Tab 3 : Integrations ────────────────────────────────────────────
677
- with gr.TabItem("🔌 Integrations"):
678
- gr.HTML("""
679
- <div style="font-family:'Space Grotesk',sans-serif; color:#E7E5E4; padding:16px;">
680
- <div style="font-family:'Syne',sans-serif; font-size:13px; font-weight:700;
681
- color:#F59E0B; letter-spacing:1px; text-transform:uppercase;
682
- margin-bottom:20px;">Enterprise Integration Matrix</div>
683
-
684
- <div style="display:grid; grid-template-columns:repeat(auto-fill,minmax(220px,1fr)); gap:16px;">
685
-
686
- <div style="background:#1C1917;border:1px solid #292524;border-radius:12px;padding:18px;">
687
- <div style="font-size:24px; margin-bottom:8px;">💬</div>
688
- <div style="font-weight:700; margin-bottom:4px;">WhatsApp Business</div>
689
- <div style="font-size:12px; color:#78716C; margin-bottom:10px;">
690
- Meta Cloud API v18+. Inbound voice notes → ASR pipeline.
691
- Quick-reply buttons. Media download.
692
- </div>
693
- <span style="font-size:10px; background:rgba(34,197,94,0.12);
694
- border:1px solid rgba(34,197,94,0.3); color:#22C55E;
695
- padding:2px 8px; border-radius:100px;">READY</span>
696
- </div>
697
-
698
- <div style="background:#1C1917;border:1px solid #292524;border-radius:12px;padding:18px;">
699
- <div style="font-size:24px; margin-bottom:8px;">📞</div>
700
- <div style="font-weight:700; margin-bottom:4px;">Twilio / SIP</div>
701
- <div style="font-size:12px; color:#78716C; margin-bottom:10px;">
702
- Twilio Media Streams WebSocket. Inbound + outbound IVR.
703
- Warm transfer to human agent. Bandwidth BXML also supported.
704
- </div>
705
- <span style="font-size:10px; background:rgba(34,197,94,0.12);
706
- border:1px solid rgba(34,197,94,0.3); color:#22C55E;
707
- padding:2px 8px; border-radius:100px;">READY</span>
708
- </div>
709
-
710
- <div style="background:#1C1917;border:1px solid #292524;border-radius:12px;padding:18px;">
711
- <div style="font-size:24px; margin-bottom:8px;">🗂️</div>
712
- <div style="font-weight:700; margin-bottom:4px;">CRM / Ticketing</div>
713
- <div style="font-size:12px; color:#78716C; margin-bottom:10px;">
714
- Zendesk native adapter. Generic REST adapter for Freshdesk,
715
- HubSpot, Salesforce. Auto-ticket on issue report.
716
- </div>
717
- <span style="font-size:10px; background:rgba(34,197,94,0.12);
718
- border:1px solid rgba(34,197,94,0.3); color:#22C55E;
719
- padding:2px 8px; border-radius:100px;">READY</span>
720
- </div>
721
-
722
- <div style="background:#1C1917;border:1px solid #292524;border-radius:12px;padding:18px;">
723
- <div style="font-size:24px; margin-bottom:8px;">👤</div>
724
- <div style="font-weight:700; margin-bottom:4px;">Human Fallback</div>
725
- <div style="font-size:12px; color:#78716C; margin-bottom:10px;">
726
- Threshold-based escalation: low confidence, explicit request,
727
- or max-turns. SIP REFER transfer + CRM context handoff.
728
- </div>
729
- <span style="font-size:10px; background:rgba(34,197,94,0.12);
730
- border:1px solid rgba(34,197,94,0.3); color:#22C55E;
731
- padding:2px 8px; border-radius:100px;">READY</span>
732
- </div>
733
-
734
- <div style="background:#1C1917;border:1px solid #292524;border-radius:12px;padding:18px;">
735
- <div style="font-size:24px; margin-bottom:8px;">🌍</div>
736
- <div style="font-weight:700; margin-bottom:4px;">More Languages</div>
737
- <div style="font-size:12px; color:#78716C; margin-bottom:10px;">
738
- Yorùbá, Igbo, Fulfulde, Kanuri roadmap.
739
- NLLB covers 200 languages. MMS covers 1,000+ TTS languages.
740
- </div>
741
- <span style="font-size:10px; background:rgba(245,158,11,0.12);
742
- border:1px solid rgba(245,158,11,0.3); color:#F59E0B;
743
- padding:2px 8px; border-radius:100px;">ROADMAP</span>
744
- </div>
745
-
746
- <div style="background:#1C1917;border:1px solid #292524;border-radius:12px;padding:18px;">
747
- <div style="font-size:24px; margin-bottom:8px;">⚡</div>
748
- <div style="font-weight:700; margin-bottom:4px;">Fine-tuned Models</div>
749
- <div style="font-size:12px; color:#78716C; margin-bottom:10px;">
750
- Custom Whisper fine-tune for Hausa dialectal variance.
751
- OuteTTS / MMS fine-tune on domain vocabulary. NLLB domain adaptation.
752
- </div>
753
- <span style="font-size:10px; background:rgba(245,158,11,0.12);
754
- border:1px solid rgba(245,158,11,0.3); color:#F59E0B;
755
- padding:2px 8px; border-radius:100px;">IN PROGRESS</span>
756
- </div>
757
-
758
- </div>
759
-
760
- <div style="margin-top:24px; padding:16px; background:rgba(245,158,11,0.05);
761
- border:1px solid rgba(245,158,11,0.2); border-radius:10px;">
762
- <div style="font-size:12px; font-weight:700; color:#F59E0B;
763
- margin-bottom:8px; letter-spacing:0.5px;">⚙ CONFIGURATION</div>
764
- <div style="font-family:'JetBrains Mono',monospace; font-size:11px;
765
- color:#78716C; line-height:2;">
766
- WHATSAPP_TOKEN=&lt;meta-token&gt; &nbsp; WHATSAPP_PHONE_ID=&lt;phone-id&gt;<br>
767
- TWILIO_ACCOUNT_SID=ACxxxx &nbsp; TWILIO_AUTH_TOKEN=xxxx<br>
768
- CRM_PROVIDER=zendesk &nbsp; ZENDESK_SUBDOMAIN=yourco &nbsp; ZENDESK_API_TOKEN=xxxx<br>
769
- SIP_PROVIDER=twilio &nbsp; (or bandwidth)
770
- </div>
771
- </div>
772
- </div>
773
  """)
774
 
775
- # ── Tab 4 : Business Case ────────────────────────────────────────────
776
- with gr.TabItem("📊 Market"):
777
- gr.HTML("""
778
- <div style="font-family:'Space Grotesk',sans-serif; color:#E7E5E4; padding:16px;">
779
- <div style="font-family:'Syne',sans-serif; font-size:13px; font-weight:700;
780
- color:#F59E0B; letter-spacing:1px; text-transform:uppercase;
781
- margin-bottom:20px;">Why Hausa Voice AI · Now</div>
782
-
783
- <div style="display:grid; grid-template-columns:repeat(auto-fill,minmax(180px,1fr));
784
- gap:16px; margin-bottom:24px;">
785
- <div style="background:#1C1917;border:1px solid #292524;border-radius:12px;
786
- padding:18px; text-align:center;">
787
- <div style="font-family:'Syne',sans-serif; font-size:32px; font-weight:800;
788
- color:#F59E0B;">100M+</div>
789
- <div style="font-size:12px; color:#78716C; margin-top:4px;">Hausa speakers</div>
790
- <div style="font-size:11px; color:#22C55E;">#1 language in West Africa</div>
791
- </div>
792
- <div style="background:#1C1917;border:1px solid #292524;border-radius:12px;
793
- padding:18px; text-align:center;">
794
- <div style="font-family:'Syne',sans-serif; font-size:32px; font-weight:800;
795
- color:#F59E0B;">63%</div>
796
- <div style="font-size:12px; color:#78716C; margin-top:4px;">Low literacy rate</div>
797
- <div style="font-size:11px; color:#38BDF8;">Voice is the UX</div>
798
- </div>
799
- <div style="background:#1C1917;border:1px solid #292524;border-radius:12px;
800
- padding:18px; text-align:center;">
801
- <div style="font-family:'Syne',sans-serif; font-size:32px; font-weight:800;
802
- color:#F59E0B;">$4.2B</div>
803
- <div style="font-size:12px; color:#78716C; margin-top:4px;">Africa call-centre spend</div>
804
- <div style="font-size:11px; color:#F59E0B;">2027 projection</div>
805
- </div>
806
- <div style="background:#1C1917;border:1px solid #292524;border-radius:12px;
807
- padding:18px; text-align:center;">
808
- <div style="font-family:'Syne',sans-serif; font-size:32px; font-weight:800;
809
- color:#F59E0B;">0</div>
810
- <div style="font-size:12px; color:#78716C; margin-top:4px;">Production Hausa VoiceBots</div>
811
- <div style="font-size:11px; color:#DC2626;">Whitespace opportunity</div>
812
- </div>
813
- </div>
814
-
815
- <div style="display:grid; grid-template-columns:1fr 1fr; gap:16px;">
816
- <div style="background:#1C1917;border:1px solid #292524;border-radius:12px;padding:18px;">
817
- <div style="font-weight:700; color:#F59E0B; margin-bottom:12px;">🎯 Target Verticals</div>
818
- <div style="font-size:13px; line-height:2; color:#A8A29E;">
819
- 📱 Telecoms (MTN, Airtel Nigeria, Glo)<br>
820
- 🏦 Fintech / Mobile money (Kuda, PalmPay)<br>
821
- 🏥 Health (NHIS, telemedicine IVR)<br>
822
- 🏛 Government services (NIMC, NIN)<br>
823
- 🛒 E-commerce (Jumia, Konga)
824
- </div>
825
- </div>
826
- <div style="background:#1C1917;border:1px solid #292524;border-radius:12px;padding:18px;">
827
- <div style="font-weight:700; color:#F59E0B; margin-bottom:12px;">🏆 Competitive Moat</div>
828
- <div style="font-size:13px; line-height:2; color:#A8A29E;">
829
- ✓ Open-source stack (no API lock-in)<br>
830
- ✓ Fine-tuned Hausa models (PlotWeaver IP)<br>
831
- ✓ On-premise deployable (data sovereignty)<br>
832
- ✓ Multi-channel from day one<br>
833
- ✓ Academic NLP + production engineering
834
- </div>
835
- </div>
836
- </div>
837
- </div>
838
  """)
839
 
840
- # ── Footer ────────────────────────────────────────────────────────────────
841
- gr.HTML("""
842
- <div style="text-align:center; padding:20px; color:#44403C; font-size:11px;
843
- border-top:1px solid #1C1917; margin-top:8px;">
844
- PlotWeaver · Hausa Voice AI Agent POC &nbsp;|&nbsp;
845
- Whisper large-v3 · NLLB-200 · MMS-TTS-hau &nbsp;|&nbsp;
846
- <a href="https://plotweaver.ai" style="color:#F59E0B; text-decoration:none;">
847
- plotweaver.ai</a>
848
- </div>
849
- """)
850
 
851
 
852
  if __name__ == "__main__":
853
- demo.launch(share=False)
 
 
 
 
 
1
  """
2
+ PlotWeaver Hausa Voice AI Agent — HuggingFace Spaces
3
+ ======================================================
4
+ Verified against gradio 5.49.1. Key constraints this file respects:
5
+
6
+ * Models load LAZILY. The Space must show UI within seconds, not after a
7
+ 3.5GB download. Nothing heavy is imported or loaded at module scope.
8
+ * CPU-SAFE DEFAULTS. whisper-large-v3 needs ~6GB and takes ~60s per utterance
9
+ on the 2-vCPU free tier — unusable. Default is whisper-small; large-v3 is
10
+ one env var away when you attach a GPU.
11
+ * gr.skip() on stream ticks that produce no audio, otherwise the player
12
+ restarts on every 0.5s tick and the agent's reply stutters.
13
+ * Streaming partials OFF by default on CPU: an extra decode every 900ms
14
+ saturates the box and makes the demo feel worse, not better.
15
  """
16
 
 
 
17
  import os
18
+ import sys
19
+ import logging
20
  from datetime import datetime
21
 
22
+ import gradio as gr
23
+ import numpy as np
24
+
25
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
26
+
27
+ logging.basicConfig(level=logging.INFO)
28
+ logger = logging.getLogger("app")
29
+
30
  from pipeline import HausaVoiceAIPipeline
31
  from nlu import NLU
32
  from orchestrator import Orchestrator
 
33
 
34
+ # ── Optional integrations (never block startup) ──────────────────────────────
35
+ try:
36
+ from integrations.crm import CRMClient
37
+ _crm = CRMClient()
38
+ INTEGRATIONS_OK = True
39
+ except Exception as e:
40
+ logger.warning(f"Integrations unavailable ({e}) — demo mode.")
41
+ _crm, INTEGRATIONS_OK = None, False
42
 
43
+ # ── Config ───────────────────────────────────────────────────────────────────
44
+ HAS_GPU = os.getenv("SPACES_GPU", "") or os.getenv("CUDA_VISIBLE_DEVICES", "")
45
+ SHOW_PARTIALS = os.getenv("SHOW_PARTIALS", "0") == "1" # off by default on CPU
46
+ STREAM_EVERY = float(os.getenv("STREAM_EVERY", "0.5"))
47
+
48
+ ai_pipeline = HausaVoiceAIPipeline()
49
+ dm = Orchestrator(crm=_crm, nlu=NLU())
50
 
 
51
  DEMO_PROMPTS = [
52
+ ("Compound request (balance + transfer)",
53
+ "Duba asusuna sannan ka aika 35000 zuwa Amina"),
54
+ ("Entity prefill recipient named",
55
+ "Ina son aika kuɗi zuwa Abu"),
56
+ ("Branch info, NOT block-card",
57
  "Ina ne reshenku mafi kusa domin in karɓi katin ATM"),
58
+ ("Report a problem", "Ina da matsala da asusuna"),
59
+ ("Escalate to human", "Ina son magana da mutum"),
60
  ]
61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
 
63
+ # ── State ───────────────────────────────────��────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
 
65
+ def new_state():
66
+ return {"conv": dm.new_session(), "history": [], "sasr": None,
67
+ "partial": "", "warm": False}
68
 
 
 
 
 
 
 
 
 
69
 
70
+ def render(history):
71
  if not history:
72
+ return ('<div class="empty">Press <b>Warm up models</b>, then speak or '
73
+ 'tap a demo prompt.</div>')
74
+ out = []
75
+ for m in history:
76
+ me = m["role"] == "user"
77
+ out.append(
78
+ f'<div class="row {"r-me" if me else "r-ag"}">'
79
+ f'<div class="bub {"b-me" if me else "b-ag"}">'
80
+ f'<div class="lbl">{"YOU" if me else "AGENT"}</div>'
81
+ f'<div class="ha">{m["hausa"]}</div>'
82
+ f'<div class="en">{m["english"]}</div>'
83
+ f'<div class="tm">{m["time"]}</div></div></div>')
84
+ return "".join(out)
85
+
86
+
87
+ # ── Core turn ────────────────────────────────────────────────────────────────
88
+
89
+ def run_turn(hausa_text, state, asr_ms=0.0):
90
+ import time
91
+ t = time.perf_counter()
92
+ english = ai_pipeline.hausa_to_english(hausa_text)
93
+ mt_in = (time.perf_counter() - t) * 1000
94
+
95
+ t = time.perf_counter()
96
+ reply_en, conv, escalated = dm.respond(english, hausa_text, state["conv"])
97
+ state["conv"] = conv
98
+ nlu_ms = (time.perf_counter() - t) * 1000
99
+
100
+ t = time.perf_counter()
101
+ reply_ha = ai_pipeline.english_to_hausa(reply_en)
102
+ mt_out = (time.perf_counter() - t) * 1000
103
+
104
+ t = time.perf_counter()
105
+ sr, wav = ai_pipeline.hausa_text_to_audio(reply_ha)
106
+ tts_ms = (time.perf_counter() - t) * 1000
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
 
108
  now = datetime.now().strftime("%H:%M:%S")
109
+ state["history"] += [
110
+ {"role": "user", "hausa": hausa_text, "english": english, "time": now},
111
+ {"role": "agent", "hausa": reply_ha, "english": reply_en, "time": now},
112
+ ]
113
+
114
+ total = asr_ms + mt_in + nlu_ms + mt_out + tts_ms
115
+ openq = [x for x in conv.tasks
116
+ if x.status in ("pending", "collecting", "confirming")]
117
+ status = (f"turn {conv.turn} · queue {len(openq)} open / "
118
+ f"{sum(1 for x in conv.tasks if x.status=='done')} done"
119
+ f"{' · ' + conv.active_task.intent if conv.active_task else ''}"
120
+ f" | ASR {asr_ms:.0f} · MT {mt_in+mt_out:.0f} · "
121
+ f"NLU {nlu_ms:.0f} · TTS {tts_ms:.0f} · total {total:.0f} ms")
122
+ if escalated:
123
+ status += " ESCALATED"
124
+ return (sr, wav), status
125
+
126
+
127
+ # ── Handlers ─────────────────────────────────────────────────────────────────
128
+
129
+ def warm_up(state, progress=gr.Progress()):
130
+ """Download + load models with visible progress, so the first real
131
+ utterance isn't a 3-minute silence."""
132
+ if state is None:
133
+ state = new_state()
134
+ try:
135
+ progress(0.05, desc="Loading ASR …")
136
+ ai_pipeline._load_asr()
137
+ progress(0.55, desc="Loading translation …")
138
+ ai_pipeline._load_nllb()
139
+ progress(0.85, desc="Loading speech synthesis …")
140
+ ai_pipeline._load_tts()
141
+ progress(1.0, desc="Ready")
142
+ state["warm"] = True
143
+ gr.Info("Models loaded. You can speak now.")
144
+ return f"Ready · {ai_pipeline.describe_models()}", state
145
+ except Exception as e:
146
+ logger.exception("warm-up failed")
147
+ gr.Warning(f"Model load failed: {e}")
148
+ return f"Model load failed: {e}", state
149
+
150
+
151
+ def on_text(text, state):
152
+ if state is None:
153
+ state = new_state()
154
+ if not text or not text.strip():
155
+ return gr.skip(), render(state["history"]), "Type or say something first.", state
156
+ try:
157
+ audio, status = run_turn(text.strip(), state)
158
+ return audio, render(state["history"]), status, state
159
+ except Exception as e:
160
+ logger.exception("turn failed")
161
+ gr.Warning(str(e))
162
+ return gr.skip(), render(state["history"]), f"Error: {e}", state
163
+
164
+
165
+ def on_record(audio, state):
166
+ """Push-to-talk: one complete recording."""
167
+ if state is None:
168
+ state = new_state()
169
+ if audio is None:
170
+ return gr.skip(), render(state["history"]), "No audio recorded.", state
171
+ sr, arr = audio
172
+ try:
173
+ import time
174
+ t = time.perf_counter()
175
+ hausa = ai_pipeline.transcribe(arr, sr)
176
+ asr_ms = (time.perf_counter() - t) * 1000
177
+ if not hausa.strip():
178
+ return gr.skip(), render(state["history"]), "Didn't catch that.", state
179
+ out, status = run_turn(hausa, state, asr_ms)
180
+ return out, render(state["history"]), status, state
181
+ except Exception as e:
182
+ logger.exception("record failed")
183
+ gr.Warning(str(e))
184
+ return gr.skip(), render(state["history"]), f"Error: {e}", state
185
+
186
+
187
+ def on_stream(chunk, state):
188
  """
189
+ Live mic. Fires every STREAM_EVERY seconds. VAD decides when the turn ends,
190
+ so there is no send button.
191
 
192
+ Returns gr.skip() for the audio output on ticks with no reply, otherwise
193
+ the player restarts on every tick.
194
  """
195
  if state is None:
196
+ state = new_state()
197
+ if chunk is None:
198
+ return gr.skip(), gr.skip(), gr.skip(), state.get("partial", ""), state
 
199
 
200
  if state["sasr"] is None:
201
  state["sasr"] = ai_pipeline.make_streaming_session(
202
  emit_partials=SHOW_PARTIALS)
203
 
204
+ sr, arr = chunk
205
+ try:
206
+ events = state["sasr"].accept_audio(arr, sr)
207
+ except Exception as e:
208
+ logger.exception("stream failed")
209
+ return gr.skip(), gr.skip(), f"Stream error: {e}", "", state
210
 
211
+ audio_out, status, convo = gr.skip(), gr.skip(), gr.skip()
 
212
 
213
  for ev in events:
214
  if ev.kind == "speech_start":
215
+ status = "listening — speech detected"
 
216
  elif ev.kind == "partial":
217
  state["partial"] = ev.text
218
+ status = f"listening … {ev.duration_ms/1000:.1f}s"
 
219
  elif ev.kind == "bargein":
 
220
  audio_out = None
221
+ status = "you interrupted go ahead"
 
222
  elif ev.kind == "discarded":
223
  state["partial"] = ""
 
 
224
  elif ev.kind == "final":
225
  state["partial"] = ""
226
+ state["sasr"].agent_speaking = True
227
+ audio_out, status = run_turn(ev.text, state, ev.latency_ms)
228
+ convo = render(state["history"])
229
 
230
+ return audio_out, convo, status, state.get("partial", ""), state
 
 
 
 
231
 
232
 
233
+ def reset(state):
234
+ return None, render([]), "New session.", "", new_state()
 
235
 
236
 
237
+ # ── UI ───────────────────────────────────────────────────────────────────────
 
 
238
 
239
+ CSS = """
240
+ @import url('https://fonts.googleapis.com/css2?family=Sora:wght@400;600;800&family=IBM+Plex+Mono:wght@400;500&display=swap');
241
+ :root{--bg:#0B0B0F;--pnl:#15151C;--brd:#26262F;--txt:#E8E6E3;--mut:#7A7A88;
242
+ --acc:#FF8A3D;--acc2:#4ADE80;--blu:#60A5FA}
243
+ .gradio-container{background:var(--bg)!important;font-family:'Sora',sans-serif!important;
244
+ color:var(--txt)!important;max-width:1280px!important}
245
+ .hdr{background:linear-gradient(135deg,#15151C,#1E1E28 60%,#15151C);
246
+ border:1px solid var(--brd);border-radius:14px;padding:22px 26px;margin-bottom:14px}
247
+ .hdr h1{font-size:25px;font-weight:800;margin:0;letter-spacing:-.5px}
248
+ .hdr h1 b{color:var(--acc)}
249
+ .hdr p{color:var(--mut);font-size:12.5px;margin:6px 0 0;letter-spacing:.4px}
250
+ .tags{display:flex;gap:7px;margin-top:13px;flex-wrap:wrap}
251
+ .tag{background:rgba(255,138,61,.12);border:1px solid rgba(255,138,61,.3);
252
+ color:var(--acc);padding:3px 11px;border-radius:99px;font-size:10.5px;
253
+ font-weight:600;letter-spacing:.6px}
254
+ .tag.g{background:rgba(74,222,128,.12);border-color:rgba(74,222,128,.3);color:var(--acc2)}
255
+ .tag.b{background:rgba(96,165,250,.12);border-color:rgba(96,165,250,.3);color:var(--blu)}
256
+ .convo{background:var(--pnl);border:1px solid var(--brd);border-radius:13px;
257
+ padding:15px;height:395px;overflow-y:auto}
258
+ .empty{color:var(--mut);text-align:center;margin-top:150px;font-size:13px}
259
+ .row{display:flex;margin-bottom:11px}
260
+ .r-me{justify-content:flex-end}.r-ag{justify-content:flex-start}
261
+ .bub{max-width:86%;padding:10px 13px;border-radius:13px;font-size:13.5px;line-height:1.5}
262
+ .b-me{background:rgba(255,138,61,.14);border:1px solid rgba(255,138,61,.26);
263
+ border-bottom-right-radius:4px}
264
+ .b-ag{background:rgba(255,255,255,.045);border:1px solid var(--brd);
265
+ border-bottom-left-radius:4px}
266
+ .lbl{font-size:9.5px;font-weight:700;letter-spacing:.9px;opacity:.55;margin-bottom:4px}
267
+ .ha{font-weight:600}
268
+ .en{font-size:11.5px;color:var(--mut);margin-top:3px;font-style:italic}
269
+ .tm{font-size:9.5px;color:#4A4A56;margin-top:4px;font-family:'IBM Plex Mono',monospace}
270
+ .note{background:rgba(96,165,250,.07);border:1px solid rgba(96,165,250,.22);
271
+ border-radius:11px;padding:13px 15px;font-size:12.5px;color:#B9C4D4;line-height:1.65}
272
+ .note b{color:var(--blu)}
273
+ footer{display:none!important}
274
+ """
275
 
276
+ with gr.Blocks(css=CSS, title="PlotWeaver · Hausa Voice AI",
277
+ theme=gr.themes.Base()) as demo:
278
+
279
+ st = gr.State(None)
280
+
281
+ gr.HTML(f"""
282
+ <div class="hdr">
283
+ <h1>Plot<b>Weaver</b> Hausa Voice AI Agent</h1>
284
+ <p>Real-time conversational AI for 100M+ Hausa speakers · investor POC</p>
285
+ <div class="tags">
286
+ <span class="tag">Whisper ASR</span>
287
+ <span class="tag">NLLB-200</span>
288
+ <span class="tag">MMS-TTS</span>
289
+ <span class="tag g">VAD endpointing</span>
290
+ <span class="tag g">Multi-intent NLU</span>
291
+ <span class="tag b">{'GPU' if HAS_GPU else 'CPU'} mode</span>
 
 
292
  </div>
293
+ </div>""")
 
294
 
295
  with gr.Tabs():
296
+ with gr.TabItem("Live Demo"):
297
+ gr.HTML('<div class="note"><b>First run:</b> press '
298
+ '<b>Warm up models</b> — roughly 2–4 minutes while ~3.5GB '
299
+ 'downloads and caches. It only happens once per Space '
300
+ 'restart. On CPU, expect 5–15s per reply; a GPU brings '
301
+ 'that under 3s.</div>')
302
 
 
 
303
  with gr.Row():
304
+ with gr.Column(scale=3):
305
+ convo = gr.HTML(render([]), elem_classes=["convo"])
306
+ partial_box = gr.Textbox(label="Live transcript",
307
+ interactive=False, lines=1)
308
+ status = gr.Textbox(
309
+ label="Status",
310
+ value=("Not warmed up." if INTEGRATIONS_OK else
311
+ "Not warmed up · demo mode (no integrations)."),
312
+ interactive=False, lines=2)
313
+
314
  with gr.Column(scale=2):
315
+ warm_btn = gr.Button("Warm up models", variant="primary")
316
+
317
+ reply = gr.Audio(label="Agent reply", autoplay=True,
318
+ interactive=False)
319
+
320
+ with gr.Tab("Type"):
321
+ txt = gr.Textbox(
322
+ label="Hausa text",
323
+ placeholder="Duba asusuna sannan ka aika 35000 zuwa Amina",
324
+ lines=2)
325
+ send = gr.Button("Send", variant="primary")
326
+
327
+ with gr.Tab("Record"):
328
+ rec = gr.Audio(sources=["microphone", "upload"],
329
+ type="numpy", label="Record, then Send")
330
+ send_rec = gr.Button("Send recording", variant="primary")
331
+
332
+ with gr.Tab("Live mic (VAD)"):
333
+ gr.Markdown(
334
+ "Speak naturally — the VAD ends your turn on a "
335
+ "pause. Heavy on CPU; use Type or Record if it lags.")
336
+ live = gr.Audio(sources=["microphone"], type="numpy",
337
+ streaming=True, label="Live")
338
+
339
+ reset_btn = gr.Button("Reset conversation")
340
+
341
+ gr.Markdown("**Demo prompts**")
342
+ for label, phrase in DEMO_PROMPTS:
343
+ gr.Button(label, size="sm").click(
344
+ lambda p=phrase: p, outputs=[txt])
345
+
346
+ warm_btn.click(warm_up, [st], [status, st])
347
+ send.click(on_text, [txt, st], [reply, convo, status, st])
348
+ txt.submit(on_text, [txt, st], [reply, convo, status, st])
349
+ send_rec.click(on_record, [rec, st], [reply, convo, status, st])
350
+ live.stream(on_stream, [live, st],
351
+ [reply, convo, status, partial_box, st],
352
+ stream_every=STREAM_EVERY, show_progress="hidden")
353
+ reset_btn.click(reset, [st],
354
+ [reply, convo, status, partial_box, st])
355
+
356
+ with gr.TabItem("How it works"):
357
+ gr.Markdown("""
358
+ ### Pipeline
359
+
360
+ `mic → VAD endpointing → Whisper (Hausa) → NLLB hau→eng → task-queue
361
+ orchestrator → NLLB eng→hau → MMS-TTS → audio`
362
+
363
+ ### What the dialogue layer does
364
+
365
+ Most voice bots resolve one intent and one missing slot per turn, which breaks
366
+ the moment a caller combines requests or answers out of order. This one
367
+ decomposes each message into a **task queue**:
368
+
369
+ | Caller says | What happens |
370
+ |---|---|
371
+ | "check my balance **and also** send 35000 to Amina" | Both tasks queued and acknowledged; neither is silently dropped |
372
+ | "send money **to Abu**" | Recipient prefilled from the utterance — never re-asked |
373
+ | "where's your branch so I can **get** my ATM card" | Branch info. *Not* block-card — destructive intents need an explicit verb |
374
+ | "too small" (as a return reason) | Accepted as a slot answer, not a fallback dead-end |
375
+ | Transfer above balance | Refused before the confirmation prompt |
376
+ | Two unparseable turns | Human handoff **with the transcript attached** |
377
+
378
+ Money actions require confidence ≥ 0.75 *and* an explicit yes/no.
379
+
380
+ ### Model configuration
381
+
382
+ Defaults are tuned for a free CPU Space. Override via Space secrets:
383
+
384
+ | Variable | Default | Notes |
385
+ |---|---|---|
386
+ | `ASR_FINAL_MODEL` | `openai/whisper-small` | `whisper-large-v3` on GPU |
387
+ | `SHOW_PARTIALS` | `0` | `1` only on GPU |
388
+ | `HF_TOKEN` | unset | Enables LLM-based NLU (big quality gain) |
389
+ | `CRM_PROVIDER` | `demo` | `zendesk` for real tickets |
390
+
391
+ Without `HF_TOKEN` the NLU uses rule-based decomposition: it passes the full
392
+ regression suite but only handles phrasings that were anticipated.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
393
  """)
394
 
395
+ with gr.TabItem("Market"):
396
+ gr.Markdown("""
397
+ ### Why Hausa, why now
398
+
399
+ | | |
400
+ |---|---|
401
+ | **100M+** | Hausa speakers the largest language in West Africa |
402
+ | **~63%** | Low literacy in core regions — voice *is* the interface |
403
+ | **$4.2B** | Projected African contact-centre spend by 2027 |
404
+ | **~0** | Production-grade Hausa voice agents in market |
405
+
406
+ **Target verticals** — telecoms (MTN, Airtel, Glo), fintech and mobile money
407
+ (Kuda, PalmPay, OPay), public services (NIMC), health IVR, e-commerce.
408
+
409
+ **Moat** — open-weights stack with no per-call API lock-in; fine-tuned Hausa
410
+ models as owned IP; deployable on-premise for data-residency requirements;
411
+ multi-channel (voice, WhatsApp, SIP) from day one.
412
+
413
+ **Roadmap** — Yorùbá, Igbo, Fulfulde, Kanuri. NLLB covers 200 languages and
414
+ MMS covers 1,000+ for TTS, so each new language is a fine-tune, not a rebuild.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
415
  """)
416
 
417
+ gr.HTML('<div style="text-align:center;padding:16px;color:#3A3A46;'
418
+ 'font-size:11px">PlotWeaver · open-weights Hausa voice AI</div>')
 
 
 
 
 
 
 
 
419
 
420
 
421
  if __name__ == "__main__":
422
+ demo.queue(max_size=12).launch(
423
+ server_name="0.0.0.0",
424
+ server_port=int(os.getenv("PORT", 7860)),
425
+ show_api=False,
426
+ )
deploy.sh ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # ── Deploy to HuggingFace Spaces ─────────────────────────────────────────────
3
+ # Validates the file layout BEFORE pushing, because the most common failure is
4
+ # integrations/ never reaching the Space (the HF web uploader skips folders),
5
+ # which crashes startup with ModuleNotFoundError.
6
+ #
7
+ # Usage: ./deploy.sh <hf-username> <space-name>
8
+ # Example: ./deploy.sh plotweaver hausa-voice-agent
9
+
10
+ set -euo pipefail
11
+
12
+ USER="${1:-}"
13
+ SPACE="${2:-}"
14
+
15
+ if [[ -z "$USER" || -z "$SPACE" ]]; then
16
+ echo "Usage: ./deploy.sh <hf-username> <space-name>"
17
+ exit 1
18
+ fi
19
+
20
+ echo "── Validating layout ─────────────────────────────────────────"
21
+
22
+ REQUIRED=(
23
+ app.py pipeline.py nlu.py orchestrator.py vad.py streaming_asr.py
24
+ requirements.txt README.md
25
+ integrations/__init__.py integrations/crm.py
26
+ integrations/sip.py integrations/whatsapp.py
27
+ )
28
+
29
+ MISSING=0
30
+ for f in "${REQUIRED[@]}"; do
31
+ if [[ -f "$f" ]]; then
32
+ printf ' ✓ %s\n' "$f"
33
+ else
34
+ printf ' ✗ %s MISSING\n' "$f"
35
+ MISSING=1
36
+ fi
37
+ done
38
+
39
+ if [[ $MISSING -eq 1 ]]; then
40
+ echo ""
41
+ echo "Refusing to deploy: files are missing. The Space would crash on boot."
42
+ exit 1
43
+ fi
44
+
45
+ echo ""
46
+ echo "── Running tests ─────────────────────────────────────────────"
47
+ python verify_boot.py || { echo "Boot check failed."; exit 1; }
48
+ python test_app.py || { echo "App launch test failed."; exit 1; }
49
+ python test_streaming.py || { echo "Streaming tests failed."; exit 1; }
50
+ python test_regressions.py || { echo "NLU tests failed."; exit 1; }
51
+
52
+ echo ""
53
+ echo "── Pushing to Space ──────────────────────────────────────────"
54
+
55
+ if [[ ! -d .git ]]; then
56
+ git init -q
57
+ git remote add origin "https://huggingface.co/spaces/${USER}/${SPACE}"
58
+ fi
59
+
60
+ git add -A # -A, not '.', so nothing is quietly skipped
61
+
62
+ echo ""
63
+ echo "Staged files:"
64
+ git status --short
65
+
66
+ echo ""
67
+ read -r -p "Push these to ${USER}/${SPACE}? [y/N] " CONFIRM
68
+ [[ "$CONFIRM" == "y" || "$CONFIRM" == "Y" ]] || { echo "Aborted."; exit 0; }
69
+
70
+ git commit -q -m "Deploy Hausa Voice AI Agent" || echo "Nothing new to commit."
71
+ git push -u origin main
72
+
73
+ echo ""
74
+ echo "Done → https://huggingface.co/spaces/${USER}/${SPACE}"
75
+ echo "Set HF_TOKEN under Settings → Variables and secrets for best NLU quality."
nlu.py CHANGED
@@ -91,7 +91,13 @@ class NLU:
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 ────────────────────────────────────────────────────────────
@@ -110,6 +116,9 @@ class NLU:
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"):
@@ -118,7 +127,12 @@ class NLU:
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}],
@@ -132,7 +146,8 @@ class NLU:
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
 
 
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
+ # The local backend is OPT-IN. Left automatic, the first NLU call on a
95
+ # Space silently downloads a ~3GB model mid-demo and blocks for
96
+ # minutes. Enable deliberately with NLU_LOCAL=1 on hardware that can
97
+ # take it.
98
+ self.use_local = os.getenv("NLU_LOCAL", "0") == "1"
99
  self._local_pipe = None
100
+ self._dead = set() # backends that failed — never retried
101
  self.prefer = prefer
102
 
103
  # ── Public API ────────────────────────────────────────────────────────────
 
116
  f"A short answer likely fills that slot.")
117
 
118
  for backend in self._backend_order():
119
+ name = backend.__name__
120
+ if name in self._dead:
121
+ continue
122
  try:
123
  result = backend(text, context)
124
  if result and result.get("tasks"):
 
127
  f"{json.dumps(result['tasks'])[:200]}")
128
  return result
129
  except Exception as e:
130
+ # Mark dead so a missing dependency or bad token doesn't cost
131
+ # a retry (and a re-download attempt) on every single turn.
132
+ self._dead.add(name)
133
+ logger.warning(
134
+ f"NLU backend {name} failed and is disabled for this "
135
+ f"session: {e}")
136
  # Absolute last resort
137
  return {"tasks": [{"intent": "unknown", "confidence": 0.0,
138
  "slots": {}, "utterance_span": text}],
 
146
  chain = []
147
  if self.hf_token:
148
  chain.append(self._api_backend)
149
+ if self.use_local:
150
+ chain.append(self._local_backend)
151
  chain.append(self._rules_backend)
152
  return chain
153
 
pipeline.py CHANGED
@@ -34,11 +34,24 @@ class HausaVoiceAIPipeline:
34
  the Space startup time reasonable on CPU.
35
  """
36
 
37
- # Dual-model ASR: large-v3 for the FINAL decode (accuracy), a small model
38
- # for live partials (latency). Partials are thrown away and replaced by the
39
- # final, so their error rate matters far less than their speed.
40
- ASR_FINAL_MODEL = os.getenv("ASR_FINAL_MODEL", "openai/whisper-large-v3")
41
- ASR_PARTIAL_MODEL = os.getenv("ASR_PARTIAL_MODEL", "openai/whisper-small")
 
 
 
 
 
 
 
 
 
 
 
 
 
42
 
43
  def __init__(self, pivot_language: str = "english"):
44
  self.pivot = pivot_language # dialogue logic runs in English
@@ -89,7 +102,7 @@ class HausaVoiceAIPipeline:
89
  return
90
  logger.info("Loading NLLB-200-distilled-600M …")
91
  from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
92
- model_id = "facebook/nllb-200-distilled-600M"
93
  self._nllb_tokenizer = AutoTokenizer.from_pretrained(model_id)
94
  self._nllb_model = AutoModelForSeq2SeqLM.from_pretrained(model_id).to(DEVICE)
95
 
@@ -98,7 +111,7 @@ class HausaVoiceAIPipeline:
98
  return
99
  logger.info("Loading MMS-TTS Hausa …")
100
  from transformers import VitsModel, AutoTokenizer
101
- model_id = "facebook/mms-tts-hau"
102
  self._tts_tokenizer = AutoTokenizer.from_pretrained(model_id)
103
  self._tts_model = VitsModel.from_pretrained(model_id).to(DEVICE)
104
  self.tts_sample_rate = self._tts_model.config.sampling_rate
 
34
  the Space startup time reasonable on CPU.
35
  """
36
 
37
+ # Model selection is CPU-FIRST. whisper-large-v3 is ~6GB in fp32 and takes
38
+ # roughly a minute per utterance on the 2-vCPU free tier it makes the
39
+ # Space look broken. Default to small on CPU, large-v3 when a GPU is
40
+ # present, and let an env var override either way.
41
+ _GPU = torch.cuda.is_available()
42
+ ASR_FINAL_MODEL = os.getenv(
43
+ "ASR_FINAL_MODEL",
44
+ "openai/whisper-large-v3" if _GPU else "openai/whisper-small")
45
+ ASR_PARTIAL_MODEL = os.getenv(
46
+ "ASR_PARTIAL_MODEL",
47
+ "openai/whisper-small" if _GPU else "openai/whisper-base")
48
+ NLLB_MODEL = os.getenv("NLLB_MODEL", "facebook/nllb-200-distilled-600M")
49
+ TTS_MODEL = os.getenv("TTS_MODEL", "facebook/mms-tts-hau")
50
+
51
+ def describe_models(self) -> str:
52
+ short = lambda m: m.split("/")[-1]
53
+ return (f"{short(self.ASR_FINAL_MODEL)} · {short(self.NLLB_MODEL)} · "
54
+ f"{short(self.TTS_MODEL)} · {'GPU' if self._GPU else 'CPU'}")
55
 
56
  def __init__(self, pivot_language: str = "english"):
57
  self.pivot = pivot_language # dialogue logic runs in English
 
102
  return
103
  logger.info("Loading NLLB-200-distilled-600M …")
104
  from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
105
+ model_id = self.NLLB_MODEL
106
  self._nllb_tokenizer = AutoTokenizer.from_pretrained(model_id)
107
  self._nllb_model = AutoModelForSeq2SeqLM.from_pretrained(model_id).to(DEVICE)
108
 
 
111
  return
112
  logger.info("Loading MMS-TTS Hausa …")
113
  from transformers import VitsModel, AutoTokenizer
114
+ model_id = self.TTS_MODEL
115
  self._tts_tokenizer = AutoTokenizer.from_pretrained(model_id)
116
  self._tts_model = VitsModel.from_pretrained(model_id).to(DEVICE)
117
  self.tts_sample_rate = self._tts_model.config.sampling_rate
requirements.txt CHANGED
@@ -1,37 +1,40 @@
1
  # ── PlotWeaver Hausa Voice AI Agent ──────────────────────────────────────────
2
- # Audited against actual imports. Nothing here is speculative.
3
- #
4
- # HF Spaces reinstalls this on every rebuild, so the list is deliberately small:
5
- # librosa was removed in favour of the scipy resampler already in the codebase
6
- # (librosa pulls numba + llvmlite and adds several minutes to cold builds).
 
 
 
7
 
8
  # ── Core ML ──────────────────────────────────────────────────────────────────
9
  torch>=2.2.0 # Whisper, NLLB, MMS, Silero VAD
10
  transformers>=4.40.0 # >=4.40 required for VitsModel (MMS-TTS)
11
- sentencepiece>=0.2.0 # NLLB tokenizer — NOT optional, NLLB fails without it
12
- accelerate>=0.28.0 # device_map="auto" for the local NLU model
13
  protobuf>=4.25.0 # slow→fast tokenizer conversion for NLLB
14
 
15
  # ── Audio ────────────────────────────────────────────────────────────────────
16
- numpy>=1.26.0,<3.0.0 # 2.x is fine; cap guards against a future 3.0 break
17
  scipy>=1.12.0 # scipy.signal.resample — the only resampler used
18
  soundfile>=0.12.1 # Gradio audio I/O backend
19
 
20
- # ── App ─────────────────────────────────────────────────────────────────────
21
- gradio>=4.44.0 # streaming=True + stream_every need >=4.44
22
  requests>=2.31.0 # WhatsApp Cloud API, Zendesk, HF Inference API
23
 
24
- # ── Optional integrations ────────────────────────────────────────────────────
25
- # Only needed when SIP_PROVIDER=twilio with real credentials. integrations/sip.py
26
- # imports twilio lazily inside the non-demo branches, so the demo never touches
27
- # it and this line can be dropped to slim the image.
28
- twilio>=9.0.0
 
 
 
 
29
 
30
  # ── Deliberately absent ──────────────────────────────────────────────────────
31
- # torchaudio nothing imports it.
32
- # librosa resampling uses scipy.signal.resample instead.
33
- # silero-vad vad.py loads it via torch.hub and falls back to the built-in
34
- # energy VAD if the download is blocked. No package needed.
35
- # audioop — G.711 mu-law decoding is implemented in numpy in
36
- # integrations/sip.py, because audioop was REMOVED from the
37
- # stdlib in Python 3.13 and would break telephony on new images.
 
1
  # ── PlotWeaver Hausa Voice AI Agent ──────────────────────────────────────────
2
+ # Versions verified by actually launching the app (see test_app.py), not by
3
+ # assumption. Two things were caught this way:
4
+ # * gradio 4.44 is now BROKEN against current huggingface_hub (HfFolder was
5
+ # removed) a fresh Space build on 4.44 fails at import.
6
+ # * `stream_every` on Audio.stream does not exist before gradio 5.
7
+ # Keep sdk_version in README.md frontmatter in sync with the gradio pin below.
8
+
9
+ gradio>=5.49.0,<6.0.0 # Audio.stream(stream_every=...) + gr.skip()
10
 
11
  # ── Core ML ──────────────────────────────────────────────────────────────────
12
  torch>=2.2.0 # Whisper, NLLB, MMS, Silero VAD
13
  transformers>=4.40.0 # >=4.40 required for VitsModel (MMS-TTS)
14
+ sentencepiece>=0.2.0 # NLLB tokenizer — NOT optional
15
+ accelerate>=0.28.0 # device placement
16
  protobuf>=4.25.0 # slow→fast tokenizer conversion for NLLB
17
 
18
  # ── Audio ────────────────────────────────────────────────────────────────────
19
+ numpy>=1.26.0,<3.0.0
20
  scipy>=1.12.0 # scipy.signal.resample — the only resampler used
21
  soundfile>=0.12.1 # Gradio audio I/O backend
22
 
23
+ # ── Misc ─────────────────────────────────────────────────────────────────────
 
24
  requests>=2.31.0 # WhatsApp Cloud API, Zendesk, HF Inference API
25
 
26
+ # ── Optional ─────────────────────────────────────────────────────────────────
27
+ # torchaudio is NOT required by this app, but Silero VAD imports it via
28
+ # torch.hub. Without it the built-in energy VAD is used instead, which works
29
+ # fine uncomment only if you want Silero's noise robustness.
30
+ # torchaudio>=2.2.0
31
+ #
32
+ # Only needed with SIP_PROVIDER=twilio and real credentials; sip.py imports it
33
+ # lazily, so the demo never touches it.
34
+ # twilio>=9.0.0
35
 
36
  # ── Deliberately absent ──────────────────────────────────────────────────────
37
+ # librosa resampling uses scipy.signal.resample.
38
+ # silero-vad loaded via torch.hub with an energy-VAD fallback.
39
+ # audioop removed from the stdlib in Python 3.13; G.711 mu-law decoding
40
+ # is implemented in numpy in integrations/sip.py.
 
 
 
test_app.py ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Verifies the app ACTUALLY LAUNCHES under the pinned gradio, and that every
3
+ event handler is wired with a matching signature.
4
+
5
+ This is the check that was missing before: the previous app.py used
6
+ `stream_every`, which does not exist in gradio 4.44 — it would have crashed on
7
+ Space startup no matter how correct the rest of the code was.
8
+
9
+ Models are stubbed, so this tests the app wiring, not the ML.
10
+ """
11
+ import os
12
+ import sys
13
+ import types
14
+ import numpy as np
15
+
16
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
17
+
18
+ # ── Stub the pipeline so no 3.5GB download happens ───────────────────────────
19
+ import pipeline as _pl
20
+
21
+
22
+ def _fake_transcribe(self, audio, sr=16000):
23
+ return "duba asusuna sannan ka aika 35000 zuwa Amina"
24
+
25
+
26
+ def _fake_translate(self, text, src, tgt, **kw):
27
+ table = {
28
+ "duba asusuna sannan ka aika 35000 zuwa Amina":
29
+ "check my balance and also send 35000 to amina",
30
+ "2234567890": "2234567890",
31
+ "eh": "yes",
32
+ }
33
+ return table.get(text, text)
34
+
35
+
36
+ def _fake_tts(self, text):
37
+ return 16000, (np.sin(np.arange(8000) / 20) * 8000).astype(np.int16)
38
+
39
+
40
+ _pl.HausaVoiceAIPipeline.transcribe = _fake_transcribe
41
+ _pl.HausaVoiceAIPipeline.transcribe_partial = _fake_transcribe
42
+ _pl.HausaVoiceAIPipeline.translate = _fake_translate
43
+ _pl.HausaVoiceAIPipeline.synthesize = _fake_tts
44
+ _pl.HausaVoiceAIPipeline.hausa_text_to_audio = _fake_tts
45
+ _pl.HausaVoiceAIPipeline._load_asr = lambda self: None
46
+ _pl.HausaVoiceAIPipeline._load_asr_fast = lambda self: None
47
+ _pl.HausaVoiceAIPipeline._load_nllb = lambda self: None
48
+ _pl.HausaVoiceAIPipeline._load_tts = lambda self: None
49
+
50
+ os.environ.setdefault("SHOW_PARTIALS", "0")
51
+
52
+ PASS, FAIL = "\033[92mPASS\033[0m", "\033[91mFAIL\033[0m"
53
+ results = []
54
+
55
+
56
+ def check(name, cond, detail=""):
57
+ results.append((name, cond))
58
+ print(f" [{PASS if cond else FAIL}] {name}" + (f" — {detail}" if detail else ""))
59
+
60
+
61
+ print("\n══ App imports and builds under the pinned gradio ══")
62
+ import gradio as gr
63
+ print(f" gradio {gr.__version__}")
64
+ import app as A
65
+ check("app module imported", True)
66
+ check("Blocks object built", isinstance(A.demo, gr.Blocks))
67
+
68
+ print("\n══ Streaming API exists in this gradio version ══")
69
+ import inspect
70
+ p = inspect.signature(gr.Audio(streaming=True).stream).parameters
71
+ check("Audio.stream accepts stream_every", "stream_every" in p)
72
+ check("gr.skip available", hasattr(gr, "skip"))
73
+
74
+ print("\n══ Event handlers are wired ══")
75
+ fns = A.demo.fns
76
+ check("events registered", len(fns) > 0, f"{len(fns)} handlers")
77
+
78
+ print("\n══ Text handler: full compound-request conversation ══")
79
+ st = None
80
+ audio, convo, status, st = A.on_text(
81
+ "duba asusuna sannan ka aika 35000 zuwa Amina", st)
82
+ check("turn 1 returns audio", audio is not None and audio is not gr.skip())
83
+ check("turn 1 acknowledges BOTH tasks",
84
+ "one at a time" in convo.lower() or "balance" in convo.lower(),
85
+ status[:70])
86
+ audio, convo, status, st = A.on_text("2234567890", st)
87
+ check("turn 2 delivers balance + continues transfer",
88
+ "balance is" in convo.lower() and "amina" in convo.lower())
89
+ audio, convo, status, st = A.on_text("eh", st)
90
+ check("turn 3 executes the transfer", "sent to amina" in convo.lower())
91
+ check("latency breakdown in status", "total" in status, status[-52:])
92
+
93
+ print("\n══ Empty input does not crash ══")
94
+ a, c, s, st2 = A.on_text("", None)
95
+ check("empty text handled", a == gr.skip() or a is None)
96
+ a, c, s, st2 = A.on_record(None, None)
97
+ check("no recording handled", a == gr.skip() or a is None)
98
+
99
+ print("\n══ Streaming handler with synthetic audio ══")
100
+ SR = 16000
101
+
102
+
103
+ def sil(ms, n=0.0005):
104
+ k = int(SR * ms / 1000)
105
+ return (np.random.randn(k) * n).astype(np.float32)
106
+
107
+
108
+ def sp(ms, amp=0.25):
109
+ k = int(SR * ms / 1000)
110
+ t = np.arange(k) / SR
111
+ s = (np.sin(2*np.pi*120*t) + 0.5*np.sin(2*np.pi*700*t)
112
+ + 0.3*np.sin(2*np.pi*1220*t))
113
+ s = s * (0.6 + 0.4*np.sin(2*np.pi*4*t)) + np.random.randn(k)*0.01
114
+ return (s / np.abs(s).max() * amp).astype(np.float32)
115
+
116
+
117
+ st = A.new_state()
118
+ stream = np.concatenate([sil(400), sp(1600), sil(1200)])
119
+ step = int(SR * 0.5)
120
+ got_reply = False
121
+ n_skips = 0
122
+ for i in range(0, len(stream), step):
123
+ out = A.on_stream((SR, stream[i:i+step]), st)
124
+ check_len = len(out) == 5
125
+ audio_o, convo_o, status_o, partial_o, st = out
126
+ if audio_o == gr.skip():
127
+ n_skips += 1
128
+ elif audio_o is not None:
129
+ got_reply = True
130
+ check("stream handler returns 5 values", check_len)
131
+ check("VAD endpointed and produced a reply", got_reply)
132
+ check("idle ticks return gr.skip (no player restart)", n_skips > 0,
133
+ f"{n_skips} skipped ticks")
134
+
135
+ print("\n══ Reset ══")
136
+ r = A.reset(st)
137
+ check("reset returns 5 values", len(r) == 5)
138
+ check("history cleared", "Press" in r[1] or "empty" in r[1])
139
+
140
+ print("\n══ Launch smoke test (real server, then shut down) ══")
141
+ try:
142
+ A.demo.queue(max_size=4)
143
+ _, url, _ = A.demo.launch(prevent_thread_lock=True, quiet=True,
144
+ server_port=7899, show_api=False)
145
+ check("Gradio server started", True, url or "local")
146
+ import urllib.request
147
+ code = urllib.request.urlopen("http://127.0.0.1:7899/", timeout=15).getcode()
148
+ check("HTTP 200 from the app root", code == 200, f"status {code}")
149
+ A.demo.close()
150
+ except Exception as e:
151
+ check("Gradio server started", False, str(e)[:110])
152
+
153
+ passed = sum(1 for _, ok in results if ok)
154
+ print(f"\n{'='*62}\n {passed}/{len(results)} checks passed\n{'='*62}")
155
+ sys.exit(0 if passed == len(results) else 1)
verify_boot.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Verifies app.py imports cleanly under file-layout problems that have actually
3
+ broken this Space:
4
+
5
+ A integrations/ folder missing entirely (HF web uploader skips folders)
6
+ B integrations/ present but without __init__.py
7
+ C integrations/ complete
8
+ D app started from a different working directory
9
+
10
+ Uses the REAL gradio (a verified dependency) and stubs only model loading, so
11
+ nothing downloads. Never launches a server — that is test_app.py's job.
12
+ """
13
+ import os
14
+ import shutil
15
+ import subprocess
16
+ import sys
17
+ import tempfile
18
+
19
+ SRC = os.path.dirname(os.path.abspath(__file__))
20
+ GREEN, RED, OFF = "\033[92m", "\033[91m", "\033[0m"
21
+
22
+ HARNESS = '''
23
+ import sys
24
+ sys.path.insert(0, sys.argv[2])
25
+ import pipeline as _pl
26
+ _pl.HausaVoiceAIPipeline._load_asr = lambda self: None
27
+ _pl.HausaVoiceAIPipeline._load_asr_fast = lambda self: None
28
+ _pl.HausaVoiceAIPipeline._load_nllb = lambda self: None
29
+ _pl.HausaVoiceAIPipeline._load_tts = lambda self: None
30
+ import runpy
31
+ runpy.run_path(sys.argv[1], run_name="__not_main__")
32
+ print("IMPORT_OK")
33
+ '''
34
+
35
+
36
+ def scenario(name, setup, cwd=None):
37
+ tmp = tempfile.mkdtemp()
38
+ for f in os.listdir(SRC):
39
+ if f.endswith(".py"):
40
+ shutil.copy(os.path.join(SRC, f), tmp)
41
+ setup(tmp)
42
+ harness = os.path.join(tmp, "_h.py")
43
+ with open(harness, "w") as fh:
44
+ fh.write(HARNESS)
45
+ r = subprocess.run(
46
+ [sys.executable, harness, os.path.join(tmp, "app.py"), tmp],
47
+ capture_output=True, text=True, cwd=cwd or tmp)
48
+ ok = "IMPORT_OK" in r.stdout
49
+ tag = f"{GREEN}BOOTS{OFF}" if ok else f"{RED}CRASH{OFF}"
50
+ print(f" [{tag}] {name}")
51
+ if not ok:
52
+ tail = (r.stderr or r.stdout).strip().splitlines()
53
+ print(" " + (tail[-1] if tail else "no output"))
54
+ shutil.rmtree(tmp, ignore_errors=True)
55
+ return ok
56
+
57
+
58
+ def none(tmp):
59
+ pass
60
+
61
+
62
+ def no_init(tmp):
63
+ d = os.path.join(tmp, "integrations")
64
+ os.makedirs(d, exist_ok=True)
65
+ for f in ("crm.py", "sip.py", "whatsapp.py"):
66
+ shutil.copy(os.path.join(SRC, "integrations", f), d)
67
+
68
+
69
+ def full(tmp):
70
+ d = os.path.join(tmp, "integrations")
71
+ os.makedirs(d, exist_ok=True)
72
+ for f in os.listdir(os.path.join(SRC, "integrations")):
73
+ if f.endswith(".py"):
74
+ shutil.copy(os.path.join(SRC, "integrations", f), d)
75
+
76
+
77
+ print("\n== Space startup scenarios ==")
78
+ res = [
79
+ scenario("A: integrations/ missing entirely", none),
80
+ scenario("B: integrations/ without __init__.py", no_init),
81
+ scenario("C: integrations/ complete", full),
82
+ scenario("D: different working directory", full, cwd="/"),
83
+ ]
84
+ print(f"\n {sum(res)}/{len(res)} scenarios boot cleanly")
85
+ sys.exit(0 if all(res) else 1)