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

Update nlu.py

Browse files
Files changed (1) hide show
  1. nlu.py +80 -60
nlu.py CHANGED
@@ -2,25 +2,32 @@
2
  NLU — Embedding similarity architecture.
3
  =========================================
4
  Replaces the legacy NLLB+Qwen pipeline (preserved in nlu_legacy.py).
5
-
6
  Why embeddings?
7
  - Latency: ~200ms vs ~10s on CPU for the legacy stack
8
  - Memory: ~420MB vs ~8GB
9
  - Hausa coverage: paraphrase-multilingual-MiniLM-L12-v2 was trained on 50+
10
  languages including Hausa, so we no longer need a translation step
11
  - Confidence comes for free: cosine similarity IS a calibrated confidence
12
-
13
  Pipeline (in order):
14
  Layer 0: Human-keyword escape ("wakili", "agent") → always wins
15
- Layer 1: Structural extractors (digits, amounts, yes/no, name, date)
16
- when the dialogue state has expected_slot set
17
  Layer 1.5: Keyword fast-path for ultra-common phrases ("duba ma'auni")
18
  — sub-millisecond, no model call
19
  Layer 2: Sentence-embedding similarity vs per-intent centroids
20
  — cosine sim ≥ threshold (0.4) → that intent, else unknown
21
-
22
  The dialogue manager receives the same (intent, entities, source) tuple
23
  as before, so app.py needs no changes.
 
 
 
 
 
 
 
 
 
 
