File size: 16,907 Bytes
ddbabb4
d402333
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ddbabb4
d402333
 
ddbabb4
d402333
ddbabb4
 
 
d402333
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ddbabb4
 
d402333
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15fbd84
 
 
 
 
d402333
15fbd84
d402333
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15fbd84
 
 
d402333
 
 
 
 
 
 
 
15fbd84
 
 
 
 
 
d402333
 
 
 
 
 
 
 
 
 
 
 
 
15fbd84
 
d402333
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
"""
NLU Module β€” Multi-Intent Decomposition + Entity Extraction
=============================================================
Fixes P0/P1 from the feedback:

  - Decomposes ONE user message into a LIST of tasks (compound requests)
  - Extracts ALL entities present in the message (slot prefill β€”
    "send money to abu" never re-asks for the recipient)
  - Returns per-task confidence so destructive intents can be gated
  - Distinguishes "ask about X" from "do X" (branch location β‰  block card)

Backend chain (first available wins):
  1. LLM_API    β€” HF Serverless Inference (set HF_TOKEN) β€” best quality
  2. LLM_LOCAL  β€” Qwen2.5-1.5B-Instruct loaded in-process β€” good, slower
  3. RULES      β€” improved keyword rules β€” degraded but never crashes

All backends return the same schema:

  {
    "tasks": [
      {
        "intent": "send_money",
        "confidence": 0.93,
        "slots": {"recipient": "abu", "amount": "350000"},
        "utterance_span": "send 350000 to abu"
      },
      ...
    ],
    "backend": "llm_api"
  }
"""

import os
import re
import json
import logging
from typing import Optional

logger = logging.getLogger(__name__)

# ── Intent catalogue (shared by all backends) ────────────────────────────────
INTENT_SCHEMA = {
    "greeting":         {"slots": [],                          "destructive": False},
    "balance_inquiry":  {"slots": ["account_id"],              "destructive": False},
    "send_money":       {"slots": ["recipient", "amount", "account_id"], "destructive": True},
    "bill_payment":     {"slots": ["account_id", "amount"],    "destructive": True},
    "block_card":       {"slots": ["account_id"],              "destructive": True},
    "branch_info":      {"slots": [],                          "destructive": False},
    "card_request":     {"slots": [],                          "destructive": False},
    "report_issue":     {"slots": ["issue_desc"],              "destructive": False},
    "track_order":      {"slots": ["order_id"],                "destructive": False},
    "return_item":      {"slots": ["order_id", "return_reason"], "destructive": False},
    "human_agent":      {"slots": [],                          "destructive": False},
    "confirmation_yes": {"slots": [],                          "destructive": False},
    "confirmation_no":  {"slots": [],                          "destructive": False},
    "cancel":           {"slots": [],                          "destructive": False},
    "goodbye":          {"slots": [],                          "destructive": False},
    "unknown":          {"slots": [],                          "destructive": False},
}

NLU_SYSTEM_PROMPT = """You are the NLU module of a customer-service voice agent.
Decompose the user's message into ALL tasks it contains, in order.
Extract every entity present. Never invent entities that are not in the text.

Intents: greeting, balance_inquiry, send_money, bill_payment, block_card,
branch_info, card_request, report_issue, track_order, return_item,
human_agent, confirmation_yes, confirmation_no, cancel, goodbye, unknown.

Slots: recipient, amount, account_id, location, issue_desc, order_id, return_reason.

CRITICAL disambiguation rules:
- "where is your branch so I can get my card" = branch_info + card_request.
  It is NOT block_card. Only choose block_card if the user explicitly wants to
  BLOCK, FREEZE, or DEACTIVATE a card.
- A message can contain multiple tasks joined by "and", "also", "then".
  Output one task per action. "check my balance and send 5000 to musa"
  = [balance_inquiry, send_money{recipient: musa, amount: 5000}].
- If the user answers a question (e.g. gives a reason like "too small"),
  map it to the slot of the pending task, intent = the pending intent.
- Confidence in [0,1]: how sure you are of the INTENT (not the slots).

Respond with ONLY valid JSON, no markdown, no commentary:
{"tasks":[{"intent":"...","confidence":0.0,"slots":{},"utterance_span":"..."}]}"""


