Toadoum commited on
Commit
d8a53e2
·
verified ·
1 Parent(s): ddbabb4

Update nlu.py

Browse files
Files changed (1) hide show
  1. nlu.py +185 -146
nlu.py CHANGED
@@ -1,14 +1,24 @@
1
  """
2
- NLU — Hybrid Hausa intent + entity extraction.
3
-
4
- Three-tier architecture:
5
- 1. Rule-based keyword matcher (fast path, ~80% of demo utterances)
6
- 2. Qwen2.5-1.5B-Instruct zero-shot JSON extractor (paraphrases, novel phrasings)
7
- 3. Rule-based fallback (if LLM fails or returns unparseable output)
8
-
9
- The LLM is lazy-loaded on first non-matched utterance so the Space boots fast.
10
- In production this would be replaced with a fine-tuned classifier on
11
- PlotWeaver's Hausa intent corpus.
 
 
 
 
 
 
 
 
 
 
12
  """
13
  from __future__ import annotations
14
  import re
@@ -18,24 +28,10 @@ from typing import Optional
18
 
19
  logger = logging.getLogger("plotweaver.nlu")
20
 
 
21
  # ---------------------------------------------------------------------------
22
- # Layer 1: rule-based fast path (covers common demo phrases)
23
  # ---------------------------------------------------------------------------
