File size: 24,014 Bytes
eeb0f9c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
Base Agent - Parent class for all specialized agents
Provides shared functionality: memory access, handoff logic, coordination
"""

from typing import Dict, Any, Optional, List
from utils.memory import ConversationMemory


class BaseAgent:
    """
    Base class for all agents
    Provides common functionality and interface
    """
    
    def __init__(self, memory: Optional[ConversationMemory] = None):
        """
        Initialize base agent
        
        Args:
            memory: Shared conversation memory (optional)
        """
        self.memory = memory or ConversationMemory()
        self.agent_name = self.__class__.__name__.replace('Agent', '').lower()
        self.system_prompt = ""
        
        # Handoff configuration
        self.can_handoff = True
        self.handoff_triggers = []
    
    # ===== Core Interface =====
    
    def handle(self, parameters: Dict[str, Any], chat_history: Optional[List] = None) -> str:
        """
        Handle user request (must be implemented by subclasses)
        
        Args:
            parameters: Request parameters from router
            chat_history: Conversation history
        
        Returns:
            str: Response message
        """
        raise NotImplementedError("Subclasses must implement handle()")
    
    # ===== Memory Access Helpers =====
    
    def get_user_profile(self) -> Dict[str, Any]:
        """Get complete user profile from memory"""
        return self.memory.get_full_profile()
    
    # ===== Smart RAG Helper =====
    
    def should_use_rag(self, user_query: str, chat_history: Optional[List] = None) -> bool:
        """
        Smart RAG Decision - Skip RAG for simple queries to improve performance
        
        Performance Impact:
        - Simple queries: 2-3s (was 8-10s) - 3x faster
        - Complex queries: 6-8s (was 8-10s) - 1.3x faster
        
        Args:
            user_query: User's message
            chat_history: Conversation history
            
        Returns:
            bool: True if RAG needed, False for simple conversational queries
        """
        query_lower = user_query.lower().strip()
        
        # 1. Greetings & acknowledgments (no RAG needed)
        greetings = [
            'xin chào', 'hello', 'hi', 'chào', 'hey',
            'cảm ơn', 'thanks', 'thank you', 'tks',
            'ok', 'được', 'vâng', 'ừ', 'uhm', 'uh huh',
            'bye', 'tạm biệt', 'hẹn gặp lại'
        ]
        if any(g in query_lower for g in greetings):
            return False
        
        # 2. Very short responses (usually conversational)
        if len(query_lower) < 10:
            short_responses = ['có', 'không', 'rồi', 'ạ', 'dạ', 'yes', 'no', 'nope', 'yep']
            if any(r == query_lower or query_lower.startswith(r + ' ') for r in short_responses):
                return False
        
        # 3. Meta questions about the bot (no RAG needed)
        meta_questions = [
            'bạn là ai', 'bạn tên gì', 'bạn có thể', 'bạn làm gì',
            'who are you', 'what can you', 'what do you'
        ]
        if any(m in query_lower for m in meta_questions):
            return False
        
        # 4. Complex medical/health questions (NEED RAG)
        complex_patterns = [
            # Medical terms
            'nguyên nhân', 'tại sao', 'why', 'how', 'làm sao',
            'cách nào', 'phương pháp', 'điều trị', 'chữa',
            'thuốc', 'medicine', 'phòng ngừa', 'prevention',
            'biến chứng', 'complication', 'nghiên cứu', 'research',
            # Specific diseases
            'bệnh', 'disease', 'viêm', 'ung thư', 'cancer',
            'tiểu đường', 'diabetes', 'huyết áp', 'blood pressure',
            # Detailed questions
            'chi tiết', 'cụ thể', 'specific', 'detail',
            'khoa học', 'scientific', 'evidence', 'hướng dẫn',
            'guideline', 'recommendation', 'chuyên gia', 'expert'
        ]
        if any(p in query_lower for p in complex_patterns):
            return True
        
        # 5. Default: Simple first-turn questions don't need RAG
        # Agent can ask clarifying questions first
        if not chat_history or len(chat_history) == 0:
            # Simple initial statements
            simple_starts = [
                'tôi muốn', 'tôi cần', 'giúp tôi', 'tôi bị',
                'i want', 'i need', 'help me', 'i have', 'i feel'
            ]
            if any(s in query_lower for s in simple_starts):
                # Let agent gather info first, use RAG later
                return False
        
        # 6. Default: Use RAG for safety (medical context)
        return True
    
    def update_user_profile(self, key: str, value: Any) -> None:
        """Update user profile in shared memory"""
        self.memory.update_profile(key, value)
    
    def get_missing_profile_fields(self, required_fields: List[str]) -> List[str]:
        """Check what profile fields are missing"""
        return self.memory.get_missing_fields(required_fields)
    
    def save_agent_data(self, key: str, value: Any) -> None:
        """Save agent-specific data to memory"""
        self.memory.add_agent_data(self.agent_name, key, value)
    
    def get_agent_data(self, key: str = None) -> Any:
        """Get agent-specific data from memory"""
        return self.memory.get_agent_data(self.agent_name, key)
    
    def get_other_agent_data(self, agent_name: str, key: str = None) -> Any:
        """Get data from another agent"""
        return self.memory.get_agent_data(agent_name, key)
    
    # ===== Context Awareness =====
    
    def get_context_summary(self) -> str:
        """Get summary of current conversation context"""
        return self.memory.get_context_summary()
    
    def get_previous_agent(self) -> Optional[str]:
        """Get name of previous agent"""
        return self.memory.get_previous_agent()
    
    def get_current_topic(self) -> Optional[str]:
        """Get current conversation topic"""
        return self.memory.get_current_topic()
    
    def set_current_topic(self, topic: str) -> None:
        """Set current conversation topic"""
        self.memory.set_current_topic(topic)
    
    def generate_natural_opening(self, user_query: str, chat_history: Optional[List] = None) -> str:
        """
        Generate natural conversation opening based on context
        Avoids robotic prefixes like "Thông tin đã tư vấn:"
        
        Args:
            user_query: Current user query
            chat_history: Conversation history
            
        Returns:
            str: Natural opening phrase (empty if not needed)
        """
        # Check if this is a topic transition
        previous_agent = self.get_previous_agent()
        is_new_topic = previous_agent and previous_agent != self.agent_name
        
        # If continuing same topic, no special opening needed
        if not is_new_topic:
            return ""
        
        # Generate natural transition based on agent type
        query_lower = user_query.lower()
        
        # Enthusiastic transitions for new requests
        if any(word in query_lower for word in ['muốn', 'cần', 'giúp', 'tư vấn']):
            openings = [
                "Ah, bây giờ bạn đang cần",
                "Được rồi, để mình",
                "Tuyệt! Mình sẽ",
                "Ok, cùng",
            ]
            import random
            return random.choice(openings) + " "
        
        # Default: no prefix, just natural response
        return ""
    
    # ===== Handoff Logic =====
    
    def should_handoff(self, user_query: str, chat_history: Optional[List] = None) -> bool:
        """
        Determine if this agent should hand off to another agent
        
        Args:
            user_query: User's current query
            chat_history: Conversation history
        
        Returns:
            bool: True if handoff is needed
        """
        if not self.can_handoff:
            return False
        
        # Check for handoff trigger keywords
        query_lower = user_query.lower()
        for trigger in self.handoff_triggers:
            if trigger in query_lower:
                return True
        
        return False
    
    def suggest_next_agent(self, user_query: str) -> Optional[str]:
        """
        Suggest which agent to hand off to
        
        Args:
            user_query: User's current query
        
        Returns:
            str: Name of suggested agent, or None
        """
        query_lower = user_query.lower()
        
        # Symptom keywords
        symptom_keywords = ['đau', 'sốt', 'ho', 'buồn nôn', 'chóng mặt', 'mệt']
        if any(kw in query_lower for kw in symptom_keywords):
            return 'symptom_agent'
        
        # Nutrition keywords
        nutrition_keywords = ['ăn', 'thực đơn', 'calo', 'giảm cân', 'tăng cân']
        if any(kw in query_lower for kw in nutrition_keywords):
            return 'nutrition_agent'
        
        # Exercise keywords
        exercise_keywords = ['tập', 'gym', 'cardio', 'yoga', 'chạy bộ']
        if any(kw in query_lower for kw in exercise_keywords):
            return 'exercise_agent'
        
        # Mental health keywords
        mental_keywords = ['stress', 'lo âu', 'trầm cảm', 'mất ngủ', 'burnout']
        if any(kw in query_lower for kw in mental_keywords):
            return 'mental_health_agent'
        
        return None
    
    def create_handoff_message(self, next_agent: str, context: str = "", user_query: str = "") -> str:
        """
        Create a SEAMLESS topic transition (not explicit handoff)
        
        Args:
            next_agent: Name of agent to hand off to
            context: Additional context for handoff
            user_query: User's query to understand intent
        
        Returns:
            str: Natural transition message (NOT "chuyển sang chuyên gia")
        """
        # Map agents to topic areas
        topic_map = {
            'symptom_agent': {
                'topic': 'triệu chứng',
                'action': 'đánh giá',
                'info_needed': ['triệu chứng cụ thể', 'thời gian xuất hiện']
            },
            'nutrition_agent': {
                'topic': 'dinh dưỡng',
                'action': 'tư vấn chế độ ăn',
                'info_needed': ['mục tiêu', 'cân nặng', 'chiều cao', 'tuổi']
            },
            'exercise_agent': {
                'topic': 'tập luyện',
                'action': 'lên lịch tập',
                'info_needed': ['mục tiêu', 'thời gian có thể tập', 'thiết bị']
            },
            'mental_health_agent': {
                'topic': 'sức khỏe tinh thần',
                'action': 'hỗ trợ',
                'info_needed': ['cảm giác hiện tại', 'thời gian kéo dài']
            }
        }
        
        topic_info = topic_map.get(next_agent, {
            'topic': 'vấn đề này',
            'action': 'tư vấn',
            'info_needed': []
        })
        
        # SEAMLESS transition - acknowledge topic change naturally
        message = f"{context}\n\n" if context else ""
        
        # Natural acknowledgment based on query
        if 'tập' in user_query.lower() or 'gym' in user_query.lower():
            message += f"Ah, bây giờ bạn đang cần về {topic_info['topic']}! "
        elif 'ăn' in user_query.lower() or 'thực đơn' in user_query.lower():
            message += f"Okii, giờ chuyển sang {topic_info['topic']} nhé! "
        else:
            message += f"Được, mình giúp bạn về {topic_info['topic']}! "
        
        # Ask for info if needed (natural, not formal)
        if topic_info['info_needed']:
            info_list = ', '.join(topic_info['info_needed'][:2])  # Max 2 items
            message += f"Để {topic_info['action']} phù hợp, cho mình biết thêm về {info_list} nhé!"
        
        return message
    
    # ===== Multi-Agent Coordination =====
    
    def needs_collaboration(self, user_query: str) -> List[str]:
        """
        Determine if multiple agents are needed
        
        Args:
            user_query: User's query
        
        Returns:
            List[str]: List of agent names needed
        """
        agents_needed = []
        query_lower = user_query.lower()
        
        # Check for each agent's keywords
        if any(kw in query_lower for kw in ['đau', 'sốt', 'ho', 'triệu chứng']):
            agents_needed.append('symptom_agent')
        
        if any(kw in query_lower for kw in ['ăn', 'thực đơn', 'calo', 'dinh dưỡng']):
            agents_needed.append('nutrition_agent')
        
        if any(kw in query_lower for kw in ['tập', 'gym', 'cardio', 'exercise']):
            agents_needed.append('exercise_agent')
        
        if any(kw in query_lower for kw in ['stress', 'lo âu', 'trầm cảm', 'mental']):
            agents_needed.append('mental_health_agent')
        
        return agents_needed
    
    # ===== Utility Methods =====
    
    def extract_user_data_from_history(self, chat_history: List) -> Dict[str, Any]:
        """
        Extract user data from conversation history
        (Can be overridden by subclasses for specific extraction)
        
        Args:
            chat_history: List of [user_msg, bot_msg] pairs
        
        Returns:
            Dict: Extracted user data
        """
        import re
        
        if not chat_history:
            return {}
        
        all_messages = " ".join([msg[0] for msg in chat_history if msg[0]])
        extracted = {}
        
        # Extract age
        age_match = re.search(r'(\d+)\s*tuổi|tuổi\s*(\d+)|tôi\s*(\d+)', all_messages.lower())
        if age_match:
            extracted['age'] = int([g for g in age_match.groups() if g][0])
        
        # Extract gender
        if re.search(r'\bnam\b|male|đàn ông', all_messages.lower()):
            extracted['gender'] = 'male'
        elif re.search(r'\bnữ\b|female|đàn bà|phụ nữ', all_messages.lower()):
            extracted['gender'] = 'female'
        
        # Extract weight
        weight_match = re.search(r'(\d+)\s*kg|nặng\s*(\d+)|cân\s*(\d+)', all_messages.lower())
        if weight_match:
            extracted['weight'] = float([g for g in weight_match.groups() if g][0])
        
        # Extract height
        height_match = re.search(r'(\d+)\s*cm|cao\s*(\d+)|chiều cao\s*(\d+)', all_messages.lower())
        if height_match:
            extracted['height'] = float([g for g in height_match.groups() if g][0])
        
        return extracted
    
    def update_memory_from_history(self, chat_history: List) -> None:
        """Extract and update memory from chat history"""
        extracted = self.extract_user_data_from_history(chat_history)
        
        for key, value in extracted.items():
            # Always update with latest info (user may correct themselves)
            self.memory.update_profile(key, value)
    
    def extract_and_save_user_info(self, user_message: str) -> Dict[str, Any]:
        """
        Extract user info from a single message using LLM (flexible, handles typos)
        Saves to memory immediately
        
        Args:
            user_message: Single user message (any format, any order)
            
        Returns:
            Dict: Extracted data
        """
        from config.settings import client, MODEL
        import json
        
        # Use LLM to extract structured data (handles typos, any order, extra info)
        extraction_prompt = f"""Extract health information from this user message. Handle typos and variations.