class NLU:

    def __init__(self, prefer: str = "auto"):
        self.hf_token = os.getenv("HF_TOKEN", "")
        self.api_model = os.getenv(
            "NLU_API_MODEL", "Qwen/Qwen2.5-72B-Instruct")
        self.local_model_id = os.getenv(
            "NLU_LOCAL_MODEL", "Qwen/Qwen2.5-1.5B-Instruct")
        # The local backend is OPT-IN. Left automatic, the first NLU call on a
        # Space silently downloads a ~3GB model mid-demo and blocks for
        # minutes. Enable deliberately with NLU_LOCAL=1 on hardware that can
        # take it.
        self.use_local = os.getenv("NLU_LOCAL", "0") == "1"
        self._local_pipe = None
        self._dead = set()          # backends that failed β€” never retried
        self.prefer = prefer

    # ── Public API ────────────────────────────────────────────────────────────

    def parse(self, text: str, pending_intent: Optional[str] = None,
              pending_slot: Optional[str] = None) -> dict:
        """
        text           : English pivot text of the user turn
        pending_intent : intent currently awaiting a slot (context for the LLM)
        pending_slot   : which slot we asked for last turn
        """
        context = ""
        if pending_intent and pending_slot:
            context = (f"\nContext: you previously asked the user for the "
                       f"'{pending_slot}' of a '{pending_intent}' task. "
                       f"A short answer likely fills that slot.")

        for backend in self._backend_order():
            name = backend.__name__
            if name in self._dead:
                continue
            try:
                result = backend(text, context)
                if result and result.get("tasks"):
                    result = self._sanitize(result)
                    logger.info(f"NLU[{result['backend']}]: "
                                f"{json.dumps(result['tasks'])[:200]}")
                    return result
            except Exception as e:
                # Mark dead so a missing dependency or bad token doesn't cost
                # a retry (and a re-download attempt) on every single turn.
                self._dead.add(name)
                logger.warning(
                    f"NLU backend {name} failed and is disabled for this "
                    f"session: {e}")
        # Absolute last resort
        return {"tasks": [{"intent": "unknown", "confidence": 0.0,
                            "slots": {}, "utterance_span": text}],
                "backend": "none"}

    # ── Backend chain ─────────────────────────────────────────────────────────

    def _backend_order(self):
        if self.prefer == "rules":
            return [self._rules_backend]
        chain = []
        if self.hf_token:
            chain.append(self._api_backend)
        if self.use_local:
            chain.append(self._local_backend)
        chain.append(self._rules_backend)
        return chain

    # ── 1. HF Serverless Inference API ───────────────────────────────────────

    def _api_backend(self, text: str, context: str) -> Optional[dict]:
        import requests
        url = f"https://api-inference.huggingface.co/models/{self.api_model}/v1/chat/completions"
        payload = {
            "model": self.api_model,
            "messages": [
                {"role": "system", "content": NLU_SYSTEM_PROMPT + context},
                {"role": "user",   "content": text},
            ],
            "max_tokens": 400,
            "temperature": 0.1,
        }
        r = requests.post(url, json=payload, timeout=20,
                          headers={"Authorization": f"Bearer {self.hf_token}"})
        r.raise_for_status()
        raw = r.json()["choices"][0]["message"]["content"]
        parsed = self._extract_json(raw)
        if parsed:
            parsed["backend"] = "llm_api"
        return parsed

    # ── 2. Local small LLM ────────────────────────────────────────────────────

    def _local_backend(self, text: str, context: str) -> Optional[dict]:
        if self._local_pipe is None:
            logger.info(f"Loading local NLU model {self.local_model_id} …")
            from transformers import pipeline as hf_pipeline
            import torch
            self._local_pipe = hf_pipeline(
                "text-generation",
                model=self.local_model_id,
                torch_dtype=torch.float16 if torch.cuda.is_available() else torch.float32,
                device_map="auto",
            )
        messages = [
            {"role": "system", "content": NLU_SYSTEM_PROMPT + context},
            {"role": "user",   "content": text},
        ]
        out = self._local_pipe(messages, max_new_tokens=400,
                                do_sample=False, temperature=None, top_p=None)
        raw = out[0]["generated_text"][-1]["content"]
        parsed = self._extract_json(raw)
        if parsed:
            parsed["backend"] = "llm_local"
        return parsed

    # ── 3. Improved rules (never fails) ───────────────────────────────────────

    def _rules_backend(self, text: str, context: str) -> dict:
        """
        Better than the old FSM keywords:
          - splits on conjunctions to find MULTIPLE tasks
          - extracts entities per clause
          - branch_info vs block_card disambiguation
        """
        t = text.lower().strip()

        # Split compound message into clauses
        clauses = re.split(r'\b(?:and also|and then|then|and|also|;|\. )\b', t)
        clauses = [c.strip() for c in clauses if c.strip()]

        tasks = []
        for clause in clauses:
            task = self._rules_classify_clause(clause)
            if task:
                tasks.append(task)

        # Merge duplicate consecutive intents (e.g. "and" split an entity off)
        merged = []
        for task in tasks:
            if merged and merged[-1]["intent"] == task["intent"]:
                merged[-1]["slots"].update(task["slots"])
                merged[-1]["utterance_span"] += " " + task["utterance_span"]
            else:
                merged.append(task)

        if not merged:
            merged = [{"intent": "unknown", "confidence": 0.3,
                       "slots": {}, "utterance_span": t}]

        return {"tasks": merged, "backend": "rules"}

    def _rules_classify_clause(self, clause: str) -> Optional[dict]:
        slots = {}

        # ── Entity extraction (always, regardless of intent) ────────────────
        # In a money-action clause ("send/transfer/pay X to Y"), the number is
        # an AMOUNT. Only treat 6-12 digit numbers as account_id when the
        # clause is about the account itself, or there is no money verb.
        money_verb = any(v in clause for v in ("send", "transfer", "pay"))
        account_ctx = any(v in clause for v in ("account", "acct", "number is"))
        numbers = re.findall(r'\b\d[\d,\.]*\b', clause)
        for num in numbers:
            digits = num.replace(",", "").replace(".", "")
            if money_verb and "amount" not in slots and len(digits) <= 7:
                slots["amount"] = digits
            elif (account_ctx or not money_verb) and 6 <= len(digits) <= 12 \
                    and "account_id" not in slots:
                slots["account_id"] = digits
            elif "amount" not in slots and len(digits) <= 7:
                slots["amount"] = digits
        # recipient: "to <name>" β€” take the LAST valid match, skipping verbs
        # ("I want to send money to abu" must yield 'abu', not 'send')
        RECIPIENT_STOPWORDS = {
            "my", "the", "a", "an", "me", "you", "check", "send", "transfer",
            "pay", "get", "make", "do", "know", "see", "block", "return",
            "track", "him", "her", "them", "it", "confirm", "cancel"}
        for m in re.finditer(r'\bto\s+([a-z]{2,20})\b', clause):
            name = m.group(1)
            if name not in RECIPIENT_STOPWORDS:
                slots["recipient"] = name
        # order id
        m = re.search(r'\border\s*#?\s*([a-z0-9\-]{4,20})\b', clause)
        if m:
            slots["order_id"] = m.group(1)

        # ── Intent (order matters: destructive intents need explicit verbs) ──
        def has(*kws):
            return any(kw in clause for kw in kws)

        # branch/location questions BEFORE block_card β€” fixes P0 #2
        if has("branch", "closest", "nearest", "location", "where is", "address"):
            intent, conf = "branch_info", 0.85
            if has("card", "atm"):
                # compound: they also want a card β€” but NOT to block it
                return {"intent": "branch_info", "confidence": 0.85,
                        "slots": slots, "utterance_span": clause}
        elif has("block my card", "block card", "freeze", "deactivate", "stolen", "lost my card"):
            intent, conf = "block_card", 0.8
        elif has("send", "transfer") and (slots.get("recipient") or slots.get("amount")):
            intent, conf = "send_money", 0.85
        elif has("send money", "transfer money"):
            intent, conf = "send_money", 0.75
        elif has("balance", "how much", "asusun"):
            intent, conf = "balance_inquiry", 0.85
        elif has("pay", "bill", "recharge", "invoice"):
            intent, conf = "bill_payment", 0.75
        elif has("track", "where is my order", "delivery", "shipment"):
            intent, conf = "track_order", 0.8
        elif has("return", "refund", "send back"):
            intent, conf = "return_item", 0.8
        elif has("problem", "issue", "complaint", "not working", "error"):
            intent, conf = "report_issue", 0.7
            slots["issue_desc"] = clause
        elif has("human", "agent", "person", "operator", "representative"):
            intent, conf = "human_agent", 0.9
        elif has("yes", "yep", "correct", "confirm", "sure", "okay", "ok"):
            intent, conf = "confirmation_yes", 0.8
        elif has("no", "nope", "wrong", "cancel that"):
            intent, conf = "confirmation_no", 0.8
        elif has("hello", "hi ", "good morning", "sannu", "salam"):
            intent, conf = "greeting", 0.9
        elif has("bye", "goodbye", "thank"):
            intent, conf = "goodbye", 0.85
        else:
            return {"intent": "unknown", "confidence": 0.3,
                    "slots": slots, "utterance_span": clause}

        return {"intent": intent, "confidence": conf,
                "slots": slots, "utterance_span": clause}

    # ── Helpers ───────────────────────────────────────────────────────────────

    @staticmethod
    def _extract_json(raw: str) -> Optional[dict]:
        """Robustly pull the first JSON object out of LLM output."""
        raw = raw.strip()
        raw = re.sub(r'^```(?:json)?|```$', '', raw, flags=re.MULTILINE).strip()
        # find first { … matching last }
        start = raw.find("{")
        end   = raw.rfind("}")
        if start == -1 or end == -1:
            return None
        try:
            return json.loads(raw[start:end + 1])
        except json.JSONDecodeError:
            return None

    @staticmethod
    def _sanitize(result: dict) -> dict:
        """Validate schema, clamp confidence, drop hallucinated slots."""
        valid_slots = {"recipient", "amount", "account_id", "location",
                       "issue_desc", "order_id", "return_reason"}
        clean_tasks = []
        for task in result.get("tasks", []):
            intent = task.get("intent", "unknown")
            if intent not in INTENT_SCHEMA:
                intent = "unknown"
            conf = float(task.get("confidence", 0.5))
            conf = max(0.0, min(1.0, conf))
            slots = {k: str(v).strip() for k, v in (task.get("slots") or {}).items()
                     if k in valid_slots and v not in (None, "", "null", "None")}
            clean_tasks.append({
                "intent": intent, "confidence": conf, "slots": slots,
                "utterance_span": str(task.get("utterance_span", ""))[:200],
            })
        result["tasks"] = clean_tasks or [
            {"intent": "unknown", "confidence": 0.0, "slots": {},
             "utterance_span": ""}]
        return result