Toadoum commited on
Commit
5683c80
·
verified ·
1 Parent(s): 3c8a5a9

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +334 -372
app.py CHANGED
@@ -1,378 +1,340 @@
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
28
  from typing import Optional
29
 
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,
37
- )
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 ""
84
- sample_rate, audio_array = audio_tuple
85
- if audio_array is None or len(audio_array) == 0:
86
- return ""
87
- if audio_array.dtype != np.float32:
88
- audio_array = audio_array.astype(np.float32) / np.iinfo(audio_array.dtype).max
89
- if audio_array.ndim > 1:
90
- audio_array = audio_array.mean(axis=1)
91
- # Cap at 30s (Whisper training chunk size)
92
- max_samples = sample_rate * 30
93
- if len(audio_array) > max_samples:
94
- audio_array = audio_array[:max_samples]
95
- if sample_rate != 16000:
96
- import scipy.signal
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
- # ---------------------------------------------------------------------------
123
- # WhatsApp-style HTML rendering
124
- # ---------------------------------------------------------------------------
125
- def _now() -> str:
126
- return time.strftime("%H:%M")
127
-
128
-
129
- def _user_bubble(text: str, is_voice: bool) -> str:
130
- t = html_lib.escape(text)
131
- if is_voice:
132
- bars = "".join(
133
- f'<span style="height:{4 + int(8 * abs(np.sin(i * 0.7)))}px;"></span>'
134
- for i in range(20)
135
- )
136
- return f'''<div class="pw-b user">
137
- <div class="pw-voice-row">
138
- <div class="pw-voice-icon">▶</div>
139
- <div class="pw-voice-bars">{bars}</div>
140
- </div>
141
- <div style="font-size:12px;color:#667781;margin-top:3px;">"{t}"</div>
142
- <div class="pw-b-meta">{_now()} ✓✓</div>
143
- </div>'''
144
- return f'<div class="pw-b user">{t}<div class="pw-b-meta">{_now()} ✓✓</div></div>'
145
-
146
-
147
- def _bot_bubble(text_ha: str, text_en: str) -> str:
148
- ha = html_lib.escape(text_ha)
149
- en = html_lib.escape(text_en)
150
- return f'''<div class="pw-b bot">
151
- <div>{ha}</div>
152
- <div class="pw-b-trans">{en}</div>
153
- <div class="pw-b-meta">{_now()} ✓✓</div>
154
- </div>'''
155
-
156
-
157
- def render_whatsapp(session: dict) -> str:
158
- vertical = session.get("vertical", "bank") if session else "bank"
159
- name = SCENARIOS[vertical]["name"]
160
- avatar = {"bank": "PB", "telecom": "PT", "ecommerce": "PD"}[vertical]
161
- escalated = session.get("escalate_to_human", False) if session else False
162
-
163
- bubbles = []
164
- for msg in session.get("history", []) if session else []:
165
- if msg["role"] == "user":
166
- bubbles.append(_user_bubble(msg["text"], msg.get("is_voice", False)))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  else:
168
- bubbles.append(_bot_bubble(msg.get("text_ha", ""), msg.get("text_en", "")))
169
-
170
- banner = ('<div class="pw-esc-banner">Session escalated to human agent</div>'
171
- if escalated else "")
172
-
173
- if not bubbles:
174
- body = '<div style="text-align:center;color:#667781;font-size:12px;padding:40px 0;">Send a message to begin…</div>'
175
- else:
176
- body = "".join(bubbles)
177
-
178
- return f"""
179
- <div class="pw-phone">
180
- <div class="pw-ph-header">
181
- <div class="pw-ph-avatar">{avatar}</div>
182
- <div>
183
- <div class="pw-ph-name">{html_lib.escape(name)}</div>
184
- <div class="pw-ph-status">online voice agent</div>
185
- </div>
186
- </div>
187
- <div class="pw-ph-messages">
188
- {banner}
189
- {body}
190
- </div>
191
- </div>
192
- <style>
193
- .pw-phone {{ max-width: 480px; margin: 0 auto; background: #ECE5DD; border-radius: 14px; overflow: hidden; border: 1px solid #ccc; display: flex; flex-direction: column; min-height: 540px; font-family: -apple-system, "Segoe UI", Roboto, sans-serif; }}
194
- .pw-ph-header {{ background: #075E54; color: #fff; padding: 10px 14px; display: flex; align-items: center; gap: 10px; }}
195
- .pw-ph-avatar {{ width: 36px; height: 36px; border-radius: 50%; background: #128C7E; display: flex; align-items: center; justify-content: center; font-weight: 500; font-size: 13px; color: #fff; }}
196
- .pw-ph-name {{ font-size: 14px; font-weight: 500; line-height: 1.2; }}
197
- .pw-ph-status {{ font-size: 11px; color: #D4EDE8; }}
198
- .pw-ph-messages {{ flex: 1; padding: 14px 10px; background: #ECE5DD; background-image: radial-gradient(#D8CFC2 1px, transparent 1px); background-size: 18px 18px; max-height: 480px; overflow-y: auto; min-height: 420px; }}
199
- .pw-b {{ max-width: 80%; padding: 7px 10px 5px; border-radius: 8px; margin-bottom: 6px; font-size: 13.5px; line-height: 1.4; color: #1f2d1f; word-wrap: break-word; }}
200
- .pw-b.user {{ background: #DCF8C6; margin-left: auto; border-bottom-right-radius: 2px; }}
201
- .pw-b.bot {{ background: #fff; margin-right: auto; border-bottom-left-radius: 2px; }}
202
- .pw-b-meta {{ font-size: 10px; color: #667781; margin-top: 3px; text-align: right; }}
203
- .pw-b-trans {{ font-size: 11px; color: #667781; font-style: italic; margin-top: 3px; border-top: 1px solid #E5E5E5; padding-top: 3px; }}
204
- .pw-voice-row {{ display: flex; align-items: center; gap: 8px; }}
205
- .pw-voice-icon {{ width: 22px; height: 22px; border-radius: 50%; background: #128C7E; color: #fff; font-size: 10px; display: flex; align-items: center; justify-content: center; }}
206
- .pw-voice-bars {{ flex: 1; height: 14px; display: flex; align-items: center; gap: 2px; }}
207
- .pw-voice-bars span {{ flex: 1; background: #8D9A9F; border-radius: 1px; }}
208
- .pw-esc-banner {{ background: #FAEEDA; color: #854F0B; font-size: 12px; padding: 8px 12px; border-radius: 8px; margin-bottom: 10px; border: 1px solid #EF9F27; text-align: center; }}
209
- </style>
210
- """
211
-
212
-
213
- # ---------------------------------------------------------------------------
214
- # Core turn handler
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"]})
232
-
233
- try:
234
- audio = synthesize_hausa(prompt["ha"])
235
- except Exception as e:
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
-
257
- # ---------------------------------------------------------------------------
258
- # Gradio event handlers
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
266
-
267
-
268
- def on_text_submit(text: str, session: dict):
269
- if not text or not text.strip():
270
- return session, render_whatsapp(session), None, ""
271
- new_session, audio = run_turn(text, session, is_voice=False)
272
- return new_session, render_whatsapp(new_session), audio, ""
273
-
274
-
275
- def on_audio_submit(audio_data, session: dict):
276
- if audio_data is None:
277
- return session, render_whatsapp(session), None
278
- try:
279
- text = transcribe_hausa(audio_data)
280
- except Exception as e:
281
- print(f"ASR failed: {e}")
282
- return session, render_whatsapp(session), None
283
- if not text:
284
- return session, render_whatsapp(session), None
285
- new_session, audio = run_turn(text, session, is_voice=True)
286
- return new_session, render_whatsapp(new_session), audio
287
-
288
-
289
- def on_reset(session: dict):
290
- vertical = session.get("vertical", "bank") if session else "bank"
291
- return on_vertical_change(vertical)
292
-
293
-
294
- # ---------------------------------------------------------------------------
295
- # Gradio UI (chat-only, minimal components)
296
- # ---------------------------------------------------------------------------
297
- CUSTOM_CSS = """
298
- .gradio-container { max-width: 720px !important; margin: 0 auto !important; }
299
- #whatsapp-container { padding: 20px 0; }
300
- """
301
-
302
- with gr.Blocks(css=CUSTOM_CSS, title="PlotWeaver Voice Agent") as demo:
303
- gr.HTML("""
304
- <div style="text-align:center; padding: 0 0 12px;">
305
- <h1 style="margin:0 0 4px; font-size: 22px; font-weight: 500;">PlotWeaver Voice Agent</h1>
306
- <p style="margin:0; color: #5f5e5a; font-size: 14px;">Hausa-first conversational AI — pick a vertical, type or speak in Hausa</p>
307
- </div>
308
- """)
309
-
310
- session_state = gr.State({})
311
-
312
- vertical_radio = gr.Radio(
313
- choices=[("PlotWeaver Bank", "bank"),
314
- ("PlotWeaver Telecom", "telecom"),
315
- ("PlotWeaver Delivery", "ecommerce")],
316
- value="bank",
317
- label="Vertical",
318
- container=False,
319
- )
320
-
321
- whatsapp_html = gr.HTML(elem_id="whatsapp-container")
322
-
323
- with gr.Row():
324
- text_input = gr.Textbox(
325
- placeholder="Type in Hausa… e.g. 'duba ma'auni'",
326
- label="",
327
- scale=4,
328
- container=False,
329
- )
330
- send_btn = gr.Button("Send", scale=1, variant="primary")
331
- reset_btn = gr.Button("Reset", scale=1)
332
-
333
- audio_input = gr.Audio(
334
- sources=["microphone", "upload"],
335
- type="numpy",
336
- label="Record or upload Hausa audio (click Stop when done recording)",
337
- )
338
-
339
- bot_audio = gr.Audio(
340
- label="Bot response (Hausa TTS)",
341
- autoplay=True,
342
- interactive=False,
343
- )
344
-
345
- # Events
346
- demo.load(
347
- fn=lambda: on_vertical_change("bank"),
348
- outputs=[session_state, whatsapp_html, bot_audio],
349
- )
350
- vertical_radio.change(
351
- fn=on_vertical_change,
352
- inputs=[vertical_radio],
353
- outputs=[session_state, whatsapp_html, bot_audio],
354
- )
355
- send_btn.click(
356
- fn=on_text_submit,
357
- inputs=[text_input, session_state],
358
- outputs=[session_state, whatsapp_html, bot_audio, text_input],
359
- )
360
- text_input.submit(
361
- fn=on_text_submit,
362
- inputs=[text_input, session_state],
363
- outputs=[session_state, whatsapp_html, bot_audio, text_input],
364
- )
365
- audio_input.stop_recording(
366
- fn=on_audio_submit,
367
- inputs=[audio_input, session_state],
368
- outputs=[session_state, whatsapp_html, bot_audio],
369
- )
370
- reset_btn.click(
371
- fn=on_reset,
372
- inputs=[session_state],
373
- outputs=[session_state, whatsapp_html, bot_audio],
374
- )
375
-
376
-
377
- if __name__ == "__main__":
378
- demo.launch(server_name="0.0.0.0", server_port=7860)
 
1
  """
2
+ NLU ModuleMulti-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