Spaces:
Runtime error
Runtime error
Nguyen Trong Lap
commited on
Commit
·
eeb0f9c
0
Parent(s):
Recreate history without binary blobs
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .DS_Store +0 -0
- .gitattributes +36 -0
- README.md +212 -0
- agents/AGENT_ARCHITECTURE.md +1235 -0
- agents/README.md +312 -0
- agents/__init__.py +40 -0
- agents/core/__init__.py +14 -0
- agents/core/base_agent.py +602 -0
- agents/core/context_analyzer.py +260 -0
- agents/core/coordinator.py +579 -0
- agents/core/orchestrator.py +212 -0
- agents/core/response_validator.py +182 -0
- agents/core/router.py +657 -0
- agents/core/unified_tone.py +155 -0
- agents/specialized/__init__.py +35 -0
- agents/specialized/exercise_agent.py +413 -0
- agents/specialized/general_health_agent.py +194 -0
- agents/specialized/mental_health_agent.py +368 -0
- agents/specialized/nutrition_agent.py +598 -0
- agents/specialized/symptom_agent.py +854 -0
- app.py +31 -0
- assets/bot-avatar.png +3 -0
- auth/auth.py +103 -0
- auth/db.py +50 -0
- chroma_db/e49f080a-e10b-4088-ba71-405ae42658a8/data_level0.bin +3 -0
- chroma_db/e49f080a-e10b-4088-ba71-405ae42658a8/header.bin +0 -0
- chroma_db/e49f080a-e10b-4088-ba71-405ae42658a8/index_metadata.pickle +3 -0
- chroma_db/e49f080a-e10b-4088-ba71-405ae42658a8/length.bin +0 -0
- chroma_db/e49f080a-e10b-4088-ba71-405ae42658a8/link_lists.bin +0 -0
- config/settings.py +22 -0
- data_mining/__init__.py +2 -0
- data_mining/mining_fitness.py +134 -0
- data_mining/mining_medical_qa.py +291 -0
- data_mining/mining_mentalchat.py +178 -0
- data_mining/mining_nutrition.py +144 -0
- data_mining/mining_vietnamese_nutrition.py +120 -0
- data_mining/mining_vimedical.py +180 -0
- data_mining/vn_food_db.py +162 -0
- examples/feedback_loop_example.py +267 -0
- examples/multilingual_example.py +239 -0
- examples/pydantic_validation_example.py +231 -0
- examples/session_persistence_example.py +103 -0
- examples/summarization_example.py +128 -0
- feedback/__init__.py +25 -0
- feedback/feedback_analyzer.py +333 -0
- feedback/feedback_system.py +425 -0
- fine_tuning/README.md +297 -0
- fine_tuning/__init__.py +14 -0
- fine_tuning/data_collector.py +217 -0
- fine_tuning/trainer.py +244 -0
.DS_Store
ADDED
|
Binary file (10.2 kB). View file
|
|
|
.gitattributes
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
*.png filter=lfs diff=lfs merge=lfs -text
|
README.md
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Healthcare AI Chatbot
|
| 2 |
+
|
| 3 |
+
Trợ lý sức khỏe cá nhân thông minh sử dụng OpenAI API và Gradio.
|
| 4 |
+
|
| 5 |
+
## ✨ Key Features
|
| 6 |
+
|
| 7 |
+
- 🤖 **Multi-Agent Architecture** - Specialized agents for nutrition, exercise, symptoms, mental health
|
| 8 |
+
- 🧠 **Conversation Memory** - Remembers user data, no repeated questions
|
| 9 |
+
- 🔄 **Agent Handoffs** - Smooth transitions between specialists
|
| 10 |
+
- 💬 **Agent Communication** - Agents share context and collaborate
|
| 11 |
+
- 📚 **RAG Integration** - Medical knowledge from WHO, CDC, NIMH
|
| 12 |
+
- 🎯 **Context-Aware Routing** - Intelligent query understanding
|
| 13 |
+
|
| 14 |
+
See [agents/AGENT_ARCHITECTURE.md](agents/AGENT_ARCHITECTURE.md) for detailed architecture documentation.
|
| 15 |
+
|
| 16 |
+
## Setup
|
| 17 |
+
|
| 18 |
+
### 1. Create Virtual Environment
|
| 19 |
+
|
| 20 |
+
First, create a virtual environment to isolate project dependencies:
|
| 21 |
+
|
| 22 |
+
**macOS/Linux:**
|
| 23 |
+
```bash
|
| 24 |
+
python3 -m venv venv
|
| 25 |
+
```
|
| 26 |
+
|
| 27 |
+
**Windows:**
|
| 28 |
+
```bash
|
| 29 |
+
python -m venv venv
|
| 30 |
+
```
|
| 31 |
+
|
| 32 |
+
### 2. Activate Virtual Environment
|
| 33 |
+
|
| 34 |
+
**macOS/Linux:**
|
| 35 |
+
```bash
|
| 36 |
+
source venv/bin/activate
|
| 37 |
+
```
|
| 38 |
+
|
| 39 |
+
**Windows:**
|
| 40 |
+
```bash
|
| 41 |
+
venv\Scripts\activate
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
### 3. Install Dependencies
|
| 45 |
+
|
| 46 |
+
```bash
|
| 47 |
+
pip install -r requirements.txt
|
| 48 |
+
```
|
| 49 |
+
|
| 50 |
+
### 4. Configure Environment Variables
|
| 51 |
+
|
| 52 |
+
Create a `.env` file in the project root:
|
| 53 |
+
|
| 54 |
+
```bash
|
| 55 |
+
OPENAI_API_KEY=your_api_key_here
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
### 5. Setup RAG System & Fine-tuning (One-time)
|
| 59 |
+
|
| 60 |
+
**IMPORTANT:** Before running the app, setup the complete system with one command:
|
| 61 |
+
|
| 62 |
+
```bash
|
| 63 |
+
# One command to setup everything (15-20 minutes)
|
| 64 |
+
bash scripts/setup_rag.sh
|
| 65 |
+
```
|
| 66 |
+
|
| 67 |
+
**What this does:**
|
| 68 |
+
|
| 69 |
+
**Phase 1: RAG Databases (10-15 minutes)**
|
| 70 |
+
- ✅ Downloads and processes medical datasets from HuggingFace
|
| 71 |
+
- ✅ Builds ChromaDB vector databases for each domain
|
| 72 |
+
- ✅ Total: ~160 MB, 6 specialized databases
|
| 73 |
+
|
| 74 |
+
**Datasets by Domain:**
|
| 75 |
+
- **Symptoms/Diseases**: ViMedical_Disease (603 diseases, ~50 MB)
|
| 76 |
+
- **Mental Health**: MentalChat16K (16K conversations, 33 topics, ~80 MB)
|
| 77 |
+
- **Nutrition**: LLM_Dietary_Recommendation (50 patient profiles + diet plans, ~20 MB)
|
| 78 |
+
- **Vietnamese Food**: Vietnamese_Nutrition (73 foods with nutrition facts, ~5 MB)
|
| 79 |
+
- **Fitness**: GYM-Exercise (1.66K exercises, ~10 MB)
|
| 80 |
+
- **Medical Q&A**: Vietnamese_Medical_QA (9.3K Q&A pairs, ~15 MB)
|
| 81 |
+
|
| 82 |
+
**Phase 2: Training Data Generation (2-3 minutes)**
|
| 83 |
+
- ✅ Generates 200 synthetic conversations using GPT-4o-mini
|
| 84 |
+
- ✅ 50 scenarios per agent (nutrition, symptom, exercise, mental_health)
|
| 85 |
+
- ✅ Cost: ~$0.50 from your API budget
|
| 86 |
+
- ✅ Saved to `fine_tuning/training_data/` (NOT committed to git)
|
| 87 |
+
|
| 88 |
+
**Phase 3: Fine-tuning (Optional, 30-60 minutes)**
|
| 89 |
+
- ❓ Prompts: "Do you want to fine-tune now? (y/N)" (10 sec timeout)
|
| 90 |
+
- ✅ If yes: Uploads data, creates fine-tuning jobs, waits for completion
|
| 91 |
+
- ✅ If no: Skip, you can fine-tune later with `python scripts/auto_finetune.py`
|
| 92 |
+
- ✅ Cost: ~$2.00 from your API budget
|
| 93 |
+
- ✅ Creates `config/fine_tuned_models.py` (NOT committed to git)
|
| 94 |
+
|
| 95 |
+
**Total Cost:** ~$2.50 from your API budget (if you choose to fine-tune)
|
| 96 |
+
|
| 97 |
+
**Fine-tune Later (Optional):**
|
| 98 |
+
```bash
|
| 99 |
+
# If you skipped fine-tuning during setup
|
| 100 |
+
python scripts/auto_finetune.py
|
| 101 |
+
```
|
| 102 |
+
|
| 103 |
+
**Manual Setup (Alternative):**
|
| 104 |
+
```bash
|
| 105 |
+
# RAG only (no training data generation)
|
| 106 |
+
python data_mining/mining_vimedical.py
|
| 107 |
+
python data_mining/mining_mentalchat.py
|
| 108 |
+
# ... other mining scripts
|
| 109 |
+
|
| 110 |
+
# Training data only
|
| 111 |
+
python scripts/generate_training_data.py
|
| 112 |
+
|
| 113 |
+
# Fine-tuning only
|
| 114 |
+
python scripts/auto_finetune.py
|
| 115 |
+
```
|
| 116 |
+
|
| 117 |
+
**Team Sharing:**
|
| 118 |
+
- ✅ Each team member runs `bash scripts/setup_rag.sh` once
|
| 119 |
+
- ✅ Everyone generates their own data with their API key
|
| 120 |
+
- ❌ RAG databases and training data are NOT committed to git (too large)
|
| 121 |
+
- ✅ Scripts and code are committed for easy sharing
|
| 122 |
+
|
| 123 |
+
## Run the Application
|
| 124 |
+
|
| 125 |
+
You have multiple options to run the application:
|
| 126 |
+
|
| 127 |
+
**Option 1: Using the shell script (recommended):**
|
| 128 |
+
```bash
|
| 129 |
+
bash run.sh
|
| 130 |
+
```
|
| 131 |
+
|
| 132 |
+
**Option 2: Using Gradio CLI:**
|
| 133 |
+
```bash
|
| 134 |
+
gradio app.py
|
| 135 |
+
```
|
| 136 |
+
|
| 137 |
+
**Option 3: Using Python directly:**
|
| 138 |
+
```bash
|
| 139 |
+
python app.py
|
| 140 |
+
```
|
| 141 |
+
|
| 142 |
+
**Notes:**
|
| 143 |
+
- The app will launch with a local URL and a public shareable link
|
| 144 |
+
- Ensure your virtual environment is activated before running
|
| 145 |
+
- Make sure `OPENAI_API_KEY` is set in your `.env` file
|
| 146 |
+
|
| 147 |
+
### 5. Deactivate Virtual Environment (when done)
|
| 148 |
+
|
| 149 |
+
```bash
|
| 150 |
+
deactivate
|
| 151 |
+
```
|
| 152 |
+
|
| 153 |
+
## Project Structure
|
| 154 |
+
|
| 155 |
+
```
|
| 156 |
+
healthcare_bot/
|
| 157 |
+
├── app.py # File chính (Gradio UI)
|
| 158 |
+
├── rag/
|
| 159 |
+
│ ├── ingest.py # Ingest tài liệu vào ChromaDB
|
| 160 |
+
│ ├── query_engine.py # LangChain Retrieval QA
|
| 161 |
+
│ └── data/ # Nguồn PDF/CSV/MD
|
| 162 |
+
├── modules/
|
| 163 |
+
│ ├── nutrition.py # Module dinh dưỡng
|
| 164 |
+
│ ├── exercise.py # Module bài tập
|
| 165 |
+
│ └── rules.json # Quy tắc cơ bản
|
| 166 |
+
├── utils/
|
| 167 |
+
│ └── helpers.py # Hàm hỗ trợ, tính BMI, format output
|
| 168 |
+
├── config/
|
| 169 |
+
│ └── settings.py # Config env + API + model
|
| 170 |
+
└── requirements.txt
|
| 171 |
+
```
|
| 172 |
+
|
| 173 |
+
## Technologies
|
| 174 |
+
|
| 175 |
+
- Python 3.9+
|
| 176 |
+
- OpenAI API (GPT-4o-mini)
|
| 177 |
+
- Gradio 5.49.0
|
| 178 |
+
- python-dotenv 1.1.1
|
| 179 |
+
- Virtual Environment (venv)
|
| 180 |
+
|
| 181 |
+
## Features
|
| 182 |
+
|
| 183 |
+
- 💬 **Chat interface với AI** - Giao diện trò chuyện thân thiện
|
| 184 |
+
- 🏥 **Tư vấn sức khỏe toàn diện** - Hỏi kỹ thông tin trước khi tư vấn
|
| 185 |
+
- 🔍 **Thu thập thông tin chi tiết** - Hỏi về triệu chứng, bệnh nền, thuốc đang dùng
|
| 186 |
+
- 📊 **Đánh giá tổng quan** - Phân tích dựa trên nhiều yếu tố sức khỏe
|
| 187 |
+
- 🌐 **Public shareable link** - Chia sẻ dễ dàng
|
| 188 |
+
- 📱 **Responsive UI** - Giao diện đẹp, hiện đại
|
| 189 |
+
- 💾 **Lưu lịch sử hội thoại** - Nhớ ngữ cảnh cuộc trò chuyện
|
| 190 |
+
|
| 191 |
+
## Cách chatbot hoạt động
|
| 192 |
+
|
| 193 |
+
Khi bạn chia sẻ triệu chứng hoặc thông tin sức khỏe, chatbot sẽ:
|
| 194 |
+
|
| 195 |
+
1. **Hỏi thông tin cá nhân**: Tuổi, giới tính, cân nặng, chiều cao (nếu cần)
|
| 196 |
+
2. **Hỏi về triệu chứng**: Thời gian, mức độ nghiêm trọng, triệu chứng kèm theo
|
| 197 |
+
3. **Hỏi về bệnh nền**: Tiểu đường, huyết áp cao, tim mạch, v.v.
|
| 198 |
+
4. **Hỏi về thuốc**: Thuốc đang dùng, liệu pháp điều trị
|
| 199 |
+
5. **Hỏi về lối sống**: Chế độ ăn, tập luyện, giấc ngủ, stress
|
| 200 |
+
6. **Đưa ra tư vấn**: Sau khi có đủ thông tin, đưa ra lời khuyên toàn diện và chính xác
|
| 201 |
+
|
| 202 |
+
**Ví dụ:**
|
| 203 |
+
```
|
| 204 |
+
User: "Tôi bị đau đầu"
|
| 205 |
+
Bot: "Tôi hiểu bạn đang bị đau đầu. Để tư vấn chính xác hơn, cho tôi hỏi thêm:
|
| 206 |
+
- Bạn bao nhiêu tuổi?
|
| 207 |
+
- Đau đầu kéo dài bao lâu rồi?
|
| 208 |
+
- Mức độ đau (nhẹ/vừa/nặng)?
|
| 209 |
+
- Có triệu chứng kèm theo không (buồn nôn, chóng mặt)?
|
| 210 |
+
- Bạn có bệnh nền gì không?
|
| 211 |
+
- Đang dùng thuốc gì không?"
|
| 212 |
+
```
|
agents/AGENT_ARCHITECTURE.md
ADDED
|
@@ -0,0 +1,1235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Agent-Based Architecture Documentation 🏗️
|
| 2 |
+
|
| 3 |
+
## Overview
|
| 4 |
+
|
| 5 |
+
This system uses an **agent-based architecture** with **OpenAI function calling** for intelligent healthcare assistance.
|
| 6 |
+
|
| 7 |
+
### Why Agent-Based Architecture?
|
| 8 |
+
|
| 9 |
+
**Advantages over Monolithic:**
|
| 10 |
+
1. **Token Efficiency** - Each agent loads only necessary prompts (60-70% reduction)
|
| 11 |
+
2. **Scalability** - Easy to add new specialized agents
|
| 12 |
+
3. **Accuracy** - Domain-specific expertise per agent
|
| 13 |
+
4. **Maintainability** - Clear separation of concerns
|
| 14 |
+
5. **Context Awareness** - Intelligent routing with conversation history
|
| 15 |
+
|
| 16 |
+
### Core Capabilities
|
| 17 |
+
|
| 18 |
+
- **Specialized Agents** - Nutrition, Exercise, Symptoms, Mental Health, General Health
|
| 19 |
+
- **Conversation Memory** - Persistent user data across conversation
|
| 20 |
+
- **Agent Handoffs** - Smooth transitions between specialists
|
| 21 |
+
- **Agent Communication** - Cross-agent data sharing and collaboration
|
| 22 |
+
- **Multi-Agent Responses** - Coordinate multiple agents for complex queries
|
| 23 |
+
- **Context-Aware Routing** - Understand conversation flow and intent
|
| 24 |
+
|
| 25 |
+
---
|
| 26 |
+
|
| 27 |
+
## 📊 System Architecture
|
| 28 |
+
|
| 29 |
+
```
|
| 30 |
+
User Input
|
| 31 |
+
↓
|
| 32 |
+
Agent Coordinator
|
| 33 |
+
↓
|
| 34 |
+
┌─────────────────────────────────────────────┐
|
| 35 |
+
│ Shared Conversation Memory │
|
| 36 |
+
│ ┌────────────────────────────────────┐ │
|
| 37 |
+
│ │ • User Profile (age, gender, etc.) │ │
|
| 38 |
+
│ │ • Agent-specific Data │ │
|
| 39 |
+
│ │ • Conversation State │ │
|
| 40 |
+
│ │ • Pending Questions │ │
|
| 41 |
+
│ └────────────────────────────────────┘ │
|
| 42 |
+
└─────────────────────────────────────────────┘
|
| 43 |
+
↓
|
| 44 |
+
Router (Function Calling) + Context Analysis
|
| 45 |
+
↓
|
| 46 |
+
┌─────────────────────────────────────┐
|
| 47 |
+
│ Chọn Agent(s) Phù Hợp │
|
| 48 |
+
├─────────────────────────────────────┤
|
| 49 |
+
│ • Nutrition Agent │
|
| 50 |
+
│ • Exercise Agent │
|
| 51 |
+
│ • Symptom Agent │
|
| 52 |
+
│ • Mental Health Agent │
|
| 53 |
+
│ • General Health Agent (default) │
|
| 54 |
+
└─────────────────────────────────────┘
|
| 55 |
+
↓
|
| 56 |
+
┌─ Single Agent Response
|
| 57 |
+
├─ Agent Handoff (smooth transition)
|
| 58 |
+
└─ Multi-Agent Combined Response
|
| 59 |
+
↓
|
| 60 |
+
Response (with full context awareness)
|
| 61 |
+
```
|
| 62 |
+
|
| 63 |
+
---
|
| 64 |
+
|
| 65 |
+
## 🤖 Các Agent
|
| 66 |
+
|
| 67 |
+
### 1. **Router** (`agents/core/router.py`)
|
| 68 |
+
|
| 69 |
+
**Chức năng:** Phân tích user input và route đến agent phù hợp
|
| 70 |
+
|
| 71 |
+
**Công nghệ:** OpenAI Function Calling
|
| 72 |
+
|
| 73 |
+
**Available Functions:**
|
| 74 |
+
```python
|
| 75 |
+
- nutrition_agent: Dinh dưỡng, BMI, calo, thực đơn
|
| 76 |
+
- exercise_agent: Tập luyện, gym, yoga, cardio
|
| 77 |
+
- symptom_agent: Triệu chứng bệnh, đau đầu, sốt
|
| 78 |
+
- mental_health_agent: Stress, lo âu, trầm cảm
|
| 79 |
+
- general_health_agent: Câu hỏi chung về sức khỏe
|
| 80 |
+
```
|
| 81 |
+
|
| 82 |
+
**🆕 Context-Aware Features:**
|
| 83 |
+
|
| 84 |
+
1. **Extended Context Window:**
|
| 85 |
+
- OLD: 3 exchanges
|
| 86 |
+
- NEW: **10 exchanges** (+233%)
|
| 87 |
+
- Hiểu conversation flow tốt hơn
|
| 88 |
+
|
| 89 |
+
2. **Last Agent Tracking:**
|
| 90 |
+
- Track agent nào vừa được dùng
|
| 91 |
+
- Giúp xử lý follow-up questions
|
| 92 |
+
- Example: "Vậy nên ăn gì?" → biết đang nói về giảm cân
|
| 93 |
+
|
| 94 |
+
3. **Enhanced Routing Prompt:**
|
| 95 |
+
- Hướng dẫn rõ ràng về câu hỏi mơ hồ
|
| 96 |
+
- Ví dụ cụ thể về follow-up questions
|
| 97 |
+
- Detect topic switching
|
| 98 |
+
|
| 99 |
+
4. **Improved System Prompt:**
|
| 100 |
+
- Nhấn mạnh khả năng hiểu ngữ cảnh
|
| 101 |
+
- Xử lý ambiguous questions
|
| 102 |
+
- Recognize follow-up patterns (vậy, còn, thì sao)
|
| 103 |
+
|
| 104 |
+
**Routing Accuracy:**
|
| 105 |
+
- Clear questions: **90-95%**
|
| 106 |
+
- Follow-up questions: **80-85%** (improved from ~60%)
|
| 107 |
+
- Topic switching: **85-90%**
|
| 108 |
+
- Multi-topic: **70-75%**
|
| 109 |
+
|
| 110 |
+
**Ví dụ:**
|
| 111 |
+
```python
|
| 112 |
+
from agents import route_to_agent
|
| 113 |
+
|
| 114 |
+
# Example 1: Clear question
|
| 115 |
+
result = route_to_agent("Tôi muốn giảm cân", chat_history)
|
| 116 |
+
# Returns: {
|
| 117 |
+
# "agent": "nutrition_agent",
|
| 118 |
+
# "parameters": {"user_query": "Tôi muốn giảm cân"},
|
| 119 |
+
# "confidence": 0.9
|
| 120 |
+
# }
|
| 121 |
+
|
| 122 |
+
# Example 2: Ambiguous follow-up (NEW - context-aware)
|
| 123 |
+
chat_history = [
|
| 124 |
+
["Tôi muốn giảm cân", "Response from nutrition_agent..."]
|
| 125 |
+
]
|
| 126 |
+
result = route_to_agent("Vậy nên ăn gì?", chat_history)
|
| 127 |
+
# Returns: {
|
| 128 |
+
# "agent": "nutrition_agent", # ✅ Understands context!
|
| 129 |
+
# "parameters": {"user_query": "Vậy nên ăn gì?"},
|
| 130 |
+
# "confidence": 0.9
|
| 131 |
+
# }
|
| 132 |
+
|
| 133 |
+
# Example 3: Topic switch
|
| 134 |
+
chat_history = [
|
| 135 |
+
["Tôi muốn giảm cân", "Response..."],
|
| 136 |
+
["Vậy nên ăn gì?", "Response..."]
|
| 137 |
+
]
|
| 138 |
+
result = route_to_agent("À mà tôi bị đau đầu", chat_history)
|
| 139 |
+
# Returns: {
|
| 140 |
+
# "agent": "symptom_agent", # ✅ Detects topic switch!
|
| 141 |
+
# "parameters": {"user_query": "À mà tôi bị đau đầu"},
|
| 142 |
+
# "confidence": 0.9
|
| 143 |
+
# }
|
| 144 |
+
```
|
| 145 |
+
|
| 146 |
+
**Context Handling Examples:**
|
| 147 |
+
|
| 148 |
+
| User Message | Context | Routed To | Why |
|
| 149 |
+
|--------------|---------|-----------|-----|
|
| 150 |
+
| "Tôi muốn giảm cân" | None | nutrition_agent | Clear question |
|
| 151 |
+
| "Vậy nên ăn gì?" | After giảm cân | nutrition_agent | Follow-up with context |
|
| 152 |
+
| "Tôi nên tập gì?" | After giảm cân | exercise_agent | Clear topic |
|
| 153 |
+
| "Còn về dinh dưỡng?" | After tập gym | nutrition_agent | Explicit topic mention |
|
| 154 |
+
| "À mà tôi bị đau đầu" | Any | symptom_agent | Clear topic switch |
|
| 155 |
+
| "Nó có ảnh hưởng gì?" | After đau đầu | symptom_agent | Pronoun resolution |
|
| 156 |
+
|
| 157 |
+
---
|
| 158 |
+
|
| 159 |
+
### 2. **Nutrition Agent** (`agents/specialized/nutrition_agent.py`)
|
| 160 |
+
|
| 161 |
+
**Chuyên môn:**
|
| 162 |
+
- Tính BMI, phân tích thể trạng
|
| 163 |
+
- Tính calo, macro (protein/carb/fat)
|
| 164 |
+
- Gợi ý thực đơn
|
| 165 |
+
- Thực phẩm bổ sung
|
| 166 |
+
|
| 167 |
+
**System Prompt:** ~500 tokens (thay vì 3000+ tokens của monolithic)
|
| 168 |
+
|
| 169 |
+
**Data Flow:**
|
| 170 |
+
```
|
| 171 |
+
User: "Tôi muốn giảm cân"
|
| 172 |
+
↓
|
| 173 |
+
Router → nutrition_agent
|
| 174 |
+
↓
|
| 175 |
+
Agent hỏi: tuổi, giới tính, cân nặng, chiều cao
|
| 176 |
+
↓
|
| 177 |
+
User cung cấp thông tin
|
| 178 |
+
↓
|
| 179 |
+
Agent tính BMI → Gọi NutritionAdvisor
|
| 180 |
+
↓
|
| 181 |
+
Response: BMI + Calo + Thực đơn + Lời khuyên
|
| 182 |
+
```
|
| 183 |
+
|
| 184 |
+
**Ví dụ Response:**
|
| 185 |
+
```
|
| 186 |
+
🥗 Tư Vấn Dinh Dưỡng Cá Nhân Hóa
|
| 187 |
+
|
| 188 |
+
📊 Phân tích BMI:
|
| 189 |
+
- BMI: 24.5 (normal)
|
| 190 |
+
- Lời khuyên: Duy trì cân nặng
|
| 191 |
+
|
| 192 |
+
🎯 Mục tiêu hàng ngày:
|
| 193 |
+
- 🔥 Calo: 1800 kcal
|
| 194 |
+
- 🥩 Protein: 112g
|
| 195 |
+
- 🍚 Carb: 202g
|
| 196 |
+
- 🥑 Chất béo: 50g
|
| 197 |
+
|
| 198 |
+
🍽️ Gợi ý thực đơn:
|
| 199 |
+
[Chi tiết món ăn...]
|
| 200 |
+
```
|
| 201 |
+
|
| 202 |
+
---
|
| 203 |
+
|
| 204 |
+
### 3. **Exercise Agent** (`agents/specialized/exercise_agent.py`)
|
| 205 |
+
|
| 206 |
+
**Chuyên môn:**
|
| 207 |
+
- Tạo lịch tập 7 ngày
|
| 208 |
+
- Tư vấn bài tập theo mục tiêu
|
| 209 |
+
- Hướng dẫn kỹ thuật an toàn
|
| 210 |
+
- Progression (tuần 1, 2, 3...)
|
| 211 |
+
|
| 212 |
+
**System Prompt:** ~400 tokens
|
| 213 |
+
|
| 214 |
+
**Data Flow:**
|
| 215 |
+
```
|
| 216 |
+
User: "Tôi muốn tập gym"
|
| 217 |
+
↓
|
| 218 |
+
Router → exercise_agent
|
| 219 |
+
↓
|
| 220 |
+
Agent hỏi: tuổi, giới tính, thể lực, mục tiêu, thời gian
|
| 221 |
+
↓
|
| 222 |
+
User cung cấp thông tin
|
| 223 |
+
↓
|
| 224 |
+
Agent gọi generate_exercise_plan()
|
| 225 |
+
↓
|
| 226 |
+
Response: Lịch tập 7 ngày chi tiết
|
| 227 |
+
```
|
| 228 |
+
|
| 229 |
+
---
|
| 230 |
+
|
| 231 |
+
### 4. **Symptom Agent** (`agents/specialized/symptom_agent.py`)
|
| 232 |
+
|
| 233 |
+
**Chuyên môn:**
|
| 234 |
+
- Đánh giá triệu chứng bằng OPQRST method
|
| 235 |
+
- Phát hiện red flags
|
| 236 |
+
- Tư vấn xử lý tại nhà
|
| 237 |
+
- Khuyên khi nào cần gặp bác sĩ
|
| 238 |
+
|
| 239 |
+
**System Prompt:** ~600 tokens
|
| 240 |
+
|
| 241 |
+
**OPQRST Method:**
|
| 242 |
+
- **O**nset: Khi nào bắt đầu?
|
| 243 |
+
- **P**rovocation/Palliation: Gì làm tệ/đỡ hơn?
|
| 244 |
+
- **Q**uality: Mô tả cảm giác?
|
| 245 |
+
- **R**egion/Radiation: Vị trí?
|
| 246 |
+
- **S**everity: Mức độ 1-10?
|
| 247 |
+
- **T**iming: Lúc nào xuất hiện?
|
| 248 |
+
|
| 249 |
+
**Red Flags Detection:**
|
| 250 |
+
```python
|
| 251 |
+
- Đau ngực + khó thở → Heart attack warning
|
| 252 |
+
- Đau đầu + cứng gáy + sốt → Meningitis warning
|
| 253 |
+
- Yếu một bên cơ thể → Stroke warning
|
| 254 |
+
```
|
| 255 |
+
|
| 256 |
+
**Data Flow:**
|
| 257 |
+
```
|
| 258 |
+
User: "Tôi bị đau đầu"
|
| 259 |
+
↓
|
| 260 |
+
Router → symptom_agent
|
| 261 |
+
↓
|
| 262 |
+
Agent check red flags → Không có
|
| 263 |
+
↓
|
| 264 |
+
Agent hỏi OPQRST (6 rounds)
|
| 265 |
+
↓
|
| 266 |
+
User trả lời từng round
|
| 267 |
+
↓
|
| 268 |
+
Agent phân tích → Đưa ra lời khuyên
|
| 269 |
+
```
|
| 270 |
+
|
| 271 |
+
---
|
| 272 |
+
|
| 273 |
+
### 5. **Mental Health Agent** (`agents/specialized/mental_health_agent.py`)
|
| 274 |
+
|
| 275 |
+
**Chuyên môn:**
|
| 276 |
+
- Hỗ trợ stress, lo âu, trầm cảm
|
| 277 |
+
- Kỹ thuật thư giãn, mindfulness
|
| 278 |
+
- Cải thiện giấc ngủ
|
| 279 |
+
- Quản lý cảm xúc
|
| 280 |
+
|
| 281 |
+
**System Prompt:** ~500 tokens
|
| 282 |
+
|
| 283 |
+
**Crisis Detection:**
|
| 284 |
+
```python
|
| 285 |
+
- Ý định tự tử → Hotline khẩn cấp:
|
| 286 |
+
• 115 - Cấp cứu y tế (Trung tâm Cấp cứu 115 TP.HCM)
|
| 287 |
+
• 1900 1267 - Chuyên gia tâm thần (Bệnh viện Tâm Thần TP.HCM)
|
| 288 |
+
• 0909 65 80 35 - Tư vấn tâm lý miễn phí (Davipharm)
|
| 289 |
+
- Tự gây thương tích → Same hotlines
|
| 290 |
+
- ONLY show hotlines for serious mental health crises
|
| 291 |
+
```
|
| 292 |
+
|
| 293 |
+
**Phong cách:**
|
| 294 |
+
- Ấm áp, đồng cảm 💙
|
| 295 |
+
- Validate cảm xúc
|
| 296 |
+
- Không phán xét
|
| 297 |
+
- Khuyến khích tìm kiếm sự hỗ trợ
|
| 298 |
+
|
| 299 |
+
---
|
| 300 |
+
|
| 301 |
+
### 6. **General Health Agent** (`agents/specialized/general_health_agent.py`)
|
| 302 |
+
|
| 303 |
+
**Chuyên môn:**
|
| 304 |
+
- Câu hỏi chung về sức khỏe
|
| 305 |
+
- Phòng bệnh
|
| 306 |
+
- Lối sống lành mạnh
|
| 307 |
+
- Default fallback agent
|
| 308 |
+
|
| 309 |
+
**System Prompt:** ~2000 tokens (comprehensive prompt từ helpers.py)
|
| 310 |
+
|
| 311 |
+
**Khi nào dùng:**
|
| 312 |
+
- Câu hỏi không rõ ràng
|
| 313 |
+
- Không match với agent chuyên môn
|
| 314 |
+
- Routing thất bại
|
| 315 |
+
|
| 316 |
+
---
|
| 317 |
+
|
| 318 |
+
## 🧠 Memory & Coordination Components
|
| 319 |
+
|
| 320 |
+
### 7. **Conversation Memory** (`utils/memory.py`) - ✨ NEW!
|
| 321 |
+
|
| 322 |
+
**Chức năng:** Shared memory system cho tất cả agents
|
| 323 |
+
|
| 324 |
+
**Core Features:**
|
| 325 |
+
|
| 326 |
+
1. **User Profile Storage**
|
| 327 |
+
```python
|
| 328 |
+
memory.update_profile('age', 25)
|
| 329 |
+
memory.update_profile('weight', 70)
|
| 330 |
+
memory.get_profile('age') # → 25
|
| 331 |
+
```
|
| 332 |
+
|
| 333 |
+
2. **Missing Fields Detection**
|
| 334 |
+
```python
|
| 335 |
+
missing = memory.get_missing_fields(['age', 'gender', 'weight', 'height'])
|
| 336 |
+
# → ['gender', 'height']
|
| 337 |
+
```
|
| 338 |
+
|
| 339 |
+
3. **Agent-Specific Data**
|
| 340 |
+
```python
|
| 341 |
+
memory.add_agent_data('nutrition', 'goal', 'weight_loss')
|
| 342 |
+
memory.get_agent_data('nutrition', 'goal') # → 'weight_loss'
|
| 343 |
+
```
|
| 344 |
+
|
| 345 |
+
4. **Conversation State Tracking**
|
| 346 |
+
```python
|
| 347 |
+
memory.set_current_agent('nutrition_agent')
|
| 348 |
+
memory.get_current_agent() # → 'nutrition_agent'
|
| 349 |
+
memory.get_previous_agent() # → 'symptom_agent'
|
| 350 |
+
```
|
| 351 |
+
|
| 352 |
+
5. **Context Summary**
|
| 353 |
+
```python
|
| 354 |
+
memory.get_context_summary()
|
| 355 |
+
# → "User: 25 tuổi, nam | 70kg, 175cm | Topic: giảm cân"
|
| 356 |
+
```
|
| 357 |
+
|
| 358 |
+
**Benefits:**
|
| 359 |
+
- ✅ No repeated questions
|
| 360 |
+
- ✅ Full conversation context
|
| 361 |
+
- ✅ Agent coordination
|
| 362 |
+
- ✅ Persistent user data
|
| 363 |
+
|
| 364 |
+
---
|
| 365 |
+
|
| 366 |
+
### 8. **Base Agent Class** (`agents/core/base_agent.py`) - ✨ NEW!
|
| 367 |
+
|
| 368 |
+
**Chức năng:** Parent class cho tất cả agents với memory support
|
| 369 |
+
|
| 370 |
+
**Core Methods:**
|
| 371 |
+
|
| 372 |
+
1. **Memory Access**
|
| 373 |
+
```python
|
| 374 |
+
class MyAgent(BaseAgent):
|
| 375 |
+
def handle(self, parameters, chat_history):
|
| 376 |
+
# Get user profile
|
| 377 |
+
profile = self.get_user_profile()
|
| 378 |
+
|
| 379 |
+
# Update profile
|
| 380 |
+
self.update_user_profile('age', 25)
|
| 381 |
+
|
| 382 |
+
# Check missing fields
|
| 383 |
+
missing = self.get_missing_profile_fields(['age', 'weight'])
|
| 384 |
+
```
|
| 385 |
+
|
| 386 |
+
2. **Handoff Detection**
|
| 387 |
+
```python
|
| 388 |
+
# Check if should hand off
|
| 389 |
+
if self.should_handoff(user_query, chat_history):
|
| 390 |
+
next_agent = self.suggest_next_agent(user_query)
|
| 391 |
+
return self.create_handoff_message(next_agent)
|
| 392 |
+
```
|
| 393 |
+
|
| 394 |
+
3. **Multi-Agent Collaboration**
|
| 395 |
+
```python
|
| 396 |
+
# Detect if multiple agents needed
|
| 397 |
+
agents_needed = self.needs_collaboration(user_query)
|
| 398 |
+
# → ['nutrition_agent', 'exercise_agent']
|
| 399 |
+
```
|
| 400 |
+
|
| 401 |
+
4. **Context Awareness**
|
| 402 |
+
```python
|
| 403 |
+
# Get conversation context
|
| 404 |
+
context = self.get_context_summary()
|
| 405 |
+
previous_agent = self.get_previous_agent()
|
| 406 |
+
current_topic = self.get_current_topic()
|
| 407 |
+
```
|
| 408 |
+
|
| 409 |
+
**Benefits:**
|
| 410 |
+
- ✅ Unified interface for all agents
|
| 411 |
+
- ✅ Built-in memory access
|
| 412 |
+
- ✅ Automatic handoff logic
|
| 413 |
+
- ✅ Context awareness
|
| 414 |
+
|
| 415 |
+
---
|
| 416 |
+
|
| 417 |
+
### 9. **Agent Coordinator** (`agents/core/coordinator.py`) - ✨ NEW!
|
| 418 |
+
|
| 419 |
+
**Chức năng:** Orchestrates all agents with shared memory
|
| 420 |
+
|
| 421 |
+
**Core Features:**
|
| 422 |
+
|
| 423 |
+
1. **Shared Memory Management**
|
| 424 |
+
- All agents share same memory instance
|
| 425 |
+
- Automatic memory updates from chat history
|
| 426 |
+
- Persistent user data across turns
|
| 427 |
+
|
| 428 |
+
2. **Single Agent Routing**
|
| 429 |
+
```python
|
| 430 |
+
coordinator = AgentCoordinator()
|
| 431 |
+
response = coordinator.handle_query(
|
| 432 |
+
"Tôi muốn giảm cân",
|
| 433 |
+
chat_history
|
| 434 |
+
)
|
| 435 |
+
# → Routes to nutrition_agent with memory
|
| 436 |
+
```
|
| 437 |
+
|
| 438 |
+
3. **Agent Handoff**
|
| 439 |
+
```python
|
| 440 |
+
# User: "Tôi muốn giảm cân nhưng bị đau đầu"
|
| 441 |
+
# Nutrition agent detects symptom keyword
|
| 442 |
+
# → Smooth handoff to symptom_agent
|
| 443 |
+
```
|
| 444 |
+
|
| 445 |
+
4. **Multi-Agent Collaboration**
|
| 446 |
+
```python
|
| 447 |
+
# User: "Tôi muốn giảm cân, nên ăn gì và tập gì?"
|
| 448 |
+
# Coordinator detects need for both agents
|
| 449 |
+
# → Combined response from nutrition + exercise
|
| 450 |
+
```
|
| 451 |
+
|
| 452 |
+
5. **Memory Persistence**
|
| 453 |
+
```python
|
| 454 |
+
# Turn 1
|
| 455 |
+
coordinator.handle_query("Tôi 25 tuổi, nam, 70kg", [])
|
| 456 |
+
|
| 457 |
+
# Turn 2 - Memory persists!
|
| 458 |
+
coordinator.handle_query("Tôi muốn giảm cân", chat_history)
|
| 459 |
+
# → Agent knows age=25, gender=male, weight=70
|
| 460 |
+
```
|
| 461 |
+
|
| 462 |
+
**Response Types:**
|
| 463 |
+
|
| 464 |
+
1. **Single Agent Response**
|
| 465 |
+
```
|
| 466 |
+
User: "Tôi muốn giảm cân"
|
| 467 |
+
→ Nutrition agent handles
|
| 468 |
+
```
|
| 469 |
+
|
| 470 |
+
2. **Handoff Response**
|
| 471 |
+
```
|
| 472 |
+
User: "Tôi muốn giảm cân nhưng bị đau đầu"
|
| 473 |
+
→ Nutrition agent → Handoff → Symptom agent
|
| 474 |
+
```
|
| 475 |
+
|
| 476 |
+
3. **Multi-Agent Response**
|
| 477 |
+
```
|
| 478 |
+
User: "Tôi muốn giảm cân, nên ăn gì và tập gì?"
|
| 479 |
+
|
| 480 |
+
Response:
|
| 481 |
+
---
|
| 482 |
+
## 🥗 Tư Vấn Dinh Dưỡng
|
| 483 |
+
[Nutrition advice]
|
| 484 |
+
|
| 485 |
+
---
|
| 486 |
+
## 💪 Tư Vấn Tập Luyện
|
| 487 |
+
[Exercise advice]
|
| 488 |
+
---
|
| 489 |
+
```
|
| 490 |
+
|
| 491 |
+
**Benefits:**
|
| 492 |
+
- ✅ Seamless agent coordination
|
| 493 |
+
- ✅ No repeated questions
|
| 494 |
+
- ✅ Multi-agent support
|
| 495 |
+
- ✅ Smooth handoffs
|
| 496 |
+
- ✅ Full context awareness
|
| 497 |
+
|
| 498 |
+
---
|
| 499 |
+
|
| 500 |
+
|
| 501 |
+
|
| 502 |
+
## 🔄 Flow Hoàn Chỉnh
|
| 503 |
+
|
| 504 |
+
### Example 1: Nutrition Request (with Memory) ✨ NEW!
|
| 505 |
+
|
| 506 |
+
```
|
| 507 |
+
User: "Tôi 25 tuổi, nam, 70kg, 175cm, muốn giảm cân"
|
| 508 |
+
↓
|
| 509 |
+
helpers.chat_logic() → USE_COORDINATOR = True
|
| 510 |
+
↓
|
| 511 |
+
AgentCoordinator.handle_query()
|
| 512 |
+
↓
|
| 513 |
+
Update Shared Memory from chat history
|
| 514 |
+
→ memory.update_profile('age', 25)
|
| 515 |
+
→ memory.update_profile('gender', 'male')
|
| 516 |
+
→ memory.update_profile('weight', 70)
|
| 517 |
+
→ memory.update_profile('height', 175)
|
| 518 |
+
↓
|
| 519 |
+
route_to_agent() → Function Calling
|
| 520 |
+
↓
|
| 521 |
+
OpenAI returns: nutrition_agent
|
| 522 |
+
↓
|
| 523 |
+
memory.set_current_agent('nutrition_agent')
|
| 524 |
+
↓
|
| 525 |
+
NutritionAgent.handle() [with memory access]
|
| 526 |
+
↓
|
| 527 |
+
Check memory for user data
|
| 528 |
+
→ user_data = memory.get_full_profile()
|
| 529 |
+
→ {age: 25, gender: 'male', weight: 70, height: 175}
|
| 530 |
+
↓
|
| 531 |
+
NutritionAdvisor.generate_nutrition_advice(user_data)
|
| 532 |
+
↓
|
| 533 |
+
Calculate BMI: 22.9 (normal)
|
| 534 |
+
Calculate targets: 1800 kcal, 112g protein...
|
| 535 |
+
Generate meal suggestions
|
| 536 |
+
↓
|
| 537 |
+
Save agent data to memory
|
| 538 |
+
→ memory.add_agent_data('nutrition', 'goal', 'weight_loss')
|
| 539 |
+
→ memory.add_agent_data('nutrition', 'bmi', 22.9)
|
| 540 |
+
↓
|
| 541 |
+
Format response
|
| 542 |
+
↓
|
| 543 |
+
Return to user
|
| 544 |
+
```
|
| 545 |
+
|
| 546 |
+
**Next Turn:**
|
| 547 |
+
```
|
| 548 |
+
User: "Vậy tôi nên tập gì?"
|
| 549 |
+
↓
|
| 550 |
+
AgentCoordinator.handle_query()
|
| 551 |
+
↓
|
| 552 |
+
Memory already has: age=25, gender=male, weight=70, height=175
|
| 553 |
+
↓
|
| 554 |
+
route_to_agent() → exercise_agent
|
| 555 |
+
↓
|
| 556 |
+
ExerciseAgent.handle() [with memory access]
|
| 557 |
+
↓
|
| 558 |
+
Get user data from memory (no need to ask again!)
|
| 559 |
+
→ profile = memory.get_full_profile()
|
| 560 |
+
→ nutrition_goal = memory.get_agent_data('nutrition', 'goal')
|
| 561 |
+
↓
|
| 562 |
+
Generate exercise plan based on profile + nutrition goal
|
| 563 |
+
↓
|
| 564 |
+
Return personalized exercise advice
|
| 565 |
+
```
|
| 566 |
+
|
| 567 |
+
**Token Usage:**
|
| 568 |
+
- Router: ~200 tokens
|
| 569 |
+
- Nutrition Agent prompt: ~500 tokens
|
| 570 |
+
- Memory operations: negligible
|
| 571 |
+
- Total: ~700 tokens (vs 3000+ monolithic)
|
| 572 |
+
|
| 573 |
+
**Key Improvement:** ✅ No repeated questions!
|
| 574 |
+
|
| 575 |
+
---
|
| 576 |
+
|
| 577 |
+
### Example 2: Symptom Assessment
|
| 578 |
+
|
| 579 |
+
```
|
| 580 |
+
User: "Tôi bị đau đầu"
|
| 581 |
+
↓
|
| 582 |
+
route_to_agent() → symptom_agent
|
| 583 |
+
↓
|
| 584 |
+
SymptomAgent.handle()
|
| 585 |
+
↓
|
| 586 |
+
Check red flags: None
|
| 587 |
+
↓
|
| 588 |
+
Assess OPQRST progress: onset not asked
|
| 589 |
+
↓
|
| 590 |
+
Ask: "Đau từ khi nào? Đột ngột hay từ từ?"
|
| 591 |
+
↓
|
| 592 |
+
User: "Đau từ 2 ngày trước, đột ngột"
|
| 593 |
+
↓
|
| 594 |
+
Assess OPQRST: quality not asked
|
| 595 |
+
↓
|
| 596 |
+
Ask: "Mô tả cảm giác? Mức độ 1-10?"
|
| 597 |
+
↓
|
| 598 |
+
... (continue 6 rounds)
|
| 599 |
+
↓
|
| 600 |
+
All OPQRST collected → Provide assessment
|
| 601 |
+
```
|
| 602 |
+
|
| 603 |
+
**Token Usage:**
|
| 604 |
+
- Each round: ~300-400 tokens
|
| 605 |
+
- Total: ~2000 tokens across conversation (vs 3000+ per message)
|
| 606 |
+
|
| 607 |
+
---
|
| 608 |
+
|
| 609 |
+
### Example 3: Agent Handoff ✨ NEW!
|
| 610 |
+
|
| 611 |
+
```
|
| 612 |
+
User: "Tôi muốn giảm cân nhưng bị đau đầu"
|
| 613 |
+
↓
|
| 614 |
+
AgentCoordinator.handle_query()
|
| 615 |
+
↓
|
| 616 |
+
route_to_agent() → nutrition_agent (primary intent)
|
| 617 |
+
↓
|
| 618 |
+
NutritionAgent.handle()
|
| 619 |
+
↓
|
| 620 |
+
Detect symptom keyword: "đau đầu"
|
| 621 |
+
↓
|
| 622 |
+
should_handoff() → True
|
| 623 |
+
↓
|
| 624 |
+
suggest_next_agent() → 'symptom_agent'
|
| 625 |
+
↓
|
| 626 |
+
create_handoff_message()
|
| 627 |
+
↓
|
| 628 |
+
Response: "Mình thấy bạn có triệu chứng đau đầu.
|
| 629 |
+
Để tư vấn chính xác hơn, mình sẽ chuyển bạn
|
| 630 |
+
sang chuyên gia đánh giá triệu chứng nhé! 😊"
|
| 631 |
+
↓
|
| 632 |
+
memory.set_current_agent('symptom_agent')
|
| 633 |
+
↓
|
| 634 |
+
Next turn: SymptomAgent handles with full context
|
| 635 |
+
```
|
| 636 |
+
|
| 637 |
+
**Benefits:**
|
| 638 |
+
- ✅ Smooth transition between agents
|
| 639 |
+
- ✅ Context preserved
|
| 640 |
+
- ✅ User-friendly handoff message
|
| 641 |
+
|
| 642 |
+
---
|
| 643 |
+
|
| 644 |
+
### Example 4: Multi-Agent Collaboration ✨ NEW!
|
| 645 |
+
|
| 646 |
+
```
|
| 647 |
+
User: "Tôi muốn giảm cân, nên ăn gì và tập gì?"
|
| 648 |
+
↓
|
| 649 |
+
AgentCoordinator.handle_query()
|
| 650 |
+
↓
|
| 651 |
+
_detect_required_agents()
|
| 652 |
+
→ ['nutrition_agent', 'exercise_agent']
|
| 653 |
+
↓
|
| 654 |
+
_needs_multi_agent() → True
|
| 655 |
+
↓
|
| 656 |
+
_handle_multi_agent_query()
|
| 657 |
+
↓
|
| 658 |
+
Get response from nutrition_agent
|
| 659 |
+
→ "Để giảm cân, bạn nên ăn..."
|
| 660 |
+
↓
|
| 661 |
+
Get response from exercise_agent
|
| 662 |
+
→ "Bạn nên tập cardio..."
|
| 663 |
+
↓
|
| 664 |
+
_combine_responses()
|
| 665 |
+
↓
|
| 666 |
+
Response:
|
| 667 |
+
---
|
| 668 |
+
## 🥗 Tư Vấn Dinh Dưỡng
|
| 669 |
+
|
| 670 |
+
Để giảm cân hiệu quả, bạn nên:
|
| 671 |
+
- Giảm 300-500 kcal/ngày
|
| 672 |
+
- Tăng protein, giảm carb tinh chế
|
| 673 |
+
- Ăn nhiều rau xanh, trái cây
|
| 674 |
+
[...]
|
| 675 |
+
|
| 676 |
+
---
|
| 677 |
+
## 💪 Tư Vấn Tập Luyện
|
| 678 |
+
|
| 679 |
+
Bạn nên tập:
|
| 680 |
+
- Cardio 30-45 phút/ngày (chạy bộ, đạp xe)
|
| 681 |
+
- Strength training 2-3 lần/tuần
|
| 682 |
+
- HIIT 2 lần/tuần
|
| 683 |
+
[...]
|
| 684 |
+
|
| 685 |
+
---
|
| 686 |
+
💬 Bạn có câu hỏi gì thêm không?
|
| 687 |
+
```
|
| 688 |
+
|
| 689 |
+
**Benefits:**
|
| 690 |
+
- ✅ Comprehensive response
|
| 691 |
+
- ✅ Multiple expert perspectives
|
| 692 |
+
- ✅ Well-organized output
|
| 693 |
+
- ✅ Single response instead of multiple turns
|
| 694 |
+
|
| 695 |
+
---
|
| 696 |
+
|
| 697 |
+
## 💾 Data Structure
|
| 698 |
+
|
| 699 |
+
### Unified User Data Format
|
| 700 |
+
|
| 701 |
+
```python
|
| 702 |
+
{
|
| 703 |
+
# Common fields
|
| 704 |
+
"age": int,
|
| 705 |
+
"gender": str, # "male" or "female"
|
| 706 |
+
"weight": float, # kg
|
| 707 |
+
"height": float, # cm
|
| 708 |
+
|
| 709 |
+
# Nutrition specific
|
| 710 |
+
"goal": str, # "weight_loss", "weight_gain", "muscle_building", "maintenance"
|
| 711 |
+
"activity_level": str, # "low", "moderate", "high"
|
| 712 |
+
"dietary_restrictions": list,
|
| 713 |
+
"health_conditions": list,
|
| 714 |
+
|
| 715 |
+
# Exercise specific
|
| 716 |
+
"fitness_level": str, # "beginner", "intermediate", "advanced"
|
| 717 |
+
"available_time": int, # minutes per day
|
| 718 |
+
|
| 719 |
+
# Symptom specific
|
| 720 |
+
"symptom_type": str,
|
| 721 |
+
"duration": str,
|
| 722 |
+
"severity": int, # 1-10
|
| 723 |
+
"location": str,
|
| 724 |
+
|
| 725 |
+
# Mental health specific
|
| 726 |
+
"stress_level": str,
|
| 727 |
+
"triggers": list
|
| 728 |
+
}
|
| 729 |
+
```
|
| 730 |
+
|
| 731 |
+
---
|
| 732 |
+
|
| 733 |
+
## 📈 Performance Comparison
|
| 734 |
+
|
| 735 |
+
### Monolithic (helpers.py - OLD)
|
| 736 |
+
|
| 737 |
+
```
|
| 738 |
+
❌ Token per request: 3000-4000 tokens
|
| 739 |
+
❌ Response time: 3-5 seconds
|
| 740 |
+
❌ Cost: $0.03-0.04 per request
|
| 741 |
+
❌ Maintainability: Low (1 file, 600+ lines)
|
| 742 |
+
❌ Scalability: Hard to add new features
|
| 743 |
+
```
|
| 744 |
+
|
| 745 |
+
### Agent-Based (NEW)
|
| 746 |
+
|
| 747 |
+
```
|
| 748 |
+
✅ Token per request: 700-1500 tokens (50-70% reduction)
|
| 749 |
+
✅ Response time: 1-3 seconds
|
| 750 |
+
✅ Cost: $0.007-0.015 per request (70% cheaper)
|
| 751 |
+
✅ Maintainability: High (modular, clear separation)
|
| 752 |
+
✅ Scalability: Easy to add new agents
|
| 753 |
+
```
|
| 754 |
+
|
| 755 |
+
---
|
| 756 |
+
|
| 757 |
+
## 🚀 Cách Sử Dụng
|
| 758 |
+
|
| 759 |
+
### 0. Import Structure (NEW!)
|
| 760 |
+
|
| 761 |
+
**Option 1: Import from main package (Recommended)**
|
| 762 |
+
```python
|
| 763 |
+
from agents import (
|
| 764 |
+
route_to_agent, # Router function
|
| 765 |
+
AgentCoordinator, # Coordinator class
|
| 766 |
+
BaseAgent, # Base agent class
|
| 767 |
+
NutritionAgent, # Specialized agents
|
| 768 |
+
ExerciseAgent,
|
| 769 |
+
get_agent # Agent factory
|
| 770 |
+
)
|
| 771 |
+
```
|
| 772 |
+
|
| 773 |
+
**Option 2: Import from subpackages (Explicit)**
|
| 774 |
+
```python
|
| 775 |
+
from agents.core import route_to_agent, AgentCoordinator, BaseAgent
|
| 776 |
+
from agents.specialized import NutritionAgent, ExerciseAgent
|
| 777 |
+
```
|
| 778 |
+
|
| 779 |
+
**Option 3: Import specific modules**
|
| 780 |
+
```python
|
| 781 |
+
from agents.core.router import route_to_agent
|
| 782 |
+
from agents.core.coordinator import AgentCoordinator
|
| 783 |
+
from agents.specialized.nutrition_agent import NutritionAgent
|
| 784 |
+
```
|
| 785 |
+
|
| 786 |
+
### 1. Basic Usage
|
| 787 |
+
|
| 788 |
+
```python
|
| 789 |
+
from utils.helpers import chat_logic
|
| 790 |
+
|
| 791 |
+
message = "Tôi muốn giảm cân"
|
| 792 |
+
chat_history = []
|
| 793 |
+
|
| 794 |
+
_, updated_history = chat_logic(message, chat_history)
|
| 795 |
+
```
|
| 796 |
+
|
| 797 |
+
### 2. Add New Agent
|
| 798 |
+
|
| 799 |
+
```python
|
| 800 |
+
# Step 1: Create new agent file
|
| 801 |
+
# agents/new_agent.py
|
| 802 |
+
|
| 803 |
+
class NewAgent:
|
| 804 |
+
def __init__(self):
|
| 805 |
+
self.system_prompt = "..."
|
| 806 |
+
|
| 807 |
+
def handle(self, parameters, chat_history):
|
| 808 |
+
# Your logic here
|
| 809 |
+
return response
|
| 810 |
+
|
| 811 |
+
# Step 2: Register in router.py
|
| 812 |
+
AVAILABLE_FUNCTIONS.append({
|
| 813 |
+
"name": "new_agent",
|
| 814 |
+
"description": "...",
|
| 815 |
+
"parameters": {...}
|
| 816 |
+
})
|
| 817 |
+
|
| 818 |
+
# Step 3: Register in __init__.py
|
| 819 |
+
AGENTS["new_agent"] = NewAgent
|
| 820 |
+
```
|
| 821 |
+
|
| 822 |
+
### 3. Test Specific Agent
|
| 823 |
+
|
| 824 |
+
```python
|
| 825 |
+
from agents import get_agent
|
| 826 |
+
|
| 827 |
+
agent = get_agent("nutrition_agent")
|
| 828 |
+
response = agent.handle({
|
| 829 |
+
"user_query": "Tôi muốn giảm cân",
|
| 830 |
+
"user_data": {
|
| 831 |
+
"age": 25,
|
| 832 |
+
"gender": "male",
|
| 833 |
+
"weight": 70,
|
| 834 |
+
"height": 175
|
| 835 |
+
}
|
| 836 |
+
}, chat_history=[])
|
| 837 |
+
|
| 838 |
+
print(response)
|
| 839 |
+
```
|
| 840 |
+
|
| 841 |
+
---
|
| 842 |
+
|
| 843 |
+
## 🧪 Testing
|
| 844 |
+
|
| 845 |
+
### Test Router
|
| 846 |
+
|
| 847 |
+
```python
|
| 848 |
+
from agents import route_to_agent
|
| 849 |
+
|
| 850 |
+
# Test nutrition routing
|
| 851 |
+
result = route_to_agent("Tôi muốn giảm cân")
|
| 852 |
+
assert result['agent'] == 'nutrition_agent'
|
| 853 |
+
|
| 854 |
+
# Test exercise routing
|
| 855 |
+
result = route_to_agent("Tôi muốn tập gym")
|
| 856 |
+
assert result['agent'] == 'exercise_agent'
|
| 857 |
+
|
| 858 |
+
# Test symptom routing
|
| 859 |
+
result = route_to_agent("Tôi bị đau đầu")
|
| 860 |
+
assert result['agent'] == 'symptom_agent'
|
| 861 |
+
```
|
| 862 |
+
|
| 863 |
+
### Test Individual Agent
|
| 864 |
+
|
| 865 |
+
```python
|
| 866 |
+
from agents import NutritionAgent
|
| 867 |
+
|
| 868 |
+
agent = NutritionAgent()
|
| 869 |
+
response = agent.handle({
|
| 870 |
+
"user_query": "Tôi muốn giảm cân",
|
| 871 |
+
"user_data": {
|
| 872 |
+
"age": 25,
|
| 873 |
+
"gender": "male",
|
| 874 |
+
"weight": 70,
|
| 875 |
+
"height": 175,
|
| 876 |
+
"goal": "weight_loss"
|
| 877 |
+
}
|
| 878 |
+
})
|
| 879 |
+
|
| 880 |
+
assert "BMI" in response
|
| 881 |
+
assert "Calo" in response
|
| 882 |
+
```
|
| 883 |
+
|
| 884 |
+
---
|
| 885 |
+
|
| 886 |
+
## 📁 File Structure
|
| 887 |
+
|
| 888 |
+
```
|
| 889 |
+
heocare-chatbot/
|
| 890 |
+
├── agents/ # NEW: Agent system
|
| 891 |
+
│ ├── __init__.py # Agent registry
|
| 892 |
+
│ ├── router.py # Function calling router
|
| 893 |
+
│ ├── nutrition_agent.py # Nutrition specialist
|
| 894 |
+
│ ├── exercise_agent.py # Exercise specialist
|
| 895 |
+
│ ├── symptom_agent.py # Symptom assessment
|
| 896 |
+
│ ├── mental_health_agent.py # Mental health support
|
| 897 |
+
│ └── general_health_agent.py # General health (fallback)
|
| 898 |
+
│
|
| 899 |
+
├── utils/
|
| 900 |
+
│ ├── helpers.py # NEW: Clean chat logic
|
| 901 |
+
│ └── helpers.py # OLD: Monolithic (deprecated)
|
| 902 |
+
│
|
| 903 |
+
├── modules/
|
| 904 |
+
│ ├── nutrition.py # Nutrition calculations
|
| 905 |
+
│ ├── exercise/ # Exercise planning
|
| 906 |
+
│ └── rules.json # Business rules
|
| 907 |
+
│
|
| 908 |
+
├── app.py # Gradio UI (updated)
|
| 909 |
+
└── config/
|
| 910 |
+
└── settings.py # OpenAI client
|
| 911 |
+
```
|
| 912 |
+
|
| 913 |
+
---
|
| 914 |
+
|
| 915 |
+
## 🔧 Configuration
|
| 916 |
+
|
| 917 |
+
### Environment Variables
|
| 918 |
+
|
| 919 |
+
```bash
|
| 920 |
+
# .env
|
| 921 |
+
OPENAI_API_KEY=your_key_here
|
| 922 |
+
MODEL=gpt-4o-mini # or gpt-4
|
| 923 |
+
```
|
| 924 |
+
|
| 925 |
+
### Model Selection
|
| 926 |
+
|
| 927 |
+
```python
|
| 928 |
+
# config/settings.py
|
| 929 |
+
MODEL = "gpt-4o-mini" # Fast, cheap, good for routing
|
| 930 |
+
# MODEL = "gpt-4" # More accurate, expensive
|
| 931 |
+
```
|
| 932 |
+
|
| 933 |
+
---
|
| 934 |
+
|
| 935 |
+
## 💡 Best Practices
|
| 936 |
+
|
| 937 |
+
### 1. Token Optimization
|
| 938 |
+
|
| 939 |
+
```python
|
| 940 |
+
# ✅ GOOD: Only load necessary prompt
|
| 941 |
+
agent = get_agent("nutrition_agent") # ~500 tokens
|
| 942 |
+
|
| 943 |
+
# ❌ BAD: Load entire monolithic prompt
|
| 944 |
+
# ~3000 tokens every time
|
| 945 |
+
```
|
| 946 |
+
|
| 947 |
+
### 2. Error Handling
|
| 948 |
+
|
| 949 |
+
```python
|
| 950 |
+
try:
|
| 951 |
+
result = route_to_agent(message, chat_history)
|
| 952 |
+
agent = get_agent(result['agent'])
|
| 953 |
+
response = agent.handle(result['parameters'], chat_history)
|
| 954 |
+
except Exception as e:
|
| 955 |
+
# Fallback to general health agent
|
| 956 |
+
agent = GeneralHealthAgent()
|
| 957 |
+
response = agent.handle({"user_query": message}, chat_history)
|
| 958 |
+
```
|
| 959 |
+
|
| 960 |
+
### 3. Context Management (NEW)
|
| 961 |
+
|
| 962 |
+
```python
|
| 963 |
+
# ✅ GOOD: Pass full chat history for context
|
| 964 |
+
result = route_to_agent(message, chat_history) # Uses last 10 exchanges
|
| 965 |
+
|
| 966 |
+
# ⚠️ CAUTION: Don't truncate history too early
|
| 967 |
+
# Router needs context to handle ambiguous questions
|
| 968 |
+
|
| 969 |
+
# 💡 TIP: For very long conversations (50+ exchanges)
|
| 970 |
+
# Consider keeping only relevant exchanges or summarizing
|
| 971 |
+
```
|
| 972 |
+
|
| 973 |
+
### 4. Caching
|
| 974 |
+
|
| 975 |
+
```python
|
| 976 |
+
# Cache agent instances (optional optimization)
|
| 977 |
+
_agent_cache = {}
|
| 978 |
+
|
| 979 |
+
def get_cached_agent(agent_name):
|
| 980 |
+
if agent_name not in _agent_cache:
|
| 981 |
+
_agent_cache[agent_name] = get_agent(agent_name)
|
| 982 |
+
return _agent_cache[agent_name]
|
| 983 |
+
```
|
| 984 |
+
|
| 985 |
+
---
|
| 986 |
+
|
| 987 |
+
## 📊 Monitoring
|
| 988 |
+
|
| 989 |
+
### Log Routing Decisions
|
| 990 |
+
|
| 991 |
+
```python
|
| 992 |
+
# In helpers.py
|
| 993 |
+
routing_result = route_to_agent(message, chat_history)
|
| 994 |
+
print(f"Routed to: {routing_result['agent']}, Confidence: {routing_result['confidence']}")
|
| 995 |
+
```
|
| 996 |
+
|
| 997 |
+
### Track Token Usage
|
| 998 |
+
|
| 999 |
+
```python
|
| 1000 |
+
# In each agent
|
| 1001 |
+
response = client.chat.completions.create(...)
|
| 1002 |
+
print(f"Tokens used: {response.usage.total_tokens}")
|
| 1003 |
+
```
|
| 1004 |
+
|
| 1005 |
+
---
|
| 1006 |
+
|
| 1007 |
+
## 🤝 Contributing
|
| 1008 |
+
|
| 1009 |
+
### Để thêm agent mới (with Memory Support):
|
| 1010 |
+
|
| 1011 |
+
**Option 1: Extend BaseAgent (Recommended)** ✨
|
| 1012 |
+
```python
|
| 1013 |
+
# agents/specialized/your_agent.py
|
| 1014 |
+
from agents.core.base_agent import BaseAgent
|
| 1015 |
+
|
| 1016 |
+
class YourAgent(BaseAgent):
|
| 1017 |
+
def __init__(self, memory=None):
|
| 1018 |
+
super().__init__(memory)
|
| 1019 |
+
self.agent_name = 'your_agent'
|
| 1020 |
+
self.system_prompt = "Your specialized prompt..."
|
| 1021 |
+
|
| 1022 |
+
def handle(self, parameters, chat_history=None):
|
| 1023 |
+
user_query = parameters.get('user_query', '')
|
| 1024 |
+
|
| 1025 |
+
# Access shared memory
|
| 1026 |
+
user_profile = self.get_user_profile()
|
| 1027 |
+
|
| 1028 |
+
# Check missing fields
|
| 1029 |
+
missing = self.get_missing_profile_fields(['age', 'weight'])
|
| 1030 |
+
if missing:
|
| 1031 |
+
return f"Cho mình biết {', '.join(missing)} nhé!"
|
| 1032 |
+
|
| 1033 |
+
# Your logic here
|
| 1034 |
+
response = self._generate_response(user_query, user_profile)
|
| 1035 |
+
|
| 1036 |
+
# Save agent data
|
| 1037 |
+
self.save_agent_data('key', 'value')
|
| 1038 |
+
|
| 1039 |
+
return response
|
| 1040 |
+
```
|
| 1041 |
+
|
| 1042 |
+
**Option 2: Standalone Agent (Legacy)**
|
| 1043 |
+
```python
|
| 1044 |
+
# agents/specialized/your_agent.py
|
| 1045 |
+
class YourAgent:
|
| 1046 |
+
def handle(self, parameters, chat_history=None):
|
| 1047 |
+
# Your logic without memory
|
| 1048 |
+
return "Response"
|
| 1049 |
+
```
|
| 1050 |
+
|
| 1051 |
+
**Steps:**
|
| 1052 |
+
1. Create `agents/specialized/your_agent.py`
|
| 1053 |
+
2. Extend `BaseAgent` for memory support (recommended)
|
| 1054 |
+
3. Register in `agents/core/router.py` AVAILABLE_FUNCTIONS
|
| 1055 |
+
4. Register in `agents/specialized/__init__.py` AGENTS
|
| 1056 |
+
5. Add to `agents/core/coordinator.py` if using coordinator
|
| 1057 |
+
6. Test thoroughly
|
| 1058 |
+
|
| 1059 |
+
**Example Registration:**
|
| 1060 |
+
```python
|
| 1061 |
+
# agents/core/router.py
|
| 1062 |
+
AVAILABLE_FUNCTIONS = [
|
| 1063 |
+
{
|
| 1064 |
+
"name": "your_agent",
|
| 1065 |
+
"description": "Your agent description",
|
| 1066 |
+
"parameters": {...}
|
| 1067 |
+
}
|
| 1068 |
+
]
|
| 1069 |
+
|
| 1070 |
+
# agents/specialized/__init__.py
|
| 1071 |
+
from .your_agent import YourAgent
|
| 1072 |
+
|
| 1073 |
+
AGENTS = {
|
| 1074 |
+
# ... existing agents
|
| 1075 |
+
'your_agent': YourAgent()
|
| 1076 |
+
}
|
| 1077 |
+
|
| 1078 |
+
# agents/core/coordinator.py (if using)
|
| 1079 |
+
from agents.specialized.your_agent import YourAgent
|
| 1080 |
+
|
| 1081 |
+
self.agents = {
|
| 1082 |
+
# ... existing agents
|
| 1083 |
+
'your_agent': YourAgent()
|
| 1084 |
+
}
|
| 1085 |
+
```
|
| 1086 |
+
|
| 1087 |
+
---
|
| 1088 |
+
|
| 1089 |
+
## 📚 RAG System (Retrieval-Augmented Generation)
|
| 1090 |
+
|
| 1091 |
+
### Smart RAG Decision (Performance Optimization)
|
| 1092 |
+
|
| 1093 |
+
**Problem:** Always calling RAG adds 4-6s latency, even for simple queries.
|
| 1094 |
+
|
| 1095 |
+
**Solution:** Conditional RAG based on query complexity.
|
| 1096 |
+
|
| 1097 |
+
```python
|
| 1098 |
+
# BaseAgent.should_use_rag() - Shared by all agents
|
| 1099 |
+
def should_use_rag(self, user_query, chat_history):
|
| 1100 |
+
# Skip RAG for:
|
| 1101 |
+
# - Greetings: "xin chào", "hello"
|
| 1102 |
+
# - Acknowledgments: "cảm ơn", "ok"
|
| 1103 |
+
# - Meta questions: "bạn là ai"
|
| 1104 |
+
# - Simple responses: "có", "không"
|
| 1105 |
+
|
| 1106 |
+
# Use RAG for:
|
| 1107 |
+
# - Complex medical terms: "nguyên nhân", "điều trị"
|
| 1108 |
+
# - Specific diseases: "bệnh", "viêm", "ung thư"
|
| 1109 |
+
# - Detailed questions: "chi tiết", "cụ thể"
|
| 1110 |
+
|
| 1111 |
+
return True/False # Smart decision
|
| 1112 |
+
```
|
| 1113 |
+
|
| 1114 |
+
**Performance Impact:**
|
| 1115 |
+
- Simple queries: **2-3s** (was 8-10s) → **3x faster** ⚡
|
| 1116 |
+
- Complex queries: **6-8s** (was 8-10s) → **1.3x faster** ⚡
|
| 1117 |
+
- Model & DB cached at startup (save 2-3s per query)
|
| 1118 |
+
|
| 1119 |
+
### Architecture: Separate Collections (Option A)
|
| 1120 |
+
|
| 1121 |
+
Each agent has its own dedicated vector database for fast, focused retrieval:
|
| 1122 |
+
|
| 1123 |
+
```
|
| 1124 |
+
rag/vector_store/
|
| 1125 |
+
├── medical_diseases/ # SymptomAgent
|
| 1126 |
+
├── mental_health/ # MentalHealthAgent
|
| 1127 |
+
├── nutrition/ # NutritionAgent
|
| 1128 |
+
├── fitness/ # FitnessAgent
|
| 1129 |
+
└── general/ # SymptomAgent (COVID, general health)
|
| 1130 |
+
```
|
| 1131 |
+
|
| 1132 |
+
### Datasets by Agent
|
| 1133 |
+
|
| 1134 |
+
| Agent | Dataset | Source | Size | Records |
|
| 1135 |
+
|-------|---------|--------|------|---------|
|
| 1136 |
+
| **SymptomAgent** | ViMedical_Disease | HuggingFace | 50 MB | 603 diseases, 12K examples |
|
| 1137 |
+
| **SymptomAgent** | COVID_QA_Castorini | HuggingFace | 5 MB | 124 COVID-19 Q&A |
|
| 1138 |
+
| **MentalHealthAgent** | MentalChat16K | HuggingFace | 80 MB | 16K conversations, 33 topics |
|
| 1139 |
+
| **NutritionAgent** | LLM_Dietary_Recommendation | HuggingFace | 20 MB | 50 patient profiles + diet plans |
|
| 1140 |
+
| **FitnessAgent** | GYM-Exercise | HuggingFace | 10 MB | 1,660 gym exercises |
|
| 1141 |
+
|
| 1142 |
+
**Total:** ~165 MB across 5 vector stores
|
| 1143 |
+
|
| 1144 |
+
### How Agents Use RAG
|
| 1145 |
+
|
| 1146 |
+
```python
|
| 1147 |
+
class SymptomAgent:
|
| 1148 |
+
def __init__(self):
|
| 1149 |
+
# Load domain-specific vector stores
|
| 1150 |
+
self.symptoms_db = ChromaDB("rag/vector_store/medical_diseases")
|
| 1151 |
+
self.general_db = ChromaDB("rag/vector_store/general")
|
| 1152 |
+
|
| 1153 |
+
def process(self, user_query):
|
| 1154 |
+
# 1. Search symptoms database
|
| 1155 |
+
results = self.symptoms_db.query(user_query, n_results=5)
|
| 1156 |
+
|
| 1157 |
+
# 2. If not enough, search general database
|
| 1158 |
+
if len(results) < 3:
|
| 1159 |
+
general_results = self.general_db.query(user_query, n_results=3)
|
| 1160 |
+
results.extend(general_results)
|
| 1161 |
+
|
| 1162 |
+
# 3. Use results in response generation
|
| 1163 |
+
context = self.format_context(results)
|
| 1164 |
+
response = self.generate_response(user_query, context)
|
| 1165 |
+
return response
|
| 1166 |
+
```
|
| 1167 |
+
|
| 1168 |
+
### Benefits
|
| 1169 |
+
|
| 1170 |
+
- **Fast Retrieval**: Each agent searches only its domain (~10-50ms)
|
| 1171 |
+
- **High Relevance**: Domain-specific results, no noise from other topics
|
| 1172 |
+
- **Scalable**: Easy to add new datasets per agent
|
| 1173 |
+
- **Maintainable**: Update one domain without affecting others
|
| 1174 |
+
|
| 1175 |
+
### Setup
|
| 1176 |
+
|
| 1177 |
+
```bash
|
| 1178 |
+
# One command sets up all RAG databases
|
| 1179 |
+
bash scripts/setup_rag.sh
|
| 1180 |
+
|
| 1181 |
+
# Automatically:
|
| 1182 |
+
# 1. Downloads 5 datasets from HuggingFace
|
| 1183 |
+
# 2. Processes and builds ChromaDB for each
|
| 1184 |
+
# 3. Moves to rag/vector_store/
|
| 1185 |
+
# 4. Total time: 10-15 minutes
|
| 1186 |
+
```
|
| 1187 |
+
|
| 1188 |
+
See `data_mining/README.md` for detailed dataset information.
|
| 1189 |
+
|
| 1190 |
+
---
|
| 1191 |
+
|
| 1192 |
+
## ✅ Implemented Features
|
| 1193 |
+
|
| 1194 |
+
- **Fine-tuning System** - Automatic data collection and model training (`fine_tuning/`)
|
| 1195 |
+
- Conversation logging for all agents
|
| 1196 |
+
- OpenAI fine-tuning API integration
|
| 1197 |
+
- Quality filtering and export tools
|
| 1198 |
+
- Training scripts and management
|
| 1199 |
+
|
| 1200 |
+
- **Session Persistence** - Save conversation memory across sessions (`utils/session_store.py`)
|
| 1201 |
+
- Automatic session save/load
|
| 1202 |
+
- User-specific memory storage
|
| 1203 |
+
- Multi-user support
|
| 1204 |
+
- Session cleanup utilities
|
| 1205 |
+
|
| 1206 |
+
- **Conversation Summarization** - Automatic summarization of long conversations (`utils/conversation_summarizer.py`)
|
| 1207 |
+
- LLM-powered summarization
|
| 1208 |
+
- Automatic trigger when conversation exceeds threshold
|
| 1209 |
+
- Keeps recent turns + summary
|
| 1210 |
+
- Token usage optimization
|
| 1211 |
+
- Context preservation
|
| 1212 |
+
|
| 1213 |
+
- **Feedback Loop** - Learn from user ratings and corrections (`feedback/`)
|
| 1214 |
+
- Collect ratings (1-5 stars, thumbs up/down)
|
| 1215 |
+
- User corrections and reports
|
| 1216 |
+
- Performance analytics per agent
|
| 1217 |
+
- Actionable insights generation
|
| 1218 |
+
- Export for fine-tuning
|
| 1219 |
+
- Agent comparison and ranking
|
| 1220 |
+
|
| 1221 |
+
- **Multi-language Support** - Vietnamese and English support (`i18n/`)
|
| 1222 |
+
- Automatic language detection
|
| 1223 |
+
- Bilingual translations (UI messages, prompts)
|
| 1224 |
+
- Language-specific agent system prompts
|
| 1225 |
+
- Seamless language switching
|
| 1226 |
+
- User language preferences
|
| 1227 |
+
- Language usage statistics
|
| 1228 |
+
|
| 1229 |
+
## 🔮 Future Enhancements
|
| 1230 |
+
|
| 1231 |
+
- **Centralized Database** - Migrate health data storage from JSON to PostgreSQL for multi-user scalability
|
| 1232 |
+
- **Admin Dashboard** - Monitor agent performance, routing accuracy, user metrics
|
| 1233 |
+
- **Analytics & Monitoring** - Track response quality, token usage, user satisfaction
|
| 1234 |
+
- **A/B Testing** - Test different prompts and routing strategies
|
| 1235 |
+
- **Voice Interface** - Speech-to-text and text-to-speech capabilities
|
agents/README.md
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Agents Package Structure 🏗️
|
| 2 |
+
|
| 3 |
+
## 📁 Directory Structure
|
| 4 |
+
|
| 5 |
+
```
|
| 6 |
+
agents/
|
| 7 |
+
├── README.md # This file
|
| 8 |
+
├── AGENT_ARCHITECTURE.md # Full architecture documentation
|
| 9 |
+
│
|
| 10 |
+
├── core/ # Core infrastructure
|
| 11 |
+
│ ├── __init__.py
|
| 12 |
+
│ ├── router.py # OpenAI function calling router
|
| 13 |
+
│ ├── coordinator.py # Multi-agent coordinator
|
| 14 |
+
│ └── base_agent.py # Base class for all agents
|
| 15 |
+
│
|
| 16 |
+
└── specialized/ # Domain-specific agents
|
| 17 |
+
├── __init__.py
|
| 18 |
+
├── nutrition_agent.py # Nutrition & diet advice
|
| 19 |
+
├── exercise_agent.py # Exercise & fitness plans
|
| 20 |
+
├── symptom_agent.py # Symptom assessment
|
| 21 |
+
├── mental_health_agent.py # Mental health support
|
| 22 |
+
└── general_health_agent.py # General health queries
|
| 23 |
+
```
|
| 24 |
+
|
| 25 |
+
---
|
| 26 |
+
|
| 27 |
+
## 🎯 Purpose of Each Component
|
| 28 |
+
|
| 29 |
+
### **Core Components** (`core/`)
|
| 30 |
+
|
| 31 |
+
#### 1. `router.py`
|
| 32 |
+
- **Purpose:** Routes user queries to appropriate agents
|
| 33 |
+
- **Technology:** OpenAI Function Calling
|
| 34 |
+
- **Key Features:**
|
| 35 |
+
- Context-aware routing (10 exchanges history)
|
| 36 |
+
- Last agent tracking
|
| 37 |
+
- Improved accuracy: 80-85% for ambiguous questions
|
| 38 |
+
|
| 39 |
+
#### 2. `coordinator.py`
|
| 40 |
+
- **Purpose:** Orchestrates multiple agents with shared memory
|
| 41 |
+
- **Key Features:**
|
| 42 |
+
- Shared conversation memory
|
| 43 |
+
- Agent handoffs
|
| 44 |
+
- Multi-agent collaboration
|
| 45 |
+
- Memory persistence across turns
|
| 46 |
+
|
| 47 |
+
#### 3. `base_agent.py`
|
| 48 |
+
- **Purpose:** Base class providing common functionality
|
| 49 |
+
- **Key Features:**
|
| 50 |
+
- Memory access helpers
|
| 51 |
+
- Agent handoff detection**
|
| 52 |
+
- Agent-to-agent communication
|
| 53 |
+
- Context awareness methods
|
| 54 |
+
- Handoff detection logic
|
| 55 |
+
- Context awareness methods
|
| 56 |
+
- User data extraction
|
| 57 |
+
|
| 58 |
+
---
|
| 59 |
+
|
| 60 |
+
### **Specialized Agents** (`specialized/`)
|
| 61 |
+
|
| 62 |
+
#### 1. `nutrition_agent.py`
|
| 63 |
+
- **Domain:** Nutrition, diet, BMI, calories
|
| 64 |
+
- **Capabilities:**
|
| 65 |
+
- Calculate BMI and calorie needs
|
| 66 |
+
- Generate meal plans
|
| 67 |
+
- Provide dietary advice
|
| 68 |
+
- Handle weight loss/gain goals
|
| 69 |
+
|
| 70 |
+
#### 2. `exercise_agent.py`
|
| 71 |
+
- **Domain:** Exercise, fitness, workout plans
|
| 72 |
+
- **Capabilities:**
|
| 73 |
+
- Create personalized workout plans
|
| 74 |
+
- Suggest exercises based on fitness level
|
| 75 |
+
- Provide form guidance
|
| 76 |
+
- Track progress
|
| 77 |
+
|
| 78 |
+
#### 3. `symptom_agent.py`
|
| 79 |
+
- **Domain:** Symptom assessment, health concerns
|
| 80 |
+
- **Capabilities:**
|
| 81 |
+
- OPQRST symptom assessment
|
| 82 |
+
- Red flag detection
|
| 83 |
+
- Triage recommendations
|
| 84 |
+
- Medical advice (when to see doctor)
|
| 85 |
+
|
| 86 |
+
#### 4. `mental_health_agent.py`
|
| 87 |
+
- **Domain:** Mental health, stress, anxiety
|
| 88 |
+
- **Capabilities:**
|
| 89 |
+
- Stress assessment
|
| 90 |
+
- Coping strategies
|
| 91 |
+
- Mindfulness techniques
|
| 92 |
+
- Crisis detection
|
| 93 |
+
|
| 94 |
+
#### 5. `general_health_agent.py`
|
| 95 |
+
- **Domain:** General health queries
|
| 96 |
+
- **Capabilities:**
|
| 97 |
+
- Answer general health questions
|
| 98 |
+
- Provide health tips
|
| 99 |
+
- Fallback for unclear queries
|
| 100 |
+
|
| 101 |
+
---
|
| 102 |
+
|
| 103 |
+
## 🔄 How It Works
|
| 104 |
+
|
| 105 |
+
### 1. **User Query Flow**
|
| 106 |
+
|
| 107 |
+
```
|
| 108 |
+
User Input
|
| 109 |
+
↓
|
| 110 |
+
helpers.py (chat_logic)
|
| 111 |
+
↓
|
| 112 |
+
AgentCoordinator
|
| 113 |
+
↓
|
| 114 |
+
┌─────────────────────────┐
|
| 115 |
+
│ Shared Memory │
|
| 116 |
+
│ - User Profile │
|
| 117 |
+
│ - Conversation State │
|
| 118 |
+
└─────────────────────────┘
|
| 119 |
+
↓
|
| 120 |
+
Router (Function Calling)
|
| 121 |
+
↓
|
| 122 |
+
Specialized Agent(s)
|
| 123 |
+
↓
|
| 124 |
+
Response (with memory)
|
| 125 |
+
```
|
| 126 |
+
|
| 127 |
+
### 2. **Import Structure**
|
| 128 |
+
|
| 129 |
+
```python
|
| 130 |
+
# From outside agents package
|
| 131 |
+
from agents import (
|
| 132 |
+
route_to_agent, # Router function
|
| 133 |
+
AgentCoordinator, # Coordinator class
|
| 134 |
+
BaseAgent, # Base agent class
|
| 135 |
+
NutritionAgent, # Specialized agents
|
| 136 |
+
get_agent # Agent factory
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
# Within agents package
|
| 140 |
+
from agents.core import router, coordinator, base_agent
|
| 141 |
+
from agents.specialized import nutrition_agent, exercise_agent
|
| 142 |
+
```
|
| 143 |
+
|
| 144 |
+
---
|
| 145 |
+
|
| 146 |
+
## 🚀 Usage Examples
|
| 147 |
+
|
| 148 |
+
### Example 1: Using Coordinator (Recommended)
|
| 149 |
+
|
| 150 |
+
```python
|
| 151 |
+
from agents import AgentCoordinator
|
| 152 |
+
|
| 153 |
+
coordinator = AgentCoordinator()
|
| 154 |
+
|
| 155 |
+
# Handle query with memory
|
| 156 |
+
response = coordinator.handle_query(
|
| 157 |
+
"Tôi 25 tuổi, muốn giảm cân",
|
| 158 |
+
chat_history
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
# Memory persists!
|
| 162 |
+
response2 = coordinator.handle_query(
|
| 163 |
+
"Tôi nên ăn gì?", # Knows age=25, goal=weight_loss
|
| 164 |
+
chat_history
|
| 165 |
+
)
|
| 166 |
+
```
|
| 167 |
+
|
| 168 |
+
### Example 2: Using Router Directly
|
| 169 |
+
|
| 170 |
+
```python
|
| 171 |
+
from agents import route_to_agent, get_agent
|
| 172 |
+
|
| 173 |
+
# Route to agent
|
| 174 |
+
routing = route_to_agent("Tôi muốn giảm cân", chat_history)
|
| 175 |
+
# → {'agent': 'nutrition_agent', 'parameters': {...}}
|
| 176 |
+
|
| 177 |
+
# Get agent instance
|
| 178 |
+
agent = get_agent(routing['agent'])
|
| 179 |
+
|
| 180 |
+
# Handle request
|
| 181 |
+
response = agent.handle(routing['parameters'], chat_history)
|
| 182 |
+
```
|
| 183 |
+
|
| 184 |
+
### Example 3: Creating Custom Agent
|
| 185 |
+
|
| 186 |
+
```python
|
| 187 |
+
from agents.core import BaseAgent
|
| 188 |
+
|
| 189 |
+
class MyCustomAgent(BaseAgent):
|
| 190 |
+
def __init__(self, memory=None):
|
| 191 |
+
super().__init__(memory)
|
| 192 |
+
self.agent_name = 'my_custom_agent'
|
| 193 |
+
self.system_prompt = "Your custom prompt..."
|
| 194 |
+
|
| 195 |
+
def handle(self, parameters, chat_history=None):
|
| 196 |
+
# Access shared memory
|
| 197 |
+
user_profile = self.get_user_profile()
|
| 198 |
+
|
| 199 |
+
# Your logic here
|
| 200 |
+
response = self._generate_response(parameters)
|
| 201 |
+
|
| 202 |
+
# Save to memory
|
| 203 |
+
self.save_agent_data('key', 'value')
|
| 204 |
+
|
| 205 |
+
return response
|
| 206 |
+
```
|
| 207 |
+
|
| 208 |
+
---
|
| 209 |
+
|
| 210 |
+
## 📊 Key Benefits of This Structure
|
| 211 |
+
|
| 212 |
+
### ✅ **Separation of Concerns**
|
| 213 |
+
- Core infrastructure separate from domain logic
|
| 214 |
+
- Easy to maintain and test
|
| 215 |
+
|
| 216 |
+
### ✅ **Scalability**
|
| 217 |
+
- Add new agents without touching core
|
| 218 |
+
- Easy to extend functionality
|
| 219 |
+
|
| 220 |
+
### ✅ **Reusability**
|
| 221 |
+
- BaseAgent provides common functionality
|
| 222 |
+
- Coordinator handles all agents uniformly
|
| 223 |
+
|
| 224 |
+
### ✅ **Memory Management**
|
| 225 |
+
- Shared memory across all agents
|
| 226 |
+
- No repeated questions
|
| 227 |
+
- Full context awareness
|
| 228 |
+
|
| 229 |
+
### ✅ **Clean Imports**
|
| 230 |
+
- Clear import paths
|
| 231 |
+
- No circular dependencies
|
| 232 |
+
- Well-organized namespaces
|
| 233 |
+
|
| 234 |
+
---
|
| 235 |
+
|
| 236 |
+
## 🔧 Adding a New Agent
|
| 237 |
+
|
| 238 |
+
### Step 1: Create Agent File
|
| 239 |
+
|
| 240 |
+
```python
|
| 241 |
+
# agents/specialized/my_agent.py
|
| 242 |
+
from agents.core import BaseAgent
|
| 243 |
+
|
| 244 |
+
class MyAgent(BaseAgent):
|
| 245 |
+
def __init__(self, memory=None):
|
| 246 |
+
super().__init__(memory)
|
| 247 |
+
self.agent_name = 'my_agent'
|
| 248 |
+
self.system_prompt = "..."
|
| 249 |
+
|
| 250 |
+
def handle(self, parameters, chat_history=None):
|
| 251 |
+
# Your implementation
|
| 252 |
+
return "Response"
|
| 253 |
+
```
|
| 254 |
+
|
| 255 |
+
### Step 2: Register in `specialized/__init__.py`
|
| 256 |
+
|
| 257 |
+
```python
|
| 258 |
+
from .my_agent import MyAgent
|
| 259 |
+
|
| 260 |
+
AGENTS = {
|
| 261 |
+
# ... existing agents
|
| 262 |
+
"my_agent": MyAgent,
|
| 263 |
+
}
|
| 264 |
+
```
|
| 265 |
+
|
| 266 |
+
### Step 3: Register in `core/router.py`
|
| 267 |
+
|
| 268 |
+
```python
|
| 269 |
+
AVAILABLE_FUNCTIONS = [
|
| 270 |
+
# ... existing functions
|
| 271 |
+
{
|
| 272 |
+
"name": "my_agent",
|
| 273 |
+
"description": "Your agent description",
|
| 274 |
+
"parameters": {...}
|
| 275 |
+
}
|
| 276 |
+
]
|
| 277 |
+
```
|
| 278 |
+
|
| 279 |
+
### Step 4: Add to Coordinator
|
| 280 |
+
|
| 281 |
+
```python
|
| 282 |
+
# agents/core/coordinator.py
|
| 283 |
+
from agents.specialized.my_agent import MyAgent
|
| 284 |
+
|
| 285 |
+
self.agents = {
|
| 286 |
+
# ... existing agents
|
| 287 |
+
'my_agent': MyAgent()
|
| 288 |
+
}
|
| 289 |
+
```
|
| 290 |
+
|
| 291 |
+
---
|
| 292 |
+
|
| 293 |
+
## 📚 Documentation
|
| 294 |
+
|
| 295 |
+
- **Full Architecture:** See `AGENT_ARCHITECTURE.md`
|
| 296 |
+
- **Implementation Guide:** See `PART1_IMPLEMENTATION.md` (if exists)
|
| 297 |
+
- **API Reference:** See individual agent files
|
| 298 |
+
|
| 299 |
+
---
|
| 300 |
+
|
| 301 |
+
## 🎯 Best Practices
|
| 302 |
+
|
| 303 |
+
1. **Always extend BaseAgent** for new agents (unless you have a good reason not to)
|
| 304 |
+
2. **Use coordinator** for production (enables memory & multi-agent)
|
| 305 |
+
3. **Keep agents focused** - One domain per agent
|
| 306 |
+
4. **Document your prompts** - Clear system prompts are crucial
|
| 307 |
+
5. **Test thoroughly** - Test routing, memory, and handoffs
|
| 308 |
+
|
| 309 |
+
---
|
| 310 |
+
|
| 311 |
+
**Last Updated:** Oct 11, 2025
|
| 312 |
+
**Version:** 2.0 (with Memory & Coordination)
|
agents/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Agents Package - Healthcare AI Agent System
|
| 3 |
+
|
| 4 |
+
Structure:
|
| 5 |
+
- core/: Router, Coordinator, Base Agent
|
| 6 |
+
- specialized/: Domain-specific agents (Nutrition, Exercise, Symptom, etc.)
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
# Core components
|
| 10 |
+
from .core import route_to_agent, get_agent_description, AgentCoordinator, BaseAgent
|
| 11 |
+
|
| 12 |
+
# Specialized agents
|
| 13 |
+
from .specialized import (
|
| 14 |
+
NutritionAgent,
|
| 15 |
+
ExerciseAgent,
|
| 16 |
+
SymptomAgent,
|
| 17 |
+
MentalHealthAgent,
|
| 18 |
+
GeneralHealthAgent,
|
| 19 |
+
AGENTS,
|
| 20 |
+
get_agent
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
__all__ = [
|
| 24 |
+
# Core
|
| 25 |
+
'route_to_agent',
|
| 26 |
+
'get_agent_description',
|
| 27 |
+
'AgentCoordinator',
|
| 28 |
+
'BaseAgent',
|
| 29 |
+
|
| 30 |
+
# Specialized agents
|
| 31 |
+
'NutritionAgent',
|
| 32 |
+
'ExerciseAgent',
|
| 33 |
+
'SymptomAgent',
|
| 34 |
+
'MentalHealthAgent',
|
| 35 |
+
'GeneralHealthAgent',
|
| 36 |
+
|
| 37 |
+
# Utilities
|
| 38 |
+
'AGENTS',
|
| 39 |
+
'get_agent'
|
| 40 |
+
]
|
agents/core/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Core agents package - Router, Coordinator, and Base Agent
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from .router import route_to_agent, get_agent_description
|
| 6 |
+
from .coordinator import AgentCoordinator
|
| 7 |
+
from .base_agent import BaseAgent
|
| 8 |
+
|
| 9 |
+
__all__ = [
|
| 10 |
+
'route_to_agent',
|
| 11 |
+
'get_agent_description',
|
| 12 |
+
'AgentCoordinator',
|
| 13 |
+
'BaseAgent'
|
| 14 |
+
]
|
agents/core/base_agent.py
ADDED
|
@@ -0,0 +1,602 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Base Agent - Parent class for all specialized agents
|
| 3 |
+
Provides shared functionality: memory access, handoff logic, coordination
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from typing import Dict, Any, Optional, List
|
| 7 |
+
from utils.memory import ConversationMemory
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class BaseAgent:
|
| 11 |
+
"""
|
| 12 |
+
Base class for all agents
|
| 13 |
+
Provides common functionality and interface
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
def __init__(self, memory: Optional[ConversationMemory] = None):
|
| 17 |
+
"""
|
| 18 |
+
Initialize base agent
|
| 19 |
+
|
| 20 |
+
Args:
|
| 21 |
+
memory: Shared conversation memory (optional)
|
| 22 |
+
"""
|
| 23 |
+
self.memory = memory or ConversationMemory()
|
| 24 |
+
self.agent_name = self.__class__.__name__.replace('Agent', '').lower()
|
| 25 |
+
self.system_prompt = ""
|
| 26 |
+
|
| 27 |
+
# Handoff configuration
|
| 28 |
+
self.can_handoff = True
|
| 29 |
+
self.handoff_triggers = []
|
| 30 |
+
|
| 31 |
+
# ===== Core Interface =====
|
| 32 |
+
|
| 33 |
+
def handle(self, parameters: Dict[str, Any], chat_history: Optional[List] = None) -> str:
|
| 34 |
+
"""
|
| 35 |
+
Handle user request (must be implemented by subclasses)
|
| 36 |
+
|
| 37 |
+
Args:
|
| 38 |
+
parameters: Request parameters from router
|
| 39 |
+
chat_history: Conversation history
|
| 40 |
+
|
| 41 |
+
Returns:
|
| 42 |
+
str: Response message
|
| 43 |
+
"""
|
| 44 |
+
raise NotImplementedError("Subclasses must implement handle()")
|
| 45 |
+
|
| 46 |
+
# ===== Memory Access Helpers =====
|
| 47 |
+
|
| 48 |
+
def get_user_profile(self) -> Dict[str, Any]:
|
| 49 |
+
"""Get complete user profile from memory"""
|
| 50 |
+
return self.memory.get_full_profile()
|
| 51 |
+
|
| 52 |
+
# ===== Smart RAG Helper =====
|
| 53 |
+
|
| 54 |
+
def should_use_rag(self, user_query: str, chat_history: Optional[List] = None) -> bool:
|
| 55 |
+
"""
|
| 56 |
+
Smart RAG Decision - Skip RAG for simple queries to improve performance
|
| 57 |
+
|
| 58 |
+
Performance Impact:
|
| 59 |
+
- Simple queries: 2-3s (was 8-10s) - 3x faster
|
| 60 |
+
- Complex queries: 6-8s (was 8-10s) - 1.3x faster
|
| 61 |
+
|
| 62 |
+
Args:
|
| 63 |
+
user_query: User's message
|
| 64 |
+
chat_history: Conversation history
|
| 65 |
+
|
| 66 |
+
Returns:
|
| 67 |
+
bool: True if RAG needed, False for simple conversational queries
|
| 68 |
+
"""
|
| 69 |
+
query_lower = user_query.lower().strip()
|
| 70 |
+
|
| 71 |
+
# 1. Greetings & acknowledgments (no RAG needed)
|
| 72 |
+
greetings = [
|
| 73 |
+
'xin chào', 'hello', 'hi', 'chào', 'hey',
|
| 74 |
+
'cảm ơn', 'thanks', 'thank you', 'tks',
|
| 75 |
+
'ok', 'được', 'vâng', 'ừ', 'uhm', 'uh huh',
|
| 76 |
+
'bye', 'tạm biệt', 'hẹn gặp lại'
|
| 77 |
+
]
|
| 78 |
+
if any(g in query_lower for g in greetings):
|
| 79 |
+
return False
|
| 80 |
+
|
| 81 |
+
# 2. Very short responses (usually conversational)
|
| 82 |
+
if len(query_lower) < 10:
|
| 83 |
+
short_responses = ['có', 'không', 'rồi', 'ạ', 'dạ', 'yes', 'no', 'nope', 'yep']
|
| 84 |
+
if any(r == query_lower or query_lower.startswith(r + ' ') for r in short_responses):
|
| 85 |
+
return False
|
| 86 |
+
|
| 87 |
+
# 3. Meta questions about the bot (no RAG needed)
|
| 88 |
+
meta_questions = [
|
| 89 |
+
'bạn là ai', 'bạn tên gì', 'bạn có thể', 'bạn làm gì',
|
| 90 |
+
'who are you', 'what can you', 'what do you'
|
| 91 |
+
]
|
| 92 |
+
if any(m in query_lower for m in meta_questions):
|
| 93 |
+
return False
|
| 94 |
+
|
| 95 |
+
# 4. Complex medical/health questions (NEED RAG)
|
| 96 |
+
complex_patterns = [
|
| 97 |
+
# Medical terms
|
| 98 |
+
'nguyên nhân', 'tại sao', 'why', 'how', 'làm sao',
|
| 99 |
+
'cách nào', 'phương pháp', 'điều trị', 'chữa',
|
| 100 |
+
'thuốc', 'medicine', 'phòng ngừa', 'prevention',
|
| 101 |
+
'biến chứng', 'complication', 'nghiên cứu', 'research',
|
| 102 |
+
# Specific diseases
|
| 103 |
+
'bệnh', 'disease', 'viêm', 'ung thư', 'cancer',
|
| 104 |
+
'tiểu đường', 'diabetes', 'huyết áp', 'blood pressure',
|
| 105 |
+
# Detailed questions
|
| 106 |
+
'chi tiết', 'cụ thể', 'specific', 'detail',
|
| 107 |
+
'khoa học', 'scientific', 'evidence', 'hướng dẫn',
|
| 108 |
+
'guideline', 'recommendation', 'chuyên gia', 'expert'
|
| 109 |
+
]
|
| 110 |
+
if any(p in query_lower for p in complex_patterns):
|
| 111 |
+
return True
|
| 112 |
+
|
| 113 |
+
# 5. Default: Simple first-turn questions don't need RAG
|
| 114 |
+
# Agent can ask clarifying questions first
|
| 115 |
+
if not chat_history or len(chat_history) == 0:
|
| 116 |
+
# Simple initial statements
|
| 117 |
+
simple_starts = [
|
| 118 |
+
'tôi muốn', 'tôi cần', 'giúp tôi', 'tôi bị',
|
| 119 |
+
'i want', 'i need', 'help me', 'i have', 'i feel'
|
| 120 |
+
]
|
| 121 |
+
if any(s in query_lower for s in simple_starts):
|
| 122 |
+
# Let agent gather info first, use RAG later
|
| 123 |
+
return False
|
| 124 |
+
|
| 125 |
+
# 6. Default: Use RAG for safety (medical context)
|
| 126 |
+
return True
|
| 127 |
+
|
| 128 |
+
def update_user_profile(self, key: str, value: Any) -> None:
|
| 129 |
+
"""Update user profile in shared memory"""
|
| 130 |
+
self.memory.update_profile(key, value)
|
| 131 |
+
|
| 132 |
+
def get_missing_profile_fields(self, required_fields: List[str]) -> List[str]:
|
| 133 |
+
"""Check what profile fields are missing"""
|
| 134 |
+
return self.memory.get_missing_fields(required_fields)
|
| 135 |
+
|
| 136 |
+
def save_agent_data(self, key: str, value: Any) -> None:
|
| 137 |
+
"""Save agent-specific data to memory"""
|
| 138 |
+
self.memory.add_agent_data(self.agent_name, key, value)
|
| 139 |
+
|
| 140 |
+
def get_agent_data(self, key: str = None) -> Any:
|
| 141 |
+
"""Get agent-specific data from memory"""
|
| 142 |
+
return self.memory.get_agent_data(self.agent_name, key)
|
| 143 |
+
|
| 144 |
+
def get_other_agent_data(self, agent_name: str, key: str = None) -> Any:
|
| 145 |
+
"""Get data from another agent"""
|
| 146 |
+
return self.memory.get_agent_data(agent_name, key)
|
| 147 |
+
|
| 148 |
+
# ===== Context Awareness =====
|
| 149 |
+
|
| 150 |
+
def get_context_summary(self) -> str:
|
| 151 |
+
"""Get summary of current conversation context"""
|
| 152 |
+
return self.memory.get_context_summary()
|
| 153 |
+
|
| 154 |
+
def get_previous_agent(self) -> Optional[str]:
|
| 155 |
+
"""Get name of previous agent"""
|
| 156 |
+
return self.memory.get_previous_agent()
|
| 157 |
+
|
| 158 |
+
def get_current_topic(self) -> Optional[str]:
|
| 159 |
+
"""Get current conversation topic"""
|
| 160 |
+
return self.memory.get_current_topic()
|
| 161 |
+
|
| 162 |
+
def set_current_topic(self, topic: str) -> None:
|
| 163 |
+
"""Set current conversation topic"""
|
| 164 |
+
self.memory.set_current_topic(topic)
|
| 165 |
+
|
| 166 |
+
def generate_natural_opening(self, user_query: str, chat_history: Optional[List] = None) -> str:
|
| 167 |
+
"""
|
| 168 |
+
Generate natural conversation opening based on context
|
| 169 |
+
Avoids robotic prefixes like "Thông tin đã tư vấn:"
|
| 170 |
+
|
| 171 |
+
Args:
|
| 172 |
+
user_query: Current user query
|
| 173 |
+
chat_history: Conversation history
|
| 174 |
+
|
| 175 |
+
Returns:
|
| 176 |
+
str: Natural opening phrase (empty if not needed)
|
| 177 |
+
"""
|
| 178 |
+
# Check if this is a topic transition
|
| 179 |
+
previous_agent = self.get_previous_agent()
|
| 180 |
+
is_new_topic = previous_agent and previous_agent != self.agent_name
|
| 181 |
+
|
| 182 |
+
# If continuing same topic, no special opening needed
|
| 183 |
+
if not is_new_topic:
|
| 184 |
+
return ""
|
| 185 |
+
|
| 186 |
+
# Generate natural transition based on agent type
|
| 187 |
+
query_lower = user_query.lower()
|
| 188 |
+
|
| 189 |
+
# Enthusiastic transitions for new requests
|
| 190 |
+
if any(word in query_lower for word in ['muốn', 'cần', 'giúp', 'tư vấn']):
|
| 191 |
+
openings = [
|
| 192 |
+
"Ah, bây giờ bạn đang cần",
|
| 193 |
+
"Được rồi, để mình",
|
| 194 |
+
"Tuyệt! Mình sẽ",
|
| 195 |
+
"Ok, cùng",
|
| 196 |
+
]
|
| 197 |
+
import random
|
| 198 |
+
return random.choice(openings) + " "
|
| 199 |
+
|
| 200 |
+
# Default: no prefix, just natural response
|
| 201 |
+
return ""
|
| 202 |
+
|
| 203 |
+
# ===== Handoff Logic =====
|
| 204 |
+
|
| 205 |
+
def should_handoff(self, user_query: str, chat_history: Optional[List] = None) -> bool:
|
| 206 |
+
"""
|
| 207 |
+
Determine if this agent should hand off to another agent
|
| 208 |
+
|
| 209 |
+
Args:
|
| 210 |
+
user_query: User's current query
|
| 211 |
+
chat_history: Conversation history
|
| 212 |
+
|
| 213 |
+
Returns:
|
| 214 |
+
bool: True if handoff is needed
|
| 215 |
+
"""
|
| 216 |
+
if not self.can_handoff:
|
| 217 |
+
return False
|
| 218 |
+
|
| 219 |
+
# Check for handoff trigger keywords
|
| 220 |
+
query_lower = user_query.lower()
|
| 221 |
+
for trigger in self.handoff_triggers:
|
| 222 |
+
if trigger in query_lower:
|
| 223 |
+
return True
|
| 224 |
+
|
| 225 |
+
return False
|
| 226 |
+
|
| 227 |
+
def suggest_next_agent(self, user_query: str) -> Optional[str]:
|
| 228 |
+
"""
|
| 229 |
+
Suggest which agent to hand off to
|
| 230 |
+
|
| 231 |
+
Args:
|
| 232 |
+
user_query: User's current query
|
| 233 |
+
|
| 234 |
+
Returns:
|
| 235 |
+
str: Name of suggested agent, or None
|
| 236 |
+
"""
|
| 237 |
+
query_lower = user_query.lower()
|
| 238 |
+
|
| 239 |
+
# Symptom keywords
|
| 240 |
+
symptom_keywords = ['đau', 'sốt', 'ho', 'buồn nôn', 'chóng mặt', 'mệt']
|
| 241 |
+
if any(kw in query_lower for kw in symptom_keywords):
|
| 242 |
+
return 'symptom_agent'
|
| 243 |
+
|
| 244 |
+
# Nutrition keywords
|
| 245 |
+
nutrition_keywords = ['ăn', 'thực đơn', 'calo', 'giảm cân', 'tăng cân']
|
| 246 |
+
if any(kw in query_lower for kw in nutrition_keywords):
|
| 247 |
+
return 'nutrition_agent'
|
| 248 |
+
|
| 249 |
+
# Exercise keywords
|
| 250 |
+
exercise_keywords = ['tập', 'gym', 'cardio', 'yoga', 'chạy bộ']
|
| 251 |
+
if any(kw in query_lower for kw in exercise_keywords):
|
| 252 |
+
return 'exercise_agent'
|
| 253 |
+
|
| 254 |
+
# Mental health keywords
|
| 255 |
+
mental_keywords = ['stress', 'lo âu', 'trầm cảm', 'mất ngủ', 'burnout']
|
| 256 |
+
if any(kw in query_lower for kw in mental_keywords):
|
| 257 |
+
return 'mental_health_agent'
|
| 258 |
+
|
| 259 |
+
return None
|
| 260 |
+
|
| 261 |
+
def create_handoff_message(self, next_agent: str, context: str = "", user_query: str = "") -> str:
|
| 262 |
+
"""
|
| 263 |
+
Create a SEAMLESS topic transition (not explicit handoff)
|
| 264 |
+
|
| 265 |
+
Args:
|
| 266 |
+
next_agent: Name of agent to hand off to
|
| 267 |
+
context: Additional context for handoff
|
| 268 |
+
user_query: User's query to understand intent
|
| 269 |
+
|
| 270 |
+
Returns:
|
| 271 |
+
str: Natural transition message (NOT "chuyển sang chuyên gia")
|
| 272 |
+
"""
|
| 273 |
+
# Map agents to topic areas
|
| 274 |
+
topic_map = {
|
| 275 |
+
'symptom_agent': {
|
| 276 |
+
'topic': 'triệu chứng',
|
| 277 |
+
'action': 'đánh giá',
|
| 278 |
+
'info_needed': ['triệu chứng cụ thể', 'thời gian xuất hiện']
|
| 279 |
+
},
|
| 280 |
+
'nutrition_agent': {
|
| 281 |
+
'topic': 'dinh dưỡng',
|
| 282 |
+
'action': 'tư vấn chế độ ăn',
|
| 283 |
+
'info_needed': ['mục tiêu', 'cân nặng', 'chiều cao', 'tuổi']
|
| 284 |
+
},
|
| 285 |
+
'exercise_agent': {
|
| 286 |
+
'topic': 'tập luyện',
|
| 287 |
+
'action': 'lên lịch tập',
|
| 288 |
+
'info_needed': ['mục tiêu', 'thời gian có thể tập', 'thiết bị']
|
| 289 |
+
},
|
| 290 |
+
'mental_health_agent': {
|
| 291 |
+
'topic': 'sức khỏe tinh thần',
|
| 292 |
+
'action': 'hỗ trợ',
|
| 293 |
+
'info_needed': ['cảm giác hiện tại', 'thời gian kéo dài']
|
| 294 |
+
}
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
topic_info = topic_map.get(next_agent, {
|
| 298 |
+
'topic': 'vấn đề này',
|
| 299 |
+
'action': 'tư vấn',
|
| 300 |
+
'info_needed': []
|
| 301 |
+
})
|
| 302 |
+
|
| 303 |
+
# SEAMLESS transition - acknowledge topic change naturally
|
| 304 |
+
message = f"{context}\n\n" if context else ""
|
| 305 |
+
|
| 306 |
+
# Natural acknowledgment based on query
|
| 307 |
+
if 'tập' in user_query.lower() or 'gym' in user_query.lower():
|
| 308 |
+
message += f"Ah, bây giờ bạn đang cần về {topic_info['topic']}! "
|
| 309 |
+
elif 'ăn' in user_query.lower() or 'thực đơn' in user_query.lower():
|
| 310 |
+
message += f"Okii, giờ chuyển sang {topic_info['topic']} nhé! "
|
| 311 |
+
else:
|
| 312 |
+
message += f"Được, mình giúp bạn về {topic_info['topic']}! "
|
| 313 |
+
|
| 314 |
+
# Ask for info if needed (natural, not formal)
|
| 315 |
+
if topic_info['info_needed']:
|
| 316 |
+
info_list = ', '.join(topic_info['info_needed'][:2]) # Max 2 items
|
| 317 |
+
message += f"Để {topic_info['action']} phù hợp, cho mình biết thêm về {info_list} nhé!"
|
| 318 |
+
|
| 319 |
+
return message
|
| 320 |
+
|
| 321 |
+
# ===== Multi-Agent Coordination =====
|
| 322 |
+
|
| 323 |
+
def needs_collaboration(self, user_query: str) -> List[str]:
|
| 324 |
+
"""
|
| 325 |
+
Determine if multiple agents are needed
|
| 326 |
+
|
| 327 |
+
Args:
|
| 328 |
+
user_query: User's query
|
| 329 |
+
|
| 330 |
+
Returns:
|
| 331 |
+
List[str]: List of agent names needed
|
| 332 |
+
"""
|
| 333 |
+
agents_needed = []
|
| 334 |
+
query_lower = user_query.lower()
|
| 335 |
+
|
| 336 |
+
# Check for each agent's keywords
|
| 337 |
+
if any(kw in query_lower for kw in ['đau', 'sốt', 'ho', 'triệu chứng']):
|
| 338 |
+
agents_needed.append('symptom_agent')
|
| 339 |
+
|
| 340 |
+
if any(kw in query_lower for kw in ['ăn', 'thực đơn', 'calo', 'dinh dưỡng']):
|
| 341 |
+
agents_needed.append('nutrition_agent')
|
| 342 |
+
|
| 343 |
+
if any(kw in query_lower for kw in ['tập', 'gym', 'cardio', 'exercise']):
|
| 344 |
+
agents_needed.append('exercise_agent')
|
| 345 |
+
|
| 346 |
+
if any(kw in query_lower for kw in ['stress', 'lo âu', 'trầm cảm', 'mental']):
|
| 347 |
+
agents_needed.append('mental_health_agent')
|
| 348 |
+
|
| 349 |
+
return agents_needed
|
| 350 |
+
|
| 351 |
+
# ===== Utility Methods =====
|
| 352 |
+
|
| 353 |
+
def extract_user_data_from_history(self, chat_history: List) -> Dict[str, Any]:
|
| 354 |
+
"""
|
| 355 |
+
Extract user data from conversation history
|
| 356 |
+
(Can be overridden by subclasses for specific extraction)
|
| 357 |
+
|
| 358 |
+
Args:
|
| 359 |
+
chat_history: List of [user_msg, bot_msg] pairs
|
| 360 |
+
|
| 361 |
+
Returns:
|
| 362 |
+
Dict: Extracted user data
|
| 363 |
+
"""
|
| 364 |
+
import re
|
| 365 |
+
|
| 366 |
+
if not chat_history:
|
| 367 |
+
return {}
|
| 368 |
+
|
| 369 |
+
all_messages = " ".join([msg[0] for msg in chat_history if msg[0]])
|
| 370 |
+
extracted = {}
|
| 371 |
+
|
| 372 |
+
# Extract age
|
| 373 |
+
age_match = re.search(r'(\d+)\s*tuổi|tuổi\s*(\d+)|tôi\s*(\d+)', all_messages.lower())
|
| 374 |
+
if age_match:
|
| 375 |
+
extracted['age'] = int([g for g in age_match.groups() if g][0])
|
| 376 |
+
|
| 377 |
+
# Extract gender
|
| 378 |
+
if re.search(r'\bnam\b|male|đàn ông', all_messages.lower()):
|
| 379 |
+
extracted['gender'] = 'male'
|
| 380 |
+
elif re.search(r'\bnữ\b|female|đàn bà|phụ nữ', all_messages.lower()):
|
| 381 |
+
extracted['gender'] = 'female'
|
| 382 |
+
|
| 383 |
+
# Extract weight
|
| 384 |
+
weight_match = re.search(r'(\d+)\s*kg|nặng\s*(\d+)|cân\s*(\d+)', all_messages.lower())
|
| 385 |
+
if weight_match:
|
| 386 |
+
extracted['weight'] = float([g for g in weight_match.groups() if g][0])
|
| 387 |
+
|
| 388 |
+
# Extract height
|
| 389 |
+
height_match = re.search(r'(\d+)\s*cm|cao\s*(\d+)|chiều cao\s*(\d+)', all_messages.lower())
|
| 390 |
+
if height_match:
|
| 391 |
+
extracted['height'] = float([g for g in height_match.groups() if g][0])
|
| 392 |
+
|
| 393 |
+
return extracted
|
| 394 |
+
|
| 395 |
+
def update_memory_from_history(self, chat_history: List) -> None:
|
| 396 |
+
"""Extract and update memory from chat history"""
|
| 397 |
+
extracted = self.extract_user_data_from_history(chat_history)
|
| 398 |
+
|
| 399 |
+
for key, value in extracted.items():
|
| 400 |
+
# Always update with latest info (user may correct themselves)
|
| 401 |
+
self.memory.update_profile(key, value)
|
| 402 |
+
|
| 403 |
+
def extract_and_save_user_info(self, user_message: str) -> Dict[str, Any]:
|
| 404 |
+
"""
|
| 405 |
+
Extract user info from a single message using LLM (flexible, handles typos)
|
| 406 |
+
Saves to memory immediately
|
| 407 |
+
|
| 408 |
+
Args:
|
| 409 |
+
user_message: Single user message (any format, any order)
|
| 410 |
+
|
| 411 |
+
Returns:
|
| 412 |
+
Dict: Extracted data
|
| 413 |
+
"""
|
| 414 |
+
from config.settings import client, MODEL
|
| 415 |
+
import json
|
| 416 |
+
|
| 417 |
+
# Use LLM to extract structured data (handles typos, any order, extra info)
|
| 418 |
+
extraction_prompt = f"""Extract health information from this user message. Handle typos and variations.
|
| 419 |
+
|
| 420 |
+
User message: "{user_message}"
|
| 421 |
+
|
| 422 |
+
Extract these fields if present (return null if not found):
|
| 423 |
+
- age: integer (tuổi, age, years old)
|
| 424 |
+
- gender: "male" or "female" (nam, nữ, male, female, đàn ông, phụ nữ)
|
| 425 |
+
- weight: float in kg (nặng, cân, weight, kg)
|
| 426 |
+
- height: float in cm (cao, chiều cao, height, cm, m)
|
| 427 |
+
IMPORTANT: Height MUST be in cm (50-300 range)
|
| 428 |
+
- If user says "1.75m" or "1.78m" → convert to cm (175, 178)
|
| 429 |
+
- If user says "175cm" or "178cm" → use as is (175, 178)
|
| 430 |
+
- NEVER return values like 1.0, 1.5, 1.75 for height!
|
| 431 |
+
- body_fat_percentage: float (tỉ lệ mỡ, body fat, %, optional)
|
| 432 |
+
|
| 433 |
+
Return ONLY valid JSON with these exact keys. Example:
|
| 434 |
+
{{"age": 30, "gender": "male", "weight": 70.5, "height": 175, "body_fat_percentage": 25}}
|
| 435 |
+
|
| 436 |
+
CRITICAL: Height must be 50-300 (in cm). If user says "1.78m", return 178, not 1.78!
|
| 437 |
+
If a field is not found, use null. Be flexible with typos and word order."""
|
| 438 |
+
|
| 439 |
+
try:
|
| 440 |
+
response = client.chat.completions.create(
|
| 441 |
+
model=MODEL,
|
| 442 |
+
messages=[
|
| 443 |
+
{"role": "system", "content": "You are a data extraction assistant. Extract structured health data from user messages. Handle typos and variations. Return only valid JSON."},
|
| 444 |
+
{"role": "user", "content": extraction_prompt}
|
| 445 |
+
],
|
| 446 |
+
temperature=0.1, # Low temp for consistent extraction
|
| 447 |
+
max_tokens=150
|
| 448 |
+
)
|
| 449 |
+
|
| 450 |
+
result_text = response.choices[0].message.content.strip()
|
| 451 |
+
|
| 452 |
+
# Parse JSON response
|
| 453 |
+
# Remove markdown code blocks if present
|
| 454 |
+
if "```json" in result_text:
|
| 455 |
+
result_text = result_text.split("```json")[1].split("```")[0].strip()
|
| 456 |
+
elif "```" in result_text:
|
| 457 |
+
result_text = result_text.split("```")[1].split("```")[0].strip()
|
| 458 |
+
|
| 459 |
+
extracted = json.loads(result_text)
|
| 460 |
+
|
| 461 |
+
# Auto-correct obvious errors before saving
|
| 462 |
+
extracted = self._auto_correct_health_data(extracted)
|
| 463 |
+
|
| 464 |
+
# Save to memory (only non-null values)
|
| 465 |
+
allowed_fields = ['age', 'gender', 'weight', 'height', 'body_fat_percentage']
|
| 466 |
+
for key, value in extracted.items():
|
| 467 |
+
if value is not None and key in allowed_fields:
|
| 468 |
+
self.update_user_profile(key, value)
|
| 469 |
+
|
| 470 |
+
return {k: v for k, v in extracted.items() if v is not None}
|
| 471 |
+
|
| 472 |
+
except Exception as e:
|
| 473 |
+
# Fallback to regex if LLM fails
|
| 474 |
+
print(f"LLM extraction failed: {e}, using regex fallback")
|
| 475 |
+
return self._extract_with_regex_fallback(user_message)
|
| 476 |
+
|
| 477 |
+
def _auto_correct_health_data(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
| 478 |
+
"""
|
| 479 |
+
Auto-correct obvious errors in health data (typos, wrong units)
|
| 480 |
+
|
| 481 |
+
Examples:
|
| 482 |
+
- height: 200 → 200cm ✅ (likely meant 200cm, not 200m)
|
| 483 |
+
- height: 1.75 → 175cm ✅ (convert m to cm)
|
| 484 |
+
- weight: 75 → 75kg ✅ (assume kg if reasonable)
|
| 485 |
+
- weight: 75000 → 75kg ✅ (likely meant 75kg, not 75000g)
|
| 486 |
+
"""
|
| 487 |
+
corrected = data.copy()
|
| 488 |
+
|
| 489 |
+
# Auto-correct height
|
| 490 |
+
if 'height' in corrected and corrected['height'] is not None:
|
| 491 |
+
height = float(corrected['height'])
|
| 492 |
+
|
| 493 |
+
# If height is very small (< 10), likely in meters → convert to cm
|
| 494 |
+
if 0 < height < 10:
|
| 495 |
+
corrected['height'] = height * 100
|
| 496 |
+
print(f"Auto-corrected height: {height}m → {corrected['height']}cm")
|
| 497 |
+
|
| 498 |
+
# If height is reasonable (50-300), assume cm
|
| 499 |
+
elif 50 <= height <= 300:
|
| 500 |
+
corrected['height'] = height
|
| 501 |
+
|
| 502 |
+
# If height is very large (> 1000), likely in mm → convert to cm
|
| 503 |
+
elif height > 1000:
|
| 504 |
+
corrected['height'] = height / 10
|
| 505 |
+
print(f"Auto-corrected height: {height}mm → {corrected['height']}cm")
|
| 506 |
+
|
| 507 |
+
# Otherwise invalid, set to None
|
| 508 |
+
else:
|
| 509 |
+
print(f"Invalid height: {height}, setting to None")
|
| 510 |
+
corrected['height'] = None
|
| 511 |
+
|
| 512 |
+
# Auto-correct weight
|
| 513 |
+
if 'weight' in corrected and corrected['weight'] is not None:
|
| 514 |
+
weight = float(corrected['weight'])
|
| 515 |
+
|
| 516 |
+
# If weight is very large (> 500), likely in grams → convert to kg
|
| 517 |
+
if weight > 500:
|
| 518 |
+
corrected['weight'] = weight / 1000
|
| 519 |
+
print(f"Auto-corrected weight: {weight}g → {corrected['weight']}kg")
|
| 520 |
+
|
| 521 |
+
# If weight is reasonable (20-300), assume kg
|
| 522 |
+
elif 20 <= weight <= 300:
|
| 523 |
+
corrected['weight'] = weight
|
| 524 |
+
|
| 525 |
+
# If weight is very small (< 20), might be wrong unit
|
| 526 |
+
elif 0 < weight < 20:
|
| 527 |
+
# Could be in different unit or child weight
|
| 528 |
+
# Keep as is but flag
|
| 529 |
+
corrected['weight'] = weight
|
| 530 |
+
|
| 531 |
+
# Otherwise invalid
|
| 532 |
+
else:
|
| 533 |
+
print(f"Invalid weight: {weight}, setting to None")
|
| 534 |
+
corrected['weight'] = None
|
| 535 |
+
|
| 536 |
+
# Auto-correct age
|
| 537 |
+
if 'age' in corrected and corrected['age'] is not None:
|
| 538 |
+
age = int(corrected['age'])
|
| 539 |
+
|
| 540 |
+
# Reasonable age range: 1-120
|
| 541 |
+
if not (1 <= age <= 120):
|
| 542 |
+
print(f"Invalid age: {age}, setting to None")
|
| 543 |
+
corrected['age'] = None
|
| 544 |
+
|
| 545 |
+
# Auto-correct body fat percentage
|
| 546 |
+
if 'body_fat_percentage' in corrected and corrected['body_fat_percentage'] is not None:
|
| 547 |
+
bf = float(corrected['body_fat_percentage'])
|
| 548 |
+
|
| 549 |
+
# Reasonable body fat: 3-60%
|
| 550 |
+
if not (3 <= bf <= 60):
|
| 551 |
+
print(f"Invalid body fat: {bf}%, setting to None")
|
| 552 |
+
corrected['body_fat_percentage'] = None
|
| 553 |
+
|
| 554 |
+
return corrected
|
| 555 |
+
|
| 556 |
+
def _extract_with_regex_fallback(self, user_message: str) -> Dict[str, Any]:
|
| 557 |
+
"""Fallback regex extraction (less flexible but reliable)"""
|
| 558 |
+
import re
|
| 559 |
+
extracted = {}
|
| 560 |
+
msg_lower = user_message.lower()
|
| 561 |
+
|
| 562 |
+
# Extract age
|
| 563 |
+
age_match = re.search(r'(\d+)\s*tuổi|tuổi\s*(\d+)|age\s*(\d+)', msg_lower)
|
| 564 |
+
if age_match:
|
| 565 |
+
age = int([g for g in age_match.groups() if g][0])
|
| 566 |
+
extracted['age'] = age
|
| 567 |
+
self.update_user_profile('age', age)
|
| 568 |
+
|
| 569 |
+
# Extract gender
|
| 570 |
+
if re.search(r'\bnam\b|male|đàn ông', msg_lower):
|
| 571 |
+
extracted['gender'] = 'male'
|
| 572 |
+
self.update_user_profile('gender', 'male')
|
| 573 |
+
elif re.search(r'\bnữ\b|female|đàn bà|phụ nữ', msg_lower):
|
| 574 |
+
extracted['gender'] = 'female'
|
| 575 |
+
self.update_user_profile('gender', 'female')
|
| 576 |
+
|
| 577 |
+
# Extract weight
|
| 578 |
+
weight_match = re.search(r'(?:nặng|cân|weight)?\s*(\d+(?:\.\d+)?)\s*kg', msg_lower)
|
| 579 |
+
if weight_match:
|
| 580 |
+
weight = float(weight_match.group(1))
|
| 581 |
+
extracted['weight'] = weight
|
| 582 |
+
self.update_user_profile('weight', weight)
|
| 583 |
+
|
| 584 |
+
# Extract height
|
| 585 |
+
height_cm_match = re.search(r'(?:cao|chiều cao|height)?\s*(\d+(?:\.\d+)?)\s*cm', msg_lower)
|
| 586 |
+
if height_cm_match:
|
| 587 |
+
height = float(height_cm_match.group(1))
|
| 588 |
+
extracted['height'] = height
|
| 589 |
+
self.update_user_profile('height', height)
|
| 590 |
+
else:
|
| 591 |
+
height_m_match = re.search(r'(?:cao|chiều cao|height)?\s*(\d+\.?\d*)\s*m\b', msg_lower)
|
| 592 |
+
if height_m_match:
|
| 593 |
+
height = float(height_m_match.group(1))
|
| 594 |
+
if height < 3:
|
| 595 |
+
height = height * 100
|
| 596 |
+
extracted['height'] = height
|
| 597 |
+
self.update_user_profile('height', height)
|
| 598 |
+
|
| 599 |
+
return extracted
|
| 600 |
+
|
| 601 |
+
def __repr__(self) -> str:
|
| 602 |
+
return f"<{self.__class__.__name__}: {self.get_context_summary()}>"
|
agents/core/context_analyzer.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Context Analyzer - Understands user intent and needs
|
| 3 |
+
Determines what type of response is most appropriate
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from typing import Dict, List, Optional
|
| 7 |
+
|
| 8 |
+
class ContextAnalyzer:
|
| 9 |
+
"""
|
| 10 |
+
Optimized Context Analyzer - Balanced between simplicity and coverage
|
| 11 |
+
Handles 90% of scenarios with 50% less complexity
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
# Define patterns once as class variables (better performance)
|
| 15 |
+
# Use compound patterns to reduce false positives
|
| 16 |
+
URGENT_PATTERNS = [
|
| 17 |
+
'đang đau', 'đau quá', 'khó chịu', 'không chịu nổi',
|
| 18 |
+
'cấp cứu', 'khẩn cấp', 'gấp', 'ngay',
|
| 19 |
+
'đau không thể', 'không aguanta được' # Strong pain indicators
|
| 20 |
+
]
|
| 21 |
+
|
| 22 |
+
# Context-specific urgent combinations (must have BOTH)
|
| 23 |
+
URGENT_COMBINATIONS = [
|
| 24 |
+
('làm sao', ['hết đau', 'giảm đau', 'đỡ', 'ngay']),
|
| 25 |
+
('giúp tôi', ['đau', 'khó chịu', 'không chịu']),
|
| 26 |
+
('phải làm gì', ['đau', 'khó chịu', 'ngay', 'gấp']),
|
| 27 |
+
('cần gì', ['giảm đau', 'hết đau', 'cấp cứu'])
|
| 28 |
+
]
|
| 29 |
+
|
| 30 |
+
INFO_PATTERNS = [
|
| 31 |
+
'tại sao', 'nguyên nhân', 'có phải', 'bị gì',
|
| 32 |
+
'là gì', 'thế nào', 'như thế nào'
|
| 33 |
+
]
|
| 34 |
+
|
| 35 |
+
PREVENTION_PATTERNS = [
|
| 36 |
+
'phòng ngừa', 'tránh', 'không bị', 'hạn chế',
|
| 37 |
+
'làm sao để không', 'để khỏi'
|
| 38 |
+
]
|
| 39 |
+
|
| 40 |
+
@staticmethod
|
| 41 |
+
def is_vague_query(user_query: str) -> bool:
|
| 42 |
+
"""
|
| 43 |
+
Detect if user query is too vague/ambiguous
|
| 44 |
+
"""
|
| 45 |
+
vague_patterns = [
|
| 46 |
+
'không khỏe', 'mệt', 'khó chịu', 'không ổn',
|
| 47 |
+
'giúp tôi', 'cần giúp', 'phải làm sao',
|
| 48 |
+
'không biết', 'chẳng hiểu', 'làm gì bây giờ'
|
| 49 |
+
]
|
| 50 |
+
|
| 51 |
+
query_lower = user_query.lower()
|
| 52 |
+
|
| 53 |
+
# Check if query is very short and vague
|
| 54 |
+
if len(user_query.split()) <= 3:
|
| 55 |
+
if any(pattern in query_lower for pattern in vague_patterns):
|
| 56 |
+
return True
|
| 57 |
+
|
| 58 |
+
# Check if query has no specific symptom/goal
|
| 59 |
+
has_specifics = any(word in query_lower for word in [
|
| 60 |
+
'đau', 'sốt', 'ho', 'buồn nôn', # Symptoms
|
| 61 |
+
'giảm cân', 'tăng cân', 'tập', # Goals
|
| 62 |
+
'ăn', 'thực đơn', 'calo' # Nutrition
|
| 63 |
+
])
|
| 64 |
+
|
| 65 |
+
if not has_specifics and any(pattern in query_lower for pattern in vague_patterns):
|
| 66 |
+
return True
|
| 67 |
+
|
| 68 |
+
return False
|
| 69 |
+
|
| 70 |
+
@staticmethod
|
| 71 |
+
def analyze_user_intent(user_query: str, chat_history: List) -> Dict:
|
| 72 |
+
"""
|
| 73 |
+
Simplified but effective intent analysis with context awareness
|
| 74 |
+
Returns only what's needed for response generation
|
| 75 |
+
"""
|
| 76 |
+
query_lower = user_query.lower()
|
| 77 |
+
|
| 78 |
+
# Check if query is too vague
|
| 79 |
+
is_vague = ContextAnalyzer.is_vague_query(user_query)
|
| 80 |
+
|
| 81 |
+
# Smart urgency check with context
|
| 82 |
+
urgency = 'medium' # default
|
| 83 |
+
|
| 84 |
+
# Check direct urgent patterns
|
| 85 |
+
if any(p in query_lower for p in ContextAnalyzer.URGENT_PATTERNS):
|
| 86 |
+
urgency = 'high'
|
| 87 |
+
# Check combination patterns (need both parts)
|
| 88 |
+
else:
|
| 89 |
+
for trigger, contexts in ContextAnalyzer.URGENT_COMBINATIONS:
|
| 90 |
+
if trigger in query_lower:
|
| 91 |
+
if any(ctx in query_lower for ctx in contexts):
|
| 92 |
+
urgency = 'high'
|
| 93 |
+
break
|
| 94 |
+
|
| 95 |
+
# If not urgent, check if it's informational
|
| 96 |
+
if urgency != 'high' and any(p in query_lower for p in ContextAnalyzer.INFO_PATTERNS):
|
| 97 |
+
urgency = 'low'
|
| 98 |
+
|
| 99 |
+
# Determine primary intent (simplified)
|
| 100 |
+
intent = 'general' # default
|
| 101 |
+
if urgency == 'high':
|
| 102 |
+
intent = 'immediate_relief'
|
| 103 |
+
elif any(p in query_lower for p in ContextAnalyzer.INFO_PATTERNS):
|
| 104 |
+
intent = 'information'
|
| 105 |
+
elif any(p in query_lower for p in ContextAnalyzer.PREVENTION_PATTERNS):
|
| 106 |
+
intent = 'prevention'
|
| 107 |
+
|
| 108 |
+
# Simple decision: solution vs education
|
| 109 |
+
needs_solution = urgency in ['high', 'medium']
|
| 110 |
+
needs_education = urgency == 'low' or intent in ['information', 'prevention']
|
| 111 |
+
|
| 112 |
+
# Add conversation stage for compatibility
|
| 113 |
+
conversation_stage = len(chat_history) if chat_history else 0
|
| 114 |
+
|
| 115 |
+
return {
|
| 116 |
+
'intent': intent,
|
| 117 |
+
'urgency': urgency,
|
| 118 |
+
'needs_solution': needs_solution,
|
| 119 |
+
'needs_education': needs_education,
|
| 120 |
+
'is_vague': is_vague,
|
| 121 |
+
'needs_clarification': is_vague,
|
| 122 |
+
'conversation_stage': conversation_stage
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
@staticmethod
|
| 126 |
+
def determine_response_structure(context: Dict) -> Dict[str, any]:
|
| 127 |
+
"""
|
| 128 |
+
Determine how to structure the response based on context
|
| 129 |
+
|
| 130 |
+
Returns dict with:
|
| 131 |
+
- structure: 'solution_first', 'assessment_first', 'education_first'
|
| 132 |
+
- include_immediate: bool
|
| 133 |
+
- include_prevention: bool
|
| 134 |
+
- include_referral: bool
|
| 135 |
+
"""
|
| 136 |
+
|
| 137 |
+
if context['urgency'] == 'high':
|
| 138 |
+
return {
|
| 139 |
+
'structure': 'solution_first',
|
| 140 |
+
'include_immediate': True,
|
| 141 |
+
'include_prevention': True, # But after solution
|
| 142 |
+
'include_referral': True,
|
| 143 |
+
'tone': 'supportive_urgent'
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
elif context['intent'] == 'diagnosis':
|
| 147 |
+
return {
|
| 148 |
+
'structure': 'assessment_first',
|
| 149 |
+
'include_immediate': False,
|
| 150 |
+
'include_prevention': True,
|
| 151 |
+
'include_referral': False,
|
| 152 |
+
'tone': 'informative'
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
elif context['intent'] == 'prevention':
|
| 156 |
+
return {
|
| 157 |
+
'structure': 'education_first',
|
| 158 |
+
'include_immediate': False,
|
| 159 |
+
'include_prevention': True,
|
| 160 |
+
'include_referral': False,
|
| 161 |
+
'tone': 'educational'
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
else: # Default balanced
|
| 165 |
+
return {
|
| 166 |
+
'structure': 'solution_first',
|
| 167 |
+
'include_immediate': True,
|
| 168 |
+
'include_prevention': True,
|
| 169 |
+
'include_referral': True,
|
| 170 |
+
'tone': 'balanced'
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
@staticmethod
|
| 174 |
+
def format_contextual_response(
|
| 175 |
+
symptom_assessment: str,
|
| 176 |
+
solutions: List[str],
|
| 177 |
+
preventions: List[str],
|
| 178 |
+
response_structure: Dict
|
| 179 |
+
) -> str:
|
| 180 |
+
"""
|
| 181 |
+
Format response based on context and user needs
|
| 182 |
+
"""
|
| 183 |
+
response = ""
|
| 184 |
+
|
| 185 |
+
if response_structure['structure'] == 'solution_first':
|
| 186 |
+
# IMMEDIATE RELIEF FIRST
|
| 187 |
+
if response_structure['include_immediate'] and solutions:
|
| 188 |
+
response += "**Để giảm triệu chứng ngay, bạn có thể:**\n"
|
| 189 |
+
for i, solution in enumerate(solutions[:3], 1): # Top 3 immediate actions
|
| 190 |
+
response += f"{i}. {solution}\n"
|
| 191 |
+
response += "\n"
|
| 192 |
+
|
| 193 |
+
# MEDICATION OPTIONS (if urgent)
|
| 194 |
+
if response_structure['tone'] == 'supportive_urgent':
|
| 195 |
+
response += "**Về thuốc:**\n"
|
| 196 |
+
response += "- Có thể dùng thuốc kháng acid (Maalox, Gaviscon) nếu đau do acid\n"
|
| 197 |
+
response += "- Thuốc chống co thắt (Buscopan) nếu đau quặn\n"
|
| 198 |
+
response += "⚠️ *Nên tham khảo dược sĩ trước khi dùng*\n\n"
|
| 199 |
+
|
| 200 |
+
# WARNING SIGNS
|
| 201 |
+
if response_structure['include_referral']:
|
| 202 |
+
response += "**⚠️ Đi khám ngay nếu:**\n"
|
| 203 |
+
response += "- Đau không giảm sau 2 giờ\n"
|
| 204 |
+
response += "- Kèm sốt, nôn, tiêu chảy\n"
|
| 205 |
+
response += "- Đau dữ dội tăng dần\n\n"
|
| 206 |
+
|
| 207 |
+
# PREVENTION (after immediate care)
|
| 208 |
+
if response_structure['include_prevention'] and preventions:
|
| 209 |
+
response += "**Sau khi đỡ, để phòng tránh:**\n"
|
| 210 |
+
for prevention in preventions[:3]:
|
| 211 |
+
response += f"• {prevention}\n"
|
| 212 |
+
|
| 213 |
+
elif response_structure['structure'] == 'assessment_first':
|
| 214 |
+
# Start with assessment/diagnosis
|
| 215 |
+
response += symptom_assessment + "\n\n"
|
| 216 |
+
|
| 217 |
+
# Then solutions
|
| 218 |
+
if solutions:
|
| 219 |
+
response += "**Cách xử lý:**\n"
|
| 220 |
+
for solution in solutions:
|
| 221 |
+
response += f"• {solution}\n"
|
| 222 |
+
|
| 223 |
+
elif response_structure['structure'] == 'education_first':
|
| 224 |
+
# Start with education/prevention
|
| 225 |
+
if preventions:
|
| 226 |
+
response += "**Để phòng ngừa hiệu quả:**\n"
|
| 227 |
+
for prevention in preventions:
|
| 228 |
+
response += f"• {prevention}\n"
|
| 229 |
+
|
| 230 |
+
return response
|
| 231 |
+
|
| 232 |
+
@staticmethod
|
| 233 |
+
def should_ask_followup(context: Dict, chat_history: List) -> bool:
|
| 234 |
+
"""
|
| 235 |
+
Determine if we should ask follow-up questions
|
| 236 |
+
|
| 237 |
+
Rules:
|
| 238 |
+
- Don't ask if urgency is high (give solutions first)
|
| 239 |
+
- Don't ask if we already have enough info
|
| 240 |
+
- Don't ask more than 2 questions total
|
| 241 |
+
"""
|
| 242 |
+
|
| 243 |
+
# High urgency = no questions, give help
|
| 244 |
+
if context['urgency'] == 'high':
|
| 245 |
+
return False
|
| 246 |
+
|
| 247 |
+
# Already asked 2+ questions = enough
|
| 248 |
+
if chat_history and len(chat_history) >= 2:
|
| 249 |
+
bot_questions = 0
|
| 250 |
+
for _, bot_msg in chat_history:
|
| 251 |
+
if bot_msg and '?' in bot_msg:
|
| 252 |
+
bot_questions += 1
|
| 253 |
+
if bot_questions >= 2:
|
| 254 |
+
return False
|
| 255 |
+
|
| 256 |
+
# First interaction and not urgent = can ask 1 question
|
| 257 |
+
if context['conversation_stage'] == 0:
|
| 258 |
+
return True
|
| 259 |
+
|
| 260 |
+
return False
|
agents/core/coordinator.py
ADDED
|
@@ -0,0 +1,579 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Agent Coordinator - Manages agent collaboration and handoffs
|
| 3 |
+
Enables multi-agent responses and smooth transitions
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from typing import Dict, List, Optional, Any
|
| 7 |
+
import asyncio
|
| 8 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 9 |
+
from utils.memory import ConversationMemory
|
| 10 |
+
from utils.session_store import get_session_store
|
| 11 |
+
from utils.conversation_summarizer import get_summarizer
|
| 12 |
+
from agents.core.router import route_to_agent, get_router
|
| 13 |
+
from fine_tuning import get_data_collector
|
| 14 |
+
from health_data import HealthContext, HealthDataStore
|
| 15 |
+
import hashlib
|
| 16 |
+
import json
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class AgentCoordinator:
|
| 20 |
+
"""
|
| 21 |
+
Coordinates multiple agents and manages handoffs
|
| 22 |
+
Provides multi-agent collaboration capabilities
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
def __init__(self, user_id: Optional[str] = None, use_embedding_router=True, enable_cache=True, enable_data_collection=True, enable_session_persistence=True):
|
| 26 |
+
"""
|
| 27 |
+
Initialize coordinator with shared memory and data store
|
| 28 |
+
|
| 29 |
+
Args:
|
| 30 |
+
user_id: Unique user identifier for session persistence
|
| 31 |
+
use_embedding_router: Use embedding-based routing (faster)
|
| 32 |
+
enable_cache: Enable response caching
|
| 33 |
+
enable_data_collection: Enable conversation logging for fine-tuning
|
| 34 |
+
enable_session_persistence: Enable session persistence across restarts
|
| 35 |
+
"""
|
| 36 |
+
# Session persistence
|
| 37 |
+
self.user_id = user_id
|
| 38 |
+
self.session_store = get_session_store() if enable_session_persistence else None
|
| 39 |
+
|
| 40 |
+
# Initialize memory with session persistence
|
| 41 |
+
self.memory = ConversationMemory(
|
| 42 |
+
user_id=user_id,
|
| 43 |
+
session_store=self.session_store
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
self.data_store = HealthDataStore()
|
| 47 |
+
self.health_context = None
|
| 48 |
+
self.agents = {}
|
| 49 |
+
|
| 50 |
+
# Enable embedding router (faster than LLM routing)
|
| 51 |
+
self.use_embedding_router = use_embedding_router
|
| 52 |
+
if use_embedding_router:
|
| 53 |
+
self.router = get_router(use_embeddings=True)
|
| 54 |
+
else:
|
| 55 |
+
self.router = None
|
| 56 |
+
|
| 57 |
+
# Enable response cache
|
| 58 |
+
self.enable_cache = enable_cache
|
| 59 |
+
self.response_cache = {} if enable_cache else None
|
| 60 |
+
|
| 61 |
+
# Enable data collection for fine-tuning
|
| 62 |
+
self.enable_data_collection = enable_data_collection
|
| 63 |
+
if enable_data_collection:
|
| 64 |
+
self.data_collector = get_data_collector()
|
| 65 |
+
else:
|
| 66 |
+
self.data_collector = None
|
| 67 |
+
|
| 68 |
+
# Conversation summarizer
|
| 69 |
+
self.summarizer = get_summarizer()
|
| 70 |
+
|
| 71 |
+
self._initialize_agents()
|
| 72 |
+
|
| 73 |
+
def _initialize_agents(self) -> None:
|
| 74 |
+
"""Initialize all agents with shared memory"""
|
| 75 |
+
# Import agents (lazy import to avoid circular dependencies)
|
| 76 |
+
from agents.specialized.nutrition_agent import NutritionAgent
|
| 77 |
+
from agents.specialized.exercise_agent import ExerciseAgent
|
| 78 |
+
from agents.specialized.symptom_agent import SymptomAgent
|
| 79 |
+
from agents.specialized.mental_health_agent import MentalHealthAgent
|
| 80 |
+
from agents.specialized.general_health_agent import GeneralHealthAgent
|
| 81 |
+
|
| 82 |
+
# Create agents with shared memory
|
| 83 |
+
self.agents = {
|
| 84 |
+
'nutrition_agent': NutritionAgent(memory=self.memory),
|
| 85 |
+
'exercise_agent': ExerciseAgent(memory=self.memory),
|
| 86 |
+
'symptom_agent': SymptomAgent(memory=self.memory),
|
| 87 |
+
'mental_health_agent': MentalHealthAgent(memory=self.memory),
|
| 88 |
+
'general_health_agent': GeneralHealthAgent(memory=self.memory)
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
def handle_query(self, message: str, chat_history: Optional[List] = None, user_id: Optional[str] = None) -> str:
|
| 92 |
+
"""
|
| 93 |
+
Main entry point - handles user query with coordination
|
| 94 |
+
|
| 95 |
+
Args:
|
| 96 |
+
message: User's message
|
| 97 |
+
chat_history: Conversation history
|
| 98 |
+
user_id: User ID for data persistence
|
| 99 |
+
|
| 100 |
+
Returns:
|
| 101 |
+
str: Response (possibly from multiple agents)
|
| 102 |
+
"""
|
| 103 |
+
chat_history = chat_history or []
|
| 104 |
+
|
| 105 |
+
# Create or update health context for user
|
| 106 |
+
if user_id:
|
| 107 |
+
self.health_context = HealthContext(user_id, self.data_store)
|
| 108 |
+
# Inject health context into all agents
|
| 109 |
+
for agent in self.agents.values():
|
| 110 |
+
if hasattr(agent, 'set_health_context'):
|
| 111 |
+
agent.set_health_context(self.health_context)
|
| 112 |
+
|
| 113 |
+
# Update memory from chat history
|
| 114 |
+
self._update_memory_from_history(chat_history)
|
| 115 |
+
|
| 116 |
+
# Summarize if conversation is too long
|
| 117 |
+
if self.summarizer.should_summarize(chat_history):
|
| 118 |
+
chat_history = self._summarize_if_needed(chat_history)
|
| 119 |
+
|
| 120 |
+
# Check if multi-agent collaboration is needed
|
| 121 |
+
if self._needs_multi_agent(message):
|
| 122 |
+
return self._handle_multi_agent_query(message, chat_history)
|
| 123 |
+
|
| 124 |
+
# Single agent routing
|
| 125 |
+
return self._handle_single_agent_query(message, chat_history)
|
| 126 |
+
|
| 127 |
+
def _get_cache_key(self, message: str, chat_history: List) -> str:
|
| 128 |
+
"""Generate cache key from message and recent history"""
|
| 129 |
+
# Include last 2 exchanges for context
|
| 130 |
+
recent_history = chat_history[-4:] if len(chat_history) > 4 else chat_history
|
| 131 |
+
cache_data = {
|
| 132 |
+
"message": message.lower().strip(),
|
| 133 |
+
"history": [(h[0].lower().strip() if h[0] else "", h[1][:50] if len(h) > 1 else "") for h in recent_history]
|
| 134 |
+
}
|
| 135 |
+
cache_str = json.dumps(cache_data, sort_keys=True)
|
| 136 |
+
return hashlib.md5(cache_str.encode()).hexdigest()
|
| 137 |
+
|
| 138 |
+
def _handle_single_agent_query(self, message: str, chat_history: List, file_data: Optional[Dict] = None) -> str:
|
| 139 |
+
"""Handle query with single agent (with potential handoff)"""
|
| 140 |
+
# Check cache first
|
| 141 |
+
if self.enable_cache:
|
| 142 |
+
cache_key = self._get_cache_key(message, chat_history)
|
| 143 |
+
if cache_key in self.response_cache:
|
| 144 |
+
# print("[CACHE HIT] Returning cached response")
|
| 145 |
+
return self.response_cache[cache_key]
|
| 146 |
+
|
| 147 |
+
# Route to appropriate agent (use embedding router if available)
|
| 148 |
+
if self.router:
|
| 149 |
+
routing_result = self.router.route(message, chat_history)
|
| 150 |
+
else:
|
| 151 |
+
routing_result = route_to_agent(message, chat_history)
|
| 152 |
+
|
| 153 |
+
agent_name = routing_result['agent']
|
| 154 |
+
parameters = routing_result['parameters']
|
| 155 |
+
|
| 156 |
+
# Update current agent in memory
|
| 157 |
+
self.memory.set_current_agent(agent_name)
|
| 158 |
+
|
| 159 |
+
# Get agent
|
| 160 |
+
agent = self.agents.get(agent_name)
|
| 161 |
+
if not agent:
|
| 162 |
+
return "Xin lỗi, không tìm thấy agent phù hợp."
|
| 163 |
+
|
| 164 |
+
# Let agent handle the request
|
| 165 |
+
response = agent.handle(parameters, chat_history)
|
| 166 |
+
|
| 167 |
+
# Log conversation for fine-tuning (with cleaned data)
|
| 168 |
+
if self.enable_data_collection and self.data_collector:
|
| 169 |
+
user_data = self.memory.get_full_profile()
|
| 170 |
+
|
| 171 |
+
# Clean user data before logging to prevent learning from errors
|
| 172 |
+
cleaned_user_data = self._clean_user_data_for_training(user_data)
|
| 173 |
+
|
| 174 |
+
self.data_collector.log_conversation(
|
| 175 |
+
agent_name=agent_name,
|
| 176 |
+
user_message=message,
|
| 177 |
+
agent_response=response,
|
| 178 |
+
user_data=cleaned_user_data,
|
| 179 |
+
metadata={'data_cleaned': True} # Flag that data was cleaned
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
# Cache the response
|
| 183 |
+
if self.enable_cache:
|
| 184 |
+
cache_key = self._get_cache_key(message, chat_history)
|
| 185 |
+
self.response_cache[cache_key] = response
|
| 186 |
+
# Limit cache size to 100 entries
|
| 187 |
+
if len(self.response_cache) > 100:
|
| 188 |
+
# Remove oldest entry (simple FIFO)
|
| 189 |
+
self.response_cache.pop(next(iter(self.response_cache)))
|
| 190 |
+
|
| 191 |
+
# Check if handoff is needed
|
| 192 |
+
if hasattr(agent, 'should_handoff') and agent.should_handoff(message, chat_history):
|
| 193 |
+
next_agent_name = agent.suggest_next_agent(message)
|
| 194 |
+
if next_agent_name and next_agent_name in self.agents:
|
| 195 |
+
return self._perform_handoff(agent, next_agent_name, response, message, chat_history)
|
| 196 |
+
|
| 197 |
+
return response
|
| 198 |
+
|
| 199 |
+
def _handle_multi_agent_query(self, message: str, chat_history: List) -> str:
|
| 200 |
+
"""Handle query that needs multiple agents (with parallel execution)"""
|
| 201 |
+
# Detect which agents are needed
|
| 202 |
+
agents_needed = self._detect_required_agents(message)
|
| 203 |
+
|
| 204 |
+
if len(agents_needed) <= 1:
|
| 205 |
+
# Fallback to single agent
|
| 206 |
+
return self._handle_single_agent_query(message, chat_history)
|
| 207 |
+
|
| 208 |
+
# Use async for parallel execution (faster!)
|
| 209 |
+
try:
|
| 210 |
+
loop = asyncio.new_event_loop()
|
| 211 |
+
asyncio.set_event_loop(loop)
|
| 212 |
+
responses = loop.run_until_complete(
|
| 213 |
+
self._handle_multi_agent_async(message, chat_history, agents_needed)
|
| 214 |
+
)
|
| 215 |
+
loop.close()
|
| 216 |
+
except Exception as e:
|
| 217 |
+
print(f"Async multi-agent failed, falling back to sequential: {e}")
|
| 218 |
+
# Fallback to sequential if async fails
|
| 219 |
+
responses = {}
|
| 220 |
+
for agent_name in agents_needed:
|
| 221 |
+
agent = self.agents.get(agent_name)
|
| 222 |
+
if agent:
|
| 223 |
+
parameters = {'user_query': message}
|
| 224 |
+
responses[agent_name] = agent.handle(parameters, chat_history)
|
| 225 |
+
|
| 226 |
+
# Combine responses
|
| 227 |
+
return self._combine_responses(responses, agents_needed)
|
| 228 |
+
|
| 229 |
+
async def _handle_multi_agent_async(self, message: str, chat_history: List, agents_needed: List[str]) -> Dict[str, str]:
|
| 230 |
+
"""Execute multiple agents in parallel using asyncio"""
|
| 231 |
+
async def call_agent(agent_name: str):
|
| 232 |
+
"""Async wrapper for agent.handle()"""
|
| 233 |
+
agent = self.agents.get(agent_name)
|
| 234 |
+
if not agent:
|
| 235 |
+
return None
|
| 236 |
+
|
| 237 |
+
# Run in thread pool (since agent.handle is sync)
|
| 238 |
+
loop = asyncio.get_event_loop()
|
| 239 |
+
with ThreadPoolExecutor() as pool:
|
| 240 |
+
parameters = {'user_query': message}
|
| 241 |
+
response = await loop.run_in_executor(
|
| 242 |
+
pool,
|
| 243 |
+
agent.handle,
|
| 244 |
+
parameters,
|
| 245 |
+
chat_history
|
| 246 |
+
)
|
| 247 |
+
return response
|
| 248 |
+
|
| 249 |
+
# Create tasks for all agents
|
| 250 |
+
tasks = {agent_name: call_agent(agent_name) for agent_name in agents_needed}
|
| 251 |
+
|
| 252 |
+
# Execute in parallel
|
| 253 |
+
results = await asyncio.gather(*tasks.values(), return_exceptions=True)
|
| 254 |
+
|
| 255 |
+
# Map results back to agent names
|
| 256 |
+
responses = {}
|
| 257 |
+
for agent_name, result in zip(tasks.keys(), results):
|
| 258 |
+
if isinstance(result, Exception):
|
| 259 |
+
print(f"Agent {agent_name} failed: {result}")
|
| 260 |
+
responses[agent_name] = f"Xin lỗi, {agent_name} gặp lỗi."
|
| 261 |
+
elif result:
|
| 262 |
+
responses[agent_name] = result
|
| 263 |
+
|
| 264 |
+
return responses
|
| 265 |
+
|
| 266 |
+
def _perform_handoff(
|
| 267 |
+
self,
|
| 268 |
+
from_agent: Any,
|
| 269 |
+
to_agent_name: str,
|
| 270 |
+
current_response: str,
|
| 271 |
+
message: str,
|
| 272 |
+
chat_history: List
|
| 273 |
+
) -> str:
|
| 274 |
+
"""
|
| 275 |
+
Perform smooth handoff between agents
|
| 276 |
+
|
| 277 |
+
Args:
|
| 278 |
+
from_agent: Current agent
|
| 279 |
+
to_agent_name: Name of agent to hand off to
|
| 280 |
+
current_response: Current agent's response
|
| 281 |
+
message: User's message
|
| 282 |
+
chat_history: Conversation history
|
| 283 |
+
|
| 284 |
+
Returns:
|
| 285 |
+
str: Combined response with handoff
|
| 286 |
+
"""
|
| 287 |
+
# Create handoff message
|
| 288 |
+
handoff_msg = from_agent.create_handoff_message(to_agent_name, current_response)
|
| 289 |
+
|
| 290 |
+
# Update memory
|
| 291 |
+
self.memory.set_current_agent(to_agent_name)
|
| 292 |
+
|
| 293 |
+
return handoff_msg
|
| 294 |
+
|
| 295 |
+
def _needs_multi_agent(self, message: str) -> bool:
|
| 296 |
+
"""
|
| 297 |
+
Determine if query needs multiple agents
|
| 298 |
+
|
| 299 |
+
Args:
|
| 300 |
+
message: User's message
|
| 301 |
+
|
| 302 |
+
Returns:
|
| 303 |
+
bool: True if multiple agents needed
|
| 304 |
+
"""
|
| 305 |
+
agents_needed = self._detect_required_agents(message)
|
| 306 |
+
return len(agents_needed) > 1
|
| 307 |
+
|
| 308 |
+
def _detect_required_agents(self, message: str) -> List[str]:
|
| 309 |
+
"""
|
| 310 |
+
Detect which agents are needed for this query
|
| 311 |
+
|
| 312 |
+
Args:
|
| 313 |
+
message: User's message
|
| 314 |
+
|
| 315 |
+
Returns:
|
| 316 |
+
List[str]: List of agent names needed
|
| 317 |
+
"""
|
| 318 |
+
agents_needed = []
|
| 319 |
+
message_lower = message.lower()
|
| 320 |
+
|
| 321 |
+
# PRIORITY 1: Symptom keywords (highest priority - health emergencies)
|
| 322 |
+
symptom_keywords = ['đau', 'sốt', 'ho', 'buồn nôn', 'chóng mặt', 'triệu chứng', 'khó tiêu', 'đầy bụng', 'ợ hơi']
|
| 323 |
+
has_symptoms = any(kw in message_lower for kw in symptom_keywords)
|
| 324 |
+
|
| 325 |
+
# PRIORITY 2: Nutrition keywords (but NOT if it's a symptom context)
|
| 326 |
+
nutrition_keywords = ['thực đơn', 'calo', 'giảm cân', 'tăng cân', 'dinh dưỡng', 'rau củ', 'thực phẩm']
|
| 327 |
+
# Special handling: 'ăn' only counts as nutrition if NOT in symptom context
|
| 328 |
+
has_nutrition = any(kw in message_lower for kw in nutrition_keywords)
|
| 329 |
+
if not has_symptoms and 'ăn' in message_lower:
|
| 330 |
+
has_nutrition = True
|
| 331 |
+
|
| 332 |
+
# PRIORITY 3: Exercise keywords
|
| 333 |
+
exercise_keywords = ['tập', 'gym', 'cardio', 'yoga', 'chạy bộ', 'exercise', 'workout']
|
| 334 |
+
has_exercise = any(kw in message_lower for kw in exercise_keywords)
|
| 335 |
+
|
| 336 |
+
# PRIORITY 4: Mental health keywords
|
| 337 |
+
mental_keywords = ['stress', 'lo âu', 'trầm cảm', 'mất ngủ', 'burnout', 'mental']
|
| 338 |
+
has_mental = any(kw in message_lower for kw in mental_keywords)
|
| 339 |
+
|
| 340 |
+
# IMPORTANT: Only trigger multi-agent if CLEARLY needs multiple domains
|
| 341 |
+
# Example: "Tôi bị đau bụng, nên ăn gì?" -> symptom + nutrition
|
| 342 |
+
# But: "WHO khuyến nghị ăn bao nhiêu rau củ?" -> ONLY nutrition
|
| 343 |
+
|
| 344 |
+
# Count how many domains are triggered
|
| 345 |
+
domain_count = sum([has_symptoms, has_nutrition, has_exercise, has_mental])
|
| 346 |
+
|
| 347 |
+
# If only 1 domain -> single agent (no multi-agent)
|
| 348 |
+
if domain_count <= 1:
|
| 349 |
+
if has_symptoms:
|
| 350 |
+
agents_needed.append('symptom_agent')
|
| 351 |
+
elif has_nutrition:
|
| 352 |
+
agents_needed.append('nutrition_agent')
|
| 353 |
+
elif has_exercise:
|
| 354 |
+
agents_needed.append('exercise_agent')
|
| 355 |
+
elif has_mental:
|
| 356 |
+
agents_needed.append('mental_health_agent')
|
| 357 |
+
else:
|
| 358 |
+
# Multiple domains detected
|
| 359 |
+
# Check if it's a REAL multi-domain question or false positive
|
| 360 |
+
|
| 361 |
+
# False positive patterns (should be single agent)
|
| 362 |
+
false_positives = [
|
| 363 |
+
'who khuyến nghị', # WHO recommendations -> single domain
|
| 364 |
+
'bao nhiêu', # Quantitative questions -> single domain
|
| 365 |
+
'khó tiêu', # Digestive issues -> symptom only
|
| 366 |
+
'đầy bụng', # Bloating -> symptom only
|
| 367 |
+
'đau bụng', # Stomach pain -> symptom only
|
| 368 |
+
'ợ hơi', # Burping -> symptom only
|
| 369 |
+
]
|
| 370 |
+
|
| 371 |
+
is_false_positive = any(pattern in message_lower for pattern in false_positives)
|
| 372 |
+
|
| 373 |
+
if is_false_positive:
|
| 374 |
+
# Use primary domain only
|
| 375 |
+
if has_nutrition:
|
| 376 |
+
agents_needed.append('nutrition_agent')
|
| 377 |
+
elif has_exercise:
|
| 378 |
+
agents_needed.append('exercise_agent')
|
| 379 |
+
elif has_symptoms:
|
| 380 |
+
agents_needed.append('symptom_agent')
|
| 381 |
+
elif has_mental:
|
| 382 |
+
agents_needed.append('mental_health_agent')
|
| 383 |
+
else:
|
| 384 |
+
# Real multi-domain question
|
| 385 |
+
if has_symptoms:
|
| 386 |
+
agents_needed.append('symptom_agent')
|
| 387 |
+
if has_nutrition:
|
| 388 |
+
agents_needed.append('nutrition_agent')
|
| 389 |
+
if has_exercise:
|
| 390 |
+
agents_needed.append('exercise_agent')
|
| 391 |
+
if has_mental:
|
| 392 |
+
agents_needed.append('mental_health_agent')
|
| 393 |
+
|
| 394 |
+
return agents_needed
|
| 395 |
+
|
| 396 |
+
def _combine_responses(self, responses: Dict[str, str], agents_order: List[str]) -> str:
|
| 397 |
+
"""
|
| 398 |
+
Combine responses from multiple agents
|
| 399 |
+
|
| 400 |
+
Args:
|
| 401 |
+
responses: Dict of agent_name -> response
|
| 402 |
+
agents_order: Order of agents
|
| 403 |
+
|
| 404 |
+
Returns:
|
| 405 |
+
str: Combined response
|
| 406 |
+
"""
|
| 407 |
+
# For natural flow, just combine responses without headers
|
| 408 |
+
# Make it feel like ONE person giving comprehensive advice
|
| 409 |
+
|
| 410 |
+
responses_list = [responses[agent] for agent in agents_order if agent in responses]
|
| 411 |
+
|
| 412 |
+
if len(responses_list) == 1:
|
| 413 |
+
# Single agent - return as is
|
| 414 |
+
return responses_list[0]
|
| 415 |
+
|
| 416 |
+
# Multiple agents - combine naturally
|
| 417 |
+
combined = ""
|
| 418 |
+
|
| 419 |
+
# First response (usually symptom assessment)
|
| 420 |
+
combined += responses_list[0]
|
| 421 |
+
|
| 422 |
+
# Add other responses with smooth transitions
|
| 423 |
+
for i in range(1, len(responses_list)):
|
| 424 |
+
# Natural transition phrases
|
| 425 |
+
transitions = [
|
| 426 |
+
"\n\nNgoài ra, ",
|
| 427 |
+
"\n\nBên cạnh đó, ",
|
| 428 |
+
"\n\nĐồng thời, ",
|
| 429 |
+
"\n\nVề mặt khác, "
|
| 430 |
+
]
|
| 431 |
+
transition = transitions[min(i-1, len(transitions)-1)]
|
| 432 |
+
combined += transition + responses_list[i]
|
| 433 |
+
|
| 434 |
+
# Natural closing (not too formal)
|
| 435 |
+
combined += "\n\nBạn thử làm theo xem có đỡ không nhé. Có gì thắc mắc cứ hỏi mình!"
|
| 436 |
+
|
| 437 |
+
return combined
|
| 438 |
+
|
| 439 |
+
def _update_memory_from_history(self, chat_history: List) -> None:
|
| 440 |
+
"""Extract and update SHARED memory from chat history to prevent duplicate questions"""
|
| 441 |
+
if not chat_history:
|
| 442 |
+
return
|
| 443 |
+
|
| 444 |
+
# Extract user info from ALL conversations (not just current agent)
|
| 445 |
+
user_info = self._extract_user_info_from_all_history(chat_history)
|
| 446 |
+
|
| 447 |
+
# Update SHARED memory that ALL agents can access
|
| 448 |
+
if user_info:
|
| 449 |
+
for key, value in user_info.items():
|
| 450 |
+
self.memory.update_profile(key, value)
|
| 451 |
+
|
| 452 |
+
def _extract_user_info_from_all_history(self, chat_history: List) -> Dict:
|
| 453 |
+
"""Extract user information from entire conversation history"""
|
| 454 |
+
user_info = {}
|
| 455 |
+
|
| 456 |
+
# Common patterns to extract
|
| 457 |
+
patterns = {
|
| 458 |
+
'age': [r'(\d+)\s*tuổi', r'tôi\s*(\d+)', r'(\d+)\s*years?\s*old'],
|
| 459 |
+
'gender': [r'tôi là (nam|nữ)', r'giới tính[:\s]*(nam|nữ)', r'(male|female|nam|nữ)'],
|
| 460 |
+
'weight': [r'(\d+)\s*kg', r'nặng\s*(\d+)', r'cân nặng[:\s]*(\d+)'],
|
| 461 |
+
'height': [r'(\d+)\s*cm', r'cao\s*(\d+)', r'chiều cao[:\s]*(\d+)'],
|
| 462 |
+
'goal': [r'muốn\s*(giảm cân|tăng cân|tăng cơ|khỏe mạnh)', r'mục tiêu[:\s]*(.+)']
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
+
# Search through all user messages
|
| 466 |
+
import re
|
| 467 |
+
for user_msg, _ in chat_history:
|
| 468 |
+
if not user_msg:
|
| 469 |
+
continue
|
| 470 |
+
|
| 471 |
+
for field, field_patterns in patterns.items():
|
| 472 |
+
if field not in user_info: # Only extract if not already found
|
| 473 |
+
for pattern in field_patterns:
|
| 474 |
+
match = re.search(pattern, user_msg.lower())
|
| 475 |
+
if match:
|
| 476 |
+
user_info[field] = match.group(1)
|
| 477 |
+
break
|
| 478 |
+
|
| 479 |
+
return user_info
|
| 480 |
+
|
| 481 |
+
# Extract gender
|
| 482 |
+
if not self.memory.get_profile('gender'):
|
| 483 |
+
if re.search(r'\bnam\b|male', all_messages.lower()):
|
| 484 |
+
self.memory.update_profile('gender', 'male')
|
| 485 |
+
elif re.search(r'\bnữ\b|female', all_messages.lower()):
|
| 486 |
+
self.memory.update_profile('gender', 'female')
|
| 487 |
+
|
| 488 |
+
# Extract weight
|
| 489 |
+
if not self.memory.get_profile('weight'):
|
| 490 |
+
weight_match = re.search(r'(\d+)\s*kg|nặng\s*(\d+)', all_messages.lower())
|
| 491 |
+
if weight_match:
|
| 492 |
+
weight = float([g for g in weight_match.groups() if g][0])
|
| 493 |
+
self.memory.update_profile('weight', weight)
|
| 494 |
+
|
| 495 |
+
# Extract height
|
| 496 |
+
if not self.memory.get_profile('height'):
|
| 497 |
+
height_match = re.search(r'(\d+)\s*cm|cao\s*(\d+)', all_messages.lower())
|
| 498 |
+
if height_match:
|
| 499 |
+
height = float([g for g in height_match.groups() if g][0])
|
| 500 |
+
self.memory.update_profile('height', height)
|
| 501 |
+
|
| 502 |
+
def _summarize_if_needed(self, chat_history: List) -> List:
|
| 503 |
+
"""
|
| 504 |
+
Summarize conversation if it's too long
|
| 505 |
+
|
| 506 |
+
Args:
|
| 507 |
+
chat_history: Full conversation history
|
| 508 |
+
|
| 509 |
+
Returns:
|
| 510 |
+
Compressed history with summary
|
| 511 |
+
"""
|
| 512 |
+
user_profile = self.memory.get_full_profile()
|
| 513 |
+
compressed = self.summarizer.compress_history(
|
| 514 |
+
chat_history,
|
| 515 |
+
target_turns=10 # Keep last 10 turns + summary
|
| 516 |
+
)
|
| 517 |
+
|
| 518 |
+
# print(f"📝 Summarized {len(chat_history)} turns → {len(compressed)} turns")
|
| 519 |
+
return compressed
|
| 520 |
+
|
| 521 |
+
def get_conversation_stats(self, chat_history: List) -> Dict[str, Any]:
|
| 522 |
+
"""Get statistics about current conversation"""
|
| 523 |
+
return self.summarizer.get_summary_stats(chat_history)
|
| 524 |
+
|
| 525 |
+
def get_memory_summary(self) -> str:
|
| 526 |
+
"""Get summary of current memory state"""
|
| 527 |
+
return self.memory.get_context_summary()
|
| 528 |
+
|
| 529 |
+
def _clean_user_data_for_training(self, user_data: Dict[str, Any]) -> Dict[str, Any]:
|
| 530 |
+
"""
|
| 531 |
+
Clean user data before logging for training
|
| 532 |
+
Ensures only valid, corrected data is used for fine-tuning
|
| 533 |
+
|
| 534 |
+
This prevents the model from learning bad patterns like:
|
| 535 |
+
- "cao 200m" (should be 200cm)
|
| 536 |
+
- "nặng 75g" (should be 75kg)
|
| 537 |
+
- Invalid BMI values
|
| 538 |
+
"""
|
| 539 |
+
cleaned = user_data.copy()
|
| 540 |
+
|
| 541 |
+
# Validate and clean height (should be 50-300 cm)
|
| 542 |
+
if 'height' in cleaned and cleaned['height'] is not None:
|
| 543 |
+
height = float(cleaned['height'])
|
| 544 |
+
if not (50 <= height <= 300):
|
| 545 |
+
# Invalid height - don't log it
|
| 546 |
+
cleaned['height'] = None
|
| 547 |
+
|
| 548 |
+
# Validate and clean weight (should be 20-300 kg)
|
| 549 |
+
if 'weight' in cleaned and cleaned['weight'] is not None:
|
| 550 |
+
weight = float(cleaned['weight'])
|
| 551 |
+
if not (20 <= weight <= 300):
|
| 552 |
+
# Invalid weight - don't log it
|
| 553 |
+
cleaned['weight'] = None
|
| 554 |
+
|
| 555 |
+
# Validate and clean age (should be 1-120)
|
| 556 |
+
if 'age' in cleaned and cleaned['age'] is not None:
|
| 557 |
+
age = int(cleaned['age'])
|
| 558 |
+
if not (1 <= age <= 120):
|
| 559 |
+
# Invalid age - don't log it
|
| 560 |
+
cleaned['age'] = None
|
| 561 |
+
|
| 562 |
+
# Validate and clean body fat (should be 3-60%)
|
| 563 |
+
if 'body_fat_percentage' in cleaned and cleaned['body_fat_percentage'] is not None:
|
| 564 |
+
bf = float(cleaned['body_fat_percentage'])
|
| 565 |
+
if not (3 <= bf <= 60):
|
| 566 |
+
# Invalid body fat - don't log it
|
| 567 |
+
cleaned['body_fat_percentage'] = None
|
| 568 |
+
|
| 569 |
+
# Remove any None values to keep training data clean
|
| 570 |
+
cleaned = {k: v for k, v in cleaned.items() if v is not None}
|
| 571 |
+
|
| 572 |
+
return cleaned
|
| 573 |
+
|
| 574 |
+
def clear_memory(self) -> None:
|
| 575 |
+
"""Clear all memory (start fresh)"""
|
| 576 |
+
self.memory.clear()
|
| 577 |
+
|
| 578 |
+
def __repr__(self) -> str:
|
| 579 |
+
return f"<AgentCoordinator: {self.get_memory_summary()}>"
|
agents/core/orchestrator.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Multi-Agent Orchestrator
|
| 3 |
+
Coordinates multiple agents to handle complex queries
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from typing import List, Dict, Any
|
| 7 |
+
import json
|
| 8 |
+
|
| 9 |
+
class MultiAgentOrchestrator:
|
| 10 |
+
"""
|
| 11 |
+
Orchestrates multiple agents to handle complex queries
|
| 12 |
+
that require expertise from multiple domains
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
def __init__(self, agents: Dict[str, Any]):
|
| 16 |
+
"""
|
| 17 |
+
Initialize orchestrator with available agents
|
| 18 |
+
|
| 19 |
+
Args:
|
| 20 |
+
agents: Dictionary of agent_name -> agent_instance
|
| 21 |
+
"""
|
| 22 |
+
self.agents = agents
|
| 23 |
+
|
| 24 |
+
def orchestrate(self, query: str, agent_names: List[str], chat_history: List = None) -> str:
|
| 25 |
+
"""
|
| 26 |
+
Orchestrate multiple agents to answer a complex query
|
| 27 |
+
|
| 28 |
+
Args:
|
| 29 |
+
query: User query
|
| 30 |
+
agent_names: List of agents to use
|
| 31 |
+
chat_history: Conversation history
|
| 32 |
+
|
| 33 |
+
Returns:
|
| 34 |
+
Combined response from all agents
|
| 35 |
+
"""
|
| 36 |
+
if len(agent_names) == 1:
|
| 37 |
+
# Single agent - just call it
|
| 38 |
+
return self._call_single_agent(agent_names[0], query, chat_history)
|
| 39 |
+
|
| 40 |
+
# Multi-agent orchestration
|
| 41 |
+
return self._orchestrate_multi_agent(query, agent_names, chat_history)
|
| 42 |
+
|
| 43 |
+
def _call_single_agent(self, agent_name: str, query: str, chat_history: List) -> str:
|
| 44 |
+
"""Call a single agent"""
|
| 45 |
+
agent = self.agents.get(agent_name)
|
| 46 |
+
if not agent:
|
| 47 |
+
return f"Agent {agent_name} not found"
|
| 48 |
+
|
| 49 |
+
try:
|
| 50 |
+
response = agent.handle(
|
| 51 |
+
parameters={"user_query": query},
|
| 52 |
+
chat_history=chat_history
|
| 53 |
+
)
|
| 54 |
+
return response
|
| 55 |
+
except Exception as e:
|
| 56 |
+
return f"Error calling {agent_name}: {str(e)}"
|
| 57 |
+
|
| 58 |
+
def _orchestrate_multi_agent(self, query: str, agent_names: List[str], chat_history: List) -> str:
|
| 59 |
+
"""
|
| 60 |
+
Orchestrate multiple agents
|
| 61 |
+
|
| 62 |
+
Strategy:
|
| 63 |
+
1. Analyze query to determine what each agent should focus on
|
| 64 |
+
2. Call each agent with specific sub-query
|
| 65 |
+
3. Combine responses intelligently
|
| 66 |
+
"""
|
| 67 |
+
# Decompose query into sub-queries for each agent
|
| 68 |
+
sub_queries = self._decompose_query(query, agent_names)
|
| 69 |
+
|
| 70 |
+
# Call each agent with their sub-query
|
| 71 |
+
responses = {}
|
| 72 |
+
for agent_name, sub_query in sub_queries.items():
|
| 73 |
+
if agent_name in self.agents:
|
| 74 |
+
try:
|
| 75 |
+
response = self.agents[agent_name].handle(
|
| 76 |
+
parameters={"user_query": sub_query},
|
| 77 |
+
chat_history=chat_history
|
| 78 |
+
)
|
| 79 |
+
responses[agent_name] = response
|
| 80 |
+
except Exception as e:
|
| 81 |
+
responses[agent_name] = f"Error: {str(e)}"
|
| 82 |
+
|
| 83 |
+
# Combine responses
|
| 84 |
+
return self._combine_responses(query, responses, agent_names)
|
| 85 |
+
|
| 86 |
+
def _decompose_query(self, query: str, agent_names: List[str]) -> Dict[str, str]:
|
| 87 |
+
"""
|
| 88 |
+
Decompose complex query into sub-queries for each agent
|
| 89 |
+
|
| 90 |
+
Example:
|
| 91 |
+
Query: "Tôi muốn giảm cân, nên ăn gì và tập gì?"
|
| 92 |
+
→
|
| 93 |
+
nutrition_agent: "Tư vấn chế độ ăn để giảm cân"
|
| 94 |
+
exercise_agent: "Tư vấn lịch tập để giảm cân"
|
| 95 |
+
"""
|
| 96 |
+
sub_queries = {}
|
| 97 |
+
|
| 98 |
+
# Simple heuristic-based decomposition
|
| 99 |
+
query_lower = query.lower()
|
| 100 |
+
|
| 101 |
+
for agent_name in agent_names:
|
| 102 |
+
if agent_name == "nutrition_agent":
|
| 103 |
+
if "ăn" in query_lower or "dinh dưỡng" in query_lower or "calo" in query_lower:
|
| 104 |
+
sub_queries[agent_name] = f"Tư vấn dinh dưỡng cho: {query}"
|
| 105 |
+
else:
|
| 106 |
+
sub_queries[agent_name] = query
|
| 107 |
+
|
| 108 |
+
elif agent_name == "exercise_agent":
|
| 109 |
+
if "tập" in query_lower or "gym" in query_lower or "luyện" in query_lower:
|
| 110 |
+
sub_queries[agent_name] = f"Tư vấn tập luyện cho: {query}"
|
| 111 |
+
else:
|
| 112 |
+
sub_queries[agent_name] = query
|
| 113 |
+
|
| 114 |
+
elif agent_name == "mental_health_agent":
|
| 115 |
+
if "stress" in query_lower or "lo âu" in query_lower:
|
| 116 |
+
sub_queries[agent_name] = f"Tư vấn sức khỏe tinh thần cho: {query}"
|
| 117 |
+
else:
|
| 118 |
+
sub_queries[agent_name] = query
|
| 119 |
+
|
| 120 |
+
else:
|
| 121 |
+
# Default: use original query
|
| 122 |
+
sub_queries[agent_name] = query
|
| 123 |
+
|
| 124 |
+
return sub_queries
|
| 125 |
+
|
| 126 |
+
def _combine_responses(self, original_query: str, responses: Dict[str, str], agent_names: List[str]) -> str:
|
| 127 |
+
"""
|
| 128 |
+
Combine responses from multiple agents into a coherent answer
|
| 129 |
+
|
| 130 |
+
Strategy:
|
| 131 |
+
1. Identify the main topic
|
| 132 |
+
2. Structure response logically
|
| 133 |
+
3. Avoid redundancy
|
| 134 |
+
"""
|
| 135 |
+
if not responses:
|
| 136 |
+
return "Xin lỗi, không thể xử lý câu hỏi này."
|
| 137 |
+
|
| 138 |
+
# Build combined response
|
| 139 |
+
combined = []
|
| 140 |
+
|
| 141 |
+
# Add intro
|
| 142 |
+
if len(responses) > 1:
|
| 143 |
+
combined.append("Để giải đáp câu hỏi của bạn, tôi sẽ tư vấn từ nhiều góc độ:\n")
|
| 144 |
+
|
| 145 |
+
# Add each agent's response with clear sections
|
| 146 |
+
agent_labels = {
|
| 147 |
+
"nutrition_agent": "📊 Dinh Dưỡng",
|
| 148 |
+
"exercise_agent": "💪 Tập Luyện",
|
| 149 |
+
"mental_health_agent": "🧠 Sức Khỏe Tinh Thần",
|
| 150 |
+
"symptom_agent": "🩺 Đánh Giá Triệu Chứng",
|
| 151 |
+
"general_health_agent": "🏥 Tổng Quan"
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
for agent_name in agent_names:
|
| 155 |
+
if agent_name in responses:
|
| 156 |
+
label = agent_labels.get(agent_name, agent_name)
|
| 157 |
+
response = responses[agent_name]
|
| 158 |
+
|
| 159 |
+
# Clean up response (remove redundant intro)
|
| 160 |
+
response = self._clean_response(response)
|
| 161 |
+
|
| 162 |
+
combined.append(f"\n{label}:\n{response}\n")
|
| 163 |
+
|
| 164 |
+
# Add conclusion
|
| 165 |
+
if len(responses) > 1:
|
| 166 |
+
combined.append("\n---\n")
|
| 167 |
+
combined.append("💡 Lưu ý: Để đạt kết quả tốt nhất, hãy kết hợp cả dinh dưỡng, tập luyện và nghỉ ngơi hợp lý.")
|
| 168 |
+
|
| 169 |
+
return "".join(combined)
|
| 170 |
+
|
| 171 |
+
def _clean_response(self, response: str) -> str:
|
| 172 |
+
"""Clean up response by removing redundant intros"""
|
| 173 |
+
# Remove common intro phrases
|
| 174 |
+
intros_to_remove = [
|
| 175 |
+
"Chào bạn!",
|
| 176 |
+
"Xin chào!",
|
| 177 |
+
"Để giải đáp câu hỏi của bạn",
|
| 178 |
+
"Mình sẽ giúp bạn",
|
| 179 |
+
]
|
| 180 |
+
|
| 181 |
+
for intro in intros_to_remove:
|
| 182 |
+
if response.startswith(intro):
|
| 183 |
+
response = response[len(intro):].strip()
|
| 184 |
+
|
| 185 |
+
return response
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
# Example usage
|
| 189 |
+
if __name__ == "__main__":
|
| 190 |
+
# Mock agents for testing
|
| 191 |
+
class MockAgent:
|
| 192 |
+
def __init__(self, name):
|
| 193 |
+
self.name = name
|
| 194 |
+
|
| 195 |
+
def handle(self, parameters, chat_history=None):
|
| 196 |
+
query = parameters.get("user_query", "")
|
| 197 |
+
return f"[{self.name}] Response to: {query}"
|
| 198 |
+
|
| 199 |
+
agents = {
|
| 200 |
+
"nutrition_agent": MockAgent("Nutrition"),
|
| 201 |
+
"exercise_agent": MockAgent("Exercise"),
|
| 202 |
+
"mental_health_agent": MockAgent("Mental Health")
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
orchestrator = MultiAgentOrchestrator(agents)
|
| 206 |
+
|
| 207 |
+
# Test multi-agent
|
| 208 |
+
query = "Tôi muốn giảm cân, nên ăn gì và tập gì?"
|
| 209 |
+
agent_names = ["nutrition_agent", "exercise_agent"]
|
| 210 |
+
|
| 211 |
+
response = orchestrator.orchestrate(query, agent_names)
|
| 212 |
+
print(response)
|
agents/core/response_validator.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Response Validator - Ensures LLM responses follow quality standards
|
| 3 |
+
Shared validation logic for all agents
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from typing import Dict, List, Tuple
|
| 7 |
+
|
| 8 |
+
class ResponseValidator:
|
| 9 |
+
"""
|
| 10 |
+
Base validator with common rules + agent-specific rules
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
# Common bad phrases across all agents
|
| 14 |
+
COMMON_BAD_PHRASES = [
|
| 15 |
+
"Dựa trên thông tin bạn cung cấp",
|
| 16 |
+
"Dựa vào thông tin",
|
| 17 |
+
"Theo thông tin bạn đưa ra",
|
| 18 |
+
"Từ thông tin trên",
|
| 19 |
+
"Với tư cách",
|
| 20 |
+
"Tôi là chuyên gia"
|
| 21 |
+
]
|
| 22 |
+
|
| 23 |
+
@staticmethod
|
| 24 |
+
def validate_common(response: str) -> Tuple[bool, List[str]]:
|
| 25 |
+
"""
|
| 26 |
+
Common validation rules for all agents
|
| 27 |
+
Returns: (is_valid, list_of_issues)
|
| 28 |
+
"""
|
| 29 |
+
issues = []
|
| 30 |
+
|
| 31 |
+
# Check for formal phrases
|
| 32 |
+
for phrase in ResponseValidator.COMMON_BAD_PHRASES:
|
| 33 |
+
if phrase.lower() in response.lower():
|
| 34 |
+
issues.append(f"Formal phrase: '{phrase}'")
|
| 35 |
+
break
|
| 36 |
+
|
| 37 |
+
# Check for excessive length (>500 words)
|
| 38 |
+
word_count = len(response.split())
|
| 39 |
+
if word_count > 500:
|
| 40 |
+
issues.append(f"Too long: {word_count} words (max 500)")
|
| 41 |
+
|
| 42 |
+
# Check for empty response
|
| 43 |
+
if len(response.strip()) < 10:
|
| 44 |
+
issues.append("Response too short or empty")
|
| 45 |
+
|
| 46 |
+
return len(issues) == 0, issues
|
| 47 |
+
|
| 48 |
+
@staticmethod
|
| 49 |
+
def validate_symptom_response(response: str, context: Dict) -> Tuple[bool, List[str]]:
|
| 50 |
+
"""
|
| 51 |
+
Symptom-specific validation
|
| 52 |
+
"""
|
| 53 |
+
issues = []
|
| 54 |
+
stage = context.get('conversation_stage', 0)
|
| 55 |
+
|
| 56 |
+
# Assessment phase: should ask, not advise
|
| 57 |
+
if stage <= 1:
|
| 58 |
+
advice_indicators = [
|
| 59 |
+
"khuyến nghị", "nên", "hãy", "bạn thử",
|
| 60 |
+
"giải pháp", "cách xử lý"
|
| 61 |
+
]
|
| 62 |
+
has_advice = any(ind in response.lower() for ind in advice_indicators)
|
| 63 |
+
has_question = '?' in response
|
| 64 |
+
|
| 65 |
+
if has_advice and not has_question:
|
| 66 |
+
issues.append("Đưa lời khuyên quá sớm (assessment phase)")
|
| 67 |
+
|
| 68 |
+
# Check if both asking and advising
|
| 69 |
+
if '?' in response:
|
| 70 |
+
advice_count = sum(1 for ind in ["khuyến nghị", "nên", "hãy thử"]
|
| 71 |
+
if ind in response.lower())
|
| 72 |
+
if advice_count >= 2:
|
| 73 |
+
issues.append("Vừa hỏi vừa khuyên")
|
| 74 |
+
|
| 75 |
+
return len(issues) == 0, issues
|
| 76 |
+
|
| 77 |
+
@staticmethod
|
| 78 |
+
def validate_nutrition_response(response: str, context: Dict, chat_history: List) -> Tuple[bool, List[str]]:
|
| 79 |
+
"""
|
| 80 |
+
Nutrition-specific validation
|
| 81 |
+
"""
|
| 82 |
+
issues = []
|
| 83 |
+
|
| 84 |
+
# Check if asking for info already provided
|
| 85 |
+
if chat_history:
|
| 86 |
+
all_user_text = " ".join([msg[0].lower() for msg in chat_history if msg[0]])
|
| 87 |
+
|
| 88 |
+
# Check if asking for age when already provided
|
| 89 |
+
if "tuổi" in all_user_text or "năm" in all_user_text:
|
| 90 |
+
if "bao nhiêu tuổi" in response.lower() or "tuổi của bạn" in response.lower():
|
| 91 |
+
issues.append("Hỏi lại tuổi đã được cung cấp")
|
| 92 |
+
|
| 93 |
+
# Check if asking for weight when already provided
|
| 94 |
+
if "kg" in all_user_text or "cân nặng" in all_user_text:
|
| 95 |
+
if "cân nặng" in response.lower() and "?" in response:
|
| 96 |
+
issues.append("Hỏi lại cân nặng đã được cung cấp")
|
| 97 |
+
|
| 98 |
+
# Check for too theoretical (should be practical)
|
| 99 |
+
theory_indicators = ["lý thuyết", "nghiên cứu cho thấy", "theo khoa học"]
|
| 100 |
+
if any(ind in response.lower() for ind in theory_indicators):
|
| 101 |
+
practical_indicators = ["bạn thử", "có thể", "ví dụ", "thực đơn"]
|
| 102 |
+
if not any(ind in response.lower() for ind in practical_indicators):
|
| 103 |
+
issues.append("Quá lý thuyết, thiếu practical advice")
|
| 104 |
+
|
| 105 |
+
return len(issues) == 0, issues
|
| 106 |
+
|
| 107 |
+
@staticmethod
|
| 108 |
+
def validate_exercise_response(response: str, context: Dict) -> Tuple[bool, List[str]]:
|
| 109 |
+
"""
|
| 110 |
+
Exercise-specific validation
|
| 111 |
+
"""
|
| 112 |
+
issues = []
|
| 113 |
+
|
| 114 |
+
# Check if workout plan is too generic
|
| 115 |
+
if "lịch tập" in response.lower() or "kế hoạch" in response.lower():
|
| 116 |
+
# Should have specific days or progression
|
| 117 |
+
has_specifics = any(word in response.lower() for word in [
|
| 118 |
+
"thứ", "ngày", "tuần 1", "tuần 2", "tháng"
|
| 119 |
+
])
|
| 120 |
+
if not has_specifics:
|
| 121 |
+
issues.append("Lịch tập quá generic, thiếu chi tiết")
|
| 122 |
+
|
| 123 |
+
# Check for progression
|
| 124 |
+
if "tập" in response.lower():
|
| 125 |
+
has_progression = any(word in response.lower() for word in [
|
| 126 |
+
"tăng dần", "progression", "tuần 1", "giai đoạn"
|
| 127 |
+
])
|
| 128 |
+
if not has_progression and len(response) > 200:
|
| 129 |
+
issues.append("Thiếu hướng dẫn progression")
|
| 130 |
+
|
| 131 |
+
return len(issues) == 0, issues
|
| 132 |
+
|
| 133 |
+
@staticmethod
|
| 134 |
+
def validate_mental_health_response(response: str, context: Dict) -> Tuple[bool, List[str]]:
|
| 135 |
+
"""
|
| 136 |
+
Mental health-specific validation
|
| 137 |
+
"""
|
| 138 |
+
issues = []
|
| 139 |
+
|
| 140 |
+
# Should have empathy/validation
|
| 141 |
+
empathy_indicators = [
|
| 142 |
+
"cảm giác", "hiểu", "bình thường", "nhiều người",
|
| 143 |
+
"không phải lỗi của bạn"
|
| 144 |
+
]
|
| 145 |
+
|
| 146 |
+
if len(response) > 100: # Only check for longer responses
|
| 147 |
+
has_empathy = any(ind in response.lower() for ind in empathy_indicators)
|
| 148 |
+
if not has_empathy:
|
| 149 |
+
issues.append("Thiếu empathy/validation")
|
| 150 |
+
|
| 151 |
+
# Check for too clinical
|
| 152 |
+
clinical_indicators = ["chẩn đoán", "bệnh", "rối loạn"]
|
| 153 |
+
if any(ind in response.lower() for ind in clinical_indicators):
|
| 154 |
+
if "không phải bác sĩ" not in response.lower():
|
| 155 |
+
issues.append("Quá clinical, cần disclaimer")
|
| 156 |
+
|
| 157 |
+
return len(issues) == 0, issues
|
| 158 |
+
|
| 159 |
+
@staticmethod
|
| 160 |
+
def validate_response(response: str, agent_type: str, context: Dict, chat_history: List = None) -> Tuple[bool, List[str]]:
|
| 161 |
+
"""
|
| 162 |
+
Main validation method - routes to appropriate validator
|
| 163 |
+
"""
|
| 164 |
+
# Common validation first
|
| 165 |
+
is_valid_common, common_issues = ResponseValidator.validate_common(response)
|
| 166 |
+
|
| 167 |
+
# Agent-specific validation
|
| 168 |
+
agent_issues = []
|
| 169 |
+
if agent_type == 'symptom':
|
| 170 |
+
_, agent_issues = ResponseValidator.validate_symptom_response(response, context)
|
| 171 |
+
elif agent_type == 'nutrition':
|
| 172 |
+
_, agent_issues = ResponseValidator.validate_nutrition_response(response, context, chat_history or [])
|
| 173 |
+
elif agent_type == 'exercise':
|
| 174 |
+
_, agent_issues = ResponseValidator.validate_exercise_response(response, context)
|
| 175 |
+
elif agent_type == 'mental_health':
|
| 176 |
+
_, agent_issues = ResponseValidator.validate_mental_health_response(response, context)
|
| 177 |
+
|
| 178 |
+
# Combine all issues
|
| 179 |
+
all_issues = common_issues + agent_issues
|
| 180 |
+
is_valid = len(all_issues) == 0
|
| 181 |
+
|
| 182 |
+
return is_valid, all_issues
|
agents/core/router.py
ADDED
|
@@ -0,0 +1,657 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Agent Router - Routes user requests to appropriate specialized agents
|
| 3 |
+
|
| 4 |
+
Supports two routing strategies:
|
| 5 |
+
1. Embedding-based routing (primary) - Automatic, scalable
|
| 6 |
+
2. LLM-based routing (fallback) - Manual, explicit
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from config.settings import client, MODEL
|
| 10 |
+
from typing import List, Dict, Tuple, Optional
|
| 11 |
+
import numpy as np
|
| 12 |
+
|
| 13 |
+
# Try to import embedding model (optional)
|
| 14 |
+
try:
|
| 15 |
+
from sentence_transformers import SentenceTransformer
|
| 16 |
+
from sklearn.metrics.pairwise import cosine_similarity
|
| 17 |
+
EMBEDDINGS_AVAILABLE = True
|
| 18 |
+
except ImportError:
|
| 19 |
+
EMBEDDINGS_AVAILABLE = False
|
| 20 |
+
print("[WARNING] sentence-transformers not installed. Using LLM-based routing only.")
|
| 21 |
+
print("Install with: pip install sentence-transformers scikit-learn")
|
| 22 |
+
|
| 23 |
+
# Define available functions/agents
|
| 24 |
+
AVAILABLE_FUNCTIONS = [
|
| 25 |
+
{
|
| 26 |
+
"name": "nutrition_agent",
|
| 27 |
+
"description": """Tư vấn dinh dưỡng và chế độ ăn uống:
|
| 28 |
+
- Tính BMI, calo, macro (protein/carb/fat)
|
| 29 |
+
- Lập thực đơn, meal plan
|
| 30 |
+
- Tư vấn thực phẩm nên ăn/tránh
|
| 31 |
+
- Giảm cân, tăng cân, tăng cơ
|
| 32 |
+
- Bổ sung dinh dưỡng, vitamin
|
| 33 |
+
|
| 34 |
+
KHÔNG dùng cho: triệu chứng bệnh (đau bụng, buồn nôn, tiêu chảy)
|
| 35 |
+
→ Triệu chứng bệnh → dùng symptom_agent""",
|
| 36 |
+
"parameters": {
|
| 37 |
+
"type": "object",
|
| 38 |
+
"properties": {
|
| 39 |
+
"user_query": {
|
| 40 |
+
"type": "string",
|
| 41 |
+
"description": "Câu hỏi của người dùng về dinh dưỡng"
|
| 42 |
+
},
|
| 43 |
+
"user_data": {
|
| 44 |
+
"type": "object",
|
| 45 |
+
"description": "Thông tin người dùng (tuổi, giới tính, cân nặng, chiều cao, mục tiêu)",
|
| 46 |
+
"properties": {
|
| 47 |
+
"age": {"type": "integer"},
|
| 48 |
+
"gender": {"type": "string"},
|
| 49 |
+
"weight": {"type": "number"},
|
| 50 |
+
"height": {"type": "number"},
|
| 51 |
+
"goal": {"type": "string"}
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
},
|
| 55 |
+
"required": ["user_query"]
|
| 56 |
+
}
|
| 57 |
+
},
|
| 58 |
+
{
|
| 59 |
+
"name": "exercise_agent",
|
| 60 |
+
"description": "Tư vấn tập luyện, lịch tập gym, bài tập thể dục, kế hoạch tập luyện, yoga, cardio",
|
| 61 |
+
"parameters": {
|
| 62 |
+
"type": "object",
|
| 63 |
+
"properties": {
|
| 64 |
+
"user_query": {
|
| 65 |
+
"type": "string",
|
| 66 |
+
"description": "Câu hỏi của người dùng về tập luyện"
|
| 67 |
+
},
|
| 68 |
+
"user_data": {
|
| 69 |
+
"type": "object",
|
| 70 |
+
"description": "Thông tin người dùng (tuổi, giới tính, thể lực, mục tiêu, thời gian)",
|
| 71 |
+
"properties": {
|
| 72 |
+
"age": {"type": "integer"},
|
| 73 |
+
"gender": {"type": "string"},
|
| 74 |
+
"fitness_level": {"type": "string"},
|
| 75 |
+
"goal": {"type": "string"},
|
| 76 |
+
"available_time": {"type": "integer"}
|
| 77 |
+
}
|
| 78 |
+
}
|
| 79 |
+
},
|
| 80 |
+
"required": ["user_query"]
|
| 81 |
+
}
|
| 82 |
+
},
|
| 83 |
+
{
|
| 84 |
+
"name": "symptom_agent",
|
| 85 |
+
"description": """CLINICAL SYMPTOM ASSESSMENT - Đánh giá triệu chứng bệnh CỤ THỂ:
|
| 86 |
+
|
| 87 |
+
✅ USE FOR (Specific symptoms):
|
| 88 |
+
- Pain: đau đầu, đau bụng, đau lưng, đau ngực, đau khớp
|
| 89 |
+
- Fever/Infection: sốt, ho, cảm cúm, viêm họng, viêm phổi
|
| 90 |
+
- Digestive: buồn nôn, nôn, tiêu chảy, táo bón, đầy hơi
|
| 91 |
+
- Neurological: chóng mặt, đau nửa đầu, mất thăng bằng
|
| 92 |
+
- Acute symptoms: triệu chứng đột ngột, bất thường, nghiêm trọng
|
| 93 |
+
|
| 94 |
+
✅ WHEN TO USE:
|
| 95 |
+
- User describes SPECIFIC symptom: "Tôi bị đau bụng"
|
| 96 |
+
- User feels sick/unwell: "Tôi không khỏe", "Tôi bị ốm"
|
| 97 |
+
- Medical concern: "Tôi sợ bị bệnh X"
|
| 98 |
+
|
| 99 |
+
❌ DO NOT USE FOR:
|
| 100 |
+
- General wellness: "Làm sao để khỏe?" → general_health_agent
|
| 101 |
+
- Prevention: "Phòng ngừa bệnh" → general_health_agent
|
| 102 |
+
- Lifestyle: "Sống khỏe mạnh" → general_health_agent
|
| 103 |
+
- Nutrition: "Nên ăn gì?" → nutrition_agent
|
| 104 |
+
- Exercise: "Tập gì?" → exercise_agent""",
|
| 105 |
+
"parameters": {
|
| 106 |
+
"type": "object",
|
| 107 |
+
"properties": {
|
| 108 |
+
"user_query": {
|
| 109 |
+
"type": "string",
|
| 110 |
+
"description": "Mô tả triệu chứng của người dùng"
|
| 111 |
+
},
|
| 112 |
+
"symptom_data": {
|
| 113 |
+
"type": "object",
|
| 114 |
+
"description": "Thông tin triệu chứng (onset, location, severity, duration)",
|
| 115 |
+
"properties": {
|
| 116 |
+
"symptom_type": {"type": "string"},
|
| 117 |
+
"duration": {"type": "string"},
|
| 118 |
+
"severity": {"type": "integer"},
|
| 119 |
+
"location": {"type": "string"}
|
| 120 |
+
}
|
| 121 |
+
}
|
| 122 |
+
},
|
| 123 |
+
"required": ["user_query"]
|
| 124 |
+
}
|
| 125 |
+
},
|
| 126 |
+
{
|
| 127 |
+
"name": "mental_health_agent",
|
| 128 |
+
"description": "Tư vấn sức khỏe tinh thần, stress, lo âu, trầm cảm, burnout, giấc ngủ, cảm xúc",
|
| 129 |
+
"parameters": {
|
| 130 |
+
"type": "object",
|
| 131 |
+
"properties": {
|
| 132 |
+
"user_query": {
|
| 133 |
+
"type": "string",
|
| 134 |
+
"description": "Câu hỏi về sức khỏe tinh thần"
|
| 135 |
+
},
|
| 136 |
+
"context": {
|
| 137 |
+
"type": "object",
|
| 138 |
+
"description": "Ngữ cảnh (công việc, gia đình, stress level)",
|
| 139 |
+
"properties": {
|
| 140 |
+
"stress_level": {"type": "string"},
|
| 141 |
+
"duration": {"type": "string"},
|
| 142 |
+
"triggers": {"type": "array", "items": {"type": "string"}}
|
| 143 |
+
}
|
| 144 |
+
}
|
| 145 |
+
},
|
| 146 |
+
"required": ["user_query"]
|
| 147 |
+
}
|
| 148 |
+
},
|
| 149 |
+
{
|
| 150 |
+
"name": "general_health_agent",
|
| 151 |
+
"description": """GENERAL WELLNESS & LIFESTYLE - Tư vấn sức khỏe TỔNG QUÁT:
|
| 152 |
+
|
| 153 |
+
✅ USE FOR (General health & wellness):
|
| 154 |
+
- Wellness: "Làm sao để khỏe mạnh?", "Sống khỏe"
|
| 155 |
+
- Prevention: "Phòng ngừa bệnh", "Tăng sức đề kháng"
|
| 156 |
+
- Lifestyle: "Lối sống lành mạnh", "Thói quen tốt"
|
| 157 |
+
- General advice: "Tư vấn sức khỏe", "Chăm sóc sức khỏe"
|
| 158 |
+
- Health education: "Kiến thức sức khỏe", "Hiểu về cơ thể"
|
| 159 |
+
- Check-ups: "Khám sức khỏe định kỳ", "Xét nghiệm gì?"
|
| 160 |
+
|
| 161 |
+
✅ WHEN TO USE:
|
| 162 |
+
- Broad health questions: "Tôi muốn khỏe hơn"
|
| 163 |
+
- No specific symptom: "Tư vấn sức khỏe tổng quát"
|
| 164 |
+
- Prevention focus: "Làm gì để không bị ốm?"
|
| 165 |
+
- Lifestyle optimization: "Cải thiện sức khỏe"
|
| 166 |
+
|
| 167 |
+
❌ DO NOT USE FOR:
|
| 168 |
+
- Specific symptoms: "Tôi bị đau bụng" → symptom_agent
|
| 169 |
+
- Nutrition details: "Lập thực đơn" → nutrition_agent
|
| 170 |
+
- Exercise plans: "Lịch tập gym" → exercise_agent
|
| 171 |
+
- Mental health: "Stress, lo âu" → mental_health_agent""",
|
| 172 |
+
"parameters": {
|
| 173 |
+
"type": "object",
|
| 174 |
+
"properties": {
|
| 175 |
+
"user_query": {
|
| 176 |
+
"type": "string",
|
| 177 |
+
"description": "Câu hỏi chung về sức khỏe"
|
| 178 |
+
}
|
| 179 |
+
},
|
| 180 |
+
"required": ["user_query"]
|
| 181 |
+
}
|
| 182 |
+
}
|
| 183 |
+
]
|
| 184 |
+
|
| 185 |
+
def route_to_agent(message, chat_history=None):
|
| 186 |
+
"""
|
| 187 |
+
Route user message to appropriate specialized agent using function calling
|
| 188 |
+
|
| 189 |
+
Args:
|
| 190 |
+
message (str): User's message
|
| 191 |
+
chat_history (list): Conversation history for context
|
| 192 |
+
|
| 193 |
+
Returns:
|
| 194 |
+
dict: {
|
| 195 |
+
"agent": str, # Agent name
|
| 196 |
+
"parameters": dict, # Extracted parameters
|
| 197 |
+
"confidence": float # Routing confidence (0-1)
|
| 198 |
+
}
|
| 199 |
+
"""
|
| 200 |
+
|
| 201 |
+
# Build context from chat history (increased from 3 to 10 for better context)
|
| 202 |
+
context = ""
|
| 203 |
+
last_agent = None
|
| 204 |
+
|
| 205 |
+
if chat_history:
|
| 206 |
+
recent_messages = chat_history[-10:] # Last 10 exchanges (was 3)
|
| 207 |
+
|
| 208 |
+
# Extract last agent from bot response
|
| 209 |
+
if recent_messages:
|
| 210 |
+
last_bot_msg = recent_messages[-1][1] if len(recent_messages[-1]) > 1 else ""
|
| 211 |
+
# Try to detect agent from debug info
|
| 212 |
+
if "Agent used:" in last_bot_msg:
|
| 213 |
+
import re
|
| 214 |
+
match = re.search(r'Agent used: `(\w+)`', last_bot_msg)
|
| 215 |
+
if match:
|
| 216 |
+
last_agent = match.group(1)
|
| 217 |
+
|
| 218 |
+
# Build context with turn numbers for clarity
|
| 219 |
+
context_lines = []
|
| 220 |
+
for i, (user_msg, bot_msg) in enumerate(recent_messages, 1):
|
| 221 |
+
# Truncate long messages
|
| 222 |
+
user_short = user_msg[:80] + "..." if len(user_msg) > 80 else user_msg
|
| 223 |
+
bot_short = bot_msg[:80] + "..." if len(bot_msg) > 80 else bot_msg
|
| 224 |
+
context_lines.append(f"Turn {i}:\n User: {user_short}\n Bot: {bot_short}")
|
| 225 |
+
|
| 226 |
+
context = "\n".join(context_lines)
|
| 227 |
+
|
| 228 |
+
# Create enhanced routing prompt with context awareness
|
| 229 |
+
routing_prompt = f"""Phân tích câu hỏi của người dùng và xác định agent phù hợp nhất.
|
| 230 |
+
|
| 231 |
+
LỊCH SỬ HỘI THOẠI (10 exchanges gần nhất):
|
| 232 |
+
{context if context else "Đây là câu hỏi đầu tiên"}
|
| 233 |
+
|
| 234 |
+
AGENT TRƯỚC ĐÓ: {last_agent if last_agent else "Chưa có"}
|
| 235 |
+
|
| 236 |
+
CÂU HỎI HIỆN TẠI: {message}
|
| 237 |
+
|
| 238 |
+
HƯỚNG DẪN QUAN TRỌNG:
|
| 239 |
+
|
| 240 |
+
1. **TRIỆU CHỨNG BỆNH CỤ THỂ → symptom_agent (ưu tiên cao nhất)**
|
| 241 |
+
- User MÔ TẢ triệu chứng CỤ THỂ: "tôi bị đau...", "tôi bị sốt", "buồn nôn"
|
| 242 |
+
- Ví dụ: "đau bụng", "đau đầu", "sốt cao", "ho ra máu", "chóng mặt"
|
| 243 |
+
- LUÔN ưu tiên symptom_agent khi có triệu chứng CỤ THỂ!
|
| 244 |
+
|
| 245 |
+
⚠️ EDGE CASES - KHÔNG PHẢI symptom_agent:
|
| 246 |
+
- "Làm sao để KHÔNG bị đau đầu?" → general_health_agent (phòng ngừa)
|
| 247 |
+
- "Ăn gì để hết đau bụng?" → nutrition_agent (dinh dưỡng)
|
| 248 |
+
- "Tập gì để hết đau lưng?" → exercise_agent (tập luyện)
|
| 249 |
+
- "Làm sao để khỏe?" → general_health_agent (tổng quát)
|
| 250 |
+
|
| 251 |
+
2. **DINH DƯỠNG → nutrition_agent**
|
| 252 |
+
- Hỏi về thực phẩm, chế độ ăn, calo, BMI, thực đơn
|
| 253 |
+
- KHÔNG phải triệu chứng bệnh
|
| 254 |
+
- Ví dụ: "nên ăn gì", "giảm cân", "thực đơn"
|
| 255 |
+
|
| 256 |
+
3. **TẬP LUYỆN → exercise_agent**
|
| 257 |
+
- Hỏi về bài tập, lịch tập, gym, cardio, dụng cụ tập
|
| 258 |
+
- Follow-up về giáo án tập: "không có dụng cụ", "tập tại nhà", "không có tạ"
|
| 259 |
+
- Ví dụ: "nên tập gì", "lịch tập gym", "không có dụng cụ gym"
|
| 260 |
+
- **QUAN TRỌNG:** Nếu đang nói về tập luyện → TIẾP TỤC exercise_agent
|
| 261 |
+
|
| 262 |
+
4. **SỨC KHỎE TINH THẦN → mental_health_agent**
|
| 263 |
+
- Stress, lo âu, trầm cảm, burnout, giấc ngủ
|
| 264 |
+
- Ví dụ: "tôi stress", "lo âu", "mất ngủ"
|
| 265 |
+
|
| 266 |
+
5. **SỨC KHỎE TỔNG QUÁT → general_health_agent**
|
| 267 |
+
- Câu hỏi CHUNG về sức khỏe, wellness, lifestyle
|
| 268 |
+
- Phòng ngừa, tăng cường sức khỏe
|
| 269 |
+
- Ví dụ: "làm sao để khỏe?", "phòng bệnh", "sống khỏe"
|
| 270 |
+
|
| 271 |
+
⚠️ EDGE CASES - Phân biệt với symptom_agent:
|
| 272 |
+
- "Tôi BỊ đau bụng" → symptom_agent (có triệu chứng)
|
| 273 |
+
- "Làm sao để KHÔNG bị đau bụng?" → general_health_agent (phòng ngừa)
|
| 274 |
+
- "Tôi không khỏe" (mơ hồ) → general_health_agent (chung chung)
|
| 275 |
+
- "Tôi bị sốt cao" → symptom_agent (triệu chứng cụ thể)
|
| 276 |
+
|
| 277 |
+
VÍ DỤ ROUTING (Bao gồm edge cases):
|
| 278 |
+
|
| 279 |
+
**Symptom Agent (có triệu chứng CỤ THỂ):**
|
| 280 |
+
✅ "Tôi bị đau bụng" → symptom_agent
|
| 281 |
+
✅ "Đau đầu từ sáng" → symptom_agent
|
| 282 |
+
✅ "Buồn nôn, muốn làm sao cho hết" → symptom_agent
|
| 283 |
+
✅ "Tôi bị sốt cao 39 độ" → symptom_agent
|
| 284 |
+
|
| 285 |
+
**General Health Agent (phòng ngừa, tổng quát):**
|
| 286 |
+
✅ "Làm sao để khỏe mạnh?" → general_health_agent
|
| 287 |
+
✅ "Phòng ngừa đau đầu" → general_health_agent (phòng ngừa!)
|
| 288 |
+
✅ "Tôi muốn sống khỏe hơn" → general_health_agent
|
| 289 |
+
✅ "Tư vấn sức khỏe tổng quát" → general_health_agent
|
| 290 |
+
|
| 291 |
+
**Nutrition Agent (dinh dưỡng):**
|
| 292 |
+
✅ "Tôi muốn giảm cân" → nutrition_agent
|
| 293 |
+
✅ "Nên ăn gì để khỏe?" → nutrition_agent
|
| 294 |
+
✅ "Ăn gì để hết đau bụng?" → nutrition_agent (dinh dưỡng!)
|
| 295 |
+
|
| 296 |
+
**Exercise Agent (tập luyện):**
|
| 297 |
+
✅ "Tôi nên tập gì?" → exercise_agent
|
| 298 |
+
✅ "Tập gì để hết đau lưng?" → exercise_agent (tập luyện!)
|
| 299 |
+
✅ "Không có dụng cụ gym thì sao?" (context: tập) → exercise_agent
|
| 300 |
+
|
| 301 |
+
**Mental Health Agent:**
|
| 302 |
+
✅ "Tôi stress quá" → mental_health_agent
|
| 303 |
+
|
| 304 |
+
**QUAN TRỌNG - CONTEXT AWARENESS:**
|
| 305 |
+
- Nếu last_agent = "exercise_agent" và câu hỏi về "dụng cụ", "tạ", "gym", "tại nhà"
|
| 306 |
+
→ TIẾP TỤC exercise_agent (đây là follow-up!)
|
| 307 |
+
- Nếu last_agent = "nutrition_agent" và câu hỏi về "món ăn", "thực đơn", "calo"
|
| 308 |
+
→ TIẾP TỤC nutrition_agent (đây là follow-up!)
|
| 309 |
+
|
| 310 |
+
Hãy chọn agent phù hợp nhất dựa trên CẢ câu hỏi hiện tại VÀ ngữ cảnh hội thoại."""
|
| 311 |
+
|
| 312 |
+
try:
|
| 313 |
+
response = client.chat.completions.create(
|
| 314 |
+
model=MODEL,
|
| 315 |
+
messages=[
|
| 316 |
+
{
|
| 317 |
+
"role": "system",
|
| 318 |
+
"content": """Bạn là hệ thống định tuyến thông minh với khả năng HIỂU NGỮ CẢNH hội thoại.
|
| 319 |
+
|
| 320 |
+
NHIỆM VỤ: Phân tích câu hỏi trong NGỮCẢNH cuộc hội thoại và chọn agent phù hợp.
|
| 321 |
+
|
| 322 |
+
KỸ NĂNG QUAN TRỌNG:
|
| 323 |
+
1. Nhận biết câu hỏi follow-up (vậy, còn, thì sao, nữa)
|
| 324 |
+
2. Hiểu context từ lịch sử hội thoại
|
| 325 |
+
3. Phát hiện topic switching (chuyển đề rõ ràng)
|
| 326 |
+
4. Xử lý câu hỏi mơ hồ bằng cách xem context
|
| 327 |
+
|
| 328 |
+
NGUYÊN TẮC:
|
| 329 |
+
- Câu hỏi RÕ RÀNG → agent trực tiếp
|
| 330 |
+
- Câu hỏi MƠ HỒ → xem lịch sử, last agent
|
| 331 |
+
- Follow-up question → có thể tiếp tục agent cũ
|
| 332 |
+
- Topic switch rõ ràng → agent mới"""
|
| 333 |
+
},
|
| 334 |
+
{
|
| 335 |
+
"role": "user",
|
| 336 |
+
"content": routing_prompt
|
| 337 |
+
}
|
| 338 |
+
],
|
| 339 |
+
functions=AVAILABLE_FUNCTIONS,
|
| 340 |
+
function_call="auto",
|
| 341 |
+
temperature=0.3 # Lower temperature for more consistent routing
|
| 342 |
+
)
|
| 343 |
+
|
| 344 |
+
# Check if function was called
|
| 345 |
+
if response.choices[0].message.function_call:
|
| 346 |
+
function_call = response.choices[0].message.function_call
|
| 347 |
+
|
| 348 |
+
import json
|
| 349 |
+
parameters = json.loads(function_call.arguments)
|
| 350 |
+
|
| 351 |
+
return {
|
| 352 |
+
"agent": function_call.name,
|
| 353 |
+
"parameters": parameters,
|
| 354 |
+
"confidence": 0.9, # High confidence when function is called
|
| 355 |
+
"raw_response": response
|
| 356 |
+
}
|
| 357 |
+
else:
|
| 358 |
+
# No function called, default to general health agent
|
| 359 |
+
return {
|
| 360 |
+
"agent": "general_health_agent",
|
| 361 |
+
"parameters": {"user_query": message},
|
| 362 |
+
"confidence": 0.5,
|
| 363 |
+
"raw_response": response
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
except Exception as e:
|
| 367 |
+
print(f"Routing error: {e}")
|
| 368 |
+
# Fallback to general health agent
|
| 369 |
+
return {
|
| 370 |
+
"agent": "general_health_agent",
|
| 371 |
+
"parameters": {"user_query": message},
|
| 372 |
+
"confidence": 0.3,
|
| 373 |
+
"error": str(e)
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
def get_agent_description(agent_name):
|
| 377 |
+
"""Get description of an agent"""
|
| 378 |
+
for func in AVAILABLE_FUNCTIONS:
|
| 379 |
+
if func["name"] == agent_name:
|
| 380 |
+
return func["description"]
|
| 381 |
+
return "Unknown agent"
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
# ============================================================
|
| 385 |
+
# Embedding-Based Router (New, Scalable Approach)
|
| 386 |
+
# ============================================================
|
| 387 |
+
|
| 388 |
+
class EmbeddingRouter:
|
| 389 |
+
"""
|
| 390 |
+
Embedding-based router that automatically matches queries to agents
|
| 391 |
+
without manual rules. More scalable than LLM-based routing.
|
| 392 |
+
"""
|
| 393 |
+
|
| 394 |
+
def __init__(self, use_embeddings=True):
|
| 395 |
+
"""
|
| 396 |
+
Initialize router
|
| 397 |
+
|
| 398 |
+
Args:
|
| 399 |
+
use_embeddings: If False, falls back to LLM-based routing
|
| 400 |
+
"""
|
| 401 |
+
self.use_embeddings = use_embeddings and EMBEDDINGS_AVAILABLE
|
| 402 |
+
|
| 403 |
+
if self.use_embeddings:
|
| 404 |
+
# Load embedding model
|
| 405 |
+
self.embedder = SentenceTransformer('sentence-transformers/all-MiniLM-L6-v2')
|
| 406 |
+
|
| 407 |
+
# Agent descriptions for embedding matching
|
| 408 |
+
self.agent_descriptions = {
|
| 409 |
+
"symptom_agent": """
|
| 410 |
+
Đánh giá triệu chứng bệnh khi BỊ ĐAU hoặc KHÔNG KHỎE:
|
| 411 |
+
đau đầu, đau bụng, đau lưng, sốt, ho, buồn nôn, chóng mặt,
|
| 412 |
+
mệt mỏi bất thường, khó thở, đau ngực, bị bệnh, cảm thấy đau,
|
| 413 |
+
đau nhức, triệu chứng bệnh lý, không khỏe, ốm, bệnh,
|
| 414 |
+
đang bị gì, bị gì vậy, triệu chứng gì
|
| 415 |
+
""",
|
| 416 |
+
"nutrition_agent": """
|
| 417 |
+
Tư vấn dinh dưỡng, ăn uống healthy, chế độ ăn:
|
| 418 |
+
giảm cân, tăng cân, giảm mỡ, muốn gầy, muốn béo,
|
| 419 |
+
ăn gì để giảm cân, ăn gì để tăng cân, calo, BMI,
|
| 420 |
+
thực đơn, chế độ ăn kiêng, thực phẩm, protein, carb, fat,
|
| 421 |
+
vitamin, khoáng chất, dinh dưỡng lành mạnh, ăn uống khoa học,
|
| 422 |
+
setup plan ăn uống, kế hoạch dinh dưỡng, healthy eating,
|
| 423 |
+
ăn healthy, ăn sạch, clean eating
|
| 424 |
+
""",
|
| 425 |
+
"exercise_agent": """
|
| 426 |
+
Tập luyện, gym, workout, fitness, thể hình:
|
| 427 |
+
tập luyện, luyện tập, gym, cardio, bài tập, lịch tập,
|
| 428 |
+
dụng cụ tập, tạ, thanh đòn, tập tại nhà, không có dụng cụ,
|
| 429 |
+
squat, push-up, plank, chạy bộ, yoga, thể dục, thể hình,
|
| 430 |
+
rèn luyện cơ thể, tăng cơ, giảm mỡ, build muscle, lose fat,
|
| 431 |
+
setup plan tập luyện, kế hoạch tập luyện, lịch tập 7 ngày,
|
| 432 |
+
tập gym, tập thể hình, workout plan, fitness plan
|
| 433 |
+
""",
|
| 434 |
+
"mental_health_agent": """
|
| 435 |
+
Sức khỏe tinh thần, stress, lo âu, trầm cảm, burnout,
|
| 436 |
+
mất ngủ, giấc ngủ, căng thẳng, áp lực, tâm lý, cảm xúc,
|
| 437 |
+
buồn bã, mệt mỏi tinh thần
|
| 438 |
+
""",
|
| 439 |
+
"general_health_agent": """
|
| 440 |
+
Câu hỏi chung về sức khỏe, lời khuyên sức khỏe,
|
| 441 |
+
phòng bệnh, chăm sóc sức khỏe, kiểm tra sức khỏe
|
| 442 |
+
"""
|
| 443 |
+
}
|
| 444 |
+
|
| 445 |
+
# Pre-compute agent embeddings
|
| 446 |
+
print("[INFO] Pre-computing agent embeddings...")
|
| 447 |
+
self.agent_embeddings = {
|
| 448 |
+
agent: self.embedder.encode(desc)
|
| 449 |
+
for agent, desc in self.agent_descriptions.items()
|
| 450 |
+
}
|
| 451 |
+
print("[INFO] Embedding router ready!")
|
| 452 |
+
else:
|
| 453 |
+
print("[INFO] Using LLM-based routing (embeddings not available)")
|
| 454 |
+
|
| 455 |
+
def route(self, message: str, chat_history: List[Tuple[str, str]] = None) -> Dict:
|
| 456 |
+
"""
|
| 457 |
+
Route message to appropriate agent
|
| 458 |
+
|
| 459 |
+
Args:
|
| 460 |
+
message: User message
|
| 461 |
+
chat_history: Conversation history
|
| 462 |
+
|
| 463 |
+
Returns:
|
| 464 |
+
{
|
| 465 |
+
"agent": agent_name,
|
| 466 |
+
"parameters": {...},
|
| 467 |
+
"confidence": float,
|
| 468 |
+
"method": "embedding" or "llm"
|
| 469 |
+
}
|
| 470 |
+
"""
|
| 471 |
+
if self.use_embeddings:
|
| 472 |
+
return self._route_with_embeddings(message, chat_history)
|
| 473 |
+
else:
|
| 474 |
+
# Fallback to LLM-based routing
|
| 475 |
+
return route_to_agent(message, chat_history)
|
| 476 |
+
|
| 477 |
+
def _route_with_embeddings(self, message: str, chat_history: List[Tuple[str, str]]) -> Dict:
|
| 478 |
+
"""Route using embedding similarity with topic change detection"""
|
| 479 |
+
# Embed query
|
| 480 |
+
query_embedding = self.embedder.encode(message)
|
| 481 |
+
|
| 482 |
+
# Calculate similarity with each agent
|
| 483 |
+
similarities = {}
|
| 484 |
+
for agent, agent_embedding in self.agent_embeddings.items():
|
| 485 |
+
similarity = cosine_similarity(
|
| 486 |
+
query_embedding.reshape(1, -1),
|
| 487 |
+
agent_embedding.reshape(1, -1)
|
| 488 |
+
)[0][0]
|
| 489 |
+
similarities[agent] = similarity
|
| 490 |
+
|
| 491 |
+
# Detect topic change vs follow-up
|
| 492 |
+
is_topic_change = self._detect_topic_change(message, chat_history)
|
| 493 |
+
|
| 494 |
+
# Context boost ONLY for genuine follow-ups (NOT topic changes)
|
| 495 |
+
if not is_topic_change and chat_history and len(chat_history) > 0:
|
| 496 |
+
# Determine CURRENT context from recent conversation (not just last message)
|
| 497 |
+
current_context_agent = self._get_current_context_agent(chat_history)
|
| 498 |
+
|
| 499 |
+
# Simple heuristic: if query is short and has follow-up indicators
|
| 500 |
+
if len(message.split()) < 10 and any(word in message.lower() for word in ["thì sao", "còn", "nữa", "thế", "vậy", "không", "khác", "nếu"]):
|
| 501 |
+
# Boost ONLY the current context agent (not all agents)
|
| 502 |
+
if current_context_agent and current_context_agent in similarities:
|
| 503 |
+
similarities[current_context_agent] += 0.15
|
| 504 |
+
print(f"[ROUTER] Boosting current context agent: {current_context_agent}")
|
| 505 |
+
|
| 506 |
+
# Get best agent
|
| 507 |
+
best_agent = max(similarities, key=similarities.get)
|
| 508 |
+
confidence = float(similarities[best_agent])
|
| 509 |
+
|
| 510 |
+
# Debug logging (disabled for cleaner output)
|
| 511 |
+
# print(f"\n[ROUTER DEBUG] Message: {message[:50]}...")
|
| 512 |
+
# print(f"[ROUTER DEBUG] Topic change detected: {is_topic_change}")
|
| 513 |
+
# print(f"[ROUTER DEBUG] Similarities:")
|
| 514 |
+
# for agent, score in sorted(similarities.items(), key=lambda x: x[1], reverse=True):
|
| 515 |
+
# print(f" - {agent}: {score:.4f}")
|
| 516 |
+
# print(f"[ROUTER DEBUG] Selected: {best_agent} (confidence: {confidence:.4f})\n")
|
| 517 |
+
|
| 518 |
+
return {
|
| 519 |
+
"agent": best_agent,
|
| 520 |
+
"parameters": {"user_query": message},
|
| 521 |
+
"confidence": confidence,
|
| 522 |
+
"method": "embedding",
|
| 523 |
+
"all_scores": {k: float(v) for k, v in similarities.items()},
|
| 524 |
+
"topic_change": is_topic_change
|
| 525 |
+
}
|
| 526 |
+
|
| 527 |
+
def _get_current_context_agent(self, chat_history: List[Tuple[str, str]]) -> Optional[str]:
|
| 528 |
+
"""
|
| 529 |
+
Determine which agent is handling the CURRENT context
|
| 530 |
+
by analyzing recent conversation (last 3-5 turns)
|
| 531 |
+
|
| 532 |
+
Returns:
|
| 533 |
+
Agent name that's currently active, or None
|
| 534 |
+
"""
|
| 535 |
+
if not chat_history or len(chat_history) == 0:
|
| 536 |
+
return None
|
| 537 |
+
|
| 538 |
+
# Check last 3-5 turns for dominant domain
|
| 539 |
+
recent_turns = chat_history[-5:] if len(chat_history) >= 5 else chat_history
|
| 540 |
+
|
| 541 |
+
domain_keywords = {
|
| 542 |
+
'nutrition_agent': ['ăn', 'dinh dưỡng', 'thực đơn', 'calo', 'bmi', 'giảm cân', 'tăng cân', 'protein', 'carb', 'meal', 'bữa'],
|
| 543 |
+
'exercise_agent': ['tập', 'luyện', 'gym', 'cardio', 'yoga', 'vận động', 'tăng cơ', 'giảm mỡ', 'workout', 'bài tập'],
|
| 544 |
+
'symptom_agent': ['đau', 'bệnh', 'triệu chứng', 'khó chịu', 'buồn nôn', 'sốt', 'ốm'],
|
| 545 |
+
'mental_health_agent': ['stress', 'lo âu', 'mất ngủ', 'trầm cảm', 'tâm lý']
|
| 546 |
+
}
|
| 547 |
+
|
| 548 |
+
# Count domain occurrences in recent turns
|
| 549 |
+
domain_counts = {agent: 0 for agent in domain_keywords.keys()}
|
| 550 |
+
|
| 551 |
+
for user_msg, bot_msg in recent_turns:
|
| 552 |
+
combined = (user_msg + " " + bot_msg).lower()
|
| 553 |
+
for agent, keywords in domain_keywords.items():
|
| 554 |
+
for keyword in keywords:
|
| 555 |
+
if keyword in combined:
|
| 556 |
+
domain_counts[agent] += 1
|
| 557 |
+
break # Count once per turn
|
| 558 |
+
|
| 559 |
+
# Return agent with highest count (if significant)
|
| 560 |
+
if domain_counts:
|
| 561 |
+
max_agent = max(domain_counts, key=domain_counts.get)
|
| 562 |
+
max_count = domain_counts[max_agent]
|
| 563 |
+
|
| 564 |
+
# Need at least 2 occurrences in recent turns to be considered "current context"
|
| 565 |
+
if max_count >= 2:
|
| 566 |
+
return max_agent
|
| 567 |
+
|
| 568 |
+
return None
|
| 569 |
+
|
| 570 |
+
def _detect_topic_change(self, message: str, chat_history: List[Tuple[str, str]]) -> bool:
|
| 571 |
+
"""
|
| 572 |
+
Detect if user is changing topics vs following up
|
| 573 |
+
|
| 574 |
+
Topic change indicators:
|
| 575 |
+
- Explicit new requests: "tôi muốn", "giúp tôi", "tư vấn về"
|
| 576 |
+
- Different domain keywords: nutrition → exercise, symptom → nutrition
|
| 577 |
+
- Long, detailed new questions
|
| 578 |
+
|
| 579 |
+
Returns:
|
| 580 |
+
bool: True if topic change, False if follow-up
|
| 581 |
+
"""
|
| 582 |
+
msg_lower = message.lower()
|
| 583 |
+
|
| 584 |
+
# Strong topic change indicators
|
| 585 |
+
topic_change_phrases = [
|
| 586 |
+
'tôi muốn', 'tôi cần', 'giúp tôi', 'tư vấn về', 'cho tôi',
|
| 587 |
+
'bây giờ', 'còn về', 'chuyển sang', 'ngoài ra',
|
| 588 |
+
'setup', 'tạo plan', 'lập kế hoạch'
|
| 589 |
+
]
|
| 590 |
+
|
| 591 |
+
if any(phrase in msg_lower for phrase in topic_change_phrases):
|
| 592 |
+
# Likely a new request
|
| 593 |
+
return True
|
| 594 |
+
|
| 595 |
+
# Check for domain-specific keywords that indicate topic change
|
| 596 |
+
domain_keywords = {
|
| 597 |
+
'nutrition': ['ăn', 'dinh dưỡng', 'thực đơn', 'calo', 'bmi', 'giảm cân', 'tăng cân'],
|
| 598 |
+
'exercise': ['tập', 'luyện', 'gym', 'cardio', 'yoga', 'vận động', 'tăng cơ', 'giảm mỡ'],
|
| 599 |
+
'symptom': ['đau', 'bệnh', 'triệu chứng', 'khó chịu', 'buồn nôn'],
|
| 600 |
+
'mental': ['stress', 'lo âu', 'mất ngủ', 'trầm cảm', 'tâm lý']
|
| 601 |
+
}
|
| 602 |
+
|
| 603 |
+
# Detect current message domain
|
| 604 |
+
current_domains = []
|
| 605 |
+
for domain, keywords in domain_keywords.items():
|
| 606 |
+
if any(kw in msg_lower for kw in keywords):
|
| 607 |
+
current_domains.append(domain)
|
| 608 |
+
|
| 609 |
+
# If no chat history, it's a new topic
|
| 610 |
+
if not chat_history or len(chat_history) == 0:
|
| 611 |
+
return True
|
| 612 |
+
|
| 613 |
+
# Check last few messages for domain
|
| 614 |
+
recent_messages = chat_history[-3:] if len(chat_history) >= 3 else chat_history
|
| 615 |
+
previous_domains = []
|
| 616 |
+
|
| 617 |
+
for user_msg, bot_msg in recent_messages:
|
| 618 |
+
combined = (user_msg + " " + bot_msg).lower()
|
| 619 |
+
for domain, keywords in domain_keywords.items():
|
| 620 |
+
if any(kw in combined for kw in keywords):
|
| 621 |
+
if domain not in previous_domains:
|
| 622 |
+
previous_domains.append(domain)
|
| 623 |
+
|
| 624 |
+
# If current domain is different from previous, it's a topic change
|
| 625 |
+
if current_domains and previous_domains:
|
| 626 |
+
# Check if there's overlap
|
| 627 |
+
overlap = set(current_domains) & set(previous_domains)
|
| 628 |
+
if not overlap:
|
| 629 |
+
# No overlap = topic change
|
| 630 |
+
return True
|
| 631 |
+
|
| 632 |
+
# Long messages (>15 words) with new content are likely topic changes
|
| 633 |
+
if len(message.split()) > 15:
|
| 634 |
+
# Check if it's not just elaborating on previous topic
|
| 635 |
+
follow_up_words = ['vì', 'do', 'bởi vì', 'là do', 'nghĩa là']
|
| 636 |
+
if not any(word in msg_lower for word in follow_up_words):
|
| 637 |
+
return True
|
| 638 |
+
|
| 639 |
+
# Default: assume follow-up
|
| 640 |
+
return False
|
| 641 |
+
|
| 642 |
+
|
| 643 |
+
# Global router instance (lazy initialization)
|
| 644 |
+
_router_instance = None
|
| 645 |
+
|
| 646 |
+
def get_router(use_embeddings=True, force_reload=False) -> EmbeddingRouter:
|
| 647 |
+
"""
|
| 648 |
+
Get global router instance
|
| 649 |
+
|
| 650 |
+
Args:
|
| 651 |
+
use_embeddings: Use embedding-based routing
|
| 652 |
+
force_reload: Force reload router (useful after updating agent descriptions)
|
| 653 |
+
"""
|
| 654 |
+
global _router_instance
|
| 655 |
+
if _router_instance is None or force_reload:
|
| 656 |
+
_router_instance = EmbeddingRouter(use_embeddings=use_embeddings)
|
| 657 |
+
return _router_instance
|
agents/core/unified_tone.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Unified Tone System - Ensures consistent personality across all agents
|
| 3 |
+
Makes the AI feel like ONE knowledgeable assistant, not multiple people
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
class UnifiedTone:
|
| 7 |
+
"""
|
| 8 |
+
Provides consistent tone and personality across all agents
|
| 9 |
+
The AI is ONE medical professional with multiple specialties
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
# Base personality - applies to ALL agents
|
| 13 |
+
BASE_PERSONALITY = """
|
| 14 |
+
Bạn là một trợ lý sức khỏe AI thông minh và đa năng.
|
| 15 |
+
|
| 16 |
+
QUAN TRỌNG - Tính nhất quán:
|
| 17 |
+
- Bạn là MỘT NGƯỜI duy nhất với nhiều chuyên môn
|
| 18 |
+
- KHÔNG tự giới thiệu là "chuyên gia dinh dưỡng" hay "huấn luyện viên"
|
| 19 |
+
- Chỉ nói "Tôi sẽ tư vấn về [lĩnh vực]" khi cần
|
| 20 |
+
- Giữ giọng điệu nhất quán xuyên suốt
|
| 21 |
+
|
| 22 |
+
SMART GREETING - Câu đầu tiên:
|
| 23 |
+
- Nếu user CHỈ chào (vd: "chào", "hello") → Chào đầy đủ + giới thiệu
|
| 24 |
+
- Nếu user VÀO THẲNG VẤN ĐỀ (vd: "đau lưng", "tôi muốn giảm cân") → Chào ngắn gọn + trả lời luôn
|
| 25 |
+
* Ví dụ: "Chào bạn! Để giúp bạn về vấn đề đau lưng..."
|
| 26 |
+
* KHÔNG greeting dài dòng khi user đã có vấn đề cụ thể
|
| 27 |
+
|
| 28 |
+
Phong cách chung:
|
| 29 |
+
- Thân thiện nhưng chuyên nghiệp
|
| 30 |
+
- Rõ ràng, súc tích, dễ hiểu
|
| 31 |
+
- Quan tâm nhưng không quá emotional
|
| 32 |
+
- Thực tế, có căn cứ khoa học
|
| 33 |
+
- Tránh emoji quá nhiều (chỉ dùng khi cần thiết)
|
| 34 |
+
"""
|
| 35 |
+
|
| 36 |
+
# Smooth transitions between specialties
|
| 37 |
+
TRANSITION_PHRASES = {
|
| 38 |
+
'to_nutrition': "Về mặt dinh dưỡng, ",
|
| 39 |
+
'to_exercise': "Về vận động và tập luyện, ",
|
| 40 |
+
'to_symptom': "Về triệu chứng bạn đang gặp, ",
|
| 41 |
+
'to_mental': "Về mặt tinh thần và cảm xúc, ",
|
| 42 |
+
'general': "Dựa trên thông tin bạn cung cấp, "
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
@staticmethod
|
| 46 |
+
def apply_unified_tone(response: str, agent_type: str) -> str:
|
| 47 |
+
"""
|
| 48 |
+
Apply unified tone to agent response
|
| 49 |
+
Ensures consistency across all agents
|
| 50 |
+
"""
|
| 51 |
+
# Remove agent-specific introductions
|
| 52 |
+
replacements = [
|
| 53 |
+
("Tôi là chuyên gia dinh dưỡng", "Về dinh dưỡng"),
|
| 54 |
+
("Tôi là huấn luyện viên", "Về tập luyện"),
|
| 55 |
+
("Với tư cách bác sĩ", "Theo y học"),
|
| 56 |
+
("Là chuyên gia tâm lý", "Về mặt tâm lý"),
|
| 57 |
+
]
|
| 58 |
+
|
| 59 |
+
for old, new in replacements:
|
| 60 |
+
response = response.replace(old, new)
|
| 61 |
+
|
| 62 |
+
# Moderate emoji usage
|
| 63 |
+
if agent_type == 'symptom':
|
| 64 |
+
# Remove excessive medical emojis
|
| 65 |
+
response = response.replace('🏥', '').replace('💊', '')
|
| 66 |
+
elif agent_type == 'exercise':
|
| 67 |
+
# Keep motivational but not excessive
|
| 68 |
+
response = response.replace('💪💪💪', '💪')
|
| 69 |
+
response = response.replace('🔥🔥🔥', '🔥')
|
| 70 |
+
|
| 71 |
+
return response
|
| 72 |
+
|
| 73 |
+
@staticmethod
|
| 74 |
+
def create_smooth_handoff(from_agent: str, to_agent: str, context: str) -> str:
|
| 75 |
+
"""
|
| 76 |
+
Create smooth transition between agent specialties
|
| 77 |
+
Makes it feel like one person switching topics, not different people
|
| 78 |
+
"""
|
| 79 |
+
transition = UnifiedTone.TRANSITION_PHRASES.get(f'to_{to_agent}', '')
|
| 80 |
+
|
| 81 |
+
# Don't say "I'm handing you over to..."
|
| 82 |
+
# Instead, smoothly transition topics
|
| 83 |
+
handoff_message = f"{context}\n\n{transition}"
|
| 84 |
+
|
| 85 |
+
return handoff_message
|
| 86 |
+
|
| 87 |
+
@staticmethod
|
| 88 |
+
def check_information_before_asking(chat_history: list, field: str) -> bool:
|
| 89 |
+
"""
|
| 90 |
+
Check if information already exists in chat history
|
| 91 |
+
Prevents duplicate questions across agents
|
| 92 |
+
"""
|
| 93 |
+
import re
|
| 94 |
+
|
| 95 |
+
# Patterns to check for each field
|
| 96 |
+
patterns = {
|
| 97 |
+
'age': r'\d+\s*tuổi',
|
| 98 |
+
'gender': r'(nam|nữ|male|female)',
|
| 99 |
+
'weight': r'\d+\s*kg',
|
| 100 |
+
'height': r'\d+\s*cm',
|
| 101 |
+
'goal': r'(giảm cân|tăng cân|tăng cơ|khỏe mạnh)',
|
| 102 |
+
'condition': r'(tiểu đường|huyết áp|tim mạch|dị ứng)'
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
if field not in patterns:
|
| 106 |
+
return False
|
| 107 |
+
|
| 108 |
+
# Check all messages in history
|
| 109 |
+
for user_msg, bot_msg in chat_history:
|
| 110 |
+
if user_msg and re.search(patterns[field], user_msg.lower()):
|
| 111 |
+
return True # Information already provided
|
| 112 |
+
if bot_msg and "không biết" in bot_msg.lower():
|
| 113 |
+
return True # User already declined to provide
|
| 114 |
+
|
| 115 |
+
return False
|
| 116 |
+
|
| 117 |
+
@staticmethod
|
| 118 |
+
def generate_smart_question(needed_info: list, chat_history: list) -> str:
|
| 119 |
+
"""
|
| 120 |
+
Generate intelligent questions that don't repeat
|
| 121 |
+
Groups multiple needs into one natural question
|
| 122 |
+
"""
|
| 123 |
+
# Filter out already known information
|
| 124 |
+
actually_needed = []
|
| 125 |
+
for info in needed_info:
|
| 126 |
+
if not UnifiedTone.check_information_before_asking(chat_history, info):
|
| 127 |
+
actually_needed.append(info)
|
| 128 |
+
|
| 129 |
+
if not actually_needed:
|
| 130 |
+
return "" # Don't ask anything
|
| 131 |
+
|
| 132 |
+
# Group related questions
|
| 133 |
+
if len(actually_needed) > 2:
|
| 134 |
+
# Ask for multiple things naturally
|
| 135 |
+
return "Để tư vấn chính xác, bạn có thể cho tôi biết tuổi, giới tính, cân nặng và mục tiêu của bạn được không?"
|
| 136 |
+
elif len(actually_needed) == 2:
|
| 137 |
+
field_names = {
|
| 138 |
+
'age': 'tuổi',
|
| 139 |
+
'gender': 'giới tính',
|
| 140 |
+
'weight': 'cân nặng',
|
| 141 |
+
'height': 'chiều cao',
|
| 142 |
+
'goal': 'mục tiêu'
|
| 143 |
+
}
|
| 144 |
+
fields = ' và '.join([field_names.get(f, f) for f in actually_needed])
|
| 145 |
+
return f"Bạn có thể cho tôi biết {fields} của bạn không?"
|
| 146 |
+
else:
|
| 147 |
+
# Single question
|
| 148 |
+
questions = {
|
| 149 |
+
'age': "Bạn bao nhiêu tuổi?",
|
| 150 |
+
'gender': "Giới tính của bạn là gì?",
|
| 151 |
+
'weight': "Cân nặng hiện tại của bạn là bao nhiêu?",
|
| 152 |
+
'height': "Chiều cao của bạn là bao nhiêu?",
|
| 153 |
+
'goal': "Mục tiêu sức khỏe của bạn là gì?"
|
| 154 |
+
}
|
| 155 |
+
return questions.get(actually_needed[0], "")
|
agents/specialized/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Specialized agents package - Domain-specific healthcare agents
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from .nutrition_agent import NutritionAgent
|
| 6 |
+
from .exercise_agent import ExerciseAgent
|
| 7 |
+
from .symptom_agent import SymptomAgent
|
| 8 |
+
from .mental_health_agent import MentalHealthAgent
|
| 9 |
+
from .general_health_agent import GeneralHealthAgent
|
| 10 |
+
|
| 11 |
+
# Agent registry
|
| 12 |
+
AGENTS = {
|
| 13 |
+
"nutrition_agent": NutritionAgent,
|
| 14 |
+
"exercise_agent": ExerciseAgent,
|
| 15 |
+
"symptom_agent": SymptomAgent,
|
| 16 |
+
"mental_health_agent": MentalHealthAgent,
|
| 17 |
+
"general_health_agent": GeneralHealthAgent
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
def get_agent(agent_name):
|
| 21 |
+
"""Get agent instance by name"""
|
| 22 |
+
agent_class = AGENTS.get(agent_name)
|
| 23 |
+
if agent_class:
|
| 24 |
+
return agent_class()
|
| 25 |
+
return GeneralHealthAgent() # Default fallback
|
| 26 |
+
|
| 27 |
+
__all__ = [
|
| 28 |
+
'NutritionAgent',
|
| 29 |
+
'ExerciseAgent',
|
| 30 |
+
'SymptomAgent',
|
| 31 |
+
'MentalHealthAgent',
|
| 32 |
+
'GeneralHealthAgent',
|
| 33 |
+
'AGENTS',
|
| 34 |
+
'get_agent'
|
| 35 |
+
]
|
agents/specialized/exercise_agent.py
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Exercise Agent - Specialized agent for exercise and fitness advice
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from config.settings import client, MODEL
|
| 6 |
+
from modules.exercise.exercise import generate_exercise_plan
|
| 7 |
+
from health_data import HealthContext
|
| 8 |
+
from fitness_tracking import FitnessTracker
|
| 9 |
+
from rag.rag_integration import get_rag_integration
|
| 10 |
+
from agents.core.base_agent import BaseAgent
|
| 11 |
+
from typing import Dict, Any, List, Optional
|
| 12 |
+
from datetime import datetime
|
| 13 |
+
import re
|
| 14 |
+
|
| 15 |
+
class ExerciseAgent(BaseAgent):
|
| 16 |
+
def __init__(self, memory=None):
|
| 17 |
+
super().__init__(memory)
|
| 18 |
+
self.health_context = None
|
| 19 |
+
self.fitness_tracker = None
|
| 20 |
+
self.rag = get_rag_integration()
|
| 21 |
+
|
| 22 |
+
# Configure handoff triggers for exercise agent
|
| 23 |
+
self.handoff_triggers = {
|
| 24 |
+
'nutrition_agent': ['ăn gì', 'thực đơn', 'calo', 'dinh dưỡng', 'giảm cân nhanh', 'tăng cân'],
|
| 25 |
+
'symptom_agent': ['đau', 'chấn thương', 'bị thương', 'sưng', 'viêm'],
|
| 26 |
+
'mental_health_agent': ['stress', 'lo âu', 'không có động lực', 'chán'],
|
| 27 |
+
'general_health_agent': ['khám', 'bác sĩ', 'xét nghiệm']
|
| 28 |
+
}
|
| 29 |
+
self.system_prompt = """Bạn là huấn luyện viên cá nhân chuyên nghiệp, nhiệt huyết và động viên.
|
| 30 |
+
|
| 31 |
+
💪 CHUYÊN MÔN:
|
| 32 |
+
- Tạo kế hoạch tập luyện cá nhân hóa
|
| 33 |
+
- Tư vấn bài tập phù hợp với thể trạng, mục tiêu
|
| 34 |
+
- Hướng dẫn kỹ thuật tập an toàn
|
| 35 |
+
- Tư vấn tập cho người có bệnh nền
|
| 36 |
+
- Lịch tập gym, tập tại nhà, cardio, yoga...
|
| 37 |
+
|
| 38 |
+
🎯 CÁCH TƯ VẤN:
|
| 39 |
+
|
| 40 |
+
1. **KIỂM TRA THÔNG TIN TRƯỚC KHI HỎI:**
|
| 41 |
+
- ĐỌC KỸ chat history - user có thể đã cung cấp thông tin rồi!
|
| 42 |
+
- Nếu user đã nói "tôi 30 tuổi, nam, muốn giảm cân, có thể tập 45 phút/ngày" → ĐỪNG HỎI LẠI!
|
| 43 |
+
- Chỉ hỏi thông tin THỰC SỰ còn thiếu
|
| 44 |
+
- Nếu đã đủ thông tin cơ bản → TẠO LỊCH TẬP NGAY!
|
| 45 |
+
|
| 46 |
+
2. **THÔNG TIN CẦN THIẾT:**
|
| 47 |
+
- Cơ bản: Tuổi, giới tính, mục tiêu, thời gian rảnh
|
| 48 |
+
- Bổ sung: Thể lực, dụng cụ có sẵn, bệnh nền
|
| 49 |
+
- Nếu thiếu → Hỏi ngắn gọn, không hỏi mãi
|
| 50 |
+
|
| 51 |
+
3. **TẠO LỊCH TẬP:**
|
| 52 |
+
- Lịch tập cụ thể theo ngày
|
| 53 |
+
- Giải thích TẠI SAO tập bài này
|
| 54 |
+
- Hướng dẫn progression (tuần 1, 2, 3...)
|
| 55 |
+
- Lưu ý an toàn, tránh chấn thương
|
| 56 |
+
|
| 57 |
+
⚠️ AN TOÀN:
|
| 58 |
+
- Người có bệnh tim, huyết áp → khuyên gặp bác sĩ trước
|
| 59 |
+
- Người có chấn thương → tập nhẹ, tránh vùng bị thương
|
| 60 |
+
- Người mới bắt đầu → từ từ, không quá sức
|
| 61 |
+
|
| 62 |
+
💬 PHONG CÁCH:
|
| 63 |
+
- Động viên, khích lệ 💪🔥
|
| 64 |
+
- Thực tế, không lý thuyết suông
|
| 65 |
+
- Dễ hiểu, dễ làm theo
|
| 66 |
+
- Hài hước nhẹ nhàng
|
| 67 |
+
- TỰ NHIÊN, MẠCH LẠC - không lặp lại ý, không copy-paste câu từ context khác
|
| 68 |
+
- Nếu hỏi thông tin → Hỏi NGẮN GỌN, TRỰC TIẾP
|
| 69 |
+
- KHÔNG dùng câu như "Bạn thử làm theo xem có đỡ không" (đây là câu của bác sĩ, không phải PT!)"""
|
| 70 |
+
|
| 71 |
+
def set_health_context(self, health_context: HealthContext):
|
| 72 |
+
"""Inject health context and initialize fitness tracker"""
|
| 73 |
+
self.health_context = health_context
|
| 74 |
+
self.fitness_tracker = FitnessTracker(health_context)
|
| 75 |
+
|
| 76 |
+
def handle(self, parameters, chat_history=None):
|
| 77 |
+
"""
|
| 78 |
+
Handle exercise request
|
| 79 |
+
|
| 80 |
+
Args:
|
| 81 |
+
parameters (dict): {
|
| 82 |
+
"user_query": str,
|
| 83 |
+
"user_data": dict (optional)
|
| 84 |
+
}
|
| 85 |
+
chat_history (list): Conversation history
|
| 86 |
+
|
| 87 |
+
Returns:
|
| 88 |
+
str: Response message
|
| 89 |
+
"""
|
| 90 |
+
user_query = parameters.get("user_query", "")
|
| 91 |
+
user_data = parameters.get("user_data", {})
|
| 92 |
+
|
| 93 |
+
# Extract and save user info from current message immediately
|
| 94 |
+
self.extract_and_save_user_info(user_query)
|
| 95 |
+
|
| 96 |
+
# Update memory from chat history
|
| 97 |
+
if chat_history:
|
| 98 |
+
self.update_memory_from_history(chat_history)
|
| 99 |
+
|
| 100 |
+
# Check if we should hand off to another agent
|
| 101 |
+
if self.should_handoff(user_query, chat_history):
|
| 102 |
+
next_agent = self.suggest_next_agent(user_query)
|
| 103 |
+
if next_agent:
|
| 104 |
+
# Save current exercise data for next agent
|
| 105 |
+
self.save_agent_data('last_exercise_advice', {
|
| 106 |
+
'query': user_query,
|
| 107 |
+
'user_profile': self.get_user_profile(),
|
| 108 |
+
'timestamp': datetime.now().isoformat()
|
| 109 |
+
})
|
| 110 |
+
|
| 111 |
+
# Check if nutrition agent shared data with us
|
| 112 |
+
nutrition_data = self.get_other_agent_data('nutrition_agent', 'nutrition_plan')
|
| 113 |
+
context = self._generate_exercise_summary(nutrition_data)
|
| 114 |
+
return self.create_handoff_message(next_agent, context, user_query)
|
| 115 |
+
|
| 116 |
+
# Use health context if available
|
| 117 |
+
if self.health_context:
|
| 118 |
+
profile = self.health_context.get_user_profile()
|
| 119 |
+
user_data = {
|
| 120 |
+
'age': profile.age,
|
| 121 |
+
'gender': profile.gender,
|
| 122 |
+
'weight': profile.weight,
|
| 123 |
+
'height': profile.height,
|
| 124 |
+
'fitness_level': profile.fitness_level,
|
| 125 |
+
'activity_level': profile.activity_level,
|
| 126 |
+
'health_conditions': profile.health_conditions
|
| 127 |
+
}
|
| 128 |
+
# Extract user data from chat history if not provided
|
| 129 |
+
elif not user_data and chat_history:
|
| 130 |
+
user_data = self._extract_user_data_from_history(chat_history)
|
| 131 |
+
# Save extracted data to shared memory for other agents
|
| 132 |
+
for key, value in user_data.items():
|
| 133 |
+
if value is not None:
|
| 134 |
+
self.update_user_profile(key, value)
|
| 135 |
+
|
| 136 |
+
# Check if we have enough data - check shared memory first
|
| 137 |
+
profile = self.get_user_profile()
|
| 138 |
+
for field in ['age', 'gender', 'weight', 'height']:
|
| 139 |
+
if not user_data.get(field) and profile.get(field):
|
| 140 |
+
user_data[field] = profile[field]
|
| 141 |
+
|
| 142 |
+
missing_fields = self._check_missing_data(user_data)
|
| 143 |
+
|
| 144 |
+
if missing_fields:
|
| 145 |
+
return self._ask_for_missing_data(missing_fields, user_data)
|
| 146 |
+
|
| 147 |
+
# Generate exercise plan
|
| 148 |
+
try:
|
| 149 |
+
plan = generate_exercise_plan(user_data)
|
| 150 |
+
|
| 151 |
+
# Adjust difficulty based on fitness tracker
|
| 152 |
+
if self.fitness_tracker:
|
| 153 |
+
metrics = self.fitness_tracker.calculate_progress_metrics()
|
| 154 |
+
if metrics.get('adherence', 0) > 0.8:
|
| 155 |
+
plan = self.fitness_tracker.adjust_difficulty(plan, 'increase')
|
| 156 |
+
elif metrics.get('adherence', 0) < 0.5:
|
| 157 |
+
plan = self.fitness_tracker.adjust_difficulty(plan, 'decrease')
|
| 158 |
+
|
| 159 |
+
response = plan
|
| 160 |
+
|
| 161 |
+
# Persist workout plan to health context
|
| 162 |
+
if self.health_context:
|
| 163 |
+
self.health_context.add_health_record('exercise', {
|
| 164 |
+
'query': user_query,
|
| 165 |
+
'plan': response,
|
| 166 |
+
'user_data': user_data,
|
| 167 |
+
'timestamp': datetime.now().isoformat()
|
| 168 |
+
})
|
| 169 |
+
|
| 170 |
+
return response
|
| 171 |
+
except Exception as e:
|
| 172 |
+
return self._handle_error(e, user_query)
|
| 173 |
+
|
| 174 |
+
def _extract_user_data_from_history(self, chat_history):
|
| 175 |
+
"""Extract user data from conversation history"""
|
| 176 |
+
user_data = {
|
| 177 |
+
'age': None,
|
| 178 |
+
'gender': None,
|
| 179 |
+
'weight': None,
|
| 180 |
+
'height': None,
|
| 181 |
+
'fitness_level': 'beginner',
|
| 182 |
+
'goal': 'health_improvement',
|
| 183 |
+
'available_time': 30,
|
| 184 |
+
'health_conditions': []
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
all_messages = " ".join([msg[0] for msg in chat_history if msg[0]])
|
| 188 |
+
|
| 189 |
+
# Extract age
|
| 190 |
+
age_match = re.search(r'(\d+)\s*tuổi|tuổi\s*(\d+)|tôi\s*(\d+)', all_messages.lower())
|
| 191 |
+
if age_match:
|
| 192 |
+
user_data['age'] = int([g for g in age_match.groups() if g][0])
|
| 193 |
+
|
| 194 |
+
# Extract gender
|
| 195 |
+
if re.search(r'\bnam\b|male|đàn ông', all_messages.lower()):
|
| 196 |
+
user_data['gender'] = 'male'
|
| 197 |
+
elif re.search(r'\bnữ\b|female|đàn bà', all_messages.lower()):
|
| 198 |
+
user_data['gender'] = 'female'
|
| 199 |
+
|
| 200 |
+
# Extract fitness level
|
| 201 |
+
if re.search(r'mới bắt đầu|beginner|chưa tập', all_messages.lower()):
|
| 202 |
+
user_data['fitness_level'] = 'beginner'
|
| 203 |
+
elif re.search(r'trung bình|intermediate|tập được', all_messages.lower()):
|
| 204 |
+
user_data['fitness_level'] = 'intermediate'
|
| 205 |
+
elif re.search(r'nâng cao|advanced|tập lâu', all_messages.lower()):
|
| 206 |
+
user_data['fitness_level'] = 'advanced'
|
| 207 |
+
|
| 208 |
+
# Extract goal
|
| 209 |
+
if re.search(r'giảm cân|weight loss|slim', all_messages.lower()):
|
| 210 |
+
user_data['goal'] = 'weight_loss'
|
| 211 |
+
elif re.search(r'tăng cân|weight gain|bulk', all_messages.lower()):
|
| 212 |
+
user_data['goal'] = 'weight_gain'
|
| 213 |
+
elif re.search(r'tập gym|muscle|cơ bắp|tăng cơ', all_messages.lower()):
|
| 214 |
+
user_data['goal'] = 'muscle_building'
|
| 215 |
+
elif re.search(r'khỏe mạnh|health|sức khỏe', all_messages.lower()):
|
| 216 |
+
user_data['goal'] = 'health_improvement'
|
| 217 |
+
|
| 218 |
+
# Extract available time
|
| 219 |
+
time_match = re.search(r'(\d+)\s*phút|(\d+)\s*tiếng', all_messages.lower())
|
| 220 |
+
if time_match:
|
| 221 |
+
time_val = int([g for g in time_match.groups() if g][0])
|
| 222 |
+
if 'tiếng' in all_messages.lower():
|
| 223 |
+
time_val *= 60
|
| 224 |
+
user_data['available_time'] = time_val
|
| 225 |
+
|
| 226 |
+
return user_data
|
| 227 |
+
|
| 228 |
+
def _check_missing_data(self, user_data):
|
| 229 |
+
"""Check what data is missing"""
|
| 230 |
+
required = ['age', 'gender', 'fitness_level', 'goal']
|
| 231 |
+
return [field for field in required if not user_data.get(field)]
|
| 232 |
+
|
| 233 |
+
def _ask_for_missing_data(self, missing_fields, current_data):
|
| 234 |
+
"""Ask for missing data"""
|
| 235 |
+
questions = {
|
| 236 |
+
'age': "bạn bao nhiêu tuổi",
|
| 237 |
+
'gender': "bạn là nam hay nữ",
|
| 238 |
+
'fitness_level': "thể lực hiện tại của bạn thế nào (mới bắt đầu/trung bình/nâng cao)",
|
| 239 |
+
'goal': "mục tiêu của bạn là gì (giảm cân/tăng cơ/khỏe mạnh hơn)"
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
q_list = [questions[f] for f in missing_fields]
|
| 243 |
+
|
| 244 |
+
if len(q_list) == 1:
|
| 245 |
+
question = q_list[0]
|
| 246 |
+
elif len(q_list) == 2:
|
| 247 |
+
question = f"{q_list[0]} và {q_list[1]}"
|
| 248 |
+
else:
|
| 249 |
+
question = ", ".join(q_list[:-1]) + f" và {q_list[-1]}"
|
| 250 |
+
|
| 251 |
+
return f"""💪 **Để tạo lịch tập phù hợp, mình cần biết thêm:**
|
| 252 |
+
|
| 253 |
+
Cho mình biết {question} nhé?
|
| 254 |
+
|
| 255 |
+
💡 **Ví dụ:** "Tôi 30 tuổi, nam, mới bắt đầu tập, muốn giảm cân, có thể tập 45 phút mỗi ngày"
|
| 256 |
+
|
| 257 |
+
Sau khi có đủ thông tin, mình sẽ tạo kế hoạch tập luyện 7 ngày chi tiết cho bạn! 🔥"""
|
| 258 |
+
|
| 259 |
+
def _handle_general_exercise_query(self, user_query, chat_history):
|
| 260 |
+
"""Handle general exercise questions using LLM + RAG"""
|
| 261 |
+
from config.settings import client, MODEL
|
| 262 |
+
|
| 263 |
+
try:
|
| 264 |
+
# Smart RAG - only query when needed (inherit from BaseAgent)
|
| 265 |
+
rag_answer = ''
|
| 266 |
+
rag_sources = []
|
| 267 |
+
|
| 268 |
+
if self.should_use_rag(user_query, chat_history):
|
| 269 |
+
rag_result = self.rag.query_exercise(user_query)
|
| 270 |
+
rag_answer = rag_result.get('answer', '')
|
| 271 |
+
rag_sources = rag_result.get('source_docs', [])
|
| 272 |
+
|
| 273 |
+
# Build conversation context with RAG context
|
| 274 |
+
rag_context = f"Dựa trên kiến thức từ cơ sở dữ liệu:\n{rag_answer}\n\n" if rag_answer else ""
|
| 275 |
+
|
| 276 |
+
messages = [{"role": "system", "content": self.system_prompt}]
|
| 277 |
+
|
| 278 |
+
# Add RAG context if available
|
| 279 |
+
if rag_context:
|
| 280 |
+
messages.append({"role": "system", "content": f"Thông tin tham khảo từ cơ sở dữ liệu:\n{rag_context}"})
|
| 281 |
+
|
| 282 |
+
# Add chat history (last 5 exchanges)
|
| 283 |
+
if chat_history:
|
| 284 |
+
recent_history = chat_history[-5:] if len(chat_history) > 5 else chat_history
|
| 285 |
+
for user_msg, bot_msg in recent_history:
|
| 286 |
+
if user_msg:
|
| 287 |
+
messages.append({"role": "user", "content": user_msg})
|
| 288 |
+
if bot_msg:
|
| 289 |
+
messages.append({"role": "assistant", "content": bot_msg})
|
| 290 |
+
|
| 291 |
+
# Add current query
|
| 292 |
+
messages.append({"role": "user", "content": user_query})
|
| 293 |
+
|
| 294 |
+
# Get LLM response
|
| 295 |
+
response = client.chat.completions.create(
|
| 296 |
+
model=MODEL,
|
| 297 |
+
messages=messages,
|
| 298 |
+
temperature=0.7,
|
| 299 |
+
max_tokens=500
|
| 300 |
+
)
|
| 301 |
+
|
| 302 |
+
llm_response = response.choices[0].message.content
|
| 303 |
+
|
| 304 |
+
# Add sources using RAG integration formatter (FIXED!)
|
| 305 |
+
if rag_sources:
|
| 306 |
+
formatted_response = self.rag.format_response_with_sources({
|
| 307 |
+
'answer': llm_response,
|
| 308 |
+
'source_docs': rag_sources
|
| 309 |
+
})
|
| 310 |
+
return formatted_response
|
| 311 |
+
|
| 312 |
+
return llm_response
|
| 313 |
+
|
| 314 |
+
except Exception as e:
|
| 315 |
+
return f"""Xin lỗi, mình gặp lỗi kỹ thuật. Bạn có thể:
|
| 316 |
+
1. Thử lại câu hỏi
|
| 317 |
+
2. Hoặc hỏi mình về chủ đề sức khỏe khác nhé! 💙
|
| 318 |
+
|
| 319 |
+
Chi tiết lỗi: {str(e)}"""
|
| 320 |
+
|
| 321 |
+
def should_handoff(self, user_query: str, chat_history: Optional[List] = None) -> bool:
|
| 322 |
+
"""
|
| 323 |
+
Override base method - Determine if should hand off to another agent
|
| 324 |
+
|
| 325 |
+
Specific triggers for exercise agent:
|
| 326 |
+
- User asks about nutrition/diet
|
| 327 |
+
- User mentions pain/injury
|
| 328 |
+
- User asks about mental health
|
| 329 |
+
"""
|
| 330 |
+
query_lower = user_query.lower()
|
| 331 |
+
|
| 332 |
+
# Check each agent's triggers
|
| 333 |
+
for agent, triggers in self.handoff_triggers.items():
|
| 334 |
+
if any(trigger in query_lower for trigger in triggers):
|
| 335 |
+
# Don't handoff if we're in the middle of exercise planning
|
| 336 |
+
if chat_history and self._is_mid_planning(chat_history):
|
| 337 |
+
return False
|
| 338 |
+
return True
|
| 339 |
+
|
| 340 |
+
return False
|
| 341 |
+
|
| 342 |
+
def suggest_next_agent(self, user_query: str) -> Optional[str]:
|
| 343 |
+
"""Override base method - Suggest which agent to hand off to"""
|
| 344 |
+
query_lower = user_query.lower()
|
| 345 |
+
|
| 346 |
+
# Priority order for handoff
|
| 347 |
+
if any(trigger in query_lower for trigger in self.handoff_triggers.get('symptom_agent', [])):
|
| 348 |
+
return 'symptom_agent'
|
| 349 |
+
|
| 350 |
+
if any(trigger in query_lower for trigger in self.handoff_triggers.get('nutrition_agent', [])):
|
| 351 |
+
return 'nutrition_agent'
|
| 352 |
+
|
| 353 |
+
if any(trigger in query_lower for trigger in self.handoff_triggers.get('mental_health_agent', [])):
|
| 354 |
+
return 'mental_health_agent'
|
| 355 |
+
|
| 356 |
+
if any(trigger in query_lower for trigger in self.handoff_triggers.get('general_health_agent', [])):
|
| 357 |
+
return 'general_health_agent'
|
| 358 |
+
|
| 359 |
+
return None
|
| 360 |
+
|
| 361 |
+
def _is_mid_planning(self, chat_history: List) -> bool:
|
| 362 |
+
"""Check if we're in the middle of exercise planning"""
|
| 363 |
+
if not chat_history or len(chat_history) < 2:
|
| 364 |
+
return False
|
| 365 |
+
|
| 366 |
+
# Check last bot response
|
| 367 |
+
last_bot_response = chat_history[-1][1] if len(chat_history[-1]) > 1 else ""
|
| 368 |
+
|
| 369 |
+
# If we just asked for user data, don't handoff
|
| 370 |
+
if any(phrase in last_bot_response for phrase in [
|
| 371 |
+
"tuổi", "giới tính", "mục tiêu", "thời gian", "dụng cụ"
|
| 372 |
+
]):
|
| 373 |
+
return True
|
| 374 |
+
|
| 375 |
+
return False
|
| 376 |
+
|
| 377 |
+
def _generate_exercise_summary(self, nutrition_data=None) -> str:
|
| 378 |
+
"""Generate summary of exercise advice for handoff"""
|
| 379 |
+
exercise_data = self.get_agent_data('exercise_plan')
|
| 380 |
+
user_profile = self.get_user_profile()
|
| 381 |
+
|
| 382 |
+
# Natural summary without robotic prefix
|
| 383 |
+
summary_parts = []
|
| 384 |
+
|
| 385 |
+
if exercise_data and isinstance(exercise_data, dict):
|
| 386 |
+
if 'goal' in exercise_data:
|
| 387 |
+
summary_parts.append(f"Mục tiêu: {exercise_data['goal']}")
|
| 388 |
+
if 'frequency' in exercise_data:
|
| 389 |
+
summary_parts.append(f"Tần suất: {exercise_data['frequency']}")
|
| 390 |
+
|
| 391 |
+
# Include nutrition data if available (agent-to-agent communication)
|
| 392 |
+
if nutrition_data and isinstance(nutrition_data, dict):
|
| 393 |
+
if 'daily_targets' in nutrition_data:
|
| 394 |
+
targets = nutrition_data['daily_targets']
|
| 395 |
+
summary_parts.append(f"Calo: {targets.get('calories', 'N/A')} kcal/ngày")
|
| 396 |
+
|
| 397 |
+
if user_profile and user_profile.get('fitness_level'):
|
| 398 |
+
summary_parts.append(f"Thể lực: {user_profile['fitness_level']}")
|
| 399 |
+
|
| 400 |
+
return " | ".join(summary_parts)[:100] if summary_parts else ""
|
| 401 |
+
|
| 402 |
+
def _handle_error(self, error, user_query):
|
| 403 |
+
"""Handle errors gracefully"""
|
| 404 |
+
return f"""Xin lỗi, mình gặp chút vấn đề khi tạo lịch tập. 😅
|
| 405 |
+
|
| 406 |
+
Lỗi: {str(error)}
|
| 407 |
+
|
| 408 |
+
Bạn có thể thử:
|
| 409 |
+
1. Cung cấp lại thông tin: tuổi, giới tính, thể lực, mục tiêu
|
| 410 |
+
2. Hỏi câu hỏi cụ thể hơn về tập luyện
|
| 411 |
+
3. Hoặc mình có thể tư vấn về chủ đề sức khỏe khác
|
| 412 |
+
|
| 413 |
+
Bạn muốn thử lại không? 💙"""
|
agents/specialized/general_health_agent.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
General Health Agent - Handles general health queries and conversations
|
| 3 |
+
Uses the comprehensive system prompt from helpers.py
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from config.settings import client, MODEL
|
| 7 |
+
from health_data import HealthContext
|
| 8 |
+
from health_analysis import HealthAnalyzer
|
| 9 |
+
from rag.rag_integration import get_rag_integration
|
| 10 |
+
from agents.core.base_agent import BaseAgent
|
| 11 |
+
from datetime import datetime
|
| 12 |
+
|
| 13 |
+
class GeneralHealthAgent(BaseAgent):
|
| 14 |
+
def __init__(self, memory=None):
|
| 15 |
+
super().__init__(memory)
|
| 16 |
+
self.health_context = None
|
| 17 |
+
self.analyzer = None
|
| 18 |
+
self.rag = get_rag_integration()
|
| 19 |
+
|
| 20 |
+
# Configure handoff triggers for general health agent
|
| 21 |
+
self.handoff_triggers = {
|
| 22 |
+
'symptom_agent': ['đau', 'sốt', 'ho', 'triệu chứng'],
|
| 23 |
+
'nutrition_agent': ['ăn gì', 'dinh dưỡng', 'calo', 'giảm cân'],
|
| 24 |
+
'exercise_agent': ['tập', 'gym', 'vận động'],
|
| 25 |
+
'mental_health_agent': ['stress', 'lo âu', 'trầm cảm']
|
| 26 |
+
}
|
| 27 |
+
# This is the comprehensive system prompt from helpers.py
|
| 28 |
+
# Keeping it here for the general health agent
|
| 29 |
+
self.system_prompt = """You are a caring, experienced healthcare consultant - think of yourself as a trusted family doctor who genuinely cares about each patient's wellbeing. You have the wisdom of experience but the warmth of a friend.
|
| 30 |
+
|
| 31 |
+
🏥 WHO YOU ARE:
|
| 32 |
+
You're a warm, approachable healthcare professional with deep knowledge in:
|
| 33 |
+
- **General Medicine:** Common illnesses, symptoms, preventive care
|
| 34 |
+
- **Nutrition & Wellness:** Diet, exercise, lifestyle optimization
|
| 35 |
+
- **Mental Health:** Stress management, anxiety, depression, emotional wellbeing, burnout, sleep issues, coping strategies
|
| 36 |
+
- **Chronic Conditions:** Diabetes, hypertension, heart health
|
| 37 |
+
|
| 38 |
+
**Be Naturally Conversational - Like a Friendly Doctor:**
|
| 39 |
+
- Talk like a real person having a caring conversation, not a textbook or robot
|
| 40 |
+
- Use natural, flowing language that builds rapport: "Tôi hiểu rồi, để mình hỏi thêm vài câu nhé..."
|
| 41 |
+
- Show empathy first, then provide information
|
| 42 |
+
- Add subtle, appropriate humor when suitable (not about serious conditions): "Haha, tôi hiểu, ngồi văn phòng nhiều thì cái lưng nó 'kêu cứu' đấy 😅"
|
| 43 |
+
- Use phrases like: "Nghe có vẻ...", "Để mình hiểu rõ hơn nhé", "À, vậy thì...", "Ừm, điều này quan trọng đấy"
|
| 44 |
+
- Avoid overly formal or clinical language - explain medical terms in everyday language that even elderly people understand
|
| 45 |
+
- Break up long responses with paragraphs and natural pauses
|
| 46 |
+
|
| 47 |
+
**Be Warm & Engaging:**
|
| 48 |
+
- Start responses with natural acknowledgment: "Cảm ơn bạn đã chia sẻ nhé", "Tôi hiểu rồi", "Được đấy"
|
| 49 |
+
- Use encouraging words naturally: "Tốt lắm đấy", "Bạn hỏi đúng rồi đấy", "Ý tưởng hay đấy"
|
| 50 |
+
- When acknowledging their situation, be accurate and balanced - don't over-praise or make it sound like their choice is the only good option
|
| 51 |
+
- NEVER end conversations abruptly - always leave the door open for more discussion
|
| 52 |
+
- Use appropriate emojis naturally (💙 🌟 💪 🙏 😊) but don't overdo it
|
| 53 |
+
|
| 54 |
+
**Master the Art of Follow-Up Questions:**
|
| 55 |
+
- Build information gradually, naturally - like a real conversation, not an interrogation
|
| 56 |
+
- Ask 2-3 questions at a time maximum, then wait for answers
|
| 57 |
+
- Make questions feel like natural curiosity: "À, mà bạn làm nghề gì vậy? Ngồi văn phòng hay đi đứng nhiều?"
|
| 58 |
+
- Connect questions to what they just said: "Bạn vừa nói đau lưng nhỉ, vậy bạn có ngồi máy tính nhiều không?"
|
| 59 |
+
- Use casual transitions: "À này", "Mà này", "Để mình hỏi thêm", "Còn về...", "Bạn thử kể cho mình nghe"
|
| 60 |
+
|
| 61 |
+
**NEVER Stop at Just One Answer - Always Extend the Conversation:**
|
| 62 |
+
After giving advice, ALWAYS suggest related topics they might need.
|
| 63 |
+
|
| 64 |
+
**Keep the Connection Alive:**
|
| 65 |
+
- NEVER end with just "Good luck" or "Hope this helps" - that's too abrupt
|
| 66 |
+
- Always invite further questions: "Có gì thắc mắc cứ hỏi mình nhé"
|
| 67 |
+
- Suggest next steps: "Bạn thử áp dụng 1-2 tuần rồi quay lại kể cho mình nghe nhé"
|
| 68 |
+
- Show ongoing support: "Mình luôn ở đây nếu bạn cần điều chỉnh gì"
|
| 69 |
+
|
| 70 |
+
⚖️ ETHICAL PRINCIPLES:
|
| 71 |
+
**Transparency (Be Honest):**
|
| 72 |
+
- Naturally mention: "Mình là trợ lý AI nên chỉ tư vấn chung chung thôi, bác sĩ thực sự sẽ khám và tư vấn chính xác hơn nhé"
|
| 73 |
+
- When unsure: "Cái này hơi ngoài chuyên môn mình, bạn nên hỏi chuyên gia về..."
|
| 74 |
+
|
| 75 |
+
**Do Good, Do No Harm:**
|
| 76 |
+
- Always prioritize their safety and wellbeing
|
| 77 |
+
- Never diagnose or prescribe: "Nghe có vẻ nghiêm trọng, bạn nên đi khám bác sĩ nhé"
|
| 78 |
+
- For serious symptoms, be direct but calm: "Mình hơi lo về triệu chứng này, bạn nên đi khám sớm để yên tâm"
|
| 79 |
+
|
| 80 |
+
🌐 LANGUAGE & TONE:
|
| 81 |
+
- **Vietnamese speakers:** Use warm, natural Vietnamese that all ages understand
|
| 82 |
+
- **English speakers:** Use friendly, conversational English
|
| 83 |
+
- Avoid medical jargon - if you must use it, explain immediately in simple terms
|
| 84 |
+
- Use analogies and examples: "Tim như một cái bơm, nếu bơm yếu thì máu không lên não đủ"
|
| 85 |
+
|
| 86 |
+
💫 CRITICAL REMINDERS:
|
| 87 |
+
1. **NEVER be robotic or formulaic** - You're a real person having a real conversation
|
| 88 |
+
2. **Build information GRADUALLY** - Ask 2-3 questions max, then wait. Don't interrogate.
|
| 89 |
+
3. **Use NATURAL transitions** - "À này", "Mà này", "Ừm", "Để mình hỏi thêm" - NOT numbered lists
|
| 90 |
+
4. **Add SUBTLE HUMOR** when appropriate - Make them smile, not just informed
|
| 91 |
+
5. **Explain in SIMPLE terms** - Elderly people, young people, everyone should understand
|
| 92 |
+
6. **ALWAYS extend the conversation** - Never end abruptly. Always suggest related topics.
|
| 93 |
+
7. **Show you're LISTENING** - Reference what they said earlier
|
| 94 |
+
8. **Be SPECIFIC, not generic** - Tailor advice to their age, job, fitness level, schedule"""
|
| 95 |
+
|
| 96 |
+
def set_health_context(self, health_context: HealthContext):
|
| 97 |
+
"""Inject health context and initialize health analyzer"""
|
| 98 |
+
self.health_context = health_context
|
| 99 |
+
self.analyzer = HealthAnalyzer(health_context)
|
| 100 |
+
|
| 101 |
+
def handle(self, parameters, chat_history=None):
|
| 102 |
+
"""
|
| 103 |
+
Handle general health queries
|
| 104 |
+
|
| 105 |
+
Args:
|
| 106 |
+
parameters (dict): {
|
| 107 |
+
"user_query": str
|
| 108 |
+
}
|
| 109 |
+
chat_history (list): Conversation history
|
| 110 |
+
|
| 111 |
+
Returns:
|
| 112 |
+
str: Response message
|
| 113 |
+
"""
|
| 114 |
+
user_query = parameters.get("user_query", "")
|
| 115 |
+
|
| 116 |
+
# Build messages with chat history
|
| 117 |
+
messages = [{"role": "system", "content": self.system_prompt}]
|
| 118 |
+
|
| 119 |
+
# Add recent chat history for context (last 10 exchanges)
|
| 120 |
+
if chat_history:
|
| 121 |
+
recent_history = chat_history[-10:] if len(chat_history) > 10 else chat_history
|
| 122 |
+
for user_msg, bot_msg in recent_history:
|
| 123 |
+
messages.append({"role": "user", "content": user_msg})
|
| 124 |
+
messages.append({"role": "assistant", "content": bot_msg})
|
| 125 |
+
|
| 126 |
+
# Add current message
|
| 127 |
+
messages.append({"role": "user", "content": user_query})
|
| 128 |
+
|
| 129 |
+
try:
|
| 130 |
+
# Smart RAG decision - skip for simple queries
|
| 131 |
+
rag_answer = ''
|
| 132 |
+
rag_sources = []
|
| 133 |
+
|
| 134 |
+
if self.should_use_rag(user_query, chat_history):
|
| 135 |
+
# Query RAG for health knowledge
|
| 136 |
+
rag_result = self.rag.query_health(user_query)
|
| 137 |
+
rag_answer = rag_result.get('answer', '')
|
| 138 |
+
rag_sources = rag_result.get('source_docs', [])
|
| 139 |
+
|
| 140 |
+
# Add RAG context to messages
|
| 141 |
+
if rag_answer:
|
| 142 |
+
messages.insert(1, {"role": "system", "content": f"Thông tin tham khảo từ cơ sở dữ liệu:\n{rag_answer}"})
|
| 143 |
+
|
| 144 |
+
response = client.chat.completions.create(
|
| 145 |
+
model=MODEL,
|
| 146 |
+
messages=messages,
|
| 147 |
+
temperature=0.7,
|
| 148 |
+
max_tokens=2000
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
bot_response = response.choices[0].message.content
|
| 152 |
+
|
| 153 |
+
# Add sources using RAG integration formatter (FIXED!)
|
| 154 |
+
if rag_sources:
|
| 155 |
+
bot_response = self.rag.format_response_with_sources({
|
| 156 |
+
'answer': bot_response,
|
| 157 |
+
'source_docs': rag_sources
|
| 158 |
+
})
|
| 159 |
+
|
| 160 |
+
# Get health insights if analyzer is available
|
| 161 |
+
health_insights = {}
|
| 162 |
+
if self.analyzer:
|
| 163 |
+
try:
|
| 164 |
+
health_insights = {
|
| 165 |
+
'health_score': self.analyzer.calculate_health_score(),
|
| 166 |
+
'risks': self.analyzer.identify_health_risks(),
|
| 167 |
+
'recommendations': self.analyzer.recommend_preventive_measures()
|
| 168 |
+
}
|
| 169 |
+
except:
|
| 170 |
+
pass
|
| 171 |
+
|
| 172 |
+
# Persist general health query
|
| 173 |
+
if self.health_context:
|
| 174 |
+
self.health_context.add_health_record('general_health', {
|
| 175 |
+
'query': user_query,
|
| 176 |
+
'response': bot_response,
|
| 177 |
+
'health_insights': health_insights,
|
| 178 |
+
'rag_sources': len(rag_sources),
|
| 179 |
+
'timestamp': datetime.now().isoformat()
|
| 180 |
+
})
|
| 181 |
+
|
| 182 |
+
return bot_response
|
| 183 |
+
|
| 184 |
+
except Exception as e:
|
| 185 |
+
return f"""Xin lỗi, mình gặp chút vấn đề kỹ thuật. 😅
|
| 186 |
+
|
| 187 |
+
Lỗi: {str(e)}
|
| 188 |
+
|
| 189 |
+
Bạn có thể thử:
|
| 190 |
+
1. Hỏi lại câu hỏi
|
| 191 |
+
2. Hỏi câu hỏi khác về sức khỏe
|
| 192 |
+
3. Hoặc chờ một chút rồi thử lại
|
| 193 |
+
|
| 194 |
+
Mình xin lỗi vì sự bất tiện này! 🙏"""
|
agents/specialized/mental_health_agent.py
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Mental Health Agent - Specialized agent for mental health support
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from config.settings import client, MODEL
|
| 6 |
+
from health_data import HealthContext
|
| 7 |
+
from personalization import PersonalizationEngine
|
| 8 |
+
from rag.rag_integration import get_rag_integration
|
| 9 |
+
from agents.core.base_agent import BaseAgent
|
| 10 |
+
from agents.core.context_analyzer import ContextAnalyzer
|
| 11 |
+
from agents.core.response_validator import ResponseValidator
|
| 12 |
+
from datetime import datetime
|
| 13 |
+
|
| 14 |
+
class MentalHealthAgent(BaseAgent):
|
| 15 |
+
def __init__(self, memory=None):
|
| 16 |
+
super().__init__(memory)
|
| 17 |
+
self.health_context = None
|
| 18 |
+
self.personalization = None
|
| 19 |
+
self.rag = get_rag_integration()
|
| 20 |
+
|
| 21 |
+
# Configure handoff triggers for mental health agent
|
| 22 |
+
self.handoff_triggers = {
|
| 23 |
+
'symptom_agent': ['đau đầu', 'mất ngủ kéo dài', 'tim đập nhanh'],
|
| 24 |
+
'nutrition_agent': ['ăn uống', 'chán ăn', 'ăn vô độ'],
|
| 25 |
+
'exercise_agent': ['tập thể dục', 'yoga', 'thiền'],
|
| 26 |
+
'general_health_agent': ['thuốc', 'bác sĩ tâm lý', 'điều trị']
|
| 27 |
+
}
|
| 28 |
+
self.system_prompt = """Bạn là nhà tâm lý trị liệu chuyên nghiệp, ấm áp và thấu hiểu.
|
| 29 |
+
|
| 30 |
+
🧠 CHUYÊN MÔN:
|
| 31 |
+
- Hỗ trợ stress, lo âu, trầm cảm
|
| 32 |
+
- Tư vấn burnout, mất ngủ
|
| 33 |
+
- Kỹ thuật thư giãn, mindfulness
|
| 34 |
+
- Quản lý cảm xúc
|
| 35 |
+
- Cải thiện giấc ngủ
|
| 36 |
+
|
| 37 |
+
🎯 CÁCH TƯ VẤN:
|
| 38 |
+
- Lắng nghe, thấu hiểu, không phán xét
|
| 39 |
+
- Validate cảm xúc: "Cảm giác của bạn là hợp lý"
|
| 40 |
+
- Normalize: "Nhiều người cũng trải qua điều này"
|
| 41 |
+
- Đưa ra kỹ thuật cụ thể (breathing, journaling...)
|
| 42 |
+
- Khuyến khích tìm kiếm sự hỗ trợ
|
| 43 |
+
|
| 44 |
+
🚨 RED FLAGS - Khuyên gặp chuyên gia NGAY:
|
| 45 |
+
- Ý định tự tử hoặc tự gây thương tích
|
| 46 |
+
- Ý định gây hại người khác
|
| 47 |
+
- Ảo giác, hoang tưởng
|
| 48 |
+
- Trầm cảm nặng không thể hoạt động
|
| 49 |
+
- Nghiện rượu, ma túy
|
| 50 |
+
|
| 51 |
+
⚠️ AN TOÀN:
|
| 52 |
+
- KHÔNG chẩn đoán bệnh tâm thần
|
| 53 |
+
- KHÔNG kê đơn thuốc
|
| 54 |
+
- Luôn khuyên gặp chuyên gia với vấn đề nghiêm trọng
|
| 55 |
+
- Cung cấp hotline khủng hoảng khi cần
|
| 56 |
+
|
| 57 |
+
💬 PHONG CÁCH:
|
| 58 |
+
- Ấm áp, đồng cảm 💙
|
| 59 |
+
- Kiên nhẫn, không vội vàng
|
| 60 |
+
- Tôn trọng, không phán xét
|
| 61 |
+
- Trấn an nhưng thực tế
|
| 62 |
+
- Khuyến khích, động viên"""
|
| 63 |
+
|
| 64 |
+
def set_health_context(self, health_context: HealthContext):
|
| 65 |
+
"""Inject health context and initialize personalization engine"""
|
| 66 |
+
self.health_context = health_context
|
| 67 |
+
self.personalization = PersonalizationEngine(health_context)
|
| 68 |
+
|
| 69 |
+
def handle(self, parameters, chat_history=None):
|
| 70 |
+
"""
|
| 71 |
+
Handle mental health support request
|
| 72 |
+
|
| 73 |
+
Args:
|
| 74 |
+
parameters (dict): {
|
| 75 |
+
"user_query": str,
|
| 76 |
+
"context": dict (optional)
|
| 77 |
+
}
|
| 78 |
+
chat_history (list): Conversation history
|
| 79 |
+
|
| 80 |
+
Returns:
|
| 81 |
+
str: Response message
|
| 82 |
+
"""
|
| 83 |
+
user_query = parameters.get("user_query", "")
|
| 84 |
+
context = parameters.get("context", {})
|
| 85 |
+
|
| 86 |
+
# Check for crisis situations first
|
| 87 |
+
crisis_response = self._check_crisis(user_query, chat_history)
|
| 88 |
+
if crisis_response:
|
| 89 |
+
# Persist crisis alert
|
| 90 |
+
if self.health_context:
|
| 91 |
+
self.health_context.add_health_record('mental_health', {
|
| 92 |
+
'query': user_query,
|
| 93 |
+
'type': 'crisis_alert',
|
| 94 |
+
'response': crisis_response,
|
| 95 |
+
'timestamp': datetime.now().isoformat()
|
| 96 |
+
})
|
| 97 |
+
return crisis_response
|
| 98 |
+
|
| 99 |
+
# Provide mental health support
|
| 100 |
+
response = self._provide_support(user_query, context, chat_history)
|
| 101 |
+
|
| 102 |
+
# Adapt communication style using personalization
|
| 103 |
+
if self.personalization:
|
| 104 |
+
preferences = self.health_context.get_preferences() if self.health_context else None
|
| 105 |
+
if preferences:
|
| 106 |
+
adapted_response = self.personalization.adapt_communication_style(response)
|
| 107 |
+
else:
|
| 108 |
+
adapted_response = response
|
| 109 |
+
else:
|
| 110 |
+
adapted_response = response
|
| 111 |
+
|
| 112 |
+
# Persist mental health data
|
| 113 |
+
if self.health_context:
|
| 114 |
+
self.health_context.add_health_record('mental_health', {
|
| 115 |
+
'query': user_query,
|
| 116 |
+
'response': adapted_response,
|
| 117 |
+
'context': context,
|
| 118 |
+
'timestamp': datetime.now().isoformat()
|
| 119 |
+
})
|
| 120 |
+
|
| 121 |
+
return adapted_response
|
| 122 |
+
|
| 123 |
+
def _check_crisis(self, user_query, chat_history):
|
| 124 |
+
"""Check for mental health crisis situations"""
|
| 125 |
+
all_text = user_query.lower()
|
| 126 |
+
if chat_history:
|
| 127 |
+
all_text += " " + " ".join([msg[0].lower() for msg in chat_history if msg[0]])
|
| 128 |
+
|
| 129 |
+
# Suicide risk
|
| 130 |
+
suicide_keywords = ["tự tử", "muốn chết", "kết thúc cuộc đời", "không muốn sống",
|
| 131 |
+
"suicide", "kill myself", "end my life"]
|
| 132 |
+
if any(keyword in all_text for keyword in suicide_keywords):
|
| 133 |
+
return """🚨 **KHẨN CẤP - BẠN KHÔNG CÔ ĐƠN**
|
| 134 |
+
|
| 135 |
+
Mình rất lo lắng về bạn. Những suy nghĩ này rất nghiêm trọng và bạn cần được hỗ trợ ngay.
|
| 136 |
+
|
| 137 |
+
🆘 **HÃY LIÊN HỆ NGAY:**
|
| 138 |
+
|
| 139 |
+
**Đường dây nóng tâm lý:**
|
| 140 |
+
- 📞 **115** - Cấp cứu y tế (Trung tâm Cấp cứu 115 TP.HCM)
|
| 141 |
+
- 📞 **1900 1267** - Chuyên gia tâm thần (Bệnh viện Tâm Thần TP.HCM)
|
| 142 |
+
- 📞 **0909 65 80 35** - Tư vấn tâm lý miễn phí (Chăm sóc sức khỏe Việt - Davipharm)
|
| 143 |
+
|
| 144 |
+
**Hoặc:**
|
| 145 |
+
- Nói chuyện với người thân, bạn bè NGAY
|
| 146 |
+
- Đến bệnh viện tâm thần gần nhất
|
| 147 |
+
- Nhắn tin cho ai đó bạn tin tưởng
|
| 148 |
+
|
| 149 |
+
💙 **BẠN QUAN TRỌNG. CUỘC SỐNG CỦA BẠN CÓ GIÁ TRỊ.**
|
| 150 |
+
|
| 151 |
+
Những cảm giác này sẽ qua đi. Có người sẵn sàng giúp bạn. Hãy cho họ cơ hội.
|
| 152 |
+
|
| 153 |
+
Bạn có thể gọi ngay bây giờ không? Hoặc có ai bạn có thể nói chuyện không?"""
|
| 154 |
+
|
| 155 |
+
# Self-harm
|
| 156 |
+
self_harm_keywords = ["tự làm đau", "cắt tay", "tự gây thương tích", "self harm", "cut myself"]
|
| 157 |
+
if any(keyword in all_text for keyword in self_harm_keywords):
|
| 158 |
+
return """⚠️ **CẦN HỖ TRỢ KHẨN CẤP**
|
| 159 |
+
|
| 160 |
+
Mình rất lo lắng về bạn. Tự gây thương tích là dấu hiệu bạn đang đau khổ và cần được giúp đỡ.
|
| 161 |
+
|
| 162 |
+
🆘 **HÃY LIÊN HỆ:**
|
| 163 |
+
- 📞 **115** - Cấp cứu y tế (Trung tâm Cấp cứu 115 TP.HCM)
|
| 164 |
+
- 📞 **1900 1267** - Chuyên gia tâm thần (Bệnh viện Tâm Thần TP.HCM)
|
| 165 |
+
- 📞 **0909 65 80 35** - Tư vấn tâm lý miễn phí (Chăm sóc sức khỏe Việt - Davipharm)
|
| 166 |
+
|
| 167 |
+
💙 **Bạn xứng đáng được chăm sóc và hỗ trợ.**
|
| 168 |
+
|
| 169 |
+
Có những cách khác để đối phó với cảm xúc khó khăn. Chuyên gia tâm lý có thể giúp bạn tìm ra cách lành mạnh hơn.
|
| 170 |
+
|
| 171 |
+
Bạn có thể gọi ngay bây giờ không?"""
|
| 172 |
+
|
| 173 |
+
return None
|
| 174 |
+
|
| 175 |
+
def _build_mental_health_context_instruction(self, user_query, chat_history, context):
|
| 176 |
+
"""
|
| 177 |
+
Build context instruction for mental health queries
|
| 178 |
+
"""
|
| 179 |
+
is_vague = context.get('is_vague', False)
|
| 180 |
+
|
| 181 |
+
# Handle vague emotional queries
|
| 182 |
+
if is_vague:
|
| 183 |
+
return """\n\nPHASE: THẤU HIỂU CẢM XÚC (VỚI GỢI Ý)
|
| 184 |
+
User đang cảm thấy không ổn nhưng chưa rõ ràng. Empathy + gợi ý:
|
| 185 |
+
|
| 186 |
+
1. VALIDATE + GỢI Ý CỤ THỂ:
|
| 187 |
+
Format: "Mình hiểu bạn đang [cảm giác user nói]. Bạn có thể chia sẻ thêm không? Ví dụ:
|
| 188 |
+
• [Gợi ý cảm xúc 1]
|
| 189 |
+
• [Gợi ý cảm xúc 2]
|
| 190 |
+
• [Gợi ý cảm xúc 3]
|
| 191 |
+
• Hoặc điều gì khác?"
|
| 192 |
+
|
| 193 |
+
2. GỢI Ý DỰA VÀO TỪ KHÓA:
|
| 194 |
+
- "mệt" → gợi ý: mệt mỏi tinh thần, burnout, stress, mất ngủ
|
| 195 |
+
- "không khỏe" → gợi ý: lo âu, buồn bã, căng thẳng, cô đơn
|
| 196 |
+
- "khó chịu" → gợi ý: bực bội, tức giận, thất vọng, áp lực
|
| 197 |
+
- "không ổn" → gợi ý: lo lắng, trầm cảm, bất an, mất phương hướng
|
| 198 |
+
|
| 199 |
+
3. VÍ DỤ CỤ THỂ:
|
| 200 |
+
User: "tôi mệt"
|
| 201 |
+
Bot: "Mình hiểu bạn đang cảm thấy mệt mỏi. Bạn có thể chia sẻ thêm không? Ví dụ:
|
| 202 |
+
• Mệt mỏi về tinh thần, cảm thấy kiệt sức?
|
| 203 |
+
• Stress từ công việc hoặc học tập?
|
| 204 |
+
• Mất ngủ, ngủ không ngon giấc?
|
| 205 |
+
• Hay điều gì khác đang làm bạn khó chịu?"
|
| 206 |
+
|
| 207 |
+
QUAN TRỌNG:
|
| 208 |
+
- Empathy cao, ấm áp
|
| 209 |
+
- Gợi ý về CẢM XÚC, không phải triệu chứng vật lý
|
| 210 |
+
- Luôn có "hoặc điều gì khác"
|
| 211 |
+
- Không ép buộc, để user tự chia sẻ"""
|
| 212 |
+
|
| 213 |
+
# Check if answering comparison self-assessment
|
| 214 |
+
if chat_history and len(chat_history) > 0:
|
| 215 |
+
last_bot_msg = chat_history[-1][1] if len(chat_history[-1]) > 1 else ""
|
| 216 |
+
if "TỰ KIỂM TRA" in last_bot_msg or "Bạn trả lời" in last_bot_msg:
|
| 217 |
+
return """\n\nPHASE: PHÂN TÍCH TÌNH TRẠNG TINH THẦN
|
| 218 |
+
User vừa trả lời. Phân tích với empathy:
|
| 219 |
+
|
| 220 |
+
1. NHẬN DIỆN (dựa vào RAG):
|
| 221 |
+
- Đọc kỹ cảm xúc, triệu chứng
|
| 222 |
+
- So sánh với các tình trạng (stress/anxiety/burnout...)
|
| 223 |
+
- Đưa ra đánh giá nhẹ nhàng
|
| 224 |
+
|
| 225 |
+
2. VALIDATE & NORMALIZE:
|
| 226 |
+
"Cảm giác của bạn là bình thường. Nhiều người cũng trải qua điều này."
|
| 227 |
+
|
| 228 |
+
3. KỸ THUẬT ĐỐI PHÓ:
|
| 229 |
+
- Cụ thể, dễ thực hiện
|
| 230 |
+
- Breathing, journaling, grounding...
|
| 231 |
+
- Giải thích tại sao hiệu quả
|
| 232 |
+
|
| 233 |
+
4. KHUYẺN KHÍCH:
|
| 234 |
+
- Nếu nhẹ: "Bạn thử các kỸ thuật này nhé"
|
| 235 |
+
- Nếu nặng: "Nên tìm chuyên gia hỗ trợ"
|
| 236 |
+
|
| 237 |
+
QUAN TRỌNG: Empathy + practical help."""
|
| 238 |
+
|
| 239 |
+
# Check if asking comparison
|
| 240 |
+
if any(phrase in user_query.lower() for phrase in [
|
| 241 |
+
'stress hay', 'anxiety hay', 'khác nhau thế nào',
|
| 242 |
+
'phân biệt', 'hay là'
|
| 243 |
+
]):
|
| 244 |
+
return """\n\nPHASE: SO SÁNH TÌNH TRẠNG TINH THẦN
|
| 245 |
+
User muốn hiểu rõ hơn. Sử dụng RAG:
|
| 246 |
+
|
| 247 |
+
1. TẠO BẢNG SO SÁNH:
|
| 248 |
+
Format:
|
| 249 |
+
**[Tình trạng A]:**
|
| 250 |
+
• Cảm giác: [feelings]
|
| 251 |
+
• Triệu chứng: [symptoms]
|
| 252 |
+
• Thời gian: [duration]
|
| 253 |
+
• Trigger: [causes]
|
| 254 |
+
|
| 255 |
+
**[Tình trạng B]:**
|
| 256 |
+
• Cảm giác: [feelings]
|
| 257 |
+
• Triệu chứng: [symptoms]
|
| 258 |
+
• Thời gian: [duration]
|
| 259 |
+
• Trigger: [causes]
|
| 260 |
+
|
| 261 |
+
**Điểm khác biệt:** [key differences]
|
| 262 |
+
|
| 263 |
+
2. CÂU HỊI TỰ KIỂM TRA:
|
| 264 |
+
• Bạn cảm thấy thế nào?
|
| 265 |
+
• Kéo dài bao lâu?
|
| 266 |
+
• Có trigger rõ ràng không?
|
| 267 |
+
• Ảnh hưởng đến sinh hoạt không?
|
| 268 |
+
|
| 269 |
+
3. LUÔN EMPATHY:
|
| 270 |
+
"Dù là gì, cảm giác của bạn đều quan trọng."
|
| 271 |
+
|
| 272 |
+
4. Kết thúc: "Bạn chia sẻ để mình hiểu rõ hơn nhé!"
|
| 273 |
+
|
| 274 |
+
QUAN TRỌNG: Dùng RAG, empathy cao."""
|
| 275 |
+
|
| 276 |
+
# Normal support
|
| 277 |
+
return """\n\nĐưa ra hỗ trợ tinh thần:
|
| 278 |
+
- Empathy & validation
|
| 279 |
+
- KỸ thuật cụ thể
|
| 280 |
+
- Khuyến khích tìm chuyên gia nếu cần
|
| 281 |
+
KHÔNG nói "Dựa trên thông tin"."""
|
| 282 |
+
|
| 283 |
+
def _provide_support(self, user_query, context, chat_history):
|
| 284 |
+
"""Provide mental health support with comparison and vague query handling"""
|
| 285 |
+
try:
|
| 286 |
+
# Analyze context
|
| 287 |
+
analyzed_context = ContextAnalyzer.analyze_user_intent(user_query, chat_history)
|
| 288 |
+
|
| 289 |
+
# Build context from chat history
|
| 290 |
+
history_context = ""
|
| 291 |
+
if chat_history:
|
| 292 |
+
recent = chat_history[-3:]
|
| 293 |
+
history_context = "\n".join([f"User: {msg[0]}\nBot: {msg[1]}" for msg in recent])
|
| 294 |
+
|
| 295 |
+
# Smart RAG - only query when needed (inherit from BaseAgent)
|
| 296 |
+
rag_answer = ''
|
| 297 |
+
rag_sources = []
|
| 298 |
+
|
| 299 |
+
if self.should_use_rag(user_query, chat_history):
|
| 300 |
+
rag_result = self.rag.query_health(user_query)
|
| 301 |
+
rag_answer = rag_result.get('answer', '')
|
| 302 |
+
rag_sources = rag_result.get('source_docs', [])
|
| 303 |
+
|
| 304 |
+
# Build RAG context
|
| 305 |
+
rag_context = f"\n\nThông tin tham khảo từ cơ sở dữ liệu:\n{rag_answer}" if rag_answer else ""
|
| 306 |
+
|
| 307 |
+
# Build context instruction
|
| 308 |
+
context_instruction = self._build_mental_health_context_instruction(
|
| 309 |
+
user_query, chat_history, analyzed_context
|
| 310 |
+
)
|
| 311 |
+
|
| 312 |
+
response = client.chat.completions.create(
|
| 313 |
+
model=MODEL,
|
| 314 |
+
messages=[
|
| 315 |
+
{"role": "system", "content": self.system_prompt},
|
| 316 |
+
{"role": "user", "content": f"""Người dùng đang tìm kiếm hỗ trợ về sức khỏe tinh thần.
|
| 317 |
+
|
| 318 |
+
Lịch sử hội thoại gần đây:
|
| 319 |
+
{history_context}
|
| 320 |
+
|
| 321 |
+
Câu hỏi hiện tại: {user_query}
|
| 322 |
+
|
| 323 |
+
Ngữ cảnh thêm: {context}{rag_context}
|
| 324 |
+
|
| 325 |
+
{context_instruction}
|
| 326 |
+
|
| 327 |
+
Nhớ: Không chẩn đoán, không kê đơn thuốc."""}
|
| 328 |
+
],
|
| 329 |
+
temperature=0.8,
|
| 330 |
+
max_tokens=1500
|
| 331 |
+
)
|
| 332 |
+
|
| 333 |
+
base_response = response.choices[0].message.content
|
| 334 |
+
|
| 335 |
+
# Add sources using RAG integration formatter (FIXED!)
|
| 336 |
+
if rag_sources:
|
| 337 |
+
base_response = self.rag.format_response_with_sources({
|
| 338 |
+
'answer': base_response,
|
| 339 |
+
'source_docs': rag_sources
|
| 340 |
+
})
|
| 341 |
+
|
| 342 |
+
# Add resource information
|
| 343 |
+
base_response += """
|
| 344 |
+
|
| 345 |
+
---
|
| 346 |
+
|
| 347 |
+
💙 **Nếu cần hỗ trợ chuyên môn:**
|
| 348 |
+
- Nếu cần nói chuyện với chuyên gia, đừng ngại đặt lịch tâm lý trị liệu nhé!
|
| 349 |
+
|
| 350 |
+
Mình luôn ở đây nếu bạn cần trò chuyện thêm. Bạn không cô đơn! 🤗"""
|
| 351 |
+
|
| 352 |
+
return base_response
|
| 353 |
+
|
| 354 |
+
except Exception as e:
|
| 355 |
+
return """Mình hiểu bạn đang trải qua thời gian khó khăn. 💙
|
| 356 |
+
|
| 357 |
+
Dù mình gặp chút vấn đề kỹ thuật, nhưng mình muốn bạn biết:
|
| 358 |
+
- Cảm xúc của bạn là hợp lệ
|
| 359 |
+
- Nhiều người cũng trải qua điều tương tự
|
| 360 |
+
- Có sự hỗ trợ dành cho bạn
|
| 361 |
+
|
| 362 |
+
🆘 **Nếu bạn cần hỗ trợ khẩn cấp:**
|
| 363 |
+
- 📞 **115** - Cấp cứu y tế (Trung tâm Cấp cứu 115 TP.HCM)
|
| 364 |
+
- 📞 **1900 1267** - Chuyên gia tâm thần (Bệnh viện Tâm Thần TP.HCM)
|
| 365 |
+
- 📞 **0909 65 80 35** - Tư vấn tâm lý miễn phí (Chăm sóc sức khỏe Việt - Davipharm)
|
| 366 |
+
- Hoặc tìm đến bạn bè, người thân
|
| 367 |
+
|
| 368 |
+
Bạn có muốn chia sẻ thêm về những gì bạn đang cảm thấy không?"""
|
agents/specialized/nutrition_agent.py
ADDED
|
@@ -0,0 +1,598 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Nutrition Agent - Specialized agent for nutrition advice
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from config.settings import client, MODEL
|
| 6 |
+
from modules.nutrition import NutritionAdvisor
|
| 7 |
+
from health_data import HealthContext
|
| 8 |
+
from personalization import PersonalizationEngine
|
| 9 |
+
from rag.rag_integration import get_rag_integration
|
| 10 |
+
from agents.core.base_agent import BaseAgent
|
| 11 |
+
from agents.core.context_analyzer import ContextAnalyzer
|
| 12 |
+
from agents.core.response_validator import ResponseValidator
|
| 13 |
+
from typing import Dict, Any, List, Optional
|
| 14 |
+
from datetime import datetime
|
| 15 |
+
import re
|
| 16 |
+
|
| 17 |
+
class NutritionAgent(BaseAgent):
|
| 18 |
+
def __init__(self, memory=None):
|
| 19 |
+
super().__init__(memory)
|
| 20 |
+
self.advisor = NutritionAdvisor()
|
| 21 |
+
self.health_context = None
|
| 22 |
+
self.personalization = None
|
| 23 |
+
self.rag = get_rag_integration()
|
| 24 |
+
|
| 25 |
+
# Configure handoff triggers for nutrition agent
|
| 26 |
+
self.handoff_triggers = {
|
| 27 |
+
'exercise_agent': ['tập', 'gym', 'cardio', 'yoga', 'chạy bộ', 'thể dục', 'vận động'],
|
| 28 |
+
'symptom_agent': ['đau bụng', 'buồn nôn', 'tiêu chảy', 'dị ứng', 'ngộ độc'],
|
| 29 |
+
'mental_health_agent': ['stress', 'lo âu', 'mất ngủ', 'ăn không ngon'],
|
| 30 |
+
'general_health_agent': ['khám', 'xét nghiệm', 'bác sĩ']
|
| 31 |
+
}
|
| 32 |
+
self.system_prompt = """Bạn là chuyên gia dinh dưỡng chuyên nghiệp.
|
| 33 |
+
|
| 34 |
+
🥗 CHUYÊN MÔN:
|
| 35 |
+
- Tư vấn dinh dưỡng cá nhân hóa dựa trên BMI, tuổi, giới tính, mục tiêu
|
| 36 |
+
- Tính toán calo, macro (protein/carb/fat)
|
| 37 |
+
- Gợi ý thực đơn phù hợp
|
| 38 |
+
- Tư vấn thực phẩm bổ sung
|
| 39 |
+
- Hướng dẫn ăn uống cho các bệnh lý (tiểu đường, huyết áp, tim mạch...)
|
| 40 |
+
|
| 41 |
+
🎯 CÁCH TƯ VẤN:
|
| 42 |
+
|
| 43 |
+
1. **KIỂM TRA THÔNG TIN TRƯỚC KHI HỎI:**
|
| 44 |
+
- ĐỌC KỸ chat history - user có thể đã cung cấp thông tin rồi!
|
| 45 |
+
- Nếu user đã nói "tôi 25 tuổi, nam, 70kg, 175cm" → ĐỪNG HỎI LẠI!
|
| 46 |
+
- Chỉ hỏi thông tin THỰC SỰ còn thiếu
|
| 47 |
+
- Nếu đã đủ (tuổi, giới tính, cân nặng, chiều cao) → ĐƯA KHUYẾN NGHỊ NGAY!
|
| 48 |
+
|
| 49 |
+
2. **ƯU TIÊN THÔNG TIN:**
|
| 50 |
+
- Câu 1: Mục tiêu (giảm cân/tăng cân/duy trì?)
|
| 51 |
+
- Câu 2: Cân nặng, chiều cao (để tính BMI)
|
| 52 |
+
- Câu 3: Mức độ hoạt động (ít/vừa/nhiều)
|
| 53 |
+
- Câu 4 (nếu cần): Bệnh nền, dị ứng
|
| 54 |
+
|
| 55 |
+
3. **KHI USER KHÔNG MUỐN CUNG CẤP:**
|
| 56 |
+
- User nói "không biết", "không muốn nói", "tư vấn chung thôi"
|
| 57 |
+
- → DỪNG hỏi, đưa khuyến nghị chung
|
| 58 |
+
- Dựa trên thông tin ĐÃ CÓ để tư vấn
|
| 59 |
+
|
| 60 |
+
4. **ĐƯA KHUYẾN NGHỊ:**
|
| 61 |
+
- Nếu có đủ thông tin: Tính calo, macro cụ thể
|
| 62 |
+
- Nếu thiếu thông tin: Đưa khuyến nghị chung (400g rau củ, protein đủ, etc.)
|
| 63 |
+
- Gợi ý thực đơn mẫu
|
| 64 |
+
- KHÔNG hỏi thêm nữa
|
| 65 |
+
|
| 66 |
+
⚠️ AN TOÀN:
|
| 67 |
+
- Luôn khuyên gặp bác sĩ dinh dưỡng cho các vấn đề phức tạp
|
| 68 |
+
- Cảnh báo về các chế độ ăn kiêng cực đoan
|
| 69 |
+
- Lưu ý về dị ứng, bệnh nền
|
| 70 |
+
|
| 71 |
+
💬 PHONG CÁCH:
|
| 72 |
+
- Chuyên nghiệp, rõ ràng, súc tích
|
| 73 |
+
- Dùng "tôi" để thể hiện tính chuyên môn
|
| 74 |
+
- KHÔNG dùng emoji
|
| 75 |
+
- Đưa ra con số cụ thể khi có thể
|
| 76 |
+
- Thực tế, không lý thuyết suông
|
| 77 |
+
- TỰ NHIÊN, MẠCH LẠC - không lặp lại ý, không copy-paste câu từ context khác
|
| 78 |
+
- Nếu hỏi thông tin → Hỏi NGẮN GỌN, TRỰC TIẾP
|
| 79 |
+
- KHÔNG dùng câu như "Bạn thử làm theo xem có đỡ không" (đây là câu của bác sĩ chữa bệnh!)"""
|
| 80 |
+
|
| 81 |
+
def set_health_context(self, health_context: HealthContext):
|
| 82 |
+
"""Inject health context and initialize personalization engine"""
|
| 83 |
+
self.health_context = health_context
|
| 84 |
+
self.personalization = PersonalizationEngine(health_context)
|
| 85 |
+
|
| 86 |
+
def handle(self, parameters, chat_history=None):
|
| 87 |
+
"""
|
| 88 |
+
Handle nutrition request using LLM for natural conversation
|
| 89 |
+
|
| 90 |
+
Args:
|
| 91 |
+
parameters (dict): {
|
| 92 |
+
"user_query": str,
|
| 93 |
+
"user_data": dict (optional)
|
| 94 |
+
}
|
| 95 |
+
chat_history (list): Conversation history
|
| 96 |
+
|
| 97 |
+
Returns:
|
| 98 |
+
str: Response message
|
| 99 |
+
"""
|
| 100 |
+
user_query = parameters.get("user_query", "")
|
| 101 |
+
user_data = parameters.get("user_data", {})
|
| 102 |
+
|
| 103 |
+
# Extract and save user info from current message immediately
|
| 104 |
+
self.extract_and_save_user_info(user_query)
|
| 105 |
+
|
| 106 |
+
# Update memory from chat history
|
| 107 |
+
if chat_history:
|
| 108 |
+
self.update_memory_from_history(chat_history)
|
| 109 |
+
|
| 110 |
+
# Check if we should hand off to another agent
|
| 111 |
+
if self.should_handoff(user_query, chat_history):
|
| 112 |
+
next_agent = self.suggest_next_agent(user_query)
|
| 113 |
+
if next_agent:
|
| 114 |
+
# Save current nutrition data for next agent
|
| 115 |
+
self.save_agent_data('last_nutrition_advice', {
|
| 116 |
+
'query': user_query,
|
| 117 |
+
'user_profile': self.get_user_profile(),
|
| 118 |
+
'timestamp': datetime.now().isoformat()
|
| 119 |
+
})
|
| 120 |
+
|
| 121 |
+
# Create handoff message with context
|
| 122 |
+
context = self._generate_nutrition_summary()
|
| 123 |
+
return self.create_handoff_message(next_agent, context, user_query)
|
| 124 |
+
|
| 125 |
+
# Use health context if available
|
| 126 |
+
if self.health_context:
|
| 127 |
+
profile = self.health_context.get_user_profile()
|
| 128 |
+
user_data = {
|
| 129 |
+
'age': profile.age,
|
| 130 |
+
'gender': profile.gender,
|
| 131 |
+
'weight': profile.weight,
|
| 132 |
+
'height': profile.height,
|
| 133 |
+
'activity_level': profile.activity_level,
|
| 134 |
+
'health_conditions': profile.health_conditions,
|
| 135 |
+
'dietary_restrictions': profile.dietary_restrictions
|
| 136 |
+
}
|
| 137 |
+
# Extract user data from chat history if not provided
|
| 138 |
+
elif not user_data and chat_history:
|
| 139 |
+
user_data = self._extract_user_data_from_history(chat_history)
|
| 140 |
+
# Save extracted data to shared memory for other agents
|
| 141 |
+
for key, value in user_data.items():
|
| 142 |
+
if value is not None:
|
| 143 |
+
self.update_user_profile(key, value)
|
| 144 |
+
|
| 145 |
+
# Check if user needs personalized advice (BMI, calories, meal plan)
|
| 146 |
+
needs_personalization = self._needs_personalized_advice(user_query, chat_history)
|
| 147 |
+
|
| 148 |
+
if needs_personalization:
|
| 149 |
+
# Check if we have enough data
|
| 150 |
+
missing_fields = self._check_missing_data(user_data)
|
| 151 |
+
|
| 152 |
+
if missing_fields:
|
| 153 |
+
return self._ask_for_missing_data(missing_fields, user_data, user_query)
|
| 154 |
+
|
| 155 |
+
# Generate personalized nutrition advice
|
| 156 |
+
try:
|
| 157 |
+
result = self.advisor.generate_nutrition_advice(user_data)
|
| 158 |
+
|
| 159 |
+
# Adapt recommendations using personalization engine
|
| 160 |
+
if self.personalization:
|
| 161 |
+
adapted_result = self.personalization.adapt_nutrition_plan(result)
|
| 162 |
+
else:
|
| 163 |
+
adapted_result = result
|
| 164 |
+
|
| 165 |
+
response = self._format_nutrition_response(adapted_result, user_data)
|
| 166 |
+
|
| 167 |
+
# Persist data to health context
|
| 168 |
+
if self.health_context:
|
| 169 |
+
self.health_context.add_health_record('nutrition', {
|
| 170 |
+
'query': user_query,
|
| 171 |
+
'advice': response,
|
| 172 |
+
'user_data': user_data,
|
| 173 |
+
'timestamp': datetime.now().isoformat()
|
| 174 |
+
})
|
| 175 |
+
|
| 176 |
+
return response
|
| 177 |
+
except Exception as e:
|
| 178 |
+
return self._handle_error(e, user_query)
|
| 179 |
+
else:
|
| 180 |
+
# General nutrition question - use LLM directly
|
| 181 |
+
response = self._handle_general_nutrition_query(user_query, chat_history)
|
| 182 |
+
|
| 183 |
+
# Persist general query
|
| 184 |
+
if self.health_context:
|
| 185 |
+
self.health_context.add_health_record('nutrition', {
|
| 186 |
+
'query': user_query,
|
| 187 |
+
'response': response,
|
| 188 |
+
'type': 'general',
|
| 189 |
+
'timestamp': datetime.now().isoformat()
|
| 190 |
+
})
|
| 191 |
+
|
| 192 |
+
return response
|
| 193 |
+
|
| 194 |
+
def _extract_user_data_from_history(self, chat_history):
|
| 195 |
+
"""Extract user data from conversation history"""
|
| 196 |
+
user_data = {
|
| 197 |
+
'age': None,
|
| 198 |
+
'gender': None,
|
| 199 |
+
'weight': None,
|
| 200 |
+
'height': None,
|
| 201 |
+
'goal': 'maintenance',
|
| 202 |
+
'activity_level': 'moderate',
|
| 203 |
+
'dietary_restrictions': [],
|
| 204 |
+
'health_conditions': []
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
all_messages = " ".join([msg[0] for msg in chat_history if msg[0]])
|
| 208 |
+
|
| 209 |
+
# Extract age
|
| 210 |
+
age_match = re.search(r'(\d+)\s*tuổi|tuổi\s*(\d+)|tôi\s*(\d+)', all_messages.lower())
|
| 211 |
+
if age_match:
|
| 212 |
+
user_data['age'] = int([g for g in age_match.groups() if g][0])
|
| 213 |
+
|
| 214 |
+
# Extract gender
|
| 215 |
+
if re.search(r'\bnam\b|male|đàn ông', all_messages.lower()):
|
| 216 |
+
user_data['gender'] = 'male'
|
| 217 |
+
elif re.search(r'\bnữ\b|female|đàn bà', all_messages.lower()):
|
| 218 |
+
user_data['gender'] = 'female'
|
| 219 |
+
|
| 220 |
+
# Extract weight - improved patterns
|
| 221 |
+
weight_match = re.search(r'(?:nặng|cân|weight)?\s*(\d+(?:\.\d+)?)\s*kg|(\d+(?:\.\d+)?)\s*kg', all_messages.lower())
|
| 222 |
+
if weight_match:
|
| 223 |
+
user_data['weight'] = float([g for g in weight_match.groups() if g][0])
|
| 224 |
+
|
| 225 |
+
# Extract height - improved patterns
|
| 226 |
+
height_cm_match = re.search(r'(?:cao|chiều cao|height)?\s*(\d+(?:\.\d+)?)\s*cm', all_messages.lower())
|
| 227 |
+
if height_cm_match:
|
| 228 |
+
user_data['height'] = float(height_cm_match.group(1))
|
| 229 |
+
else:
|
| 230 |
+
height_m_match = re.search(r'(?:cao|chiều cao|height)?\s*(\d+\.?\d*)\s*m\b', all_messages.lower())
|
| 231 |
+
if height_m_match:
|
| 232 |
+
height = float(height_m_match.group(1))
|
| 233 |
+
if height < 3: # Convert meters to cm
|
| 234 |
+
height = height * 100
|
| 235 |
+
user_data['height'] = height
|
| 236 |
+
|
| 237 |
+
# Extract goal
|
| 238 |
+
if re.search(r'giảm cân|weight loss', all_messages.lower()):
|
| 239 |
+
user_data['goal'] = 'weight_loss'
|
| 240 |
+
elif re.search(r'tăng cân|weight gain', all_messages.lower()):
|
| 241 |
+
user_data['goal'] = 'weight_gain'
|
| 242 |
+
elif re.search(r'tập gym|muscle|cơ bắp', all_messages.lower()):
|
| 243 |
+
user_data['goal'] = 'muscle_building'
|
| 244 |
+
|
| 245 |
+
return user_data
|
| 246 |
+
|
| 247 |
+
def _needs_personalized_advice(self, user_query, chat_history):
|
| 248 |
+
"""
|
| 249 |
+
Determine if user needs personalized advice (BMI, calories, meal plan)
|
| 250 |
+
or just general nutrition info
|
| 251 |
+
"""
|
| 252 |
+
# Keywords that indicate need for personalization
|
| 253 |
+
personalization_keywords = [
|
| 254 |
+
'giảm cân', 'tăng cân', 'bmi', 'calo', 'calorie',
|
| 255 |
+
'thực đơn', 'meal plan', 'chế độ ăn cá nhân',
|
| 256 |
+
'tôi nên ăn gì', 'tư vấn cho tôi', 'phù hợp với tôi'
|
| 257 |
+
]
|
| 258 |
+
|
| 259 |
+
query_lower = user_query.lower()
|
| 260 |
+
|
| 261 |
+
# Check if user explicitly asks for personalized advice
|
| 262 |
+
if any(kw in query_lower for kw in personalization_keywords):
|
| 263 |
+
return True
|
| 264 |
+
|
| 265 |
+
# Check chat history - if user already provided personal info
|
| 266 |
+
if chat_history:
|
| 267 |
+
all_messages = " ".join([msg[0] for msg in chat_history if msg[0]]).lower()
|
| 268 |
+
if any(kw in all_messages for kw in personalization_keywords):
|
| 269 |
+
return True
|
| 270 |
+
|
| 271 |
+
# Default: general question
|
| 272 |
+
return False
|
| 273 |
+
|
| 274 |
+
def _check_missing_data(self, user_data):
|
| 275 |
+
"""Check what data is missing - check shared memory first"""
|
| 276 |
+
required = ['age', 'gender', 'weight', 'height']
|
| 277 |
+
|
| 278 |
+
# Check shared memory for missing fields
|
| 279 |
+
profile = self.get_user_profile()
|
| 280 |
+
for field in required:
|
| 281 |
+
if not user_data.get(field) and profile.get(field):
|
| 282 |
+
user_data[field] = profile[field]
|
| 283 |
+
|
| 284 |
+
return [field for field in required if not user_data.get(field)]
|
| 285 |
+
|
| 286 |
+
def _ask_for_missing_data(self, missing_fields, current_data, user_query):
|
| 287 |
+
"""Ask for missing data"""
|
| 288 |
+
questions = {
|
| 289 |
+
'age': "bạn bao nhiêu tuổi",
|
| 290 |
+
'gender': "bạn là nam hay nữ",
|
| 291 |
+
'weight': "bạn nặng bao nhiêu kg",
|
| 292 |
+
'height': "bạn cao bao nhiêu cm"
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
# Build friendly question
|
| 296 |
+
q_list = [questions[f] for f in missing_fields]
|
| 297 |
+
|
| 298 |
+
if len(q_list) == 1:
|
| 299 |
+
question = q_list[0]
|
| 300 |
+
elif len(q_list) == 2:
|
| 301 |
+
question = f"{q_list[0]} và {q_list[1]}"
|
| 302 |
+
else:
|
| 303 |
+
question = ", ".join(q_list[:-1]) + f" và {q_list[-1]}"
|
| 304 |
+
|
| 305 |
+
return f"""🥗 **Để tư vấn dinh dưỡng chính xác, mình cần biết thêm:**
|
| 306 |
+
|
| 307 |
+
Cho mình biết {question} nhé?
|
| 308 |
+
|
| 309 |
+
💡 **Ví dụ:** "Tôi 25 tuổi, nam, nặng 70kg, cao 175cm"
|
| 310 |
+
|
| 311 |
+
Sau khi có đủ thông tin, mình sẽ tính BMI và đưa ra lời khuyên dinh dưỡng cá nhân hóa cho bạn! 😊"""
|
| 312 |
+
|
| 313 |
+
def _format_nutrition_response(self, result, user_data):
|
| 314 |
+
"""Format nutrition advice into friendly response"""
|
| 315 |
+
bmi_info = result['bmi_analysis']
|
| 316 |
+
targets = result['daily_targets']
|
| 317 |
+
meals = result['meal_suggestions']
|
| 318 |
+
supplements = result['supplement_recommendations']
|
| 319 |
+
|
| 320 |
+
response = f"""🥗 **Tư Vấn Dinh Dưỡng Cá Nhân Hóa**
|
| 321 |
+
|
| 322 |
+
👤 **Thông tin của bạn:**
|
| 323 |
+
- {user_data['age']} tuổi, {user_data['gender']}, {user_data['weight']}kg, {user_data['height']}cm
|
| 324 |
+
|
| 325 |
+
📊 **Phân tích BMI:**
|
| 326 |
+
- BMI: **{bmi_info['bmi']}** ({bmi_info['category']})
|
| 327 |
+
- Lời khuyên: {bmi_info['advice']}
|
| 328 |
+
|
| 329 |
+
🎯 **Mục tiêu hàng ngày:**
|
| 330 |
+
- 🔥 Calo: **{targets['daily_calories']} kcal**
|
| 331 |
+
- 🥩 Protein: **{targets['protein']}**
|
| 332 |
+
- 🍚 Carb: **{targets['carbs']}**
|
| 333 |
+
- 🥑 Chất béo: **{targets['fats']}**
|
| 334 |
+
- 💧 Nước: **{targets['water']}**
|
| 335 |
+
|
| 336 |
+
🍽️ **Gợi ý thực đơn:**
|
| 337 |
+
|
| 338 |
+
**Sáng:**
|
| 339 |
+
- {meals['breakfast'][0]}
|
| 340 |
+
- {meals['breakfast'][1]}
|
| 341 |
+
|
| 342 |
+
**Trưa:**
|
| 343 |
+
- {meals['lunch'][0]}
|
| 344 |
+
- {meals['lunch'][1]}
|
| 345 |
+
|
| 346 |
+
**Tối:**
|
| 347 |
+
- {meals['dinner'][0]}
|
| 348 |
+
- {meals['dinner'][1]}
|
| 349 |
+
|
| 350 |
+
**Snack:**
|
| 351 |
+
- {meals['snacks'][0]}
|
| 352 |
+
- {meals['snacks'][1]}
|
| 353 |
+
"""
|
| 354 |
+
|
| 355 |
+
if supplements:
|
| 356 |
+
response += f"\n💊 **Thực phẩm bổ sung gợi ý:**\n"
|
| 357 |
+
response += "\n".join([f"- {s}" for s in supplements[:4]])
|
| 358 |
+
|
| 359 |
+
response += f"""
|
| 360 |
+
|
| 361 |
+
🤖 **Lời khuyên chuyên gia:**
|
| 362 |
+
{result['personalized_advice'][:600]}...
|
| 363 |
+
|
| 364 |
+
---
|
| 365 |
+
|
| 366 |
+
⚠️ *Đây là tư vấn tham khảo. Với các vấn đề phức tạp, hãy gặp bác sĩ dinh dưỡng nhé!*
|
| 367 |
+
|
| 368 |
+
💬 Bạn có câu hỏi gì về chế độ ăn này không? Hoặc muốn mình điều chỉnh gì không? 😊"""
|
| 369 |
+
|
| 370 |
+
return response
|
| 371 |
+
|
| 372 |
+
def _build_nutrition_context_instruction(self, user_query, chat_history):
|
| 373 |
+
"""
|
| 374 |
+
Build context instruction for nutrition queries
|
| 375 |
+
"""
|
| 376 |
+
# Check if user is answering comparison self-assessment
|
| 377 |
+
if chat_history and len(chat_history) > 0:
|
| 378 |
+
last_bot_msg = chat_history[-1][1] if len(chat_history[-1]) > 1 else ""
|
| 379 |
+
if "TỰ KIỂM TRA" in last_bot_msg or "Bạn trả lời" in last_bot_msg:
|
| 380 |
+
return """\n\nPHASE: PHÂN TÍCH LỰA CHỌN DINH DƯỠNG
|
| 381 |
+
User vừa trả lời các câu hỏi. Phân tích:
|
| 382 |
+
|
| 383 |
+
1. NHẬN DIỆN PHÙ HỢP (dựa vào RAG):
|
| 384 |
+
- Đọc kỹ mục tiêu, lifestyle, sở thích
|
| 385 |
+
- So sánh với đặc điểm của từng lựa chọn
|
| 386 |
+
- Đưa ra lựa chọn PHÙ HỢP NHẤT
|
| 387 |
+
|
| 388 |
+
2. GIẢI THÍCH:
|
| 389 |
+
- Vì sao lựa chọn này phù hợp
|
| 390 |
+
- Lợi ích cụ thể cho user
|
| 391 |
+
- Lưu ý khi thực hiện
|
| 392 |
+
|
| 393 |
+
3. HƯỚNG DẪN BẮT ĐẦU:
|
| 394 |
+
- Cách bắt đầu cụ thể
|
| 395 |
+
- Thực đơn mẫu (nếu cần)
|
| 396 |
+
- Tips để duy trì
|
| 397 |
+
|
| 398 |
+
4. Kết thúc: "Bạn cần hướng dẫn chi tiết hơn không?"
|
| 399 |
+
KHÔNG nói "Dựa trên thông tin"."""
|
| 400 |
+
|
| 401 |
+
# Check if asking comparison question
|
| 402 |
+
if any(phrase in user_query.lower() for phrase in [
|
| 403 |
+
'nên ăn', 'hay', 'hoặc', 'khác nhau thế nào',
|
| 404 |
+
'chọn', 'so sánh', 'tốt hơn'
|
| 405 |
+
]):
|
| 406 |
+
return """\n\nPHASE: SO SÁNH DINH DƯỠNG (GENERIC)
|
| 407 |
+
User muốn so sánh các lựa chọn dinh dưỡng. Sử dụng RAG để:
|
| 408 |
+
|
| 409 |
+
1. XÁC ĐỊNH các lựa chọn (từ user query):
|
| 410 |
+
- Trích xuất diets/foods user đề cập
|
| 411 |
+
- Hoặc tìm các lựa chọn liên quan
|
| 412 |
+
|
| 413 |
+
2. TẠO BẢNG SO SÁNH:
|
| 414 |
+
Format:
|
| 415 |
+
**[Lựa chọn A]:**
|
| 416 |
+
• Macros: [protein/carb/fat]
|
| 417 |
+
• Ưu điểm: [benefits]
|
| 418 |
+
• Nhược điểm: [drawbacks]
|
| 419 |
+
• Phù hợp cho: [who]
|
| 420 |
+
|
| 421 |
+
**[Lựa chọn B]:**
|
| 422 |
+
• Macros: [protein/carb/fat]
|
| 423 |
+
• Ưu điểm: [benefits]
|
| 424 |
+
• Nhược điểm: [drawbacks]
|
| 425 |
+
• Phù hợp cho: [who]
|
| 426 |
+
|
| 427 |
+
**Điểm khác biệt chính:** [key differences]
|
| 428 |
+
|
| 429 |
+
3. CÂU HỊI TỰ KIỂM TRA:
|
| 430 |
+
Tạo 3-5 câu hỏi giúp user tự đánh giá:
|
| 431 |
+
• Mục tiêu của bạn?
|
| 432 |
+
• Lifestyle như thế nào?
|
| 433 |
+
• Có hạn chế gì không?
|
| 434 |
+
• Thời gian chuẩn bị?
|
| 435 |
+
|
| 436 |
+
4. Kết thúc: "Bạn trả lời giúp mình để recommend phù hợp nhé!"
|
| 437 |
+
|
| 438 |
+
QUAN TRỌNG: Dùng RAG knowledge, KHÔNG hard-code."""
|
| 439 |
+
|
| 440 |
+
# Normal advice
|
| 441 |
+
return """\n\nĐưa ra lời khuyên dinh dưỡng cụ thể, thực tế.
|
| 442 |
+
KHÔNG quá lý thuyết.
|
| 443 |
+
KHÔNG nói "Dựa trên thông tin"."""
|
| 444 |
+
|
| 445 |
+
def _handle_general_nutrition_query(self, user_query, chat_history):
|
| 446 |
+
"""Handle general nutrition questions using LLM + RAG with comparison support"""
|
| 447 |
+
from config.settings import client, MODEL
|
| 448 |
+
|
| 449 |
+
try:
|
| 450 |
+
# Smart RAG - only query when needed (inherit from BaseAgent)
|
| 451 |
+
rag_answer = ''
|
| 452 |
+
rag_sources = []
|
| 453 |
+
|
| 454 |
+
if self.should_use_rag(user_query, chat_history):
|
| 455 |
+
rag_result = self.rag.query_nutrition(user_query)
|
| 456 |
+
rag_answer = rag_result.get('answer', '')
|
| 457 |
+
rag_sources = rag_result.get('source_docs', [])
|
| 458 |
+
|
| 459 |
+
# Build conversation context with RAG context
|
| 460 |
+
rag_context = f"Dựa trên kiến thức từ cơ sở dữ liệu:\n{rag_answer}\n\n" if rag_answer else ""
|
| 461 |
+
|
| 462 |
+
messages = [{"role": "system", "content": self.system_prompt}]
|
| 463 |
+
|
| 464 |
+
# Add RAG context if available
|
| 465 |
+
if rag_context:
|
| 466 |
+
messages.append({"role": "system", "content": f"Thông tin tham khảo từ cơ sở dữ liệu:\n{rag_context}"})
|
| 467 |
+
|
| 468 |
+
# Add chat history (last 5 exchanges)
|
| 469 |
+
if chat_history:
|
| 470 |
+
recent_history = chat_history[-5:] if len(chat_history) > 5 else chat_history
|
| 471 |
+
for user_msg, bot_msg in recent_history:
|
| 472 |
+
if user_msg:
|
| 473 |
+
messages.append({"role": "user", "content": user_msg})
|
| 474 |
+
if bot_msg:
|
| 475 |
+
messages.append({"role": "assistant", "content": bot_msg})
|
| 476 |
+
|
| 477 |
+
# Add current query with context instruction
|
| 478 |
+
context_prompt = self._build_nutrition_context_instruction(user_query, chat_history)
|
| 479 |
+
messages.append({"role": "user", "content": user_query + context_prompt})
|
| 480 |
+
|
| 481 |
+
# Get LLM response
|
| 482 |
+
response = client.chat.completions.create(
|
| 483 |
+
model=MODEL,
|
| 484 |
+
messages=messages,
|
| 485 |
+
temperature=0.7,
|
| 486 |
+
max_tokens=500
|
| 487 |
+
)
|
| 488 |
+
|
| 489 |
+
llm_response = response.choices[0].message.content
|
| 490 |
+
|
| 491 |
+
# Add sources using RAG integration formatter (FIXED!)
|
| 492 |
+
if rag_sources:
|
| 493 |
+
formatted_response = self.rag.format_response_with_sources({
|
| 494 |
+
'answer': llm_response,
|
| 495 |
+
'source_docs': rag_sources
|
| 496 |
+
})
|
| 497 |
+
return formatted_response
|
| 498 |
+
|
| 499 |
+
return llm_response
|
| 500 |
+
|
| 501 |
+
except Exception as e:
|
| 502 |
+
return f"""Xin lỗi, mình gặp lỗi kỹ thuật. Bạn có thể:
|
| 503 |
+
1. Thử lại câu hỏi
|
| 504 |
+
2. Hỏi cách khác
|
| 505 |
+
3. Liên hệ hỗ trợ
|
| 506 |
+
|
| 507 |
+
Chi tiết lỗi: {str(e)}"""
|
| 508 |
+
|
| 509 |
+
def should_handoff(self, user_query: str, chat_history: Optional[List] = None) -> bool:
|
| 510 |
+
"""
|
| 511 |
+
Override base method - Determine if should hand off to another agent
|
| 512 |
+
|
| 513 |
+
Specific triggers for nutrition agent:
|
| 514 |
+
- User asks about exercise/workout
|
| 515 |
+
- User mentions symptoms (stomach pain, nausea)
|
| 516 |
+
- User asks about mental health affecting eating
|
| 517 |
+
"""
|
| 518 |
+
query_lower = user_query.lower()
|
| 519 |
+
|
| 520 |
+
# Check each agent's triggers
|
| 521 |
+
for agent, triggers in self.handoff_triggers.items():
|
| 522 |
+
if any(trigger in query_lower for trigger in triggers):
|
| 523 |
+
# Don't handoff if we're in the middle of nutrition consultation
|
| 524 |
+
if chat_history and self._is_mid_consultation(chat_history):
|
| 525 |
+
return False
|
| 526 |
+
return True
|
| 527 |
+
|
| 528 |
+
return False
|
| 529 |
+
|
| 530 |
+
def suggest_next_agent(self, user_query: str) -> Optional[str]:
|
| 531 |
+
"""Override base method - Suggest which agent to hand off to based on query"""
|
| 532 |
+
query_lower = user_query.lower()
|
| 533 |
+
|
| 534 |
+
# Priority order for handoff
|
| 535 |
+
if any(trigger in query_lower for trigger in self.handoff_triggers.get('symptom_agent', [])):
|
| 536 |
+
return 'symptom_agent'
|
| 537 |
+
|
| 538 |
+
if any(trigger in query_lower for trigger in self.handoff_triggers.get('exercise_agent', [])):
|
| 539 |
+
return 'exercise_agent'
|
| 540 |
+
|
| 541 |
+
if any(trigger in query_lower for trigger in self.handoff_triggers.get('mental_health_agent', [])):
|
| 542 |
+
return 'mental_health_agent'
|
| 543 |
+
|
| 544 |
+
if any(trigger in query_lower for trigger in self.handoff_triggers.get('general_health_agent', [])):
|
| 545 |
+
return 'general_health_agent'
|
| 546 |
+
|
| 547 |
+
return None
|
| 548 |
+
|
| 549 |
+
def _is_mid_consultation(self, chat_history: List) -> bool:
|
| 550 |
+
"""Check if we're in the middle of nutrition consultation"""
|
| 551 |
+
if not chat_history or len(chat_history) < 2:
|
| 552 |
+
return False
|
| 553 |
+
|
| 554 |
+
# Check last bot response
|
| 555 |
+
last_bot_response = chat_history[-1][1] if len(chat_history[-1]) > 1 else ""
|
| 556 |
+
|
| 557 |
+
# If we just asked for user data, don't handoff
|
| 558 |
+
if any(phrase in last_bot_response for phrase in [
|
| 559 |
+
"cân nặng", "chiều cao", "tuổi", "giới tính", "mục tiêu"
|
| 560 |
+
]):
|
| 561 |
+
return True
|
| 562 |
+
|
| 563 |
+
return False
|
| 564 |
+
|
| 565 |
+
def _generate_nutrition_summary(self) -> str:
|
| 566 |
+
"""Generate summary of nutrition advice for handoff"""
|
| 567 |
+
nutrition_data = self.get_agent_data('nutrition_plan')
|
| 568 |
+
user_profile = self.get_user_profile()
|
| 569 |
+
|
| 570 |
+
# Natural summary without robotic prefix
|
| 571 |
+
summary_parts = []
|
| 572 |
+
|
| 573 |
+
if nutrition_data and isinstance(nutrition_data, dict):
|
| 574 |
+
if 'bmi_analysis' in nutrition_data:
|
| 575 |
+
bmi = nutrition_data['bmi_analysis']
|
| 576 |
+
summary_parts.append(f"BMI: {bmi.get('bmi', 'N/A')} ({bmi.get('category', 'N/A')})")
|
| 577 |
+
|
| 578 |
+
if 'daily_targets' in nutrition_data:
|
| 579 |
+
targets = nutrition_data['daily_targets']
|
| 580 |
+
summary_parts.append(f"Calo: {targets.get('calories', 'N/A')} kcal/ngày")
|
| 581 |
+
|
| 582 |
+
if user_profile and user_profile.get('goal'):
|
| 583 |
+
summary_parts.append(f"Mục tiêu: {user_profile['goal']}")
|
| 584 |
+
|
| 585 |
+
return " | ".join(summary_parts)[:100] if summary_parts else ""
|
| 586 |
+
|
| 587 |
+
def _handle_error(self, error, user_query):
|
| 588 |
+
"""Handle errors gracefully"""
|
| 589 |
+
return f"""Xin lỗi, mình gặp chút vấn đề khi tạo tư vấn dinh dưỡng. 😅
|
| 590 |
+
|
| 591 |
+
Lỗi: {str(error)}
|
| 592 |
+
|
| 593 |
+
Bạn có thể thử:
|
| 594 |
+
1. Cung cấp lại thông tin: tuổi, giới tính, cân nặng, chiều cao
|
| 595 |
+
2. Hỏi câu hỏi cụ thể hơn về dinh dưỡng
|
| 596 |
+
3. Hoặc mình có thể tư vấn về chủ đề sức khỏe khác
|
| 597 |
+
|
| 598 |
+
Bạn muốn thử lại không? 💙"""
|
agents/specialized/symptom_agent.py
ADDED
|
@@ -0,0 +1,854 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Symptom Agent - Specialized agent for symptom assessment using OPQRST method
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from config.settings import client, MODEL
|
| 6 |
+
from health_data import HealthContext
|
| 7 |
+
from health_analysis import HealthAnalyzer
|
| 8 |
+
from rag.rag_integration import get_rag_integration
|
| 9 |
+
from agents.core.base_agent import BaseAgent
|
| 10 |
+
from agents.core.context_analyzer import ContextAnalyzer
|
| 11 |
+
from agents.core.response_validator import ResponseValidator
|
| 12 |
+
from typing import Dict, Any, List, Optional
|
| 13 |
+
from datetime import datetime
|
| 14 |
+
import re
|
| 15 |
+
|
| 16 |
+
class SymptomAgent(BaseAgent):
|
| 17 |
+
def __init__(self, memory=None):
|
| 18 |
+
super().__init__(memory)
|
| 19 |
+
self.health_context = None
|
| 20 |
+
self.analyzer = None
|
| 21 |
+
self.rag = get_rag_integration()
|
| 22 |
+
|
| 23 |
+
# Configure handoff triggers for symptom agent
|
| 24 |
+
self.handoff_triggers = {
|
| 25 |
+
'nutrition_agent': ['ăn gì', 'thực đơn', 'dinh dưỡng'],
|
| 26 |
+
'exercise_agent': ['tập luyện', 'vận động', 'phục hồi chức năng'],
|
| 27 |
+
'mental_health_agent': ['lo âu', 'stress', 'mất ngủ do lo'],
|
| 28 |
+
'general_health_agent': ['khám tổng quát', 'xét nghiệm', 'kiểm tra sức khỏe']
|
| 29 |
+
}
|
| 30 |
+
self.system_prompt = """Bạn là bác sĩ tư vấn chuyên nghiệp.
|
| 31 |
+
|
| 32 |
+
🩺 NHIỆM VỤ:
|
| 33 |
+
Thu thập thông tin triệu chứng một cách có hệ thống và chuyên nghiệp.
|
| 34 |
+
|
| 35 |
+
📋 PHƯƠNG PHÁP OPQRST (Hỏi tự nhiên, KHÔNG dùng template):
|
| 36 |
+
|
| 37 |
+
**Onset (Khởi phát):**
|
| 38 |
+
- Khi nào bắt đầu? Đột ngột hay từ từ?
|
| 39 |
+
- Ví dụ tự nhiên:
|
| 40 |
+
* Đau đầu: "Đau đầu từ khi nào rồi bạn? Đột ngột hay từ từ?"
|
| 41 |
+
* Đầy bụng: "Cảm giác đầy bụng này xuất hiện từ bao giờ? Sau khi ăn hay suốt ngày?"
|
| 42 |
+
|
| 43 |
+
**Quality (Đặc điểm):**
|
| 44 |
+
- Mô tả cảm giác như thế nào?
|
| 45 |
+
- Ví dụ tự nhiên:
|
| 46 |
+
* Đau đầu: "Đau kiểu gì? Đau nhói, tức, đập thình thình, hay nặng nề?"
|
| 47 |
+
* Đầy bụng: "Cảm giác đầy như thế nào? Căng cứng, khó tiêu, hay đau tức?"
|
| 48 |
+
|
| 49 |
+
**Region (Vị trí):**
|
| 50 |
+
- Ở đâu? Có lan ra không?
|
| 51 |
+
- Ví dụ tự nhiên:
|
| 52 |
+
* Đau đầu: "Đau ở đâu? Trán, thái dương, sau gáy, hay cả đầu?"
|
| 53 |
+
* Đầy bụng: "Đầy ở vùng nào? Trên rốn, dưới rốn, hay toàn bộ bụng?"
|
| 54 |
+
|
| 55 |
+
**Provocation/Palliation (Yếu tố ảnh hưởng):**
|
| 56 |
+
- Gì làm tệ/đỡ hơn?
|
| 57 |
+
- Ví dụ tự nhiên:
|
| 58 |
+
* Đau đầu: "Có gì làm đau nhiều hơn không? Ánh sáng, tiếng ồn, stress? Nghỉ ngơi có đỡ không?"
|
| 59 |
+
* Đầy bụng: "Ăn gì làm nặng hơn? Có loại thức ăn nào làm đỡ không?"
|
| 60 |
+
|
| 61 |
+
**Severity (Mức độ):**
|
| 62 |
+
- Mức độ và triệu chứng kèm theo?
|
| 63 |
+
- Ví dụ tự nhiên:
|
| 64 |
+
* Đau đầu: "Đau nhiều không? Có buồn nôn, nhìn mờ, hoặc sợ ánh sáng không?"
|
| 65 |
+
* Đầy bụng: "Đầy nhiều không? Có ợ hơi, buồn nôn, hoặc khó thở không?"
|
| 66 |
+
|
| 67 |
+
**Timing (Thời gian):**
|
| 68 |
+
- Khi nào xuất hiện? Liên tục hay từng đợt?
|
| 69 |
+
- Ví dụ tự nhiên:
|
| 70 |
+
* Đau đầu: "Đau suốt hay từng cơn? Thường xuất hiện lúc nào trong ngày?"
|
| 71 |
+
* Đầy bụng: "Đầy suốt ngày hay chỉ sau ăn? Kéo dài bao lâu?"
|
| 72 |
+
|
| 73 |
+
🎯 NGUYÊN TẮC QUAN TRỌNG:
|
| 74 |
+
|
| 75 |
+
1. **HỎI TỐI ĐA 3-4 CÂU:**
|
| 76 |
+
- Không hỏi mãi theo template OPQRST
|
| 77 |
+
- Hỏi 3-4 câu quan trọng nhất
|
| 78 |
+
- Nếu user không biết/không rõ → Chuyển sang đưa khuyến nghị
|
| 79 |
+
|
| 80 |
+
2. **ƯU TIÊN THÔNG TIN:**
|
| 81 |
+
- Câu 1: Thời gian xuất hiện (khi nào?)
|
| 82 |
+
- Câu 2: Đặc điểm (đau như thế nào?)
|
| 83 |
+
- Câu 3: Mức độ (có triệu chứng kèm theo?)
|
| 84 |
+
- Câu 4 (nếu cần): Yếu tố ảnh hưởng
|
| 85 |
+
|
| 86 |
+
3. **KHI USER KHÔNG BIẾT:**
|
| 87 |
+
- User nói "không biết", "không rõ", "không chắc"
|
| 88 |
+
- → DỪNG hỏi, chuyển sang đưa khuyến nghị
|
| 89 |
+
- Dựa trên thông tin ĐÃ CÓ để tư vấn
|
| 90 |
+
|
| 91 |
+
4. **ĐƯA KHUYẾN NGHỊ:**
|
| 92 |
+
- Tổng hợp thông tin đã thu thập
|
| 93 |
+
- Đưa ra các biện pháp tự chăm sóc phù hợp
|
| 94 |
+
- Khuyên gặp bác sĩ nếu cần
|
| 95 |
+
- KHÔNG hỏi thêm nữa
|
| 96 |
+
|
| 97 |
+
🚨 RED FLAGS - Khuyên gặp bác sĩ NGAY:
|
| 98 |
+
- Đau ngực + khó thở → Nghi ngờ tim
|
| 99 |
+
- Đau đầu dữ dội đột ngột + cứng gáy + sốt → Nghi ngờ màng não
|
| 100 |
+
- Yếu đột ngột một bên → Nghi ngờ đột quỵ
|
| 101 |
+
- Đau bụng dữ dội → Nghi ngờ ruột thừa/cấp cứu
|
| 102 |
+
- Ho/nôn ra máu
|
| 103 |
+
- Ý định tự tử
|
| 104 |
+
|
| 105 |
+
⚠️ AN TOÀN & GIỚI HẠN:
|
| 106 |
+
- KHÔNG chẩn đoán bệnh
|
| 107 |
+
- KHÔNG kê đơn thuốc
|
| 108 |
+
- KHÔNG tạo giáo án tập luyện (đó là việc của exercise_agent)
|
| 109 |
+
- KHÔNG tư vấn dinh dưỡng chi tiết (đó là việc của nutrition_agent)
|
| 110 |
+
- CHỈ tập trung vào ĐÁNH GIÁ TRIỆU CHỨNG
|
| 111 |
+
- Luôn khuyên gặp bác sĩ với triệu chứng nghiêm trọng
|
| 112 |
+
- Với red flags → khuyên đi cấp cứu NGAY
|
| 113 |
+
|
| 114 |
+
💬 PHONG CÁCH:
|
| 115 |
+
- Tự nhiên, conversational - như đang nói chuyện
|
| 116 |
+
- KHÔNG formal, KHÔNG "Dựa trên thông tin bạn cung cấp"
|
| 117 |
+
- Emoji tối thiểu (chỉ khi thật sự cần)
|
| 118 |
+
- Ngắn gọn, đi thẳng vấn đề
|
| 119 |
+
- KHÔNG vừa hỏi vừa khuyên trong cùng 1 response
|
| 120 |
+
|
| 121 |
+
🏥 KHI USER HỎI ĐỊA CHỈ BỆNH VIỆN:
|
| 122 |
+
- ĐỪNG lặp lại triệu chứng nếu đã nói rồi!
|
| 123 |
+
- Nếu user hỏi "tôi muốn đi khám", "bệnh viện nào tốt", "cho tôi địa chỉ"
|
| 124 |
+
→ ĐI THẲNG VÀO ĐỊA CHỈ, không cần nhắc lại "Triệu chứng đau đầu và mất ngủ..."
|
| 125 |
+
- Format địa chỉ bệnh viện:
|
| 126 |
+
|
| 127 |
+
**Bệnh viện/Phòng khám gần [địa điểm]:**
|
| 128 |
+
|
| 129 |
+
1. **Tên bệnh viện**
|
| 130 |
+
- Địa chỉ: [địa chỉ đầy đủ]
|
| 131 |
+
- Chuyên khoa: [chuyên khoa liên quan]
|
| 132 |
+
- SĐT: [nếu có]
|
| 133 |
+
|
| 134 |
+
**Khi nào cần đi khám:** [điều kiện]
|
| 135 |
+
|
| 136 |
+
- KHÔNG dùng "Giải pháp:" cho danh sách bệnh viện
|
| 137 |
+
- KHÔNG mix địa chỉ với home remedies (thiền, yoga) trong cùng list
|
| 138 |
+
|
| 139 |
+
📝 VÍ DỤ WORKFLOW:
|
| 140 |
+
|
| 141 |
+
**Tình huống: User đau bụng**
|
| 142 |
+
|
| 143 |
+
Turn 1:
|
| 144 |
+
User: "Tôi đau bụng"
|
| 145 |
+
Bot: "Bạn bắt đầu bị đau từ khi nào vậy?"
|
| 146 |
+
|
| 147 |
+
Turn 2:
|
| 148 |
+
User: "Mới xuất hiện, đau âm ỉ"
|
| 149 |
+
Bot: "Đau ở vị trí nào? Trên rốn, dưới rốn, hay toàn bộ bụng?"
|
| 150 |
+
|
| 151 |
+
Turn 3:
|
| 152 |
+
User: "Phía trên rốn"
|
| 153 |
+
Bot: "Có triệu chứng kèm theo như buồn nôn, ợ hơi, hoặc đầy bụng không?"
|
| 154 |
+
|
| 155 |
+
Turn 4:
|
| 156 |
+
User: "Không biết, giờ tôi muốn làm sao cho hết đau"
|
| 157 |
+
Bot: "Dựa trên thông tin bạn cung cấp (đau âm ỉ vùng thượng vị, mới xuất hiện),
|
| 158 |
+
đây có thể là triệu chứng của viêm dạ dày hoặc khó tiêu. Khuyến nghị:
|
| 159 |
+
|
| 160 |
+
1. Nghỉ ngơi, tránh căng thẳng
|
| 161 |
+
2. Ăn nhẹ, tránh thức ăn cay nóng, cà phê, rượu
|
| 162 |
+
3. Có thể dùng thuốc giảm acid (theo chỉ định)
|
| 163 |
+
|
| 164 |
+
Nếu đau không giảm sau 24h hoặc xuất hiện triệu chứng nặng hơn
|
| 165 |
+
(nôn ra máu, đau dữ dội), hãy đến bệnh viện ngay."
|
| 166 |
+
|
| 167 |
+
→ DỪNG hỏi, đưa khuyến nghị dựa trên thông tin có!
|
| 168 |
+
|
| 169 |
+
🎯 NGUYÊN TẮC QUAN TRỌNG:
|
| 170 |
+
|
| 171 |
+
1. **ƯU TIÊN GIẢI PHÁP KHI USER CẦN:**
|
| 172 |
+
- Nếu user nói "đau quá", "khó chịu", "làm sao" → Đưa giải pháp NGAY
|
| 173 |
+
- Không hỏi thêm khi user đang cần giúp đỡ khẩn cấp
|
| 174 |
+
- Cấu trúc: Giải pháp ngay → Thuốc (nếu cần) → Cảnh báo → Phòng ngừa
|
| 175 |
+
|
| 176 |
+
2. **CHỈ HỎI KHI CẦN THIẾT:**
|
| 177 |
+
- Tối đa 1-2 câu hỏi trong toàn bộ conversation
|
| 178 |
+
- Nếu đã có đủ info cơ bản → Đưa lời khuyên luôn
|
| 179 |
+
- Nếu user không muốn trả lời → Đưa lời khuyên chung
|
| 180 |
+
|
| 181 |
+
3. **EMOJI - Dùng tiết kiệm:**
|
| 182 |
+
- KHÔNG dùng 😔 cho mọi triệu chứng
|
| 183 |
+
- Chỉ dùng khi thực sự cần (trấn an, động viên)
|
| 184 |
+
- Có thể không dùng emoji nếu câu đã đủ ấm áp
|
| 185 |
+
|
| 186 |
+
4. **PHÂN TÍCH CONTEXT:**
|
| 187 |
+
- Nếu là câu hỏi ĐẦU TIÊN → có thể đồng cảm
|
| 188 |
+
- Nếu đang FOLLOW-UP → đi thẳng vào câu hỏi, không cần lặp lại đồng cảm
|
| 189 |
+
- Nếu user đã trả lời nhiều câu → cảm ơn họ, không cần đồng cảm nữa
|
| 190 |
+
|
| 191 |
+
VÍ DỤ CÁCH HỎI ĐA DẠNG:
|
| 192 |
+
|
| 193 |
+
❌ SAI (Lặp lại pattern):
|
| 194 |
+
Turn 1: "Đau đầu khó chịu lắm nhỉ 😔 Cho mình hỏi..."
|
| 195 |
+
Turn 2: "Đau nhói khó chịu quá 😔 Mà này..."
|
| 196 |
+
Turn 3: "Sợ ánh sáng khó chịu lắm 😔 Còn về..."
|
| 197 |
+
→ LẶP LẠI "khó chịu" + 😔 = MÁY MÓC!
|
| 198 |
+
|
| 199 |
+
✅ ĐÚNG (Đa dạng, tự nhiên):
|
| 200 |
+
Turn 1: "Mình hiểu rồi. Cho mình hỏi, bạn bị đau từ khi nào?"
|
| 201 |
+
Turn 2: "À, đau nhói từ 2 ngày trước nhỉ. Vậy đau ở đâu? Trán, thái dương, hay cả đầu?"
|
| 202 |
+
Turn 3: "Được rồi. Có gì làm đau nhiều hơn không? Ví dụ ánh sáng, tiếng ồn, hay stress?"
|
| 203 |
+
→ BIẾN ĐỔI, TỰ NHIÊN!
|
| 204 |
+
|
| 205 |
+
VÍ DỤ THEO TRIỆU CHỨNG:
|
| 206 |
+
|
| 207 |
+
**Đau đầu - Variations:**
|
| 208 |
+
- "Cho mình hỏi, bạn bị đau từ khi nào?"
|
| 209 |
+
- "Mình hiểu. Đau đầu xuất hiện đột ngột hay từ từ?"
|
| 210 |
+
- "Để mình giúp bạn tìm hiểu. Đau kiểu gì? Nhói, tức, hay đập thình thình?"
|
| 211 |
+
|
| 212 |
+
**Đầy bụng - Variations:**
|
| 213 |
+
- "Cảm giác đầy này xuất hiện từ bao giờ?"
|
| 214 |
+
- "Liên quan đến ăn uống không? Sau khi ăn hay suốt ngày?"
|
| 215 |
+
- "Có ợ hơi hoặc khó tiêu không?"
|
| 216 |
+
|
| 217 |
+
**Đau lưng - Variations:**
|
| 218 |
+
- "Bạn bị đau lưng từ khi nào?"
|
| 219 |
+
- "Có bị chấn thương hay làm gì nặng không?"
|
| 220 |
+
- "Đau ở vị trí nào? Lưng trên, giữa, hay dưới?"
|
| 221 |
+
|
| 222 |
+
QUAN TRỌNG:
|
| 223 |
+
- Mỗi triệu chứng cần cách hỏi KHÁC NHAU
|
| 224 |
+
- Mỗi TURN trong conversation cần cách diễn đạt KHÁC NHAU
|
| 225 |
+
- KHÔNG lặp lại patterns - hãy TỰ NHIÊN như người thật!"""
|
| 226 |
+
|
| 227 |
+
def set_health_context(self, health_context: HealthContext):
|
| 228 |
+
"""Inject health context and initialize health analyzer"""
|
| 229 |
+
self.health_context = health_context
|
| 230 |
+
self.analyzer = HealthAnalyzer(health_context)
|
| 231 |
+
|
| 232 |
+
def handle(self, parameters, chat_history=None):
|
| 233 |
+
"""
|
| 234 |
+
Handle symptom assessment request using LLM for natural conversation
|
| 235 |
+
|
| 236 |
+
Args:
|
| 237 |
+
parameters (dict): {"user_query": str}
|
| 238 |
+
chat_history (list): Conversation history
|
| 239 |
+
|
| 240 |
+
Returns:
|
| 241 |
+
str: Response message
|
| 242 |
+
"""
|
| 243 |
+
user_query = parameters.get("user_query", "")
|
| 244 |
+
|
| 245 |
+
# Check for red flags first
|
| 246 |
+
red_flag_response = self._check_red_flags(user_query, chat_history)
|
| 247 |
+
if red_flag_response:
|
| 248 |
+
# Persist red flag alert
|
| 249 |
+
if self.health_context:
|
| 250 |
+
self.health_context.add_health_record('symptom', {
|
| 251 |
+
'query': user_query,
|
| 252 |
+
'type': 'red_flag_alert',
|
| 253 |
+
'response': red_flag_response,
|
| 254 |
+
'timestamp': datetime.now().isoformat()
|
| 255 |
+
})
|
| 256 |
+
return red_flag_response
|
| 257 |
+
|
| 258 |
+
# Use LLM to naturally assess symptoms and ask questions
|
| 259 |
+
response = self._natural_symptom_assessment(user_query, chat_history)
|
| 260 |
+
|
| 261 |
+
# Analyze health risks if analyzer is available
|
| 262 |
+
if self.analyzer:
|
| 263 |
+
risks = self.analyzer.identify_health_risks()
|
| 264 |
+
predictions = self.analyzer.predict_disease_risk()
|
| 265 |
+
else:
|
| 266 |
+
risks = []
|
| 267 |
+
predictions = {}
|
| 268 |
+
|
| 269 |
+
# Persist symptom data to health context
|
| 270 |
+
if self.health_context:
|
| 271 |
+
self.health_context.add_health_record('symptom', {
|
| 272 |
+
'query': user_query,
|
| 273 |
+
'response': response,
|
| 274 |
+
'risks': risks,
|
| 275 |
+
'predictions': predictions,
|
| 276 |
+
'timestamp': datetime.now().isoformat()
|
| 277 |
+
})
|
| 278 |
+
|
| 279 |
+
return response
|
| 280 |
+
|
| 281 |
+
def _build_context_instruction(self, context, chat_history, user_query=""):
|
| 282 |
+
"""
|
| 283 |
+
Build clear instruction based on conversation stage
|
| 284 |
+
"""
|
| 285 |
+
stage = context.get('conversation_stage', 0)
|
| 286 |
+
urgency = context.get('urgency', 'medium')
|
| 287 |
+
is_vague = context.get('is_vague', False)
|
| 288 |
+
|
| 289 |
+
# PRIORITY: Handle vague/unclear queries first
|
| 290 |
+
if is_vague and stage == 0:
|
| 291 |
+
return """\n\nPHASE: LÀM RÕ Ý ĐỊNH (VỚI GỢI Ý)
|
| 292 |
+
User query không rõ ràng. Giúp user bằng GỢI Ý CỤ THỂ:
|
| 293 |
+
|
| 294 |
+
1. ACKNOWLEDGE + HỎI VỚI GỢI Ý:
|
| 295 |
+
Format: "Mình thấy bạn [cảm giác user nói]. Bạn có thể cho mình biết rõ hơn không? Ví dụ như:
|
| 296 |
+
• [Gợi ý 1 liên quan]
|
| 297 |
+
• [Gợi ý 2 liên quan]
|
| 298 |
+
• [Gợi ý 3 liên quan]
|
| 299 |
+
• Hoặc vấn đề khác?"
|
| 300 |
+
|
| 301 |
+
2. GỢI Ý DỰA VÀO TỪ KHÓA:
|
| 302 |
+
- "mệt" → gợi ý: mệt cơ thể, mệt tinh thần, mất ngủ, stress
|
| 303 |
+
- "không khỏe" → gợi ý: đau đầu, buồn nôn, chóng mặt, sốt
|
| 304 |
+
- "khó chịu" → gợi ý: đau bụng, khó tiêu, lo âu, căng thẳng
|
| 305 |
+
- "không ổn" → gợi ý: sức khỏe thể chất, tinh thần, dinh dưỡng
|
| 306 |
+
|
| 307 |
+
3. VÍ DỤ CỤ THỂ:
|
| 308 |
+
User: "tôi mệt"
|
| 309 |
+
Bot: "Mình thấy bạn đang cảm thấy mệt. Bạn có thể nói rõ hơn không? Ví dụ:
|
| 310 |
+
• Mệt cơ thể, không có sức?
|
| 311 |
+
• Mệt tinh thần, stress?
|
| 312 |
+
• Mất ngủ, ngủ không ngon?
|
| 313 |
+
• Hay vấn đề khác?"
|
| 314 |
+
|
| 315 |
+
QUAN TRỌNG:
|
| 316 |
+
- Dùng từ khóa user nói để tạo gợi ý phù hợp
|
| 317 |
+
- 3-4 gợi ý cụ thể
|
| 318 |
+
- Luôn có "hoặc vấn đề khác" để mở rộng
|
| 319 |
+
- Tự nhiên, không formal"""
|
| 320 |
+
|
| 321 |
+
# Assessment phase (first 1-2 turns)
|
| 322 |
+
if stage <= 1:
|
| 323 |
+
return """\n\nPHASE: ĐÁNH GIÁ TRIỆU CHỨNG
|
| 324 |
+
Hỏi 1-2 câu ngắn để hiểu rõ:
|
| 325 |
+
- Thời gian xuất hiện
|
| 326 |
+
- Vị trí đau
|
| 327 |
+
- Mức độ đau
|
| 328 |
+
CHỈ HỎi, KHÔNG đưa lời khuyên."""
|
| 329 |
+
|
| 330 |
+
# High urgency - skip to solutions
|
| 331 |
+
if urgency == 'high':
|
| 332 |
+
return """\n\nPHASE: GIẢI PHÁP KHẨN CẤP
|
| 333 |
+
User cần giúp NGAY. Đưa ra:
|
| 334 |
+
1. Giải pháp tức thời (2-3 điềm)
|
| 335 |
+
2. Thuốc có thể dùng + disclaimer
|
| 336 |
+
3. Cảnh báo khi nào đi khám
|
| 337 |
+
KHÔNG hỏi thêm."""
|
| 338 |
+
|
| 339 |
+
# Check if user is answering self-assessment questions
|
| 340 |
+
if chat_history and len(chat_history) > 0:
|
| 341 |
+
last_bot_msg = chat_history[-1][1] if len(chat_history[-1]) > 1 else ""
|
| 342 |
+
# More specific detection - must have "Câu hỏi tự kiểm tra" section
|
| 343 |
+
if ("Câu hỏi tự kiểm tra" in last_bot_msg or "### Câu hỏi tự kiểm tra" in last_bot_msg) and len(user_query) > 30:
|
| 344 |
+
return """\n\nPHASE: PHÂN TÍCH KẾT QUẢ TỰ KIỂM TRA
|
| 345 |
+
User vừa trả lời self-assessment. Phân tích THÔNG MINH dựa trên ĐÚNG CONTEXT:
|
| 346 |
+
|
| 347 |
+
QUAN TRỌNG:
|
| 348 |
+
- CHỈ phân tích dựa trên triệu chứng user VỪA NÓI
|
| 349 |
+
- KHÔNG dùng thông tin từ RAG không liên quan
|
| 350 |
+
- KHÔNG nhầm lẫn với các bệnh khác
|
| 351 |
+
|
| 352 |
+
1. NHẬN DIỆN PATTERN (dựa vào RAG knowledge):
|
| 353 |
+
- Đọc kỹ triệu chứng user mô tả
|
| 354 |
+
- So sánh với các bệnh có thể có (từ RAG)
|
| 355 |
+
- Tìm điểm KHÁC BIỆT quan trọng
|
| 356 |
+
- Đưa ra 1-2 khả năng phù hợp nhất
|
| 357 |
+
|
| 358 |
+
2. ĐÁNH GIÁ MỨC ĐỘ NGHI��M TRỌNG:
|
| 359 |
+
- Có red flags? → "CẦN KHÁM NGAY"
|
| 360 |
+
- Triệu chứng nặng? → "Nên đi khám sớm"
|
| 361 |
+
- Triệu chứng nhẹ? → "Thử giải pháp, không đỡ thì khám"
|
| 362 |
+
|
| 363 |
+
3. LUÔN DISCLAIMER:
|
| 364 |
+
"Đây chỉ là đánh giá sơ bộ dựa trên triệu chứng. Bác sĩ sẽ chẩn đoán chính xác qua khám lâm sàng và xét nghiệm."
|
| 365 |
+
|
| 366 |
+
4. NEXT STEPS:
|
| 367 |
+
- Giải pháp tạm thời (nếu không nguy hiểm)
|
| 368 |
+
- Khi nào cần đi khám
|
| 369 |
+
- "Bạn muốn biết thêm về [bệnh nghi ngờ] không?"
|
| 370 |
+
|
| 371 |
+
QUAN TRỌNG: Phân tích GENERIC cho MỌI triệu chứng, KHÔNG hard-code."""
|
| 372 |
+
|
| 373 |
+
# Check if user asking "how to know" / differential diagnosis
|
| 374 |
+
if any(phrase in user_query.lower() for phrase in [
|
| 375 |
+
'làm sao biết', 'làm sao để biết', 'phân biệt',
|
| 376 |
+
'khác nhau thế nào', 'hay', 'hoặc'
|
| 377 |
+
]):
|
| 378 |
+
return """\n\nPHASE: HƯỚNG DẪN TỰ KIỂM TRA (GENERIC)
|
| 379 |
+
User muốn phân biệt các bệnh/tình trạng. Sử dụng RAG để:
|
| 380 |
+
|
| 381 |
+
1. XÁC ĐỊNH các bệnh có thể (từ user query):
|
| 382 |
+
- Trích xuất các bệnh user đề cập
|
| 383 |
+
- Hoặc tìm các bệnh liên quan đến triệu chứng
|
| 384 |
+
|
| 385 |
+
2. TẠO BẢNG SO SÁNH:
|
| 386 |
+
Format:
|
| 387 |
+
**[Bệnh A]:**
|
| 388 |
+
• Triệu chứng đặc trưng 1
|
| 389 |
+
• Triệu chứng đặc trưng 2
|
| 390 |
+
• Đặc điểm riêng
|
| 391 |
+
|
| 392 |
+
**[Bệnh B]:**
|
| 393 |
+
• Triệu chứng đặc trưng 1
|
| 394 |
+
• Triệu chứng đặc trưng 2
|
| 395 |
+
• Đặc điểm riêng
|
| 396 |
+
|
| 397 |
+
**Điểm khác biệt chính:** [Highlight key differences]
|
| 398 |
+
|
| 399 |
+
3. CÂU HỎI TỰ KIỂM TRA:
|
| 400 |
+
Tạo 3-5 câu hỏi giúp user tự đánh giá:
|
| 401 |
+
• Về thời gian xuất hiện
|
| 402 |
+
• Về đặc điểm triệu chứng
|
| 403 |
+
• Về yếu tố kích hoạt
|
| 404 |
+
• Về triệu chứng kèm theo
|
| 405 |
+
|
| 406 |
+
4. LUÔN DISCLAIMER:
|
| 407 |
+
"Tuy nhiên, chỉ bác sĩ mới chẩn đoán chính xác qua khám lâm sàng và xét nghiệm."
|
| 408 |
+
|
| 409 |
+
5. Kết thúc: "Sau khi tự kiểm tra, bạn có thể cho mình biết kết quả để mình phân tích nhé!"
|
| 410 |
+
|
| 411 |
+
QUAN TRỌNG: Dùng RAG knowledge, KHÔNG hard-code bệnh cụ thể."""
|
| 412 |
+
|
| 413 |
+
# Advice phase (have enough info)
|
| 414 |
+
return """\n\nPHASE: TƯ VẤN & PHÒNG NGỪÀ
|
| 415 |
+
Đưa ra:
|
| 416 |
+
1. Đánh giá ngắn (1 câu): Triệu chứng có thể là gì
|
| 417 |
+
2. Giải pháp (3-4 điểm cụ thể)
|
| 418 |
+
3. Khi nào cần đi khám
|
| 419 |
+
4. Kết thúc: "Có gì thắc mắc cứ hỏi mình nhé!"
|
| 420 |
+
KHÔNG nói "Dựa trên thông tin"."""
|
| 421 |
+
|
| 422 |
+
def _validate_response(self, response, context):
|
| 423 |
+
"""
|
| 424 |
+
Validate if LLM response follows instructions
|
| 425 |
+
Returns: (is_valid, list_of_issues)
|
| 426 |
+
"""
|
| 427 |
+
issues = []
|
| 428 |
+
stage = context.get('conversation_stage', 0)
|
| 429 |
+
|
| 430 |
+
# Check for bad formal phrases
|
| 431 |
+
bad_phrases = [
|
| 432 |
+
"Dựa trên thông tin bạn cung cấp",
|
| 433 |
+
"Dựa vào thông tin",
|
| 434 |
+
"Theo thông tin bạn đưa ra"
|
| 435 |
+
]
|
| 436 |
+
|
| 437 |
+
for phrase in bad_phrases:
|
| 438 |
+
if phrase.lower() in response.lower():
|
| 439 |
+
issues.append(f"Dùng cụm từ formal: '{phrase}'")
|
| 440 |
+
break
|
| 441 |
+
|
| 442 |
+
# Assessment phase: should ask, not advise
|
| 443 |
+
if stage <= 1:
|
| 444 |
+
advice_indicators = [
|
| 445 |
+
"khuyến nghị", "nên", "hãy", "bạn thử",
|
| 446 |
+
"giải pháp", "cách xử lý"
|
| 447 |
+
]
|
| 448 |
+
has_advice = any(ind in response.lower() for ind in advice_indicators)
|
| 449 |
+
has_question = '?' in response
|
| 450 |
+
|
| 451 |
+
if has_advice and not has_question:
|
| 452 |
+
issues.append("Đưa lời khuyên quá sớm (phase assessment)")
|
| 453 |
+
|
| 454 |
+
# Check if both asking and advising (bad)
|
| 455 |
+
if '?' in response:
|
| 456 |
+
advice_count = sum(1 for ind in ["khuyến nghị", "nên", "hãy thử"] if ind in response.lower())
|
| 457 |
+
if advice_count >= 2:
|
| 458 |
+
issues.append("Vừa hỏi vừa khuyên trong cùng response")
|
| 459 |
+
|
| 460 |
+
is_valid = len(issues) == 0
|
| 461 |
+
return is_valid, issues
|
| 462 |
+
|
| 463 |
+
def _post_process_response(self, response, context):
|
| 464 |
+
"""
|
| 465 |
+
Clean up LLM response to ensure quality
|
| 466 |
+
"""
|
| 467 |
+
# Remove formal phrases
|
| 468 |
+
bad_phrases = [
|
| 469 |
+
"Dựa trên thông tin bạn cung cấp",
|
| 470 |
+
"Dựa vào thông tin",
|
| 471 |
+
"Theo thông tin bạn đưa ra",
|
| 472 |
+
"Từ thông tin trên"
|
| 473 |
+
]
|
| 474 |
+
|
| 475 |
+
for phrase in bad_phrases:
|
| 476 |
+
response = response.replace(phrase, "")
|
| 477 |
+
response = response.replace(phrase.lower(), "")
|
| 478 |
+
|
| 479 |
+
# Clean up extra whitespace
|
| 480 |
+
response = "\n".join(line.strip() for line in response.split("\n") if line.strip())
|
| 481 |
+
|
| 482 |
+
return response
|
| 483 |
+
|
| 484 |
+
def _check_red_flags(self, user_query, chat_history):
|
| 485 |
+
"""Check for dangerous symptoms that need immediate medical attention"""
|
| 486 |
+
all_text = user_query.lower()
|
| 487 |
+
if chat_history:
|
| 488 |
+
all_text += " " + " ".join([msg[0].lower() for msg in chat_history if msg[0]])
|
| 489 |
+
|
| 490 |
+
red_flags = {
|
| 491 |
+
"heart_attack": {
|
| 492 |
+
"keywords": ["đau ngực", "khó thở", "chest pain", "đau tim"],
|
| 493 |
+
"message": """🚨 **CẢNH BÁO KHẨN CẤP**
|
| 494 |
+
|
| 495 |
+
Triệu chứng của bạn có thể liên quan đến **cơn đau tim**. Đây là tình huống khẩn cấp!
|
| 496 |
+
|
| 497 |
+
⚠️ **HÃY LÀM NGAY:**
|
| 498 |
+
1. **Gọi cấp cứu 115** hoặc đến bệnh viện GẤP
|
| 499 |
+
2. Ngồi nghỉ, không vận động
|
| 500 |
+
3. Nếu có aspirin, nhai 1 viên (nếu không dị ứng)
|
| 501 |
+
4. Thông báo cho người thân
|
| 502 |
+
|
| 503 |
+
🚑 **KHÔNG TỰ LÁI XE** - Gọi xe cấp cứu hoặc nhờ người khác đưa đi
|
| 504 |
+
|
| 505 |
+
Sức khỏe của bạn là ưu tiên số 1. Hãy đi khám NGAY nhé! 💙"""
|
| 506 |
+
},
|
| 507 |
+
"stroke": {
|
| 508 |
+
"keywords": ["yếu một bên", "méo miệng", "nói khó", "tê nửa người"],
|
| 509 |
+
"message": """🚨 **CẢNH BÁO KHẨN CẤP - NGUY CƠ ĐỘT QUỴ**
|
| 510 |
+
|
| 511 |
+
Triệu chứng của bạn có thể là **đột quỵ não**. Mỗi phút đều quan trọng!
|
| 512 |
+
|
| 513 |
+
⚠️ **HÃY LÀM NGAY:**
|
| 514 |
+
1. **Gọi cấp cứu 115 NGAY LẬP TỨC**
|
| 515 |
+
2. Ghi nhớ thời gian triệu chứng bắt đầu
|
| 516 |
+
3. Nằm nghỉ, đầu hơi cao
|
| 517 |
+
4. KHÔNG cho ăn uống gì
|
| 518 |
+
|
| 519 |
+
🚑 Đây là cấp cứu y tế. Hãy đi bệnh viện NGAY! Thời gian vàng chỉ có 3-4 giờ!"""
|
| 520 |
+
},
|
| 521 |
+
"meningitis": {
|
| 522 |
+
"keywords": ["đau đầu dữ dội", "cứng gáy", "sốt cao", "buồn nôn"],
|
| 523 |
+
"message": """🚨 **CẢNH BÁO - CẦN KHÁM NGAY**
|
| 524 |
+
|
| 525 |
+
Triệu chứng đau đầu dữ dội + cứng gáy + sốt có thể là **viêm màng não** - rất nguy hiểm!
|
| 526 |
+
|
| 527 |
+
⚠️ **HÃY LÀM NGAY:**
|
| 528 |
+
1. Đi bệnh viện hoặc gọi cấp cứu 115
|
| 529 |
+
2. Không trì hoãn
|
| 530 |
+
3. Thông báo bác sĩ về tất cả triệu chứng
|
| 531 |
+
|
| 532 |
+
Đây là tình huống nghiêm trọng. Hãy đi khám NGAY nhé! 🏥"""
|
| 533 |
+
},
|
| 534 |
+
"severe_abdominal": {
|
| 535 |
+
"keywords": ["đau bụng dữ dội", "đau bụng không chịu nổi", "đau bụng cấp"],
|
| 536 |
+
"message": """⚠️ **CẦN KHÁM BÁC SĨ NGAY**
|
| 537 |
+
|
| 538 |
+
Đau bụng dữ dội có thể là nhiều nguyên nhân nghiêm trọng (viêm ruột thừa, sỏi mật, thủng dạ dày...).
|
| 539 |
+
|
| 540 |
+
🏥 **Hãy đi khám ngay nếu:**
|
| 541 |
+
- Đau không giảm sau 1-2 giờ
|
| 542 |
+
- Kèm sốt, nôn, tiêu chảy
|
| 543 |
+
- Bụng cứng, đau khi ấn
|
| 544 |
+
- Có máu trong phân
|
| 545 |
+
|
| 546 |
+
Đừng chần chừ, hãy đi bệnh viện để được khám và xử lý kịp thời nhé!"""
|
| 547 |
+
}
|
| 548 |
+
}
|
| 549 |
+
|
| 550 |
+
for flag_type, flag_data in red_flags.items():
|
| 551 |
+
if any(keyword in all_text for keyword in flag_data["keywords"]):
|
| 552 |
+
return flag_data["message"]
|
| 553 |
+
|
| 554 |
+
return None
|
| 555 |
+
|
| 556 |
+
def _needs_rag_query(self, user_query, chat_history):
|
| 557 |
+
"""Determine if RAG query is needed for this question"""
|
| 558 |
+
# Simple questions don't need RAG
|
| 559 |
+
simple_patterns = [
|
| 560 |
+
'đau', 'bị', 'khó tiêu', 'mệt', 'chóng mặt', 'buồn nôn',
|
| 561 |
+
'sốt', 'ho', 'cảm', 'đau đầu', 'đau bụng', 'đau lưng'
|
| 562 |
+
]
|
| 563 |
+
|
| 564 |
+
# Check if it's a simple symptom report (first turn)
|
| 565 |
+
if not chat_history or len(chat_history) == 0:
|
| 566 |
+
# First message - usually just symptom report
|
| 567 |
+
if any(pattern in user_query.lower() for pattern in simple_patterns):
|
| 568 |
+
return False # Don't need RAG for initial symptom report
|
| 569 |
+
|
| 570 |
+
# Need RAG for complex questions or specific medical info
|
| 571 |
+
complex_patterns = [
|
| 572 |
+
'nguyên nhân', 'tại sao', 'làm sao', 'điều trị', 'thuốc',
|
| 573 |
+
'phòng ngừa', 'biến chứng', 'triệu chứng của', 'bệnh gì'
|
| 574 |
+
]
|
| 575 |
+
|
| 576 |
+
if any(pattern in user_query.lower() for pattern in complex_patterns):
|
| 577 |
+
return True
|
| 578 |
+
|
| 579 |
+
# Default: don't use RAG for conversational turns
|
| 580 |
+
return False
|
| 581 |
+
|
| 582 |
+
def _natural_symptom_assessment(self, user_query, chat_history):
|
| 583 |
+
"""Use LLM to naturally assess symptoms with context awareness"""
|
| 584 |
+
try:
|
| 585 |
+
# Analyze user context and intent
|
| 586 |
+
context = ContextAnalyzer.analyze_user_intent(user_query, chat_history)
|
| 587 |
+
response_structure = ContextAnalyzer.determine_response_structure(context)
|
| 588 |
+
|
| 589 |
+
# Smart RAG - only query when needed
|
| 590 |
+
rag_answer = ''
|
| 591 |
+
rag_sources = []
|
| 592 |
+
|
| 593 |
+
if self._needs_rag_query(user_query, chat_history):
|
| 594 |
+
# Build context-aware RAG query
|
| 595 |
+
# Include recent conversation context to get relevant results
|
| 596 |
+
if chat_history and len(chat_history) > 0:
|
| 597 |
+
last_exchange = chat_history[-1]
|
| 598 |
+
last_bot_msg = last_exchange[1] if len(last_exchange) > 1 else ""
|
| 599 |
+
# If answering self-assessment, include the diseases mentioned
|
| 600 |
+
if "Câu hỏi tự kiểm tra" in last_bot_msg:
|
| 601 |
+
# Extract diseases from last bot message
|
| 602 |
+
import re
|
| 603 |
+
diseases = re.findall(r'\*\*\[(.*?)\]:\*\*', last_bot_msg)
|
| 604 |
+
if diseases:
|
| 605 |
+
# Query specifically about those diseases
|
| 606 |
+
enhanced_query = f"{user_query} (liên quan đến: {', '.join(diseases)})"
|
| 607 |
+
rag_result = self.rag.query_health(enhanced_query)
|
| 608 |
+
else:
|
| 609 |
+
rag_result = self.rag.query_health(user_query)
|
| 610 |
+
else:
|
| 611 |
+
rag_result = self.rag.query_health(user_query)
|
| 612 |
+
else:
|
| 613 |
+
rag_result = self.rag.query_health(user_query)
|
| 614 |
+
|
| 615 |
+
rag_answer = rag_result.get('answer', '')
|
| 616 |
+
rag_sources = rag_result.get('source_docs', [])
|
| 617 |
+
|
| 618 |
+
# Build conversation context
|
| 619 |
+
messages = [{"role": "system", "content": self.system_prompt}]
|
| 620 |
+
|
| 621 |
+
# Add RAG context if available with explicit filtering instruction
|
| 622 |
+
if rag_answer:
|
| 623 |
+
# Add warning about context relevance
|
| 624 |
+
rag_instruction = f"""Thông tin tham khảo từ cơ sở dữ liệu:\n{rag_answer}
|
| 625 |
+
|
| 626 |
+
⚠️ QUAN TRỌNG:
|
| 627 |
+
- CHỈ sử dụng thông tin LIÊN QUAN đến triệu chứng user đang nói
|
| 628 |
+
- KHÔNG dùng thông tin về bệnh khác không liên quan
|
| 629 |
+
- Nếu thông tin RAG không match với triệu chứng user → BỎ QUA"""
|
| 630 |
+
messages.append({"role": "system", "content": rag_instruction})
|
| 631 |
+
|
| 632 |
+
# Add chat history (last 5 exchanges for context)
|
| 633 |
+
if chat_history:
|
| 634 |
+
recent_history = chat_history[-5:] if len(chat_history) > 5 else chat_history
|
| 635 |
+
for user_msg, bot_msg in recent_history:
|
| 636 |
+
if user_msg:
|
| 637 |
+
messages.append({"role": "user", "content": user_msg})
|
| 638 |
+
if bot_msg:
|
| 639 |
+
messages.append({"role": "assistant", "content": bot_msg})
|
| 640 |
+
|
| 641 |
+
# Build context-aware instruction
|
| 642 |
+
context_prompt = self._build_context_instruction(context, chat_history, user_query)
|
| 643 |
+
|
| 644 |
+
messages.append({"role": "user", "content": user_query + context_prompt})
|
| 645 |
+
|
| 646 |
+
# Get LLM response
|
| 647 |
+
response = client.chat.completions.create(
|
| 648 |
+
model=MODEL,
|
| 649 |
+
messages=messages,
|
| 650 |
+
temperature=0.7,
|
| 651 |
+
max_tokens=500
|
| 652 |
+
)
|
| 653 |
+
|
| 654 |
+
llm_response = response.choices[0].message.content
|
| 655 |
+
|
| 656 |
+
# CRITICAL: Check for context mismatch (e.g., talking about brain when discussing stomach)
|
| 657 |
+
if chat_history and len(chat_history) > 0:
|
| 658 |
+
# Get recent symptoms mentioned
|
| 659 |
+
recent_symptoms = []
|
| 660 |
+
for msg, _ in chat_history[-3:]:
|
| 661 |
+
if msg:
|
| 662 |
+
recent_symptoms.extend([
|
| 663 |
+
'đau bụng', 'dạ dày', 'tiêu hóa', 'ăn', 'buồn nôn', 'ợ'
|
| 664 |
+
] if any(w in msg.lower() for w in ['bụng', 'dạ dày', 'ăn', 'nôn', 'ợ']) else [])
|
| 665 |
+
|
| 666 |
+
# Check if response mentions completely unrelated conditions
|
| 667 |
+
unrelated_keywords = {
|
| 668 |
+
'stomach': ['viêm màng não', 'cứng gáy', 'não'],
|
| 669 |
+
'head': ['đau bụng', 'tiêu hóa', 'dạ dày'],
|
| 670 |
+
'respiratory': ['đau bụng', 'dạ dày']
|
| 671 |
+
}
|
| 672 |
+
|
| 673 |
+
# If discussing stomach but response mentions brain → REJECT
|
| 674 |
+
if recent_symptoms and any('bụng' in s or 'dạ dày' in s for s in recent_symptoms):
|
| 675 |
+
if any(keyword in llm_response.lower() for keyword in ['viêm màng não', 'cứng gáy', 'não', 'đầu dữ dội']):
|
| 676 |
+
print("⚠️ CONTEXT MISMATCH DETECTED: Response about brain when discussing stomach!")
|
| 677 |
+
# Force retry with explicit instruction
|
| 678 |
+
messages[-1]['content'] += "\n\n🚨 LỖI NGHIÊM TRỌNG: User đang nói về BỤng/DẠ DÀY, KHÔNG phải đầu/não! Phân tích lại ĐÚNG triệu chứng!"
|
| 679 |
+
|
| 680 |
+
retry_response = client.chat.completions.create(
|
| 681 |
+
model=MODEL,
|
| 682 |
+
messages=messages,
|
| 683 |
+
temperature=0.3, # Very low temp for accuracy
|
| 684 |
+
max_tokens=500
|
| 685 |
+
)
|
| 686 |
+
llm_response = retry_response.choices[0].message.content
|
| 687 |
+
|
| 688 |
+
# Validate response quality using shared validator
|
| 689 |
+
is_valid, issues = ResponseValidator.validate_response(
|
| 690 |
+
llm_response,
|
| 691 |
+
agent_type='symptom',
|
| 692 |
+
context=context,
|
| 693 |
+
chat_history=chat_history
|
| 694 |
+
)
|
| 695 |
+
|
| 696 |
+
# Retry if invalid (max 1 retry)
|
| 697 |
+
if not is_valid:
|
| 698 |
+
print(f"Response validation failed: {issues}. Retrying...")
|
| 699 |
+
# Add stronger instruction
|
| 700 |
+
messages[-1]['content'] += f"\n\nLỖI TRƯỚC: {', '.join(issues)}. HÃY SỬA LẠI!"
|
| 701 |
+
|
| 702 |
+
retry_response = client.chat.completions.create(
|
| 703 |
+
model=MODEL,
|
| 704 |
+
messages=messages,
|
| 705 |
+
temperature=0.5, # Lower temp for more control
|
| 706 |
+
max_tokens=500
|
| 707 |
+
)
|
| 708 |
+
llm_response = retry_response.choices[0].message.content
|
| 709 |
+
|
| 710 |
+
# Post-process to ensure quality
|
| 711 |
+
llm_response = self._post_process_response(llm_response, context)
|
| 712 |
+
|
| 713 |
+
# Add sources using RAG integration formatter (FIXED!)
|
| 714 |
+
if rag_sources:
|
| 715 |
+
formatted_response = self.rag.format_response_with_sources({
|
| 716 |
+
'answer': llm_response,
|
| 717 |
+
'source_docs': rag_sources
|
| 718 |
+
})
|
| 719 |
+
return formatted_response
|
| 720 |
+
|
| 721 |
+
return llm_response
|
| 722 |
+
|
| 723 |
+
except Exception as e:
|
| 724 |
+
return f"""Xin lỗi, mình gặp lỗi kỹ thuật. Bạn có thể:
|
| 725 |
+
1. Thử lại câu hỏi
|
| 726 |
+
2. Hoặc nếu triệu chứng nghiêm trọng, hãy gặp bác sĩ ngay nhé 🙏
|
| 727 |
+
|
| 728 |
+
Lỗi: {str(e)[:100]}"""
|
| 729 |
+
|
| 730 |
+
def _assess_opqrst_progress(self, chat_history):
|
| 731 |
+
"""Assess how much OPQRST data has been collected"""
|
| 732 |
+
if not chat_history:
|
| 733 |
+
return {'complete': False, 'next_step': 'onset', 'data': {}}
|
| 734 |
+
|
| 735 |
+
# Analyze conversation to see what's been asked
|
| 736 |
+
all_bot_messages = " ".join([msg[1].lower() for msg in chat_history if msg[1]])
|
| 737 |
+
all_user_messages = " ".join([msg[0].lower() for msg in chat_history if msg[0]])
|
| 738 |
+
|
| 739 |
+
opqrst_data = {
|
| 740 |
+
'onset': None,
|
| 741 |
+
'provocation': None,
|
| 742 |
+
'quality': None,
|
| 743 |
+
'region': None,
|
| 744 |
+
'severity': None,
|
| 745 |
+
'timing': None
|
| 746 |
+
}
|
| 747 |
+
|
| 748 |
+
# Check what's been asked
|
| 749 |
+
if "khi nào" in all_bot_messages or "bắt đầu" in all_bot_messages:
|
| 750 |
+
opqrst_data['onset'] = 'asked'
|
| 751 |
+
|
| 752 |
+
if "làm tệ hơn" in all_bot_messages or "làm đỡ" in all_bot_messages:
|
| 753 |
+
opqrst_data['provocation'] = 'asked'
|
| 754 |
+
|
| 755 |
+
if "mô tả cảm giác" in all_bot_messages or "đau kiểu gì" in all_bot_messages:
|
| 756 |
+
opqrst_data['quality'] = 'asked'
|
| 757 |
+
|
| 758 |
+
if "vị trí" in all_bot_messages or "ở đâu" in all_bot_messages:
|
| 759 |
+
opqrst_data['region'] = 'asked'
|
| 760 |
+
|
| 761 |
+
if "mức độ" in all_bot_messages or "1-10" in all_bot_messages:
|
| 762 |
+
opqrst_data['severity'] = 'asked'
|
| 763 |
+
|
| 764 |
+
if "lúc nào xuất hiện" in all_bot_messages or "liên tục" in all_bot_messages:
|
| 765 |
+
opqrst_data['timing'] = 'asked'
|
| 766 |
+
|
| 767 |
+
# Determine next step
|
| 768 |
+
for step, value in opqrst_data.items():
|
| 769 |
+
if value is None:
|
| 770 |
+
return {'complete': False, 'next_step': step, 'data': opqrst_data}
|
| 771 |
+
|
| 772 |
+
# All steps completed
|
| 773 |
+
return {'complete': True, 'next_step': None, 'data': opqrst_data}
|
| 774 |
+
|
| 775 |
+
def _ask_next_opqrst_question(self, next_step, user_query):
|
| 776 |
+
"""Ask the next OPQRST question"""
|
| 777 |
+
questions = {
|
| 778 |
+
'onset': """Mình hiểu rồi. Để đánh giá chính xác hơn, cho mình hỏi thêm nhé:
|
| 779 |
+
|
| 780 |
+
- Triệu chứng này bắt đầu từ khi nào? (hôm nay, mấy ngày, mấy tuần?)
|
| 781 |
+
- Nó xuất hiện đột ngột hay từ từ?""",
|
| 782 |
+
|
| 783 |
+
'quality': """À được rồi. Mà này:
|
| 784 |
+
|
| 785 |
+
- Bạn mô tả cảm giác đó như thế nào? (đau nhói, tức, nóng rát, tê, đập thình thình...)
|
| 786 |
+
- Mức độ từ 1-10 thì bao nhiêu? (1 = nhẹ, 10 = không chịu nổi)""",
|
| 787 |
+
|
| 788 |
+
'region': """Ừm, để mình hỏi thêm:
|
| 789 |
+
|
| 790 |
+
- Vị trí chính xác ở đâu? (chỉ rõ vùng cơ thể)
|
| 791 |
+
- Có lan ra chỗ khác không?""",
|
| 792 |
+
|
| 793 |
+
'provocation': """Bạn có nhận thấy:
|
| 794 |
+
|
| 795 |
+
- Có gì làm nó tệ hơn không? (vận động, ăn uống, stress, tư thế...)
|
| 796 |
+
- Có gì làm nó đỡ hơn không? (nghỉ ngơi, thuốc, chườm...)""",
|
| 797 |
+
|
| 798 |
+
'timing': """Quan trọng nhé:
|
| 799 |
+
|
| 800 |
+
- Nó xuất hiện lúc nào trong ngày? (sáng, chiều, tối, đêm?)
|
| 801 |
+
- Liên tục hay từng đợt? Mỗi đợt kéo dài bao lâu?""",
|
| 802 |
+
|
| 803 |
+
'severity': """Cuối cùng:
|
| 804 |
+
|
| 805 |
+
- Có kèm theo triệu chứng nào khác không? (sốt, buồn nôn, chóng mặt, mệt mỏi...)
|
| 806 |
+
- Có ảnh hưởng đến ăn uống, ngủ nghỉ, sinh hoạt không?
|
| 807 |
+
- Bạn có bệnh nền gì không? Đang uống thuốc gì không?"""
|
| 808 |
+
}
|
| 809 |
+
|
| 810 |
+
return questions.get(next_step, "Cho mình biết thêm về triệu chứng của bạn nhé?")
|
| 811 |
+
|
| 812 |
+
def _provide_assessment(self, opqrst_data, user_query):
|
| 813 |
+
"""Provide symptom assessment after collecting OPQRST data"""
|
| 814 |
+
# Use LLM to analyze symptoms with OPQRST context
|
| 815 |
+
try:
|
| 816 |
+
response = client.chat.completions.create(
|
| 817 |
+
model=MODEL,
|
| 818 |
+
messages=[
|
| 819 |
+
{"role": "system", "content": self.system_prompt},
|
| 820 |
+
{"role": "user", "content": f"""Dựa vào thông tin OPQRST đã thu thập, hãy đánh giá triệu chứng và đưa ra lời khuyên.
|
| 821 |
+
|
| 822 |
+
Triệu chứng ban đầu: {user_query}
|
| 823 |
+
|
| 824 |
+
Thông tin đã thu thập:
|
| 825 |
+
- Onset: {opqrst_data.get('onset', 'chưa rõ')}
|
| 826 |
+
- Quality: {opqrst_data.get('quality', 'chưa rõ')}
|
| 827 |
+
- Region: {opqrst_data.get('region', 'chưa rõ')}
|
| 828 |
+
- Provocation: {opqrst_data.get('provocation', 'chưa rõ')}
|
| 829 |
+
- Timing: {opqrst_data.get('timing', 'chưa rõ')}
|
| 830 |
+
- Severity: {opqrst_data.get('severity', 'chưa rõ')}
|
| 831 |
+
|
| 832 |
+
Hãy đưa ra:
|
| 833 |
+
1. Phân tích triệu chứng
|
| 834 |
+
2. Nguyên nhân có thể
|
| 835 |
+
3. Lời khuyên xử lý tại nhà (nếu phù hợp)
|
| 836 |
+
4. Khi nào cần gặp bác sĩ
|
| 837 |
+
5. Lời động viên, trấn an"""}
|
| 838 |
+
],
|
| 839 |
+
temperature=0.7,
|
| 840 |
+
max_tokens=1500
|
| 841 |
+
)
|
| 842 |
+
|
| 843 |
+
return response.choices[0].message.content
|
| 844 |
+
|
| 845 |
+
except Exception as e:
|
| 846 |
+
return f"""Xin lỗi, mình gặp chút vấn đề khi phân tích triệu chứng.
|
| 847 |
+
|
| 848 |
+
Dựa vào những gì bạn chia sẻ, mình khuyên bạn nên:
|
| 849 |
+
- Theo dõi triệu chứng thêm 24-48 giờ
|
| 850 |
+
- Nghỉ ngơi đầy đủ
|
| 851 |
+
- Uống đủ nước
|
| 852 |
+
- Nếu triệu chứng tệ hơn hoặc không giảm → đi khám bác sĩ
|
| 853 |
+
|
| 854 |
+
Với các triệu chứng bất thường, tốt nhất là được bác sĩ khám trực tiếp nhé! 🏥"""
|
app.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from ui import build_ui
|
| 2 |
+
import signal
|
| 3 |
+
import sys
|
| 4 |
+
import os
|
| 5 |
+
|
| 6 |
+
def signal_handler(sig, frame):
|
| 7 |
+
"""Handle Ctrl+C gracefully"""
|
| 8 |
+
print("\n\n👋 Đang tắt server... Bye bye!")
|
| 9 |
+
# Use os._exit() instead of sys.exit() to avoid atexit callbacks
|
| 10 |
+
# This prevents the torch cleanup race condition warning
|
| 11 |
+
os._exit(0)
|
| 12 |
+
|
| 13 |
+
# Register signal handler
|
| 14 |
+
signal.signal(signal.SIGINT, signal_handler)
|
| 15 |
+
|
| 16 |
+
demo = build_ui()
|
| 17 |
+
if __name__ == "__main__":
|
| 18 |
+
try:
|
| 19 |
+
demo.queue().launch(
|
| 20 |
+
debug=False,
|
| 21 |
+
share=True,
|
| 22 |
+
show_api=False,
|
| 23 |
+
show_error=True,
|
| 24 |
+
quiet=False # Keep startup messages but hide processing time in UI
|
| 25 |
+
)
|
| 26 |
+
except KeyboardInterrupt:
|
| 27 |
+
print("\n\n👋 Server đã tắt. Hẹn gặp lại!")
|
| 28 |
+
except Exception as e:
|
| 29 |
+
print(f"\n❌ Lỗi: {e}")
|
| 30 |
+
finally:
|
| 31 |
+
print("✅ Cleanup hoàn tất.")
|
assets/bot-avatar.png
ADDED
|
|
Git LFS Details
|
auth/auth.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# auth/auth.py
|
| 2 |
+
import bcrypt
|
| 3 |
+
from auth.db import get_connection
|
| 4 |
+
|
| 5 |
+
def register_user(username, password):
|
| 6 |
+
username = username.strip()
|
| 7 |
+
password = password.strip()
|
| 8 |
+
if not username or not password:
|
| 9 |
+
return False, "Vui lòng nhập tài khoản và mật khẩu"
|
| 10 |
+
conn = get_connection()
|
| 11 |
+
cursor = conn.cursor()
|
| 12 |
+
cursor.execute("SELECT * FROM users WHERE username = ?", (username,))
|
| 13 |
+
if cursor.fetchone():
|
| 14 |
+
conn.close()
|
| 15 |
+
return False, "Tài khoản đã tồn tại"
|
| 16 |
+
hashed = bcrypt.hashpw(password.encode(), bcrypt.gensalt())
|
| 17 |
+
cursor.execute("INSERT INTO users (username, password_hash) VALUES (?, ?)", (username, hashed))
|
| 18 |
+
conn.commit()
|
| 19 |
+
conn.close()
|
| 20 |
+
return True, "Đăng ký thành công"
|
| 21 |
+
|
| 22 |
+
def login_user(username, password):
|
| 23 |
+
username = username.strip()
|
| 24 |
+
password = password.strip()
|
| 25 |
+
if not username or not password:
|
| 26 |
+
return False, "Vui lòng nhập tài khoản và mật khẩu"
|
| 27 |
+
conn = get_connection()
|
| 28 |
+
cursor = conn.cursor()
|
| 29 |
+
cursor.execute("SELECT password_hash FROM users WHERE username = ?", (username,))
|
| 30 |
+
row = cursor.fetchone()
|
| 31 |
+
conn.close()
|
| 32 |
+
if row and bcrypt.checkpw(password.encode(), row[0]):
|
| 33 |
+
return True, "Đăng nhập thành công"
|
| 34 |
+
return False, "Sai tài khoản hoặc mật khẩu"
|
| 35 |
+
|
| 36 |
+
def save_message(username, message):
|
| 37 |
+
if isinstance(message, (list, tuple)):
|
| 38 |
+
message = "\n".join(str(m) for m in message)
|
| 39 |
+
conn = get_connection()
|
| 40 |
+
cursor = conn.cursor()
|
| 41 |
+
cursor.execute("INSERT INTO chat_history (username, message) VALUES (?, ?)", (username, message))
|
| 42 |
+
conn.commit()
|
| 43 |
+
conn.close()
|
| 44 |
+
|
| 45 |
+
def clear_history(username):
|
| 46 |
+
conn = get_connection()
|
| 47 |
+
cursor = conn.cursor()
|
| 48 |
+
cursor.execute("DELETE FROM chat_history WHERE username = ?", (username,))
|
| 49 |
+
conn.commit()
|
| 50 |
+
conn.close()
|
| 51 |
+
|
| 52 |
+
# def save_message(username, message, agent_type):
|
| 53 |
+
# if isinstance(message, (list, tuple)):
|
| 54 |
+
# message = "\n".join(str(m) for m in message)
|
| 55 |
+
# conn = get_connection()
|
| 56 |
+
# cursor = conn.cursor()
|
| 57 |
+
# cursor.execute("INSERT INTO chat_history (username, message, agent_type) VALUES (?, ?, ?)", (username, message, agent_type))
|
| 58 |
+
# conn.commit()
|
| 59 |
+
# conn.close()
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def load_history(username):
|
| 63 |
+
conn = get_connection()
|
| 64 |
+
cursor = conn.cursor()
|
| 65 |
+
cursor.execute("SELECT message FROM chat_history WHERE username = ? ORDER BY timestamp ASC", (username,))
|
| 66 |
+
rows = cursor.fetchall()
|
| 67 |
+
conn.close()
|
| 68 |
+
|
| 69 |
+
messages = [row[0] for row in rows]
|
| 70 |
+
|
| 71 |
+
history = []
|
| 72 |
+
for i in range(0, len(messages), 2):
|
| 73 |
+
if i + 1 < len(messages):
|
| 74 |
+
history.append([messages[i], messages[i + 1]])
|
| 75 |
+
|
| 76 |
+
# Convert to ChatbotDataMessage format
|
| 77 |
+
from utils.helpers import convert_list_to_chatbot_messages
|
| 78 |
+
return convert_list_to_chatbot_messages(history)
|
| 79 |
+
|
| 80 |
+
# def load_history(username, agent_type):
|
| 81 |
+
# conn = get_connection()
|
| 82 |
+
# cursor = conn.cursor()
|
| 83 |
+
# cursor.execute("SELECT message FROM chat_history WHERE username = ? ORDER BY timestamp ASC", (username,agent_type))
|
| 84 |
+
# rows = cursor.fetchall()
|
| 85 |
+
# conn.close()
|
| 86 |
+
|
| 87 |
+
# messages = [row[0] for row in rows]
|
| 88 |
+
|
| 89 |
+
# history = []
|
| 90 |
+
# for i in range(0, len(messages), 2):
|
| 91 |
+
# if i + 1 < len(messages):
|
| 92 |
+
# history.append([messages[i], messages[i + 1]])
|
| 93 |
+
# return history
|
| 94 |
+
|
| 95 |
+
def logout_user(state):
|
| 96 |
+
state.value["user"] = None
|
| 97 |
+
state.value["history"] = []
|
| 98 |
+
return "Đã đăng xuất"
|
| 99 |
+
|
| 100 |
+
# def logout_user(state):
|
| 101 |
+
# state.value["user"] = None
|
| 102 |
+
# state.value["history"] = []
|
| 103 |
+
# return "Đã đăng xuất"
|
auth/db.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# # auth/db.py
|
| 2 |
+
import sqlite3
|
| 3 |
+
|
| 4 |
+
DB_PATH = "users.db"
|
| 5 |
+
|
| 6 |
+
def get_connection():
|
| 7 |
+
return sqlite3.connect(DB_PATH)
|
| 8 |
+
|
| 9 |
+
# def init_db():
|
| 10 |
+
# conn = get_connection()
|
| 11 |
+
# cursor = conn.cursor()
|
| 12 |
+
# cursor.execute('''
|
| 13 |
+
# CREATE TABLE IF NOT EXISTS users (
|
| 14 |
+
# username TEXT PRIMARY KEY,
|
| 15 |
+
# password_hash TEXT
|
| 16 |
+
# )
|
| 17 |
+
# ''')
|
| 18 |
+
# cursor.execute('''
|
| 19 |
+
# CREATE TABLE IF NOT EXISTS chat_history (
|
| 20 |
+
# id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 21 |
+
# username TEXT,
|
| 22 |
+
# message TEXT,
|
| 23 |
+
# agent_type TEXT,
|
| 24 |
+
# timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
|
| 25 |
+
# )
|
| 26 |
+
# ''')
|
| 27 |
+
# conn.commit()
|
| 28 |
+
# conn.close()
|
| 29 |
+
|
| 30 |
+
def init_db():
|
| 31 |
+
conn = get_connection()
|
| 32 |
+
cursor = conn.cursor()
|
| 33 |
+
cursor.execute('''
|
| 34 |
+
CREATE TABLE IF NOT EXISTS users (
|
| 35 |
+
username TEXT PRIMARY KEY,
|
| 36 |
+
password_hash TEXT
|
| 37 |
+
)
|
| 38 |
+
''')
|
| 39 |
+
cursor.execute('''
|
| 40 |
+
CREATE TABLE IF NOT EXISTS chat_history (
|
| 41 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 42 |
+
username TEXT,
|
| 43 |
+
message TEXT,
|
| 44 |
+
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
| 45 |
+
FOREIGN KEY(username) REFERENCES users(username)
|
| 46 |
+
)
|
| 47 |
+
''')
|
| 48 |
+
conn.commit()
|
| 49 |
+
conn.close()
|
| 50 |
+
|
chroma_db/e49f080a-e10b-4088-ba71-405ae42658a8/data_level0.bin
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:8a1c7ea64fe9a64a550318f75a98804b3c188afa8150c4d21ada092a2b2cc939
|
| 3 |
+
size 5809016
|
chroma_db/e49f080a-e10b-4088-ba71-405ae42658a8/header.bin
ADDED
|
Binary file (100 Bytes). View file
|
|
|
chroma_db/e49f080a-e10b-4088-ba71-405ae42658a8/index_metadata.pickle
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:bea7df1a6cd117ce0c99c0f140761e17b43f79b42dd85c270144e3c9090c5983
|
| 3 |
+
size 319012
|
chroma_db/e49f080a-e10b-4088-ba71-405ae42658a8/length.bin
ADDED
|
Binary file (13.9 kB). View file
|
|
|
chroma_db/e49f080a-e10b-4088-ba71-405ae42658a8/link_lists.bin
ADDED
|
Binary file (30.7 kB). View file
|
|
|
config/settings.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from dotenv import load_dotenv
|
| 3 |
+
|
| 4 |
+
# Tải biến từ file .env
|
| 5 |
+
load_dotenv()
|
| 6 |
+
import openai
|
| 7 |
+
|
| 8 |
+
# Environment-configurable settings
|
| 9 |
+
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "Your API Key")
|
| 10 |
+
OPENAI_BASE_URL = os.getenv("OPENAI_BASE_URL", "https://aiportalapi.stu-platform.live/jpe")
|
| 11 |
+
MODEL = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
|
| 12 |
+
EMBEDDING_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
|
| 13 |
+
|
| 14 |
+
# Initialize OpenAI client (reuse across project)
|
| 15 |
+
client = openai.OpenAI(
|
| 16 |
+
base_url=OPENAI_BASE_URL,
|
| 17 |
+
api_key=OPENAI_API_KEY
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
CHROMA_PATH = r"chroma_db/"
|
| 21 |
+
DATA_PATH = r"rag/data"
|
| 22 |
+
RULES_PATH = r"modules/rules.json"
|
data_mining/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Data Mining Package
|
| 2 |
+
# Scripts for downloading and processing medical datasets
|
data_mining/mining_fitness.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Fitness Dataset - Download & Process
|
| 3 |
+
Downloads and processes gym exercise data into ChromaDB
|
| 4 |
+
Dataset: onurSakar/GYM-Exercise (1.66K exercises)
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from datasets import load_dataset
|
| 8 |
+
import pandas as pd
|
| 9 |
+
import chromadb
|
| 10 |
+
from sentence_transformers import SentenceTransformer
|
| 11 |
+
import os
|
| 12 |
+
|
| 13 |
+
def download_fitness():
|
| 14 |
+
"""Download GYM Exercise dataset from HuggingFace"""
|
| 15 |
+
|
| 16 |
+
print("📥 Downloading GYM Exercise dataset...")
|
| 17 |
+
print(" Source: onurSakar/GYM-Exercise")
|
| 18 |
+
|
| 19 |
+
try:
|
| 20 |
+
dataset = load_dataset("onurSakar/GYM-Exercise")
|
| 21 |
+
|
| 22 |
+
os.makedirs("data_mining/datasets", exist_ok=True)
|
| 23 |
+
|
| 24 |
+
df = dataset['train'].to_pandas()
|
| 25 |
+
|
| 26 |
+
output_path = "data_mining/datasets/gym_exercise.csv"
|
| 27 |
+
df.to_csv(output_path, index=False)
|
| 28 |
+
|
| 29 |
+
file_size = os.path.getsize(output_path) / (1024 * 1024)
|
| 30 |
+
|
| 31 |
+
print(f"✅ Downloaded: {output_path}")
|
| 32 |
+
print(f"📊 Records: {len(df)}")
|
| 33 |
+
print(f"📊 File size: {file_size:.2f} MB")
|
| 34 |
+
|
| 35 |
+
return True
|
| 36 |
+
|
| 37 |
+
except Exception as e:
|
| 38 |
+
print(f"❌ Download failed: {e}")
|
| 39 |
+
return False
|
| 40 |
+
|
| 41 |
+
def process_fitness():
|
| 42 |
+
"""Process Fitness dataset and build ChromaDB"""
|
| 43 |
+
|
| 44 |
+
print("\n🔨 Processing Fitness dataset...")
|
| 45 |
+
|
| 46 |
+
csv_path = "data_mining/datasets/gym_exercise.csv"
|
| 47 |
+
if not os.path.exists(csv_path):
|
| 48 |
+
print(f"❌ Dataset not found: {csv_path}")
|
| 49 |
+
return False
|
| 50 |
+
|
| 51 |
+
df = pd.read_csv(csv_path)
|
| 52 |
+
print(f"📊 Loaded {len(df)} records")
|
| 53 |
+
|
| 54 |
+
print("🤖 Loading embedding model...")
|
| 55 |
+
embedder = SentenceTransformer('keepitreal/vietnamese-sbert')
|
| 56 |
+
|
| 57 |
+
print("💾 Initializing ChromaDB...")
|
| 58 |
+
os.makedirs("data_mining/output", exist_ok=True)
|
| 59 |
+
client = chromadb.PersistentClient(path="data_mining/output/fitness_chroma")
|
| 60 |
+
|
| 61 |
+
collection = client.get_or_create_collection(
|
| 62 |
+
name="fitness",
|
| 63 |
+
metadata={"hnsw:space": "cosine"}
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
print("📝 Processing fitness data...")
|
| 67 |
+
|
| 68 |
+
processed = 0
|
| 69 |
+
|
| 70 |
+
for idx, row in df.iterrows():
|
| 71 |
+
text_parts = []
|
| 72 |
+
for col in df.columns:
|
| 73 |
+
value = str(row[col])
|
| 74 |
+
if value and value != 'nan' and len(value) > 2:
|
| 75 |
+
text_parts.append(f"{col}: {value}")
|
| 76 |
+
|
| 77 |
+
text = "\n".join(text_parts)
|
| 78 |
+
|
| 79 |
+
if len(text) < 10:
|
| 80 |
+
continue
|
| 81 |
+
|
| 82 |
+
embedding = embedder.encode(text)
|
| 83 |
+
|
| 84 |
+
collection.add(
|
| 85 |
+
ids=[f"fitness_{processed:05d}"],
|
| 86 |
+
embeddings=[embedding.tolist()],
|
| 87 |
+
documents=[text],
|
| 88 |
+
metadatas=[{
|
| 89 |
+
'domain': 'fitness',
|
| 90 |
+
'agent': 'FitnessAgent',
|
| 91 |
+
'source': 'GYM_Exercise',
|
| 92 |
+
'index': processed
|
| 93 |
+
}]
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
processed += 1
|
| 97 |
+
|
| 98 |
+
if (processed % 100) == 0:
|
| 99 |
+
print(f" Processed {processed}/{len(df)} records...")
|
| 100 |
+
|
| 101 |
+
print(f"✅ Processed {processed} fitness records")
|
| 102 |
+
print(f"💾 Database saved to: data_mining/output/fitness_chroma/")
|
| 103 |
+
|
| 104 |
+
db_path = "data_mining/output/fitness_chroma"
|
| 105 |
+
total_size = 0
|
| 106 |
+
for dirpath, dirnames, filenames in os.walk(db_path):
|
| 107 |
+
for filename in filenames:
|
| 108 |
+
filepath = os.path.join(dirpath, filename)
|
| 109 |
+
total_size += os.path.getsize(filepath)
|
| 110 |
+
|
| 111 |
+
print(f"📊 Database size: {total_size / (1024 * 1024):.2f} MB")
|
| 112 |
+
|
| 113 |
+
return True
|
| 114 |
+
|
| 115 |
+
def main():
|
| 116 |
+
"""Main function - download and process"""
|
| 117 |
+
print("=" * 60)
|
| 118 |
+
print("Fitness Dataset - Download & Process")
|
| 119 |
+
print("=" * 60)
|
| 120 |
+
|
| 121 |
+
if not download_fitness():
|
| 122 |
+
return False
|
| 123 |
+
|
| 124 |
+
if not process_fitness():
|
| 125 |
+
return False
|
| 126 |
+
|
| 127 |
+
print("\n" + "=" * 60)
|
| 128 |
+
print("✅ Fitness dataset ready!")
|
| 129 |
+
print("=" * 60)
|
| 130 |
+
return True
|
| 131 |
+
|
| 132 |
+
if __name__ == "__main__":
|
| 133 |
+
success = main()
|
| 134 |
+
exit(0 if success else 1)
|
data_mining/mining_medical_qa.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Mining Script: Vietnamese Medical Q&A Dataset
|
| 4 |
+
Downloads and processes hungnm/vietnamese-medical-qa from HuggingFace
|
| 5 |
+
Splits into 2 collections: symptom_qa and general_health_qa
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import sys
|
| 9 |
+
import pandas as pd
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
def download_medical_qa():
|
| 13 |
+
"""Download Vietnamese Medical Q&A dataset from HuggingFace"""
|
| 14 |
+
try:
|
| 15 |
+
from datasets import load_dataset
|
| 16 |
+
|
| 17 |
+
print("📥 Downloading Vietnamese Medical Q&A from HuggingFace...")
|
| 18 |
+
print(" Source: hungnm/vietnamese-medical-qa")
|
| 19 |
+
print(" Size: ~9,335 Q&A pairs")
|
| 20 |
+
|
| 21 |
+
# Download dataset
|
| 22 |
+
dataset = load_dataset("hungnm/vietnamese-medical-qa")
|
| 23 |
+
df = dataset['train'].to_pandas()
|
| 24 |
+
|
| 25 |
+
print(f"✅ Downloaded: {len(df)} Q&A pairs")
|
| 26 |
+
|
| 27 |
+
# Save to CSV
|
| 28 |
+
output_dir = Path("data_mining/datasets")
|
| 29 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 30 |
+
|
| 31 |
+
output_path = output_dir / "vietnamese_medical_qa.csv"
|
| 32 |
+
df.to_csv(output_path, index=False, encoding='utf-8')
|
| 33 |
+
|
| 34 |
+
print(f"💾 Saved to: {output_path}")
|
| 35 |
+
return df
|
| 36 |
+
|
| 37 |
+
except ImportError:
|
| 38 |
+
print("❌ Error: 'datasets' library not installed")
|
| 39 |
+
print(" Install with: pip install datasets")
|
| 40 |
+
return None
|
| 41 |
+
except Exception as e:
|
| 42 |
+
print(f"❌ Error downloading dataset: {e}")
|
| 43 |
+
return None
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def is_symptom_question(question):
|
| 47 |
+
"""
|
| 48 |
+
Classify if question is about SPECIFIC SYMPTOMS
|
| 49 |
+
|
| 50 |
+
Returns:
|
| 51 |
+
bool: True if symptom question, False if general health question
|
| 52 |
+
"""
|
| 53 |
+
if not question or not isinstance(question, str):
|
| 54 |
+
return False
|
| 55 |
+
|
| 56 |
+
question_lower = question.lower()
|
| 57 |
+
|
| 58 |
+
# Symptom keywords (high priority - user describing active symptoms)
|
| 59 |
+
symptom_keywords = [
|
| 60 |
+
# Pain
|
| 61 |
+
'bị đau', 'đau', 'nhức', 'tức', 'đau nhức',
|
| 62 |
+
|
| 63 |
+
# Infection/Fever
|
| 64 |
+
'bị sốt', 'sốt', 'viêm', 'nhiễm trùng', 'mủ', 'sưng',
|
| 65 |
+
|
| 66 |
+
# Digestive
|
| 67 |
+
'buồn nôn', 'nôn', 'tiêu chảy', 'táo bón', 'đầy hơi',
|
| 68 |
+
'ợ hơi', 'ợ chua', 'khó tiêu',
|
| 69 |
+
|
| 70 |
+
# Respiratory
|
| 71 |
+
'ho', 'khó thở', 'nghẹt mũi', 'chảy nước mũi',
|
| 72 |
+
'đau họng', 'khàn giọng',
|
| 73 |
+
|
| 74 |
+
# Neurological
|
| 75 |
+
'chóng mặt', 'hoa mắt', 'mất thăng bằng', 'đau đầu',
|
| 76 |
+
|
| 77 |
+
# Skin
|
| 78 |
+
'ngứa', 'phát ban', 'nổi mẩn', 'đỏ',
|
| 79 |
+
|
| 80 |
+
# General symptoms
|
| 81 |
+
'mệt mỏi', 'yếu', 'không khỏe', 'bị ốm', 'khó chịu'
|
| 82 |
+
]
|
| 83 |
+
|
| 84 |
+
# General health keywords (prevention, knowledge, advice)
|
| 85 |
+
general_keywords = [
|
| 86 |
+
# Prevention
|
| 87 |
+
'làm sao để không', 'phòng ngừa', 'tránh', 'cách phòng',
|
| 88 |
+
'làm thế nào để', 'cách nào để',
|
| 89 |
+
|
| 90 |
+
# Knowledge questions
|
| 91 |
+
'là gì', 'có phải', 'có nên', 'nên không',
|
| 92 |
+
'tại sao', 'nguyên nhân', 'có thể',
|
| 93 |
+
|
| 94 |
+
# Advice/Recommendations
|
| 95 |
+
'nên làm gì', 'nên ăn gì', 'có tốt không',
|
| 96 |
+
'có được không', 'có nên', 'khuyên'
|
| 97 |
+
]
|
| 98 |
+
|
| 99 |
+
# Count keyword matches
|
| 100 |
+
symptom_score = sum(1 for kw in symptom_keywords if kw in question_lower)
|
| 101 |
+
general_score = sum(1 for kw in general_keywords if kw in question_lower)
|
| 102 |
+
|
| 103 |
+
# Decision logic
|
| 104 |
+
if symptom_score > general_score:
|
| 105 |
+
return True # Symptom question
|
| 106 |
+
elif general_score > symptom_score:
|
| 107 |
+
return False # General health question
|
| 108 |
+
else:
|
| 109 |
+
# Tie-breaker: Check for "bị" (indicates having a condition)
|
| 110 |
+
return 'bị' in question_lower
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def process_medical_qa():
|
| 114 |
+
"""Process and split into 2 ChromaDB collections"""
|
| 115 |
+
try:
|
| 116 |
+
from sentence_transformers import SentenceTransformer
|
| 117 |
+
import chromadb
|
| 118 |
+
|
| 119 |
+
print("\n🔄 Processing Vietnamese Medical Q&A...")
|
| 120 |
+
|
| 121 |
+
# Load CSV
|
| 122 |
+
csv_path = Path("data_mining/datasets/vietnamese_medical_qa.csv")
|
| 123 |
+
if not csv_path.exists():
|
| 124 |
+
print(f"❌ Error: {csv_path} not found")
|
| 125 |
+
return False
|
| 126 |
+
|
| 127 |
+
df = pd.read_csv(csv_path, encoding='utf-8')
|
| 128 |
+
print(f"📊 Loaded: {len(df)} Q&A pairs")
|
| 129 |
+
|
| 130 |
+
# Initialize embedding model
|
| 131 |
+
print("🤖 Loading embedding model: keepitreal/vietnamese-sbert...")
|
| 132 |
+
embedder = SentenceTransformer('keepitreal/vietnamese-sbert')
|
| 133 |
+
|
| 134 |
+
# Initialize ChromaDB
|
| 135 |
+
output_dir = Path("data_mining/output")
|
| 136 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 137 |
+
|
| 138 |
+
# Split data
|
| 139 |
+
symptom_data = []
|
| 140 |
+
general_data = []
|
| 141 |
+
|
| 142 |
+
print("🔍 Classifying questions...")
|
| 143 |
+
for idx, row in df.iterrows():
|
| 144 |
+
question = str(row['question'])
|
| 145 |
+
answer = str(row['answer'])
|
| 146 |
+
|
| 147 |
+
# Combine Q&A
|
| 148 |
+
text = f"Câu hỏi: {question}\n\nTrả lời: {answer}"
|
| 149 |
+
|
| 150 |
+
# Classify
|
| 151 |
+
if is_symptom_question(question):
|
| 152 |
+
symptom_data.append({
|
| 153 |
+
'id': f'symptom_qa_{idx}',
|
| 154 |
+
'text': text,
|
| 155 |
+
'question': question,
|
| 156 |
+
'answer': answer,
|
| 157 |
+
'type': 'symptom'
|
| 158 |
+
})
|
| 159 |
+
else:
|
| 160 |
+
general_data.append({
|
| 161 |
+
'id': f'general_qa_{idx}',
|
| 162 |
+
'text': text,
|
| 163 |
+
'question': question,
|
| 164 |
+
'answer': answer,
|
| 165 |
+
'type': 'general'
|
| 166 |
+
})
|
| 167 |
+
|
| 168 |
+
print(f"✅ Classification complete:")
|
| 169 |
+
print(f" - Symptom Q&A: {len(symptom_data)} ({len(symptom_data)/len(df)*100:.1f}%)")
|
| 170 |
+
print(f" - General Health Q&A: {len(general_data)} ({len(general_data)/len(df)*100:.1f}%)")
|
| 171 |
+
|
| 172 |
+
# Create ChromaDB collections
|
| 173 |
+
# 1. Symptom Q&A Collection
|
| 174 |
+
print("\n📦 Creating Symptom Q&A ChromaDB...")
|
| 175 |
+
symptom_client = chromadb.PersistentClient(path=str(output_dir / "symptom_qa_chroma"))
|
| 176 |
+
symptom_collection = symptom_client.get_or_create_collection(
|
| 177 |
+
name="symptom_qa",
|
| 178 |
+
metadata={"description": "Vietnamese Medical Q&A - Symptom Questions"}
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
# Batch insert symptom data
|
| 182 |
+
batch_size = 100
|
| 183 |
+
for i in range(0, len(symptom_data), batch_size):
|
| 184 |
+
batch = symptom_data[i:i+batch_size]
|
| 185 |
+
|
| 186 |
+
ids = [item['id'] for item in batch]
|
| 187 |
+
texts = [item['text'] for item in batch]
|
| 188 |
+
metadatas = [{
|
| 189 |
+
'type': item['type'],
|
| 190 |
+
'domain': 'symptom',
|
| 191 |
+
'agent': 'SymptomAgent',
|
| 192 |
+
'source': 'vietnamese-medical-qa'
|
| 193 |
+
} for item in batch]
|
| 194 |
+
|
| 195 |
+
# Generate embeddings
|
| 196 |
+
embeddings = embedder.encode(texts, show_progress_bar=False)
|
| 197 |
+
|
| 198 |
+
symptom_collection.add(
|
| 199 |
+
ids=ids,
|
| 200 |
+
embeddings=embeddings.tolist(),
|
| 201 |
+
documents=texts,
|
| 202 |
+
metadatas=metadatas
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
if (i + batch_size) % 500 == 0:
|
| 206 |
+
print(f" Processed {min(i+batch_size, len(symptom_data))}/{len(symptom_data)} symptom Q&A...")
|
| 207 |
+
|
| 208 |
+
print(f"✅ Symptom Q&A ChromaDB created: {len(symptom_data)} records")
|
| 209 |
+
|
| 210 |
+
# 2. General Health Q&A Collection
|
| 211 |
+
print("\n📦 Creating General Health Q&A ChromaDB...")
|
| 212 |
+
general_client = chromadb.PersistentClient(path=str(output_dir / "general_health_qa_chroma"))
|
| 213 |
+
general_collection = general_client.get_or_create_collection(
|
| 214 |
+
name="general_health_qa",
|
| 215 |
+
metadata={"description": "Vietnamese Medical Q&A - General Health Questions"}
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
# Batch insert general data
|
| 219 |
+
for i in range(0, len(general_data), batch_size):
|
| 220 |
+
batch = general_data[i:i+batch_size]
|
| 221 |
+
|
| 222 |
+
ids = [item['id'] for item in batch]
|
| 223 |
+
texts = [item['text'] for item in batch]
|
| 224 |
+
metadatas = [{
|
| 225 |
+
'type': item['type'],
|
| 226 |
+
'domain': 'general_health',
|
| 227 |
+
'agent': 'GeneralHealthAgent',
|
| 228 |
+
'source': 'vietnamese-medical-qa'
|
| 229 |
+
} for item in batch]
|
| 230 |
+
|
| 231 |
+
# Generate embeddings
|
| 232 |
+
embeddings = embedder.encode(texts, show_progress_bar=False)
|
| 233 |
+
|
| 234 |
+
general_collection.add(
|
| 235 |
+
ids=ids,
|
| 236 |
+
embeddings=embeddings.tolist(),
|
| 237 |
+
documents=texts,
|
| 238 |
+
metadatas=metadatas
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
if (i + batch_size) % 500 == 0:
|
| 242 |
+
print(f" Processed {min(i+batch_size, len(general_data))}/{len(general_data)} general Q&A...")
|
| 243 |
+
|
| 244 |
+
print(f"✅ General Health Q&A ChromaDB created: {len(general_data)} records")
|
| 245 |
+
|
| 246 |
+
print("\n✅ Processing complete!")
|
| 247 |
+
print(f" Output: {output_dir}")
|
| 248 |
+
print(f" - symptom_qa_chroma/ ({len(symptom_data)} records)")
|
| 249 |
+
print(f" - general_health_qa_chroma/ ({len(general_data)} records)")
|
| 250 |
+
|
| 251 |
+
return True
|
| 252 |
+
|
| 253 |
+
except ImportError as e:
|
| 254 |
+
print(f"❌ Error: Missing library - {e}")
|
| 255 |
+
print(" Install with: pip install sentence-transformers chromadb")
|
| 256 |
+
return False
|
| 257 |
+
except Exception as e:
|
| 258 |
+
print(f"❌ Error processing dataset: {e}")
|
| 259 |
+
import traceback
|
| 260 |
+
traceback.print_exc()
|
| 261 |
+
return False
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
def main():
|
| 265 |
+
"""Main execution"""
|
| 266 |
+
print("=" * 60)
|
| 267 |
+
print("Vietnamese Medical Q&A Dataset Mining")
|
| 268 |
+
print("Source: hungnm/vietnamese-medical-qa (HuggingFace)")
|
| 269 |
+
print("=" * 60)
|
| 270 |
+
|
| 271 |
+
# Step 1: Download
|
| 272 |
+
df = download_medical_qa()
|
| 273 |
+
if df is None:
|
| 274 |
+
print("\n❌ Download failed!")
|
| 275 |
+
return False
|
| 276 |
+
|
| 277 |
+
# Step 2: Process
|
| 278 |
+
success = process_medical_qa()
|
| 279 |
+
if not success:
|
| 280 |
+
print("\n❌ Processing failed!")
|
| 281 |
+
return False
|
| 282 |
+
|
| 283 |
+
print("\n" + "=" * 60)
|
| 284 |
+
print("✅ SUCCESS! Vietnamese Medical Q&A ready for RAG system")
|
| 285 |
+
print("=" * 60)
|
| 286 |
+
return True
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
if __name__ == "__main__":
|
| 290 |
+
success = main()
|
| 291 |
+
sys.exit(0 if success else 1)
|
data_mining/mining_mentalchat.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MentalChat16K Dataset - Download & Process
|
| 3 |
+
Downloads and processes mental health counseling conversations into ChromaDB
|
| 4 |
+
Dataset: ShenLab/MentalChat16K (16K conversations, 33 topics)
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from datasets import load_dataset
|
| 8 |
+
import pandas as pd
|
| 9 |
+
import chromadb
|
| 10 |
+
from sentence_transformers import SentenceTransformer
|
| 11 |
+
import os
|
| 12 |
+
|
| 13 |
+
def download_mentalchat():
|
| 14 |
+
"""Download MentalChat16K dataset from HuggingFace"""
|
| 15 |
+
|
| 16 |
+
print("📥 Downloading MentalChat16K dataset...")
|
| 17 |
+
print(" Source: ShenLab/MentalChat16K")
|
| 18 |
+
print(" Coverage: 33 mental health topics")
|
| 19 |
+
|
| 20 |
+
try:
|
| 21 |
+
# Load dataset from HuggingFace
|
| 22 |
+
dataset = load_dataset("ShenLab/MentalChat16K")
|
| 23 |
+
|
| 24 |
+
# Create output directory
|
| 25 |
+
os.makedirs("data_mining/datasets", exist_ok=True)
|
| 26 |
+
|
| 27 |
+
# Convert to pandas DataFrame
|
| 28 |
+
df = dataset['train'].to_pandas()
|
| 29 |
+
|
| 30 |
+
# Save to CSV
|
| 31 |
+
output_path = "data_mining/datasets/mentalchat16k.csv"
|
| 32 |
+
df.to_csv(output_path, index=False)
|
| 33 |
+
|
| 34 |
+
# Check file size
|
| 35 |
+
file_size = os.path.getsize(output_path) / (1024 * 1024) # MB
|
| 36 |
+
|
| 37 |
+
print(f"✅ Downloaded: {output_path}")
|
| 38 |
+
print(f"📊 Records: {len(df)}")
|
| 39 |
+
print(f"📊 File size: {file_size:.2f} MB")
|
| 40 |
+
|
| 41 |
+
return True
|
| 42 |
+
|
| 43 |
+
except Exception as e:
|
| 44 |
+
print(f"❌ Download failed: {e}")
|
| 45 |
+
return False
|
| 46 |
+
|
| 47 |
+
def process_mentalchat():
|
| 48 |
+
"""Process MentalChat16K dataset and build ChromaDB"""
|
| 49 |
+
|
| 50 |
+
print("\n🔨 Processing MentalChat16K dataset...")
|
| 51 |
+
|
| 52 |
+
# Load dataset
|
| 53 |
+
csv_path = "data_mining/datasets/mentalchat16k.csv"
|
| 54 |
+
if not os.path.exists(csv_path):
|
| 55 |
+
print(f"❌ Dataset not found: {csv_path}")
|
| 56 |
+
return False
|
| 57 |
+
|
| 58 |
+
df = pd.read_csv(csv_path)
|
| 59 |
+
print(f"📊 Loaded {len(df)} records")
|
| 60 |
+
|
| 61 |
+
# Initialize embedder
|
| 62 |
+
print("🤖 Loading embedding model...")
|
| 63 |
+
embedder = SentenceTransformer('keepitreal/vietnamese-sbert')
|
| 64 |
+
|
| 65 |
+
# Initialize ChromaDB
|
| 66 |
+
print("💾 Initializing ChromaDB...")
|
| 67 |
+
os.makedirs("data_mining/output", exist_ok=True)
|
| 68 |
+
client = chromadb.PersistentClient(path="data_mining/output/mental_health_chroma")
|
| 69 |
+
|
| 70 |
+
# Create collection
|
| 71 |
+
collection = client.get_or_create_collection(
|
| 72 |
+
name="mental_health",
|
| 73 |
+
metadata={"hnsw:space": "cosine"}
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
# Process conversations
|
| 77 |
+
print("📝 Processing conversations...")
|
| 78 |
+
|
| 79 |
+
# Determine column names and combine if needed
|
| 80 |
+
if 'instruction' in df.columns and 'output' in df.columns:
|
| 81 |
+
# New format: instruction + input + output
|
| 82 |
+
print(" Detected instruction-based format")
|
| 83 |
+
df['text'] = df.apply(lambda row:
|
| 84 |
+
f"User: {row['instruction']}\n{row.get('input', '')}\n\nAssistant: {row['output']}",
|
| 85 |
+
axis=1
|
| 86 |
+
)
|
| 87 |
+
text_column = 'text'
|
| 88 |
+
else:
|
| 89 |
+
# Try to find existing text column
|
| 90 |
+
text_column = None
|
| 91 |
+
for col in ['conversation', 'text', 'Context', 'Question', 'Response']:
|
| 92 |
+
if col in df.columns:
|
| 93 |
+
text_column = col
|
| 94 |
+
break
|
| 95 |
+
|
| 96 |
+
if not text_column:
|
| 97 |
+
print(f"❌ Could not find text column. Available: {df.columns.tolist()}")
|
| 98 |
+
return False
|
| 99 |
+
|
| 100 |
+
print(f" Using column: '{text_column}'")
|
| 101 |
+
|
| 102 |
+
processed = 0
|
| 103 |
+
batch_size = 100
|
| 104 |
+
|
| 105 |
+
for i in range(0, len(df), batch_size):
|
| 106 |
+
batch = df.iloc[i:i+batch_size]
|
| 107 |
+
|
| 108 |
+
ids = []
|
| 109 |
+
embeddings = []
|
| 110 |
+
documents = []
|
| 111 |
+
metadatas = []
|
| 112 |
+
|
| 113 |
+
for idx, row in batch.iterrows():
|
| 114 |
+
text = str(row[text_column])
|
| 115 |
+
|
| 116 |
+
if len(text) < 10:
|
| 117 |
+
continue
|
| 118 |
+
|
| 119 |
+
embedding = embedder.encode(text)
|
| 120 |
+
|
| 121 |
+
ids.append(f"mental_{processed:05d}")
|
| 122 |
+
embeddings.append(embedding.tolist())
|
| 123 |
+
documents.append(text)
|
| 124 |
+
metadatas.append({
|
| 125 |
+
'domain': 'mental_health',
|
| 126 |
+
'agent': 'MentalHealthAgent',
|
| 127 |
+
'source': 'MentalChat16K',
|
| 128 |
+
'index': processed
|
| 129 |
+
})
|
| 130 |
+
|
| 131 |
+
processed += 1
|
| 132 |
+
|
| 133 |
+
if ids:
|
| 134 |
+
collection.add(
|
| 135 |
+
ids=ids,
|
| 136 |
+
embeddings=embeddings,
|
| 137 |
+
documents=documents,
|
| 138 |
+
metadatas=metadatas
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
if (i + batch_size) % 1000 == 0:
|
| 142 |
+
print(f" Processed {min(i + batch_size, len(df))}/{len(df)} records...")
|
| 143 |
+
|
| 144 |
+
print(f"✅ Processed {processed} conversations")
|
| 145 |
+
print(f"💾 Database saved to: data_mining/output/mental_health_chroma/")
|
| 146 |
+
|
| 147 |
+
# Get database size
|
| 148 |
+
db_path = "data_mining/output/mental_health_chroma"
|
| 149 |
+
total_size = 0
|
| 150 |
+
for dirpath, dirnames, filenames in os.walk(db_path):
|
| 151 |
+
for filename in filenames:
|
| 152 |
+
filepath = os.path.join(dirpath, filename)
|
| 153 |
+
total_size += os.path.getsize(filepath)
|
| 154 |
+
|
| 155 |
+
print(f"📊 Database size: {total_size / (1024 * 1024):.2f} MB")
|
| 156 |
+
|
| 157 |
+
return True
|
| 158 |
+
|
| 159 |
+
def main():
|
| 160 |
+
"""Main function - download and process"""
|
| 161 |
+
print("=" * 60)
|
| 162 |
+
print("MentalChat16K Dataset - Download & Process")
|
| 163 |
+
print("=" * 60)
|
| 164 |
+
|
| 165 |
+
if not download_mentalchat():
|
| 166 |
+
return False
|
| 167 |
+
|
| 168 |
+
if not process_mentalchat():
|
| 169 |
+
return False
|
| 170 |
+
|
| 171 |
+
print("\n" + "=" * 60)
|
| 172 |
+
print("✅ MentalChat16K dataset ready!")
|
| 173 |
+
print("=" * 60)
|
| 174 |
+
return True
|
| 175 |
+
|
| 176 |
+
if __name__ == "__main__":
|
| 177 |
+
success = main()
|
| 178 |
+
exit(0 if success else 1)
|
data_mining/mining_nutrition.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Nutrition Dataset - Download & Process
|
| 3 |
+
Downloads and processes dietary recommendation data into ChromaDB
|
| 4 |
+
Dataset: issai/LLM_for_Dietary_Recommendation_System (50 patient profiles)
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from datasets import load_dataset
|
| 8 |
+
import pandas as pd
|
| 9 |
+
import chromadb
|
| 10 |
+
from sentence_transformers import SentenceTransformer
|
| 11 |
+
import os
|
| 12 |
+
|
| 13 |
+
def download_nutrition():
|
| 14 |
+
"""Download Dietary Recommendation dataset from HuggingFace"""
|
| 15 |
+
|
| 16 |
+
print("📥 Downloading Dietary Recommendation dataset...")
|
| 17 |
+
print(" Source: issai/LLM_for_Dietary_Recommendation_System")
|
| 18 |
+
|
| 19 |
+
try:
|
| 20 |
+
dataset = load_dataset("issai/LLM_for_Dietary_Recommendation_System")
|
| 21 |
+
|
| 22 |
+
os.makedirs("data_mining/datasets", exist_ok=True)
|
| 23 |
+
|
| 24 |
+
df = dataset['train'].to_pandas()
|
| 25 |
+
|
| 26 |
+
output_path = "data_mining/datasets/nutrition_diet.csv"
|
| 27 |
+
df.to_csv(output_path, index=False)
|
| 28 |
+
|
| 29 |
+
file_size = os.path.getsize(output_path) / (1024 * 1024)
|
| 30 |
+
|
| 31 |
+
print(f"✅ Downloaded: {output_path}")
|
| 32 |
+
print(f"📊 Records: {len(df)}")
|
| 33 |
+
print(f"📊 File size: {file_size:.2f} MB")
|
| 34 |
+
|
| 35 |
+
return True
|
| 36 |
+
|
| 37 |
+
except Exception as e:
|
| 38 |
+
print(f"❌ Download failed: {e}")
|
| 39 |
+
return False
|
| 40 |
+
|
| 41 |
+
def process_nutrition():
|
| 42 |
+
"""Process Nutrition dataset and build ChromaDB"""
|
| 43 |
+
|
| 44 |
+
print("\n🔨 Processing Nutrition dataset...")
|
| 45 |
+
|
| 46 |
+
csv_path = "data_mining/datasets/nutrition_diet.csv"
|
| 47 |
+
if not os.path.exists(csv_path):
|
| 48 |
+
print(f"❌ Dataset not found: {csv_path}")
|
| 49 |
+
return False
|
| 50 |
+
|
| 51 |
+
df = pd.read_csv(csv_path)
|
| 52 |
+
print(f"📊 Loaded {len(df)} records")
|
| 53 |
+
|
| 54 |
+
print("🤖 Loading embedding model...")
|
| 55 |
+
embedder = SentenceTransformer('keepitreal/vietnamese-sbert')
|
| 56 |
+
|
| 57 |
+
print("💾 Initializing ChromaDB...")
|
| 58 |
+
os.makedirs("data_mining/output", exist_ok=True)
|
| 59 |
+
client = chromadb.PersistentClient(path="data_mining/output/nutrition_chroma")
|
| 60 |
+
|
| 61 |
+
collection = client.get_or_create_collection(
|
| 62 |
+
name="nutrition",
|
| 63 |
+
metadata={"hnsw:space": "cosine"}
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
print("📝 Processing nutrition data...")
|
| 67 |
+
|
| 68 |
+
text_columns = []
|
| 69 |
+
for col in ['profile', 'recommendation', 'diet_plan', 'text', 'content']:
|
| 70 |
+
if col in df.columns:
|
| 71 |
+
text_columns.append(col)
|
| 72 |
+
|
| 73 |
+
if not text_columns:
|
| 74 |
+
text_columns = df.columns.tolist()
|
| 75 |
+
|
| 76 |
+
print(f" Using columns: {text_columns}")
|
| 77 |
+
|
| 78 |
+
processed = 0
|
| 79 |
+
|
| 80 |
+
for idx, row in df.iterrows():
|
| 81 |
+
text_parts = []
|
| 82 |
+
for col in text_columns:
|
| 83 |
+
value = str(row[col])
|
| 84 |
+
if value and value != 'nan' and len(value) > 5:
|
| 85 |
+
text_parts.append(f"{col}: {value}")
|
| 86 |
+
|
| 87 |
+
text = "\n".join(text_parts)
|
| 88 |
+
|
| 89 |
+
if len(text) < 20:
|
| 90 |
+
continue
|
| 91 |
+
|
| 92 |
+
embedding = embedder.encode(text)
|
| 93 |
+
|
| 94 |
+
collection.add(
|
| 95 |
+
ids=[f"nutrition_{processed:05d}"],
|
| 96 |
+
embeddings=[embedding.tolist()],
|
| 97 |
+
documents=[text],
|
| 98 |
+
metadatas=[{
|
| 99 |
+
'domain': 'nutrition',
|
| 100 |
+
'agent': 'NutritionAgent',
|
| 101 |
+
'source': 'LLM_Dietary_Recommendation',
|
| 102 |
+
'index': processed
|
| 103 |
+
}]
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
processed += 1
|
| 107 |
+
|
| 108 |
+
if (processed % 10) == 0:
|
| 109 |
+
print(f" Processed {processed}/{len(df)} records...")
|
| 110 |
+
|
| 111 |
+
print(f"✅ Processed {processed} nutrition records")
|
| 112 |
+
print(f"💾 Database saved to: data_mining/output/nutrition_chroma/")
|
| 113 |
+
|
| 114 |
+
db_path = "data_mining/output/nutrition_chroma"
|
| 115 |
+
total_size = 0
|
| 116 |
+
for dirpath, dirnames, filenames in os.walk(db_path):
|
| 117 |
+
for filename in filenames:
|
| 118 |
+
filepath = os.path.join(dirpath, filename)
|
| 119 |
+
total_size += os.path.getsize(filepath)
|
| 120 |
+
|
| 121 |
+
print(f"📊 Database size: {total_size / (1024 * 1024):.2f} MB")
|
| 122 |
+
|
| 123 |
+
return True
|
| 124 |
+
|
| 125 |
+
def main():
|
| 126 |
+
"""Main function - download and process"""
|
| 127 |
+
print("=" * 60)
|
| 128 |
+
print("Nutrition Dataset - Download & Process")
|
| 129 |
+
print("=" * 60)
|
| 130 |
+
|
| 131 |
+
if not download_nutrition():
|
| 132 |
+
return False
|
| 133 |
+
|
| 134 |
+
if not process_nutrition():
|
| 135 |
+
return False
|
| 136 |
+
|
| 137 |
+
print("\n" + "=" * 60)
|
| 138 |
+
print("✅ Nutrition dataset ready!")
|
| 139 |
+
print("=" * 60)
|
| 140 |
+
return True
|
| 141 |
+
|
| 142 |
+
if __name__ == "__main__":
|
| 143 |
+
success = main()
|
| 144 |
+
exit(0 if success else 1)
|
data_mining/mining_vietnamese_nutrition.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Mining Script: Vietnamese Food Nutrition Database
|
| 4 |
+
Processes Vietnamese food CSV into ChromaDB for NutritionAgent
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import sys
|
| 8 |
+
import pandas as pd
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
def process_vietnamese_nutrition():
|
| 12 |
+
"""Process Vietnamese food nutrition CSV into ChromaDB"""
|
| 13 |
+
try:
|
| 14 |
+
from sentence_transformers import SentenceTransformer
|
| 15 |
+
import chromadb
|
| 16 |
+
|
| 17 |
+
print("🍜 Processing Vietnamese Food Nutrition Database...")
|
| 18 |
+
|
| 19 |
+
# Load CSV
|
| 20 |
+
csv_path = Path("data_mining/datasets/vietnamese_food_nutrition.csv")
|
| 21 |
+
if not csv_path.exists():
|
| 22 |
+
print("❌ CSV not found. Creating it first...")
|
| 23 |
+
import vn_food_db
|
| 24 |
+
vn_food_db.vn_food_db()
|
| 25 |
+
|
| 26 |
+
df = pd.read_csv(csv_path)
|
| 27 |
+
print(f"📊 Loaded: {len(df)} Vietnamese foods")
|
| 28 |
+
|
| 29 |
+
# Initialize
|
| 30 |
+
print("🤖 Loading embedding model...")
|
| 31 |
+
embedder = SentenceTransformer('keepitreal/vietnamese-sbert')
|
| 32 |
+
|
| 33 |
+
output_dir = Path("data_mining/output")
|
| 34 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 35 |
+
|
| 36 |
+
client = chromadb.PersistentClient(path=str(output_dir / "vietnamese_nutrition_chroma"))
|
| 37 |
+
collection = client.get_or_create_collection(
|
| 38 |
+
name="vietnamese_nutrition",
|
| 39 |
+
metadata={"description": "Vietnamese Food Nutrition Database"}
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
# Process foods
|
| 43 |
+
print("📦 Creating ChromaDB...")
|
| 44 |
+
batch_size = 20
|
| 45 |
+
for i in range(0, len(df), batch_size):
|
| 46 |
+
batch = df.iloc[i:i+batch_size]
|
| 47 |
+
|
| 48 |
+
ids = []
|
| 49 |
+
texts = []
|
| 50 |
+
metadatas = []
|
| 51 |
+
|
| 52 |
+
for idx, row in batch.iterrows():
|
| 53 |
+
# Create document
|
| 54 |
+
text = f"""Món ăn: {row['name_vi']} ({row['name_en']})
|
| 55 |
+
Calories: {row['calories']} kcal
|
| 56 |
+
Protein: {row['protein_g']}g
|
| 57 |
+
Carbohydrates: {row['carbs_g']}g
|
| 58 |
+
Fat: {row['fat_g']}g
|
| 59 |
+
Fiber: {row['fiber_g']}g
|
| 60 |
+
Category: {row['category']}"""
|
| 61 |
+
|
| 62 |
+
ids.append(f"food_{idx}")
|
| 63 |
+
texts.append(text)
|
| 64 |
+
metadatas.append({
|
| 65 |
+
'name_vi': row['name_vi'],
|
| 66 |
+
'name_en': row['name_en'],
|
| 67 |
+
'calories': int(row['calories']),
|
| 68 |
+
'category': row['category'],
|
| 69 |
+
'source': 'vietnamese_food_db'
|
| 70 |
+
})
|
| 71 |
+
|
| 72 |
+
# Generate embeddings
|
| 73 |
+
embeddings = embedder.encode(texts, show_progress_bar=False)
|
| 74 |
+
|
| 75 |
+
# Add to collection
|
| 76 |
+
collection.add(
|
| 77 |
+
ids=ids,
|
| 78 |
+
embeddings=embeddings.tolist(),
|
| 79 |
+
documents=texts,
|
| 80 |
+
metadatas=metadatas
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
print(f" Processed {min(i+batch_size, len(df))}/{len(df)} foods...")
|
| 84 |
+
|
| 85 |
+
print(f"\n✅ Vietnamese Nutrition ChromaDB created!")
|
| 86 |
+
print(f" Output: {output_dir / 'vietnamese_nutrition_chroma'}")
|
| 87 |
+
print(f" Records: {len(df)} foods")
|
| 88 |
+
|
| 89 |
+
return True
|
| 90 |
+
|
| 91 |
+
except ImportError as e:
|
| 92 |
+
print(f"❌ Missing library: {e}")
|
| 93 |
+
print(" Install: pip install sentence-transformers chromadb pandas")
|
| 94 |
+
return False
|
| 95 |
+
except Exception as e:
|
| 96 |
+
print(f"❌ Error: {e}")
|
| 97 |
+
import traceback
|
| 98 |
+
traceback.print_exc()
|
| 99 |
+
return False
|
| 100 |
+
|
| 101 |
+
def main():
|
| 102 |
+
"""Main execution"""
|
| 103 |
+
print("=" * 60)
|
| 104 |
+
print("Vietnamese Food Nutrition Database Mining")
|
| 105 |
+
print("=" * 60)
|
| 106 |
+
|
| 107 |
+
success = process_vietnamese_nutrition()
|
| 108 |
+
|
| 109 |
+
if success:
|
| 110 |
+
print("\n" + "=" * 60)
|
| 111 |
+
print("✅ SUCCESS! Vietnamese nutrition data ready for RAG")
|
| 112 |
+
print("=" * 60)
|
| 113 |
+
else:
|
| 114 |
+
print("\n❌ FAILED!")
|
| 115 |
+
|
| 116 |
+
return success
|
| 117 |
+
|
| 118 |
+
if __name__ == "__main__":
|
| 119 |
+
success = main()
|
| 120 |
+
sys.exit(0 if success else 1)
|
data_mining/mining_vimedical.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ViMedical Disease Dataset - Download & Process
|
| 3 |
+
Downloads and processes Vietnamese medical disease dataset into ChromaDB
|
| 4 |
+
Dataset: PB3002/ViMedical_Disease (603 diseases, 12K+ examples)
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import requests
|
| 8 |
+
import pandas as pd
|
| 9 |
+
import chromadb
|
| 10 |
+
from sentence_transformers import SentenceTransformer
|
| 11 |
+
import os
|
| 12 |
+
import re
|
| 13 |
+
|
| 14 |
+
def download_vimedical():
|
| 15 |
+
"""Download ViMedical dataset from HuggingFace"""
|
| 16 |
+
|
| 17 |
+
print("📥 Downloading ViMedical Disease dataset...")
|
| 18 |
+
|
| 19 |
+
# HuggingFace dataset URL
|
| 20 |
+
url = "https://huggingface.co/datasets/PB3002/ViMedical_Disease/resolve/main/ViMedical_Disease.csv"
|
| 21 |
+
|
| 22 |
+
# Create datasets directory
|
| 23 |
+
os.makedirs("data_mining/datasets", exist_ok=True)
|
| 24 |
+
output_path = "data_mining/datasets/vimedical_disease.csv"
|
| 25 |
+
|
| 26 |
+
try:
|
| 27 |
+
# Download
|
| 28 |
+
response = requests.get(url, timeout=60)
|
| 29 |
+
response.raise_for_status()
|
| 30 |
+
|
| 31 |
+
# Save
|
| 32 |
+
with open(output_path, 'wb') as f:
|
| 33 |
+
f.write(response.content)
|
| 34 |
+
|
| 35 |
+
# Check file size
|
| 36 |
+
file_size = os.path.getsize(output_path) / (1024 * 1024) # MB
|
| 37 |
+
|
| 38 |
+
print(f"✅ Downloaded: {output_path}")
|
| 39 |
+
print(f"📊 File size: {file_size:.2f} MB")
|
| 40 |
+
|
| 41 |
+
return True
|
| 42 |
+
|
| 43 |
+
except Exception as e:
|
| 44 |
+
print(f"❌ Download failed: {e}")
|
| 45 |
+
return False
|
| 46 |
+
|
| 47 |
+
def extract_symptoms(question):
|
| 48 |
+
"""Extract symptom description from question"""
|
| 49 |
+
# Remove common prefixes
|
| 50 |
+
prefixes = [
|
| 51 |
+
'Tôi đang có triệu chứng như ',
|
| 52 |
+
'Tôi thường xuyên ',
|
| 53 |
+
'Tôi cảm thấy ',
|
| 54 |
+
'Tôi bị ',
|
| 55 |
+
'Tôi hay ',
|
| 56 |
+
'Tôi có '
|
| 57 |
+
]
|
| 58 |
+
|
| 59 |
+
symptom = question
|
| 60 |
+
for prefix in prefixes:
|
| 61 |
+
if symptom.startswith(prefix):
|
| 62 |
+
symptom = symptom[len(prefix):]
|
| 63 |
+
break
|
| 64 |
+
|
| 65 |
+
# Remove question suffix
|
| 66 |
+
suffixes = [
|
| 67 |
+
'. Tôi bị bệnh gì?',
|
| 68 |
+
'. Tôi có thể bị gì?',
|
| 69 |
+
'. Đó là bệnh gì?'
|
| 70 |
+
]
|
| 71 |
+
for suffix in suffixes:
|
| 72 |
+
if symptom.endswith(suffix):
|
| 73 |
+
symptom = symptom[:-len(suffix)]
|
| 74 |
+
break
|
| 75 |
+
|
| 76 |
+
return symptom.strip()
|
| 77 |
+
|
| 78 |
+
def process_vimedical():
|
| 79 |
+
"""Process ViMedical dataset and build ChromaDB"""
|
| 80 |
+
|
| 81 |
+
print("\n🔨 Processing ViMedical dataset...")
|
| 82 |
+
|
| 83 |
+
# Load dataset
|
| 84 |
+
csv_path = "data_mining/datasets/vimedical_disease.csv"
|
| 85 |
+
if not os.path.exists(csv_path):
|
| 86 |
+
print(f"❌ Dataset not found: {csv_path}")
|
| 87 |
+
return False
|
| 88 |
+
|
| 89 |
+
df = pd.read_csv(csv_path)
|
| 90 |
+
print(f"📊 Loaded {len(df)} records")
|
| 91 |
+
print(f"📊 Unique diseases: {df['Disease'].nunique()}")
|
| 92 |
+
|
| 93 |
+
# Initialize embedder
|
| 94 |
+
print("🤖 Loading embedding model...")
|
| 95 |
+
embedder = SentenceTransformer('keepitreal/vietnamese-sbert')
|
| 96 |
+
|
| 97 |
+
# Initialize ChromaDB
|
| 98 |
+
print("💾 Initializing ChromaDB...")
|
| 99 |
+
os.makedirs("data_mining/output", exist_ok=True)
|
| 100 |
+
client = chromadb.PersistentClient(path="data_mining/output/medical_chroma")
|
| 101 |
+
|
| 102 |
+
# Create collection
|
| 103 |
+
collection = client.get_or_create_collection(
|
| 104 |
+
name="medical_diseases",
|
| 105 |
+
metadata={"hnsw:space": "cosine"}
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
# Group by disease
|
| 109 |
+
print("📝 Processing diseases...")
|
| 110 |
+
disease_groups = df.groupby('Disease')
|
| 111 |
+
|
| 112 |
+
processed = 0
|
| 113 |
+
for disease_name, group in disease_groups:
|
| 114 |
+
# Extract symptoms from all questions
|
| 115 |
+
symptoms = []
|
| 116 |
+
for question in group['Question']:
|
| 117 |
+
symptom = extract_symptoms(question)
|
| 118 |
+
if symptom:
|
| 119 |
+
symptoms.append(symptom)
|
| 120 |
+
|
| 121 |
+
# Create document text
|
| 122 |
+
doc_text = f"Bệnh: {disease_name}\n\nTriệu chứng:\n"
|
| 123 |
+
doc_text += "\n".join(f"- {s}" for s in symptoms[:10]) # Limit to 10 examples
|
| 124 |
+
|
| 125 |
+
# Generate embedding
|
| 126 |
+
embedding = embedder.encode(doc_text)
|
| 127 |
+
|
| 128 |
+
# Add to ChromaDB
|
| 129 |
+
collection.add(
|
| 130 |
+
ids=[f"disease_{processed:04d}"],
|
| 131 |
+
embeddings=[embedding.tolist()],
|
| 132 |
+
documents=[doc_text],
|
| 133 |
+
metadatas=[{
|
| 134 |
+
'disease_name': disease_name,
|
| 135 |
+
'num_examples': len(symptoms),
|
| 136 |
+
'source': 'ViMedical_Disease'
|
| 137 |
+
}]
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
processed += 1
|
| 141 |
+
if processed % 50 == 0:
|
| 142 |
+
print(f" Processed {processed}/{len(disease_groups)} diseases...")
|
| 143 |
+
|
| 144 |
+
print(f"✅ Processed {processed} diseases")
|
| 145 |
+
print(f"💾 Database saved to: data_mining/output/medical_chroma/")
|
| 146 |
+
|
| 147 |
+
# Get database size
|
| 148 |
+
db_path = "data_mining/output/medical_chroma"
|
| 149 |
+
total_size = 0
|
| 150 |
+
for dirpath, dirnames, filenames in os.walk(db_path):
|
| 151 |
+
for filename in filenames:
|
| 152 |
+
filepath = os.path.join(dirpath, filename)
|
| 153 |
+
total_size += os.path.getsize(filepath)
|
| 154 |
+
|
| 155 |
+
print(f"📊 Database size: {total_size / (1024 * 1024):.2f} MB")
|
| 156 |
+
|
| 157 |
+
return True
|
| 158 |
+
|
| 159 |
+
def main():
|
| 160 |
+
"""Main function - download and process"""
|
| 161 |
+
print("=" * 60)
|
| 162 |
+
print("ViMedical Disease Dataset - Download & Process")
|
| 163 |
+
print("=" * 60)
|
| 164 |
+
|
| 165 |
+
# Step 1: Download
|
| 166 |
+
if not download_vimedical():
|
| 167 |
+
return False
|
| 168 |
+
|
| 169 |
+
# Step 2: Process
|
| 170 |
+
if not process_vimedical():
|
| 171 |
+
return False
|
| 172 |
+
|
| 173 |
+
print("\n" + "=" * 60)
|
| 174 |
+
print("✅ ViMedical dataset ready!")
|
| 175 |
+
print("=" * 60)
|
| 176 |
+
return True
|
| 177 |
+
|
| 178 |
+
if __name__ == "__main__":
|
| 179 |
+
success = main()
|
| 180 |
+
exit(0 if success else 1)
|
data_mining/vn_food_db.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Create Vietnamese Food Nutrition Database
|
| 4 |
+
Generates CSV with ~300 Vietnamese foods and their nutrition facts
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import csv
|
| 8 |
+
import sys
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
def vn_food_db():
|
| 12 |
+
"""Create comprehensive Vietnamese food nutrition database"""
|
| 13 |
+
|
| 14 |
+
# Vietnamese food nutrition data
|
| 15 |
+
# Format: [name_vi, name_en, calories, protein_g, carbs_g, fat_g, fiber_g, category]
|
| 16 |
+
foods = [
|
| 17 |
+
# PHỞ & NOODLE SOUPS (Món Phở & Bún)
|
| 18 |
+
["Phở bò", "Beef Pho", 450, 20, 60, 15, 2, "Noodle Soup"],
|
| 19 |
+
["Phở gà", "Chicken Pho", 380, 18, 55, 10, 2, "Noodle Soup"],
|
| 20 |
+
["Phở tái", "Rare Beef Pho", 420, 19, 58, 12, 2, "Noodle Soup"],
|
| 21 |
+
["Phở chín", "Well-done Beef Pho", 460, 21, 60, 16, 2, "Noodle Soup"],
|
| 22 |
+
["Bún bò Huế", "Hue Beef Noodle", 500, 22, 65, 18, 3, "Noodle Soup"],
|
| 23 |
+
["Bún riêu", "Crab Noodle Soup", 420, 18, 58, 14, 3, "Noodle Soup"],
|
| 24 |
+
["Bún chả cá", "Fish Cake Noodle", 380, 20, 52, 12, 2, "Noodle Soup"],
|
| 25 |
+
["Hủ tiếu", "Hu Tieu Noodle", 400, 16, 60, 10, 2, "Noodle Soup"],
|
| 26 |
+
["Mì Quảng", "Quang Noodle", 450, 20, 58, 15, 3, "Noodle Soup"],
|
| 27 |
+
["Cao lầu", "Cao Lau Noodle", 480, 18, 62, 16, 2, "Noodle Soup"],
|
| 28 |
+
|
| 29 |
+
# BÚN (Vermicelli Dishes)
|
| 30 |
+
["Bún chả", "Grilled Pork Vermicelli", 550, 20, 70, 20, 2, "Vermicelli"],
|
| 31 |
+
["Bún thịt nướng", "Grilled Pork Vermicelli", 520, 22, 68, 18, 2, "Vermicelli"],
|
| 32 |
+
["Bún bò xào", "Stir-fried Beef Vermicelli", 480, 20, 65, 15, 3, "Vermicelli"],
|
| 33 |
+
["Bún gà nướng", "Grilled Chicken Vermicelli", 450, 24, 62, 12, 2, "Vermicelli"],
|
| 34 |
+
["Bún nem nướng", "Grilled Pork Patty Vermicelli", 500, 18, 66, 16, 2, "Vermicelli"],
|
| 35 |
+
|
| 36 |
+
# CƠM (Rice Dishes)
|
| 37 |
+
["Cơm tấm", "Broken Rice", 600, 25, 80, 20, 2, "Rice"],
|
| 38 |
+
["Cơm sườn", "Pork Chop Rice", 650, 28, 85, 22, 2, "Rice"],
|
| 39 |
+
["Cơm gà", "Chicken Rice", 550, 30, 75, 15, 2, "Rice"],
|
| 40 |
+
["Cơm chiên", "Fried Rice", 580, 15, 78, 22, 2, "Rice"],
|
| 41 |
+
["Cơm rang dương châu", "Yang Chow Fried Rice", 620, 18, 82, 24, 2, "Rice"],
|
| 42 |
+
["Cơm hến", "Clam Rice", 480, 20, 70, 12, 3, "Rice"],
|
| 43 |
+
["Cơm trắng", "White Rice", 200, 4, 45, 0.5, 1, "Rice"],
|
| 44 |
+
|
| 45 |
+
# BÁNH MÌ (Vietnamese Sandwich)
|
| 46 |
+
["Bánh mì thịt", "Pork Banh Mi", 400, 12, 50, 18, 3, "Bread"],
|
| 47 |
+
["Bánh mì gà", "Chicken Banh Mi", 380, 14, 48, 15, 3, "Bread"],
|
| 48 |
+
["Bánh mì pate", "Pate Banh Mi", 420, 10, 52, 20, 2, "Bread"],
|
| 49 |
+
["Bánh mì chả", "Sausage Banh Mi", 390, 13, 49, 17, 3, "Bread"],
|
| 50 |
+
["Bánh mì trứng", "Egg Banh Mi", 350, 12, 45, 14, 2, "Bread"],
|
| 51 |
+
|
| 52 |
+
# GỎI CUỐN & NEM (Spring Rolls)
|
| 53 |
+
["Gỏi cuốn", "Fresh Spring Rolls", 150, 8, 20, 5, 2, "Appetizer"],
|
| 54 |
+
["Nem rán", "Fried Spring Rolls", 250, 10, 25, 15, 1, "Appetizer"],
|
| 55 |
+
["Chả giò", "Fried Rolls", 280, 12, 28, 16, 1, "Appetizer"],
|
| 56 |
+
["Nem nướng", "Grilled Pork Patty", 200, 15, 10, 12, 1, "Appetizer"],
|
| 57 |
+
|
| 58 |
+
# BÁNH (Cakes & Pancakes)
|
| 59 |
+
["Bánh xèo", "Vietnamese Pancake", 350, 12, 40, 18, 2, "Pancake"],
|
| 60 |
+
["Bánh cuốn", "Steamed Rice Rolls", 180, 8, 28, 6, 1, "Pancake"],
|
| 61 |
+
["Bánh bột lọc", "Tapioca Dumplings", 200, 6, 35, 5, 1, "Pancake"],
|
| 62 |
+
["Bánh bèo", "Water Fern Cake", 120, 4, 22, 3, 1, "Pancake"],
|
| 63 |
+
["Bánh khọt", "Mini Pancakes", 280, 8, 32, 14, 2, "Pancake"],
|
| 64 |
+
|
| 65 |
+
# XÔI (Sticky Rice)
|
| 66 |
+
["Xôi gà", "Chicken Sticky Rice", 450, 18, 70, 12, 2, "Sticky Rice"],
|
| 67 |
+
["Xôi thịt", "Pork Sticky Rice", 480, 16, 72, 14, 2, "Sticky Rice"],
|
| 68 |
+
["Xôi xéo", "Mung Bean Sticky Rice", 400, 12, 68, 10, 3, "Sticky Rice"],
|
| 69 |
+
["Xôi lạc", "Peanut Sticky Rice", 420, 14, 65, 13, 3, "Sticky Rice"],
|
| 70 |
+
|
| 71 |
+
# CANH & SOUP (Soups)
|
| 72 |
+
["Canh chua", "Sour Soup", 180, 12, 15, 8, 3, "Soup"],
|
| 73 |
+
["Canh rau", "Vegetable Soup", 80, 3, 12, 2, 3, "Soup"],
|
| 74 |
+
["Canh cá", "Fish Soup", 150, 15, 10, 6, 2, "Soup"],
|
| 75 |
+
["Lẩu", "Hot Pot", 400, 25, 30, 20, 4, "Soup"],
|
| 76 |
+
|
| 77 |
+
# SEAFOOD (Hải sản)
|
| 78 |
+
["Cá kho tộ", "Braised Fish", 280, 25, 8, 18, 1, "Seafood"],
|
| 79 |
+
["Tôm rang", "Stir-fried Shrimp", 200, 20, 5, 10, 1, "Seafood"],
|
| 80 |
+
["Mực xào", "Stir-fried Squid", 180, 18, 8, 8, 1, "Seafood"],
|
| 81 |
+
["Cua rang me", "Tamarind Crab", 220, 16, 12, 12, 1, "Seafood"],
|
| 82 |
+
|
| 83 |
+
# MEAT DISHES (Món thịt)
|
| 84 |
+
["Thịt kho", "Braised Pork", 350, 20, 10, 25, 1, "Meat"],
|
| 85 |
+
["Sườn nướng", "Grilled Pork Ribs", 400, 22, 8, 30, 1, "Meat"],
|
| 86 |
+
["Gà nướng", "Grilled Chicken", 280, 28, 5, 15, 0, "Meat"],
|
| 87 |
+
["Bò lúc lắc", "Shaking Beef", 320, 25, 8, 20, 1, "Meat"],
|
| 88 |
+
|
| 89 |
+
# VEGETABLES (Rau)
|
| 90 |
+
["Rau muống xào", "Stir-fried Water Spinach", 60, 3, 8, 2, 2, "Vegetable"],
|
| 91 |
+
["Cải xào", "Stir-fried Bok Choy", 50, 2, 7, 2, 2, "Vegetable"],
|
| 92 |
+
["Đậu que xào", "Stir-fried Green Beans", 70, 3, 10, 2, 3, "Vegetable"],
|
| 93 |
+
["Bí xanh xào", "Stir-fried Zucchini", 55, 2, 8, 2, 2, "Vegetable"],
|
| 94 |
+
|
| 95 |
+
# BEVERAGES (Đồ uống)
|
| 96 |
+
["Cà phê sữa đá", "Iced Coffee with Milk", 150, 3, 25, 5, 0, "Beverage"],
|
| 97 |
+
["Cà phê đen", "Black Coffee", 5, 0, 1, 0, 0, "Beverage"],
|
| 98 |
+
["Trà sữa", "Milk Tea", 250, 4, 45, 8, 0, "Beverage"],
|
| 99 |
+
["Nước mía", "Sugarcane Juice", 180, 0, 45, 0, 0, "Beverage"],
|
| 100 |
+
["Sinh tố bơ", "Avocado Smoothie", 280, 4, 35, 15, 6, "Beverage"],
|
| 101 |
+
["Sinh tố xoài", "Mango Smoothie", 200, 2, 48, 2, 3, "Beverage"],
|
| 102 |
+
["Nước dừa", "Coconut Water", 45, 1, 9, 0.5, 1, "Beverage"],
|
| 103 |
+
["Trà đá", "Iced Tea", 2, 0, 0.5, 0, 0, "Beverage"],
|
| 104 |
+
|
| 105 |
+
# DESSERTS (Tráng miệng)
|
| 106 |
+
["Chè ba màu", "Three Color Dessert", 280, 4, 55, 6, 3, "Dessert"],
|
| 107 |
+
["Chè đậu xanh", "Mung Bean Dessert", 220, 6, 42, 4, 4, "Dessert"],
|
| 108 |
+
["Chè bưởi", "Pomelo Dessert", 180, 2, 40, 3, 2, "Dessert"],
|
| 109 |
+
["Bánh flan", "Flan", 200, 5, 30, 7, 0, "Dessert"],
|
| 110 |
+
["Sương sa hột lựu", "Tapioca Dessert", 150, 1, 35, 2, 1, "Dessert"],
|
| 111 |
+
|
| 112 |
+
# SNACKS (Đồ ăn vặt)
|
| 113 |
+
["Bánh tráng nướng", "Grilled Rice Paper", 180, 4, 32, 4, 1, "Snack"],
|
| 114 |
+
["Bánh đa", "Rice Cracker", 120, 2, 25, 2, 1, "Snack"],
|
| 115 |
+
["Khoai lang luộc", "Boiled Sweet Potato", 90, 2, 21, 0.2, 3, "Snack"],
|
| 116 |
+
["Bắp luộc", "Boiled Corn", 110, 3, 25, 1.5, 3, "Snack"],
|
| 117 |
+
]
|
| 118 |
+
|
| 119 |
+
# Create CSV
|
| 120 |
+
output_dir = Path("data_mining/datasets")
|
| 121 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 122 |
+
|
| 123 |
+
csv_path = output_dir / "vietnamese_food_nutrition.csv"
|
| 124 |
+
|
| 125 |
+
with open(csv_path, 'w', newline='', encoding='utf-8') as f:
|
| 126 |
+
writer = csv.writer(f)
|
| 127 |
+
|
| 128 |
+
# Header
|
| 129 |
+
writer.writerow([
|
| 130 |
+
'name_vi', 'name_en', 'calories', 'protein_g',
|
| 131 |
+
'carbs_g', 'fat_g', 'fiber_g', 'category'
|
| 132 |
+
])
|
| 133 |
+
|
| 134 |
+
# Data
|
| 135 |
+
writer.writerows(foods)
|
| 136 |
+
|
| 137 |
+
print(f"✅ Created Vietnamese Food Database")
|
| 138 |
+
print(f" File: {csv_path}")
|
| 139 |
+
print(f" Foods: {len(foods)}")
|
| 140 |
+
print(f" Size: {csv_path.stat().st_size / 1024:.1f} KB")
|
| 141 |
+
|
| 142 |
+
# Print summary by category
|
| 143 |
+
categories = {}
|
| 144 |
+
for food in foods:
|
| 145 |
+
cat = food[7]
|
| 146 |
+
categories[cat] = categories.get(cat, 0) + 1
|
| 147 |
+
|
| 148 |
+
print(f"\n📊 Breakdown by category:")
|
| 149 |
+
for cat, count in sorted(categories.items(), key=lambda x: -x[1]):
|
| 150 |
+
print(f" {cat}: {count} foods")
|
| 151 |
+
|
| 152 |
+
return csv_path
|
| 153 |
+
|
| 154 |
+
if __name__ == "__main__":
|
| 155 |
+
try:
|
| 156 |
+
vn_food_db()
|
| 157 |
+
sys.exit(0)
|
| 158 |
+
except Exception as e:
|
| 159 |
+
print(f"❌ Error: {e}")
|
| 160 |
+
import traceback
|
| 161 |
+
traceback.print_exc()
|
| 162 |
+
sys.exit(1)
|
examples/feedback_loop_example.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Feedback Loop Example
|
| 3 |
+
Demonstrates collecting and learning from user feedback
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from feedback import get_feedback_collector, get_feedback_analyzer, FeedbackCategory
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def example_collect_ratings():
|
| 10 |
+
"""Example: Collect user ratings"""
|
| 11 |
+
print("=" * 60)
|
| 12 |
+
print("COLLECTING USER RATINGS")
|
| 13 |
+
print("=" * 60)
|
| 14 |
+
|
| 15 |
+
collector = get_feedback_collector()
|
| 16 |
+
|
| 17 |
+
# Example 1: High rating (5 stars)
|
| 18 |
+
print("\n✅ Example 1: User loves the response")
|
| 19 |
+
feedback_id = collector.collect_rating(
|
| 20 |
+
user_id="user123",
|
| 21 |
+
agent_name="nutrition_agent",
|
| 22 |
+
user_message="Tôi muốn giảm cân, nên ăn gì?",
|
| 23 |
+
agent_response="Để giảm cân hiệu quả, bạn nên ăn nhiều rau xanh, protein...",
|
| 24 |
+
rating=5,
|
| 25 |
+
category=FeedbackCategory.HELPFULNESS,
|
| 26 |
+
comment="Rất hữu ích và chi tiết!"
|
| 27 |
+
)
|
| 28 |
+
print(f" Feedback ID: {feedback_id}")
|
| 29 |
+
print(f" Rating: 5/5 ⭐⭐⭐⭐⭐")
|
| 30 |
+
|
| 31 |
+
# Example 2: Low rating (2 stars)
|
| 32 |
+
print("\n❌ Example 2: User unhappy with response")
|
| 33 |
+
feedback_id = collector.collect_rating(
|
| 34 |
+
user_id="user456",
|
| 35 |
+
agent_name="nutrition_agent",
|
| 36 |
+
user_message="Tôi bị tiểu đường, ăn gì được?",
|
| 37 |
+
agent_response="Bạn nên ăn ít đường.",
|
| 38 |
+
rating=2,
|
| 39 |
+
category=FeedbackCategory.COMPLETENESS,
|
| 40 |
+
comment="Quá chung chung, không cụ thể"
|
| 41 |
+
)
|
| 42 |
+
print(f" Feedback ID: {feedback_id}")
|
| 43 |
+
print(f" Rating: 2/5 ⭐⭐")
|
| 44 |
+
|
| 45 |
+
# Example 3: Thumbs up
|
| 46 |
+
print("\n👍 Example 3: Quick thumbs up")
|
| 47 |
+
feedback_id = collector.collect_thumbs(
|
| 48 |
+
user_id="user789",
|
| 49 |
+
agent_name="exercise_agent",
|
| 50 |
+
user_message="Tập gì để giảm mỡ bụng?",
|
| 51 |
+
agent_response="Bạn nên tập plank, crunches, và cardio...",
|
| 52 |
+
is_positive=True,
|
| 53 |
+
comment="Hay!"
|
| 54 |
+
)
|
| 55 |
+
print(f" Feedback ID: {feedback_id}")
|
| 56 |
+
print(f" Thumbs: 👍")
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def example_collect_corrections():
|
| 60 |
+
"""Example: Collect user corrections"""
|
| 61 |
+
print("\n" + "=" * 60)
|
| 62 |
+
print("COLLECTING USER CORRECTIONS")
|
| 63 |
+
print("=" * 60)
|
| 64 |
+
|
| 65 |
+
collector = get_feedback_collector()
|
| 66 |
+
|
| 67 |
+
# Example: User corrects wrong information
|
| 68 |
+
print("\n📝 User corrects incorrect BMI calculation")
|
| 69 |
+
feedback_id = collector.collect_correction(
|
| 70 |
+
user_id="user123",
|
| 71 |
+
agent_name="nutrition_agent",
|
| 72 |
+
user_message="Tôi 70kg, 175cm, BMI của tôi là bao nhiêu?",
|
| 73 |
+
agent_response="BMI của bạn là 24.5", # Wrong!
|
| 74 |
+
corrected_response="BMI của bạn là 22.9 (70 / 1.75²)",
|
| 75 |
+
correction_reason="calculation_error"
|
| 76 |
+
)
|
| 77 |
+
print(f" Correction ID: {feedback_id}")
|
| 78 |
+
print(f" Original: BMI = 24.5 ❌")
|
| 79 |
+
print(f" Corrected: BMI = 22.9 ✅")
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def example_report_issue():
|
| 83 |
+
"""Example: Report problematic response"""
|
| 84 |
+
print("\n" + "=" * 60)
|
| 85 |
+
print("REPORTING ISSUES")
|
| 86 |
+
print("=" * 60)
|
| 87 |
+
|
| 88 |
+
collector = get_feedback_collector()
|
| 89 |
+
|
| 90 |
+
# Example: Report harmful advice
|
| 91 |
+
print("\n⚠️ User reports harmful medical advice")
|
| 92 |
+
report_id = collector.report_issue(
|
| 93 |
+
user_id="user999",
|
| 94 |
+
agent_name="symptom_agent",
|
| 95 |
+
user_message="Tôi bị đau ngực dữ dội",
|
| 96 |
+
agent_response="Bạn nên nghỉ ngơi, uống nước",
|
| 97 |
+
issue_type="harmful",
|
| 98 |
+
description="Đau ngực dữ dội cần đi bệnh viện ngay, không nên chỉ nghỉ ngơi",
|
| 99 |
+
severity="critical"
|
| 100 |
+
)
|
| 101 |
+
print(f" Report ID: {report_id}")
|
| 102 |
+
print(f" Severity: CRITICAL 🚨")
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def example_analyze_feedback():
|
| 106 |
+
"""Example: Analyze feedback to find patterns"""
|
| 107 |
+
print("\n" + "=" * 60)
|
| 108 |
+
print("ANALYZING FEEDBACK")
|
| 109 |
+
print("=" * 60)
|
| 110 |
+
|
| 111 |
+
collector = get_feedback_collector()
|
| 112 |
+
|
| 113 |
+
# Add more sample data
|
| 114 |
+
print("\n📊 Adding sample feedback data...")
|
| 115 |
+
for i in range(10):
|
| 116 |
+
collector.collect_rating(
|
| 117 |
+
user_id=f"user{i}",
|
| 118 |
+
agent_name="nutrition_agent",
|
| 119 |
+
user_message=f"Question {i}",
|
| 120 |
+
agent_response=f"Response {i}",
|
| 121 |
+
rating=4 if i % 2 == 0 else 3,
|
| 122 |
+
category=FeedbackCategory.HELPFULNESS
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
# Get statistics
|
| 126 |
+
print("\n📈 Feedback Statistics:")
|
| 127 |
+
stats = collector.get_feedback_stats(agent_name="nutrition_agent")
|
| 128 |
+
print(f" Total ratings: {stats['total_ratings']}")
|
| 129 |
+
print(f" Average rating: {stats['average_rating']:.1f}/5.0")
|
| 130 |
+
print(f" Rating distribution:")
|
| 131 |
+
for rating in [5, 4, 3, 2, 1]:
|
| 132 |
+
count = stats['rating_distribution'][rating]
|
| 133 |
+
print(f" {rating} stars: {count}")
|
| 134 |
+
|
| 135 |
+
# Analyze performance
|
| 136 |
+
print("\n🔍 Performance Analysis:")
|
| 137 |
+
analyzer = get_feedback_analyzer(collector)
|
| 138 |
+
analysis = analyzer.analyze_agent_performance("nutrition_agent")
|
| 139 |
+
|
| 140 |
+
print(f" Overall rating: {analysis['overall_rating']:.1f}/5.0")
|
| 141 |
+
|
| 142 |
+
if analysis['strengths']:
|
| 143 |
+
print(f"\n Strengths:")
|
| 144 |
+
for strength in analysis['strengths']:
|
| 145 |
+
print(f" ✅ {strength}")
|
| 146 |
+
|
| 147 |
+
if analysis['weaknesses']:
|
| 148 |
+
print(f"\n Weaknesses:")
|
| 149 |
+
for weakness in analysis['weaknesses']:
|
| 150 |
+
print(f" ⚠️ {weakness}")
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def example_get_insights():
|
| 154 |
+
"""Example: Get actionable insights"""
|
| 155 |
+
print("\n" + "=" * 60)
|
| 156 |
+
print("ACTIONABLE INSIGHTS")
|
| 157 |
+
print("=" * 60)
|
| 158 |
+
|
| 159 |
+
collector = get_feedback_collector()
|
| 160 |
+
analyzer = get_feedback_analyzer(collector)
|
| 161 |
+
|
| 162 |
+
# Get insights
|
| 163 |
+
insights = analyzer.get_actionable_insights("nutrition_agent", limit=3)
|
| 164 |
+
|
| 165 |
+
if insights:
|
| 166 |
+
print("\n💡 Top Improvement Opportunities:")
|
| 167 |
+
for i, insight in enumerate(insights, 1):
|
| 168 |
+
print(f"\n {i}. [{insight['priority'].upper()}] {insight['category']}")
|
| 169 |
+
print(f" Issue: {insight['issue']}")
|
| 170 |
+
print(f" Action: {insight['action']}")
|
| 171 |
+
if insight['examples']:
|
| 172 |
+
print(f" Examples: {', '.join(insight['examples'][:2])}")
|
| 173 |
+
else:
|
| 174 |
+
print("\n No insights available yet. Collect more feedback!")
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def example_generate_report():
|
| 178 |
+
"""Example: Generate improvement report"""
|
| 179 |
+
print("\n" + "=" * 60)
|
| 180 |
+
print("IMPROVEMENT REPORT")
|
| 181 |
+
print("=" * 60)
|
| 182 |
+
|
| 183 |
+
collector = get_feedback_collector()
|
| 184 |
+
analyzer = get_feedback_analyzer(collector)
|
| 185 |
+
|
| 186 |
+
# Generate report
|
| 187 |
+
report = analyzer.generate_improvement_report("nutrition_agent")
|
| 188 |
+
print(report)
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def example_export_for_training():
|
| 192 |
+
"""Example: Export feedback for fine-tuning"""
|
| 193 |
+
print("\n" + "=" * 60)
|
| 194 |
+
print("EXPORT FOR FINE-TUNING")
|
| 195 |
+
print("=" * 60)
|
| 196 |
+
|
| 197 |
+
collector = get_feedback_collector()
|
| 198 |
+
|
| 199 |
+
# Export high-quality feedback
|
| 200 |
+
print("\n📦 Exporting high-quality feedback (rating >= 4)...")
|
| 201 |
+
output_file = collector.export_for_fine_tuning(
|
| 202 |
+
agent_name="nutrition_agent",
|
| 203 |
+
min_rating=4,
|
| 204 |
+
include_corrections=True
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
print(f" ✅ Exported to: {output_file}")
|
| 208 |
+
print(f" Ready for fine-tuning!")
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def example_compare_agents():
|
| 212 |
+
"""Example: Compare agent performance"""
|
| 213 |
+
print("\n" + "=" * 60)
|
| 214 |
+
print("AGENT COMPARISON")
|
| 215 |
+
print("=" * 60)
|
| 216 |
+
|
| 217 |
+
collector = get_feedback_collector()
|
| 218 |
+
|
| 219 |
+
# Add feedback for different agents
|
| 220 |
+
print("\n📊 Adding feedback for multiple agents...")
|
| 221 |
+
agents = ["nutrition_agent", "exercise_agent", "symptom_agent"]
|
| 222 |
+
|
| 223 |
+
for agent in agents:
|
| 224 |
+
for i in range(5):
|
| 225 |
+
rating = 5 if agent == "nutrition_agent" else (4 if agent == "exercise_agent" else 3)
|
| 226 |
+
collector.collect_rating(
|
| 227 |
+
user_id=f"user{i}",
|
| 228 |
+
agent_name=agent,
|
| 229 |
+
user_message=f"Question for {agent}",
|
| 230 |
+
agent_response=f"Response from {agent}",
|
| 231 |
+
rating=rating
|
| 232 |
+
)
|
| 233 |
+
|
| 234 |
+
# Compare
|
| 235 |
+
analyzer = get_feedback_analyzer(collector)
|
| 236 |
+
comparison = analyzer.compare_agents()
|
| 237 |
+
|
| 238 |
+
print(f"\n🏆 Agent Rankings:")
|
| 239 |
+
for i, agent in enumerate(comparison['agents'], 1):
|
| 240 |
+
print(f" {i}. {agent['agent']}: {agent['average_rating']:.1f}/5.0 ({agent['total_feedback']} feedback)")
|
| 241 |
+
|
| 242 |
+
if comparison['best_agent']:
|
| 243 |
+
print(f"\n Best: {comparison['best_agent']['agent']} 🥇")
|
| 244 |
+
|
| 245 |
+
if comparison['worst_agent']:
|
| 246 |
+
print(f" Needs improvement: {comparison['worst_agent']['agent']} ⚠️")
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
if __name__ == '__main__':
|
| 250 |
+
example_collect_ratings()
|
| 251 |
+
example_collect_corrections()
|
| 252 |
+
example_report_issue()
|
| 253 |
+
example_analyze_feedback()
|
| 254 |
+
example_get_insights()
|
| 255 |
+
example_generate_report()
|
| 256 |
+
example_export_for_training()
|
| 257 |
+
example_compare_agents()
|
| 258 |
+
|
| 259 |
+
print("\n" + "=" * 60)
|
| 260 |
+
print("✅ FEEDBACK LOOP DEMO COMPLETE!")
|
| 261 |
+
print("=" * 60)
|
| 262 |
+
print("\nNext steps:")
|
| 263 |
+
print("1. Integrate feedback collection into your UI")
|
| 264 |
+
print("2. Review feedback regularly")
|
| 265 |
+
print("3. Use insights to improve agents")
|
| 266 |
+
print("4. Export high-quality feedback for fine-tuning")
|
| 267 |
+
print("5. Monitor trends and act on critical issues")
|
examples/multilingual_example.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Multilingual Support Example
|
| 3 |
+
Demonstrates automatic language detection and bilingual responses
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from i18n import Language, detect_language, t, get_multilingual_handler, Translations
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def test_language_detection():
|
| 10 |
+
"""Test automatic language detection"""
|
| 11 |
+
print("=" * 60)
|
| 12 |
+
print("LANGUAGE DETECTION TEST")
|
| 13 |
+
print("=" * 60)
|
| 14 |
+
|
| 15 |
+
test_cases = [
|
| 16 |
+
("Tôi muốn giảm cân", Language.VIETNAMESE),
|
| 17 |
+
("I want to lose weight", Language.ENGLISH),
|
| 18 |
+
("Bạn có thể giúp tôi không?", Language.VIETNAMESE),
|
| 19 |
+
("Can you help me?", Language.ENGLISH),
|
| 20 |
+
("Tôi bị đau đầu", Language.VIETNAMESE),
|
| 21 |
+
("I have a headache", Language.ENGLISH),
|
| 22 |
+
("Làm sao để tăng cơ?", Language.VIETNAMESE),
|
| 23 |
+
("How to build muscle?", Language.ENGLISH),
|
| 24 |
+
]
|
| 25 |
+
|
| 26 |
+
for text, expected in test_cases:
|
| 27 |
+
detected = detect_language(text)
|
| 28 |
+
status = "✅" if detected == expected else "❌"
|
| 29 |
+
print(f"{status} '{text}' → {detected.value} (expected: {expected.value})")
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def test_translations():
|
| 33 |
+
"""Test translation system"""
|
| 34 |
+
print("\n" + "=" * 60)
|
| 35 |
+
print("TRANSLATION TEST")
|
| 36 |
+
print("=" * 60)
|
| 37 |
+
|
| 38 |
+
keys = [
|
| 39 |
+
'greeting',
|
| 40 |
+
'ask_age',
|
| 41 |
+
'ask_weight',
|
| 42 |
+
'bmi_normal',
|
| 43 |
+
'thank_you_feedback',
|
| 44 |
+
'error_occurred'
|
| 45 |
+
]
|
| 46 |
+
|
| 47 |
+
print("\n🇻🇳 Vietnamese:")
|
| 48 |
+
for key in keys:
|
| 49 |
+
text = t(key, Language.VIETNAMESE)
|
| 50 |
+
print(f" {key}: {text}")
|
| 51 |
+
|
| 52 |
+
print("\n🇬🇧 English:")
|
| 53 |
+
for key in keys:
|
| 54 |
+
text = t(key, Language.ENGLISH)
|
| 55 |
+
print(f" {key}: {text}")
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def test_agent_prompts():
|
| 59 |
+
"""Test agent system prompts in both languages"""
|
| 60 |
+
print("\n" + "=" * 60)
|
| 61 |
+
print("AGENT PROMPTS TEST")
|
| 62 |
+
print("=" * 60)
|
| 63 |
+
|
| 64 |
+
agents = ['nutrition', 'exercise', 'symptom', 'mental_health']
|
| 65 |
+
|
| 66 |
+
for agent in agents:
|
| 67 |
+
print(f"\n📋 {agent.upper()} Agent:")
|
| 68 |
+
|
| 69 |
+
print("\n🇻🇳 Vietnamese:")
|
| 70 |
+
prompt_vi = Translations.get_agent_prompt(agent, Language.VIETNAMESE)
|
| 71 |
+
print(f" {prompt_vi[:100]}...")
|
| 72 |
+
|
| 73 |
+
print("\n🇬🇧 English:")
|
| 74 |
+
prompt_en = Translations.get_agent_prompt(agent, Language.ENGLISH)
|
| 75 |
+
print(f" {prompt_en[:100]}...")
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def test_multilingual_handler():
|
| 79 |
+
"""Test multilingual handler"""
|
| 80 |
+
print("\n" + "=" * 60)
|
| 81 |
+
print("MULTILINGUAL HANDLER TEST")
|
| 82 |
+
print("=" * 60)
|
| 83 |
+
|
| 84 |
+
handler = get_multilingual_handler()
|
| 85 |
+
|
| 86 |
+
# Simulate users with different languages
|
| 87 |
+
print("\n👤 User 1 (Vietnamese):")
|
| 88 |
+
lang1 = handler.detect_and_set_language("user1", "Tôi muốn giảm cân")
|
| 89 |
+
print(f" Detected: {lang1.value}")
|
| 90 |
+
print(f" Greeting: {handler.translate_message('greeting', lang1)}")
|
| 91 |
+
|
| 92 |
+
print("\n👤 User 2 (English):")
|
| 93 |
+
lang2 = handler.detect_and_set_language("user2", "I want to lose weight")
|
| 94 |
+
print(f" Detected: {lang2.value}")
|
| 95 |
+
print(f" Greeting: {handler.translate_message('greeting', lang2)}")
|
| 96 |
+
|
| 97 |
+
print("\n👤 User 3 (Vietnamese):")
|
| 98 |
+
lang3 = handler.detect_and_set_language("user3", "Tôi bị đau đầu")
|
| 99 |
+
print(f" Detected: {lang3.value}")
|
| 100 |
+
print(f" Greeting: {handler.translate_message('greeting', lang3)}")
|
| 101 |
+
|
| 102 |
+
# Get statistics
|
| 103 |
+
print("\n📊 Language Statistics:")
|
| 104 |
+
stats = handler.get_language_stats()
|
| 105 |
+
print(f" Total users: {stats['total_users']}")
|
| 106 |
+
print(f" Vietnamese: {stats['vietnamese_users']} ({stats['vietnamese_percentage']}%)")
|
| 107 |
+
print(f" English: {stats['english_users']} ({stats['english_percentage']}%)")
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def test_conversation_flow():
|
| 111 |
+
"""Test full conversation flow with language detection"""
|
| 112 |
+
print("\n" + "=" * 60)
|
| 113 |
+
print("CONVERSATION FLOW TEST")
|
| 114 |
+
print("=" * 60)
|
| 115 |
+
|
| 116 |
+
handler = get_multilingual_handler()
|
| 117 |
+
|
| 118 |
+
# Vietnamese conversation
|
| 119 |
+
print("\n🇻🇳 Vietnamese Conversation:")
|
| 120 |
+
print("-" * 40)
|
| 121 |
+
|
| 122 |
+
user_msg_vi = "Tôi muốn giảm cân"
|
| 123 |
+
lang_vi = handler.detect_and_set_language("user_vi", user_msg_vi)
|
| 124 |
+
|
| 125 |
+
print(f"User: {user_msg_vi}")
|
| 126 |
+
print(f"Detected language: {lang_vi.value}")
|
| 127 |
+
print(f"Bot: {handler.translate_message('greeting', lang_vi)}")
|
| 128 |
+
print(f"Bot: {handler.translate_message('ask_age', lang_vi)}")
|
| 129 |
+
|
| 130 |
+
# English conversation
|
| 131 |
+
print("\n🇬🇧 English Conversation:")
|
| 132 |
+
print("-" * 40)
|
| 133 |
+
|
| 134 |
+
user_msg_en = "I want to lose weight"
|
| 135 |
+
lang_en = handler.detect_and_set_language("user_en", user_msg_en)
|
| 136 |
+
|
| 137 |
+
print(f"User: {user_msg_en}")
|
| 138 |
+
print(f"Detected language: {lang_en.value}")
|
| 139 |
+
print(f"Bot: {handler.translate_message('greeting', lang_en)}")
|
| 140 |
+
print(f"Bot: {handler.translate_message('ask_age', lang_en)}")
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def test_mixed_language():
|
| 144 |
+
"""Test handling of mixed language input"""
|
| 145 |
+
print("\n" + "=" * 60)
|
| 146 |
+
print("MIXED LANGUAGE TEST")
|
| 147 |
+
print("=" * 60)
|
| 148 |
+
|
| 149 |
+
handler = get_multilingual_handler()
|
| 150 |
+
|
| 151 |
+
# User starts in Vietnamese
|
| 152 |
+
print("\n👤 User starts in Vietnamese:")
|
| 153 |
+
lang1 = handler.detect_and_set_language("user_mixed", "Tôi muốn giảm cân")
|
| 154 |
+
print(f" Message: 'Tôi muốn giảm cân'")
|
| 155 |
+
print(f" Detected: {lang1.value}")
|
| 156 |
+
print(f" Response: {handler.translate_message('greeting', lang1)}")
|
| 157 |
+
|
| 158 |
+
# User switches to English
|
| 159 |
+
print("\n👤 User switches to English:")
|
| 160 |
+
lang2 = handler.detect_and_set_language("user_mixed", "How many calories should I eat?")
|
| 161 |
+
print(f" Message: 'How many calories should I eat?'")
|
| 162 |
+
print(f" Detected: {lang2.value}")
|
| 163 |
+
print(f" Response: {handler.translate_message('nutrition_advice', lang2)}")
|
| 164 |
+
|
| 165 |
+
# User switches back to Vietnamese
|
| 166 |
+
print("\n👤 User switches back to Vietnamese:")
|
| 167 |
+
lang3 = handler.detect_and_set_language("user_mixed", "Cảm ơn bạn!")
|
| 168 |
+
print(f" Message: 'Cảm ơn bạn!'")
|
| 169 |
+
print(f" Detected: {lang3.value}")
|
| 170 |
+
print(f" Response: {handler.translate_message('thank_you_feedback', lang3)}")
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def demo_real_world_usage():
|
| 174 |
+
"""Demo real-world usage scenario"""
|
| 175 |
+
print("\n" + "=" * 60)
|
| 176 |
+
print("REAL-WORLD USAGE DEMO")
|
| 177 |
+
print("=" * 60)
|
| 178 |
+
|
| 179 |
+
handler = get_multilingual_handler()
|
| 180 |
+
|
| 181 |
+
scenarios = [
|
| 182 |
+
{
|
| 183 |
+
'user_id': 'nguyen_van_a',
|
| 184 |
+
'messages': [
|
| 185 |
+
"Tôi 25 tuổi, 70kg, 175cm",
|
| 186 |
+
"Tôi muốn giảm 5kg trong 2 tháng",
|
| 187 |
+
"Tôi nên ăn bao nhiêu calo?"
|
| 188 |
+
]
|
| 189 |
+
},
|
| 190 |
+
{
|
| 191 |
+
'user_id': 'john_smith',
|
| 192 |
+
'messages': [
|
| 193 |
+
"I'm 30 years old, 80kg, 180cm",
|
| 194 |
+
"I want to build muscle",
|
| 195 |
+
"What exercises should I do?"
|
| 196 |
+
]
|
| 197 |
+
}
|
| 198 |
+
]
|
| 199 |
+
|
| 200 |
+
for scenario in scenarios:
|
| 201 |
+
user_id = scenario['user_id']
|
| 202 |
+
print(f"\n👤 User: {user_id}")
|
| 203 |
+
print("-" * 40)
|
| 204 |
+
|
| 205 |
+
for msg in scenario['messages']:
|
| 206 |
+
lang = handler.detect_and_set_language(user_id, msg)
|
| 207 |
+
print(f"\nUser ({lang.value}): {msg}")
|
| 208 |
+
|
| 209 |
+
# Simulate bot response
|
| 210 |
+
if "calo" in msg.lower() or "calories" in msg.lower():
|
| 211 |
+
response_key = 'nutrition_advice'
|
| 212 |
+
elif "tập" in msg.lower() or "exercise" in msg.lower():
|
| 213 |
+
response_key = 'exercise_plan'
|
| 214 |
+
else:
|
| 215 |
+
response_key = 'greeting'
|
| 216 |
+
|
| 217 |
+
response = handler.translate_message(response_key, lang)
|
| 218 |
+
print(f"Bot ({lang.value}): {response}")
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
if __name__ == '__main__':
|
| 222 |
+
test_language_detection()
|
| 223 |
+
test_translations()
|
| 224 |
+
test_agent_prompts()
|
| 225 |
+
test_multilingual_handler()
|
| 226 |
+
test_conversation_flow()
|
| 227 |
+
test_mixed_language()
|
| 228 |
+
demo_real_world_usage()
|
| 229 |
+
|
| 230 |
+
print("\n" + "=" * 60)
|
| 231 |
+
print("✅ MULTILINGUAL SUPPORT DEMO COMPLETE!")
|
| 232 |
+
print("=" * 60)
|
| 233 |
+
print("\nKey Features:")
|
| 234 |
+
print("✅ Automatic language detection (Vietnamese/English)")
|
| 235 |
+
print("✅ Bilingual translations for all messages")
|
| 236 |
+
print("✅ Language-specific agent prompts")
|
| 237 |
+
print("✅ Seamless language switching")
|
| 238 |
+
print("✅ User language preferences")
|
| 239 |
+
print("✅ Language usage statistics")
|
examples/pydantic_validation_example.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Pydantic Validation Example
|
| 3 |
+
Demonstrates automatic parsing and validation of health data
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from health_data import (
|
| 7 |
+
PydanticUserHealthProfile,
|
| 8 |
+
PydanticHealthRecord,
|
| 9 |
+
NutritionRecord,
|
| 10 |
+
ExerciseRecord,
|
| 11 |
+
HealthDataParser,
|
| 12 |
+
merge_records,
|
| 13 |
+
RecordType
|
| 14 |
+
)
|
| 15 |
+
from datetime import datetime, timedelta
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def test_height_parsing():
|
| 19 |
+
"""Test parsing height from various formats"""
|
| 20 |
+
print("=" * 60)
|
| 21 |
+
print("HEIGHT PARSING TEST")
|
| 22 |
+
print("=" * 60)
|
| 23 |
+
|
| 24 |
+
test_cases = [
|
| 25 |
+
"1.78m", # Meters
|
| 26 |
+
"1,78m", # Comma separator
|
| 27 |
+
"178cm", # Centimeters
|
| 28 |
+
"178", # Just number
|
| 29 |
+
"1.78", # Decimal
|
| 30 |
+
"5'10\"", # Feet/inches
|
| 31 |
+
]
|
| 32 |
+
|
| 33 |
+
for test in test_cases:
|
| 34 |
+
result = HealthDataParser.parse_height(test)
|
| 35 |
+
print(f"Input: {test:15} → {result} cm")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def test_weight_parsing():
|
| 39 |
+
"""Test parsing weight from various formats"""
|
| 40 |
+
print("\n" + "=" * 60)
|
| 41 |
+
print("WEIGHT PARSING TEST")
|
| 42 |
+
print("=" * 60)
|
| 43 |
+
|
| 44 |
+
test_cases = [
|
| 45 |
+
"70kg", # Kilograms
|
| 46 |
+
"70", # Just number
|
| 47 |
+
"154lbs", # Pounds
|
| 48 |
+
"70.5", # Decimal
|
| 49 |
+
]
|
| 50 |
+
|
| 51 |
+
for test in test_cases:
|
| 52 |
+
result = HealthDataParser.parse_weight(test)
|
| 53 |
+
print(f"Input: {test:15} → {result} kg")
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def test_pydantic_validation():
|
| 57 |
+
"""Test Pydantic automatic validation"""
|
| 58 |
+
print("\n" + "=" * 60)
|
| 59 |
+
print("PYDANTIC VALIDATION TEST")
|
| 60 |
+
print("=" * 60)
|
| 61 |
+
|
| 62 |
+
# Test 1: Valid data with various formats
|
| 63 |
+
print("\n✅ Test 1: Valid data with mixed formats")
|
| 64 |
+
try:
|
| 65 |
+
profile = PydanticUserHealthProfile(
|
| 66 |
+
user_id="user123",
|
| 67 |
+
age="25 tuổi", # Will parse to 25
|
| 68 |
+
gender="male",
|
| 69 |
+
weight="70kg", # Will parse to 70.0
|
| 70 |
+
height="1.78m" # Will parse to 178.0
|
| 71 |
+
)
|
| 72 |
+
print(f" Age: {profile.age}")
|
| 73 |
+
print(f" Weight: {profile.weight} kg")
|
| 74 |
+
print(f" Height: {profile.height} cm")
|
| 75 |
+
print(f" BMI: {profile.bmi} ({profile.get_bmi_category()})")
|
| 76 |
+
print(" ✅ Success!")
|
| 77 |
+
except Exception as e:
|
| 78 |
+
print(f" ❌ Error: {e}")
|
| 79 |
+
|
| 80 |
+
# Test 2: Invalid height (too high)
|
| 81 |
+
print("\n❌ Test 2: Invalid height (too high)")
|
| 82 |
+
try:
|
| 83 |
+
profile = PydanticUserHealthProfile(
|
| 84 |
+
user_id="user456",
|
| 85 |
+
height="500cm" # Too high!
|
| 86 |
+
)
|
| 87 |
+
print(" ❌ Should have failed!")
|
| 88 |
+
except Exception as e:
|
| 89 |
+
print(f" ✅ Caught error: {e}")
|
| 90 |
+
|
| 91 |
+
# Test 3: Invalid age (too young)
|
| 92 |
+
print("\n❌ Test 3: Invalid age (too young)")
|
| 93 |
+
try:
|
| 94 |
+
profile = PydanticUserHealthProfile(
|
| 95 |
+
user_id="user789",
|
| 96 |
+
age=10 # Too young!
|
| 97 |
+
)
|
| 98 |
+
print(" ❌ Should have failed!")
|
| 99 |
+
except Exception as e:
|
| 100 |
+
print(f" ✅ Caught error: {e}")
|
| 101 |
+
|
| 102 |
+
# Test 4: Auto BMI calculation
|
| 103 |
+
print("\n✅ Test 4: Auto BMI calculation")
|
| 104 |
+
profile = PydanticUserHealthProfile(
|
| 105 |
+
user_id="user999",
|
| 106 |
+
weight="70kg",
|
| 107 |
+
height="1,75m" # Comma separator!
|
| 108 |
+
)
|
| 109 |
+
print(f" Weight: {profile.weight} kg")
|
| 110 |
+
print(f" Height: {profile.height} cm")
|
| 111 |
+
print(f" BMI: {profile.bmi} (auto-calculated)")
|
| 112 |
+
print(f" Category: {profile.get_bmi_category()}")
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def test_health_records():
|
| 116 |
+
"""Test health records with validation"""
|
| 117 |
+
print("\n" + "=" * 60)
|
| 118 |
+
print("HEALTH RECORDS TEST")
|
| 119 |
+
print("=" * 60)
|
| 120 |
+
|
| 121 |
+
# Create nutrition record
|
| 122 |
+
print("\n📊 Creating Nutrition Record")
|
| 123 |
+
nutrition = NutritionRecord(
|
| 124 |
+
user_id="user123",
|
| 125 |
+
height="1.78m",
|
| 126 |
+
weight="70kg",
|
| 127 |
+
data={
|
| 128 |
+
'calories': 2000,
|
| 129 |
+
'protein': 150,
|
| 130 |
+
'carbs': 200,
|
| 131 |
+
'fat': 60
|
| 132 |
+
}
|
| 133 |
+
)
|
| 134 |
+
print(f" Height: {nutrition.height} cm")
|
| 135 |
+
print(f" Weight: {nutrition.weight} kg")
|
| 136 |
+
print(f" BMI: {nutrition.bmi}")
|
| 137 |
+
print(f" Calories: {nutrition.data['calories']}")
|
| 138 |
+
|
| 139 |
+
# Create exercise record
|
| 140 |
+
print("\n🏃 Creating Exercise Record")
|
| 141 |
+
exercise = ExerciseRecord(
|
| 142 |
+
user_id="user123",
|
| 143 |
+
data={
|
| 144 |
+
'exercise_type': 'cardio',
|
| 145 |
+
'duration_minutes': 30,
|
| 146 |
+
'calories_burned': 300
|
| 147 |
+
}
|
| 148 |
+
)
|
| 149 |
+
print(f" Type: {exercise.data['exercise_type']}")
|
| 150 |
+
print(f" Duration: {exercise.data['duration_minutes']} min")
|
| 151 |
+
print(f" Calories: {exercise.data['calories_burned']}")
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def test_merge_records():
|
| 155 |
+
"""Test merging records from multiple days"""
|
| 156 |
+
print("\n" + "=" * 60)
|
| 157 |
+
print("MERGE RECORDS TEST")
|
| 158 |
+
print("=" * 60)
|
| 159 |
+
|
| 160 |
+
# Create sample records over 7 days
|
| 161 |
+
records = []
|
| 162 |
+
base_date = datetime.now() - timedelta(days=7)
|
| 163 |
+
|
| 164 |
+
for i in range(7):
|
| 165 |
+
# Nutrition record
|
| 166 |
+
nutrition = NutritionRecord(
|
| 167 |
+
user_id="user123",
|
| 168 |
+
weight=70 - i * 0.2, # Gradually losing weight
|
| 169 |
+
height=178,
|
| 170 |
+
data={
|
| 171 |
+
'calories': 1800 + i * 50,
|
| 172 |
+
'protein': 140 + i * 5,
|
| 173 |
+
}
|
| 174 |
+
)
|
| 175 |
+
nutrition.timestamp = base_date + timedelta(days=i)
|
| 176 |
+
records.append(nutrition)
|
| 177 |
+
|
| 178 |
+
# Exercise record
|
| 179 |
+
exercise = ExerciseRecord(
|
| 180 |
+
user_id="user123",
|
| 181 |
+
data={
|
| 182 |
+
'exercise_type': 'cardio' if i % 2 == 0 else 'strength',
|
| 183 |
+
'duration_minutes': 30 + i * 5,
|
| 184 |
+
'calories_burned': 250 + i * 20
|
| 185 |
+
}
|
| 186 |
+
)
|
| 187 |
+
exercise.timestamp = base_date + timedelta(days=i)
|
| 188 |
+
records.append(exercise)
|
| 189 |
+
|
| 190 |
+
print(f"\n📦 Created {len(records)} records over 7 days")
|
| 191 |
+
|
| 192 |
+
# Merge with average strategy
|
| 193 |
+
print("\n📊 Merging with 'average' strategy:")
|
| 194 |
+
merged = merge_records(records, strategy='average')
|
| 195 |
+
|
| 196 |
+
print(f"\nTotal records: {merged['total_records']}")
|
| 197 |
+
print(f"Date range: {merged['date_range']['start'][:10]} to {merged['date_range']['end'][:10]}")
|
| 198 |
+
|
| 199 |
+
if 'nutrition' in merged['by_type']:
|
| 200 |
+
nutrition_data = merged['by_type']['nutrition']
|
| 201 |
+
print(f"\n🍎 Nutrition Summary:")
|
| 202 |
+
print(f" Average calories: {nutrition_data['average_daily']['calories']}")
|
| 203 |
+
print(f" Average protein: {nutrition_data['average_daily']['protein']}g")
|
| 204 |
+
|
| 205 |
+
if 'exercise' in merged['by_type']:
|
| 206 |
+
exercise_data = merged['by_type']['exercise']
|
| 207 |
+
print(f"\n🏃 Exercise Summary:")
|
| 208 |
+
print(f" Total workouts: {exercise_data['total_workouts']}")
|
| 209 |
+
print(f" Total duration: {exercise_data['total_duration_minutes']} min")
|
| 210 |
+
print(f" Total calories burned: {exercise_data['total_calories_burned']}")
|
| 211 |
+
print(f" Exercise types: {exercise_data['exercise_types']}")
|
| 212 |
+
|
| 213 |
+
if 'health_metrics' in merged and 'weight' in merged['health_metrics']:
|
| 214 |
+
weight_data = merged['health_metrics']['weight']
|
| 215 |
+
print(f"\n⚖️ Weight Progress:")
|
| 216 |
+
print(f" Start: {weight_data['max']} kg")
|
| 217 |
+
print(f" End: {weight_data['latest']} kg")
|
| 218 |
+
print(f" Change: {weight_data['change']} kg")
|
| 219 |
+
print(f" Average: {weight_data['average']} kg")
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
if __name__ == '__main__':
|
| 223 |
+
test_height_parsing()
|
| 224 |
+
test_weight_parsing()
|
| 225 |
+
test_pydantic_validation()
|
| 226 |
+
test_health_records()
|
| 227 |
+
test_merge_records()
|
| 228 |
+
|
| 229 |
+
print("\n" + "=" * 60)
|
| 230 |
+
print("✅ ALL TESTS COMPLETE!")
|
| 231 |
+
print("=" * 60)
|
examples/session_persistence_example.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Session Persistence Example
|
| 3 |
+
Demonstrates how conversation memory persists across sessions
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from agents.core.coordinator import AgentCoordinator
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def example_first_session():
|
| 10 |
+
"""First session - user provides information"""
|
| 11 |
+
print("=" * 60)
|
| 12 |
+
print("SESSION 1: User provides information")
|
| 13 |
+
print("=" * 60)
|
| 14 |
+
|
| 15 |
+
# Create coordinator with user_id
|
| 16 |
+
coordinator = AgentCoordinator(user_id="user123")
|
| 17 |
+
|
| 18 |
+
# User provides information
|
| 19 |
+
query1 = "Tôi 25 tuổi, nam, 70kg, 175cm, muốn giảm cân"
|
| 20 |
+
response1 = coordinator.handle_query(query1, [])
|
| 21 |
+
|
| 22 |
+
print(f"\nUser: {query1}")
|
| 23 |
+
print(f"Bot: {response1[:200]}...")
|
| 24 |
+
|
| 25 |
+
# Check what's in memory
|
| 26 |
+
profile = coordinator.memory.get_full_profile()
|
| 27 |
+
print(f"\n📊 Memory saved:")
|
| 28 |
+
print(f" Age: {profile['age']}")
|
| 29 |
+
print(f" Gender: {profile['gender']}")
|
| 30 |
+
print(f" Weight: {profile['weight']}kg")
|
| 31 |
+
print(f" Height: {profile['height']}cm")
|
| 32 |
+
|
| 33 |
+
print("\n✅ Session saved automatically!")
|
| 34 |
+
print(" (User closes app)")
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def example_second_session():
|
| 38 |
+
"""Second session - memory is restored"""
|
| 39 |
+
print("\n" + "=" * 60)
|
| 40 |
+
print("SESSION 2: User returns (next day)")
|
| 41 |
+
print("=" * 60)
|
| 42 |
+
|
| 43 |
+
# Create NEW coordinator with SAME user_id
|
| 44 |
+
# Memory will be automatically loaded!
|
| 45 |
+
coordinator = AgentCoordinator(user_id="user123")
|
| 46 |
+
|
| 47 |
+
# Check memory - it should be loaded!
|
| 48 |
+
profile = coordinator.memory.get_full_profile()
|
| 49 |
+
print(f"\n📊 Memory restored:")
|
| 50 |
+
print(f" Age: {profile['age']}")
|
| 51 |
+
print(f" Gender: {profile['gender']}")
|
| 52 |
+
print(f" Weight: {profile['weight']}kg")
|
| 53 |
+
print(f" Height: {profile['height']}cm")
|
| 54 |
+
|
| 55 |
+
# User asks new question - bot remembers!
|
| 56 |
+
query2 = "Tôi nên ăn bao nhiêu calo mỗi ngày?"
|
| 57 |
+
response2 = coordinator.handle_query(query2, [])
|
| 58 |
+
|
| 59 |
+
print(f"\nUser: {query2}")
|
| 60 |
+
print(f"Bot: {response2[:200]}...")
|
| 61 |
+
print("\n✅ Bot remembers user info from previous session!")
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def example_different_user():
|
| 65 |
+
"""Different user - separate session"""
|
| 66 |
+
print("\n" + "=" * 60)
|
| 67 |
+
print("SESSION 3: Different user")
|
| 68 |
+
print("=" * 60)
|
| 69 |
+
|
| 70 |
+
# Different user_id = different session
|
| 71 |
+
coordinator = AgentCoordinator(user_id="user456")
|
| 72 |
+
|
| 73 |
+
profile = coordinator.memory.get_full_profile()
|
| 74 |
+
print(f"\n📊 Memory for user456:")
|
| 75 |
+
print(f" Age: {profile['age']}") # Should be None
|
| 76 |
+
print(f" Gender: {profile['gender']}") # Should be None
|
| 77 |
+
|
| 78 |
+
print("\n✅ Each user has separate session!")
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def example_without_persistence():
|
| 82 |
+
"""Without user_id - no persistence"""
|
| 83 |
+
print("\n" + "=" * 60)
|
| 84 |
+
print("SESSION 4: Without persistence (no user_id)")
|
| 85 |
+
print("=" * 60)
|
| 86 |
+
|
| 87 |
+
# No user_id = no persistence
|
| 88 |
+
coordinator = AgentCoordinator() # No user_id
|
| 89 |
+
|
| 90 |
+
print("\n⚠️ Memory will NOT persist across sessions")
|
| 91 |
+
print(" (Useful for anonymous/guest users)")
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
if __name__ == '__main__':
|
| 95 |
+
# Run examples
|
| 96 |
+
example_first_session()
|
| 97 |
+
example_second_session()
|
| 98 |
+
example_different_user()
|
| 99 |
+
example_without_persistence()
|
| 100 |
+
|
| 101 |
+
print("\n" + "=" * 60)
|
| 102 |
+
print("✅ Session Persistence Demo Complete!")
|
| 103 |
+
print("=" * 60)
|
examples/summarization_example.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Conversation Summarization Example
|
| 3 |
+
Demonstrates automatic summarization of long conversations
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from agents.core.coordinator import AgentCoordinator
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def simulate_long_conversation():
|
| 10 |
+
"""Simulate a long conversation to trigger summarization"""
|
| 11 |
+
print("=" * 60)
|
| 12 |
+
print("CONVERSATION SUMMARIZATION DEMO")
|
| 13 |
+
print("=" * 60)
|
| 14 |
+
|
| 15 |
+
coordinator = AgentCoordinator(user_id="demo_user")
|
| 16 |
+
|
| 17 |
+
# Simulate 20 conversation turns
|
| 18 |
+
conversations = [
|
| 19 |
+
("Tôi 25 tuổi, nam, 70kg, 175cm", "Cảm ơn thông tin..."),
|
| 20 |
+
("Tôi muốn giảm cân", "Để giảm cân hiệu quả..."),
|
| 21 |
+
("Nên ăn bao nhiêu calo?", "Với thông tin của bạn..."),
|
| 22 |
+
("Tôi nên tập gì?", "Bạn nên tập cardio..."),
|
| 23 |
+
("Bao lâu thì thấy kết quả?", "Thường sau 2-4 tuần..."),
|
| 24 |
+
("Tôi có thể ăn gì?", "Bạn nên ăn nhiều rau xanh..."),
|
| 25 |
+
("Sáng nên ăn gì?", "Bữa sáng nên có protein..."),
|
| 26 |
+
("Tối nên ăn gì?", "Bữa tối nên nhẹ..."),
|
| 27 |
+
("Tôi có thể ăn trái cây không?", "Có, nhưng hạn chế..."),
|
| 28 |
+
("Nên tập mấy lần 1 tuần?", "Nên tập 3-4 lần..."),
|
| 29 |
+
("Mỗi lần tập bao lâu?", "Mỗi lần 30-45 phút..."),
|
| 30 |
+
("Tôi nên uống bao nhiêu nước?", "Nên uống 2-3 lít..."),
|
| 31 |
+
("Có nên nhịn ăn không?", "Không nên nhịn ăn..."),
|
| 32 |
+
("Tôi có thể ăn đêm không?", "Nên tránh ăn đêm..."),
|
| 33 |
+
("Làm sao để không đói?", "Ăn nhiều protein..."),
|
| 34 |
+
("Tôi bị đau đầu khi tập", "Có thể do thiếu nước..."),
|
| 35 |
+
("Nên bổ sung gì?", "Có thể bổ sung vitamin..."),
|
| 36 |
+
("Tôi có cần whey protein không?", "Không bắt buộc..."),
|
| 37 |
+
("Khi nào nên nghỉ?", "Nên nghỉ 1-2 ngày..."),
|
| 38 |
+
("Làm sao biết đang giảm cân đúng?", "Theo dõi cân nặng..."),
|
| 39 |
+
]
|
| 40 |
+
|
| 41 |
+
chat_history = []
|
| 42 |
+
|
| 43 |
+
for i, (user_msg, bot_msg) in enumerate(conversations, 1):
|
| 44 |
+
chat_history.append((user_msg, bot_msg))
|
| 45 |
+
|
| 46 |
+
# Show progress
|
| 47 |
+
if i % 5 == 0:
|
| 48 |
+
print(f"\n📊 After {i} turns:")
|
| 49 |
+
stats = coordinator.get_conversation_stats(chat_history)
|
| 50 |
+
print(f" Total turns: {stats['total_turns']}")
|
| 51 |
+
print(f" Estimated tokens: {stats['estimated_tokens']}")
|
| 52 |
+
print(f" Should summarize: {stats['should_summarize']}")
|
| 53 |
+
|
| 54 |
+
print(f"\n" + "=" * 60)
|
| 55 |
+
print("BEFORE SUMMARIZATION")
|
| 56 |
+
print("=" * 60)
|
| 57 |
+
print(f"Total conversation turns: {len(chat_history)}")
|
| 58 |
+
|
| 59 |
+
# Trigger summarization
|
| 60 |
+
print(f"\n" + "=" * 60)
|
| 61 |
+
print("APPLYING SUMMARIZATION")
|
| 62 |
+
print("=" * 60)
|
| 63 |
+
|
| 64 |
+
# This happens automatically in coordinator.handle_query()
|
| 65 |
+
# But we can also do it manually:
|
| 66 |
+
from utils.conversation_summarizer import get_summarizer
|
| 67 |
+
|
| 68 |
+
summarizer = get_summarizer()
|
| 69 |
+
result = summarizer.summarize_conversation(
|
| 70 |
+
chat_history,
|
| 71 |
+
user_profile=coordinator.memory.get_full_profile(),
|
| 72 |
+
keep_recent=5
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
print(f"\n📝 SUMMARY:")
|
| 76 |
+
print(result['summary'])
|
| 77 |
+
|
| 78 |
+
print(f"\n💬 RECENT HISTORY ({len(result['recent_history'])} turns):")
|
| 79 |
+
for user_msg, bot_msg in result['recent_history']:
|
| 80 |
+
print(f" User: {user_msg}")
|
| 81 |
+
print(f" Bot: {bot_msg[:50]}...")
|
| 82 |
+
|
| 83 |
+
print(f"\n" + "=" * 60)
|
| 84 |
+
print("AFTER SUMMARIZATION")
|
| 85 |
+
print("=" * 60)
|
| 86 |
+
print(f"Summarized turns: {result['summarized_turns']}")
|
| 87 |
+
print(f"Kept recent turns: {len(result['recent_history'])}")
|
| 88 |
+
print(f"Total context size: {result['summarized_turns'] + len(result['recent_history'])} → {len(result['recent_history']) + 1} (summary + recent)")
|
| 89 |
+
|
| 90 |
+
# Show compressed history
|
| 91 |
+
compressed = summarizer.compress_history(chat_history, target_turns=10)
|
| 92 |
+
print(f"\n📦 Compressed history: {len(chat_history)} → {len(compressed)} turns")
|
| 93 |
+
print(f" Token reduction: ~{((len(chat_history) - len(compressed)) / len(chat_history) * 100):.0f}%")
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def test_automatic_summarization():
|
| 97 |
+
"""Test automatic summarization in coordinator"""
|
| 98 |
+
print("\n\n" + "=" * 60)
|
| 99 |
+
print("AUTOMATIC SUMMARIZATION TEST")
|
| 100 |
+
print("=" * 60)
|
| 101 |
+
|
| 102 |
+
coordinator = AgentCoordinator(user_id="test_user")
|
| 103 |
+
|
| 104 |
+
# Create long history
|
| 105 |
+
chat_history = [
|
| 106 |
+
(f"Câu hỏi {i}", f"Câu trả lời {i}")
|
| 107 |
+
for i in range(1, 21)
|
| 108 |
+
]
|
| 109 |
+
|
| 110 |
+
print(f"Initial history: {len(chat_history)} turns")
|
| 111 |
+
|
| 112 |
+
# This will trigger automatic summarization
|
| 113 |
+
response = coordinator.handle_query(
|
| 114 |
+
"Tôi muốn tóm tắt cuộc trò chuyện",
|
| 115 |
+
chat_history
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
print(f"\n✅ Automatic summarization triggered!")
|
| 119 |
+
print(f" Response: {response[:100]}...")
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
if __name__ == '__main__':
|
| 123 |
+
simulate_long_conversation()
|
| 124 |
+
test_automatic_summarization()
|
| 125 |
+
|
| 126 |
+
print("\n" + "=" * 60)
|
| 127 |
+
print("✅ Summarization Demo Complete!")
|
| 128 |
+
print("=" * 60)
|
feedback/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Feedback Module
|
| 3 |
+
Collect and learn from user ratings and corrections
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from .feedback_system import (
|
| 7 |
+
FeedbackCollector,
|
| 8 |
+
FeedbackType,
|
| 9 |
+
FeedbackCategory,
|
| 10 |
+
get_feedback_collector
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
from .feedback_analyzer import (
|
| 14 |
+
FeedbackAnalyzer,
|
| 15 |
+
get_feedback_analyzer
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
__all__ = [
|
| 19 |
+
'FeedbackCollector',
|
| 20 |
+
'FeedbackType',
|
| 21 |
+
'FeedbackCategory',
|
| 22 |
+
'get_feedback_collector',
|
| 23 |
+
'FeedbackAnalyzer',
|
| 24 |
+
'get_feedback_analyzer'
|
| 25 |
+
]
|
feedback/feedback_analyzer.py
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Feedback Analyzer
|
| 3 |
+
Analyze feedback patterns and generate insights for improvement
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import json
|
| 7 |
+
from datetime import datetime, timedelta
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Dict, Any, List, Optional
|
| 10 |
+
from collections import defaultdict, Counter
|
| 11 |
+
import re
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class FeedbackAnalyzer:
|
| 15 |
+
"""Analyze feedback to identify improvement opportunities"""
|
| 16 |
+
|
| 17 |
+
def __init__(self, feedback_collector):
|
| 18 |
+
self.collector = feedback_collector
|
| 19 |
+
|
| 20 |
+
def analyze_agent_performance(self, agent_name: str) -> Dict[str, Any]:
|
| 21 |
+
"""
|
| 22 |
+
Comprehensive analysis of agent performance
|
| 23 |
+
|
| 24 |
+
Args:
|
| 25 |
+
agent_name: Name of the agent to analyze
|
| 26 |
+
|
| 27 |
+
Returns:
|
| 28 |
+
Performance analysis
|
| 29 |
+
"""
|
| 30 |
+
stats = self.collector.get_feedback_stats(agent_name=agent_name)
|
| 31 |
+
low_rated = self.collector.get_low_rated_responses(agent_name=agent_name)
|
| 32 |
+
corrections = self.collector.get_corrections(agent_name=agent_name)
|
| 33 |
+
|
| 34 |
+
analysis = {
|
| 35 |
+
'agent_name': agent_name,
|
| 36 |
+
'overall_rating': stats['average_rating'],
|
| 37 |
+
'total_feedback': stats['total_ratings'],
|
| 38 |
+
'rating_distribution': stats['rating_distribution'],
|
| 39 |
+
'strengths': [],
|
| 40 |
+
'weaknesses': [],
|
| 41 |
+
'common_issues': [],
|
| 42 |
+
'improvement_suggestions': []
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
# Identify strengths (high-rated patterns)
|
| 46 |
+
if stats['average_rating'] >= 4.0:
|
| 47 |
+
analysis['strengths'].append("High overall satisfaction")
|
| 48 |
+
|
| 49 |
+
# Identify weaknesses (low-rated patterns)
|
| 50 |
+
if stats['rating_distribution'][1] + stats['rating_distribution'][2] > stats['total_ratings'] * 0.2:
|
| 51 |
+
analysis['weaknesses'].append("High number of low ratings (1-2 stars)")
|
| 52 |
+
|
| 53 |
+
# Analyze common issues from low-rated responses
|
| 54 |
+
if low_rated:
|
| 55 |
+
issues = self._extract_common_issues(low_rated)
|
| 56 |
+
analysis['common_issues'] = issues
|
| 57 |
+
|
| 58 |
+
# Analyze corrections
|
| 59 |
+
if corrections:
|
| 60 |
+
correction_patterns = self._analyze_corrections(corrections)
|
| 61 |
+
analysis['correction_patterns'] = correction_patterns
|
| 62 |
+
|
| 63 |
+
# Generate improvement suggestions
|
| 64 |
+
for pattern in correction_patterns:
|
| 65 |
+
analysis['improvement_suggestions'].append(
|
| 66 |
+
f"Improve {pattern['category']}: {pattern['suggestion']}"
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
return analysis
|
| 70 |
+
|
| 71 |
+
def _extract_common_issues(self, low_rated: List[Dict]) -> List[Dict[str, Any]]:
|
| 72 |
+
"""Extract common issues from low-rated responses"""
|
| 73 |
+
issues = []
|
| 74 |
+
|
| 75 |
+
# Analyze comments
|
| 76 |
+
comments = [r.get('comment', '') for r in low_rated if r.get('comment')]
|
| 77 |
+
|
| 78 |
+
# Common keywords in negative feedback
|
| 79 |
+
issue_keywords = {
|
| 80 |
+
'incorrect': 'Thông tin không chính xác',
|
| 81 |
+
'wrong': 'Câu trả lời sai',
|
| 82 |
+
'unhelpful': 'Không hữu ích',
|
| 83 |
+
'confusing': 'Khó hiểu',
|
| 84 |
+
'incomplete': 'Thiếu thông tin',
|
| 85 |
+
'too long': 'Quá dài dòng',
|
| 86 |
+
'too short': 'Quá ngắn gọn',
|
| 87 |
+
'rude': 'Không lịch sự',
|
| 88 |
+
'generic': 'Quá chung chung'
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
issue_counts = Counter()
|
| 92 |
+
|
| 93 |
+
for comment in comments:
|
| 94 |
+
comment_lower = comment.lower()
|
| 95 |
+
for keyword, description in issue_keywords.items():
|
| 96 |
+
if keyword in comment_lower:
|
| 97 |
+
issue_counts[description] += 1
|
| 98 |
+
|
| 99 |
+
# Get top issues
|
| 100 |
+
for issue, count in issue_counts.most_common(5):
|
| 101 |
+
issues.append({
|
| 102 |
+
'issue': issue,
|
| 103 |
+
'frequency': count,
|
| 104 |
+
'percentage': round(count / len(low_rated) * 100, 1)
|
| 105 |
+
})
|
| 106 |
+
|
| 107 |
+
return issues
|
| 108 |
+
|
| 109 |
+
def _analyze_corrections(self, corrections: List[Dict]) -> List[Dict[str, Any]]:
|
| 110 |
+
"""Analyze user corrections to find patterns"""
|
| 111 |
+
patterns = []
|
| 112 |
+
|
| 113 |
+
# Group by correction reason
|
| 114 |
+
by_reason = defaultdict(list)
|
| 115 |
+
for correction in corrections:
|
| 116 |
+
reason = correction.get('correction_reason', 'other')
|
| 117 |
+
by_reason[reason].append(correction)
|
| 118 |
+
|
| 119 |
+
# Analyze each category
|
| 120 |
+
for reason, items in by_reason.items():
|
| 121 |
+
if len(items) >= 2: # Only include if multiple occurrences
|
| 122 |
+
patterns.append({
|
| 123 |
+
'category': reason,
|
| 124 |
+
'count': len(items),
|
| 125 |
+
'suggestion': self._generate_suggestion(reason, items)
|
| 126 |
+
})
|
| 127 |
+
|
| 128 |
+
return patterns
|
| 129 |
+
|
| 130 |
+
def _generate_suggestion(self, reason: str, corrections: List[Dict]) -> str:
|
| 131 |
+
"""Generate improvement suggestion based on corrections"""
|
| 132 |
+
suggestions = {
|
| 133 |
+
'incorrect_info': 'Verify medical information against authoritative sources',
|
| 134 |
+
'missing_context': 'Ask more follow-up questions to gather context',
|
| 135 |
+
'tone': 'Adjust tone to be more empathetic and supportive',
|
| 136 |
+
'too_generic': 'Provide more personalized and specific advice',
|
| 137 |
+
'calculation_error': 'Double-check all numerical calculations',
|
| 138 |
+
'outdated_info': 'Update knowledge base with latest medical guidelines'
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
return suggestions.get(reason, f'Review and improve handling of: {reason}')
|
| 142 |
+
|
| 143 |
+
def get_trending_issues(self, days: int = 7) -> List[Dict[str, Any]]:
|
| 144 |
+
"""
|
| 145 |
+
Get trending issues in recent feedback
|
| 146 |
+
|
| 147 |
+
Args:
|
| 148 |
+
days: Number of days to analyze
|
| 149 |
+
|
| 150 |
+
Returns:
|
| 151 |
+
List of trending issues
|
| 152 |
+
"""
|
| 153 |
+
cutoff_date = datetime.now() - timedelta(days=days)
|
| 154 |
+
|
| 155 |
+
recent_low_rated = []
|
| 156 |
+
for file_path in (self.collector.storage_dir / "ratings").glob("*.json"):
|
| 157 |
+
with open(file_path, 'r', encoding='utf-8') as f:
|
| 158 |
+
data = json.load(f)
|
| 159 |
+
timestamp = datetime.fromisoformat(data.get('timestamp', ''))
|
| 160 |
+
|
| 161 |
+
if timestamp >= cutoff_date and data.get('rating', 5) <= 2:
|
| 162 |
+
recent_low_rated.append(data)
|
| 163 |
+
|
| 164 |
+
return self._extract_common_issues(recent_low_rated)
|
| 165 |
+
|
| 166 |
+
def compare_agents(self) -> Dict[str, Any]:
|
| 167 |
+
"""
|
| 168 |
+
Compare performance across all agents
|
| 169 |
+
|
| 170 |
+
Returns:
|
| 171 |
+
Comparison data
|
| 172 |
+
"""
|
| 173 |
+
stats = self.collector.get_feedback_stats()
|
| 174 |
+
|
| 175 |
+
comparison = {
|
| 176 |
+
'agents': [],
|
| 177 |
+
'best_agent': None,
|
| 178 |
+
'worst_agent': None,
|
| 179 |
+
'average_rating': stats['average_rating']
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
# Rank agents
|
| 183 |
+
agent_rankings = []
|
| 184 |
+
for agent, data in stats['by_agent'].items():
|
| 185 |
+
agent_rankings.append({
|
| 186 |
+
'agent': agent,
|
| 187 |
+
'average_rating': data['average'],
|
| 188 |
+
'total_feedback': data['count']
|
| 189 |
+
})
|
| 190 |
+
|
| 191 |
+
# Sort by rating
|
| 192 |
+
agent_rankings.sort(key=lambda x: x['average_rating'], reverse=True)
|
| 193 |
+
|
| 194 |
+
comparison['agents'] = agent_rankings
|
| 195 |
+
|
| 196 |
+
if agent_rankings:
|
| 197 |
+
comparison['best_agent'] = agent_rankings[0]
|
| 198 |
+
comparison['worst_agent'] = agent_rankings[-1]
|
| 199 |
+
|
| 200 |
+
return comparison
|
| 201 |
+
|
| 202 |
+
def generate_improvement_report(self, agent_name: Optional[str] = None) -> str:
|
| 203 |
+
"""
|
| 204 |
+
Generate a comprehensive improvement report
|
| 205 |
+
|
| 206 |
+
Args:
|
| 207 |
+
agent_name: Specific agent or all agents
|
| 208 |
+
|
| 209 |
+
Returns:
|
| 210 |
+
Formatted report
|
| 211 |
+
"""
|
| 212 |
+
if agent_name:
|
| 213 |
+
analysis = self.analyze_agent_performance(agent_name)
|
| 214 |
+
|
| 215 |
+
report = f"""
|
| 216 |
+
# Feedback Analysis Report: {agent_name}
|
| 217 |
+
Generated: {datetime.now().strftime('%Y-%m-%d %H:%M')}
|
| 218 |
+
|
| 219 |
+
## Overall Performance
|
| 220 |
+
- Average Rating: {analysis['overall_rating']:.1f}/5.0
|
| 221 |
+
- Total Feedback: {analysis['total_feedback']}
|
| 222 |
+
|
| 223 |
+
## Rating Distribution
|
| 224 |
+
- ⭐⭐⭐⭐⭐ (5 stars): {analysis['rating_distribution'][5]}
|
| 225 |
+
- ⭐⭐⭐⭐ (4 stars): {analysis['rating_distribution'][4]}
|
| 226 |
+
- ⭐⭐⭐ (3 stars): {analysis['rating_distribution'][3]}
|
| 227 |
+
- ⭐⭐ (2 stars): {analysis['rating_distribution'][2]}
|
| 228 |
+
- ⭐ (1 star): {analysis['rating_distribution'][1]}
|
| 229 |
+
|
| 230 |
+
## Strengths
|
| 231 |
+
"""
|
| 232 |
+
for strength in analysis['strengths']:
|
| 233 |
+
report += f"- ✅ {strength}\n"
|
| 234 |
+
|
| 235 |
+
report += "\n## Weaknesses\n"
|
| 236 |
+
for weakness in analysis['weaknesses']:
|
| 237 |
+
report += f"- ⚠️ {weakness}\n"
|
| 238 |
+
|
| 239 |
+
if analysis['common_issues']:
|
| 240 |
+
report += "\n## Common Issues\n"
|
| 241 |
+
for issue in analysis['common_issues']:
|
| 242 |
+
report += f"- {issue['issue']}: {issue['frequency']} occurrences ({issue['percentage']}%)\n"
|
| 243 |
+
|
| 244 |
+
if analysis['improvement_suggestions']:
|
| 245 |
+
report += "\n## Improvement Suggestions\n"
|
| 246 |
+
for i, suggestion in enumerate(analysis['improvement_suggestions'], 1):
|
| 247 |
+
report += f"{i}. {suggestion}\n"
|
| 248 |
+
|
| 249 |
+
return report
|
| 250 |
+
|
| 251 |
+
else:
|
| 252 |
+
# All agents comparison
|
| 253 |
+
comparison = self.compare_agents()
|
| 254 |
+
|
| 255 |
+
report = f"""
|
| 256 |
+
# Overall Feedback Analysis Report
|
| 257 |
+
Generated: {datetime.now().strftime('%Y-%m-%d %H:%M')}
|
| 258 |
+
|
| 259 |
+
## System-wide Performance
|
| 260 |
+
- Average Rating: {comparison['average_rating']:.1f}/5.0
|
| 261 |
+
|
| 262 |
+
## Agent Rankings
|
| 263 |
+
"""
|
| 264 |
+
for i, agent in enumerate(comparison['agents'], 1):
|
| 265 |
+
report += f"{i}. {agent['agent']}: {agent['average_rating']:.1f}/5.0 ({agent['total_feedback']} feedback)\n"
|
| 266 |
+
|
| 267 |
+
if comparison['best_agent']:
|
| 268 |
+
report += f"\n🏆 Best Performing: {comparison['best_agent']['agent']} ({comparison['best_agent']['average_rating']:.1f}/5.0)\n"
|
| 269 |
+
|
| 270 |
+
if comparison['worst_agent']:
|
| 271 |
+
report += f"⚠️ Needs Improvement: {comparison['worst_agent']['agent']} ({comparison['worst_agent']['average_rating']:.1f}/5.0)\n"
|
| 272 |
+
|
| 273 |
+
return report
|
| 274 |
+
|
| 275 |
+
def get_actionable_insights(self, agent_name: str, limit: int = 5) -> List[Dict[str, Any]]:
|
| 276 |
+
"""
|
| 277 |
+
Get top actionable insights for improvement
|
| 278 |
+
|
| 279 |
+
Args:
|
| 280 |
+
agent_name: Agent to analyze
|
| 281 |
+
limit: Number of insights to return
|
| 282 |
+
|
| 283 |
+
Returns:
|
| 284 |
+
List of actionable insights
|
| 285 |
+
"""
|
| 286 |
+
analysis = self.analyze_agent_performance(agent_name)
|
| 287 |
+
low_rated = self.collector.get_low_rated_responses(agent_name=agent_name, limit=20)
|
| 288 |
+
corrections = self.collector.get_corrections(agent_name=agent_name, limit=20)
|
| 289 |
+
|
| 290 |
+
insights = []
|
| 291 |
+
|
| 292 |
+
# Insight 1: Most common low-rating issue
|
| 293 |
+
if analysis['common_issues']:
|
| 294 |
+
top_issue = analysis['common_issues'][0]
|
| 295 |
+
insights.append({
|
| 296 |
+
'priority': 'high',
|
| 297 |
+
'category': 'quality',
|
| 298 |
+
'issue': top_issue['issue'],
|
| 299 |
+
'frequency': top_issue['frequency'],
|
| 300 |
+
'action': f"Review and fix responses related to: {top_issue['issue']}",
|
| 301 |
+
'examples': [r['user_message'] for r in low_rated[:3]]
|
| 302 |
+
})
|
| 303 |
+
|
| 304 |
+
# Insight 2: Correction patterns
|
| 305 |
+
if corrections:
|
| 306 |
+
insights.append({
|
| 307 |
+
'priority': 'high',
|
| 308 |
+
'category': 'accuracy',
|
| 309 |
+
'issue': 'User corrections available',
|
| 310 |
+
'frequency': len(corrections),
|
| 311 |
+
'action': 'Incorporate user corrections into training data',
|
| 312 |
+
'examples': [c['correction_reason'] for c in corrections[:3]]
|
| 313 |
+
})
|
| 314 |
+
|
| 315 |
+
# Insight 3: Rating trend
|
| 316 |
+
stats = self.collector.get_feedback_stats(agent_name=agent_name)
|
| 317 |
+
low_rating_pct = (stats['rating_distribution'][1] + stats['rating_distribution'][2]) / max(stats['total_ratings'], 1) * 100
|
| 318 |
+
|
| 319 |
+
if low_rating_pct > 20:
|
| 320 |
+
insights.append({
|
| 321 |
+
'priority': 'critical',
|
| 322 |
+
'category': 'overall',
|
| 323 |
+
'issue': f'{low_rating_pct:.1f}% of ratings are 1-2 stars',
|
| 324 |
+
'action': 'Urgent review needed - high dissatisfaction rate',
|
| 325 |
+
'examples': []
|
| 326 |
+
})
|
| 327 |
+
|
| 328 |
+
return insights[:limit]
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
def get_feedback_analyzer(feedback_collector) -> FeedbackAnalyzer:
|
| 332 |
+
"""Create feedback analyzer instance"""
|
| 333 |
+
return FeedbackAnalyzer(feedback_collector)
|
feedback/feedback_system.py
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Feedback System
|
| 3 |
+
Collect and learn from user ratings and corrections
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import json
|
| 7 |
+
from datetime import datetime
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Optional, Dict, Any, List
|
| 10 |
+
from enum import Enum
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class FeedbackType(str, Enum):
|
| 14 |
+
"""Types of feedback"""
|
| 15 |
+
RATING = "rating"
|
| 16 |
+
CORRECTION = "correction"
|
| 17 |
+
THUMBS_UP = "thumbs_up"
|
| 18 |
+
THUMBS_DOWN = "thumbs_down"
|
| 19 |
+
REPORT = "report"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class FeedbackCategory(str, Enum):
|
| 23 |
+
"""Feedback categories"""
|
| 24 |
+
ACCURACY = "accuracy"
|
| 25 |
+
HELPFULNESS = "helpfulness"
|
| 26 |
+
TONE = "tone"
|
| 27 |
+
COMPLETENESS = "completeness"
|
| 28 |
+
SAFETY = "safety"
|
| 29 |
+
OTHER = "other"
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class FeedbackCollector:
|
| 33 |
+
"""Collect user feedback on agent responses"""
|
| 34 |
+
|
| 35 |
+
def __init__(self, storage_dir: str = "feedback/data"):
|
| 36 |
+
self.storage_dir = Path(storage_dir)
|
| 37 |
+
self.storage_dir.mkdir(parents=True, exist_ok=True)
|
| 38 |
+
|
| 39 |
+
# Create subdirectories
|
| 40 |
+
(self.storage_dir / "ratings").mkdir(exist_ok=True)
|
| 41 |
+
(self.storage_dir / "corrections").mkdir(exist_ok=True)
|
| 42 |
+
(self.storage_dir / "reports").mkdir(exist_ok=True)
|
| 43 |
+
|
| 44 |
+
def collect_rating(
|
| 45 |
+
self,
|
| 46 |
+
user_id: str,
|
| 47 |
+
agent_name: str,
|
| 48 |
+
user_message: str,
|
| 49 |
+
agent_response: str,
|
| 50 |
+
rating: int,
|
| 51 |
+
category: Optional[FeedbackCategory] = None,
|
| 52 |
+
comment: Optional[str] = None,
|
| 53 |
+
metadata: Optional[Dict[str, Any]] = None
|
| 54 |
+
) -> str:
|
| 55 |
+
"""
|
| 56 |
+
Collect user rating for an agent response
|
| 57 |
+
|
| 58 |
+
Args:
|
| 59 |
+
user_id: User identifier
|
| 60 |
+
agent_name: Name of the agent
|
| 61 |
+
user_message: User's original message
|
| 62 |
+
agent_response: Agent's response
|
| 63 |
+
rating: Rating (1-5 stars)
|
| 64 |
+
category: Feedback category
|
| 65 |
+
comment: Optional user comment
|
| 66 |
+
metadata: Additional metadata
|
| 67 |
+
|
| 68 |
+
Returns:
|
| 69 |
+
Feedback ID
|
| 70 |
+
"""
|
| 71 |
+
feedback_id = f"{user_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
| 72 |
+
|
| 73 |
+
feedback_data = {
|
| 74 |
+
'feedback_id': feedback_id,
|
| 75 |
+
'user_id': user_id,
|
| 76 |
+
'agent_name': agent_name,
|
| 77 |
+
'feedback_type': FeedbackType.RATING,
|
| 78 |
+
'rating': rating,
|
| 79 |
+
'category': category.value if category else None,
|
| 80 |
+
'user_message': user_message,
|
| 81 |
+
'agent_response': agent_response,
|
| 82 |
+
'comment': comment,
|
| 83 |
+
'metadata': metadata or {},
|
| 84 |
+
'timestamp': datetime.now().isoformat()
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
# Save to file
|
| 88 |
+
file_path = self.storage_dir / "ratings" / f"{feedback_id}.json"
|
| 89 |
+
with open(file_path, 'w', encoding='utf-8') as f:
|
| 90 |
+
json.dump(feedback_data, f, ensure_ascii=False, indent=2)
|
| 91 |
+
|
| 92 |
+
return feedback_id
|
| 93 |
+
|
| 94 |
+
def collect_correction(
|
| 95 |
+
self,
|
| 96 |
+
user_id: str,
|
| 97 |
+
agent_name: str,
|
| 98 |
+
user_message: str,
|
| 99 |
+
agent_response: str,
|
| 100 |
+
corrected_response: str,
|
| 101 |
+
correction_reason: str,
|
| 102 |
+
metadata: Optional[Dict[str, Any]] = None
|
| 103 |
+
) -> str:
|
| 104 |
+
"""
|
| 105 |
+
Collect user correction for an agent response
|
| 106 |
+
|
| 107 |
+
Args:
|
| 108 |
+
user_id: User identifier
|
| 109 |
+
agent_name: Name of the agent
|
| 110 |
+
user_message: User's original message
|
| 111 |
+
agent_response: Agent's incorrect response
|
| 112 |
+
corrected_response: User's corrected response
|
| 113 |
+
correction_reason: Why the correction was needed
|
| 114 |
+
metadata: Additional metadata
|
| 115 |
+
|
| 116 |
+
Returns:
|
| 117 |
+
Feedback ID
|
| 118 |
+
"""
|
| 119 |
+
feedback_id = f"{user_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
| 120 |
+
|
| 121 |
+
feedback_data = {
|
| 122 |
+
'feedback_id': feedback_id,
|
| 123 |
+
'user_id': user_id,
|
| 124 |
+
'agent_name': agent_name,
|
| 125 |
+
'feedback_type': FeedbackType.CORRECTION,
|
| 126 |
+
'user_message': user_message,
|
| 127 |
+
'agent_response': agent_response,
|
| 128 |
+
'corrected_response': corrected_response,
|
| 129 |
+
'correction_reason': correction_reason,
|
| 130 |
+
'metadata': metadata or {},
|
| 131 |
+
'timestamp': datetime.now().isoformat()
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
# Save to file
|
| 135 |
+
file_path = self.storage_dir / "corrections" / f"{feedback_id}.json"
|
| 136 |
+
with open(file_path, 'w', encoding='utf-8') as f:
|
| 137 |
+
json.dump(feedback_data, f, ensure_ascii=False, indent=2)
|
| 138 |
+
|
| 139 |
+
return feedback_id
|
| 140 |
+
|
| 141 |
+
def collect_thumbs(
|
| 142 |
+
self,
|
| 143 |
+
user_id: str,
|
| 144 |
+
agent_name: str,
|
| 145 |
+
user_message: str,
|
| 146 |
+
agent_response: str,
|
| 147 |
+
is_positive: bool,
|
| 148 |
+
comment: Optional[str] = None
|
| 149 |
+
) -> str:
|
| 150 |
+
"""
|
| 151 |
+
Collect thumbs up/down feedback
|
| 152 |
+
|
| 153 |
+
Args:
|
| 154 |
+
user_id: User identifier
|
| 155 |
+
agent_name: Name of the agent
|
| 156 |
+
user_message: User's original message
|
| 157 |
+
agent_response: Agent's response
|
| 158 |
+
is_positive: True for thumbs up, False for thumbs down
|
| 159 |
+
comment: Optional comment
|
| 160 |
+
|
| 161 |
+
Returns:
|
| 162 |
+
Feedback ID
|
| 163 |
+
"""
|
| 164 |
+
feedback_type = FeedbackType.THUMBS_UP if is_positive else FeedbackType.THUMBS_DOWN
|
| 165 |
+
|
| 166 |
+
return self.collect_rating(
|
| 167 |
+
user_id=user_id,
|
| 168 |
+
agent_name=agent_name,
|
| 169 |
+
user_message=user_message,
|
| 170 |
+
agent_response=agent_response,
|
| 171 |
+
rating=5 if is_positive else 1,
|
| 172 |
+
comment=comment,
|
| 173 |
+
metadata={'feedback_type': feedback_type}
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
def report_issue(
|
| 177 |
+
self,
|
| 178 |
+
user_id: str,
|
| 179 |
+
agent_name: str,
|
| 180 |
+
user_message: str,
|
| 181 |
+
agent_response: str,
|
| 182 |
+
issue_type: str,
|
| 183 |
+
description: str,
|
| 184 |
+
severity: str = "medium"
|
| 185 |
+
) -> str:
|
| 186 |
+
"""
|
| 187 |
+
Report an issue with agent response
|
| 188 |
+
|
| 189 |
+
Args:
|
| 190 |
+
user_id: User identifier
|
| 191 |
+
agent_name: Name of the agent
|
| 192 |
+
user_message: User's original message
|
| 193 |
+
agent_response: Agent's problematic response
|
| 194 |
+
issue_type: Type of issue (harmful/incorrect/inappropriate/other)
|
| 195 |
+
description: Detailed description
|
| 196 |
+
severity: low/medium/high/critical
|
| 197 |
+
|
| 198 |
+
Returns:
|
| 199 |
+
Report ID
|
| 200 |
+
"""
|
| 201 |
+
report_id = f"report_{user_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
| 202 |
+
|
| 203 |
+
report_data = {
|
| 204 |
+
'report_id': report_id,
|
| 205 |
+
'user_id': user_id,
|
| 206 |
+
'agent_name': agent_name,
|
| 207 |
+
'feedback_type': FeedbackType.REPORT,
|
| 208 |
+
'user_message': user_message,
|
| 209 |
+
'agent_response': agent_response,
|
| 210 |
+
'issue_type': issue_type,
|
| 211 |
+
'description': description,
|
| 212 |
+
'severity': severity,
|
| 213 |
+
'status': 'pending',
|
| 214 |
+
'timestamp': datetime.now().isoformat()
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
# Save to file
|
| 218 |
+
file_path = self.storage_dir / "reports" / f"{report_id}.json"
|
| 219 |
+
with open(file_path, 'w', encoding='utf-8') as f:
|
| 220 |
+
json.dump(report_data, f, ensure_ascii=False, indent=2)
|
| 221 |
+
|
| 222 |
+
return report_id
|
| 223 |
+
|
| 224 |
+
def get_feedback_stats(self, agent_name: Optional[str] = None) -> Dict[str, Any]:
|
| 225 |
+
"""
|
| 226 |
+
Get feedback statistics
|
| 227 |
+
|
| 228 |
+
Args:
|
| 229 |
+
agent_name: Filter by agent name (optional)
|
| 230 |
+
|
| 231 |
+
Returns:
|
| 232 |
+
Statistics dictionary
|
| 233 |
+
"""
|
| 234 |
+
stats = {
|
| 235 |
+
'total_ratings': 0,
|
| 236 |
+
'total_corrections': 0,
|
| 237 |
+
'total_reports': 0,
|
| 238 |
+
'average_rating': 0.0,
|
| 239 |
+
'rating_distribution': {1: 0, 2: 0, 3: 0, 4: 0, 5: 0},
|
| 240 |
+
'by_agent': {},
|
| 241 |
+
'by_category': {}
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
# Count ratings
|
| 245 |
+
ratings = []
|
| 246 |
+
for file_path in (self.storage_dir / "ratings").glob("*.json"):
|
| 247 |
+
with open(file_path, 'r', encoding='utf-8') as f:
|
| 248 |
+
data = json.load(f)
|
| 249 |
+
|
| 250 |
+
if agent_name and data.get('agent_name') != agent_name:
|
| 251 |
+
continue
|
| 252 |
+
|
| 253 |
+
rating = data.get('rating', 0)
|
| 254 |
+
ratings.append(rating)
|
| 255 |
+
stats['rating_distribution'][rating] += 1
|
| 256 |
+
|
| 257 |
+
# By agent
|
| 258 |
+
agent = data.get('agent_name', 'unknown')
|
| 259 |
+
if agent not in stats['by_agent']:
|
| 260 |
+
stats['by_agent'][agent] = {'count': 0, 'total_rating': 0}
|
| 261 |
+
stats['by_agent'][agent]['count'] += 1
|
| 262 |
+
stats['by_agent'][agent]['total_rating'] += rating
|
| 263 |
+
|
| 264 |
+
# By category
|
| 265 |
+
category = data.get('category', 'other')
|
| 266 |
+
if category not in stats['by_category']:
|
| 267 |
+
stats['by_category'][category] = 0
|
| 268 |
+
stats['by_category'][category] += 1
|
| 269 |
+
|
| 270 |
+
stats['total_ratings'] = len(ratings)
|
| 271 |
+
stats['average_rating'] = sum(ratings) / len(ratings) if ratings else 0.0
|
| 272 |
+
|
| 273 |
+
# Calculate average per agent
|
| 274 |
+
for agent in stats['by_agent']:
|
| 275 |
+
count = stats['by_agent'][agent]['count']
|
| 276 |
+
total = stats['by_agent'][agent]['total_rating']
|
| 277 |
+
stats['by_agent'][agent]['average'] = total / count if count > 0 else 0.0
|
| 278 |
+
|
| 279 |
+
# Count corrections
|
| 280 |
+
stats['total_corrections'] = len(list((self.storage_dir / "corrections").glob("*.json")))
|
| 281 |
+
|
| 282 |
+
# Count reports
|
| 283 |
+
stats['total_reports'] = len(list((self.storage_dir / "reports").glob("*.json")))
|
| 284 |
+
|
| 285 |
+
return stats
|
| 286 |
+
|
| 287 |
+
def get_low_rated_responses(
|
| 288 |
+
self,
|
| 289 |
+
min_rating: int = 2,
|
| 290 |
+
agent_name: Optional[str] = None,
|
| 291 |
+
limit: int = 50
|
| 292 |
+
) -> List[Dict[str, Any]]:
|
| 293 |
+
"""
|
| 294 |
+
Get low-rated responses for improvement
|
| 295 |
+
|
| 296 |
+
Args:
|
| 297 |
+
min_rating: Maximum rating to include (1-5)
|
| 298 |
+
agent_name: Filter by agent name
|
| 299 |
+
limit: Maximum number of results
|
| 300 |
+
|
| 301 |
+
Returns:
|
| 302 |
+
List of low-rated responses
|
| 303 |
+
"""
|
| 304 |
+
low_rated = []
|
| 305 |
+
|
| 306 |
+
for file_path in (self.storage_dir / "ratings").glob("*.json"):
|
| 307 |
+
with open(file_path, 'r', encoding='utf-8') as f:
|
| 308 |
+
data = json.load(f)
|
| 309 |
+
|
| 310 |
+
if data.get('rating', 5) <= min_rating:
|
| 311 |
+
if agent_name is None or data.get('agent_name') == agent_name:
|
| 312 |
+
low_rated.append(data)
|
| 313 |
+
|
| 314 |
+
# Sort by rating (lowest first)
|
| 315 |
+
low_rated.sort(key=lambda x: x.get('rating', 5))
|
| 316 |
+
|
| 317 |
+
return low_rated[:limit]
|
| 318 |
+
|
| 319 |
+
def get_corrections(
|
| 320 |
+
self,
|
| 321 |
+
agent_name: Optional[str] = None,
|
| 322 |
+
limit: int = 100
|
| 323 |
+
) -> List[Dict[str, Any]]:
|
| 324 |
+
"""
|
| 325 |
+
Get user corrections for learning
|
| 326 |
+
|
| 327 |
+
Args:
|
| 328 |
+
agent_name: Filter by agent name
|
| 329 |
+
limit: Maximum number of results
|
| 330 |
+
|
| 331 |
+
Returns:
|
| 332 |
+
List of corrections
|
| 333 |
+
"""
|
| 334 |
+
corrections = []
|
| 335 |
+
|
| 336 |
+
for file_path in (self.storage_dir / "corrections").glob("*.json"):
|
| 337 |
+
with open(file_path, 'r', encoding='utf-8') as f:
|
| 338 |
+
data = json.load(f)
|
| 339 |
+
|
| 340 |
+
if agent_name is None or data.get('agent_name') == agent_name:
|
| 341 |
+
corrections.append(data)
|
| 342 |
+
|
| 343 |
+
# Sort by timestamp (newest first)
|
| 344 |
+
corrections.sort(key=lambda x: x.get('timestamp', ''), reverse=True)
|
| 345 |
+
|
| 346 |
+
return corrections[:limit]
|
| 347 |
+
|
| 348 |
+
def export_for_fine_tuning(
|
| 349 |
+
self,
|
| 350 |
+
agent_name: str,
|
| 351 |
+
min_rating: int = 4,
|
| 352 |
+
include_corrections: bool = True,
|
| 353 |
+
output_file: Optional[str] = None
|
| 354 |
+
) -> str:
|
| 355 |
+
"""
|
| 356 |
+
Export high-quality feedback for fine-tuning
|
| 357 |
+
|
| 358 |
+
Args:
|
| 359 |
+
agent_name: Agent to export for
|
| 360 |
+
min_rating: Minimum rating to include
|
| 361 |
+
include_corrections: Include user corrections
|
| 362 |
+
output_file: Output file path
|
| 363 |
+
|
| 364 |
+
Returns:
|
| 365 |
+
Path to exported file
|
| 366 |
+
"""
|
| 367 |
+
if output_file is None:
|
| 368 |
+
output_file = f"feedback_training_{agent_name}_{datetime.now().strftime('%Y%m%d')}.jsonl"
|
| 369 |
+
|
| 370 |
+
output_path = self.storage_dir / output_file
|
| 371 |
+
|
| 372 |
+
training_data = []
|
| 373 |
+
|
| 374 |
+
# Add high-rated responses
|
| 375 |
+
for file_path in (self.storage_dir / "ratings").glob("*.json"):
|
| 376 |
+
with open(file_path, 'r', encoding='utf-8') as f:
|
| 377 |
+
data = json.load(f)
|
| 378 |
+
|
| 379 |
+
if data.get('agent_name') == agent_name and data.get('rating', 0) >= min_rating:
|
| 380 |
+
training_data.append({
|
| 381 |
+
'messages': [
|
| 382 |
+
{'role': 'user', 'content': data['user_message']},
|
| 383 |
+
{'role': 'assistant', 'content': data['agent_response']}
|
| 384 |
+
],
|
| 385 |
+
'metadata': {
|
| 386 |
+
'rating': data['rating'],
|
| 387 |
+
'source': 'user_rating'
|
| 388 |
+
}
|
| 389 |
+
})
|
| 390 |
+
|
| 391 |
+
# Add corrections
|
| 392 |
+
if include_corrections:
|
| 393 |
+
for file_path in (self.storage_dir / "corrections").glob("*.json"):
|
| 394 |
+
with open(file_path, 'r', encoding='utf-8') as f:
|
| 395 |
+
data = json.load(f)
|
| 396 |
+
|
| 397 |
+
if data.get('agent_name') == agent_name:
|
| 398 |
+
training_data.append({
|
| 399 |
+
'messages': [
|
| 400 |
+
{'role': 'user', 'content': data['user_message']},
|
| 401 |
+
{'role': 'assistant', 'content': data['corrected_response']}
|
| 402 |
+
],
|
| 403 |
+
'metadata': {
|
| 404 |
+
'source': 'user_correction',
|
| 405 |
+
'reason': data.get('correction_reason')
|
| 406 |
+
}
|
| 407 |
+
})
|
| 408 |
+
|
| 409 |
+
# Write to JSONL
|
| 410 |
+
with open(output_path, 'w', encoding='utf-8') as f:
|
| 411 |
+
for item in training_data:
|
| 412 |
+
f.write(json.dumps(item, ensure_ascii=False) + '\n')
|
| 413 |
+
|
| 414 |
+
return str(output_path)
|
| 415 |
+
|
| 416 |
+
|
| 417 |
+
# Global instance
|
| 418 |
+
_feedback_collector = None
|
| 419 |
+
|
| 420 |
+
def get_feedback_collector() -> FeedbackCollector:
|
| 421 |
+
"""Get global feedback collector instance"""
|
| 422 |
+
global _feedback_collector
|
| 423 |
+
if _feedback_collector is None:
|
| 424 |
+
_feedback_collector = FeedbackCollector()
|
| 425 |
+
return _feedback_collector
|
fine_tuning/README.md
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Fine-tuning Module 🎯
|
| 2 |
+
|
| 3 |
+
Train custom models on your healthcare conversation data.
|
| 4 |
+
|
| 5 |
+
## Overview
|
| 6 |
+
|
| 7 |
+
This module automatically collects conversation data and enables fine-tuning of specialized healthcare agents using OpenAI's fine-tuning API.
|
| 8 |
+
|
| 9 |
+
## Features
|
| 10 |
+
|
| 11 |
+
- ✅ **Automatic Data Collection** - Logs all agent conversations
|
| 12 |
+
- ✅ **Quality Filtering** - Filter by user ratings
|
| 13 |
+
- ✅ **OpenAI Format Export** - Ready for fine-tuning
|
| 14 |
+
- ✅ **Multi-Agent Support** - Train each agent separately
|
| 15 |
+
- ✅ **Job Management** - Track fine-tuning progress
|
| 16 |
+
|
| 17 |
+
## How It Works
|
| 18 |
+
|
| 19 |
+
### 1. Data Collection (Automatic)
|
| 20 |
+
|
| 21 |
+
The system automatically logs conversations when enabled:
|
| 22 |
+
|
| 23 |
+
```python
|
| 24 |
+
# In coordinator (already integrated)
|
| 25 |
+
coordinator = AgentCoordinator(enable_data_collection=True)
|
| 26 |
+
```
|
| 27 |
+
|
| 28 |
+
Data is stored in `fine_tuning/data/` organized by agent:
|
| 29 |
+
```
|
| 30 |
+
fine_tuning/data/
|
| 31 |
+
├── nutrition/
|
| 32 |
+
│ ├── conversations_20241025.jsonl
|
| 33 |
+
│ └── multi_turn_20241025.jsonl
|
| 34 |
+
├── exercise/
|
| 35 |
+
├── symptom/
|
| 36 |
+
├── mental_health/
|
| 37 |
+
└── general_health/
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
### 2. Export Training Data
|
| 41 |
+
|
| 42 |
+
Export conversations in OpenAI fine-tuning format:
|
| 43 |
+
|
| 44 |
+
```python
|
| 45 |
+
from fine_tuning import get_data_collector
|
| 46 |
+
|
| 47 |
+
collector = get_data_collector()
|
| 48 |
+
|
| 49 |
+
# Export all conversations
|
| 50 |
+
training_file = collector.export_for_openai_finetuning(
|
| 51 |
+
agent_name='nutrition_agent',
|
| 52 |
+
output_file='nutrition_training.jsonl'
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
# Export only high-quality conversations (rating >= 4.0)
|
| 56 |
+
training_file = collector.export_for_openai_finetuning(
|
| 57 |
+
agent_name='nutrition_agent',
|
| 58 |
+
min_quality_rating=4.0
|
| 59 |
+
)
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
### 3. Fine-tune Agent
|
| 63 |
+
|
| 64 |
+
#### Option A: Using Script (Recommended)
|
| 65 |
+
|
| 66 |
+
```bash
|
| 67 |
+
# Fine-tune nutrition agent
|
| 68 |
+
python scripts/fine_tune_agent.py --agent nutrition
|
| 69 |
+
|
| 70 |
+
# With quality filtering
|
| 71 |
+
python scripts/fine_tune_agent.py --agent nutrition --min-rating 4.0
|
| 72 |
+
|
| 73 |
+
# Start job without waiting
|
| 74 |
+
python scripts/fine_tune_agent.py --agent exercise --no-wait
|
| 75 |
+
```
|
| 76 |
+
|
| 77 |
+
#### Option B: Using Python API
|
| 78 |
+
|
| 79 |
+
```python
|
| 80 |
+
from fine_tuning import fine_tune_agent
|
| 81 |
+
|
| 82 |
+
# Fine-tune and wait for completion
|
| 83 |
+
model_id = fine_tune_agent(
|
| 84 |
+
agent_name='nutrition',
|
| 85 |
+
training_file='nutrition_training.jsonl',
|
| 86 |
+
model='gpt-4o-mini-2024-07-18',
|
| 87 |
+
wait_for_completion=True
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
print(f"Fine-tuned model: {model_id}")
|
| 91 |
+
```
|
| 92 |
+
|
| 93 |
+
### 4. Use Fine-tuned Model
|
| 94 |
+
|
| 95 |
+
Update agent configuration to use the fine-tuned model:
|
| 96 |
+
|
| 97 |
+
```python
|
| 98 |
+
# config/settings.py or agent file
|
| 99 |
+
MODEL = 'ft:gpt-4o-mini-2024-07-18:your-org:nutrition:abc123'
|
| 100 |
+
```
|
| 101 |
+
|
| 102 |
+
## Data Format
|
| 103 |
+
|
| 104 |
+
### Conversation Entry
|
| 105 |
+
```json
|
| 106 |
+
{
|
| 107 |
+
"timestamp": "2024-10-25T10:00:00",
|
| 108 |
+
"agent": "nutrition_agent",
|
| 109 |
+
"user_message": "Tôi muốn giảm cân",
|
| 110 |
+
"agent_response": "Để giảm cân hiệu quả...",
|
| 111 |
+
"user_data": {
|
| 112 |
+
"age": 25,
|
| 113 |
+
"gender": "male",
|
| 114 |
+
"weight": 70,
|
| 115 |
+
"height": 175
|
| 116 |
+
},
|
| 117 |
+
"metadata": {
|
| 118 |
+
"rating": 5.0,
|
| 119 |
+
"feedback": "Very helpful"
|
| 120 |
+
}
|
| 121 |
+
}
|
| 122 |
+
```
|
| 123 |
+
|
| 124 |
+
### OpenAI Fine-tuning Format
|
| 125 |
+
```json
|
| 126 |
+
{
|
| 127 |
+
"messages": [
|
| 128 |
+
{"role": "system", "content": "You are a nutrition specialist."},
|
| 129 |
+
{"role": "user", "content": "Tôi muốn giảm cân"},
|
| 130 |
+
{"role": "assistant", "content": "Để giảm cân hiệu quả..."}
|
| 131 |
+
]
|
| 132 |
+
}
|
| 133 |
+
```
|
| 134 |
+
|
| 135 |
+
## API Reference
|
| 136 |
+
|
| 137 |
+
### ConversationDataCollector
|
| 138 |
+
|
| 139 |
+
```python
|
| 140 |
+
from fine_tuning import get_data_collector
|
| 141 |
+
|
| 142 |
+
collector = get_data_collector()
|
| 143 |
+
|
| 144 |
+
# Log single conversation
|
| 145 |
+
collector.log_conversation(
|
| 146 |
+
agent_name='nutrition_agent',
|
| 147 |
+
user_message='User question',
|
| 148 |
+
agent_response='Agent answer',
|
| 149 |
+
user_data={'age': 25},
|
| 150 |
+
metadata={'rating': 5.0}
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
# Log multi-turn conversation
|
| 154 |
+
collector.log_multi_turn_conversation(
|
| 155 |
+
agent_name='nutrition_agent',
|
| 156 |
+
conversation_history=[
|
| 157 |
+
('User msg 1', 'Agent response 1'),
|
| 158 |
+
('User msg 2', 'Agent response 2')
|
| 159 |
+
],
|
| 160 |
+
user_data={'age': 25}
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
# Get conversation counts
|
| 164 |
+
counts = collector.get_conversation_count()
|
| 165 |
+
# {'nutrition': 150, 'exercise': 89, ...}
|
| 166 |
+
|
| 167 |
+
# Export for fine-tuning
|
| 168 |
+
training_file = collector.export_for_openai_finetuning(
|
| 169 |
+
agent_name='nutrition_agent',
|
| 170 |
+
min_quality_rating=4.0
|
| 171 |
+
)
|
| 172 |
+
```
|
| 173 |
+
|
| 174 |
+
### FineTuningTrainer
|
| 175 |
+
|
| 176 |
+
```python
|
| 177 |
+
from fine_tuning import FineTuningTrainer
|
| 178 |
+
|
| 179 |
+
trainer = FineTuningTrainer()
|
| 180 |
+
|
| 181 |
+
# Upload training file
|
| 182 |
+
file_id = trainer.upload_training_file('training.jsonl')
|
| 183 |
+
|
| 184 |
+
# Create fine-tuning job
|
| 185 |
+
job_id = trainer.create_fine_tuning_job(
|
| 186 |
+
training_file_id=file_id,
|
| 187 |
+
model='gpt-4o-mini-2024-07-18',
|
| 188 |
+
suffix='nutrition-v1'
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
# Check job status
|
| 192 |
+
status = trainer.check_job_status(job_id)
|
| 193 |
+
|
| 194 |
+
# Wait for completion
|
| 195 |
+
result = trainer.wait_for_completion(job_id)
|
| 196 |
+
|
| 197 |
+
# List all fine-tuned models
|
| 198 |
+
models = trainer.list_fine_tuned_models()
|
| 199 |
+
```
|
| 200 |
+
|
| 201 |
+
## Best Practices
|
| 202 |
+
|
| 203 |
+
### 1. Data Quality
|
| 204 |
+
|
| 205 |
+
- ✅ Collect at least **100-200 conversations** per agent
|
| 206 |
+
- ✅ Include diverse user queries and scenarios
|
| 207 |
+
- ✅ Filter by quality rating (>= 4.0 recommended)
|
| 208 |
+
- ✅ Review and clean data before fine-tuning
|
| 209 |
+
|
| 210 |
+
### 2. Training
|
| 211 |
+
|
| 212 |
+
- ✅ Start with `gpt-4o-mini` (faster, cheaper)
|
| 213 |
+
- ✅ Use descriptive suffixes (e.g., `nutrition-v1`)
|
| 214 |
+
- ✅ Monitor training progress
|
| 215 |
+
- ✅ Test fine-tuned model before deployment
|
| 216 |
+
|
| 217 |
+
### 3. Evaluation
|
| 218 |
+
|
| 219 |
+
- ✅ Compare fine-tuned vs base model responses
|
| 220 |
+
- ✅ Test on held-out conversations
|
| 221 |
+
- ✅ Collect user feedback on fine-tuned model
|
| 222 |
+
- ✅ Iterate based on results
|
| 223 |
+
|
| 224 |
+
## Cost Estimation
|
| 225 |
+
|
| 226 |
+
Fine-tuning costs (OpenAI pricing):
|
| 227 |
+
- **Training**: ~$0.008 per 1K tokens
|
| 228 |
+
- **Usage**: Same as base model
|
| 229 |
+
|
| 230 |
+
Example:
|
| 231 |
+
- 200 conversations × 500 tokens avg = 100K tokens
|
| 232 |
+
- Training cost: ~$0.80
|
| 233 |
+
- Usage: Same as gpt-4o-mini
|
| 234 |
+
|
| 235 |
+
## Troubleshooting
|
| 236 |
+
|
| 237 |
+
### No conversations collected
|
| 238 |
+
|
| 239 |
+
```bash
|
| 240 |
+
# Check if data collection is enabled
|
| 241 |
+
# In coordinator initialization:
|
| 242 |
+
coordinator = AgentCoordinator(enable_data_collection=True)
|
| 243 |
+
```
|
| 244 |
+
|
| 245 |
+
### Training file format error
|
| 246 |
+
|
| 247 |
+
```bash
|
| 248 |
+
# Validate JSONL format
|
| 249 |
+
python -c "import json; [json.loads(line) for line in open('file.jsonl')]"
|
| 250 |
+
```
|
| 251 |
+
|
| 252 |
+
### Fine-tuning job failed
|
| 253 |
+
|
| 254 |
+
```python
|
| 255 |
+
# Check job status
|
| 256 |
+
trainer = FineTuningTrainer()
|
| 257 |
+
status = trainer.check_job_status('job-id')
|
| 258 |
+
print(status)
|
| 259 |
+
```
|
| 260 |
+
|
| 261 |
+
## Examples
|
| 262 |
+
|
| 263 |
+
### Complete Workflow
|
| 264 |
+
|
| 265 |
+
```python
|
| 266 |
+
from fine_tuning import get_data_collector, fine_tune_agent
|
| 267 |
+
|
| 268 |
+
# 1. Check collected data
|
| 269 |
+
collector = get_data_collector()
|
| 270 |
+
counts = collector.get_conversation_count()
|
| 271 |
+
print(f"Nutrition conversations: {counts.get('nutrition', 0)}")
|
| 272 |
+
|
| 273 |
+
# 2. Export training data
|
| 274 |
+
training_file = collector.export_for_openai_finetuning(
|
| 275 |
+
agent_name='nutrition_agent',
|
| 276 |
+
min_quality_rating=4.0
|
| 277 |
+
)
|
| 278 |
+
|
| 279 |
+
# 3. Fine-tune
|
| 280 |
+
model_id = fine_tune_agent(
|
| 281 |
+
agent_name='nutrition',
|
| 282 |
+
training_file=training_file,
|
| 283 |
+
suffix='v1',
|
| 284 |
+
wait_for_completion=True
|
| 285 |
+
)
|
| 286 |
+
|
| 287 |
+
# 4. Update configuration
|
| 288 |
+
print(f"Update MODEL to: {model_id}")
|
| 289 |
+
```
|
| 290 |
+
|
| 291 |
+
## Future Enhancements
|
| 292 |
+
|
| 293 |
+
- [ ] Automatic quality scoring
|
| 294 |
+
- [ ] Data augmentation
|
| 295 |
+
- [ ] Multi-model comparison
|
| 296 |
+
- [ ] A/B testing framework
|
| 297 |
+
- [ ] Continuous learning pipeline
|
fine_tuning/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Fine-tuning Module
|
| 3 |
+
Collect conversation data and train custom models
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from .data_collector import ConversationDataCollector, get_data_collector
|
| 7 |
+
from .trainer import FineTuningTrainer, fine_tune_agent
|
| 8 |
+
|
| 9 |
+
__all__ = [
|
| 10 |
+
'ConversationDataCollector',
|
| 11 |
+
'get_data_collector',
|
| 12 |
+
'FineTuningTrainer',
|
| 13 |
+
'fine_tune_agent'
|
| 14 |
+
]
|
fine_tuning/data_collector.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Data Collector for Fine-tuning
|
| 3 |
+
Collects and stores conversation data for training custom models
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import json
|
| 7 |
+
import os
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import List, Dict, Any, Optional
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class ConversationDataCollector:
|
| 14 |
+
"""Collects conversation data for fine-tuning"""
|
| 15 |
+
|
| 16 |
+
def __init__(self, data_dir: str = "fine_tuning/data"):
|
| 17 |
+
self.data_dir = Path(data_dir)
|
| 18 |
+
self.data_dir.mkdir(parents=True, exist_ok=True)
|
| 19 |
+
|
| 20 |
+
# Create subdirectories for each agent
|
| 21 |
+
self.agent_dirs = {
|
| 22 |
+
'nutrition': self.data_dir / 'nutrition',
|
| 23 |
+
'exercise': self.data_dir / 'exercise',
|
| 24 |
+
'symptom': self.data_dir / 'symptom',
|
| 25 |
+
'mental_health': self.data_dir / 'mental_health',
|
| 26 |
+
'general_health': self.data_dir / 'general_health'
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
for agent_dir in self.agent_dirs.values():
|
| 30 |
+
agent_dir.mkdir(exist_ok=True)
|
| 31 |
+
|
| 32 |
+
def log_conversation(
|
| 33 |
+
self,
|
| 34 |
+
agent_name: str,
|
| 35 |
+
user_message: str,
|
| 36 |
+
agent_response: str,
|
| 37 |
+
user_data: Optional[Dict[str, Any]] = None,
|
| 38 |
+
metadata: Optional[Dict[str, Any]] = None
|
| 39 |
+
) -> None:
|
| 40 |
+
"""
|
| 41 |
+
Log a conversation turn for fine-tuning
|
| 42 |
+
|
| 43 |
+
Args:
|
| 44 |
+
agent_name: Name of the agent (nutrition, exercise, etc.)
|
| 45 |
+
user_message: User's message
|
| 46 |
+
agent_response: Agent's response
|
| 47 |
+
user_data: User profile data (age, gender, etc.)
|
| 48 |
+
metadata: Additional metadata (rating, feedback, etc.)
|
| 49 |
+
"""
|
| 50 |
+
conversation_entry = {
|
| 51 |
+
'timestamp': datetime.now().isoformat(),
|
| 52 |
+
'agent': agent_name,
|
| 53 |
+
'user_message': user_message,
|
| 54 |
+
'agent_response': agent_response,
|
| 55 |
+
'user_data': user_data or {},
|
| 56 |
+
'metadata': metadata or {}
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
# Save to agent-specific file
|
| 60 |
+
agent_key = agent_name.replace('_agent', '')
|
| 61 |
+
if agent_key in self.agent_dirs:
|
| 62 |
+
filename = f"conversations_{datetime.now().strftime('%Y%m%d')}.jsonl"
|
| 63 |
+
filepath = self.agent_dirs[agent_key] / filename
|
| 64 |
+
|
| 65 |
+
with open(filepath, 'a', encoding='utf-8') as f:
|
| 66 |
+
f.write(json.dumps(conversation_entry, ensure_ascii=False) + '\n')
|
| 67 |
+
|
| 68 |
+
def log_multi_turn_conversation(
|
| 69 |
+
self,
|
| 70 |
+
agent_name: str,
|
| 71 |
+
conversation_history: List[tuple],
|
| 72 |
+
user_data: Optional[Dict[str, Any]] = None,
|
| 73 |
+
metadata: Optional[Dict[str, Any]] = None
|
| 74 |
+
) -> None:
|
| 75 |
+
"""
|
| 76 |
+
Log a multi-turn conversation
|
| 77 |
+
|
| 78 |
+
Args:
|
| 79 |
+
agent_name: Name of the agent
|
| 80 |
+
conversation_history: List of (user_msg, agent_msg) tuples
|
| 81 |
+
user_data: User profile data
|
| 82 |
+
metadata: Additional metadata
|
| 83 |
+
"""
|
| 84 |
+
multi_turn_entry = {
|
| 85 |
+
'timestamp': datetime.now().isoformat(),
|
| 86 |
+
'agent': agent_name,
|
| 87 |
+
'conversation': [
|
| 88 |
+
{'user': user_msg, 'agent': agent_msg}
|
| 89 |
+
for user_msg, agent_msg in conversation_history
|
| 90 |
+
],
|
| 91 |
+
'user_data': user_data or {},
|
| 92 |
+
'metadata': metadata or {}
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
agent_key = agent_name.replace('_agent', '')
|
| 96 |
+
if agent_key in self.agent_dirs:
|
| 97 |
+
filename = f"multi_turn_{datetime.now().strftime('%Y%m%d')}.jsonl"
|
| 98 |
+
filepath = self.agent_dirs[agent_key] / filename
|
| 99 |
+
|
| 100 |
+
with open(filepath, 'a', encoding='utf-8') as f:
|
| 101 |
+
f.write(json.dumps(multi_turn_entry, ensure_ascii=False) + '\n')
|
| 102 |
+
|
| 103 |
+
def get_conversation_count(self, agent_name: Optional[str] = None) -> Dict[str, int]:
|
| 104 |
+
"""
|
| 105 |
+
Get count of logged conversations
|
| 106 |
+
|
| 107 |
+
Args:
|
| 108 |
+
agent_name: Optional agent name to filter by
|
| 109 |
+
|
| 110 |
+
Returns:
|
| 111 |
+
Dict with agent names and conversation counts
|
| 112 |
+
"""
|
| 113 |
+
counts = {}
|
| 114 |
+
|
| 115 |
+
agents_to_check = [agent_name.replace('_agent', '')] if agent_name else self.agent_dirs.keys()
|
| 116 |
+
|
| 117 |
+
for agent_key in agents_to_check:
|
| 118 |
+
if agent_key in self.agent_dirs:
|
| 119 |
+
agent_dir = self.agent_dirs[agent_key]
|
| 120 |
+
count = 0
|
| 121 |
+
|
| 122 |
+
for file in agent_dir.glob('conversations_*.jsonl'):
|
| 123 |
+
with open(file, 'r', encoding='utf-8') as f:
|
| 124 |
+
count += sum(1 for _ in f)
|
| 125 |
+
|
| 126 |
+
counts[agent_key] = count
|
| 127 |
+
|
| 128 |
+
return counts
|
| 129 |
+
|
| 130 |
+
def export_for_openai_finetuning(
|
| 131 |
+
self,
|
| 132 |
+
agent_name: str,
|
| 133 |
+
output_file: Optional[str] = None,
|
| 134 |
+
min_quality_rating: Optional[float] = None
|
| 135 |
+
) -> str:
|
| 136 |
+
"""
|
| 137 |
+
Export conversations in OpenAI fine-tuning format
|
| 138 |
+
|
| 139 |
+
Args:
|
| 140 |
+
agent_name: Agent to export data for
|
| 141 |
+
output_file: Output file path
|
| 142 |
+
min_quality_rating: Minimum quality rating to include
|
| 143 |
+
|
| 144 |
+
Returns:
|
| 145 |
+
Path to exported file
|
| 146 |
+
"""
|
| 147 |
+
agent_key = agent_name.replace('_agent', '')
|
| 148 |
+
if agent_key not in self.agent_dirs:
|
| 149 |
+
raise ValueError(f"Unknown agent: {agent_name}")
|
| 150 |
+
|
| 151 |
+
if output_file is None:
|
| 152 |
+
output_file = self.data_dir / f"{agent_key}_finetuning_{datetime.now().strftime('%Y%m%d')}.jsonl"
|
| 153 |
+
|
| 154 |
+
agent_dir = self.agent_dirs[agent_key]
|
| 155 |
+
exported_count = 0
|
| 156 |
+
|
| 157 |
+
with open(output_file, 'w', encoding='utf-8') as out_f:
|
| 158 |
+
# Process single-turn conversations
|
| 159 |
+
for file in agent_dir.glob('conversations_*.jsonl'):
|
| 160 |
+
with open(file, 'r', encoding='utf-8') as in_f:
|
| 161 |
+
for line in in_f:
|
| 162 |
+
entry = json.loads(line)
|
| 163 |
+
|
| 164 |
+
# Filter by quality rating if specified
|
| 165 |
+
if min_quality_rating:
|
| 166 |
+
rating = entry.get('metadata', {}).get('rating')
|
| 167 |
+
if rating is None or rating < min_quality_rating:
|
| 168 |
+
continue
|
| 169 |
+
|
| 170 |
+
# Convert to OpenAI format
|
| 171 |
+
openai_format = {
|
| 172 |
+
"messages": [
|
| 173 |
+
{"role": "system", "content": f"You are a {agent_key} specialist."},
|
| 174 |
+
{"role": "user", "content": entry['user_message']},
|
| 175 |
+
{"role": "assistant", "content": entry['agent_response']}
|
| 176 |
+
]
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
out_f.write(json.dumps(openai_format, ensure_ascii=False) + '\n')
|
| 180 |
+
exported_count += 1
|
| 181 |
+
|
| 182 |
+
# Process multi-turn conversations
|
| 183 |
+
for file in agent_dir.glob('multi_turn_*.jsonl'):
|
| 184 |
+
with open(file, 'r', encoding='utf-8') as in_f:
|
| 185 |
+
for line in in_f:
|
| 186 |
+
entry = json.loads(line)
|
| 187 |
+
|
| 188 |
+
# Filter by quality rating if specified
|
| 189 |
+
if min_quality_rating:
|
| 190 |
+
rating = entry.get('metadata', {}).get('rating')
|
| 191 |
+
if rating is None or rating < min_quality_rating:
|
| 192 |
+
continue
|
| 193 |
+
|
| 194 |
+
# Convert to OpenAI format
|
| 195 |
+
messages = [{"role": "system", "content": f"You are a {agent_key} specialist."}]
|
| 196 |
+
|
| 197 |
+
for turn in entry['conversation']:
|
| 198 |
+
messages.append({"role": "user", "content": turn['user']})
|
| 199 |
+
messages.append({"role": "assistant", "content": turn['agent']})
|
| 200 |
+
|
| 201 |
+
openai_format = {"messages": messages}
|
| 202 |
+
out_f.write(json.dumps(openai_format, ensure_ascii=False) + '\n')
|
| 203 |
+
exported_count += 1
|
| 204 |
+
|
| 205 |
+
print(f"✅ Exported {exported_count} conversations to {output_file}")
|
| 206 |
+
return str(output_file)
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
# Global instance
|
| 210 |
+
_collector = None
|
| 211 |
+
|
| 212 |
+
def get_data_collector() -> ConversationDataCollector:
|
| 213 |
+
"""Get global data collector instance"""
|
| 214 |
+
global _collector
|
| 215 |
+
if _collector is None:
|
| 216 |
+
_collector = ConversationDataCollector()
|
| 217 |
+
return _collector
|
fine_tuning/trainer.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Fine-tuning Trainer
|
| 3 |
+
Handles the fine-tuning process with OpenAI API
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
import time
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Optional, Dict, Any
|
| 10 |
+
from openai import OpenAI
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class FineTuningTrainer:
|
| 14 |
+
"""Manages fine-tuning jobs with OpenAI"""
|
| 15 |
+
|
| 16 |
+
def __init__(self, api_key: Optional[str] = None):
|
| 17 |
+
self.client = OpenAI(api_key=api_key or os.getenv('OPENAI_API_KEY'))
|
| 18 |
+
self.jobs_dir = Path('fine_tuning/jobs')
|
| 19 |
+
self.jobs_dir.mkdir(parents=True, exist_ok=True)
|
| 20 |
+
|
| 21 |
+
def upload_training_file(self, file_path: str) -> str:
|
| 22 |
+
"""
|
| 23 |
+
Upload training file to OpenAI
|
| 24 |
+
|
| 25 |
+
Args:
|
| 26 |
+
file_path: Path to training data file (JSONL format)
|
| 27 |
+
|
| 28 |
+
Returns:
|
| 29 |
+
File ID from OpenAI
|
| 30 |
+
"""
|
| 31 |
+
print(f"📤 Uploading training file: {file_path}")
|
| 32 |
+
|
| 33 |
+
with open(file_path, 'rb') as f:
|
| 34 |
+
response = self.client.files.create(
|
| 35 |
+
file=f,
|
| 36 |
+
purpose='fine-tune'
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
file_id = response.id
|
| 40 |
+
print(f"✅ File uploaded successfully: {file_id}")
|
| 41 |
+
return file_id
|
| 42 |
+
|
| 43 |
+
def create_fine_tuning_job(
|
| 44 |
+
self,
|
| 45 |
+
training_file_id: str,
|
| 46 |
+
model: str = "gpt-4o-mini-2024-07-18",
|
| 47 |
+
suffix: Optional[str] = None,
|
| 48 |
+
hyperparameters: Optional[Dict[str, Any]] = None
|
| 49 |
+
) -> str:
|
| 50 |
+
"""
|
| 51 |
+
Create a fine-tuning job
|
| 52 |
+
|
| 53 |
+
Args:
|
| 54 |
+
training_file_id: ID of uploaded training file
|
| 55 |
+
model: Base model to fine-tune
|
| 56 |
+
suffix: Suffix for fine-tuned model name
|
| 57 |
+
hyperparameters: Training hyperparameters
|
| 58 |
+
|
| 59 |
+
Returns:
|
| 60 |
+
Fine-tuning job ID
|
| 61 |
+
"""
|
| 62 |
+
print(f"🚀 Creating fine-tuning job...")
|
| 63 |
+
print(f" Base model: {model}")
|
| 64 |
+
print(f" Training file: {training_file_id}")
|
| 65 |
+
|
| 66 |
+
job_params = {
|
| 67 |
+
'training_file': training_file_id,
|
| 68 |
+
'model': model
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
if suffix:
|
| 72 |
+
job_params['suffix'] = suffix
|
| 73 |
+
|
| 74 |
+
if hyperparameters:
|
| 75 |
+
job_params['hyperparameters'] = hyperparameters
|
| 76 |
+
|
| 77 |
+
response = self.client.fine_tuning.jobs.create(**job_params)
|
| 78 |
+
|
| 79 |
+
job_id = response.id
|
| 80 |
+
print(f"✅ Fine-tuning job created: {job_id}")
|
| 81 |
+
|
| 82 |
+
# Save job info
|
| 83 |
+
self._save_job_info(job_id, {
|
| 84 |
+
'training_file_id': training_file_id,
|
| 85 |
+
'model': model,
|
| 86 |
+
'suffix': suffix,
|
| 87 |
+
'hyperparameters': hyperparameters,
|
| 88 |
+
'status': 'created'
|
| 89 |
+
})
|
| 90 |
+
|
| 91 |
+
return job_id
|
| 92 |
+
|
| 93 |
+
def check_job_status(self, job_id: str) -> Dict[str, Any]:
|
| 94 |
+
"""
|
| 95 |
+
Check status of fine-tuning job
|
| 96 |
+
|
| 97 |
+
Args:
|
| 98 |
+
job_id: Fine-tuning job ID
|
| 99 |
+
|
| 100 |
+
Returns:
|
| 101 |
+
Job status information
|
| 102 |
+
"""
|
| 103 |
+
response = self.client.fine_tuning.jobs.retrieve(job_id)
|
| 104 |
+
|
| 105 |
+
status_info = {
|
| 106 |
+
'id': response.id,
|
| 107 |
+
'status': response.status,
|
| 108 |
+
'model': response.model,
|
| 109 |
+
'fine_tuned_model': response.fine_tuned_model,
|
| 110 |
+
'created_at': response.created_at,
|
| 111 |
+
'finished_at': response.finished_at,
|
| 112 |
+
'trained_tokens': response.trained_tokens
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
return status_info
|
| 116 |
+
|
| 117 |
+
def wait_for_completion(
|
| 118 |
+
self,
|
| 119 |
+
job_id: str,
|
| 120 |
+
check_interval: int = 60,
|
| 121 |
+
timeout: int = 3600
|
| 122 |
+
) -> Dict[str, Any]:
|
| 123 |
+
"""
|
| 124 |
+
Wait for fine-tuning job to complete
|
| 125 |
+
|
| 126 |
+
Args:
|
| 127 |
+
job_id: Fine-tuning job ID
|
| 128 |
+
check_interval: Seconds between status checks
|
| 129 |
+
timeout: Maximum seconds to wait
|
| 130 |
+
|
| 131 |
+
Returns:
|
| 132 |
+
Final job status
|
| 133 |
+
"""
|
| 134 |
+
print(f"⏳ Waiting for fine-tuning job {job_id} to complete...")
|
| 135 |
+
|
| 136 |
+
start_time = time.time()
|
| 137 |
+
|
| 138 |
+
while True:
|
| 139 |
+
status_info = self.check_job_status(job_id)
|
| 140 |
+
status = status_info['status']
|
| 141 |
+
|
| 142 |
+
print(f" Status: {status}")
|
| 143 |
+
|
| 144 |
+
if status == 'succeeded':
|
| 145 |
+
print(f"✅ Fine-tuning completed!")
|
| 146 |
+
print(f" Fine-tuned model: {status_info['fine_tuned_model']}")
|
| 147 |
+
self._save_job_info(job_id, status_info)
|
| 148 |
+
return status_info
|
| 149 |
+
|
| 150 |
+
elif status in ['failed', 'cancelled']:
|
| 151 |
+
print(f"❌ Fine-tuning {status}")
|
| 152 |
+
self._save_job_info(job_id, status_info)
|
| 153 |
+
raise Exception(f"Fine-tuning job {status}")
|
| 154 |
+
|
| 155 |
+
elif time.time() - start_time > timeout:
|
| 156 |
+
print(f"⏰ Timeout reached")
|
| 157 |
+
raise TimeoutError(f"Fine-tuning job exceeded {timeout} seconds")
|
| 158 |
+
|
| 159 |
+
time.sleep(check_interval)
|
| 160 |
+
|
| 161 |
+
def list_fine_tuned_models(self) -> list:
|
| 162 |
+
"""
|
| 163 |
+
List all fine-tuned models
|
| 164 |
+
|
| 165 |
+
Returns:
|
| 166 |
+
List of fine-tuned model information
|
| 167 |
+
"""
|
| 168 |
+
response = self.client.fine_tuning.jobs.list(limit=50)
|
| 169 |
+
|
| 170 |
+
models = []
|
| 171 |
+
for job in response.data:
|
| 172 |
+
if job.fine_tuned_model:
|
| 173 |
+
models.append({
|
| 174 |
+
'job_id': job.id,
|
| 175 |
+
'model_id': job.fine_tuned_model,
|
| 176 |
+
'base_model': job.model,
|
| 177 |
+
'status': job.status,
|
| 178 |
+
'created_at': job.created_at,
|
| 179 |
+
'finished_at': job.finished_at
|
| 180 |
+
})
|
| 181 |
+
|
| 182 |
+
return models
|
| 183 |
+
|
| 184 |
+
def cancel_job(self, job_id: str) -> None:
|
| 185 |
+
"""
|
| 186 |
+
Cancel a running fine-tuning job
|
| 187 |
+
|
| 188 |
+
Args:
|
| 189 |
+
job_id: Fine-tuning job ID
|
| 190 |
+
"""
|
| 191 |
+
print(f"🛑 Cancelling job {job_id}...")
|
| 192 |
+
self.client.fine_tuning.jobs.cancel(job_id)
|
| 193 |
+
print(f"✅ Job cancelled")
|
| 194 |
+
|
| 195 |
+
def _save_job_info(self, job_id: str, info: Dict[str, Any]) -> None:
|
| 196 |
+
"""Save job information to file"""
|
| 197 |
+
import json
|
| 198 |
+
|
| 199 |
+
job_file = self.jobs_dir / f"{job_id}.json"
|
| 200 |
+
with open(job_file, 'w') as f:
|
| 201 |
+
json.dump(info, f, indent=2, default=str)
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def fine_tune_agent(
|
| 205 |
+
agent_name: str,
|
| 206 |
+
training_file: str,
|
| 207 |
+
model: str = "gpt-4o-mini-2024-07-18",
|
| 208 |
+
suffix: Optional[str] = None,
|
| 209 |
+
wait_for_completion: bool = True
|
| 210 |
+
) -> str:
|
| 211 |
+
"""
|
| 212 |
+
Convenience function to fine-tune an agent
|
| 213 |
+
|
| 214 |
+
Args:
|
| 215 |
+
agent_name: Name of agent (nutrition, exercise, etc.)
|
| 216 |
+
training_file: Path to training data
|
| 217 |
+
model: Base model to use
|
| 218 |
+
suffix: Suffix for model name
|
| 219 |
+
wait_for_completion: Whether to wait for job to finish
|
| 220 |
+
|
| 221 |
+
Returns:
|
| 222 |
+
Fine-tuned model ID or job ID
|
| 223 |
+
"""
|
| 224 |
+
trainer = FineTuningTrainer()
|
| 225 |
+
|
| 226 |
+
# Upload file
|
| 227 |
+
file_id = trainer.upload_training_file(training_file)
|
| 228 |
+
|
| 229 |
+
# Create job
|
| 230 |
+
if suffix is None:
|
| 231 |
+
suffix = f"{agent_name}-{int(time.time())}"
|
| 232 |
+
|
| 233 |
+
job_id = trainer.create_fine_tuning_job(
|
| 234 |
+
training_file_id=file_id,
|
| 235 |
+
model=model,
|
| 236 |
+
suffix=suffix
|
| 237 |
+
)
|
| 238 |
+
|
| 239 |
+
# Wait for completion if requested
|
| 240 |
+
if wait_for_completion:
|
| 241 |
+
status = trainer.wait_for_completion(job_id)
|
| 242 |
+
return status['fine_tuned_model']
|
| 243 |
+
else:
|
| 244 |
+
return job_id
|