24
- INTENT_KEYWORDS = {
25
- "check_balance": ["duba", "ma'auni", "balance", "kudi", "asusu"],
26
- "block_card": ["toshe", "kati", "block"],
27
- "transfer_money": ["tura", "canji", "canjin", "aika", "transfer"],
28
- "buy_airtime": ["airtime", "caji"],
29
- "buy_bundle": ["bundle", "data", "intanet"],
30
- "complaint": ["korafi", "matsala", "complain"],
31
- "check_order": ["bincika", "order", "oda"],
32
- "reschedule": ["sake tsara", "reschedule", "canja lokaci"],
33
- "return_item": ["mayar", "mayarwa", "return"],
34
- "human_agent": ["mutum", "wakili", "agent", "human"],
35
- "yes": ["i ", " i", "eh", "haka ne", "yes", "ok", "okay"],
36
- "no": ["a'a", "a'aa", "ba haka", " no", "no "],
37
- }
38
-
39
  WORD_DIGITS = {
40
  "sifili": "0", "daya": "1", "ɗaya": "1", "biyu": "2", "uku": "3",
41
  "hudu": "4", "huɗu": "4", "biyar": "5", "shida": "6", "bakwai": "7",
@@ -48,18 +44,12 @@ WORD_AMOUNTS = {
48
  "ɗari": 100, "dari": 100,
49
  }
50
 
 
 
 
51
 
52
- def _norm(t: str) -> str:
53
- return " " + t.lower().strip() + " "
54
-
55
-
56
- def _match_intent_kw(text: str) -> Optional[str]:
57
- t = _norm(text)
58
- for intent, kws in INTENT_KEYWORDS.items():
59
- for kw in kws:
60
- if kw in t:
61
- return intent
62
- return None
63
 
64
 
65
  def _extract_digits(text: str) -> Optional[str]:
@@ -82,71 +72,93 @@ def _extract_amount(text: str) -> Optional[int]:
82
  return None
83
 
84
 
85
- def _rule_based_parse(text: str, expected: Optional[str]) -> tuple[str, dict]:
86
- """Layer 1 + 3: deterministic keyword + slot matcher."""
87
- entities: dict = {}
88
- if not text or not text.strip():
89
- return "unknown", entities
90
-
91
- # Universal escape
92
- if _match_intent_kw(text) == "human_agent":
93
- return "human_agent", entities
94
-
95
- if expected == "digits":
96
- d = _extract_digits(text)
97
- if d:
98
- entities["digits"] = d
99
- return "provide_digits", entities
100
 
101
- if expected == "amount":
102
- a = _extract_amount(text)
103
- if a is not None:
104
- entities["amount"] = a
105
- return "provide_amount", entities
106
 
107
- if expected == "name":
108
- name = text.strip().split()[-1] if text.strip() else ""
109
- if name:
110
- entities["name"] = name
111
- return "provide_name", entities
112
 
113
- if expected == "date":
114
- entities["date"] = text.strip()
115
- return "provide_date", entities
116
 
117
- if expected == "bundle":
118
- t = text.lower()
119
- for b in ("rana", "mako", "wata"):
120
- if b in t:
121
- entities["bundle"] = b
122
- return "provide_bundle", entities
123
 
124
- if expected == "text":
125
- entities["text"] = text.strip()
126
- return "provide_text", entities
127
 
128
- if expected == "yesno":
129
- i = _match_intent_kw(text)
130
- if i in ("yes", "no"):
131
- return i, entities
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
 
133
- i = _match_intent_kw(text)
134
- if i:
135
- return i, entities
136
 
137
- return "unknown", entities
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
 
139
 
140
  # ---------------------------------------------------------------------------
141
- # Layer 2: Qwen2.5-1.5B-Instruct zero-shot NLU
142
  # ---------------------------------------------------------------------------
143
  _llm_model = None
144
  _llm_tokenizer = None
145
- _llm_failed = False # set to True after any load failure, to prevent retries
146
 
147
 
148
  def _load_llm():
149
- """Lazy-load Qwen2.5-1.5B-Instruct. Called only when rule-based misses."""
150
  global _llm_model, _llm_tokenizer, _llm_failed
151
  if _llm_failed:
152
  return None, None
@@ -155,25 +167,23 @@ def _load_llm():
155
  try:
156
  import torch
157
  from transformers import AutoModelForCausalLM, AutoTokenizer
158
- logger.info("Loading Qwen2.5-1.5B-Instruct for NLU…")
159
  model_id = "Qwen/Qwen2.5-1.5B-Instruct"
160
  _llm_tokenizer = AutoTokenizer.from_pretrained(model_id)
161
  _llm_model = AutoModelForCausalLM.from_pretrained(
162
  model_id,
163
- torch_dtype=torch.float32, # CPU — bfloat16 not broadly supported
164
  low_cpu_mem_usage=True,
165
  )
166
  _llm_model.eval()
167
- logger.info("Qwen2.5-1.5B-Instruct ready.")
168
  return _llm_model, _llm_tokenizer
169
  except Exception as e:
170
- logger.warning(f"LLM load failed: {e}")
171
  _llm_failed = True
172
  return None, None
173
 
174
 
175
- # Candidate intents per expected-slot context. Keeps the LLM prompt small
176
- # and constrains output to valid options only.
177
  CANDIDATE_INTENTS = {
178
  None: ["check_balance", "block_card", "transfer_money",
179
  "buy_airtime", "buy_bundle", "complaint",
@@ -184,8 +194,6 @@ CANDIDATE_INTENTS = {
184
  "check_order", "reschedule", "return_item",
185
  "human_agent", "unknown"],
186
  "yesno": ["yes", "no", "human_agent", "unknown"],
187
- "digits": ["provide_digits", "human_agent", "unknown"],
188
- "amount": ["provide_amount", "human_agent", "unknown"],
189
  "name": ["provide_name", "human_agent", "unknown"],
190
  "date": ["provide_date", "human_agent", "unknown"],
191
  "bundle": ["provide_bundle", "human_agent", "unknown"],
@@ -193,42 +201,39 @@ CANDIDATE_INTENTS = {
193
  }
194
 
195
 
196
- SYSTEM_PROMPT = """You are an intent classifier for a Hausa-language customer service voice agent.
197
 
198
- Analyze the user's Hausa utterance and return a JSON object with:
199
- - "intent": one of the candidate intents provided
200
- - "entities": a dict of extracted values (may be empty)
201
 
202
  Intent meanings:
203
- - check_balance: user wants to check their account balance
204
- - block_card: user wants to block or freeze their bank card
205
- - transfer_money: user wants to transfer or send money
206
- - buy_airtime: user wants to buy phone airtime
207
- - buy_bundle: user wants to buy a data bundle
208
- - complaint: user wants to file a complaint
209
- - check_order: user wants to check an order status
210
  - reschedule: user wants to reschedule a delivery
211
  - return_item: user wants to return an item
212
- - human_agent: user wants to speak to a human
213
- - yes / no: affirmative or negative response
214
- - provide_digits / provide_amount / provide_name / provide_date / provide_bundle / provide_text: user is providing specific information
215
- - unknown: cannot determine the intent
216
 
217
- Return ONLY a valid JSON object, no explanation. Example: {"intent": "check_balance", "entities": {}}"""
218
 
219
 
220
- def _llm_parse(text: str, expected: Optional[str]) -> Optional[tuple[str, dict]]:
221
- """Layer 2: zero-shot LLM classification. Returns None on any failure."""
222
  model, tokenizer = _load_llm()
223
  if model is None:
224
  return None
225
 
226
  candidates = CANDIDATE_INTENTS.get(expected, CANDIDATE_INTENTS[None])
227
  user_prompt = (
228
- f'Hausa utterance: "{text}"\n'
229
- f'Expected slot type: {expected or "any"}\n'
230
  f'Candidate intents: {", ".join(candidates)}\n\n'
231
- 'Respond with JSON only.'
232
  )
233
  messages = [
234
  {"role": "system", "content": SYSTEM_PROMPT},
@@ -241,14 +246,13 @@ def _llm_parse(text: str, expected: Optional[str]) -> Optional[tuple[str, dict]]
241
  with torch.no_grad():
242
  out = model.generate(
243
  **inputs,
244
- max_new_tokens=80,
245
  do_sample=False,
246
  pad_token_id=tokenizer.eos_token_id,
247
  )
248
  generated = tokenizer.decode(out[0][inputs.input_ids.shape[1]:], skip_special_tokens=True).strip()
249
- logger.info(f"LLM raw output: {generated}")
250
 
251
- # Extract JSON (model sometimes wraps it in markdown fences or prose)
252
  m = re.search(r"\{.*?\}", generated, re.DOTALL)
253
  if not m:
254
  return None
@@ -257,13 +261,12 @@ def _llm_parse(text: str, expected: Optional[str]) -> Optional[tuple[str, dict]]
257
  entities = parsed.get("entities", {}) or {}
258
  if not isinstance(entities, dict):
259
  entities = {}
260
- # Validate intent is in candidate list
261
  if intent not in candidates:
262
- logger.info(f"LLM returned out-of-candidate intent: {intent}")
263
  return None
264
  return intent, entities
265
  except Exception as e:
266
- logger.warning(f"LLM inference failed: {e}")
267
  return None
268
 
269
 
@@ -273,38 +276,74 @@ def _llm_parse(text: str, expected: Optional[str]) -> Optional[tuple[str, dict]]
273
  def parse(text: str, expected: Optional[str] = None,
274
  use_llm: bool = True) -> tuple[str, dict, str]:
275
  """
276
- Hybrid NLU. Returns (intent, entities, source) where source is one of
277
- 'rule', 'llm', or 'rule_fallback'.
278
-
279
- Flow:
280
- 1. Try rule-based keyword/slot matcher (fast, deterministic)
281
- 2. If result is 'unknown' AND use_llm=True: try Qwen2.5 zero-shot
282
- 3. If LLM fails or returns invalid output: return rule-based 'unknown'
283
  """
284
- intent, entities = _rule_based_parse(text, expected)
285
-
286
- if intent != "unknown":
287
- return intent, entities, "rule"
288
-
289
- if not use_llm:
290
- return intent, entities, "rule"
291
-
292
- # Rule-based missed — try LLM
293
- llm_result = _llm_parse(text, expected)
294
- if llm_result is None:
295
- return intent, entities, "rule_fallback"
296
 
297
- llm_intent, llm_entities = llm_result
 
 
298
 
299
- # Sanity-check entities for slot-typed expected (LLM might hallucinate
300
- # digits; re-run our deterministic extractors for strict-format slots)
301
  if expected == "digits":
302
  d = _extract_digits(text)
303
  if d:
304
- llm_entities["digits"] = d
305
- elif expected == "amount":
 
 
306
  a = _extract_amount(text)
307
  if a is not None:
308
- llm_entities["amount"] = a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
309
 
310
- return llm_intent, llm_entities, "llm"
 
1
  """
2
+ NLU — NLLB + Qwen pivot-through-English architecture.
3
+
4
+ Flow:
5
+ 1. Deterministic structural extractors run FIRST on the original Hausa
6
+ text (digits, amounts, yes/no keywords). These MUST be deterministic
7
+ because "1234" "provide_digits" with digits="1234" is non-negotiable
8
+ for banks, and regex is faster + more reliable than any model for
9
+ this sub-task.
10
+
11
+ 2. If structural extractors don't match the expected slot type, the text
12
+ is translated Hausa → English via NLLB-200, then classified by
13
+ Qwen2.5-1.5B in English (where it is strong) into one of a small
14
+ fixed set of intent labels.
15
+
16
+ 3. If NLLB or Qwen fails, we return "unknown" cleanly — the dialogue
17
+ manager will re-prompt.
18
+
19
+ All models are lazy-loaded on first use. Cold-start downloads:
20
+ - NLLB-200-distilled-600M: ~2.4 GB
21
+ - Qwen2.5-1.5B-Instruct: ~3 GB
22
  """
23
  from __future__ import annotations
24
  import re
 
28
 
29
  logger = logging.getLogger("plotweaver.nlu")
30
 
31
+
32
  # ---------------------------------------------------------------------------
33
+ # Deterministic structural extractors (run on raw Hausa text)
34
  # ---------------------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  WORD_DIGITS = {
36
  "sifili": "0", "daya": "1", "ɗaya": "1", "biyu": "2", "uku": "3",
37
  "hudu": "4", "huɗu": "4", "biyar": "5", "shida": "6", "bakwai": "7",
 
44
  "ɗari": 100, "dari": 100,
45
  }
46
 
47
+ # Hausa yes/no keywords for the sole case where we short-circuit Qwen
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-agent escape hatch
52
+ HUMAN_KEYWORDS = {"mutum", "wakili", "agent", "human"}
 
 
 
 
 
 
 
 
 
53
 
54
 
55
  def _extract_digits(text: str) -> Optional[str]:
 
72
  return None
73
 
74
 
75
+ def _match_yesno(text: str) -> Optional[str]:
76
+ t = " " + text.lower().strip() + " "
77
+ for kw in HAUSA_YES:
78
+ if f" {kw} " in t or t.strip() == kw:
79
+ return "yes"
80
+ for kw in HAUSA_NO:
81
+ if f" {kw} " in t or t.strip() == kw:
82
+ return "no"
83
+ return None
 
 
 
 
 
 
84
 
 
 
 
 
 
85
 
86
+ def _contains_human_keyword(text: str) -> bool:
87
+ t = text.lower()
88
+ return any(kw in t for kw in HUMAN_KEYWORDS)
 
 
89
 
 
 
 
90
 
91
+ # ---------------------------------------------------------------------------
92
+ # NLLB-200 Ha → En translation (lazy-loaded)
93
+ # ---------------------------------------------------------------------------
94
+ _nllb_model = None
95
+ _nllb_tokenizer = None
96
+ _nllb_failed = False
97
 
 
 
 
98
 
99
+ def _load_nllb():
100
+ """Lazy-load NLLB-200-distilled-600M."""
101
+ global _nllb_model, _nllb_tokenizer, _nllb_failed
102
+ if _nllb_failed:
103
+ return None, None
104
+ if _nllb_model is not None:
105
+ return _nllb_model, _nllb_tokenizer
106
+ try:
107
+ import torch
108
+ from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
109
+ logger.info("Loading NLLB-200-distilled-600M…")
110
+ model_id = "facebook/nllb-200-distilled-600M"
111
+ _nllb_tokenizer = AutoTokenizer.from_pretrained(model_id)
112
+ _nllb_model = AutoModelForSeq2SeqLM.from_pretrained(
113
+ model_id,
114
+ torch_dtype=torch.float32,
115
+ low_cpu_mem_usage=True,
116
+ )
117
+ _nllb_model.eval()
118
+ logger.info("NLLB-200 ready.")
119
+ return _nllb_model, _nllb_tokenizer
120
+ except Exception as e:
121
+ logger.warning(f"NLLB load failed: {e}")
122
+ _nllb_failed = True
123
+ return None, None
124
 
 
 
 
125
 
126
+ def translate_ha_to_en(text: str) -> Optional[str]:
127
+ """Translate Hausa to English via NLLB. Returns None on failure."""
128
+ model, tokenizer = _load_nllb()
129
+ if model is None or not text.strip():
130
+ return None
131
+ try:
132
+ import torch
133
+ # NLLB requires source language token set on tokenizer
134
+ tokenizer.src_lang = "hau_Latn"
135
+ inputs = tokenizer(text, return_tensors="pt", truncation=True, max_length=128)
136
+ # Force English output via forced_bos_token_id
137
+ forced_bos_id = tokenizer.convert_tokens_to_ids("eng_Latn")
138
+ with torch.no_grad():
139
+ out = model.generate(
140
+ **inputs,
141
+ forced_bos_token_id=forced_bos_id,
142
+ max_new_tokens=128,
143
+ num_beams=2,
144
+ )
145
+ translated = tokenizer.batch_decode(out, skip_special_tokens=True)[0].strip()
146
+ logger.info(f"NLLB Ha→En: {text!r} → {translated!r}")
147
+ return translated
148
+ except Exception as e:
149
+ logger.warning(f"NLLB translate failed: {e}")
150
+ return None
151
 
152
 
153
  # ---------------------------------------------------------------------------
154
+ # Qwen2.5-1.5B intent classifier (operates on English text)
155
  # ---------------------------------------------------------------------------
156
  _llm_model = None
157
  _llm_tokenizer = None
158
+ _llm_failed = False
159
 
160
 
161
  def _load_llm():
 
162
  global _llm_model, _llm_tokenizer, _llm_failed
163
  if _llm_failed:
164
  return None, None
 
167
  try:
168
  import torch
169
  from transformers import AutoModelForCausalLM, AutoTokenizer
170
+ logger.info("Loading Qwen2.5-1.5B-Instruct…")
171
  model_id = "Qwen/Qwen2.5-1.5B-Instruct"
172
  _llm_tokenizer = AutoTokenizer.from_pretrained(model_id)
173
  _llm_model = AutoModelForCausalLM.from_pretrained(
174
  model_id,
175
+ torch_dtype=torch.float32,
176
  low_cpu_mem_usage=True,
177
  )
178
  _llm_model.eval()
179
+ logger.info("Qwen2.5-1.5B ready.")
180
  return _llm_model, _llm_tokenizer
181
  except Exception as e:
182
+ logger.warning(f"Qwen load failed: {e}")
183
  _llm_failed = True
184
  return None, None
185
 
186
 
 
 
187
  CANDIDATE_INTENTS = {
188
  None: ["check_balance", "block_card", "transfer_money",
189
  "buy_airtime", "buy_bundle", "complaint",
 
194
  "check_order", "reschedule", "return_item",
195
  "human_agent", "unknown"],
196
  "yesno": ["yes", "no", "human_agent", "unknown"],
 
 
197
  "name": ["provide_name", "human_agent", "unknown"],
198
  "date": ["provide_date", "human_agent", "unknown"],
199
  "bundle": ["provide_bundle", "human_agent", "unknown"],
 
201
  }
202
 
203
 
204
+ SYSTEM_PROMPT = """You are an intent classifier for a customer-service voice bot.
205
 
206
+ 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.
 
 
207
 
208
  Intent meanings:
209
+ - check_balance: user wants to check an account balance
210
+ - block_card: user wants to block, freeze, or cancel a bank card
211
+ - transfer_money: user wants to send or transfer money
212
+ - buy_airtime: user wants to buy phone airtime / top-up
213
+ - buy_bundle: user wants to buy a data bundle / internet package
214
+ - complaint: user wants to file a complaint or report a problem
215
+ - check_order: user wants to check the status of an order
216
  - reschedule: user wants to reschedule a delivery
217
  - return_item: user wants to return an item
218
+ - human_agent: user wants to speak to a human person
219
+ - yes / no: affirmative or negative reply
220
+ - provide_name / provide_date / provide_bundle / provide_text: user is supplying information
221
+ - unknown: cannot determine intent
222
 
223
+ Return ONLY valid JSON. No explanation, no markdown. Example: {"intent": "check_balance", "entities": {}}"""
224
 
225
 
226
+ def _qwen_classify(english_text: str, expected: Optional[str]) -> Optional[tuple[str, dict]]:
227
+ """Classify an English utterance into an intent. Returns None on failure."""
228
  model, tokenizer = _load_llm()
229
  if model is None:
230
  return None
231
 
232
  candidates = CANDIDATE_INTENTS.get(expected, CANDIDATE_INTENTS[None])
233
  user_prompt = (
234
+ f'Utterance: "{english_text}"\n'
 
235
  f'Candidate intents: {", ".join(candidates)}\n\n'
236
+ 'Return JSON only.'
237
  )
238
  messages = [
239
  {"role": "system", "content": SYSTEM_PROMPT},
 
246
  with torch.no_grad():
247
  out = model.generate(
248
  **inputs,
249
+ max_new_tokens=60,
250
  do_sample=False,
251
  pad_token_id=tokenizer.eos_token_id,
252
  )
253
  generated = tokenizer.decode(out[0][inputs.input_ids.shape[1]:], skip_special_tokens=True).strip()
254
+ logger.info(f"Qwen raw: {generated}")
255
 
 
256
  m = re.search(r"\{.*?\}", generated, re.DOTALL)
257
  if not m:
258
  return None
 
261
  entities = parsed.get("entities", {}) or {}
262
  if not isinstance(entities, dict):
263
  entities = {}
 
264
  if intent not in candidates:
265
+ logger.info(f"Qwen returned out-of-candidate intent: {intent}")
266
  return None
267
  return intent, entities
268
  except Exception as e:
269
+ logger.warning(f"Qwen inference failed: {e}")
270
  return None
271
 
272
 
 
276
  def parse(text: str, expected: Optional[str] = None,
277
  use_llm: bool = True) -> tuple[str, dict, str]:
278
  """
279
+ NLU. Returns (intent, entities, source) where source is one of:
280
+ - 'structural': deterministic extractor caught it (digits, amount, yes/no)
281
+ - 'nllb+qwen': translated via NLLB and classified via Qwen
282
+ - 'human_keyword': caught human-agent escape hatch by keyword
283
+ - 'unknown': nothing matched
 
 
284
  """
285
+ entities: dict = {}
286
+ if not text or not text.strip():
287
+ return "unknown", entities, "unknown"
 
 
 
 
 
 
 
 
 
288
 
289
+ # Always-on human-agent escape (safety)
290
+ if _contains_human_keyword(text):
291
+ return "human_agent", entities, "human_keyword"
292
 
293
+ # Layer 1: deterministic structural extractors for strict-format slots
 
294
  if expected == "digits":
295
  d = _extract_digits(text)
296
  if d:
297
+ entities["digits"] = d
298
+ return "provide_digits", entities, "structural"
299
+
300
+ if expected == "amount":
301
  a = _extract_amount(text)
302
  if a is not None:
303
+ entities["amount"] = a
304
+ return "provide_amount", entities, "structural"
305
+
306
+ if expected == "yesno":
307
+ yn = _match_yesno(text)
308
+ if yn:
309
+ return yn, entities, "structural"
310
+
311
+ if expected == "name":
312
+ # Name is free-form; take the last token as a quick heuristic. Qwen
313
+ # would not help here — names don't translate meaningfully.
314
+ name = text.strip().split()[-1] if text.strip() else ""
315
+ if name:
316
+ entities["name"] = name
317
+ return "provide_name", entities, "structural"
318
+
319
+ if expected == "date":
320
+ entities["date"] = text.strip()
321
+ return "provide_date", entities, "structural"
322
+
323
+ # Layer 2: NLLB Ha → En, then Qwen classification
324
+ if not use_llm:
325
+ return "unknown", entities, "unknown"
326
+
327
+ english_text = translate_ha_to_en(text)
328
+ if english_text is None:
329
+ return "unknown", entities, "unknown"
330
+
331
+ qwen_result = _qwen_classify(english_text, expected)
332
+ if qwen_result is None:
333
+ return "unknown", entities, "unknown"
334
+
335
+ intent, llm_entities = qwen_result
336
+
337
+ # For free-text slots, pass the original Hausa text through (don't want
338
+ # English-translated complaint text stored as a Hausa complaint)
339
+ if expected == "bundle":
340
+ t = text.lower()
341
+ for b in ("rana", "mako", "wata"):
342
+ if b in t:
343
+ llm_entities["bundle"] = b
344
+ break
345
+
346
+ if expected == "text":
347
+ llm_entities["text"] = text.strip()
348
 
349
+ return intent, llm_entities, "nllb+qwen"