User message: "{user_message}"

Extract these fields if present (return null if not found):
- age: integer (tuổi, age, years old)
- gender: "male" or "female" (nam, nữ, male, female, đàn ông, phụ nữ)
- weight: float in kg (nặng, cân, weight, kg)
- height: float in cm (cao, chiều cao, height, cm, m)
  IMPORTANT: Height MUST be in cm (50-300 range)
  - If user says "1.75m" or "1.78m" → convert to cm (175, 178)
  - If user says "175cm" or "178cm" → use as is (175, 178)
  - NEVER return values like 1.0, 1.5, 1.75 for height!
- body_fat_percentage: float (tỉ lệ mỡ, body fat, %, optional)

Return ONLY valid JSON with these exact keys. Example:
{{"age": 30, "gender": "male", "weight": 70.5, "height": 175, "body_fat_percentage": 25}}

CRITICAL: Height must be 50-300 (in cm). If user says "1.78m", return 178, not 1.78!
If a field is not found, use null. Be flexible with typos and word order."""

        try:
            response = client.chat.completions.create(
                model=MODEL,
                messages=[
                    {"role": "system", "content": "You are a data extraction assistant. Extract structured health data from user messages. Handle typos and variations. Return only valid JSON."},
                    {"role": "user", "content": extraction_prompt}
                ],
                temperature=0.1,  # Low temp for consistent extraction
                max_tokens=150
            )
            
            result_text = response.choices[0].message.content.strip()
            
            # Parse JSON response
            # Remove markdown code blocks if present
            if "```json" in result_text:
                result_text = result_text.split("```json")[1].split("```")[0].strip()
            elif "```" in result_text:
                result_text = result_text.split("```")[1].split("```")[0].strip()
            
            extracted = json.loads(result_text)
            
            # Auto-correct obvious errors before saving
            extracted = self._auto_correct_health_data(extracted)
            
            # Save to memory (only non-null values)
            allowed_fields = ['age', 'gender', 'weight', 'height', 'body_fat_percentage']
            for key, value in extracted.items():
                if value is not None and key in allowed_fields:
                    self.update_user_profile(key, value)
            
            return {k: v for k, v in extracted.items() if v is not None}
            
        except Exception as e:
            # Fallback to regex if LLM fails
            print(f"LLM extraction failed: {e}, using regex fallback")
            return self._extract_with_regex_fallback(user_message)
    
    def _auto_correct_health_data(self, data: Dict[str, Any]) -> Dict[str, Any]:
        """
        Auto-correct obvious errors in health data (typos, wrong units)
        
        Examples:
        - height: 200 → 200cm ✅ (likely meant 200cm, not 200m)
        - height: 1.75 → 175cm ✅ (convert m to cm)
        - weight: 75 → 75kg ✅ (assume kg if reasonable)
        - weight: 75000 → 75kg ✅ (likely meant 75kg, not 75000g)
        """
        corrected = data.copy()
        
        # Auto-correct height
        if 'height' in corrected and corrected['height'] is not None:
            height = float(corrected['height'])
            
            # If height is very small (< 10), likely in meters → convert to cm
            if 0 < height < 10:
                corrected['height'] = height * 100
                print(f"Auto-corrected height: {height}m → {corrected['height']}cm")
            
            # If height is reasonable (50-300), assume cm
            elif 50 <= height <= 300:
                corrected['height'] = height
            
            # If height is very large (> 1000), likely in mm → convert to cm
            elif height > 1000:
                corrected['height'] = height / 10
                print(f"Auto-corrected height: {height}mm → {corrected['height']}cm")
            
            # Otherwise invalid, set to None
            else:
                print(f"Invalid height: {height}, setting to None")
                corrected['height'] = None
        
        # Auto-correct weight
        if 'weight' in corrected and corrected['weight'] is not None:
            weight = float(corrected['weight'])
            
            # If weight is very large (> 500), likely in grams → convert to kg
            if weight > 500:
                corrected['weight'] = weight / 1000
                print(f"Auto-corrected weight: {weight}g → {corrected['weight']}kg")
            
            # If weight is reasonable (20-300), assume kg
            elif 20 <= weight <= 300:
                corrected['weight'] = weight
            
            # If weight is very small (< 20), might be wrong unit
            elif 0 < weight < 20:
                # Could be in different unit or child weight
                # Keep as is but flag
                corrected['weight'] = weight
            
            # Otherwise invalid
            else:
                print(f"Invalid weight: {weight}, setting to None")
                corrected['weight'] = None
        
        # Auto-correct age
        if 'age' in corrected and corrected['age'] is not None:
            age = int(corrected['age'])
            
            # Reasonable age range: 1-120
            if not (1 <= age <= 120):
                print(f"Invalid age: {age}, setting to None")
                corrected['age'] = None
        
        # Auto-correct body fat percentage
        if 'body_fat_percentage' in corrected and corrected['body_fat_percentage'] is not None:
            bf = float(corrected['body_fat_percentage'])
            
            # Reasonable body fat: 3-60%
            if not (3 <= bf <= 60):
                print(f"Invalid body fat: {bf}%, setting to None")
                corrected['body_fat_percentage'] = None
        
        return corrected
    
    def _extract_with_regex_fallback(self, user_message: str) -> Dict[str, Any]:
        """Fallback regex extraction (less flexible but reliable)"""
        import re
        extracted = {}
        msg_lower = user_message.lower()
        
        # Extract age
        age_match = re.search(r'(\d+)\s*tuổi|tuổi\s*(\d+)|age\s*(\d+)', msg_lower)
        if age_match:
            age = int([g for g in age_match.groups() if g][0])
            extracted['age'] = age
            self.update_user_profile('age', age)
        
        # Extract gender
        if re.search(r'\bnam\b|male|đàn ông', msg_lower):
            extracted['gender'] = 'male'
            self.update_user_profile('gender', 'male')
        elif re.search(r'\bnữ\b|female|đàn bà|phụ nữ', msg_lower):
            extracted['gender'] = 'female'
            self.update_user_profile('gender', 'female')
        
        # Extract weight
        weight_match = re.search(r'(?:nặng|cân|weight)?\s*(\d+(?:\.\d+)?)\s*kg', msg_lower)
        if weight_match:
            weight = float(weight_match.group(1))
            extracted['weight'] = weight
            self.update_user_profile('weight', weight)
        
        # Extract height
        height_cm_match = re.search(r'(?:cao|chiều cao|height)?\s*(\d+(?:\.\d+)?)\s*cm', msg_lower)
        if height_cm_match:
            height = float(height_cm_match.group(1))
            extracted['height'] = height
            self.update_user_profile('height', height)
        else:
            height_m_match = re.search(r'(?:cao|chiều cao|height)?\s*(\d+\.?\d*)\s*m\b', msg_lower)
            if height_m_match:
                height = float(height_m_match.group(1))
                if height < 3:
                    height = height * 100
                extracted['height'] = height
                self.update_user_profile('height', height)
        
        return extracted
    
    def __repr__(self) -> str:
        return f"<{self.__class__.__name__}: {self.get_context_summary()}>"