24
  """
25
  from __future__ import annotations
26
  import re
@@ -45,11 +52,17 @@ WORD_AMOUNTS = {
45
  "ɗari": 100, "dari": 100,
46
  }
47
 
48
- HAUSA_YES = {"i", "eh", "haka ne", "haka", "ok", "okay", "yes"}
49
- HAUSA_NO = {"a'a", "a'aa", "ba haka", "ba", "no"}
 
 
 
 
50
 
51
  HUMAN_KEYWORDS = {"mutum", "wakili", "agent", "human"}
52
 
 
 
53
 
54
  def _extract_digits(text: str) -> Optional[str]:
55
  m = re.findall(r"\d+", text)
@@ -72,19 +85,22 @@ def _extract_amount(text: str) -> Optional[int]:
72
 
73
 
74
  def _match_yesno(text: str) -> Optional[str]:
75
- t = " " + text.lower().strip() + " "
76
- for kw in HAUSA_YES:
77
- if f" {kw} " in t or t.strip() == kw:
78
- return "yes"
79
- for kw in HAUSA_NO:
80
- if f" {kw} " in t or t.strip() == kw:
81
- return "no"
 
 
 
82
  return None
83
 
84
 
85
  def _contains_human_keyword(text: str) -> bool:
86
- t = text.lower()
87
- return any(kw in t for kw in HUMAN_KEYWORDS)
88
 
89
 
90
  # ---------------------------------------------------------------------------
@@ -130,11 +146,13 @@ INTENT_KEYWORDS = {
130
 
131
 
132
  def _match_intent_keyword(text: str) -> Optional[str]:
133
- t = text.lower().strip()
 
 
134
  all_kw = [(intent, kw) for intent, kws in INTENT_KEYWORDS.items() for kw in kws]
135
  all_kw.sort(key=lambda x: len(x[1]), reverse=True)
136
  for intent, kw in all_kw:
137
- if kw in t:
138
  return intent
139
  return None
140
 
@@ -148,14 +166,12 @@ def _match_intent_keyword(text: str) -> Optional[str]:
148
  # ---------------------------------------------------------------------------
149
  INTENT_EXAMPLES = {
150
  "check_balance": [
151
- # Hausa
152
  "duba ma'auni",
153
  "ina son sanin kuɗin asusuna",
154
  "nawa ne a asusuna",
155
  "menene ma'aunin asusuna",
156
  "yi mini bayanin asusuna",
157
  "ina son ganin kuɗina",
158
- # English
159
  "check my balance",
160
  "what is my account balance",
161
  "how much money do I have",
@@ -289,9 +305,8 @@ INTENT_EXAMPLES = {
289
 
290
 
291
  # Confidence threshold: cosine similarities below this become 'unknown'.
292
- # Tuned by hand at 0.4; lower if too many things are routed to 'unknown',
293
- # raise if too many incorrect intents get through. See nlu/tests for the
294
- # validation methodology.
295
  CONFIDENCE_THRESHOLD = 0.4
296
 
297
  # Embedding model. Multilingual (50+ languages), 420MB, CPU-fast.
@@ -307,7 +322,10 @@ _embed_failed = False
307
 
308
 
309
  def _load_encoder():
310
- """Lazy-load the sentence encoder + compute intent centroids."""
 
 
 
311
  global _encoder, _intent_centroids, _embed_failed
312
  if _embed_failed:
313
  return None
@@ -317,7 +335,7 @@ def _load_encoder():
317
  import numpy as np
318
  from sentence_transformers import SentenceTransformer
319
  logger.info(f"Loading embedding model {EMBEDDING_MODEL_ID}…")
320
- _encoder = SentenceTransformer(EMBEDDING_MODEL_ID)
321
  logger.info("Computing intent centroids…")
322
  _intent_centroids = {}
323
  for intent, phrases in INTENT_EXAMPLES.items():
@@ -335,32 +353,27 @@ def _load_encoder():
335
  return None
336
 
337
 
338
- def _classify_with_embedding(text: str, expected: Optional[str]) -> Optional[tuple[str, float]]:
339
- """Cosine similarity vs intent centroids. Returns (intent, confidence)
340
- or None on failure. Respects expected_slot if it constrains valid intents."""
 
 
 
 
 
 
341
  encoder = _load_encoder()
342
  if encoder is None or _intent_centroids is None:
343
  return None
344
  try:
345
  import numpy as np
346
  query = encoder.encode(text, normalize_embeddings=True)
347
-
348
- # If expected_slot constrains the answer space, filter candidates.
349
- # For 'yesno', embedding NLU shouldn't fire — yes/no is handled by
350
- # the structural layer. If we get here with yesno expected, it means
351
- # the user said something non-standard; we treat that as a possible
352
- # intent pivot (any intent is fair game).
353
- valid_intents = list(_intent_centroids.keys())
354
-
355
- scores = {}
356
- for intent in valid_intents:
357
- centroid = _intent_centroids[intent]
358
- scores[intent] = float(np.dot(query, centroid))
359
-
360
  best_intent = max(scores, key=scores.get)
361
  best_score = scores[best_intent]
362
- logger.info(f"NLU embedding: top match {best_intent}@{best_score:.3f}, "
363
- f"all scores: { {k: round(v,3) for k,v in sorted(scores.items(), key=lambda x: -x[1])[:3]} }")
364
  return best_intent, best_score
365
  except Exception as e:
366
  logger.warning(f"Embedding classification failed: {e}")
@@ -374,12 +387,11 @@ def parse(text: str, expected: Optional[str] = None,
374
  use_llm: bool = True) -> tuple[str, dict, str]:
375
  """
376
  NLU entry point. Returns (intent, entities, source) where source is:
377
- - 'structural': digit/amount/yes-no/name/date regex matched
378
- - 'keyword': keyword fast-path matched
379
- - 'embedding': sentence encoder matched above threshold
380
  - 'human_keyword': escape-hatch keyword caught
381
- - 'unknown': nothing matched
382
-
383
  `use_llm` is a misnomer kept for backward compat with the legacy module's
384
  signature — here it means "use the embedding layer". Set False to test
385
  rule-only behavior.
