Toadoum commited on
Commit
6d23228
Β·
verified Β·
1 Parent(s): 5683c80

Create orchestrator.py

Browse files
Files changed (1) hide show
  1. orchestrator.py +514 -0
orchestrator.py ADDED
@@ -0,0 +1,514 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Orchestrator β€” Task Queue Dialogue Management
3
+ ===============================================
4
+ Replaces the one-intent-one-slot FSM. Fixes every issue in the feedback:
5
+
6
+ P0 Compound requests β†’ NLU decomposes; ALL tasks enter a queue;
7
+ each is acknowledged and executed in order.
8
+ Nothing is silently dropped.
9
+ P0 Wrong destructive β†’ destructive intents require BOTH
10
+ intent confidence β‰₯ 0.75 AND explicit confirmation.
11
+ Below threshold β†’ clarifying question, never action.
12
+ P1 Named entity ignored β†’ slots from NLU prefill the task; only
13
+ genuinely missing slots are asked.
14
+ P1 Dead-end fallback β†’ "unknown" triggers ONE clarifying question with
15
+ candidate intents; second failure β†’ human handoff
16
+ WITH full context attached to the CRM ticket.
17
+ P2 No balance guard β†’ transfers are checked against balance before
18
+ confirmation; insufficient funds is surfaced.
19
+ P2 Number formatting β†’ fmt_amount() used everywhere: "₦350,000".
20
+ """
21
+
22
+ import re
23
+ import uuid
24
+ import logging
25
+ from dataclasses import dataclass, field
26
+ from datetime import datetime
27
+ from typing import Optional
28
+
29
+ from nlu import NLU, INTENT_SCHEMA
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ CONFIDENCE_GATE_DESTRUCTIVE = 0.75 # below this, never act on money/card intents
34
+ CONFIDENCE_GATE_NORMAL = 0.50
35
+ MAX_CLARIFY_ATTEMPTS = 2 # then human handoff with context
36
+ MAX_TURNS = 20
37
+
38
+
39
+ # ── Formatting (P2) ───────────────────────────────────────────────────────────
40
+
41
+ def fmt_amount(raw) -> str:
42
+ """'350000' β†’ '₦350,000' β€” single source of truth for money display."""
43
+ try:
44
+ n = int(str(raw).replace(",", "").replace(".", "").replace("₦", "").strip())
45
+ return f"₦{n:,}"
46
+ except (ValueError, TypeError):
47
+ return f"₦{raw}"
48
+
49
+
50
+ def amount_int(raw) -> Optional[int]:
51
+ try:
52
+ return int(str(raw).replace(",", "").replace(".", "").replace("₦", "").strip())
53
+ except (ValueError, TypeError):
54
+ return None
55
+
56
+
57
+ # ── Task model ────────────────────────────────────────────────────────────────
58
+
59
+ @dataclass
60
+ class Task:
61
+ task_id: str
62
+ intent: str
63
+ slots: dict
64
+ confidence: float
65
+ status: str = "pending" # pending | collecting | confirming | done | failed
66
+ result: Optional[str] = None
67
+
68
+ @property
69
+ def required_slots(self) -> list:
70
+ return INTENT_SCHEMA.get(self.intent, {}).get("slots", [])
71
+
72
+ @property
73
+ def missing_slots(self) -> list:
74
+ # account_id is optional if we already verified identity this session
75
+ return [s for s in self.required_slots if s not in self.slots]
76
+
77
+ @property
78
+ def is_destructive(self) -> bool:
79
+ return INTENT_SCHEMA.get(self.intent, {}).get("destructive", False)
80
+
81
+
82
+ @dataclass
83
+ class Session:
84
+ session_id: str = ""
85
+ tasks: list = field(default_factory=list) # task queue
86
+ active_task: Optional[Task] = None
87
+ awaiting_slot: Optional[str] = None
88
+ awaiting_confirmation: bool = False
89
+ verified_account: Optional[str] = None # identity, session-scoped
90
+ balance: Optional[int] = None # cached after lookup
91
+ clarify_attempts: int = 0
92
+ turn: int = 0
93
+ escalated: bool = False
94
+ history: list = field(default_factory=list)
95
+ started_at: str = field(default_factory=lambda: datetime.utcnow().isoformat())
96
+
97
+
98
+ # ── Slot questions ────────────────────────────────────────────────────────────
99
+
100
+ SLOT_QUESTIONS = {
101
+ "account_id": "Could you give me your account number, please?",
102
+ "recipient": "Who would you like to send the money to?",
103
+ "amount": "How much would you like to send?",
104
+ "location": "Which city or area are you in?",
105
+ "issue_desc": "Could you briefly describe the problem?",
106
+ "order_id": "What is your order number?",
107
+ "return_reason": "What is the reason for the return?",
108
+ }
109
+
110
+
111
+ class Orchestrator:
112
+
113
+ def __init__(self, crm=None, nlu: Optional[NLU] = None):
114
+ self.crm = crm
115
+ self.nlu = nlu or NLU()
116
+
117
+ def new_session(self) -> Session:
118
+ return Session(session_id=str(uuid.uuid4())[:8])
119
+
120
+ # ── Main entry ────────────────────────────────────────────────────────────
121
+
122
+ def respond(self, english_text: str, hausa_text: str,
123
+ session: Session) -> tuple[str, Session, bool]:
124
+ session.turn += 1
125
+ session.history.append({"role": "user", "en": english_text,
126
+ "ha": hausa_text, "turn": session.turn})
127
+
128
+ if session.turn >= MAX_TURNS:
129
+ return self._escalate(session,
130
+ "We've been talking a while β€” let me connect you to a colleague "
131
+ "who can wrap this up quickly.")
132
+
133
+ # 1. NLU with pending-slot context
134
+ pending_intent = session.active_task.intent if session.active_task else None
135
+ nlu_result = self.nlu.parse(english_text,
136
+ pending_intent=pending_intent,
137
+ pending_slot=session.awaiting_slot)
138
+ tasks_found = nlu_result["tasks"]
139
+
140
+ # 2. Route the parse
141
+ response = self._handle_parse(tasks_found, english_text, session)
142
+
143
+ # 3. Drive the queue until we need user input
144
+ followup = self._drive_queue(session)
145
+ if followup:
146
+ response = (response + " " + followup).strip() if response else followup
147
+
148
+ session.history.append({"role": "agent", "en": response,
149
+ "ha": None, "turn": session.turn})
150
+ return response, session, session.escalated
151
+
152
+ # ── Parse handling ────────────────────────────────────────────────────────
153
+
154
+ def _handle_parse(self, tasks_found: list, raw_text: str,
155
+ session: Session) -> str:
156
+ parts = []
157
+
158
+ for parsed in tasks_found:
159
+ intent = parsed["intent"]
160
+ conf = parsed["confidence"]
161
+ slots = parsed["slots"]
162
+
163
+ # ── Universal intents ────────────────────────────────────────────
164
+ if intent == "human_agent":
165
+ return self._escalate(session,
166
+ "Of course β€” connecting you to a human agent now. "
167
+ f"Your reference is REF-{session.session_id}. "
168
+ "They will see everything we discussed.")[0]
169
+
170
+ if intent in ("cancel",):
171
+ session.active_task = None
172
+ session.awaiting_slot = None
173
+ session.awaiting_confirmation = False
174
+ session.tasks = [t for t in session.tasks if t.status == "done"]
175
+ parts.append("Alright, I've cancelled that.")
176
+ continue
177
+
178
+ if intent == "goodbye":
179
+ pending = [t for t in session.tasks
180
+ if t.status in ("pending", "collecting", "confirming")]
181
+ if pending:
182
+ parts.append(f"Before you go β€” we still have "
183
+ f"{len(pending)} pending request(s). "
184
+ f"Say 'cancel' to drop them, or continue.")
185
+ else:
186
+ parts.append("Thank you for calling. Have a wonderful day!")
187
+ continue
188
+
189
+ if intent == "greeting" and session.turn <= 2:
190
+ parts.append("Hello! I can help you check balances, send money, "
191
+ "pay bills, track orders, or report an issue. "
192
+ "What can I do for you?")
193
+ continue
194
+
195
+ # ── Confirmation answers for the active task ─────────────────────
196
+ if session.awaiting_confirmation and intent in (
197
+ "confirmation_yes", "confirmation_no"):
198
+ parts.append(self._handle_confirmation(
199
+ intent == "confirmation_yes", session))
200
+ continue
201
+
202
+ # ── Slot answer for the active task ──────────────────────────────
203
+ if (session.awaiting_slot and session.active_task
204
+ and intent in ("unknown", session.active_task.intent)):
205
+ filled = self._try_fill_pending_slot(raw_text, slots, session)
206
+ if filled:
207
+ continue # queue driver will move it forward
208
+
209
+ # ── Unknown β†’ clarify, not dead-end (P1) ─────────────────────────
210
+ if intent == "unknown" or conf < CONFIDENCE_GATE_NORMAL:
211
+ parts.append(self._clarify_or_handoff(raw_text, session))
212
+ continue
213
+
214
+ # ── New task β†’ enqueue with prefilled slots (P0 + P1) ────────────
215
+ task = Task(task_id=str(uuid.uuid4())[:6],
216
+ intent=intent, slots=dict(slots), confidence=conf)
217
+ # identity carries over within the session
218
+ if session.verified_account and "account_id" in task.required_slots:
219
+ task.slots.setdefault("account_id", session.verified_account)
220
+ session.tasks.append(task)
221
+ session.clarify_attempts = 0
222
+
223
+ # Acknowledge compound requests explicitly (P0 #1)
224
+ new_tasks = [t for t in session.tasks if t.status == "pending"]
225
+ if len(new_tasks) > 1:
226
+ names = ", then ".join(self._intent_label(t.intent) for t in new_tasks)
227
+ parts.insert(0, f"Got it β€” I'll {names}, one at a time.")
228
+
229
+ return " ".join(p for p in parts if p)
230
+
231
+ # ── Queue driver ──────────────────────────────────────────────────────────
232
+
233
+ def _drive_queue(self, session: Session) -> str:
234
+ """
235
+ Advance the active task; when it completes, pull the next from the
236
+ queue. Stops (returns a question) whenever user input is needed.
237
+ """
238
+ out = []
239
+
240
+ while True:
241
+ # promote next task if none active
242
+ if session.active_task is None:
243
+ nxt = next((t for t in session.tasks if t.status == "pending"), None)
244
+ if nxt is None:
245
+ break
246
+ session.active_task = nxt
247
+ nxt.status = "collecting"
248
+ # identity verified earlier in this session carries forward β€”
249
+ # never re-ask for the account number (P1)
250
+ if session.verified_account and "account_id" in nxt.required_slots:
251
+ nxt.slots.setdefault("account_id", session.verified_account)
252
+
253
+ task = session.active_task
254
+
255
+ # 1. Missing slots? Ask for exactly ONE (but never one we have)
256
+ missing = task.missing_slots
257
+ if missing:
258
+ slot = missing[0]
259
+ session.awaiting_slot = slot
260
+ out.append(SLOT_QUESTIONS.get(
261
+ slot, f"Could you provide the {slot.replace('_', ' ')}?"))
262
+ return " ".join(out)
263
+
264
+ # 2. Destructive β†’ confidence gate + confirmation (P0 #2)
265
+ if task.is_destructive and not session.awaiting_confirmation:
266
+ if task.confidence < CONFIDENCE_GATE_DESTRUCTIVE:
267
+ session.awaiting_confirmation = True
268
+ task.status = "confirming"
269
+ out.append(self._describe_action(task) +
270
+ " β€” did I understand that correctly? "
271
+ "Please say yes or no.")
272
+ return " ".join(out)
273
+
274
+ # balance guard before confirming a transfer (P2)
275
+ guard_msg = self._balance_guard(task, session)
276
+ if guard_msg:
277
+ task.status = "failed"
278
+ session.active_task = None
279
+ session.awaiting_slot = None
280
+ out.append(guard_msg)
281
+ continue
282
+
283
+ session.awaiting_confirmation = True
284
+ task.status = "confirming"
285
+ out.append(self._describe_action(task) +
286
+ " Say yes to confirm or no to cancel.")
287
+ return " ".join(out)
288
+
289
+ # 3. Execute non-destructive task
290
+ result = self._execute(task, session)
291
+ task.status = "done"
292
+ task.result = result
293
+ out.append(result)
294
+ session.active_task = None
295
+ session.awaiting_slot = None
296
+
297
+ # queue empty
298
+ if out:
299
+ remaining = [t for t in session.tasks if t.status == "pending"]
300
+ if not remaining and not session.awaiting_confirmation:
301
+ out.append("Is there anything else I can help you with?")
302
+ return " ".join(out)
303
+
304
+ # ── Confirmation handling ─────────────────────────────────────────────────
305
+
306
+ def _handle_confirmation(self, confirmed: bool, session: Session) -> str:
307
+ task = session.active_task
308
+ session.awaiting_confirmation = False
309
+ if task is None:
310
+ return ""
311
+ if confirmed:
312
+ result = self._execute(task, session)
313
+ task.status = "done"
314
+ task.result = result
315
+ session.active_task = None
316
+ return result
317
+ task.status = "failed"
318
+ session.active_task = None
319
+ return ("No problem, I've cancelled that. "
320
+ "Just tell me if you'd like to do something else.")
321
+
322
+ # ── Slot filling ──────────────────────────────────────────────────────────
323
+
324
+ def _try_fill_pending_slot(self, raw_text: str, nlu_slots: dict,
325
+ session: Session) -> bool:
326
+ task = session.active_task
327
+ slot = session.awaiting_slot
328
+ if not task or not slot:
329
+ return False
330
+
331
+ value = nlu_slots.get(slot)
332
+
333
+ # heuristic extraction for short answers
334
+ if not value:
335
+ t = raw_text.strip()
336
+ if slot == "account_id":
337
+ m = re.search(r'\b(\d{6,12})\b', t)
338
+ value = m.group(1) if m else None
339
+ elif slot == "amount":
340
+ m = re.search(r'\b(\d{1,3}(?:[,\.]\d{3})*|\d+)\b', t)
341
+ value = m.group(1) if m else None
342
+ elif slot == "recipient":
343
+ m = re.match(r'^(?:to\s+)?([a-zA-Z]{2,20})$', t)
344
+ value = m.group(1) if m else None
345
+ elif slot in ("issue_desc", "return_reason", "location"):
346
+ # ANY non-trivial answer counts β€” "too small" is a valid
347
+ # return reason (fixes P1 dead-end)
348
+ value = t if len(t) >= 2 else None
349
+ elif slot == "order_id":
350
+ m = re.search(r'\b([a-zA-Z0-9\-]{4,20})\b', t)
351
+ value = m.group(1) if m else None
352
+
353
+ if value:
354
+ task.slots[slot] = str(value)
355
+ session.awaiting_slot = None
356
+ session.clarify_attempts = 0
357
+ if slot == "account_id":
358
+ session.verified_account = str(value)
359
+ return True
360
+ return False
361
+
362
+ # ── Clarify β†’ handoff (P1 dead-end fix) ──────────────────────────────────
363
+
364
+ def _clarify_or_handoff(self, raw_text: str, session: Session) -> str:
365
+ session.clarify_attempts += 1
366
+ if session.clarify_attempts >= MAX_CLARIFY_ATTEMPTS:
367
+ return self._escalate(session,
368
+ "I want to make sure you get proper help β€” connecting you "
369
+ "to a human agent now. They'll see our whole conversation, "
370
+ "so you won't have to repeat anything.")[0]
371
+ return ("Sorry β€” just to be sure I get this right: are you asking "
372
+ "about your balance, a transfer, a payment, an order, "
373
+ "or something else? You can also say it in your own words.")
374
+
375
+ # ── Execution ─────────────────────────────────────────────────────────────
376
+
377
+ def _execute(self, task: Task, session: Session) -> str:
378
+ i = task.intent
379
+ s = task.slots
380
+
381
+ if i == "balance_inquiry":
382
+ bal = self._mock_balance(s.get("account_id", session.verified_account or "0"))
383
+ session.balance = bal
384
+ return (f"Your account balance is {fmt_amount(bal)} "
385
+ f"as of {datetime.utcnow().strftime('%d %b %Y')}.")
386
+
387
+ if i == "send_money":
388
+ tx = f"TXN-{session.session_id}-{task.task_id.upper()}"
389
+ amt = fmt_amount(s['amount'])
390
+ if session.balance is not None:
391
+ session.balance -= amount_int(s["amount"]) or 0
392
+ return (f"Done β€” {amt} sent to {s['recipient'].title()}. "
393
+ f"Transaction reference: {tx}.")
394
+
395
+ if i == "bill_payment":
396
+ tx = f"TXN-{session.session_id}-{task.task_id.upper()}"
397
+ return (f"Your payment of {fmt_amount(s['amount'])} has been "
398
+ f"processed. Reference: {tx}.")
399
+
400
+ if i == "block_card":
401
+ return (f"Your card linked to account ending "
402
+ f"…{s.get('account_id', '')[-4:]} is now blocked. "
403
+ f"A replacement can be requested at any branch.")
404
+
405
+ if i == "branch_info":
406
+ loc = s.get("location", "your area")
407
+ return (f"Our closest branch to {loc} is at 12 Ahmadu Bello Way β€” "
408
+ f"open Monday to Friday, 8am to 4pm. You can pick up an "
409
+ f"ATM card there with a valid ID.")
410
+
411
+ if i == "card_request":
412
+ return ("You can collect a new ATM card at any branch with a "
413
+ "valid ID, or I can order one to be delivered β€” "
414
+ "just say 'deliver my card'.")
415
+
416
+ if i == "track_order":
417
+ oid = s.get("order_id", "")
418
+ return (f"Order {oid} is out for delivery and should arrive "
419
+ f"within 2 business days.")
420
+
421
+ if i == "return_item":
422
+ ticket = self._crm_ticket(session,
423
+ f"Return request β€” order {s.get('order_id','?')} β€” "
424
+ f"reason: {s.get('return_reason','?')}")
425
+ return (f"I've registered your return for order "
426
+ f"{s.get('order_id','')} (reason: {s.get('return_reason','')}). "
427
+ f"Ticket {ticket}. You'll receive a pickup label by SMS.")
428
+
429
+ if i == "report_issue":
430
+ ticket = self._crm_ticket(session, s.get("issue_desc", raw := ""))
431
+ return (f"I've created support ticket {ticket}. "
432
+ f"Our team will contact you within 24 hours.")
433
+
434
+ return "Done."
435
+
436
+ # ── Balance guard (P2) ──────────���─────────────────────────────────────────
437
+
438
+ def _balance_guard(self, task: Task, session: Session) -> Optional[str]:
439
+ if task.intent != "send_money":
440
+ return None
441
+ amt = amount_int(task.slots.get("amount"))
442
+ if amt is None:
443
+ return None
444
+ # look up balance if we haven't yet this session
445
+ if session.balance is None and task.slots.get("account_id"):
446
+ session.balance = self._mock_balance(task.slots["account_id"])
447
+ if session.balance is not None and amt > session.balance:
448
+ return (f"I can't process that transfer: you asked to send "
449
+ f"{fmt_amount(amt)} but your balance is "
450
+ f"{fmt_amount(session.balance)}. "
451
+ f"Would you like to send a smaller amount?")
452
+ return None
453
+
454
+ # ── Escalation with context (P1) ─────────────────────────────────────────
455
+
456
+ def _escalate(self, session: Session, message: str) -> tuple[str, Session, bool]:
457
+ session.escalated = True
458
+ transcript = "\n".join(
459
+ f"[{h['turn']}] {h['role']}: {h.get('en','')}" for h in session.history)
460
+ self._crm_ticket(session,
461
+ f"ESCALATION β€” full context attached:\n{transcript}",
462
+ subject=f"Voice escalation {session.session_id}")
463
+ pending = [t for t in session.tasks
464
+ if t.status in ("pending", "collecting", "confirming")]
465
+ if pending:
466
+ message += (f" Note for the agent: {len(pending)} request(s) "
467
+ f"still open ({', '.join(t.intent for t in pending)}).")
468
+ return message, session, True
469
+
470
+ # ── Helpers ───────────────────────────────────────────────────────────────
471
+
472
+ def _describe_action(self, task: Task) -> str:
473
+ s = task.slots
474
+ if task.intent == "send_money":
475
+ return (f"You want to send {fmt_amount(s['amount'])} "
476
+ f"to {s['recipient'].title()}.")
477
+ if task.intent == "bill_payment":
478
+ return f"You want to pay {fmt_amount(s['amount'])}."
479
+ if task.intent == "block_card":
480
+ return (f"You want to BLOCK the card on account "
481
+ f"ending …{s.get('account_id','')[-4:]}.")
482
+ return f"You want to {self._intent_label(task.intent)}."
483
+
484
+ @staticmethod
485
+ def _intent_label(intent: str) -> str:
486
+ return {
487
+ "balance_inquiry": "check your balance",
488
+ "send_money": "make a transfer",
489
+ "bill_payment": "pay a bill",
490
+ "block_card": "block your card",
491
+ "branch_info": "find a branch",
492
+ "card_request": "get a card",
493
+ "track_order": "track your order",
494
+ "return_item": "process a return",
495
+ "report_issue": "log your issue",
496
+ }.get(intent, intent.replace("_", " "))
497
+
498
+ @staticmethod
499
+ def _mock_balance(account_id: str) -> int:
500
+ import hashlib
501
+ seed = int(hashlib.md5(str(account_id).encode()).hexdigest()[:6], 16)
502
+ return (seed % 400_000) + 50_000
503
+
504
+ def _crm_ticket(self, session: Session, description: str,
505
+ subject: str = "") -> str:
506
+ if self.crm:
507
+ try:
508
+ res = self.crm.create_ticket(
509
+ subject=subject or f"Voice session {session.session_id}",
510
+ description=description)
511
+ return res["ticket_id"]
512
+ except Exception as e:
513
+ logger.warning(f"CRM ticket failed: {e}")
514
+ return f"TKT-{session.session_id}-{str(uuid.uuid4())[:4].upper()}"