Toadoum's picture
Upload 12 files
15fbd84 verified
Raw
History Blame Contribute Delete
16.9 kB
"""
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