@@ -392,7 +404,7 @@ def parse(text: str, expected: Optional[str] = None,
392
  if _contains_human_keyword(text):
393
  return "human_agent", entities, "human_keyword"
394
 
395
- # Layer 1: Structural extractors for strict-format slots
396
  if expected == "digits":
397
  d = _extract_digits(text)
398
  if d:
@@ -411,6 +423,9 @@ def parse(text: str, expected: Optional[str] = None,
411
  return yn, entities, "structural"
412
 
413
  if expected == "name":
 
 
 
414
  name = text.strip().split()[-1] if text.strip() else ""
415
  if name:
416
  entities["name"] = name
@@ -420,6 +435,21 @@ def parse(text: str, expected: Optional[str] = None,
420
  entities["date"] = text.strip()
421
  return "provide_date", entities, "structural"
422
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
423
  # Layer 1.5: Keyword fast-path (cheap, runs in any state so users can
424
  # pivot intent mid-flow).
425
  kw_intent = _match_intent_keyword(text)
@@ -432,7 +462,7 @@ def parse(text: str, expected: Optional[str] = None,
432
  logger.info(f"NLU: use_llm=False, returning unknown for {text!r}")
433
  return "unknown", entities, "unknown"
434
 
435
- embed_result = _classify_with_embedding(text, expected)
436
  if embed_result is None:
437
  logger.warning(f"NLU embedding unavailable, returning unknown for {text!r}")
438
  return "unknown", entities, "unknown"
@@ -443,15 +473,5 @@ def parse(text: str, expected: Optional[str] = None,
443
  f"{CONFIDENCE_THRESHOLD}, returning unknown")
444
  return "unknown", entities, "unknown"
445
 
446
- # Free-text slot pass-through (preserve original Hausa)
447
- if expected == "bundle":
448
- t = text.lower()
449
- for b in ("rana", "mako", "wata"):
450
- if b in t:
451
- entities["bundle"] = b
452
- break
453
- if expected == "text":
454
- entities["text"] = text.strip()
455
-
456
  logger.info(f"NLU embedding accepted: {text!r} → {intent} (conf={confidence:.3f})")
457
  return intent, entities, "embedding"
 
2
  NLU — Embedding similarity architecture.
3
  =========================================
4
  Replaces the legacy NLLB+Qwen pipeline (preserved in nlu_legacy.py).
 
5
  Why embeddings?
6
  - Latency: ~200ms vs ~10s on CPU for the legacy stack
7
  - Memory: ~420MB vs ~8GB
8
  - Hausa coverage: paraphrase-multilingual-MiniLM-L12-v2 was trained on 50+
9
  languages including Hausa, so we no longer need a translation step
10
  - Confidence comes for free: cosine similarity IS a calibrated confidence
 
11
  Pipeline (in order):
12
  Layer 0: Human-keyword escape ("wakili", "agent") → always wins
13
+ Layer 1: Structural extractors (digits, amounts, yes/no, name, date, free
14
+ text, bundle) when the dialogue state sets an expected slot
15
  Layer 1.5: Keyword fast-path for ultra-common phrases ("duba ma'auni")
16
  — sub-millisecond, no model call
17
  Layer 2: Sentence-embedding similarity vs per-intent centroids
18
  — cosine sim ≥ threshold (0.4) → that intent, else unknown
 
19
  The dialogue manager receives the same (intent, entities, source) tuple
20
  as before, so app.py needs no changes.
21
+
22
+ Fixes vs the POC
23
+ ----------------
24
+ * _match_yesno no longer treats English "I" (inside any sentence) as Hausa
25
+ "i"=yes via substring containment; short tokens match exactly.
26
+ * _match_intent_keyword matches keywords as whole whitespace-delimited tokens,
27
+ so short keywords ("ba", "data", "oda") can't fire inside unrelated words.
28
+ * expected == "text" / "bundle" now emit the provide_text / provide_bundle
29
+ intents the FSM transitions expect — previously these slots were never
30
+ satisfied, silently breaking the complaint, bundle, and return flows.
31
  """
32
  from __future__ import annotations
33
  import re
 
52
  "ɗari": 100, "dari": 100,
53
  }
54
 
55
+ # Short yes/no tokens: matched EXACTLY (whole utterance) to avoid substring
56
+ # false positives. Multi-word cues are matched as whitespace-bounded phrases.
57
+ HAUSA_YES_EXACT = {"i", "eh", "ok", "okay", "yes"}
58
+ HAUSA_YES_PHRASE = {"haka ne", "haka"}
59
+ HAUSA_NO_EXACT = {"a'a", "a'aa", "ba", "no"}
60
+ HAUSA_NO_PHRASE = {"ba haka"}
61
 
62
  HUMAN_KEYWORDS = {"mutum", "wakili", "agent", "human"}
63
 
64
+ BUNDLE_TYPES = ("rana", "mako", "wata")
65
+
66
 
67
  def _extract_digits(text: str) -> Optional[str]:
68
  m = re.findall(r"\d+", text)
 
85
 
86
 
87
  def _match_yesno(text: str) -> Optional[str]:
88
+ t = text.lower().strip()
89
+ if t in HAUSA_YES_EXACT:
90
+ return "yes"
91
+ if t in HAUSA_NO_EXACT:
92
+ return "no"
93
+ padded = f" {t} "
94
+ if any(f" {kw} " in padded for kw in HAUSA_YES_PHRASE):
95
+ return "yes"
96
+ if any(f" {kw} " in padded for kw in HAUSA_NO_PHRASE):
97
+ return "no"
98
  return None
99
 
100
 
101
  def _contains_human_keyword(text: str) -> bool:
102
+ padded = f" {text.lower().strip()} "
103
+ return any(f" {kw} " in padded for kw in HUMAN_KEYWORDS)
104
 
105
 
106
  # ---------------------------------------------------------------------------
 
146
 
147
 
148
  def _match_intent_keyword(text: str) -> Optional[str]:
149
+ # Whitespace-bounded match: the keyword must appear as a whole token (or
150
+ # token sequence), never as a fragment inside another word.
151
+ padded = f" {text.lower().strip()} "
152
  all_kw = [(intent, kw) for intent, kws in INTENT_KEYWORDS.items() for kw in kws]
153
  all_kw.sort(key=lambda x: len(x[1]), reverse=True)
154
  for intent, kw in all_kw:
155
+ if f" {kw} " in padded:
156
  return intent
157
  return None
158
 
 
166
  # ---------------------------------------------------------------------------
167
  INTENT_EXAMPLES = {
168
  "check_balance": [
 
169
  "duba ma'auni",
170
  "ina son sanin kuɗin asusuna",
171
  "nawa ne a asusuna",
172
  "menene ma'aunin asusuna",
173
  "yi mini bayanin asusuna",
174
  "ina son ganin kuɗina",
 
175
  "check my balance",
176
  "what is my account balance",
177
  "how much money do I have",
 
305
 
306
 
307
  # Confidence threshold: cosine similarities below this become 'unknown'.
308
+ # Tuned by hand at 0.4 re-tune with eval_nlu.py (threshold sweep) once you
309
+ # have real Hausa traffic from the turn logs.
 
310
  CONFIDENCE_THRESHOLD = 0.4
311
 
312
  # Embedding model. Multilingual (50+ languages), 420MB, CPU-fast.
 
322
 
323
 
324
  def _load_encoder():
325
+ """Lazy-load the sentence encoder + compute intent centroids.
326
+ Pinned to CPU: on ZeroGPU the GPU isn't attached at import time, and the
327
+ encoder is fast enough on CPU (~200ms) that a GPU round-trip would be a net
328
+ loss — only ASR/TTS belong on the GPU."""
329
  global _encoder, _intent_centroids, _embed_failed
330
  if _embed_failed:
331
  return None
 
335
  import numpy as np
336
  from sentence_transformers import SentenceTransformer
337
  logger.info(f"Loading embedding model {EMBEDDING_MODEL_ID}…")
338
+ _encoder = SentenceTransformer(EMBEDDING_MODEL_ID, device="cpu")
339
  logger.info("Computing intent centroids…")
340
  _intent_centroids = {}
341
  for intent, phrases in INTENT_EXAMPLES.items():
 
353
  return None
354
 
355
 
356
+ def _classify_with_embedding(text: str) -> Optional[tuple[str, float]]:
357
+ """Cosine similarity vs all intent centroids. Returns (intent, confidence)
358
+ or None on failure.
359
+
360
+ Note: we deliberately score against every intent rather than constraining by
361
+ the dialogue's expected slot. This is what lets a caller pivot mid-flow
362
+ (e.g. say "transfer money" while we're asking for account digits). The
363
+ expected-slot constraint is enforced upstream by the structural extractors
364
+ in parse(), not here."""
365
  encoder = _load_encoder()
366
  if encoder is None or _intent_centroids is None:
367
  return None
368
  try:
369
  import numpy as np
370
  query = encoder.encode(text, normalize_embeddings=True)
371
+ scores = {intent: float(np.dot(query, centroid))
372
+ for intent, centroid in _intent_centroids.items()}
 
 
 
 
 
 
 
 
 
 
 
373
  best_intent = max(scores, key=scores.get)
374
  best_score = scores[best_intent]
375
+ top3 = {k: round(v, 3) for k, v in sorted(scores.items(), key=lambda x: -x[1])[:3]}
376
+ logger.info(f"NLU embedding: top match {best_intent}@{best_score:.3f}, top3: {top3}")
377
  return best_intent, best_score
378
  except Exception as e:
379
  logger.warning(f"Embedding classification failed: {e}")
 
387
  use_llm: bool = True) -> tuple[str, dict, str]:
388
  """
389
  NLU entry point. Returns (intent, entities, source) where source is:
390
+ - 'structural': digit/amount/yes-no/name/date/text/bundle matched
391
+ - 'keyword': keyword fast-path matched
392
+ - 'embedding': sentence encoder matched above threshold
393
  - 'human_keyword': escape-hatch keyword caught
394
+ - 'unknown': nothing matched
 
395
  `use_llm` is a misnomer kept for backward compat with the legacy module's
396
  signature — here it means "use the embedding layer". Set False to test
397
  rule-only behavior.
 
404
  if _contains_human_keyword(text):
405
  return "human_agent", entities, "human_keyword"
406
 
407
+ # Layer 1: Structural extractors for slot-filling states
408
  if expected == "digits":
409
  d = _extract_digits(text)
410
  if d:
 
423
  return yn, entities, "structural"
424
 
425
  if expected == "name":
426
+ # NOTE: naive — takes the last token, so "Musa Ibrahim" → "Ibrahim".
427
+ # Fine for single-name demos; add a recipient-confirmation turn before
428
+ # using this for real money movement.
429
  name = text.strip().split()[-1] if text.strip() else ""
430
  if name:
431
  entities["name"] = name
 
435
  entities["date"] = text.strip()
436
  return "provide_date", entities, "structural"
437
 
438
+ if expected == "text":
439
+ # Free-text capture (complaint body, return reason). Layer 0 already
440
+ # handled an explicit human-agent request, so anything else is content.
441
+ entities["text"] = text.strip()
442
+ return "provide_text", entities, "structural"
443
+
444
+ if expected == "bundle":
445
+ t = text.lower()
446
+ for b in BUNDLE_TYPES:
447
+ if f" {b} " in f" {t} " or t.strip() == b:
448
+ entities["bundle"] = b
449
+ return "provide_bundle", entities, "structural"
450
+ # No recognized bundle word — fall through so the user can still pivot
451
+ # (e.g. change their mind to airtime) or get a fallback re-prompt.
452
+
453
  # Layer 1.5: Keyword fast-path (cheap, runs in any state so users can
454
  # pivot intent mid-flow).
455
  kw_intent = _match_intent_keyword(text)
 
462
  logger.info(f"NLU: use_llm=False, returning unknown for {text!r}")
463
  return "unknown", entities, "unknown"
464
 
465
+ embed_result = _classify_with_embedding(text)
466
  if embed_result is None:
467
  logger.warning(f"NLU embedding unavailable, returning unknown for {text!r}")
468
  return "unknown", entities, "unknown"
 
473
  f"{CONFIDENCE_THRESHOLD}, returning unknown")
474
  return "unknown", entities, "unknown"
475
 
 
 
 
 
 
 
 
 
 
 
476
  logger.info(f"NLU embedding accepted: {text!r} → {intent} (conf={confidence:.3f})")
477
  return intent, entities, "embedding"