sakeef commited on
Commit
1cc481e
·
verified ·
1 Parent(s): f9430a6

Upload dialog_manager.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. dialog_manager.py +393 -0
dialog_manager.py ADDED
@@ -0,0 +1,393 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Dialog Manager — Conversation State Tracking & Context Management
3
+ =================================================================
4
+ Manages multi-turn dialog state for Bengali public service conversations.
5
+
6
+ Responsibilities:
7
+ - Track conversation history (user + agent turns)
8
+ - Maintain slot/entity memory across turns
9
+ - Determine dialog acts (greet → inform → query → confirm → close)
10
+ - Build context windows for the response generation model
11
+ - Handle domain switching and topic transitions
12
+
13
+ Designed to work with:
14
+ - JointIntentNER model (NLU component)
15
+ - BanglaT5 response generation (NLG component)
16
+ """
17
+
18
+ import json
19
+ from typing import Dict, List, Optional, Tuple
20
+ from dataclasses import dataclass, field, asdict
21
+ from enum import Enum
22
+ from datetime import datetime
23
+
24
+
25
+ # ============================================================================
26
+ # DIALOG STATES
27
+ # ============================================================================
28
+
29
+ class DialogState(Enum):
30
+ """High-level dialog states."""
31
+ IDLE = "idle" # No active conversation
32
+ GREETING = "greeting" # Initial greeting phase
33
+ INFORMATION = "information" # Providing/collecting information
34
+ QUERY = "query" # User asking questions
35
+ CONFIRMATION = "confirmation" # Confirming details
36
+ CLOSING = "closing" # Wrapping up conversation
37
+ ESCALATION = "escalation" # Needs human agent
38
+
39
+
40
+ # Intent-to-state mapping
41
+ INTENT_STATE_MAP = {
42
+ "greeting": DialogState.GREETING,
43
+ "farewell": DialogState.CLOSING,
44
+ "thanks": DialogState.CLOSING,
45
+ "passport_application": DialogState.INFORMATION,
46
+ "passport_renewal": DialogState.INFORMATION,
47
+ "passport_status": DialogState.QUERY,
48
+ "passport_fee": DialogState.QUERY,
49
+ "nid_application": DialogState.INFORMATION,
50
+ "nid_correction": DialogState.INFORMATION,
51
+ "nid_status": DialogState.QUERY,
52
+ "utility_bill_payment": DialogState.INFORMATION,
53
+ "utility_new_connection": DialogState.INFORMATION,
54
+ "utility_complaint": DialogState.QUERY,
55
+ "welfare_application": DialogState.INFORMATION,
56
+ "welfare_eligibility": DialogState.QUERY,
57
+ "welfare_status": DialogState.QUERY,
58
+ "general_inquiry": DialogState.QUERY,
59
+ "complaint": DialogState.ESCALATION,
60
+ }
61
+
62
+
63
+ # ============================================================================
64
+ # SLOT DEFINITIONS PER DOMAIN
65
+ # ============================================================================
66
+
67
+ DOMAIN_SLOTS = {
68
+ "passport": [
69
+ "applicant_name", "nid_number", "date_of_birth",
70
+ "passport_type", "application_type", "fee_amount",
71
+ ],
72
+ "nid": [
73
+ "applicant_name", "date_of_birth", "voter_area",
74
+ "correction_field", "nid_number",
75
+ ],
76
+ "utilities": [
77
+ "account_number", "bill_type", "payment_method",
78
+ "complaint_type", "connection_type", "area",
79
+ ],
80
+ "welfare": [
81
+ "applicant_name", "age", "scheme_name",
82
+ "eligibility_status", "application_id",
83
+ ],
84
+ "general": [
85
+ "topic", "query_type",
86
+ ],
87
+ }
88
+
89
+
90
+ # ============================================================================
91
+ # DATA STRUCTURES
92
+ # ============================================================================
93
+
94
+ @dataclass
95
+ class Turn:
96
+ """A single turn in the conversation."""
97
+ role: str # "citizen" or "agent"
98
+ text: str # The utterance text
99
+ intent: Optional[str] = None
100
+ entities: Optional[Dict[str, str]] = None
101
+ timestamp: Optional[str] = None
102
+
103
+ def to_dict(self) -> Dict:
104
+ return asdict(self)
105
+
106
+
107
+ @dataclass
108
+ class ConversationState:
109
+ """Full state of a conversation."""
110
+ dialog_id: str
111
+ domain: str = "general"
112
+ state: DialogState = DialogState.IDLE
113
+ turns: List[Turn] = field(default_factory=list)
114
+ slots: Dict[str, Optional[str]] = field(default_factory=dict)
115
+ turn_count: int = 0
116
+ confidence_scores: List[float] = field(default_factory=list)
117
+ created_at: str = field(default_factory=lambda: datetime.now().isoformat())
118
+
119
+ def to_dict(self) -> Dict:
120
+ d = asdict(self)
121
+ d["state"] = self.state.value
122
+ return d
123
+
124
+
125
+ # ============================================================================
126
+ # DIALOG MANAGER
127
+ # ============================================================================
128
+
129
+ class DialogManager:
130
+ """
131
+ Manages dialog state for multi-turn Bengali public service conversations.
132
+
133
+ The dialog manager sits between NLU (intent + entities) and NLG (response
134
+ generation), maintaining conversation context and determining the system's
135
+ next action.
136
+
137
+ Architecture:
138
+ User Input → NLU → DialogManager.update() → context → NLG → Response
139
+ """
140
+
141
+ def __init__(self, max_context_turns: int = 5, max_turns: int = 20):
142
+ """
143
+ Args:
144
+ max_context_turns: Number of recent turns to include in context
145
+ window for response generation.
146
+ max_turns: Maximum turns before suggesting escalation.
147
+ """
148
+ self.max_context_turns = max_context_turns
149
+ self.max_turns = max_turns
150
+ self.conversations: Dict[str, ConversationState] = {}
151
+
152
+ def start_conversation(self, dialog_id: str, domain: str = "general") -> ConversationState:
153
+ """Initialize a new conversation."""
154
+ conv = ConversationState(
155
+ dialog_id=dialog_id,
156
+ domain=domain,
157
+ state=DialogState.IDLE,
158
+ slots={slot: None for slot in DOMAIN_SLOTS.get(domain, [])},
159
+ )
160
+ self.conversations[dialog_id] = conv
161
+ return conv
162
+
163
+ def get_conversation(self, dialog_id: str) -> Optional[ConversationState]:
164
+ """Retrieve an existing conversation."""
165
+ return self.conversations.get(dialog_id)
166
+
167
+ def update(
168
+ self,
169
+ dialog_id: str,
170
+ user_text: str,
171
+ intent: str,
172
+ entities: Dict[str, str],
173
+ confidence: float = 1.0,
174
+ ) -> Tuple[ConversationState, str]:
175
+ """
176
+ Process a user turn and update dialog state.
177
+
178
+ Args:
179
+ dialog_id: Conversation identifier
180
+ user_text: The user's utterance
181
+ intent: Predicted intent from NLU
182
+ entities: Extracted entities from NLU {entity_type: value}
183
+ confidence: Intent classification confidence score
184
+
185
+ Returns:
186
+ (updated_state, context_for_nlg)
187
+ """
188
+ conv = self.conversations.get(dialog_id)
189
+ if conv is None:
190
+ conv = self.start_conversation(dialog_id)
191
+
192
+ # 1. Record the user turn
193
+ user_turn = Turn(
194
+ role="citizen",
195
+ text=user_text,
196
+ intent=intent,
197
+ entities=entities if entities else None,
198
+ timestamp=datetime.now().isoformat(),
199
+ )
200
+ conv.turns.append(user_turn)
201
+ conv.turn_count += 1
202
+ conv.confidence_scores.append(confidence)
203
+
204
+ # 2. Update domain based on intent (if domain-specific)
205
+ new_domain = self._infer_domain(intent)
206
+ if new_domain and new_domain != conv.domain:
207
+ conv.domain = new_domain
208
+ # Re-initialize slots for new domain
209
+ conv.slots = {slot: None for slot in DOMAIN_SLOTS.get(new_domain, [])}
210
+
211
+ # 3. Update dialog state
212
+ conv.state = self._transition_state(conv, intent, confidence)
213
+
214
+ # 4. Fill slots from entities
215
+ self._fill_slots(conv, entities)
216
+
217
+ # 5. Build context for response generation
218
+ context = self._build_context(conv)
219
+
220
+ return conv, context
221
+
222
+ def add_agent_response(self, dialog_id: str, response_text: str):
223
+ """Record the agent's response in conversation history."""
224
+ conv = self.conversations.get(dialog_id)
225
+ if conv is None:
226
+ return
227
+
228
+ agent_turn = Turn(
229
+ role="agent",
230
+ text=response_text,
231
+ timestamp=datetime.now().isoformat(),
232
+ )
233
+ conv.turns.append(agent_turn)
234
+
235
+ def get_filled_slots(self, dialog_id: str) -> Dict[str, str]:
236
+ """Return slots that have been filled."""
237
+ conv = self.conversations.get(dialog_id)
238
+ if conv is None:
239
+ return {}
240
+ return {k: v for k, v in conv.slots.items() if v is not None}
241
+
242
+ def get_missing_slots(self, dialog_id: str) -> List[str]:
243
+ """Return slots that still need to be filled."""
244
+ conv = self.conversations.get(dialog_id)
245
+ if conv is None:
246
+ return []
247
+ return [k for k, v in conv.slots.items() if v is None]
248
+
249
+ def should_escalate(self, dialog_id: str) -> bool:
250
+ """Check if conversation should be escalated to human agent."""
251
+ conv = self.conversations.get(dialog_id)
252
+ if conv is None:
253
+ return False
254
+
255
+ # Escalate if: explicit complaint, too many turns, or low confidence
256
+ if conv.state == DialogState.ESCALATION:
257
+ return True
258
+ if conv.turn_count > self.max_turns:
259
+ return True
260
+ if len(conv.confidence_scores) >= 3:
261
+ recent = conv.confidence_scores[-3:]
262
+ if all(c < 0.5 for c in recent):
263
+ return True
264
+
265
+ return False
266
+
267
+ def end_conversation(self, dialog_id: str) -> Optional[Dict]:
268
+ """End a conversation and return its summary."""
269
+ conv = self.conversations.pop(dialog_id, None)
270
+ if conv is None:
271
+ return None
272
+
273
+ return {
274
+ "dialog_id": dialog_id,
275
+ "domain": conv.domain,
276
+ "total_turns": conv.turn_count,
277
+ "final_state": conv.state.value,
278
+ "filled_slots": self.get_filled_slots(dialog_id),
279
+ "avg_confidence": (
280
+ sum(conv.confidence_scores) / len(conv.confidence_scores)
281
+ if conv.confidence_scores else 0
282
+ ),
283
+ }
284
+
285
+ # ------------------------------------------------------------------
286
+ # Internal Methods
287
+ # ------------------------------------------------------------------
288
+
289
+ def _infer_domain(self, intent: str) -> Optional[str]:
290
+ """Infer domain from intent name."""
291
+ if intent.startswith("passport"):
292
+ return "passport"
293
+ elif intent.startswith("nid"):
294
+ return "nid"
295
+ elif intent.startswith("utility"):
296
+ return "utilities"
297
+ elif intent.startswith("welfare"):
298
+ return "welfare"
299
+ return None
300
+
301
+ def _transition_state(
302
+ self, conv: ConversationState, intent: str, confidence: float
303
+ ) -> DialogState:
304
+ """Determine next dialog state based on current state + intent."""
305
+
306
+ # Low confidence → stay in current state (don't make wrong transitions)
307
+ if confidence < 0.3:
308
+ return conv.state
309
+
310
+ # Map intent to target state
311
+ target = INTENT_STATE_MAP.get(intent, DialogState.QUERY)
312
+
313
+ # State transition rules
314
+ current = conv.state
315
+
316
+ if current == DialogState.IDLE:
317
+ return target
318
+
319
+ if current == DialogState.GREETING:
320
+ # After greeting, move to whatever the user wants
321
+ if target in (DialogState.GREETING, DialogState.CLOSING):
322
+ return target
323
+ return target
324
+
325
+ if current == DialogState.CLOSING:
326
+ # If user continues after farewell, re-open
327
+ if target not in (DialogState.CLOSING,):
328
+ return target
329
+ return DialogState.CLOSING
330
+
331
+ # Default: follow the intent mapping
332
+ return target
333
+
334
+ def _fill_slots(self, conv: ConversationState, entities: Dict[str, str]):
335
+ """Fill conversation slots from extracted entities."""
336
+ if not entities:
337
+ return
338
+
339
+ # Map NER entity types to slot names
340
+ entity_slot_map = {
341
+ "PERSON": "applicant_name",
342
+ "NID": "nid_number",
343
+ "DATE": "date_of_birth",
344
+ "MONEY": "fee_amount",
345
+ "LOCATION": "area",
346
+ "ACCOUNT": "account_number",
347
+ "AGE": "age",
348
+ "SCHEME": "scheme_name",
349
+ "DOCUMENT": "passport_type",
350
+ }
351
+
352
+ for entity_type, value in entities.items():
353
+ slot_name = entity_slot_map.get(entity_type)
354
+ if slot_name and slot_name in conv.slots:
355
+ conv.slots[slot_name] = value
356
+
357
+ def _build_context(self, conv: ConversationState) -> str:
358
+ """
359
+ Build context string for response generation model.
360
+
361
+ Takes the last N turns and formats them as the model expects.
362
+ """
363
+ # Get recent turns (up to max_context_turns)
364
+ recent_turns = conv.turns[-self.max_context_turns:]
365
+
366
+ # Format as "role: text" pairs
367
+ context_parts = []
368
+ for turn in recent_turns:
369
+ if turn.role == "citizen":
370
+ context_parts.append(f"নাগরিক: {turn.text}")
371
+ else:
372
+ context_parts.append(f"এজেন্ট: {turn.text}")
373
+
374
+ return " ".join(context_parts)
375
+
376
+ def get_state_summary(self, dialog_id: str) -> Dict:
377
+ """Get a summary of current conversation state (for debugging/logging)."""
378
+ conv = self.conversations.get(dialog_id)
379
+ if conv is None:
380
+ return {"error": "Conversation not found"}
381
+
382
+ return {
383
+ "dialog_id": dialog_id,
384
+ "domain": conv.domain,
385
+ "state": conv.state.value,
386
+ "turn_count": conv.turn_count,
387
+ "filled_slots": {k: v for k, v in conv.slots.items() if v is not None},
388
+ "missing_slots": [k for k, v in conv.slots.items() if v is None],
389
+ "should_escalate": self.should_escalate(dialog_id),
390
+ "last_intent": (
391
+ conv.turns[-1].intent if conv.turns and conv.turns[-1].intent else None
392
+ ),
393
+ }