Toadoum commited on
Commit
31ff13b
Β·
verified Β·
1 Parent(s): c9f850d

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +85 -51
app.py CHANGED
@@ -1,17 +1,27 @@
1
  """
2
- PlotWeaver Voice Agent β€” HuggingFace Space (Gradio 6 + Python 3.13)
3
- ====================================================================
4
  Hausa-first conversational AI for African banks, telecoms, and delivery.
5
-
6
- Pipeline (all real, running on CPU):
7
- ASR (openai/whisper-small)
8
- β†’ NLU (rule-based + Qwen2.5-1.5B-Instruct fallback, see nlu.py)
9
- β†’ Dialogue FSM (see dialogue.py)
10
- β†’ TTS (facebook/mms-tts-hau)
11
-
12
- First turn: ~30-60s model downloads. Subsequent turns: ~5-10s on CPU.
 
 
 
 
 
 
 
 
 
13
  """
14
  from __future__ import annotations
 
15
  import time
16
  import uuid
17
  import html as html_lib
@@ -20,6 +30,7 @@ from typing import Optional
20
  import gradio as gr
21
  import numpy as np
22
  import torch
 
23
  from transformers import (
24
  VitsModel, AutoTokenizer,
25
  WhisperProcessor, WhisperForConditionalGeneration,
@@ -27,42 +38,46 @@ from transformers import (
27
 
28
  from dialogue import (
29
  DialogueState, SCENARIOS,
30
- get_prompt, get_expected_slot, transition,
31
  )
32
  from nlu import parse as nlu_parse
 
 
 
 
 
 
 
 
 
 
 
33
 
34
 
35
  # ---------------------------------------------------------------------------
36
- # Model loading (lazy, cached)
37
  # ---------------------------------------------------------------------------
38
- _asr_model = None
39
- _asr_processor = None
40
- _tts_model = None
41
- _tts_tokenizer = None
42
-
43
-
44
- def load_asr():
45
- global _asr_model, _asr_processor
46
- if _asr_model is None:
47
- print("Loading Whisper-small…")
48
- _asr_processor = WhisperProcessor.from_pretrained("openai/whisper-small")
49
- _asr_model = WhisperForConditionalGeneration.from_pretrained("openai/whisper-small")
50
- _asr_model.eval()
51
- print("Whisper-small ready.")
52
- return _asr_model, _asr_processor
53
-
54
-
55
- def load_tts():
56
- global _tts_model, _tts_tokenizer
57
- if _tts_model is None:
58
- print("Loading MMS-TTS Hausa…")
59
- _tts_model = VitsModel.from_pretrained("facebook/mms-tts-hau")
60
- _tts_tokenizer = AutoTokenizer.from_pretrained("facebook/mms-tts-hau")
61
- _tts_model.eval()
62
- print("MMS-TTS Hausa ready.")
63
- return _tts_model, _tts_tokenizer
64
 
 
65
 
 
 
66
  def transcribe_hausa(audio_tuple) -> str:
67
  if audio_tuple is None:
68
  return ""
@@ -82,24 +97,26 @@ def transcribe_hausa(audio_tuple) -> str:
82
  num_samples = int(len(audio_array) * 16000 / sample_rate)
83
  audio_array = scipy.signal.resample(audio_array, num_samples).astype(np.float32)
84
 
85
- model, processor = load_asr()
86
- inputs = processor(audio_array, sampling_rate=16000, return_tensors="pt")
87
- forced_ids = processor.get_decoder_prompt_ids(language="hausa", task="transcribe")
88
  with torch.no_grad():
89
- ids = model.generate(inputs.input_features, forced_decoder_ids=forced_ids, max_new_tokens=128)
90
- text = processor.batch_decode(ids, skip_special_tokens=True)[0].strip()
91
  return text
92
 
93
 
 
94
  def synthesize_hausa(text: str) -> Optional[tuple]:
95
  if not text.strip():
96
  return None
97
- model, tokenizer = load_tts()
98
- inputs = tokenizer(text, return_tensors="pt")
 
99
  with torch.no_grad():
100
- out = model(**inputs).waveform
101
  audio = out.squeeze().cpu().numpy().astype(np.float32)
102
- return (model.config.sampling_rate, audio)
103
 
104
 
105
  # ---------------------------------------------------------------------------
@@ -198,15 +215,17 @@ def render_whatsapp(session: dict) -> str:
198
  # ---------------------------------------------------------------------------
199
  def run_turn(user_text: str, session: dict, is_voice: bool = False):
200
  """Returns (updated_session_dict, bot_audio)."""
 
201
  state = DialogueState.from_dict(session) if session else None
202
  if state is None:
203
  state = DialogueState(session_id="sess_" + uuid.uuid4().hex[:8], vertical="bank")
204
 
 
205
  expected = get_expected_slot(state.vertical, state.current_state)
206
- intent, entities, _ = nlu_parse(user_text, expected)
207
  state = transition(state, intent, entities)
208
 
209
- prompt = get_prompt(state.vertical, state.current_state)
210
 
211
  state.history.append({"role": "user", "text": user_text, "is_voice": is_voice})
212
  state.history.append({"role": "bot", "text_ha": prompt["ha"], "text_en": prompt["en"]})
@@ -217,6 +236,21 @@ def run_turn(user_text: str, session: dict, is_voice: bool = False):
217
  print(f"TTS failed: {e}")
218
  audio = None
219
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
220
  return state.to_dict(), audio
221
 
222
 
@@ -225,7 +259,7 @@ def run_turn(user_text: str, session: dict, is_voice: bool = False):
225
  # ---------------------------------------------------------------------------
226
  def on_vertical_change(vertical: str):
227
  state = DialogueState(session_id="sess_" + uuid.uuid4().hex[:8], vertical=vertical)
228
- greet = get_prompt(vertical, "greeting")
229
  state.history.append({"role": "bot", "text_ha": greet["ha"], "text_en": greet["en"]})
230
  session = state.to_dict()
231
  return session, render_whatsapp(session), None
@@ -341,4 +375,4 @@ with gr.Blocks(css=CUSTOM_CSS, title="PlotWeaver Voice Agent") as demo:
341
 
342
 
343
  if __name__ == "__main__":
344
- demo.launch(server_name="0.0.0.0", server_port=7860)
 
1
  """
2
+ PlotWeaver Voice Agent β€” HuggingFace Space (Gradio 6 + ZeroGPU)
3
+ ================================================================
4
  Hausa-first conversational AI for African banks, telecoms, and delivery.
5
+ Pipeline:
6
+ ASR (openai/whisper-small) ── on GPU (@spaces.GPU)
7
+ β†’ NLU (embedding similarity, nlu.py) ── on CPU (~200ms; GPU would be slower)
8
+ β†’ Dialogue FSM (dialogue.py) + Backend (backend.py)
9
+ β†’ TTS (facebook/mms-tts-hau) ── on GPU (@spaces.GPU)
10
+
11
+ ZeroGPU notes
12
+ -------------
13
+ * Requires a PRO account + ZeroGPU hardware selected in Space settings.
14
+ * Models are placed on cuda at MODULE level (the ZeroGPU contract), even though
15
+ a real GPU is only attached inside @spaces.GPU functions.
16
+ * Decorated functions return CPU/numpy data (never live CUDA tensors).
17
+ * Low `duration` values raise queue priority β€” ASR/TTS per turn are seconds.
18
+
19
+ Secrets / variables (Space β†’ Settings):
20
+ HF_TOKEN write token, for log syncing (see logging_util.py)
21
+ PW_LOG_DATASET e.g. "plotweaver/voice-agent-logs"
22
  """
23
  from __future__ import annotations
24
+ import os
25
  import time
26
  import uuid
27
  import html as html_lib
 
30
  import gradio as gr
31
  import numpy as np
32
  import torch
33
+ import spaces
34
  from transformers import (
35
  VitsModel, AutoTokenizer,
36
  WhisperProcessor, WhisperForConditionalGeneration,
 
38
 
39
  from dialogue import (
40
  DialogueState, SCENARIOS,
41
+ render_prompt, get_expected_slot, transition,
42
  )
43
  from nlu import parse as nlu_parse
44
+ from logging_util import log_turn
45
+
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # Device selection β€” cuda at module level on ZeroGPU, else local CPU/GPU
49
+ # ---------------------------------------------------------------------------
50
+ _IS_ZEROGPU = bool(os.environ.get("SPACES_ZERO_GPU"))
51
+ if _IS_ZEROGPU or torch.cuda.is_available():
52
+ DEVICE = "cuda"
53
+ else:
54
+ DEVICE = "cpu"
55
 
56
 
57
  # ---------------------------------------------------------------------------
58
+ # Model loading (module level β€” required by ZeroGPU)
59
  # ---------------------------------------------------------------------------
60
+ print(f"Loading models on {DEVICE}…")
61
+
62
+ _asr_processor = WhisperProcessor.from_pretrained("openai/whisper-small")
63
+ _asr_model = (
64
+ WhisperForConditionalGeneration
65
+ .from_pretrained("openai/whisper-small")
66
+ .to(DEVICE)
67
+ .eval()
68
+ )
69
+
70
+ _tts_tokenizer = AutoTokenizer.from_pretrained("facebook/mms-tts-hau")
71
+ _tts_model = VitsModel.from_pretrained("facebook/mms-tts-hau").to(DEVICE).eval()
72
+ # PlotWeaver Hausa production voice config (tune here, not at call sites).
73
+ _tts_model.speaking_rate = 0.85
74
+ _tts_model.noise_scale = 0.4
75
+ _TTS_SEED = 42
 
 
 
 
 
 
 
 
 
 
76
 
77
+ print("Models ready.")
78
 
79
+
80
+ @spaces.GPU(duration=15)
81
  def transcribe_hausa(audio_tuple) -> str:
82
  if audio_tuple is None:
83
  return ""
 
97
  num_samples = int(len(audio_array) * 16000 / sample_rate)
98
  audio_array = scipy.signal.resample(audio_array, num_samples).astype(np.float32)
99
 
100
+ inputs = _asr_processor(audio_array, sampling_rate=16000, return_tensors="pt")
101
+ forced_ids = _asr_processor.get_decoder_prompt_ids(language="hausa", task="transcribe")
102
+ feats = inputs.input_features.to(DEVICE)
103
  with torch.no_grad():
104
+ ids = _asr_model.generate(feats, forced_decoder_ids=forced_ids, max_new_tokens=128)
105
+ text = _asr_processor.batch_decode(ids, skip_special_tokens=True)[0].strip()
106
  return text
107
 
108
 
109
+ @spaces.GPU(duration=10)
110
  def synthesize_hausa(text: str) -> Optional[tuple]:
111
  if not text.strip():
112
  return None
113
+ torch.manual_seed(_TTS_SEED) # reproducible voice (prod parity)
114
+ inputs = _tts_tokenizer(text, return_tensors="pt")
115
+ inputs = {k: v.to(DEVICE) for k, v in inputs.items()}
116
  with torch.no_grad():
117
+ out = _tts_model(**inputs).waveform
118
  audio = out.squeeze().cpu().numpy().astype(np.float32)
119
+ return (_tts_model.config.sampling_rate, audio)
120
 
121
 
122
  # ---------------------------------------------------------------------------
 
215
  # ---------------------------------------------------------------------------
216
  def run_turn(user_text: str, session: dict, is_voice: bool = False):
217
  """Returns (updated_session_dict, bot_audio)."""
218
+ t0 = time.time()
219
  state = DialogueState.from_dict(session) if session else None
220
  if state is None:
221
  state = DialogueState(session_id="sess_" + uuid.uuid4().hex[:8], vertical="bank")
222
 
223
+ state_from = state.current_state
224
  expected = get_expected_slot(state.vertical, state.current_state)
225
+ intent, entities, source = nlu_parse(user_text, expected)
226
  state = transition(state, intent, entities)
227
 
228
+ prompt = render_prompt(state.vertical, state.current_state, state.slots)
229
 
230
  state.history.append({"role": "user", "text": user_text, "is_voice": is_voice})
231
  state.history.append({"role": "bot", "text_ha": prompt["ha"], "text_en": prompt["en"]})
 
236
  print(f"TTS failed: {e}")
237
  audio = None
238
 
239
+ log_turn({
240
+ "session_id": state.session_id,
241
+ "vertical": state.vertical,
242
+ "turn": state.turn_count,
243
+ "is_voice": is_voice,
244
+ "asr_text": user_text,
245
+ "expected_slot": expected,
246
+ "intent": intent,
247
+ "nlu_source": source,
248
+ "state_from": state_from,
249
+ "state_to": state.current_state,
250
+ "escalated": state.escalate_to_human,
251
+ "latency_ms": round((time.time() - t0) * 1000),
252
+ })
253
+
254
  return state.to_dict(), audio
255
 
256
 
 
259
  # ---------------------------------------------------------------------------
260
  def on_vertical_change(vertical: str):
261
  state = DialogueState(session_id="sess_" + uuid.uuid4().hex[:8], vertical=vertical)
262
+ greet = render_prompt(vertical, "greeting", state.slots)
263
  state.history.append({"role": "bot", "text_ha": greet["ha"], "text_en": greet["en"]})
264
  session = state.to_dict()
265
  return session, render_whatsapp(session), None
 
375
 
376
 
377
  if __name__ == "__main__":
378
+ demo.launch(server_name="0.0.0.0", server_port=7860)