Toadoum commited on
Commit
8a2752b
·
verified ·
1 Parent(s): b46b46c

Create nlu_legacy.py

Browse files
Files changed (1) hide show
  1. nlu_legacy.py +441 -0
nlu_legacy.py ADDED
@@ -0,0 +1,441 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ NLU (LEGACY) — NLLB + Qwen pivot-through-English architecture.
3
+
4
+ This is the older, heavier NLU pipeline. It is kept in the repo as a fallback
5
+ in case the embedding-based NLU (in nlu.py) misbehaves on a phrase the old
6
+ pipeline used to handle. NOT imported by default — `app.py` imports from
7
+ `nlu.py`. Switch by changing the import in app.py if needed.
8
+
9
+ Pipeline:
10
+ 1. Structural extractors (digits, amounts, yes/no) on raw Hausa.
11
+ 2. Keyword fast-path for common phrases.
12
+ 3. NLLB-200 translates Hausa → English, then Qwen2.5-1.5B classifies
13
+ the English text into one of a fixed set of intents.
14
+
15
+ Cold-start downloads:
16
+ - NLLB-200-distilled-600M: ~2.4 GB
17
+ - Qwen2.5-1.5B-Instruct: ~3 GB
18
+ """
19
+ from __future__ import annotations
20
+ import re
21
+ import json
22
+ import logging
23
+ from typing import Optional
24
+
25
+ logger = logging.getLogger("plotweaver.nlu_legacy")
26
+
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Deterministic structural extractors (run on raw Hausa text)
30
+ # ---------------------------------------------------------------------------
31
+ WORD_DIGITS = {
32
+ "sifili": "0", "daya": "1", "ɗaya": "1", "biyu": "2", "uku": "3",
33
+ "hudu": "4", "huɗu": "4", "biyar": "5", "shida": "6", "bakwai": "7",
34
+ "takwas": "8", "tara": "9",
35
+ }
36
+
37
+ WORD_AMOUNTS = {
38
+ "dubu goma": 10000, "dubu biyar": 5000, "dubu biyu": 2000,
39
+ "dubu": 1000, "ɗari biyar": 500, "dari biyar": 500,
40
+ "ɗari": 100, "dari": 100,
41
+ }
42
+
43
+ # Hausa yes/no keywords for the sole case where we short-circuit Qwen
44
+ HAUSA_YES = {"i", "eh", "haka ne", "haka", "ok", "okay", "yes"}
45
+ HAUSA_NO = {"a'a", "a'aa", "ba haka", "ba", "no"}
46
+
47
+ # Human-agent escape hatch
48
+ HUMAN_KEYWORDS = {"mutum", "wakili", "agent", "human"}
49
+
50
+
51
+ def _extract_digits(text: str) -> Optional[str]:
52
+ m = re.findall(r"\d+", text)
53
+ if m:
54
+ return "".join(m)
55
+ tokens = text.lower().split()
56
+ d = [WORD_DIGITS[tok] for tok in tokens if tok in WORD_DIGITS]
57
+ return "".join(d) if d else None
58
+
59
+
60
+ def _extract_amount(text: str) -> Optional[int]:
61
+ m = re.search(r"\d+", text)
62
+ if m:
63
+ return int(m.group())
64
+ t = text.lower()
65
+ for phrase in sorted(WORD_AMOUNTS.keys(), key=len, reverse=True):
66
+ if phrase in t:
67
+ return WORD_AMOUNTS[phrase]
68
+ return None
69
+
70
+
71
+ def _match_yesno(text: str) -> Optional[str]:
72
+ t = " " + text.lower().strip() + " "
73
+ for kw in HAUSA_YES:
74
+ if f" {kw} " in t or t.strip() == kw:
75
+ return "yes"
76
+ for kw in HAUSA_NO:
77
+ if f" {kw} " in t or t.strip() == kw:
78
+ return "no"
79
+ return None
80
+
81
+
82
+ def _contains_human_keyword(text: str) -> bool:
83
+ t = text.lower()
84
+ return any(kw in t for kw in HUMAN_KEYWORDS)
85
+
86
+
87
+ # Keyword fast-path for common intents. Runs BEFORE NLLB+Qwen so that the
88
+ # scripted demo flows don't require a 6GB LLM load. Phrases are Hausa and
89
+ # English pairs that customers actually use. When none match, we fall
90
+ # through to NLLB+Qwen for paraphrases.
91
+ INTENT_KEYWORDS = {
92
+ "check_balance": [
93
+ "duba ma'auni", "ma'auni", "balance", "check balance",
94
+ "account balance", "how much", "kudin asusu",
95
+ ],
96
+ "block_card": [
97
+ "toshe kati", "block card", "cancel card", "freeze card",
98
+ "toshe", "lost card", "ɓatar da kati",
99
+ ],
100
+ "transfer_money": [
101
+ "canjin kuɗi", "canjin kudi", "transfer", "transfer money",
102
+ "send money", "aiki kuɗi", "aiki kudi",
103
+ ],
104
+ "buy_airtime": [
105
+ "saya airtime", "airtime", "buy airtime", "top up", "topup",
106
+ "recharge", "karɓi airtime",
107
+ ],
108
+ "buy_bundle": [
109
+ "saya bundle", "bundle", "buy bundle", "buy data", "data",
110
+ "internet", "megabyte",
111
+ ],
112
+ "complaint": [
113
+ "yin korafi", "korafi", "complaint", "complain", "problem",
114
+ "matsala", "file complaint",
115
+ ],
116
+ "check_order": [
117
+ "bincika oda", "oda", "check order", "order status", "my order",
118
+ "where is my order", "track order",
119
+ ],
120
+ "reschedule": [
121
+ "sake tsara", "reschedule", "change time", "another day",
122
+ "later", "tomorrow",
123
+ ],
124
+ "return_item": [
125
+ "mayar da kaya", "return", "return item", "send back", "mayar",
126
+ ],
127
+ }
128
+
129
+
130
+ def _match_intent_keyword(text: str) -> Optional[str]:
131
+ """Keyword fast-path for common customer-service intents.
132
+ Returns the intent name if a keyword matches, else None."""
133
+ t = text.lower().strip()
134
+ # Check longer phrases first so "check balance" wins over "check order"
135
+ all_kw = [(intent, kw) for intent, kws in INTENT_KEYWORDS.items() for kw in kws]
136
+ all_kw.sort(key=lambda x: len(x[1]), reverse=True)
137
+ for intent, kw in all_kw:
138
+ if kw in t:
139
+ return intent
140
+ return None
141
+
142
+
143
+ def _looks_english(text: str) -> bool:
144
+ """Heuristic: if text contains no Hausa-specific characters and is majority
145
+ ASCII, treat as English and skip NLLB translation. Hausa uses ɓ, ɗ, ƙ, ƴ
146
+ and the apostrophe in "a'a", "ma'auni", "jumma'a" etc."""
147
+ hausa_chars = set("ɓɗƙƴƁƊƘƳ")
148
+ if any(c in hausa_chars for c in text):
149
+ return False
150
+ # Common Hausa words — if any match, treat as Hausa
151
+ hausa_markers = {
152
+ "duba", "ma'auni", "toshe", "kati", "canjin", "kuɗi", "kudi",
153
+ "saya", "airtime", "bundle", "korafi", "bincika", "oda",
154
+ "sake", "tsara", "mayar", "kaya", "wakili", "mutum",
155
+ "sannu", "nagode", "don", "allah", "ka", "yana", "tana",
156
+ "dubu", "ɗari", "dari", "biyar", "biyu", "uku", "hudu", "huɗu",
157
+ }
158
+ tokens = set(text.lower().split())
159
+ return not bool(tokens & hausa_markers)
160
+
161
+
162
+ # ---------------------------------------------------------------------------
163
+ # NLLB-200 Ha → En translation (lazy-loaded)
164
+ # ---------------------------------------------------------------------------
165
+ _nllb_model = None
166
+ _nllb_tokenizer = None
167
+ _nllb_failed = False
168
+
169
+
170
+ def _load_nllb():
171
+ """Lazy-load NLLB-200-distilled-600M."""
172
+ global _nllb_model, _nllb_tokenizer, _nllb_failed
173
+ if _nllb_failed:
174
+ return None, None
175
+ if _nllb_model is not None:
176
+ return _nllb_model, _nllb_tokenizer
177
+ try:
178
+ import torch
179
+ from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
180
+ logger.info("Loading NLLB-200-distilled-600M…")
181
+ model_id = "facebook/nllb-200-distilled-600M"
182
+ _nllb_tokenizer = AutoTokenizer.from_pretrained(model_id)
183
+ _nllb_model = AutoModelForSeq2SeqLM.from_pretrained(
184
+ model_id,
185
+ torch_dtype=torch.float32,
186
+ low_cpu_mem_usage=True,
187
+ )
188
+ _nllb_model.eval()
189
+ logger.info("NLLB-200 ready.")
190
+ return _nllb_model, _nllb_tokenizer
191
+ except Exception as e:
192
+ logger.warning(f"NLLB load failed: {e}")
193
+ _nllb_failed = True
194
+ return None, None
195
+
196
+
197
+ def translate_ha_to_en(text: str) -> Optional[str]:
198
+ """Translate Hausa to English via NLLB. Returns None on failure."""
199
+ model, tokenizer = _load_nllb()
200
+ if model is None or not text.strip():
201
+ return None
202
+ try:
203
+ import torch
204
+ # NLLB requires source language token set on tokenizer
205
+ tokenizer.src_lang = "hau_Latn"
206
+ inputs = tokenizer(text, return_tensors="pt", truncation=True, max_length=128)
207
+ # Force English output via forced_bos_token_id
208
+ forced_bos_id = tokenizer.convert_tokens_to_ids("eng_Latn")
209
+ with torch.no_grad():
210
+ out = model.generate(
211
+ **inputs,
212
+ forced_bos_token_id=forced_bos_id,
213
+ max_new_tokens=128,
214
+ num_beams=2,
215
+ )
216
+ translated = tokenizer.batch_decode(out, skip_special_tokens=True)[0].strip()
217
+ logger.info(f"NLLB Ha→En: {text!r} → {translated!r}")
218
+ return translated
219
+ except Exception as e:
220
+ logger.warning(f"NLLB translate failed: {e}")
221
+ return None
222
+
223
+
224
+ # ---------------------------------------------------------------------------
225
+ # Qwen2.5-1.5B intent classifier (operates on English text)
226
+ # ---------------------------------------------------------------------------
227
+ _llm_model = None
228
+ _llm_tokenizer = None
229
+ _llm_failed = False
230
+
231
+
232
+ def _load_llm():
233
+ global _llm_model, _llm_tokenizer, _llm_failed
234
+ if _llm_failed:
235
+ return None, None
236
+ if _llm_model is not None:
237
+ return _llm_model, _llm_tokenizer
238
+ try:
239
+ import torch
240
+ from transformers import AutoModelForCausalLM, AutoTokenizer
241
+ logger.info("Loading Qwen2.5-1.5B-Instruct…")
242
+ model_id = "Qwen/Qwen2.5-1.5B-Instruct"
243
+ _llm_tokenizer = AutoTokenizer.from_pretrained(model_id)
244
+ _llm_model = AutoModelForCausalLM.from_pretrained(
245
+ model_id,
246
+ torch_dtype=torch.float32,
247
+ low_cpu_mem_usage=True,
248
+ )
249
+ _llm_model.eval()
250
+ logger.info("Qwen2.5-1.5B ready.")
251
+ return _llm_model, _llm_tokenizer
252
+ except Exception as e:
253
+ logger.warning(f"Qwen load failed: {e}")
254
+ _llm_failed = True
255
+ return None, None
256
+
257
+
258
+ CANDIDATE_INTENTS = {
259
+ None: ["check_balance", "block_card", "transfer_money",
260
+ "buy_airtime", "buy_bundle", "complaint",
261
+ "check_order", "reschedule", "return_item",
262
+ "human_agent", "unknown"],
263
+ "intent": ["check_balance", "block_card", "transfer_money",
264
+ "buy_airtime", "buy_bundle", "complaint",
265
+ "check_order", "reschedule", "return_item",
266
+ "human_agent", "unknown"],
267
+ "yesno": ["yes", "no", "human_agent", "unknown"],
268
+ "name": ["provide_name", "human_agent", "unknown"],
269
+ "date": ["provide_date", "human_agent", "unknown"],
270
+ "bundle": ["provide_bundle", "human_agent", "unknown"],
271
+ "text": ["provide_text", "human_agent", "unknown"],
272
+ }
273
+
274
+
275
+ SYSTEM_PROMPT = """You are an intent classifier for a customer-service voice bot.
276
+
277
+ You will be given an English-language utterance (translated from Hausa) and a list of candidate intents. Return JSON with the single best-matching intent and any entities you can extract.
278
+
279
+ Intent meanings:
280
+ - check_balance: user wants to check an account balance
281
+ - block_card: user wants to block, freeze, or cancel a bank card
282
+ - transfer_money: user wants to send or transfer money
283
+ - buy_airtime: user wants to buy phone airtime / top-up
284
+ - buy_bundle: user wants to buy a data bundle / internet package
285
+ - complaint: user wants to file a complaint or report a problem
286
+ - check_order: user wants to check the status of an order
287
+ - reschedule: user wants to reschedule a delivery
288
+ - return_item: user wants to return an item
289
+ - human_agent: user wants to speak to a human person
290
+ - yes / no: affirmative or negative reply
291
+ - provide_name / provide_date / provide_bundle / provide_text: user is supplying information
292
+ - unknown: cannot determine intent
293
+
294
+ Return ONLY valid JSON. No explanation, no markdown. Example: {"intent": "check_balance", "entities": {}}"""
295
+
296
+
297
+ def _qwen_classify(english_text: str, expected: Optional[str]) -> Optional[tuple[str, dict]]:
298
+ """Classify an English utterance into an intent. Returns None on failure."""
299
+ model, tokenizer = _load_llm()
300
+ if model is None:
301
+ return None
302
+
303
+ candidates = CANDIDATE_INTENTS.get(expected, CANDIDATE_INTENTS[None])
304
+ user_prompt = (
305
+ f'Utterance: "{english_text}"\n'
306
+ f'Candidate intents: {", ".join(candidates)}\n\n'
307
+ 'Return JSON only.'
308
+ )
309
+ messages = [
310
+ {"role": "system", "content": SYSTEM_PROMPT},
311
+ {"role": "user", "content": user_prompt},
312
+ ]
313
+ try:
314
+ import torch
315
+ prompt = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
316
+ inputs = tokenizer(prompt, return_tensors="pt")
317
+ with torch.no_grad():
318
+ out = model.generate(
319
+ **inputs,
320
+ max_new_tokens=60,
321
+ do_sample=False,
322
+ pad_token_id=tokenizer.eos_token_id,
323
+ )
324
+ generated = tokenizer.decode(out[0][inputs.input_ids.shape[1]:], skip_special_tokens=True).strip()
325
+ logger.info(f"Qwen raw: {generated}")
326
+
327
+ m = re.search(r"\{.*?\}", generated, re.DOTALL)
328
+ if not m:
329
+ return None
330
+ parsed = json.loads(m.group())
331
+ intent = parsed.get("intent", "unknown")
332
+ entities = parsed.get("entities", {}) or {}
333
+ if not isinstance(entities, dict):
334
+ entities = {}
335
+ if intent not in candidates:
336
+ logger.info(f"Qwen returned out-of-candidate intent: {intent}")
337
+ return None
338
+ return intent, entities
339
+ except Exception as e:
340
+ logger.warning(f"Qwen inference failed: {e}")
341
+ return None
342
+
343
+
344
+ # ---------------------------------------------------------------------------
345
+ # Public API
346
+ # ---------------------------------------------------------------------------
347
+ def parse(text: str, expected: Optional[str] = None,
348
+ use_llm: bool = True) -> tuple[str, dict, str]:
349
+ """
350
+ NLU. Returns (intent, entities, source) where source is one of:
351
+ - 'structural': deterministic extractor caught it (digits, amount, yes/no)
352
+ - 'nllb+qwen': translated via NLLB and classified via Qwen
353
+ - 'human_keyword': caught human-agent escape hatch by keyword
354
+ - 'unknown': nothing matched
355
+ """
356
+ entities: dict = {}
357
+ if not text or not text.strip():
358
+ return "unknown", entities, "unknown"
359
+
360
+ # Always-on human-agent escape (safety)
361
+ if _contains_human_keyword(text):
362
+ return "human_agent", entities, "human_keyword"
363
+
364
+ # Layer 1: deterministic structural extractors for strict-format slots
365
+ if expected == "digits":
366
+ d = _extract_digits(text)
367
+ if d:
368
+ entities["digits"] = d
369
+ return "provide_digits", entities, "structural"
370
+
371
+ if expected == "amount":
372
+ a = _extract_amount(text)
373
+ if a is not None:
374
+ entities["amount"] = a
375
+ return "provide_amount", entities, "structural"
376
+
377
+ if expected == "yesno":
378
+ yn = _match_yesno(text)
379
+ if yn:
380
+ return yn, entities, "structural"
381
+
382
+ if expected == "name":
383
+ # Name is free-form; take the last token as a quick heuristic. Qwen
384
+ # would not help here — names don't translate meaningfully.
385
+ name = text.strip().split()[-1] if text.strip() else ""
386
+ if name:
387
+ entities["name"] = name
388
+ return "provide_name", entities, "structural"
389
+
390
+ if expected == "date":
391
+ entities["date"] = text.strip()
392
+ return "provide_date", entities, "structural"
393
+
394
+ # Layer 1.5: Keyword fast-path for common intents (Hausa + English).
395
+ # Runs in ANY state so users can pivot intent mid-flow ("actually I want
396
+ # to transfer money instead"). Structural extractors above already
397
+ # claimed the strict-slot cases (digits, amounts, yes/no), so by this
398
+ # point if we're in a slot-filling state and the text didn't match
399
+ # the slot, it's fair game to re-interpret as a new intent.
400
+ kw_intent = _match_intent_keyword(text)
401
+ if kw_intent:
402
+ logger.info(f"NLU: keyword matched {text!r} → {kw_intent}")
403
+ return kw_intent, entities, "keyword"
404
+
405
+ # Layer 2: NLLB Ha → En (skip if input already English), then Qwen
406
+ if not use_llm:
407
+ logger.info(f"NLU: use_llm=False, returning unknown for {text!r}")
408
+ return "unknown", entities, "unknown"
409
+
410
+ if _looks_english(text):
411
+ logger.info(f"NLU: input looks English, skipping NLLB: {text!r}")
412
+ english_text = text
413
+ source_tag = "qwen_en"
414
+ else:
415
+ logger.info(f"NLU: translating Hausa via NLLB: {text!r}")
416
+ english_text = translate_ha_to_en(text)
417
+ if english_text is None:
418
+ logger.warning("NLU: NLLB failed, returning unknown")
419
+ return "unknown", entities, "unknown"
420
+ source_tag = "nllb+qwen"
421
+
422
+ qwen_result = _qwen_classify(english_text, expected)
423
+ if qwen_result is None:
424
+ logger.warning(f"NLU: Qwen returned no valid intent for {english_text!r}")
425
+ return "unknown", entities, "unknown"
426
+
427
+ intent, llm_entities = qwen_result
428
+ logger.info(f"NLU: Qwen classified {english_text!r} → intent={intent}")
429
+
430
+ # For free-text slots, pass the original Hausa text through
431
+ if expected == "bundle":
432
+ t = text.lower()
433
+ for b in ("rana", "mako", "wata"):
434
+ if b in t:
435
+ llm_entities["bundle"] = b
436
+ break
437
+
438
+ if expected == "text":
439
+ llm_entities["text"] = text.strip()
440
+
441
+ return intent, llm_entities, source_tag