-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathnlu_engine.py
More file actions
1794 lines (1513 loc) · 93.5 KB
/
Copy pathnlu_engine.py
File metadata and controls
1794 lines (1513 loc) · 93.5 KB
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
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import os
import nltk
# Point NLTK to the local nltk_data folder first, then Render path for deployment
local_nltk_path = os.path.join(os.path.dirname(__file__), "..", "nltk_data")
render_nltk_path = "/opt/render/nltk_data"
# Add local path first (for development), then Render path (for deployment)
nltk.data.path.insert(0, local_nltk_path)
nltk.data.path.append(render_nltk_path)
import json
import logging
import re
from typing import Dict, List, Tuple, Optional
from dataclasses import dataclass
from enum import Enum
logger = logging.getLogger(__name__)
# NLTK will be imported lazily to avoid deployment issues
NLTK_AVAILABLE = False
NLTK_INITIALIZED = False
def _initialize_nltk():
"""Initialize NLTK safely with error handling"""
global NLTK_AVAILABLE, NLTK_INITIALIZED
if NLTK_INITIALIZED:
return NLTK_AVAILABLE
try:
import nltk
from nltk.tokenize import word_tokenize, sent_tokenize
from nltk.corpus import stopwords
from nltk.stem import PorterStemmer
from nltk.tag import pos_tag
NLTK_AVAILABLE = True
NLTK_INITIALIZED = True
# logger.info("✅ NLTK initialized successfully for NLU engine")
return True
except ImportError:
NLTK_AVAILABLE = False
NLTK_INITIALIZED = True
logger.warning("NLTK not available for NLU engine")
return False
except Exception as e:
NLTK_AVAILABLE = False
NLTK_INITIALIZED = True
logger.warning(f"NLTK initialization failed: {e}")
return False
# Multilingual NLP engine removed - using simple rule-based classification only
MULTILINGUAL_NLP_AVAILABLE = False
class Intent(Enum):
"""Defined intents for the school chatbot"""
# CRITICAL SAFETY: Medical emergency detection (HIGHEST PRIORITY)
EMERGENCY = "emergency" # Medical emergencies, 911 calls, life-threatening situations
# Enhanced greeting intents for better personalization
GREETING_WITH_NAME = "greeting_with_name"
GREETING_SIMPLE = "greeting_simple"
GREETING_RETURNING_USER = "greeting_returning_user" # New: returning user greeting
GREETING_EXCITED = "greeting_excited" # New: enthusiastic greeting
GREETING_FORMAL = "greeting_formal" # New: formal/polite greeting
GREETING_CASUAL = "greeting_casual" # New: casual greeting
# Existing intents
ENROLLMENT_INQUIRY = "enrollment_inquiry"
SCHOOL_INFO = "school_info"
STAFF_INQUIRY = "staff_inquiry"
SCHEDULE_INQUIRY = "schedule_inquiry"
CONTACT_INFO = "contact_info"
CONTACT_ESCALATION = "contact_escalation" # New: live person contact requests
NAME_INTRODUCTION = "name_introduction"
CHILD_INTRODUCTION = "child_introduction"
NAME_QUERY = "name_query" # New: "what is my name" queries
FACILITIES_INQUIRY = "facilities_inquiry" # New: cafeteria, library, gym, etc.
FINANCIAL_INQUIRY = "financial_inquiry" # New: tuition, fees, payments
GENERAL_INFO = "general_info" # New: school overview, mission, vision
CONFIRMATION = "confirmation" # New: yes/no responses
LOCATION_INQUIRY = "location_inquiry" # New: directions, address, campus map
HELP_REQUEST = "help_request" # New: general help, assistance
APPRECIATION = "appreciation" # New: thank you, thanks
EMOTIONAL_EXPRESSION = "emotional_expression" # New: i am sad, i am happy, etc.
CLARIFICATION = "clarification"
DENIAL = "denial"
GOODBYE = "goodbye"
# Emergency and Safety Intents - for critical situations
MEDICAL_EMERGENCY = "medical_emergency" # Medical emergencies requiring immediate attention
SAFETY_EMERGENCY = "safety_emergency" # School safety emergencies
SAFETY_INQUIRY = "safety_inquiry" # Safety questions (drills, procedures, etc.)
# Conversation Flow Intents - for better multi-turn conversations
ENROLLMENT_DOCUMENTS = "enrollment_documents" # Specific document requirements
ENROLLMENT_DEADLINE = "enrollment_deadline" # Deadline and timeline questions
ENROLLMENT_PROCESS = "enrollment_process" # Step-by-step process
SCHOOL_OVERVIEW = "school_overview" # General school information request
GRADE_LEVELS = "grade_levels" # What grades/levels offered
SCHOOL_PROGRAMS = "school_programs" # Academic programs and curricula
FOLLOW_UP_QUESTION = "follow_up_question" # Follow-up to previous answer
TOPIC_CONTINUATION = "topic_continuation" # Continuing same topic
CLARIFICATION_REQUEST = "clarification_request" # Asking for more details
COMPARISON_REQUEST = "comparison_request" # Comparing options/programs
UNKNOWN = "unknown"
@dataclass
class Entity:
"""Represents an extracted entity from user input"""
type: str # e.g., "person_name", "child_name", "age"
value: str
confidence: float
start: int = 0
end: int = 0
@dataclass
class NLUResult:
"""Result of NLU analysis"""
intent: Intent
confidence: float
entities: List[Entity]
is_multi_question: bool = False
questions: List[str] = None
class NLUEngine:
"""
Natural Language Understanding engine for the school chatbot.
Uses AI-powered intent classification with rule-based fallback.
"""
def __init__(self):
# Optional: Initialize AI clients (OpenAI/Groq) for advanced classification
self.openai_client = None
# Multi-question detection patterns
self.question_separators = [
r'\?', # Question marks
r'\.\s+(?=[A-Z])', # Periods followed by capital letters
r';\s+', # Semicolons
r'and\s+', # "and" as separator
r'also\s+', # "also" as separator
r'what\s+about\s+', # "what about" as separator
r'how\s+about\s+', # "how about" as separator
r'hay\s+du\s+', # "hay du" as separator (Aklanon)
r'hay\s+tag\s+', # "hay tag" as separator (Aklanon)
r'ano\s+naman\s+', # "ano naman" as separator (Tagalog)
r'ano\s+pa\s+naman\s+', # "ano pa naman" as separator (Tagalog)
]
self.groq_client = None
# Initialize NLTK components for enhanced text processing
self.stemmer = None
self.stop_words = set()
def detect_multi_questions(self, user_input: str) -> Tuple[bool, List[str]]:
"""Detect if the input contains multiple questions and parse them"""
import re
# Clean the input
cleaned_input = user_input.strip()
# 🚨 CRITICAL FIX: Default to NOT splitting unless there's clear evidence
# This prevents false positives like "i have a daughter and shes in grade 4?"
# First, check for obvious single statements that should NEVER be split
single_statement_patterns = [
# Personal statements with "and" - these are single statements, not multiple questions
r'i\s+have\s+.*?and\s+.*?', # "i have a daughter and shes in grade 4"
r'my\s+\w+\s+.*?and\s+.*?', # "my child is in grade 3 and 4"
r'we\s+have\s+.*?and\s+.*?', # "we have a child and he's in grade 5"
r'our\s+\w+\s+.*?and\s+.*?', # "our daughter and she's in grade 2"
# Tagalog personal statements
r'ako\s+may\s+.*?at\s+.*?', # "ako may anak at nasa baitang 4"
r'ang\s+\w+\s+ko\s+.*?at\s+.*?', # "ang anak ko ay si Maria at nasa baitang 4"
# Aklanon personal statements
r'ako\s+may\s+.*?at\s+.*?', # "ako may unga at nasa baitang 4"
r'ang\s+\w+\s+ko\s+.*?at\s+.*?', # "ang unga ko ay si Maria at nasa baitang 4"
# Help requests that shouldn't be split
r'can\s+you\s+help\s+.*?', # "can you help me find where the office is"
r'please\s+help\s+.*?', # "please help me find"
r'help\s+me\s+.*?', # "help me find"
]
is_single_statement = any(re.search(pattern, cleaned_input, re.IGNORECASE) for pattern in single_statement_patterns)
# If it's clearly a single statement, don't split
if is_single_statement:
return False, [user_input]
# Only proceed with multiquestion detection if there are multiple question marks
question_marks = cleaned_input.count('?')
if question_marks <= 1:
# No multiple question marks = likely single question/statement
return False, [user_input]
# If we have multiple question marks, then we can consider splitting
if question_marks > 1:
questions = [q.strip() for q in cleaned_input.split('?') if q.strip()]
# Remove the last empty element if it exists
if questions and not questions[-1]:
questions = questions[:-1]
# Only return as multiple questions if we have at least 2 meaningful questions
if len(questions) >= 2:
# Filter out very short questions
filtered_questions = []
for q in questions:
q = q.strip()
if len(q) > 10: # Minimum meaningful question length
filtered_questions.append(q)
if len(filtered_questions) >= 2:
return True, filtered_questions
# Enhanced detection for genuine multiple questions connected by "and"
# Only split if both parts look like complete questions
genuine_multiquestion_patterns = [
# Pattern: "what is X and where is Y" - both parts are complete questions
r'(what|where|when|who|how|why|which|can|could|would|should|is|are|do|does|did|will|have|has)\s+.*?\s+and\s+(what|where|when|who|how|why|which|can|could|would|should|is|are|do|does|did|will|have|has)\s+.*?',
# Pattern: "who is X and who is Y" - both parts are complete questions
r'(who|what|where|when|how|why|which|can|could|would|should|is|are|do|does|did|will|have|has)\s+.*?\s+and\s+(who|what|where|when|how|why|which|can|could|would|should|is|are|do|does|did|will|have|has)\s+.*?',
]
for pattern in genuine_multiquestion_patterns:
if re.search(pattern, cleaned_input, re.IGNORECASE):
# Split by "and" and check if both parts are meaningful questions
parts = re.split(r'\s+and\s+', cleaned_input, flags=re.IGNORECASE)
if len(parts) >= 2:
filtered_parts = []
for part in parts:
part = part.strip()
if len(part) > 15: # Minimum length for a complete question
# Check if it starts with a question word
question_starters = ['what', 'where', 'when', 'who', 'how', 'why', 'which', 'can', 'could', 'would', 'should', 'is', 'are', 'do', 'does', 'did', 'will', 'have', 'has']
if any(part.lower().startswith(starter + ' ') for starter in question_starters):
filtered_parts.append(part)
if len(filtered_parts) >= 2:
return True, filtered_parts
# Default: treat as single question/statement
return False, [user_input]
def _initialize_nltk_components(self):
"""Initialize NLTK components safely"""
if _initialize_nltk():
try:
from nltk.stem import PorterStemmer
from nltk.corpus import stopwords
self.stemmer = PorterStemmer()
self.stop_words = set(stopwords.words('english'))
# logger.info(f"✅ NLU Engine initialized with {len(self.stop_words)} stopwords")
except Exception as e:
logger.warning(f"⚠️ Could not initialize NLTK components: {e}")
self.stop_words = set(['the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by'])
def _preprocess_text(self, text: str) -> str:
"""Enhanced text preprocessing using NLTK"""
if not _initialize_nltk():
return text.lower().strip()
try:
from nltk.tokenize import word_tokenize
# Tokenize and clean text
tokens = word_tokenize(text.lower())
# Remove stopwords and non-alphabetic tokens
filtered_tokens = [token for token in tokens
if token.isalpha() and token not in self.stop_words]
# Stem tokens if stemmer is available
if self.stemmer:
filtered_tokens = [self.stemmer.stem(token) for token in filtered_tokens]
return ' '.join(filtered_tokens)
except Exception as e:
logger.warning(f"Text preprocessing failed: {e}")
return text.lower().strip()
def _extract_key_phrases(self, text: str) -> List[str]:
"""Extract key phrases using NLTK POS tagging"""
if not _initialize_nltk():
return text.split()
try:
from nltk.tokenize import word_tokenize
from nltk.tag import pos_tag
tokens = word_tokenize(text)
pos_tags = pos_tag(tokens)
# Extract nouns, adjectives, and important verbs
key_phrases = []
for token, pos in pos_tags:
if pos.startswith(('NN', 'JJ', 'VB')) and len(token) > 2:
key_phrases.append(token.lower())
return key_phrases
except Exception as e:
logger.warning(f"Key phrase extraction failed: {e}")
return text.split()
def _calculate_semantic_similarity(self, text1: str, text2: str) -> float:
"""Calculate semantic similarity between two texts"""
if not _initialize_nltk():
# Simple word overlap similarity
words1 = set(text1.lower().split())
words2 = set(text2.lower().split())
intersection = words1.intersection(words2)
union = words1.union(words2)
return len(intersection) / len(union) if union else 0.0
try:
# Enhanced similarity using preprocessed text
processed1 = self._preprocess_text(text1)
processed2 = self._preprocess_text(text2)
words1 = set(processed1.split())
words2 = set(processed2.split())
if not words1 or not words2:
return 0.0
intersection = words1.intersection(words2)
union = words1.union(words2)
# Jaccard similarity
jaccard = len(intersection) / len(union)
# Weight by key phrase overlap
key_phrases1 = set(self._extract_key_phrases(text1))
key_phrases2 = set(self._extract_key_phrases(text2))
key_intersection = key_phrases1.intersection(key_phrases2)
key_union = key_phrases1.union(key_phrases2)
key_similarity = len(key_intersection) / len(key_union) if key_union else 0.0
# Combine similarities (weight key phrases more)
return (jaccard * 0.6) + (key_similarity * 0.4)
except Exception as e:
logger.warning(f"Semantic similarity calculation failed: {e}")
return 0.0
async def _detect_emergency_with_context(self, user_input: str, user_lower: str) -> Optional[NLUResult]:
"""
Enhanced emergency detection with NLP context analysis to avoid false positives
"""
try:
# Step 1: Check for humor/sarcasm indicators first (highest priority)
humor_indicators = await self._detect_humor_context(user_input, user_lower)
if humor_indicators['is_humor']:
# logger.info(f"😄 Humor detected: {humor_indicators['reason']} - NOT an emergency")
return None
# Step 2: Check for emotional context (jokes, expressions, metaphors)
emotional_context = await self._analyze_emotional_context(user_input, user_lower)
if emotional_context['is_expression']:
# logger.info(f"💭 Emotional expression detected: {emotional_context['reason']} - NOT an emergency")
return None
# Step 3: Check for serious emergency indicators with context
emergency_indicators = await self._detect_serious_emergency_indicators(user_input, user_lower)
if emergency_indicators['is_emergency']:
logger.warning(f"🚨 REAL EMERGENCY DETECTED: {emergency_indicators['reason']}")
return NLUResult(Intent.EMERGENCY, 0.95, [])
# Step 4: Check for standalone medical terms (likely not emergencies)
standalone_medical = self._check_standalone_medical_terms(user_lower)
if standalone_medical['is_standalone']:
# logger.info(f"ℹ️ Standalone medical term: {standalone_medical['reason']} - NOT emergency")
return None
# Step 5: Fallback to original keyword detection (but with lower confidence)
fallback_emergency = self._fallback_emergency_detection(user_lower)
if fallback_emergency:
logger.warning(f"🚨 FALLBACK EMERGENCY DETECTED: {fallback_emergency}")
return NLUResult(Intent.EMERGENCY, 0.7, []) # Lower confidence for fallback
return None
except Exception as e:
logger.error(f"Emergency detection failed: {e}")
# Fallback to original simple detection
return self._fallback_emergency_detection(user_lower)
async def _detect_humor_context(self, user_input: str, user_lower: str) -> Dict:
"""
Detect humor, sarcasm, and non-literal language using NLP patterns
"""
humor_indicators = {
'is_humor': False,
'reason': '',
'confidence': 0.0
}
# Strong humor indicators (high confidence)
strong_humor_patterns = [
r'\b(haha|hehe|hihi|lol|lmao|rofl|funny|joke|joking|kidding|just kidding)\b',
r'\b(thought|thinking|gonna|going to|almost|nearly|close call)\b',
r'\b(you made me|you gave me|you almost|you nearly)\b',
r'\b(that was|that\'s|this is)\s+(funny|hilarious|amusing|comical)\b',
r'\b(not serious|not really|just saying|just messing)\b'
]
for pattern in strong_humor_patterns:
if re.search(pattern, user_lower):
humor_indicators['is_humor'] = True
humor_indicators['reason'] = f"Humor pattern: {pattern}"
humor_indicators['confidence'] = 0.9
return humor_indicators
# Medium humor indicators
medium_humor_patterns = [
r'\b(almost|nearly|close|scared|worried|nervous)\b.*\b(heart|attack|stroke|emergency)\b',
r'\b(heart|attack|stroke|emergency)\b.*\b(almost|nearly|close|scared|worried|nervous)\b',
r'\b(you|that|this)\s+(almost|nearly|gave|made)\s+(me|us)\b',
r'\b(thought|thinking|was thinking|was worried)\b.*\b(you|that|this)\b'
]
for pattern in medium_humor_patterns:
if re.search(pattern, user_lower):
humor_indicators['is_humor'] = True
humor_indicators['reason'] = f"Contextual humor: {pattern}"
humor_indicators['confidence'] = 0.7
return humor_indicators
# Check for laughter patterns
laughter_patterns = [r'ha{2,}', r'he{2,}', r'hi{2,}', r'lol+', r'rofl+']
for pattern in laughter_patterns:
if re.search(pattern, user_lower):
humor_indicators['is_humor'] = True
humor_indicators['reason'] = f"Laughter pattern: {pattern}"
humor_indicators['confidence'] = 0.8
return humor_indicators
return humor_indicators
async def _analyze_emotional_context(self, user_input: str, user_lower: str) -> Dict:
"""
Analyze emotional context to distinguish between real distress and expressions
"""
emotional_context = {
'is_expression': False,
'reason': '',
'confidence': 0.0
}
# Expression patterns (not literal emergencies)
expression_patterns = [
r'\b(thought|thinking|was thinking|was worried|was scared)\b',
r'\b(you|that|this)\s+(almost|nearly|gave|made|caused)\s+(me|us)\s+(to|a)\b',
r'\b(heart|attack|stroke|emergency)\b.*\b(almost|nearly|close|scared|worried|nervous)\b',
r'\b(almost|nearly|close|scared|worried|nervous)\b.*\b(heart|attack|stroke|emergency)\b',
r'\b(you|that|this)\s+(are|were|will be)\s+(gonna|going to)\b',
r'\b(gonna|going to)\s+(give|cause|make)\s+(me|us)\b',
r'\b(thought|thinking|about)\b.*\b(heart|attack|stroke|emergency)\b',
r'\b(heart|attack|stroke|emergency)\b.*\b(symptoms|about|information|what is|tell me|explain)\b'
]
for pattern in expression_patterns:
if re.search(pattern, user_lower):
emotional_context['is_expression'] = True
emotional_context['reason'] = f"Expression pattern: {pattern}"
emotional_context['confidence'] = 0.8
return emotional_context
# Check for metaphorical language
metaphorical_indicators = [
'thought', 'thinking', 'gonna', 'going to', 'almost', 'nearly',
'you made me', 'you gave me', 'you almost', 'you nearly'
]
metaphorical_count = sum(1 for indicator in metaphorical_indicators if indicator in user_lower)
if metaphorical_count >= 2:
emotional_context['is_expression'] = True
emotional_context['reason'] = f"Multiple metaphorical indicators: {metaphorical_count}"
emotional_context['confidence'] = 0.7
return emotional_context
# Check for informational context patterns
info_patterns = [
r'\b(about|symptoms|information|what is|tell me|explain)\b.*\b(heart|attack|stroke|emergency)\b',
r'\b(heart|attack|stroke|emergency)\b.*\b(about|symptoms|information|what is|tell me|explain)\b',
r'\b(thought|thinking)\s+(about|of)\b',
r'\b(what|how|when|where|why)\b.*\b(heart|attack|stroke|emergency)\b'
]
for pattern in info_patterns:
if re.search(pattern, user_lower):
emotional_context['is_expression'] = True
emotional_context['reason'] = f"Informational pattern: {pattern}"
emotional_context['confidence'] = 0.8
return emotional_context
return emotional_context
def _check_standalone_medical_terms(self, user_lower: str) -> Dict:
"""
Check if medical terms appear without urgent context (likely not emergencies)
"""
standalone_result = {
'is_standalone': False,
'reason': '',
'confidence': 0.0
}
# Medical terms that need urgent context to be emergencies
medical_terms = ['heart attack', 'stroke', 'cardiac arrest', 'chest pain']
for term in medical_terms:
if term in user_lower:
# Check for urgent context indicators
urgent_indicators = ['help', 'emergency', 'ambulance', '911', 'call', 'now', 'immediately', 'urgent', 'dying', 'can\'t breathe']
has_urgent_context = any(indicator in user_lower for indicator in urgent_indicators)
if not has_urgent_context:
standalone_result['is_standalone'] = True
standalone_result['reason'] = f"Medical term '{term}' without urgent context"
standalone_result['confidence'] = 0.8
return standalone_result
return standalone_result
async def _detect_serious_emergency_indicators(self, user_input: str, user_lower: str) -> Dict:
"""
Detect serious emergency indicators with high confidence
"""
emergency_indicators = {
'is_emergency': False,
'reason': '',
'confidence': 0.0
}
# High-confidence emergency patterns
# Enhanced patterns for better emergency detection
serious_emergency_patterns = [
# Direct emergency statements (removed standalone "emergency" to prevent false positives)
r'\b(ambulance|911|call 911|call emergency)\b',
r'\b(medical emergency|urgent medical|urgent help needed)\b',
r'\b(life threatening|critical condition|critical situation)\b',
# Specific medical emergency conditions
r'\b(can\'t breathe|can\'t breath|shortness of breath|difficulty breathing)\b',
r'\b(unconscious|passed out|fainted|not responding|unresponsive)\b',
r'\b(bleeding|blood|hemorrhage|severe bleeding)\b',
r'\b(chest pain|heart pain|severe pain|intense pain)\b',
r'\b(heart attack|cardiac arrest|stroke|seizure)\b',
r'\b(severe injury|accident|hurt badly|broken bone|fracture)\b',
# "I'm having" patterns for direct emergency reporting - enhanced with variations
r'\bi\'?m having a (heart attack|stroke|seizure|medical emergency)\b',
r'\bi\'?m having (chest pain|difficulty breathing|severe pain)\b',
r'\bi\'?m (bleeding|injured|hurt badly|dying)\b',
r'\bi am having a (heart attack|stroke|seizure|medical emergency)\b',
r'\bi am having (chest pain|difficulty breathing|severe pain)\b',
r'\bi am (bleeding|injured|hurt badly|dying)\b',
r'\bhaving (a heart attack|a stroke|a seizure|chest pain)\b',
# Real emergency help requests (multiple urgent indicators)
r'\b(someone|somebody|my (friend|family|child|parent))\b.*\b(help|emergency|dying|hurt|injured)\b',
r'\b(help|emergency)\b.*\b(someone|somebody|my (friend|family|child|parent))\b.*\b(dying|hurt|injured)\b',
# Very specific emergency help patterns (not general "help me")
r'\b(help me now|help us now|help me please|help us please)\b.*\b(emergency|dying|hurt|injured|unconscious|bleeding)\b',
r'\b(emergency|dying|hurt|injured|unconscious|bleeding)\b.*\b(help me now|help us now|help me please|help us please)\b',
# Time-sensitive emergency indicators
r'\b(dying|cardiac arrest|stroke|heart attack)\b.*\b(now|immediately|help|emergency)\b',
r'\b(now|immediately|help|emergency)\b.*\b(dying|cardiac arrest|stroke|heart attack)\b',
# Direct statements about self
r'\bi\'?m dying\b',
r'\bi\'?m having an emergency\b',
r'\bi need (medical attention|an ambulance|911)\b',
r'\bi am dying\b',
r'\bi am having an emergency\b',
# Simplified heart attack patterns (to catch the test case)
r'heart attack',
r'having a heart attack',
r'having heart attack'
]
for pattern in serious_emergency_patterns:
if re.search(pattern, user_lower):
# Check if it's used in a figurative context
is_figurative = False
figurative_contexts = [
'to know', 'laughing', 'waiting to happen', 'killing me', 'skipped a beat',
'dying to', 'dying of', 'dying from', 'feels like', 'like a', 'as if'
]
for context in figurative_contexts:
if context in user_lower:
logger.info(f"🎭 NLU: Figurative use detected in pattern: '{pattern}' with '{context}' in '{user_input}'")
is_figurative = True
break
# Only trigger emergency if not figurative
if not is_figurative:
emergency_indicators['is_emergency'] = True
emergency_indicators['reason'] = f"Serious emergency pattern: {pattern}"
emergency_indicators['confidence'] = 0.95
return emergency_indicators
# Check for urgent action words combined with medical terms
# 🎯 FIX: Removed standalone "help" to avoid false positives with "help me with homework"
urgent_words = ['emergency', 'ambulance', '911', 'call 911', 'help me', 'help us', 'call', 'now', 'immediately', 'urgent']
medical_words = ['heart attack', 'stroke', 'dying', 'bleeding', 'unconscious', 'can\'t breathe']
urgent_count = sum(1 for word in urgent_words if word in user_lower)
medical_count = sum(1 for word in medical_words if word in user_lower)
if urgent_count >= 1 and medical_count >= 1:
# Check if it's used in a figurative context
is_figurative = False
figurative_contexts = [
'to know', 'laughing', 'waiting to happen', 'killing me', 'skipped a beat',
'dying to', 'dying of', 'dying from', 'feels like', 'like a', 'as if'
]
for context in figurative_contexts:
if context in user_lower:
logger.info(f"🎭 NLU: Figurative use detected in urgent+medical: '{context}' in '{user_input}'")
is_figurative = True
break
# Only trigger emergency if not figurative
if not is_figurative:
emergency_indicators['is_emergency'] = True
emergency_indicators['reason'] = f"Urgent + medical terms: {urgent_count} urgent, {medical_count} medical"
emergency_indicators['confidence'] = 0.9
return emergency_indicators
return emergency_indicators
def _fallback_emergency_detection(self, user_lower: str) -> Optional[NLUResult]:
"""
Fallback emergency detection using original keyword matching (lower confidence)
Only triggers for high-confidence emergency scenarios
"""
# Only check for high-confidence emergency keywords (not just medical terms)
# Enhanced with more direct emergency phrases
high_confidence_emergency_keywords = [
# Direct emergency terms (removed standalone 'emergency' to prevent false positives)
'ambulance', '911', 'call 911', 'medical emergency',
# Direct physical conditions
'can\'t breathe', 'unconscious', 'bleeding', 'severe pain', 'heart attack',
'stroke', 'seizure', 'cardiac arrest', 'not breathing', 'choking',
# Direct urgent help phrases
'help me now', 'need urgent help', 'critical', 'life threatening',
'i\'m dying', 'i\'m having a heart attack', 'i\'m having a stroke',
# Tagalog emergency terms
'tulong agad', 'emergency po', 'atake sa puso', 'hindi makahinga'
]
# Check for high-confidence emergency patterns
import re
for keyword in high_confidence_emergency_keywords:
# Use word boundary matching to avoid false positives like "held" containing "help"
if re.search(r'\b' + re.escape(keyword) + r'\b', user_lower):
# Check if it's used in a figurative context
is_figurative = False
figurative_contexts = [
'to know', 'laughing', 'waiting to happen', 'killing me', 'skipped a beat',
'dying to', 'dying of', 'dying from', 'feels like', 'like a', 'as if'
]
for context in figurative_contexts:
if context in user_lower:
logger.info(f"🎭 NLU: Figurative use detected in fallback: '{keyword}' with '{context}'")
is_figurative = True
break
# Only trigger emergency if not figurative
if not is_figurative:
logger.warning(f"🚨 FALLBACK EMERGENCY DETECTED: '{keyword}'")
return NLUResult(Intent.EMERGENCY, 0.7, []) # Lower confidence for fallback
# For medical terms without urgent context, don't flag as emergency
# This prevents false positives for informational queries
medical_terms = ['heart attack', 'stroke', 'cardiac arrest', 'chest pain']
for term in medical_terms:
if term in user_lower:
# Check if it's in an informational context
info_contexts = ['symptoms', 'about', 'what is', 'information', 'tell me about', 'explain']
if any(context in user_lower for context in info_contexts):
# logger.info(f"ℹ️ Medical term '{term}' in informational context - NOT emergency")
return None
# If no urgent context, don't flag as emergency
# logger.info(f"ℹ️ Medical term '{term}' without urgent context - NOT emergency")
return None
return None
async def analyze_intent(self, user_input: str, context: Dict = None) -> NLUResult:
"""
Analyze user input to determine intent and extract entities using advanced NLP
"""
# 🚨 MULTI-QUESTION DETECTION: Check if input contains multiple questions
is_multi_question, questions = self.detect_multi_questions(user_input)
if is_multi_question:
# For multi-question inputs, analyze the primary intent but mark as multi-question
primary_question = questions[0] if questions else user_input
result = await self._analyze_single_intent(primary_question, context)
result.is_multi_question = True
result.questions = questions
return result
# Single question - proceed with normal analysis
return await self._analyze_single_intent(user_input, context)
async def _analyze_single_intent(self, user_input: str, context: Dict = None) -> NLUResult:
"""
Analyze a single question for intent and entities
"""
# FIRST PRIORITY: Check for medical emergencies with context awareness (CRITICAL SAFETY)
user_lower = user_input.lower()
# Direct emergency detection for critical phrases (highest priority)
critical_emergency_phrases = [
"heart attack", "stroke", "can't breathe", "911",
"help me now", "medical emergency", "critical condition", "dying"
]
# Context words that indicate figurative usage (not real emergencies)
figurative_contexts = [
'to know', 'laughing', 'waiting to happen', 'killing me', 'skipped a beat',
'dying to', 'dying of', 'dying from', 'feels like', 'like a', 'as if',
'almost', 'nearly', 'thought', 'thinking', 'gonna', 'going to',
'you made me', 'you gave me', 'you almost', 'you nearly', 'close call',
'was thinking', 'was worried', 'was scared', 'haha', 'hehe', 'lol',
'joke', 'joking', 'kidding', 'just kidding', 'not serious', 'not really'
]
# Special cases for common figurative expressions
figurative_expressions = [
"dying to know", "dying laughing", "gonna die laughing", "dying of laughter",
"dying of", "dying from", "dying with", "almost died", "nearly died"
]
is_figurative = False
for expr in figurative_expressions:
if expr in user_lower:
logger.info(f"🎭 NLU: Figurative 'dying' expression detected: '{expr}' in '{user_input}'")
is_figurative = True
break
# Also check for "dying" + context words
if "dying" in user_lower:
figurative_contexts = ["laughing", "laugh", "know", "curiosity", "funny", "joke", "amused", "hilarious"]
for context in figurative_contexts:
if context in user_lower:
logger.info(f"🎭 NLU: Figurative 'dying' with context '{context}' detected in: '{user_input}'")
is_figurative = True
break
if is_figurative:
# Skip emergency detection for this query - return unknown intent
return NLUResult(Intent.UNKNOWN, 0.3, [])
else:
# Check for direct critical emergency phrases first
for phrase in critical_emergency_phrases:
if phrase in user_lower:
# Check if it's used in a figurative context
is_figurative = False
for context in figurative_contexts:
if context in user_lower:
logger.info(f"🎭 NLU: Figurative use detected: '{phrase}' with '{context}' in '{user_input}'")
is_figurative = True
break
# Only trigger emergency if not figurative
if not is_figurative:
logger.warning(f"🚨 CRITICAL EMERGENCY DETECTED: '{phrase}' in '{user_input}'")
return NLUResult(Intent.EMERGENCY, 0.98, [])
# Check for specific phrases with apostrophes (which might cause matching issues)
apostrophe_phrases = [
"i'm having a heart attack", "i'm having a stroke", "i'm dying",
"i am having a heart attack", "i am having a stroke", "i am dying"
]
for phrase in apostrophe_phrases:
# Normalize both strings to handle apostrophe differences
norm_phrase = phrase.replace("'", "").lower()
norm_input = user_lower.replace("'", "").lower()
if norm_phrase in norm_input or phrase in user_lower:
# Check if it's used in a figurative context
is_figurative = False
for context in figurative_contexts:
if context in user_lower:
logger.info(f"🎭 NLU: Figurative use detected in apostrophe phrase: '{phrase}' with '{context}' in '{user_input}'")
is_figurative = True
break
# Only trigger emergency if not figurative
if not is_figurative:
logger.warning(f"🚨 CRITICAL EMERGENCY DETECTED: '{phrase}' matches '{user_input}'")
return NLUResult(Intent.EMERGENCY, 0.98, [])
# Enhanced emergency detection with NLP context analysis
emergency_result = await self._detect_emergency_with_context(user_input, user_lower)
if emergency_result:
return emergency_result
# Normalize common typos and variations
normalized_input = user_lower
typo_corrections = {
'kayo': ['kayO', 'kay0', 'kayoo', 'kayou'],
'prinsipal': ['principal', 'prinsipal', 'prinsipal', 'prinsipal'],
'sino': ['sino', 'sino', 'sino', 'sino'],
'may': ['may', 'may', 'may', 'may']
}
# Apply typo corrections
for correct, typos in typo_corrections.items():
for typo in typos:
normalized_input = normalized_input.replace(typo, correct)
# Check for staff inquiry patterns with typo tolerance
if "may prinsipal" in normalized_input or "may principal" in normalized_input:
# logger.info(f"🎯 Rule-based staff inquiry detected: 'may prinsipal' pattern (normalized from '{user_input}')")
return NLUResult(Intent.STAFF_INQUIRY, 0.9, [])
# Multilingual NLP engine removed - using rule-based classification only
# Fallback to rule-based classification with enhanced confidence
rule_result = self._rule_based_classification(user_input)
# Multilingual NLP engine removed - using rule-based classification only
# logger.info(f"🔍 Using rule-based result: {rule_result.intent.value} (confidence: {rule_result.confidence:.2f})")
return rule_result
def _rule_based_classification(self, user_input: str) -> NLUResult:
"""Enhanced rule-based classification with better multilingual support and confidence scoring"""
user_lower = user_input.lower().strip()
# PHASE 1: Exact phrase matching (highest priority)
# This catches complex multilingual phrases before word-by-word analysis
exact_phrases = {
# English location phrases
"where is the school": (Intent.LOCATION_INQUIRY, 0.95),
"where is your school": (Intent.LOCATION_INQUIRY, 0.95),
"where is the school located": (Intent.LOCATION_INQUIRY, 0.95),
"what is the school location": (Intent.LOCATION_INQUIRY, 0.9),
"school location": (Intent.LOCATION_INQUIRY, 0.85),
"where can i find the school": (Intent.LOCATION_INQUIRY, 0.9),
"school address": (Intent.LOCATION_INQUIRY, 0.9),
# Enhanced multilingual greetings
"kumusta": (Intent.GREETING_SIMPLE, 0.9),
"salamat": (Intent.APPRECIATION, 0.85),
"hola": (Intent.GREETING_SIMPLE, 0.8),
"konnichiwa": (Intent.GREETING_SIMPLE, 0.8),
# Aklanon greetings and thanks
"salamat gid": (Intent.APPRECIATION, 0.95),
"damo nga salamat": (Intent.APPRECIATION, 0.95),
"maayong adlaw": (Intent.GREETING_SIMPLE, 0.9),
"maayong gabii": (Intent.GREETING_SIMPLE, 0.9),
"maayong buntag": (Intent.GREETING_SIMPLE, 0.9),
# Tagalog location phrases
"saan ang lokasyon ng paaralan": (Intent.LOCATION_INQUIRY, 0.95),
"saan ang paaralan": (Intent.LOCATION_INQUIRY, 0.9),
"ano ang contact number ninyo": (Intent.CONTACT_INFO, 0.95),
"sabihin mo sa akin ang tungkol sa school programs": (Intent.SCHOOL_INFO, 0.9),
"sabihin sa akin tungkol sa": (Intent.GENERAL_INFO, 0.8),
# Tagalog contact escalation phrases
"gusto ko makausap ang isang tao": (Intent.CONTACT_ESCALATION, 0.95),
"kailangan ko makausap ang isang tao": (Intent.CONTACT_ESCALATION, 0.95),
"gusto ko makipag-usap sa isang tao": (Intent.CONTACT_ESCALATION, 0.95),
"kailangan ko makipag-usap sa isang tao": (Intent.CONTACT_ESCALATION, 0.95),
"gusto ko makausap ang isang staff": (Intent.CONTACT_ESCALATION, 0.95),
"kailangan ko makausap ang isang staff": (Intent.CONTACT_ESCALATION, 0.95),
"gusto ko makausap ang isang teacher": (Intent.CONTACT_ESCALATION, 0.95),
"kailangan ko makausap ang isang teacher": (Intent.CONTACT_ESCALATION, 0.95),
"gusto ko makausap ang isang principal": (Intent.CONTACT_ESCALATION, 0.95),
"kailangan ko makausap ang isang principal": (Intent.CONTACT_ESCALATION, 0.95),
"gusto ko makausap ang isang guidance counselor": (Intent.CONTACT_ESCALATION, 0.95),
"kailangan ko makausap ang isang guidance counselor": (Intent.CONTACT_ESCALATION, 0.95),
# Aklanon location phrases
"diin ang lokasyon sang paaralan": (Intent.LOCATION_INQUIRY, 0.95),
"diin ang paaralan": (Intent.LOCATION_INQUIRY, 0.9),
"diin nga lokasyon": (Intent.LOCATION_INQUIRY, 0.9),
"ano nga contact number": (Intent.CONTACT_INFO, 0.9),
# Aklanon contact escalation phrases
"gusto ko makausap ang isa ka tawo": (Intent.CONTACT_ESCALATION, 0.95),
"kailangan ko makausap ang isa ka tawo": (Intent.CONTACT_ESCALATION, 0.95),
"gusto ko makipag-usap sa isa ka tawo": (Intent.CONTACT_ESCALATION, 0.95),
"kailangan ko makipag-usap sa isa ka tawo": (Intent.CONTACT_ESCALATION, 0.95),
"gusto ko magistryo sa tawo": (Intent.CONTACT_ESCALATION, 0.95),
"kailangan ko magistryo sa tawo": (Intent.CONTACT_ESCALATION, 0.95),
"gusto ko makausap ang isa ka staff": (Intent.CONTACT_ESCALATION, 0.95),
"kailangan ko makausap ang isa ka staff": (Intent.CONTACT_ESCALATION, 0.95),
"gusto ko makausap ang isa ka teacher": (Intent.CONTACT_ESCALATION, 0.95),
"kailangan ko makausap ang isa ka teacher": (Intent.CONTACT_ESCALATION, 0.95),
"gusto ko makausap ang isa ka principal": (Intent.CONTACT_ESCALATION, 0.95),
"kailangan ko makausap ang isa ka principal": (Intent.CONTACT_ESCALATION, 0.95),
"gusto ko makausap ang isa ka guidance counselor": (Intent.CONTACT_ESCALATION, 0.95),
"kailangan ko makausap ang isa ka guidance counselor": (Intent.CONTACT_ESCALATION, 0.95),
}
for phrase, (intent, confidence) in exact_phrases.items():
if phrase in user_lower:
# But check if this is a greeting + name introduction first
name_patterns = ["my name is", "i am called", "i'm called", "im called", "i'm", "ako si", "ngaean ko si", "ngaean ko", "ako ay"]
if any(pattern in user_lower for pattern in name_patterns):
# This is a greeting with name, not just a simple greeting
return NLUResult(Intent.GREETING_WITH_NAME, 0.95, [])
# logger.info(f"🎯 Exact phrase match: '{phrase}' → {intent.value}")
return NLUResult(intent, confidence, [])
# PHASE 2: Enhanced pattern-based matching with weighted confidence scoring
confidence_score = 0.0
detected_intent = Intent.UNKNOWN
evidence_factors = []
# Enhanced greeting detection with confidence scoring
greeting_indicators = self._analyze_greeting_patterns(user_lower)
if greeting_indicators['intent'] != Intent.UNKNOWN:
# Check if this is a greeting + question combination
is_greeting_with_question = self._is_greeting_with_question(user_lower)
if is_greeting_with_question:
# Don't return greeting intent, let other intents be detected
pass
else:
return NLUResult(greeting_indicators['intent'], greeting_indicators['confidence'], [])
# Enhanced enrollment detection
enrollment_score = self._calculate_enrollment_confidence(user_lower)
if enrollment_score > 0.6:
return NLUResult(Intent.ENROLLMENT_INQUIRY, enrollment_score, [])
# Enhanced location detection
location_score = self._calculate_location_confidence(user_lower)
if location_score > 0.6:
return NLUResult(Intent.LOCATION_INQUIRY, location_score, [])
# Enhanced staff inquiry detection
staff_score = self._calculate_staff_confidence(user_lower)
if staff_score > 0.6:
return NLUResult(Intent.STAFF_INQUIRY, staff_score, [])
# 🚨 REMOVED: Old emergency detection code replaced by context-aware detection above
# Enhanced emotional expression detection for Tagalog/Aklanon and English
emotional_patterns = [
"malungkot ako", "masaya ako", "nag-aalala ako", "natutuwa ako",
"pagod ako", "galit ako", "nervous ako", "takot ako", "nalilito ako",
"naiinis ako", "nag-aalala ako", "nalulungkot ako",
# English emotional expressions
"dying laughing", "laughing so hard", "can't stop laughing", "rolling on the floor",
"this is killing me", "i'm cracking up", "bursting with laughter"
]
for pattern in emotional_patterns:
if pattern in user_lower:
# logger.info(f"🎯 Tagalog/Aklanon emotional expression detected: '{pattern}'")
return NLUResult(Intent.EMOTIONAL_EXPRESSION, 0.9, [])
# Continue with priority-based classification for other intents
# DO NOT return early - let it fall through to Priority rules
# Priority 1: Denials and clarifications (check first to avoid false positives)
if any(phrase in user_lower for phrase in ["not asking", "i am not", "i'm not", "hindi ako", "wala ako"]):
return NLUResult(Intent.DENIAL, 0.9, [])
if any(phrase in user_lower for phrase in ["i meant", "what i mean", "clarify", "correction"]):
return NLUResult(Intent.CLARIFICATION, 0.8, [])
# Priority 2: Check for contact escalation FIRST (even with name introductions)
# This handles cases like "ako si heinz and i want to talk to a live person"
contact_escalation_patterns = [
# English patterns
"talk to someone", "speak to someone", "contact someone", "live person", "human",
"staff member", "want to speak", "need to talk", "talk to a live",
"speak to a live", "contact a live", "talk to staff", "speak to staff", "contact staff",
"talk to teacher", "speak to teacher", "contact teacher", "talk to principal", "speak to principal",
"contact principal", "talk to guidance", "speak to guidance", "contact guidance",
"talk to counselor", "speak to counselor", "contact counselor",
"where can i contact", "how can i contact", "contact the admin", "talk to admin",
"where is the messenger", "messenger link", "messenger button", "contact link",