Spaces:
Sleeping
Sleeping
Devrajsinh bharatsinh gohil
commited on
Commit
·
b0b150b
0
Parent(s):
Initial commit of MEXAR Ultimate - Phase 2 cleanup complete
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .gitignore +66 -0
- CHANGES_IMAGE_PREVIEW_FIX.md +217 -0
- COMPLETE_FIX_SUMMARY.md +316 -0
- FIX_SQLALCHEMY_F405.md +78 -0
- README.md +289 -0
- backend/.env.example +42 -0
- backend/Procfile +1 -0
- backend/api/admin.py +53 -0
- backend/api/agents.py +281 -0
- backend/api/auth.py +110 -0
- backend/api/chat.py +511 -0
- backend/api/compile.py +109 -0
- backend/api/deps.py +32 -0
- backend/api/diagnostics.py +133 -0
- backend/api/prompts.py +28 -0
- backend/api/websocket.py +113 -0
- backend/core/cache.py +122 -0
- backend/core/config.py +25 -0
- backend/core/database.py +26 -0
- backend/core/monitoring.py +206 -0
- backend/core/rate_limiter.py +172 -0
- backend/core/security.py +32 -0
- backend/main.py +148 -0
- backend/migrations/README.md +65 -0
- backend/migrations/__init__.py +0 -0
- backend/migrations/add_preferences.py +23 -0
- backend/migrations/fix_vector_dimension.sql +20 -0
- backend/migrations/hybrid_search_function.sql +103 -0
- backend/migrations/rag_migration.sql +112 -0
- backend/models/__init__.py +19 -0
- backend/models/agent.py +51 -0
- backend/models/chunk.py +29 -0
- backend/models/conversation.py +35 -0
- backend/models/user.py +18 -0
- backend/modules/__init__.py +3 -0
- backend/modules/data_validator.py +360 -0
- backend/modules/explainability.py +276 -0
- backend/modules/knowledge_compiler.py +403 -0
- backend/modules/multimodal_processor.py +415 -0
- backend/modules/prompt_analyzer.py +336 -0
- backend/modules/reasoning_engine.py +476 -0
- backend/quick_test.py +32 -0
- backend/requirements.txt +53 -0
- backend/services/agent_service.py +84 -0
- backend/services/auth_service.py +60 -0
- backend/services/conversation_service.py +150 -0
- backend/services/inference_service.py +130 -0
- backend/services/storage_service.py +144 -0
- backend/services/tts_service.py +305 -0
- backend/utils/__init__.py +3 -0
.gitignore
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
*.so
|
| 6 |
+
.Python
|
| 7 |
+
venv/
|
| 8 |
+
.venv/
|
| 9 |
+
env/
|
| 10 |
+
.env
|
| 11 |
+
*.egg-info/
|
| 12 |
+
.eggs/
|
| 13 |
+
*.egg
|
| 14 |
+
dist/
|
| 15 |
+
build/
|
| 16 |
+
|
| 17 |
+
# Node.js
|
| 18 |
+
node_modules/
|
| 19 |
+
npm-debug.log*
|
| 20 |
+
yarn-debug.log*
|
| 21 |
+
yarn-error.log*
|
| 22 |
+
|
| 23 |
+
# Build outputs
|
| 24 |
+
frontend/build/
|
| 25 |
+
*.tgz
|
| 26 |
+
|
| 27 |
+
# IDE
|
| 28 |
+
.vscode/
|
| 29 |
+
.idea/
|
| 30 |
+
*.swp
|
| 31 |
+
*.swo
|
| 32 |
+
*~
|
| 33 |
+
.project
|
| 34 |
+
.classpath
|
| 35 |
+
.settings/
|
| 36 |
+
|
| 37 |
+
# OS files
|
| 38 |
+
.DS_Store
|
| 39 |
+
.DS_Store?
|
| 40 |
+
._*
|
| 41 |
+
.Spotlight-V100
|
| 42 |
+
.Trashes
|
| 43 |
+
ehthumbs.db
|
| 44 |
+
Thumbs.db
|
| 45 |
+
|
| 46 |
+
# Data directories
|
| 47 |
+
backend/data/storage/
|
| 48 |
+
backend/data/temp/
|
| 49 |
+
backend/data/tts_cache/
|
| 50 |
+
backend/data/agents/
|
| 51 |
+
*.db
|
| 52 |
+
|
| 53 |
+
# Test data (optional - uncomment if you want to include)
|
| 54 |
+
# test_data/
|
| 55 |
+
|
| 56 |
+
# Logs
|
| 57 |
+
*.log
|
| 58 |
+
logs/
|
| 59 |
+
|
| 60 |
+
# Misc
|
| 61 |
+
*.bak
|
| 62 |
+
*.tmp
|
| 63 |
+
.cache/
|
| 64 |
+
|
| 65 |
+
# Documentation build
|
| 66 |
+
md files/
|
CHANGES_IMAGE_PREVIEW_FIX.md
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Image Preview and Groq API Fix - Summary
|
| 2 |
+
|
| 3 |
+
## Changes Made
|
| 4 |
+
|
| 5 |
+
### 1. Image Preview UI Fix (Frontend)
|
| 6 |
+
|
| 7 |
+
**Issue:** Image preview was showing as a large card above the input field, not matching the desired inline thumbnail appearance from the screenshot.
|
| 8 |
+
|
| 9 |
+
**Solution:**
|
| 10 |
+
- Removed the large preview card that appeared above the input
|
| 11 |
+
- Added a small **60px inline thumbnail** that appears next to the input controls
|
| 12 |
+
- Thumbnail includes:
|
| 13 |
+
- Clickable to view full size in lightbox
|
| 14 |
+
- Small close button overlay (top-right)
|
| 15 |
+
- Proper border and styling matching the purple theme
|
| 16 |
+
- Uses `objectFit: 'cover'` for clean thumbnail appearance
|
| 17 |
+
|
| 18 |
+
**Files Modified:**
|
| 19 |
+
- `frontend/src/pages/Chat.jsx` (lines 687-793)
|
| 20 |
+
|
| 21 |
+
**Visual Changes:**
|
| 22 |
+
```
|
| 23 |
+
Before: [Large preview card]
|
| 24 |
+
[Input field with buttons]
|
| 25 |
+
|
| 26 |
+
After: [Input field with buttons] [60px thumbnail]
|
| 27 |
+
```
|
| 28 |
+
|
| 29 |
+
---
|
| 30 |
+
|
| 31 |
+
### 2. Image Display in Chat Messages
|
| 32 |
+
|
| 33 |
+
**Issue:** When sending an image, it wasn't appearing in the user's chat bubble.
|
| 34 |
+
|
| 35 |
+
**Solution:**
|
| 36 |
+
- Added `multimodal_data` with `image_url` to the user message object
|
| 37 |
+
- This stores the base64 preview URL for immediate display
|
| 38 |
+
- The existing message rendering code (lines 483-521) already handles displaying images in chat bubbles
|
| 39 |
+
|
| 40 |
+
**Files Modified:**
|
| 41 |
+
- `frontend/src/pages/Chat.jsx` (lines 241-266)
|
| 42 |
+
|
| 43 |
+
**Code Added:**
|
| 44 |
+
```javascript
|
| 45 |
+
multimodal_data: {
|
| 46 |
+
image_url: imagePreview // Store preview URL for display
|
| 47 |
+
}
|
| 48 |
+
```
|
| 49 |
+
|
| 50 |
+
---
|
| 51 |
+
|
| 52 |
+
### 3. Groq API Image Processing Error Handling (Backend)
|
| 53 |
+
|
| 54 |
+
**Issue:** Groq API image processing errors were causing the entire multimodal chat to fail.
|
| 55 |
+
|
| 56 |
+
**Solution:**
|
| 57 |
+
- Improved error handling in the multimodal chat endpoint
|
| 58 |
+
- Now catches and logs image processing errors without breaking the chat flow
|
| 59 |
+
- Provides fallback context when image analysis fails
|
| 60 |
+
- Better error messages for debugging
|
| 61 |
+
|
| 62 |
+
**Files Modified:**
|
| 63 |
+
- `backend/api/chat.py` (lines 212-240)
|
| 64 |
+
|
| 65 |
+
**Error Handling Flow:**
|
| 66 |
+
```python
|
| 67 |
+
try:
|
| 68 |
+
processor = create_multimodal_processor()
|
| 69 |
+
image_result = processor.process_image(str(temp_path))
|
| 70 |
+
|
| 71 |
+
if image_result.get("success"):
|
| 72 |
+
# Use AI-generated description
|
| 73 |
+
multimodal_context += f"\n[IMAGE DESCRIPTION]: {image_desc}"
|
| 74 |
+
else:
|
| 75 |
+
# Fallback: mention the image was uploaded
|
| 76 |
+
logger.warning(f"Image analysis failed: {error}")
|
| 77 |
+
multimodal_context += f"\n[IMAGE]: User uploaded an image"
|
| 78 |
+
except Exception as e:
|
| 79 |
+
# Graceful degradation
|
| 80 |
+
logger.error(f"Image processing exception: {e}")
|
| 81 |
+
multimodal_context += f"\n[IMAGE]: User uploaded an image"
|
| 82 |
+
```
|
| 83 |
+
|
| 84 |
+
---
|
| 85 |
+
|
| 86 |
+
## Testing Checklist
|
| 87 |
+
|
| 88 |
+
### Image Preview
|
| 89 |
+
- [ ] Upload an image using the image upload button
|
| 90 |
+
- [ ] Verify small 60px thumbnail appears inline with input controls
|
| 91 |
+
- [ ] Click thumbnail to view full size in lightbox
|
| 92 |
+
- [ ] Click close button (X) on thumbnail to remove
|
| 93 |
+
- [ ] Verify thumbnail disappears after sending message
|
| 94 |
+
|
| 95 |
+
### Chat Message Display
|
| 96 |
+
- [ ] Send a message with an image attached
|
| 97 |
+
- [ ] Verify image appears in your chat bubble as a thumbnail
|
| 98 |
+
- [ ] Verify image can be clicked to view full size
|
| 99 |
+
- [ ] Verify text message appears below the image
|
| 100 |
+
|
| 101 |
+
### Groq API Processing
|
| 102 |
+
- [ ] Check backend logs when sending an image
|
| 103 |
+
- [ ] Verify "Analyzing image" log appears
|
| 104 |
+
- [ ] If Groq API works: Should see image description in reasoning
|
| 105 |
+
- [ ] If Groq API fails: Should see warning but chat still works
|
| 106 |
+
|
| 107 |
+
---
|
| 108 |
+
|
| 109 |
+
## Common Issues and Solutions
|
| 110 |
+
|
| 111 |
+
### Issue: GROQ_API_KEY not found
|
| 112 |
+
**Solution:** Create `.env` file in `backend/` directory:
|
| 113 |
+
```
|
| 114 |
+
GROQ_API_KEY=your_groq_api_key_here
|
| 115 |
+
```
|
| 116 |
+
|
| 117 |
+
### Issue: Image processing fails with "model not found"
|
| 118 |
+
**Solution:** Groq's vision model is: `llama-3.2-90b-vision-preview`
|
| 119 |
+
- This is already configured in `utils/groq_client.py`
|
| 120 |
+
- Ensure your API key has access to vision models
|
| 121 |
+
|
| 122 |
+
### Issue: Image doesn't appear after sending
|
| 123 |
+
**Solution:** Check:
|
| 124 |
+
1. Browser console for errors
|
| 125 |
+
2. Network tab to verify image was uploaded to Supabase
|
| 126 |
+
3. Backend logs for processing errors
|
| 127 |
+
|
| 128 |
+
---
|
| 129 |
+
|
| 130 |
+
## Architecture Overview
|
| 131 |
+
|
| 132 |
+
### Frontend Flow
|
| 133 |
+
```
|
| 134 |
+
1. User selects image → handleFileSelect()
|
| 135 |
+
2. FileReader creates base64 preview → setImagePreview()
|
| 136 |
+
3. Preview shows as inline thumbnail
|
| 137 |
+
4. User sends message → handleSend()
|
| 138 |
+
5. Image included in userMessage.multimodal_data
|
| 139 |
+
6. API call: sendMultimodalMessage()
|
| 140 |
+
7. Preview clears, message appears in chat
|
| 141 |
+
```
|
| 142 |
+
|
| 143 |
+
### Backend Flow
|
| 144 |
+
```
|
| 145 |
+
1. Receive multimodal request at /api/chat/multimodal
|
| 146 |
+
2. Upload image to Supabase Storage → image_url
|
| 147 |
+
3. Save temp copy for AI processing
|
| 148 |
+
4. Groq Vision analyzes image → description
|
| 149 |
+
5. Description added to multimodal_context
|
| 150 |
+
6. Reasoning engine processes query + context
|
| 151 |
+
7. Return answer + image_url to frontend
|
| 152 |
+
8. Cleanup temp file
|
| 153 |
+
```
|
| 154 |
+
|
| 155 |
+
---
|
| 156 |
+
|
| 157 |
+
## Files Changed Summary
|
| 158 |
+
|
| 159 |
+
### Frontend
|
| 160 |
+
- `frontend/src/pages/Chat.jsx`
|
| 161 |
+
- Removed large preview card (removed ~50 lines)
|
| 162 |
+
- Added inline 60px thumbnail preview (+50 lines)
|
| 163 |
+
- Added multimodal_data to user message (+3 lines)
|
| 164 |
+
|
| 165 |
+
### Backend
|
| 166 |
+
- `backend/api/chat.py`
|
| 167 |
+
- Improved image processing error handling (+16 lines)
|
| 168 |
+
- Added try-catch for graceful degradation
|
| 169 |
+
|
| 170 |
+
- `backend/test_groq_vision.py` (new file)
|
| 171 |
+
- Diagnostic script to test Groq configuration
|
| 172 |
+
|
| 173 |
+
---
|
| 174 |
+
|
| 175 |
+
## Next Steps
|
| 176 |
+
|
| 177 |
+
1. **Test the changes:**
|
| 178 |
+
- Start backend: `cd backend && uvicorn main:app --reload`
|
| 179 |
+
- Frontend should already be running
|
| 180 |
+
- Upload and send an image
|
| 181 |
+
|
| 182 |
+
2. **Verify Groq API:**
|
| 183 |
+
```bash
|
| 184 |
+
cd backend
|
| 185 |
+
python test_groq_vision.py
|
| 186 |
+
```
|
| 187 |
+
|
| 188 |
+
3. **Check logs** if issues occur:
|
| 189 |
+
- Backend console for API errors
|
| 190 |
+
- Browser DevTools console for frontend errors
|
| 191 |
+
- Network tab for upload status
|
| 192 |
+
|
| 193 |
+
---
|
| 194 |
+
|
| 195 |
+
## Visual Reference
|
| 196 |
+
|
| 197 |
+
Based on your screenshot, the final result should look like:
|
| 198 |
+
|
| 199 |
+
```
|
| 200 |
+
┌─────────────────────────────────────────────────┐
|
| 201 |
+
│ Input field text here... │
|
| 202 |
+
│ │
|
| 203 |
+
│ [🎤] [📷] [60x60 img] [Send ➤] │
|
| 204 |
+
│ thumbnail │
|
| 205 |
+
└─────────────────────────────────────────────────┘
|
| 206 |
+
```
|
| 207 |
+
|
| 208 |
+
When sent, appears in chat as:
|
| 209 |
+
```
|
| 210 |
+
User bubble:
|
| 211 |
+
┌────────────────┐
|
| 212 |
+
│ [thumbnail] │ ← clickable
|
| 213 |
+
│ │
|
| 214 |
+
│ Your message │
|
| 215 |
+
│ text here │
|
| 216 |
+
└────────────────┘
|
| 217 |
+
```
|
COMPLETE_FIX_SUMMARY.md
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Complete Fix Summary - Image Preview & Groq API
|
| 2 |
+
|
| 3 |
+
## ✅ All Issues Fixed
|
| 4 |
+
|
| 5 |
+
### Issue 1: Image Preview Position ✓
|
| 6 |
+
**Problem:** Image was showing as inline thumbnail, not matching your reference screenshots
|
| 7 |
+
|
| 8 |
+
**Solution:** Restored large preview card ABOVE the input field
|
| 9 |
+
- Preview now appears above the input (like screenshot #3)
|
| 10 |
+
- Max size: 300px wide, 200px tall
|
| 11 |
+
- Close button in top-right corner
|
| 12 |
+
- Click to view full-size in lightbox
|
| 13 |
+
- Purple border matching app theme
|
| 14 |
+
|
| 15 |
+
**Files Changed:**
|
| 16 |
+
- `frontend/src/pages/Chat.jsx` (lines 691-744)
|
| 17 |
+
|
| 18 |
+
---
|
| 19 |
+
|
| 20 |
+
### Issue 2: Duplicate Preview Removed ✓
|
| 21 |
+
**Problem:** There were two previews (above AND inline)
|
| 22 |
+
|
| 23 |
+
**Solution:** Removed the inline 60px thumbnail
|
| 24 |
+
- Only one preview now - the large one above input
|
| 25 |
+
- Cleaner UI matching your screenshots
|
| 26 |
+
|
| 27 |
+
**Files Changed:**
|
| 28 |
+
- `frontend/src/pages/Chat.jsx` (lines 785-853)
|
| 29 |
+
|
| 30 |
+
---
|
| 31 |
+
|
| 32 |
+
### Issue 3: Groq API Not Recognizing Images ✓
|
| 33 |
+
**Problem:** Groq was returning "I don't have information about the image"
|
| 34 |
+
|
| 35 |
+
**Solution:** Added comprehensive logging to track the entire flow
|
| 36 |
+
- Added logging at every step of image processing
|
| 37 |
+
- File size validation
|
| 38 |
+
- Base64 encoding verification
|
| 39 |
+
- API call tracking
|
| 40 |
+
- Detailed error messages
|
| 41 |
+
|
| 42 |
+
**Files Changed:**
|
| 43 |
+
- `backend/utils/groq_client.py` (lines 156-230)
|
| 44 |
+
- `backend/api/chat.py` (lines 220-270)
|
| 45 |
+
|
| 46 |
+
**Logging Format:**
|
| 47 |
+
```
|
| 48 |
+
[MULTIMODAL] Image uploaded to Supabase: https://...
|
| 49 |
+
[MULTIMODAL] Saving temp file: data/temp/abc123.jpg
|
| 50 |
+
[MULTIMODAL] Temp file saved, size: 45678 bytes
|
| 51 |
+
[MULTIMODAL] Starting image analysis with Groq Vision...
|
| 52 |
+
[GROQ VISION] Starting image analysis for: data/temp/abc123.jpg
|
| 53 |
+
[GROQ VISION] Image file size: 45678 bytes
|
| 54 |
+
[GROQ VISION] Image encoded to base64, length: 61234 chars
|
| 55 |
+
[GROQ VISION] Detected MIME type: image/jpeg
|
| 56 |
+
[GROQ VISION] Calling Groq API with model: llama-3.2-90b-vision-preview
|
| 57 |
+
[GROQ VISION] Success! Response length: 234 chars
|
| 58 |
+
[GROQ VISION] Response preview: This image shows a financial literacy infographic...
|
| 59 |
+
[MULTIMODAL] ✓ Image analyzed successfully
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
---
|
| 63 |
+
|
| 64 |
+
## Testing Steps
|
| 65 |
+
|
| 66 |
+
### 1. Test Image Preview (Frontend)
|
| 67 |
+
|
| 68 |
+
1. **Navigate to any agent chat**
|
| 69 |
+
2. **Click the image upload button** 📷
|
| 70 |
+
3. **Select an image file**
|
| 71 |
+
4. **Verify:**
|
| 72 |
+
- ✓ Large preview appears ABOVE the input field
|
| 73 |
+
- ✓ Preview is max 300x200px
|
| 74 |
+
- ✓ Close button (X) appears in top-right
|
| 75 |
+
- ✓ Click image to view full-size
|
| 76 |
+
- ✓ Click X to remove preview
|
| 77 |
+
5. **Type a message** describing the image
|
| 78 |
+
6. **Click Send**
|
| 79 |
+
7. **Verify:**
|
| 80 |
+
- ✓ Preview disappears from input area
|
| 81 |
+
- ✓ Image appears in YOUR message bubble (right side, purple)
|
| 82 |
+
- ✓ Image is clickable for full view
|
| 83 |
+
|
| 84 |
+
### 2. Test Groq Image Recognition (Backend)
|
| 85 |
+
|
| 86 |
+
1. **Open backend terminal** to watch logs
|
| 87 |
+
2. **Upload and send an image** with text "what this image about"
|
| 88 |
+
3. **Check backend logs** for:
|
| 89 |
+
```
|
| 90 |
+
[MULTIMODAL] Image uploaded to Supabase...
|
| 91 |
+
[MULTIMODAL] Starting image analysis with Groq Vision...
|
| 92 |
+
[GROQ VISION] Starting image analysis...
|
| 93 |
+
[GROQ VISION] Success! Response length: XXX chars
|
| 94 |
+
```
|
| 95 |
+
4. **Verify in chat:**
|
| 96 |
+
- ✓ MEXAR responds with actual description of the image
|
| 97 |
+
- ✓ NOT "I don't have information about the image"
|
| 98 |
+
- ✓ Response shows confidence score
|
| 99 |
+
- ✓ "Explain reasoning" button available
|
| 100 |
+
|
| 101 |
+
### 3. What to Look For in Logs
|
| 102 |
+
|
| 103 |
+
**✓ SUCCESS PATTERN:**
|
| 104 |
+
```
|
| 105 |
+
[MULTIMODAL] Image uploaded to Supabase: https://...
|
| 106 |
+
[MULTIMODAL] Temp file saved, size: 45678 bytes
|
| 107 |
+
[GROQ VISION] Image encoded to base64, length: 61234 chars
|
| 108 |
+
[GROQ VISION] Calling Groq API with model: llama-3.2-90b-vision-preview
|
| 109 |
+
[GROQ VISION] Success! Response length: 234 chars
|
| 110 |
+
[MULTIMODAL] ✓ Image analyzed successfully
|
| 111 |
+
```
|
| 112 |
+
|
| 113 |
+
**❌ ERROR PATTERNS:**
|
| 114 |
+
|
| 115 |
+
**Pattern 1 - Missing API Key:**
|
| 116 |
+
```
|
| 117 |
+
[GROQ VISION] API call failed: ValueError: GROQ_API_KEY not found
|
| 118 |
+
```
|
| 119 |
+
**Fix:** Add GROQ_API_KEY to backend/.env
|
| 120 |
+
|
| 121 |
+
**Pattern 2 - File Not Found:**
|
| 122 |
+
```
|
| 123 |
+
[MULTIMODAL] Image processing exception: FileNotFoundError
|
| 124 |
+
```
|
| 125 |
+
**Fix:** Check Supabase storage permissions
|
| 126 |
+
|
| 127 |
+
**Pattern 3 - API Error:**
|
| 128 |
+
```
|
| 129 |
+
[GROQ VISION] API call failed: HTTPError: 401 Unauthorized
|
| 130 |
+
```
|
| 131 |
+
**Fix:** Check API key is valid
|
| 132 |
+
|
| 133 |
+
**Pattern 4 - Model Not Available:**
|
| 134 |
+
```
|
| 135 |
+
[GROQ VISION] API call failed: Model not found
|
| 136 |
+
```
|
| 137 |
+
**Fix:** Verify Groq account has vision access
|
| 138 |
+
|
| 139 |
+
---
|
| 140 |
+
|
| 141 |
+
## Visual Comparison
|
| 142 |
+
|
| 143 |
+
### BEFORE (Your Issue)
|
| 144 |
+
```
|
| 145 |
+
┌─────────────────────────────────────┐
|
| 146 |
+
│ [User Message with Image] │
|
| 147 |
+
│ [Small inline thumbnail] │
|
| 148 |
+
│ "what this image about" │
|
| 149 |
+
└─────────────────────────────────────┘
|
| 150 |
+
|
| 151 |
+
└─[MEXAR Response]──────────────────┐
|
| 152 |
+
│ "I don't have information about │
|
| 153 |
+
│ the image 'download (1).jpg'..." │
|
| 154 |
+
│ │
|
| 155 |
+
│ 🔴 NOT WORKING - No recognition │
|
| 156 |
+
└────────────────────────────────────┘
|
| 157 |
+
|
| 158 |
+
Input: [inline 60px thumbnail] [text]
|
| 159 |
+
```
|
| 160 |
+
|
| 161 |
+
### AFTER (Fixed)
|
| 162 |
+
```
|
| 163 |
+
┌─[Large Preview Above Input]───┐
|
| 164 |
+
│ ┌─────────────────────┐ [X] │
|
| 165 |
+
│ │ │ │
|
| 166 |
+
│ │ [Image Preview] │ │
|
| 167 |
+
│ │ (300x200px) │ │
|
| 168 |
+
│ │ │ │
|
| 169 |
+
│ └─────────────────────┘ │
|
| 170 |
+
└───────────────────────────────┘
|
| 171 |
+
|
| 172 |
+
Input: [🎤] [📷] [text field] [Send]
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
└─[User Message]────────────────────┐
|
| 176 |
+
│ ┌────────────┐ │
|
| 177 |
+
│ │ [Image] │ ← clickable │
|
| 178 |
+
│ └────────────┘ │
|
| 179 |
+
│ "what this image about" │
|
| 180 |
+
└───────────────────────────────────┘
|
| 181 |
+
|
| 182 |
+
└─[MEXAR Response]──────────────────┐
|
| 183 |
+
│ "This image shows a financial │
|
| 184 |
+
│ literacy infographic with a │
|
| 185 |
+
│ light bulb and text about..." │
|
| 186 |
+
│ │
|
| 187 |
+
│ ✅ WORKING - Image recognized! │
|
| 188 |
+
│ Confidence: 85% [Explain] │
|
| 189 |
+
└────────────────────────────────────┘
|
| 190 |
+
```
|
| 191 |
+
|
| 192 |
+
---
|
| 193 |
+
|
| 194 |
+
## Common Issues & Solutions
|
| 195 |
+
|
| 196 |
+
### Issue: Preview not appearing
|
| 197 |
+
**Check:**
|
| 198 |
+
1. Browser console for errors
|
| 199 |
+
2. Image file type (jpg, png, gif, webp only)
|
| 200 |
+
3. File size (should be < 10MB)
|
| 201 |
+
|
| 202 |
+
### Issue: "I don't have information about the image"
|
| 203 |
+
**Debug:**
|
| 204 |
+
1. Check backend logs for `[GROQ VISION]` messages
|
| 205 |
+
2. Look for API errors or exceptions
|
| 206 |
+
3. Verify GROQ_API_KEY is set
|
| 207 |
+
4. Test API key with: `cd backend && python test_groq_vision.py`
|
| 208 |
+
|
| 209 |
+
### Issue: Image disappears after sending
|
| 210 |
+
**This is normal!** The preview should:
|
| 211 |
+
- Disappear from input area after sending
|
| 212 |
+
- Appear in your message bubble
|
| 213 |
+
- Stay visible in chat history
|
| 214 |
+
|
| 215 |
+
If it's not appearing in message bubble:
|
| 216 |
+
1. Check browser console
|
| 217 |
+
2. Verify response includes `image_url`
|
| 218 |
+
3. Check Supabase storage upload succeeded
|
| 219 |
+
|
| 220 |
+
---
|
| 221 |
+
|
| 222 |
+
## Architecture Flow
|
| 223 |
+
|
| 224 |
+
### Upload → Display → Send → AI Process
|
| 225 |
+
|
| 226 |
+
```
|
| 227 |
+
1. User selects image
|
| 228 |
+
↓
|
| 229 |
+
2. FileReader creates base64 preview
|
| 230 |
+
↓
|
| 231 |
+
3. Preview shows ABOVE input (300x200px)
|
| 232 |
+
↓
|
| 233 |
+
4. User types message + clicks Send
|
| 234 |
+
↓
|
| 235 |
+
5. Frontend: sendMultimodalMessage()
|
| 236 |
+
- Uploads original file to Supabase
|
| 237 |
+
- Includes base64 in message for display
|
| 238 |
+
↓
|
| 239 |
+
6. Backend: /api/chat/multimodal
|
| 240 |
+
- Saves temp copy of image
|
| 241 |
+
- Calls Groq Vision API
|
| 242 |
+
- Gets AI description
|
| 243 |
+
↓
|
| 244 |
+
7. Groq Vision: describe_image()
|
| 245 |
+
- Encodes to base64
|
| 246 |
+
- Sends to llama-3.2-90b-vision-preview
|
| 247 |
+
- Returns description
|
| 248 |
+
↓
|
| 249 |
+
8. Backend: Reasoning Engine
|
| 250 |
+
- Combines: user text + image description
|
| 251 |
+
- Generates answer
|
| 252 |
+
↓
|
| 253 |
+
9. Response to frontend
|
| 254 |
+
- Answer text
|
| 255 |
+
- Confidence score
|
| 256 |
+
- Image URL for display
|
| 257 |
+
- Explainability data
|
| 258 |
+
↓
|
| 259 |
+
10. Display in chat
|
| 260 |
+
- User bubble: image + text
|
| 261 |
+
- AI bubble: answer + confidence
|
| 262 |
+
```
|
| 263 |
+
|
| 264 |
+
---
|
| 265 |
+
|
| 266 |
+
## Files Modified Summary
|
| 267 |
+
|
| 268 |
+
### Frontend (`frontend/src/pages/Chat.jsx`)
|
| 269 |
+
- **Added:** Large preview card above input (lines 691-744)
|
| 270 |
+
- **Removed:** Inline 60px thumbnail (lines 785-853)
|
| 271 |
+
- **Result:** Single, large preview matching your screenshots
|
| 272 |
+
|
| 273 |
+
### Backend (`backend/api/chat.py`)
|
| 274 |
+
- **Enhanced:** Image processing logging (lines 220-270)
|
| 275 |
+
- **Added:** Detailed step-by-step tracking
|
| 276 |
+
- **Added:** Error type logging
|
| 277 |
+
- **Result:** Full visibility into image processing
|
| 278 |
+
|
| 279 |
+
### Backend (`backend/utils/groq_client.py`)
|
| 280 |
+
- **Enhanced:** describe_image() function (lines 156-230)
|
| 281 |
+
- **Added:** File validation
|
| 282 |
+
- **Added:** API call logging
|
| 283 |
+
- **Added:** Response preview logging
|
| 284 |
+
- **Result:** Complete Groq API debugging
|
| 285 |
+
|
| 286 |
+
---
|
| 287 |
+
|
| 288 |
+
## Next Steps
|
| 289 |
+
|
| 290 |
+
1. **Test the changes** - Upload an image and verify:
|
| 291 |
+
- Preview appears above input (large, not inline)
|
| 292 |
+
- MEXAR recognizes and describes the image
|
| 293 |
+
- Backend logs show successful Groq API calls
|
| 294 |
+
|
| 295 |
+
2. **Watch backend logs** - Look for:
|
| 296 |
+
- `[MULTIMODAL]` tags for upload/processing
|
| 297 |
+
- `[GROQ VISION]` tags for API calls
|
| 298 |
+
- Success messages with description preview
|
| 299 |
+
|
| 300 |
+
3. **If Groq still fails:**
|
| 301 |
+
- Share the backend log output
|
| 302 |
+
- Check if GROQ_API_KEY has vision access
|
| 303 |
+
- Try test script: `python backend/test_groq_vision.py`
|
| 304 |
+
|
| 305 |
+
---
|
| 306 |
+
|
| 307 |
+
## Success Criteria ✅
|
| 308 |
+
|
| 309 |
+
- [ ] Image preview appears ABOVE input (like screenshot #3)
|
| 310 |
+
- [ ] Preview is large (300x200px max), not tiny (60px)
|
| 311 |
+
- [ ] Image shows in your message bubble after sending
|
| 312 |
+
- [ ] MEXAR actually describes the image content
|
| 313 |
+
- [ ] Backend logs show `[GROQ VISION] Success!`
|
| 314 |
+
- [ ] No more "I don't have information about the image"
|
| 315 |
+
|
| 316 |
+
All changes are complete and ready for testing!
|
FIX_SQLALCHEMY_F405.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SQLAlchemy f405 Error Fix
|
| 2 |
+
|
| 3 |
+
## Error
|
| 4 |
+
```
|
| 5 |
+
sqlalchemy.exc.AmbiguousForeignKeysError:
|
| 6 |
+
Could not determine join condition between parent/child tables on relationship
|
| 7 |
+
(Background on this error at: https://sqlalche.me/e/20/f405)
|
| 8 |
+
```
|
| 9 |
+
|
| 10 |
+
## Root Cause
|
| 11 |
+
The `User` model was missing the `conversations` relationship, causing SQLAlchemy to be unable to properly join the tables when querying conversations.
|
| 12 |
+
|
| 13 |
+
## Fix Applied
|
| 14 |
+
|
| 15 |
+
**File:** `backend/models/user.py`
|
| 16 |
+
|
| 17 |
+
**Added:**
|
| 18 |
+
```python
|
| 19 |
+
from sqlalchemy.orm import relationship
|
| 20 |
+
|
| 21 |
+
# Inside User class:
|
| 22 |
+
# Relationships
|
| 23 |
+
conversations = relationship("Conversation", backref="user", cascade="all, delete-orphan")
|
| 24 |
+
```
|
| 25 |
+
|
| 26 |
+
## Why This Fixes It
|
| 27 |
+
|
| 28 |
+
The SQLAlchemy f405 error occurs when there's an ambiguous or missing relationship definition. In this case:
|
| 29 |
+
|
| 30 |
+
- `Conversation` model had:
|
| 31 |
+
- `user_id = ForeignKey("users.id")`
|
| 32 |
+
- Trying to create a back-reference to User
|
| 33 |
+
|
| 34 |
+
- `User` model was missing the corresponding relationship definition
|
| 35 |
+
|
| 36 |
+
- SQLAlchemy couldn't determine how to join the tables
|
| 37 |
+
|
| 38 |
+
By adding the `conversations` relationship to the User model:
|
| 39 |
+
- ✅ Complete bidirectional relationship established
|
| 40 |
+
- ✅ SQLAlchemy can now properly join User ↔ Conversation
|
| 41 |
+
- ✅ Cascade delete works properly (when user deleted, conversations deleted)
|
| 42 |
+
- ✅ No ambiguity in foreign key relationships
|
| 43 |
+
|
| 44 |
+
## Testing
|
| 45 |
+
|
| 46 |
+
The uvicorn server should have automatically reloaded. Try:
|
| 47 |
+
|
| 48 |
+
1. Upload an image in the chat
|
| 49 |
+
2. Send a message
|
| 50 |
+
3. Check that no SQLAlchemy errors appear in backend logs
|
| 51 |
+
4. Verify message is saved to database
|
| 52 |
+
|
| 53 |
+
## Related Models
|
| 54 |
+
|
| 55 |
+
All relationships are now properly defined:
|
| 56 |
+
|
| 57 |
+
```
|
| 58 |
+
User
|
| 59 |
+
↓ (one-to-many)
|
| 60 |
+
conversations[] ✅
|
| 61 |
+
agents[] ✅
|
| 62 |
+
|
| 63 |
+
Agent
|
| 64 |
+
↓ (one-to-many)
|
| 65 |
+
conversations[] ✅
|
| 66 |
+
compilation_jobs[] ✅
|
| 67 |
+
chunks[] ✅
|
| 68 |
+
|
| 69 |
+
Conversation
|
| 70 |
+
↓ (one-to-many)
|
| 71 |
+
messages[] ✅
|
| 72 |
+
↑ (many-to-one)
|
| 73 |
+
user ✅ (via backref)
|
| 74 |
+
agent ✅ (via back_populates)
|
| 75 |
+
```
|
| 76 |
+
|
| 77 |
+
## Status
|
| 78 |
+
✅ **FIXED** - The relationship is now complete and the error should be resolved.
|
README.md
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# MEXAR Ultimate 🧠
|
| 2 |
+
|
| 3 |
+
**Multimodal Explainable AI Reasoning Assistant**
|
| 4 |
+
|
| 5 |
+
[](https://www.python.org/downloads/)
|
| 6 |
+
[](https://reactjs.org/)
|
| 7 |
+
[](https://fastapi.tiangolo.com/)
|
| 8 |
+
[](LICENSE)
|
| 9 |
+
|
| 10 |
+
MEXAR is an explainable AI system that creates domain-specific intelligent agents from your data. It uses **RAG (Retrieval-Augmented Generation)** with source attribution and faithfulness scoring to provide transparent, verifiable answers.
|
| 11 |
+
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
## ✨ Key Features
|
| 15 |
+
|
| 16 |
+
| Feature | Description |
|
| 17 |
+
|---------|-------------|
|
| 18 |
+
| 🔍 **Hybrid Search** | Combines semantic (vector) + keyword search with RRF fusion |
|
| 19 |
+
| 🎯 **Cross-Encoder Reranking** | Improves retrieval precision using sentence-transformers |
|
| 20 |
+
| 📊 **Source Attribution** | Inline citations `[1]`, `[2]` linking answers to sources |
|
| 21 |
+
| ✅ **Faithfulness Scoring** | Measures how well answers are grounded in context |
|
| 22 |
+
| 🗣️ **Multimodal Input** | Audio (Whisper), Images (Vision), Video support |
|
| 23 |
+
| 🔐 **Domain Guardrails** | Prevents hallucinations outside knowledge base |
|
| 24 |
+
| 🔊 **Text-to-Speech** | ElevenLabs + Web Speech API support |
|
| 25 |
+
| 📁 **5 File Types** | CSV, PDF, DOCX, JSON, TXT |
|
| 26 |
+
|
| 27 |
+
---
|
| 28 |
+
|
| 29 |
+
## 🏗️ Architecture
|
| 30 |
+
|
| 31 |
+
```
|
| 32 |
+
┌─────────────────────────────────────────────────────────────────┐
|
| 33 |
+
│ MEXAR Architecture │
|
| 34 |
+
├─────────────────────────────────────────────────────────────────┤
|
| 35 |
+
│ │
|
| 36 |
+
│ [User] ──► [React Frontend] │
|
| 37 |
+
│ │ │
|
| 38 |
+
│ ▼ │
|
| 39 |
+
│ [FastAPI Backend] │
|
| 40 |
+
│ │ │
|
| 41 |
+
│ ├──► Data Validator (CSV/PDF/DOCX/JSON/TXT) │
|
| 42 |
+
│ ├──► Prompt Analyzer (LLM-based domain extraction) │
|
| 43 |
+
│ ├──► Knowledge Compiler (FastEmbed → pgvector) │
|
| 44 |
+
│ └──► Reasoning Engine │
|
| 45 |
+
│ │ │
|
| 46 |
+
│ ├──► Hybrid Search (semantic + keyword) │
|
| 47 |
+
│ ├──► Reranker (cross-encoder) │
|
| 48 |
+
│ ├──► Source Attribution (inline citations) │
|
| 49 |
+
│ └──► Faithfulness Scorer (claim verification) │
|
| 50 |
+
│ │
|
| 51 |
+
│ [External Services] │
|
| 52 |
+
│ ├──► Supabase (PostgreSQL + Storage) │
|
| 53 |
+
│ ├──► Groq API (LLM + Whisper + Vision) │
|
| 54 |
+
│ └──► ElevenLabs (Text-to-Speech) │
|
| 55 |
+
└─────────────────────────────────────────────────────────────────┘
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
---
|
| 59 |
+
|
| 60 |
+
## 🚀 Quick Start
|
| 61 |
+
|
| 62 |
+
### Prerequisites
|
| 63 |
+
|
| 64 |
+
- **Python 3.9+** with pip
|
| 65 |
+
- **Node.js 18+** with npm
|
| 66 |
+
- **PostgreSQL** with `pgvector` extension (or use Supabase)
|
| 67 |
+
- **Groq API Key** - Get free at [console.groq.com](https://console.groq.com)
|
| 68 |
+
|
| 69 |
+
### 1. Backend Setup
|
| 70 |
+
|
| 71 |
+
```bash
|
| 72 |
+
cd backend
|
| 73 |
+
|
| 74 |
+
# Create virtual environment
|
| 75 |
+
python -m venv venv
|
| 76 |
+
|
| 77 |
+
# Activate (Windows)
|
| 78 |
+
.\venv\Scripts\activate
|
| 79 |
+
|
| 80 |
+
# Activate (macOS/Linux)
|
| 81 |
+
source venv/bin/activate
|
| 82 |
+
|
| 83 |
+
# Install dependencies
|
| 84 |
+
pip install -r requirements.txt
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
**Configure Environment Variables:**
|
| 88 |
+
|
| 89 |
+
Create `backend/.env`:
|
| 90 |
+
```env
|
| 91 |
+
# Required
|
| 92 |
+
GROQ_API_KEY=your_groq_api_key_here
|
| 93 |
+
DATABASE_URL=postgresql://user:password@host:5432/database
|
| 94 |
+
SECRET_KEY=your_secure_secret_key
|
| 95 |
+
|
| 96 |
+
# Supabase Storage
|
| 97 |
+
SUPABASE_URL=https://your-project.supabase.co
|
| 98 |
+
SUPABASE_KEY=your_supabase_service_role_key
|
| 99 |
+
|
| 100 |
+
# Optional: ElevenLabs TTS
|
| 101 |
+
ELEVENLABS_API_KEY=your_elevenlabs_api_key_here
|
| 102 |
+
```
|
| 103 |
+
|
| 104 |
+
**Run Server:**
|
| 105 |
+
```bash
|
| 106 |
+
python -m uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
| 107 |
+
```
|
| 108 |
+
|
| 109 |
+
### 2. Frontend Setup
|
| 110 |
+
|
| 111 |
+
```bash
|
| 112 |
+
cd frontend
|
| 113 |
+
|
| 114 |
+
# Install dependencies
|
| 115 |
+
npm install
|
| 116 |
+
|
| 117 |
+
# Start development server
|
| 118 |
+
npm start
|
| 119 |
+
```
|
| 120 |
+
|
| 121 |
+
Open [http://localhost:3000](http://localhost:3000) in your browser.
|
| 122 |
+
|
| 123 |
+
---
|
| 124 |
+
|
| 125 |
+
## 📁 Project Structure
|
| 126 |
+
|
| 127 |
+
```
|
| 128 |
+
mexar_ultimate/
|
| 129 |
+
├── backend/
|
| 130 |
+
│ ├── api/ # REST API endpoints
|
| 131 |
+
│ │ ├── auth.py # Authentication (JWT)
|
| 132 |
+
│ │ ├── agents.py # Agent CRUD
|
| 133 |
+
│ │ ├── chat.py # Chat + multimodal
|
| 134 |
+
│ │ ├── compile.py # Knowledge compilation
|
| 135 |
+
│ │ └── websocket.py # Real-time updates
|
| 136 |
+
│ ├── core/ # Core configuration
|
| 137 |
+
│ │ ├── config.py # Settings
|
| 138 |
+
│ │ ├── database.py # SQLAlchemy setup
|
| 139 |
+
│ │ └── security.py # JWT handling
|
| 140 |
+
│ ├── models/ # Database models
|
| 141 |
+
│ │ ├── user.py # User model
|
| 142 |
+
│ │ ├── agent.py # Agent + CompilationJob
|
| 143 |
+
│ │ ├── chunk.py # DocumentChunk (pgvector)
|
| 144 |
+
│ │ └── conversation.py # Chat history
|
| 145 |
+
│ ├── modules/ # Core AI modules
|
| 146 |
+
│ │ ├── data_validator.py # File parsing
|
| 147 |
+
│ │ ├── prompt_analyzer.py # Domain extraction
|
| 148 |
+
│ │ ├── knowledge_compiler.py # Vector embeddings
|
| 149 |
+
│ │ ├── reasoning_engine.py # RAG pipeline
|
| 150 |
+
│ │ ├── multimodal_processor.py # Audio/Image/Video
|
| 151 |
+
│ │ └── explainability.py # UI formatting
|
| 152 |
+
│ ├── utils/ # Utility modules
|
| 153 |
+
│ │ ├── groq_client.py # Groq API wrapper
|
| 154 |
+
│ │ ├── hybrid_search.py # RRF search fusion
|
| 155 |
+
│ │ ├── reranker.py # Cross-encoder
|
| 156 |
+
│ │ ├── faithfulness.py # Claim verification
|
| 157 |
+
│ │ └── source_attribution.py # Citation extraction
|
| 158 |
+
│ ├── services/ # External services
|
| 159 |
+
│ │ ├── tts_service.py # Text-to-speech
|
| 160 |
+
│ │ └── storage_service.py # Supabase storage
|
| 161 |
+
│ ├── main.py # FastAPI app entry
|
| 162 |
+
│ └── requirements.txt # Python dependencies
|
| 163 |
+
│
|
| 164 |
+
├── frontend/
|
| 165 |
+
│ ├── src/
|
| 166 |
+
│ │ ├── pages/ # React pages
|
| 167 |
+
│ │ │ ├── Landing.jsx # Home page
|
| 168 |
+
│ │ │ ├── Login.jsx # Authentication
|
| 169 |
+
│ │ │ ├── Dashboard.jsx # User dashboard
|
| 170 |
+
│ │ │ ├── AgentCreation.jsx # Create agent
|
| 171 |
+
│ │ │ ├── CompilationProgress.jsx # Build progress
|
| 172 |
+
│ │ │ └── Chat.jsx # Chat interface
|
| 173 |
+
│ │ ├── components/ # Reusable UI
|
| 174 |
+
│ │ ├── contexts/ # React contexts
|
| 175 |
+
│ │ ├── api/ # API client
|
| 176 |
+
│ │ └── App.jsx # Main component
|
| 177 |
+
│ └── package.json # Node dependencies
|
| 178 |
+
│
|
| 179 |
+
└── README.md
|
| 180 |
+
```
|
| 181 |
+
|
| 182 |
+
---
|
| 183 |
+
|
| 184 |
+
## 🔧 API Reference
|
| 185 |
+
|
| 186 |
+
### Authentication
|
| 187 |
+
| Method | Endpoint | Description |
|
| 188 |
+
|--------|----------|-------------|
|
| 189 |
+
| POST | `/api/auth/register` | Register new user |
|
| 190 |
+
| POST | `/api/auth/login` | Login (returns JWT) |
|
| 191 |
+
| GET | `/api/auth/me` | Get current user |
|
| 192 |
+
|
| 193 |
+
### Agents
|
| 194 |
+
| Method | Endpoint | Description |
|
| 195 |
+
|--------|----------|-------------|
|
| 196 |
+
| GET | `/api/agents/` | List all agents |
|
| 197 |
+
| GET | `/api/agents/{name}` | Get agent details |
|
| 198 |
+
| DELETE | `/api/agents/{name}` | Delete agent |
|
| 199 |
+
|
| 200 |
+
### Compilation
|
| 201 |
+
| Method | Endpoint | Description |
|
| 202 |
+
|--------|----------|-------------|
|
| 203 |
+
| POST | `/api/compile/` | Start compilation (multipart) |
|
| 204 |
+
| GET | `/api/compile/{name}/status` | Check compilation status |
|
| 205 |
+
|
| 206 |
+
### Chat
|
| 207 |
+
| Method | Endpoint | Description |
|
| 208 |
+
|--------|----------|-------------|
|
| 209 |
+
| POST | `/api/chat/` | Send message |
|
| 210 |
+
| POST | `/api/chat/multimodal` | Send with audio/image |
|
| 211 |
+
| GET | `/api/chat/{agent}/history` | Get chat history |
|
| 212 |
+
| POST | `/api/chat/transcribe` | Transcribe audio |
|
| 213 |
+
|
| 214 |
+
---
|
| 215 |
+
|
| 216 |
+
## 🧪 Technologies
|
| 217 |
+
|
| 218 |
+
### Backend
|
| 219 |
+
- **FastAPI** - Modern async Python web framework
|
| 220 |
+
- **SQLAlchemy** - ORM for PostgreSQL
|
| 221 |
+
- **pgvector** - Vector similarity search
|
| 222 |
+
- **FastEmbed** - Local embedding generation (BAAI/bge-small-en-v1.5)
|
| 223 |
+
- **sentence-transformers** - Cross-encoder reranking
|
| 224 |
+
- **Groq API** - LLM (Llama 3.1/3.3), Whisper (audio), Vision (images)
|
| 225 |
+
|
| 226 |
+
### Frontend
|
| 227 |
+
- **React 18** - UI framework
|
| 228 |
+
- **Material-UI** - Component library
|
| 229 |
+
- **React Router** - Navigation
|
| 230 |
+
- **Axios** - HTTP client
|
| 231 |
+
|
| 232 |
+
### External Services
|
| 233 |
+
- **Supabase** - Managed PostgreSQL + Storage
|
| 234 |
+
- **Groq** - Fast AI inference
|
| 235 |
+
- **ElevenLabs** - Text-to-Speech (optional)
|
| 236 |
+
|
| 237 |
+
---
|
| 238 |
+
|
| 239 |
+
## 📊 How It Works
|
| 240 |
+
|
| 241 |
+
### 1. Agent Creation
|
| 242 |
+
```
|
| 243 |
+
User uploads files → DataValidator parses → PromptAnalyzer extracts domain
|
| 244 |
+
→ KnowledgeCompiler creates embeddings
|
| 245 |
+
→ Stored in pgvector
|
| 246 |
+
```
|
| 247 |
+
|
| 248 |
+
### 2. Query Processing
|
| 249 |
+
```
|
| 250 |
+
User query → Domain Guardrail check
|
| 251 |
+
→ Hybrid Search (semantic + keyword)
|
| 252 |
+
→ Cross-Encoder Reranking (top 5)
|
| 253 |
+
→ LLM Generation with context
|
| 254 |
+
→ Source Attribution (citations)
|
| 255 |
+
→ Faithfulness Scoring
|
| 256 |
+
→ Explainability formatting
|
| 257 |
+
```
|
| 258 |
+
|
| 259 |
+
### 3. Confidence Scoring
|
| 260 |
+
Confidence is calculated from:
|
| 261 |
+
- **Retrieval Quality** (35%) - How relevant the retrieved chunks are
|
| 262 |
+
- **Rerank Score** (30%) - Cross-encoder confidence
|
| 263 |
+
- **Faithfulness** (25%) - How grounded the answer is
|
| 264 |
+
- **Base Floor** (10%) - For in-domain queries
|
| 265 |
+
|
| 266 |
+
---
|
| 267 |
+
|
| 268 |
+
## 🌐 Deployment
|
| 269 |
+
|
| 270 |
+
See [implementation_plan.md](./implementation_plan.md) for detailed deployment instructions covering:
|
| 271 |
+
- GitHub repository setup
|
| 272 |
+
- Vercel (frontend)
|
| 273 |
+
- Render.com (backend)
|
| 274 |
+
- Neon PostgreSQL (database)
|
| 275 |
+
|
| 276 |
+
---
|
| 277 |
+
|
| 278 |
+
## 📄 License
|
| 279 |
+
|
| 280 |
+
MIT License - See [LICENSE](LICENSE) for details.
|
| 281 |
+
|
| 282 |
+
---
|
| 283 |
+
|
| 284 |
+
## 🙏 Acknowledgments
|
| 285 |
+
|
| 286 |
+
- [Groq](https://groq.com) - Fast AI inference
|
| 287 |
+
- [Supabase](https://supabase.com) - Postgres + Storage
|
| 288 |
+
- [FastEmbed](https://github.com/qdrant/fastembed) - Embeddings
|
| 289 |
+
- [sentence-transformers](https://www.sbert.net) - Reranking models
|
backend/.env.example
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# MEXAR Core Engine - Backend Environment Variables
|
| 2 |
+
# Copy this file to .env and fill in your values
|
| 3 |
+
|
| 4 |
+
# ===========================================
|
| 5 |
+
# REQUIRED: Groq API (LLM, Whisper, Vision)
|
| 6 |
+
# ===========================================
|
| 7 |
+
# Get your free API key at: https://console.groq.com
|
| 8 |
+
GROQ_API_KEY=your_groq_api_key_here
|
| 9 |
+
|
| 10 |
+
# ===========================================
|
| 11 |
+
# REQUIRED: Database
|
| 12 |
+
# ===========================================
|
| 13 |
+
# PostgreSQL with pgvector extension
|
| 14 |
+
# For Supabase: Copy from Settings > Database > Connection string
|
| 15 |
+
DATABASE_URL=postgresql://user:password@host:5432/database
|
| 16 |
+
|
| 17 |
+
# ===========================================
|
| 18 |
+
# REQUIRED: Security
|
| 19 |
+
# ===========================================
|
| 20 |
+
# Generate a secure random key for JWT tokens
|
| 21 |
+
# Example: python -c "import secrets; print(secrets.token_urlsafe(32))"
|
| 22 |
+
SECRET_KEY=your_secure_secret_key_here
|
| 23 |
+
|
| 24 |
+
# ===========================================
|
| 25 |
+
# REQUIRED: Supabase Storage
|
| 26 |
+
# ===========================================
|
| 27 |
+
# Get from Supabase Dashboard > Settings > API
|
| 28 |
+
SUPABASE_URL=https://your-project-id.supabase.co
|
| 29 |
+
SUPABASE_KEY=your_supabase_service_role_key
|
| 30 |
+
|
| 31 |
+
# ===========================================
|
| 32 |
+
# OPTIONAL: Text-to-Speech
|
| 33 |
+
# ===========================================
|
| 34 |
+
# ElevenLabs API (10,000 chars/month free)
|
| 35 |
+
# Get at: https://elevenlabs.io
|
| 36 |
+
ELEVENLABS_API_KEY=your_elevenlabs_api_key_here
|
| 37 |
+
|
| 38 |
+
# ===========================================
|
| 39 |
+
# OPTIONAL: Local Storage Path
|
| 40 |
+
# ===========================================
|
| 41 |
+
# For development only, production uses Supabase Storage
|
| 42 |
+
STORAGE_PATH=./data/storage
|
backend/Procfile
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
web: python -m uvicorn main:app --host 0.0.0.0 --port $PORT
|
backend/api/admin.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
from fastapi import APIRouter, Depends
|
| 3 |
+
from sqlalchemy.orm import Session
|
| 4 |
+
|
| 5 |
+
from core.database import get_db
|
| 6 |
+
from core.cache import cache
|
| 7 |
+
from core.monitoring import analytics
|
| 8 |
+
from api.deps import get_current_user
|
| 9 |
+
from models.user import User
|
| 10 |
+
|
| 11 |
+
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
| 12 |
+
|
| 13 |
+
@router.get("/stats")
|
| 14 |
+
def get_system_stats(
|
| 15 |
+
current_user: User = Depends(get_current_user)
|
| 16 |
+
):
|
| 17 |
+
"""Get system statistics (admin only)."""
|
| 18 |
+
# In production, add admin check
|
| 19 |
+
stats = analytics.get_stats()
|
| 20 |
+
cache_stats = cache.get_stats()
|
| 21 |
+
|
| 22 |
+
return {
|
| 23 |
+
"analytics": stats,
|
| 24 |
+
"cache": cache_stats
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
@router.get("/health")
|
| 28 |
+
def detailed_health_check():
|
| 29 |
+
"""Detailed health check endpoint."""
|
| 30 |
+
return {
|
| 31 |
+
"status": "healthy",
|
| 32 |
+
"services": {
|
| 33 |
+
"database": "connected",
|
| 34 |
+
"cache": "active",
|
| 35 |
+
"workers": "ready"
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
@router.post("/cache/clear")
|
| 40 |
+
def clear_cache(
|
| 41 |
+
current_user: User = Depends(get_current_user)
|
| 42 |
+
):
|
| 43 |
+
"""Clear all cache entries."""
|
| 44 |
+
cache.clear()
|
| 45 |
+
return {"message": "Cache cleared successfully"}
|
| 46 |
+
|
| 47 |
+
@router.post("/analytics/reset")
|
| 48 |
+
def reset_analytics(
|
| 49 |
+
current_user: User = Depends(get_current_user)
|
| 50 |
+
):
|
| 51 |
+
"""Reset analytics counters."""
|
| 52 |
+
analytics.reset()
|
| 53 |
+
return {"message": "Analytics reset successfully"}
|
backend/api/agents.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MEXAR Agents API - Phase 2
|
| 3 |
+
Handles agent CRUD operations and knowledge graph data.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import json
|
| 7 |
+
import logging
|
| 8 |
+
from typing import List, Optional
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 13 |
+
from sqlalchemy.orm import Session
|
| 14 |
+
from pydantic import BaseModel, ConfigDict
|
| 15 |
+
|
| 16 |
+
from core.database import get_db
|
| 17 |
+
from services.agent_service import agent_service
|
| 18 |
+
from api.deps import get_current_user
|
| 19 |
+
from models.user import User
|
| 20 |
+
|
| 21 |
+
logger = logging.getLogger(__name__)
|
| 22 |
+
|
| 23 |
+
router = APIRouter(prefix="/api/agents", tags=["agents"])
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# ===== PYDANTIC MODELS =====
|
| 27 |
+
|
| 28 |
+
class AgentCreate(BaseModel):
|
| 29 |
+
name: str
|
| 30 |
+
system_prompt: str
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class AgentResponse(BaseModel):
|
| 34 |
+
id: int
|
| 35 |
+
name: str
|
| 36 |
+
status: str
|
| 37 |
+
domain: Optional[str] = None
|
| 38 |
+
entity_count: int
|
| 39 |
+
created_at: datetime
|
| 40 |
+
stats: dict = {}
|
| 41 |
+
|
| 42 |
+
model_config = ConfigDict(from_attributes=True)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# ===== LIST AGENTS =====
|
| 46 |
+
|
| 47 |
+
@router.get("/", response_model=List[AgentResponse])
|
| 48 |
+
def list_agents(
|
| 49 |
+
db: Session = Depends(get_db),
|
| 50 |
+
current_user: User = Depends(get_current_user)
|
| 51 |
+
):
|
| 52 |
+
"""List all agents owned by the current user."""
|
| 53 |
+
agents = agent_service.list_agents(db, current_user)
|
| 54 |
+
response = []
|
| 55 |
+
|
| 56 |
+
for agent in agents:
|
| 57 |
+
stats = {}
|
| 58 |
+
if agent.storage_path:
|
| 59 |
+
try:
|
| 60 |
+
metadata_path = Path(agent.storage_path) / "metadata.json"
|
| 61 |
+
if metadata_path.exists():
|
| 62 |
+
with open(metadata_path, 'r', encoding='utf-8') as f:
|
| 63 |
+
data = json.load(f)
|
| 64 |
+
stats = data.get("stats", {})
|
| 65 |
+
except Exception as e:
|
| 66 |
+
logger.warning(f"Failed to load stats for agent {agent.name}: {e}")
|
| 67 |
+
|
| 68 |
+
# Convert SQLAlchemy object to dict to include extra fields
|
| 69 |
+
agent_dict = {
|
| 70 |
+
"id": agent.id,
|
| 71 |
+
"name": agent.name,
|
| 72 |
+
"status": agent.status,
|
| 73 |
+
"domain": agent.domain,
|
| 74 |
+
"entity_count": agent.entity_count,
|
| 75 |
+
"created_at": agent.created_at,
|
| 76 |
+
"stats": stats
|
| 77 |
+
}
|
| 78 |
+
response.append(agent_dict)
|
| 79 |
+
|
| 80 |
+
return response
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
# ===== CREATE AGENT =====
|
| 84 |
+
|
| 85 |
+
@router.post("/", response_model=AgentResponse)
|
| 86 |
+
def create_agent(
|
| 87 |
+
agent_in: AgentCreate,
|
| 88 |
+
db: Session = Depends(get_db),
|
| 89 |
+
current_user: User = Depends(get_current_user)
|
| 90 |
+
):
|
| 91 |
+
"""Create a new agent entry (compilation happens via /api/compile)."""
|
| 92 |
+
try:
|
| 93 |
+
return agent_service.create_agent(
|
| 94 |
+
db,
|
| 95 |
+
current_user,
|
| 96 |
+
agent_in.name,
|
| 97 |
+
agent_in.system_prompt
|
| 98 |
+
)
|
| 99 |
+
except ValueError as e:
|
| 100 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
# ===== GET AGENT DETAILS =====
|
| 104 |
+
|
| 105 |
+
@router.get("/{agent_name}")
|
| 106 |
+
def get_agent_details(
|
| 107 |
+
agent_name: str,
|
| 108 |
+
db: Session = Depends(get_db),
|
| 109 |
+
current_user: User = Depends(get_current_user)
|
| 110 |
+
):
|
| 111 |
+
"""Get full details of an agent including compiled stats."""
|
| 112 |
+
agent = agent_service.get_agent(db, current_user, agent_name)
|
| 113 |
+
if not agent:
|
| 114 |
+
raise HTTPException(status_code=404, detail="Agent not found")
|
| 115 |
+
|
| 116 |
+
# Build response with database info
|
| 117 |
+
response = {
|
| 118 |
+
"id": agent.id,
|
| 119 |
+
"name": agent.name,
|
| 120 |
+
"status": agent.status,
|
| 121 |
+
"system_prompt": agent.system_prompt,
|
| 122 |
+
"domain": agent.domain,
|
| 123 |
+
"created_at": agent.created_at,
|
| 124 |
+
"entity_count": agent.entity_count,
|
| 125 |
+
"storage_path": agent.storage_path,
|
| 126 |
+
"stats": {},
|
| 127 |
+
"metadata": {}
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
# Load compiled metadata for stats
|
| 131 |
+
if agent.storage_path:
|
| 132 |
+
storage_path = Path(agent.storage_path)
|
| 133 |
+
metadata_file = storage_path / "metadata.json"
|
| 134 |
+
|
| 135 |
+
if metadata_file.exists():
|
| 136 |
+
try:
|
| 137 |
+
with open(metadata_file, 'r', encoding='utf-8') as f:
|
| 138 |
+
metadata = json.load(f)
|
| 139 |
+
response["metadata"] = metadata
|
| 140 |
+
response["stats"] = metadata.get("stats", {})
|
| 141 |
+
except Exception as e:
|
| 142 |
+
logger.warning(f"Failed to load metadata for {agent_name}: {e}")
|
| 143 |
+
|
| 144 |
+
return response
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
# ===== GET KNOWLEDGE GRAPH DATA =====
|
| 148 |
+
|
| 149 |
+
@router.get("/{agent_name}/graph")
|
| 150 |
+
def get_agent_graph(
|
| 151 |
+
agent_name: str,
|
| 152 |
+
db: Session = Depends(get_db),
|
| 153 |
+
current_user: User = Depends(get_current_user)
|
| 154 |
+
):
|
| 155 |
+
"""
|
| 156 |
+
Get knowledge graph data for D3.js visualization.
|
| 157 |
+
|
| 158 |
+
Returns nodes and links in D3-compatible format.
|
| 159 |
+
"""
|
| 160 |
+
agent = agent_service.get_agent(db, current_user, agent_name)
|
| 161 |
+
if not agent:
|
| 162 |
+
raise HTTPException(status_code=404, detail="Agent not found")
|
| 163 |
+
|
| 164 |
+
if agent.status != "ready":
|
| 165 |
+
raise HTTPException(
|
| 166 |
+
status_code=400,
|
| 167 |
+
detail=f"Agent is not ready. Status: {agent.status}"
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
# Load knowledge graph from file
|
| 171 |
+
storage_path = Path(agent.storage_path)
|
| 172 |
+
graph_file = storage_path / "knowledge_graph.json"
|
| 173 |
+
|
| 174 |
+
if not graph_file.exists():
|
| 175 |
+
raise HTTPException(status_code=404, detail="Knowledge graph not found")
|
| 176 |
+
|
| 177 |
+
try:
|
| 178 |
+
with open(graph_file, 'r', encoding='utf-8') as f:
|
| 179 |
+
graph_data = json.load(f)
|
| 180 |
+
|
| 181 |
+
# Convert to D3.js format
|
| 182 |
+
nodes = []
|
| 183 |
+
links = []
|
| 184 |
+
node_ids = set()
|
| 185 |
+
|
| 186 |
+
# Extract nodes from graph data
|
| 187 |
+
if "nodes" in graph_data:
|
| 188 |
+
for node in graph_data["nodes"]:
|
| 189 |
+
node_id = node.get("id", str(node))
|
| 190 |
+
if node_id not in node_ids:
|
| 191 |
+
nodes.append({
|
| 192 |
+
"id": node_id,
|
| 193 |
+
"label": node.get("label", node_id),
|
| 194 |
+
"type": node.get("type", "entity"),
|
| 195 |
+
"group": hash(node.get("type", "entity")) % 10
|
| 196 |
+
})
|
| 197 |
+
node_ids.add(node_id)
|
| 198 |
+
|
| 199 |
+
# Extract links/edges
|
| 200 |
+
if "edges" in graph_data:
|
| 201 |
+
for edge in graph_data["edges"]:
|
| 202 |
+
source = edge.get("source", edge.get("from"))
|
| 203 |
+
target = edge.get("target", edge.get("to"))
|
| 204 |
+
if source and target:
|
| 205 |
+
links.append({
|
| 206 |
+
"source": source,
|
| 207 |
+
"target": target,
|
| 208 |
+
"label": edge.get("relation", edge.get("label", "")),
|
| 209 |
+
"weight": edge.get("weight", 1)
|
| 210 |
+
})
|
| 211 |
+
elif "links" in graph_data:
|
| 212 |
+
links = graph_data["links"]
|
| 213 |
+
|
| 214 |
+
return {
|
| 215 |
+
"nodes": nodes,
|
| 216 |
+
"links": links,
|
| 217 |
+
"stats": {
|
| 218 |
+
"node_count": len(nodes),
|
| 219 |
+
"link_count": len(links)
|
| 220 |
+
}
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
except json.JSONDecodeError as e:
|
| 224 |
+
logger.error(f"Failed to parse knowledge graph: {e}")
|
| 225 |
+
raise HTTPException(status_code=500, detail="Invalid graph data")
|
| 226 |
+
except Exception as e:
|
| 227 |
+
logger.error(f"Error loading graph: {e}")
|
| 228 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
# ===== GET AGENT EXPLAINABILITY =====
|
| 232 |
+
|
| 233 |
+
@router.get("/{agent_name}/explainability")
|
| 234 |
+
def get_agent_explainability(
|
| 235 |
+
agent_name: str,
|
| 236 |
+
db: Session = Depends(get_db),
|
| 237 |
+
current_user: User = Depends(get_current_user)
|
| 238 |
+
):
|
| 239 |
+
"""Get explainability metadata for an agent."""
|
| 240 |
+
agent = agent_service.get_agent(db, current_user, agent_name)
|
| 241 |
+
if not agent:
|
| 242 |
+
raise HTTPException(status_code=404, detail="Agent not found")
|
| 243 |
+
|
| 244 |
+
storage_path = Path(agent.storage_path)
|
| 245 |
+
metadata_file = storage_path / "metadata.json"
|
| 246 |
+
|
| 247 |
+
if not metadata_file.exists():
|
| 248 |
+
return {"explainability": None}
|
| 249 |
+
|
| 250 |
+
try:
|
| 251 |
+
with open(metadata_file, 'r', encoding='utf-8') as f:
|
| 252 |
+
metadata = json.load(f)
|
| 253 |
+
|
| 254 |
+
return {
|
| 255 |
+
"agent_name": agent_name,
|
| 256 |
+
"domain": metadata.get("prompt_analysis", {}).get("domain"),
|
| 257 |
+
"domain_signature": metadata.get("domain_signature", []),
|
| 258 |
+
"capabilities": metadata.get("prompt_analysis", {}).get("capabilities", []),
|
| 259 |
+
"stats": metadata.get("stats", {})
|
| 260 |
+
}
|
| 261 |
+
except Exception as e:
|
| 262 |
+
logger.warning(f"Failed to load explainability for {agent_name}: {e}")
|
| 263 |
+
return {"explainability": None}
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
# ===== DELETE AGENT =====
|
| 267 |
+
|
| 268 |
+
@router.delete("/{agent_name}")
|
| 269 |
+
def delete_agent(
|
| 270 |
+
agent_name: str,
|
| 271 |
+
db: Session = Depends(get_db),
|
| 272 |
+
current_user: User = Depends(get_current_user)
|
| 273 |
+
):
|
| 274 |
+
"""Delete an agent and its files."""
|
| 275 |
+
try:
|
| 276 |
+
agent_service.delete_agent(db, current_user, agent_name)
|
| 277 |
+
return {"message": f"Agent '{agent_name}' deleted successfully"}
|
| 278 |
+
except ValueError as e:
|
| 279 |
+
raise HTTPException(status_code=404, detail=str(e))
|
| 280 |
+
except Exception as e:
|
| 281 |
+
raise HTTPException(status_code=500, detail=str(e))
|
backend/api/auth.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 3 |
+
from sqlalchemy.orm import Session
|
| 4 |
+
from typing import Dict, Any, Optional
|
| 5 |
+
from pydantic import BaseModel, EmailStr
|
| 6 |
+
from core.database import get_db
|
| 7 |
+
from services.auth_service import auth_service
|
| 8 |
+
from api.deps import get_current_user
|
| 9 |
+
from models.user import User
|
| 10 |
+
|
| 11 |
+
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
| 12 |
+
|
| 13 |
+
# Pydantic models
|
| 14 |
+
class UserCreate(BaseModel):
|
| 15 |
+
email: EmailStr
|
| 16 |
+
password: str
|
| 17 |
+
|
| 18 |
+
class UserLogin(BaseModel):
|
| 19 |
+
email: EmailStr
|
| 20 |
+
password: str
|
| 21 |
+
|
| 22 |
+
class Token(BaseModel):
|
| 23 |
+
access_token: str
|
| 24 |
+
token_type: str
|
| 25 |
+
user: dict
|
| 26 |
+
|
| 27 |
+
class PasswordChange(BaseModel):
|
| 28 |
+
old_password: str
|
| 29 |
+
new_password: str
|
| 30 |
+
|
| 31 |
+
class UserPreferences(BaseModel):
|
| 32 |
+
tts_provider: str = "elevenlabs"
|
| 33 |
+
auto_play_tts: bool = False
|
| 34 |
+
other: Optional[Dict[str, Any]] = {}
|
| 35 |
+
|
| 36 |
+
@router.post("/register", response_model=dict)
|
| 37 |
+
def register(user_in: UserCreate, db: Session = Depends(get_db)):
|
| 38 |
+
"""Register a new user"""
|
| 39 |
+
try:
|
| 40 |
+
user = auth_service.register_user(db, user_in.email, user_in.password)
|
| 41 |
+
return {"message": "User registered successfully", "id": user.id, "email": user.email}
|
| 42 |
+
except ValueError as e:
|
| 43 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 44 |
+
|
| 45 |
+
@router.post("/login", response_model=Token)
|
| 46 |
+
def login(user_in: UserLogin, db: Session = Depends(get_db)):
|
| 47 |
+
"""Login and get token"""
|
| 48 |
+
result = auth_service.authenticate_user(db, user_in.email, user_in.password)
|
| 49 |
+
if not result:
|
| 50 |
+
raise HTTPException(
|
| 51 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 52 |
+
detail="Incorrect email or password",
|
| 53 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 54 |
+
)
|
| 55 |
+
return result
|
| 56 |
+
|
| 57 |
+
@router.get("/me")
|
| 58 |
+
def read_users_me(current_user: User = Depends(get_current_user)):
|
| 59 |
+
"""Get current user data"""
|
| 60 |
+
return {
|
| 61 |
+
"id": current_user.id,
|
| 62 |
+
"email": current_user.email,
|
| 63 |
+
"id": current_user.id,
|
| 64 |
+
"email": current_user.email,
|
| 65 |
+
"created_at": current_user.created_at,
|
| 66 |
+
"preferences": current_user.preferences or {}
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
@router.put("/preferences")
|
| 70 |
+
def update_preferences(
|
| 71 |
+
prefs: UserPreferences,
|
| 72 |
+
current_user: User = Depends(get_current_user),
|
| 73 |
+
db: Session = Depends(get_db)
|
| 74 |
+
):
|
| 75 |
+
"""Update user preferences"""
|
| 76 |
+
# Initialize defaults if None
|
| 77 |
+
current_prefs = dict(current_user.preferences) if current_user.preferences else {}
|
| 78 |
+
|
| 79 |
+
# Update values
|
| 80 |
+
current_prefs["tts_provider"] = prefs.tts_provider
|
| 81 |
+
current_prefs["auto_play_tts"] = prefs.auto_play_tts
|
| 82 |
+
if prefs.other:
|
| 83 |
+
current_prefs.update(prefs.other)
|
| 84 |
+
|
| 85 |
+
current_user.preferences = current_prefs
|
| 86 |
+
db.commit()
|
| 87 |
+
db.refresh(current_user)
|
| 88 |
+
|
| 89 |
+
return {"message": "Preferences updated", "preferences": current_user.preferences}
|
| 90 |
+
|
| 91 |
+
@router.post("/change-password")
|
| 92 |
+
def change_password(
|
| 93 |
+
password_data: PasswordChange,
|
| 94 |
+
current_user: User = Depends(get_current_user),
|
| 95 |
+
db: Session = Depends(get_db)
|
| 96 |
+
):
|
| 97 |
+
"""Change user password"""
|
| 98 |
+
try:
|
| 99 |
+
auth_service.change_password(
|
| 100 |
+
db,
|
| 101 |
+
current_user.email,
|
| 102 |
+
password_data.old_password,
|
| 103 |
+
password_data.new_password
|
| 104 |
+
)
|
| 105 |
+
return {"message": "Password updated successfully"}
|
| 106 |
+
except ValueError as e:
|
| 107 |
+
raise HTTPException(
|
| 108 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 109 |
+
detail=str(e)
|
| 110 |
+
)
|
backend/api/chat.py
ADDED
|
@@ -0,0 +1,511 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MEXAR Chat API - Phase 2
|
| 3 |
+
Handles all chat interactions with agents.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from typing import Optional
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
import shutil
|
| 9 |
+
import uuid
|
| 10 |
+
import logging
|
| 11 |
+
|
| 12 |
+
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
|
| 13 |
+
from fastapi.responses import FileResponse
|
| 14 |
+
from sqlalchemy.orm import Session
|
| 15 |
+
from pydantic import BaseModel
|
| 16 |
+
|
| 17 |
+
from core.database import get_db
|
| 18 |
+
from services.agent_service import agent_service
|
| 19 |
+
from services.tts_service import get_tts_service
|
| 20 |
+
from services.storage_service import storage_service
|
| 21 |
+
from services.conversation_service import conversation_service
|
| 22 |
+
from api.deps import get_current_user
|
| 23 |
+
from models.user import User
|
| 24 |
+
from modules.reasoning_engine import create_reasoning_engine
|
| 25 |
+
from modules.explainability import create_explainability_generator
|
| 26 |
+
|
| 27 |
+
logger = logging.getLogger(__name__)
|
| 28 |
+
|
| 29 |
+
router = APIRouter(prefix="/api/chat", tags=["chat"])
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
# Pydantic models for JSON requests
|
| 33 |
+
class ChatRequest(BaseModel):
|
| 34 |
+
agent_name: str
|
| 35 |
+
message: str
|
| 36 |
+
include_explainability: bool = True
|
| 37 |
+
include_tts: bool = False
|
| 38 |
+
tts_provider: str = "elevenlabs" # "elevenlabs" or "web_speech"
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class MultimodalChatRequest(BaseModel):
|
| 42 |
+
agent_name: str
|
| 43 |
+
message: str = ""
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class TTSRequest(BaseModel):
|
| 47 |
+
text: str
|
| 48 |
+
provider: str = "elevenlabs" # "elevenlabs" or "web_speech"
|
| 49 |
+
voice_id: Optional[str] = None
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
# ===== MAIN CHAT ENDPOINT (JSON) =====
|
| 53 |
+
|
| 54 |
+
@router.post("")
|
| 55 |
+
@router.post("/")
|
| 56 |
+
async def chat_json(
|
| 57 |
+
request: ChatRequest,
|
| 58 |
+
db: Session = Depends(get_db),
|
| 59 |
+
current_user: User = Depends(get_current_user)
|
| 60 |
+
):
|
| 61 |
+
"""
|
| 62 |
+
Chat with an agent using JSON body.
|
| 63 |
+
This is the primary endpoint used by the frontend.
|
| 64 |
+
"""
|
| 65 |
+
# Get agent with ownership check
|
| 66 |
+
agent = agent_service.get_agent(db, current_user, request.agent_name)
|
| 67 |
+
if not agent:
|
| 68 |
+
raise HTTPException(status_code=404, detail=f"Agent '{request.agent_name}' not found")
|
| 69 |
+
|
| 70 |
+
if agent.status != "ready":
|
| 71 |
+
raise HTTPException(
|
| 72 |
+
status_code=400,
|
| 73 |
+
detail=f"Agent is not ready. Current status: {agent.status}"
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
# Get/Create conversation
|
| 77 |
+
conversation = conversation_service.get_or_create_conversation(
|
| 78 |
+
db, agent.id, current_user.id
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
# Log USER message
|
| 82 |
+
conversation_service.add_message(
|
| 83 |
+
db, conversation.id, "user", request.message
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
try:
|
| 87 |
+
# Use agent's storage path for reasoning engine
|
| 88 |
+
storage_path = Path(agent.storage_path).parent
|
| 89 |
+
engine = create_reasoning_engine(str(storage_path))
|
| 90 |
+
|
| 91 |
+
result = engine.reason(
|
| 92 |
+
agent_name=agent.name,
|
| 93 |
+
query=request.message
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
response = {
|
| 97 |
+
"success": True,
|
| 98 |
+
"answer": result["answer"],
|
| 99 |
+
"confidence": result["confidence"],
|
| 100 |
+
"in_domain": result["in_domain"]
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
if request.include_explainability:
|
| 104 |
+
try:
|
| 105 |
+
explainer = create_explainability_generator()
|
| 106 |
+
response["explainability"] = explainer.generate(result)
|
| 107 |
+
except Exception as e:
|
| 108 |
+
logger.warning(f"Explainability generation failed: {e}")
|
| 109 |
+
response["explainability"] = result.get("explainability")
|
| 110 |
+
|
| 111 |
+
# Log ASSISTANT message
|
| 112 |
+
conversation_service.add_message(
|
| 113 |
+
db,
|
| 114 |
+
conversation.id,
|
| 115 |
+
"assistant",
|
| 116 |
+
result["answer"],
|
| 117 |
+
explainability_data=response.get("explainability"),
|
| 118 |
+
confidence=result["confidence"]
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
# Generate TTS if requested
|
| 122 |
+
if request.include_tts:
|
| 123 |
+
try:
|
| 124 |
+
tts_service = get_tts_service()
|
| 125 |
+
tts_result = tts_service.generate_speech(
|
| 126 |
+
text=result["answer"],
|
| 127 |
+
provider=request.tts_provider
|
| 128 |
+
)
|
| 129 |
+
response["tts"] = tts_result
|
| 130 |
+
except Exception as e:
|
| 131 |
+
logger.warning(f"TTS generation failed: {e}")
|
| 132 |
+
response["tts"] = {"success": False, "error": str(e)}
|
| 133 |
+
|
| 134 |
+
return response
|
| 135 |
+
|
| 136 |
+
except Exception as e:
|
| 137 |
+
logger.error(f"Chat error: {e}")
|
| 138 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
# ===== MULTIMODAL CHAT ENDPOINT =====
|
| 142 |
+
|
| 143 |
+
@router.post("/multimodal")
|
| 144 |
+
async def chat_multimodal(
|
| 145 |
+
agent_name: str = Form(...),
|
| 146 |
+
message: str = Form(""),
|
| 147 |
+
audio: UploadFile = File(None),
|
| 148 |
+
image: UploadFile = File(None),
|
| 149 |
+
include_explainability: bool = Form(True),
|
| 150 |
+
include_tts: bool = Form(False),
|
| 151 |
+
tts_provider: str = Form("elevenlabs"),
|
| 152 |
+
db: Session = Depends(get_db),
|
| 153 |
+
current_user: User = Depends(get_current_user)
|
| 154 |
+
):
|
| 155 |
+
"""
|
| 156 |
+
Chat with an agent using multimodal inputs (audio/image).
|
| 157 |
+
Uses multipart form data.
|
| 158 |
+
"""
|
| 159 |
+
from modules.multimodal_processor import create_multimodal_processor
|
| 160 |
+
|
| 161 |
+
# Get agent with ownership check
|
| 162 |
+
agent = agent_service.get_agent(db, current_user, agent_name)
|
| 163 |
+
if not agent:
|
| 164 |
+
raise HTTPException(status_code=404, detail=f"Agent '{agent_name}' not found")
|
| 165 |
+
|
| 166 |
+
if agent.status != "ready":
|
| 167 |
+
raise HTTPException(
|
| 168 |
+
status_code=400,
|
| 169 |
+
detail=f"Agent is not ready. Current status: {agent.status}"
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
# Get/Create conversation
|
| 173 |
+
conversation = conversation_service.get_or_create_conversation(
|
| 174 |
+
db, agent.id, current_user.id
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
try:
|
| 178 |
+
multimodal_context = ""
|
| 179 |
+
audio_url = None
|
| 180 |
+
image_url = None
|
| 181 |
+
|
| 182 |
+
# Process audio if provided
|
| 183 |
+
if audio and audio.filename:
|
| 184 |
+
# Upload to Supabase Storage
|
| 185 |
+
upload_result = await storage_service.upload_file(
|
| 186 |
+
file=audio,
|
| 187 |
+
bucket="chat-media",
|
| 188 |
+
folder=f"audio/{agent.id}"
|
| 189 |
+
)
|
| 190 |
+
audio_url = upload_result["url"]
|
| 191 |
+
|
| 192 |
+
# Save temporarily for processing
|
| 193 |
+
temp_dir = Path("data/temp")
|
| 194 |
+
temp_dir.mkdir(parents=True, exist_ok=True)
|
| 195 |
+
temp_path = temp_dir / f"{uuid.uuid4()}{Path(audio.filename).suffix}"
|
| 196 |
+
|
| 197 |
+
with open(temp_path, "wb") as buffer:
|
| 198 |
+
await audio.seek(0) # Reset file pointer
|
| 199 |
+
shutil.copyfileobj(audio.file, buffer)
|
| 200 |
+
|
| 201 |
+
processor = create_multimodal_processor()
|
| 202 |
+
audio_text = processor.process_audio(str(temp_path))
|
| 203 |
+
if audio_text:
|
| 204 |
+
multimodal_context += f"\n[AUDIO TRANSCRIPTION]: {audio_text}"
|
| 205 |
+
|
| 206 |
+
# Clean up temp file
|
| 207 |
+
try:
|
| 208 |
+
temp_path.unlink()
|
| 209 |
+
except:
|
| 210 |
+
pass
|
| 211 |
+
|
| 212 |
+
# Process image if provided
|
| 213 |
+
if image and image.filename:
|
| 214 |
+
# Upload to Supabase Storage
|
| 215 |
+
upload_result = await storage_service.upload_file(
|
| 216 |
+
file=image,
|
| 217 |
+
bucket="chat-media",
|
| 218 |
+
folder=f"images/{agent.id}"
|
| 219 |
+
)
|
| 220 |
+
image_url = upload_result["url"]
|
| 221 |
+
logger.info(f"[MULTIMODAL] Image uploaded to Supabase: {image_url}")
|
| 222 |
+
|
| 223 |
+
# Save temporarily for processing
|
| 224 |
+
temp_dir = Path("data/temp")
|
| 225 |
+
temp_dir.mkdir(parents=True, exist_ok=True)
|
| 226 |
+
temp_path = temp_dir / f"{uuid.uuid4()}{Path(image.filename).suffix}"
|
| 227 |
+
|
| 228 |
+
logger.info(f"[MULTIMODAL] Saving temp file: {temp_path}")
|
| 229 |
+
|
| 230 |
+
with open(temp_path, "wb") as buffer:
|
| 231 |
+
await image.seek(0) # Reset file pointer
|
| 232 |
+
shutil.copyfileobj(image.file, buffer)
|
| 233 |
+
|
| 234 |
+
file_size = temp_path.stat().st_size
|
| 235 |
+
logger.info(f"[MULTIMODAL] Temp file saved, size: {file_size} bytes")
|
| 236 |
+
|
| 237 |
+
try:
|
| 238 |
+
logger.info(f"[MULTIMODAL] Starting image analysis with Groq Vision...")
|
| 239 |
+
processor = create_multimodal_processor()
|
| 240 |
+
image_result = processor.process_image(str(temp_path))
|
| 241 |
+
|
| 242 |
+
logger.info(f"[MULTIMODAL] Image processing result: {image_result.get('success')}")
|
| 243 |
+
|
| 244 |
+
if image_result.get("success"):
|
| 245 |
+
image_desc = image_result.get("description", "")
|
| 246 |
+
if image_desc:
|
| 247 |
+
logger.info(f"[MULTIMODAL] ✓ Image analyzed successfully, description length: {len(image_desc)} chars")
|
| 248 |
+
logger.info(f"[MULTIMODAL] Description preview: {image_desc[:150]}...")
|
| 249 |
+
multimodal_context += f"\n[IMAGE DESCRIPTION]: {image_desc}"
|
| 250 |
+
else:
|
| 251 |
+
logger.warning(f"[MULTIMODAL] Image analysis returned success but empty description")
|
| 252 |
+
multimodal_context += f"\n[IMAGE]: User uploaded an image named {image.filename}"
|
| 253 |
+
else:
|
| 254 |
+
# Log error but don't fail - provide basic context
|
| 255 |
+
error_msg = image_result.get('error', 'Unknown error')
|
| 256 |
+
error_type = image_result.get('error_type', 'Unknown')
|
| 257 |
+
logger.warning(f"[MULTIMODAL] Image analysis failed - {error_type}: {error_msg}")
|
| 258 |
+
multimodal_context += f"\n[IMAGE]: User uploaded an image named {image.filename}"
|
| 259 |
+
|
| 260 |
+
except Exception as e:
|
| 261 |
+
logger.error(f"[MULTIMODAL] Image processing exception: {type(e).__name__}: {str(e)}")
|
| 262 |
+
import traceback
|
| 263 |
+
logger.error(f"[MULTIMODAL] Traceback: {traceback.format_exc()}")
|
| 264 |
+
multimodal_context += f"\n[IMAGE]: User uploaded an image named {image.filename}"
|
| 265 |
+
|
| 266 |
+
# Clean up temp file
|
| 267 |
+
try:
|
| 268 |
+
temp_path.unlink()
|
| 269 |
+
logger.info(f"[MULTIMODAL] Temp file cleaned up")
|
| 270 |
+
except:
|
| 271 |
+
pass
|
| 272 |
+
|
| 273 |
+
# Run reasoning
|
| 274 |
+
storage_path = Path(agent.storage_path).parent
|
| 275 |
+
engine = create_reasoning_engine(str(storage_path))
|
| 276 |
+
|
| 277 |
+
result = engine.reason(
|
| 278 |
+
agent_name=agent.name,
|
| 279 |
+
query=message,
|
| 280 |
+
multimodal_context=multimodal_context
|
| 281 |
+
)
|
| 282 |
+
|
| 283 |
+
# Log USER message with attachments
|
| 284 |
+
conversation_service.add_message(
|
| 285 |
+
db,
|
| 286 |
+
conversation.id,
|
| 287 |
+
"user",
|
| 288 |
+
message,
|
| 289 |
+
multimodal_data={
|
| 290 |
+
"audio_url": audio_url,
|
| 291 |
+
"image_url": image_url
|
| 292 |
+
}
|
| 293 |
+
)
|
| 294 |
+
|
| 295 |
+
response = {
|
| 296 |
+
"success": True,
|
| 297 |
+
"answer": result["answer"],
|
| 298 |
+
"confidence": result["confidence"],
|
| 299 |
+
"in_domain": result["in_domain"],
|
| 300 |
+
"audio_url": audio_url,
|
| 301 |
+
"image_url": image_url
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
if include_explainability:
|
| 305 |
+
try:
|
| 306 |
+
explainer = create_explainability_generator()
|
| 307 |
+
response["explainability"] = explainer.generate(result)
|
| 308 |
+
except Exception:
|
| 309 |
+
response["explainability"] = result.get("explainability")
|
| 310 |
+
|
| 311 |
+
# Log ASSISTANT message
|
| 312 |
+
conversation_service.add_message(
|
| 313 |
+
db,
|
| 314 |
+
conversation.id,
|
| 315 |
+
"assistant",
|
| 316 |
+
result["answer"],
|
| 317 |
+
explainability_data=response.get("explainability"),
|
| 318 |
+
confidence=result["confidence"]
|
| 319 |
+
)
|
| 320 |
+
|
| 321 |
+
# Generate TTS if requested
|
| 322 |
+
if include_tts:
|
| 323 |
+
try:
|
| 324 |
+
tts_service = get_tts_service()
|
| 325 |
+
tts_result = tts_service.generate_speech(
|
| 326 |
+
text=result["answer"],
|
| 327 |
+
provider=tts_provider
|
| 328 |
+
)
|
| 329 |
+
response["tts"] = tts_result
|
| 330 |
+
except Exception as e:
|
| 331 |
+
logger.warning(f"TTS generation failed: {e}")
|
| 332 |
+
response["tts"] = {"success": False, "error": str(e)}
|
| 333 |
+
|
| 334 |
+
return response
|
| 335 |
+
|
| 336 |
+
except Exception as e:
|
| 337 |
+
logger.error(f"Multimodal chat error: {e}")
|
| 338 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
# ===== HISTORY ENDPOINTS =====
|
| 342 |
+
|
| 343 |
+
@router.get("/{agent_name}/history")
|
| 344 |
+
def get_chat_history(
|
| 345 |
+
agent_name: str,
|
| 346 |
+
limit: int = 50,
|
| 347 |
+
db: Session = Depends(get_db),
|
| 348 |
+
current_user: User = Depends(get_current_user)
|
| 349 |
+
):
|
| 350 |
+
"""Get conversation history with an agent."""
|
| 351 |
+
from services.conversation_service import conversation_service
|
| 352 |
+
|
| 353 |
+
agent = agent_service.get_agent(db, current_user, agent_name)
|
| 354 |
+
if not agent:
|
| 355 |
+
raise HTTPException(status_code=404, detail="Agent not found")
|
| 356 |
+
|
| 357 |
+
history = conversation_service.get_conversation_history(
|
| 358 |
+
db, agent.id, current_user.id, limit
|
| 359 |
+
)
|
| 360 |
+
return {"messages": history}
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
@router.delete("/{agent_name}/history")
|
| 364 |
+
def clear_chat_history(
|
| 365 |
+
agent_name: str,
|
| 366 |
+
db: Session = Depends(get_db),
|
| 367 |
+
current_user: User = Depends(get_current_user)
|
| 368 |
+
):
|
| 369 |
+
"""Clear conversation history with an agent."""
|
| 370 |
+
from models.conversation import Conversation
|
| 371 |
+
|
| 372 |
+
agent = agent_service.get_agent(db, current_user, agent_name)
|
| 373 |
+
if not agent:
|
| 374 |
+
raise HTTPException(status_code=404, detail="Agent not found")
|
| 375 |
+
|
| 376 |
+
conversation = db.query(Conversation).filter(
|
| 377 |
+
Conversation.agent_id == agent.id,
|
| 378 |
+
Conversation.user_id == current_user.id
|
| 379 |
+
).first()
|
| 380 |
+
|
| 381 |
+
if conversation:
|
| 382 |
+
db.delete(conversation)
|
| 383 |
+
db.commit()
|
| 384 |
+
|
| 385 |
+
return {"message": "Chat history cleared"}
|
| 386 |
+
|
| 387 |
+
|
| 388 |
+
# ===== TEXT-TO-SPEECH ENDPOINTS =====
|
| 389 |
+
|
| 390 |
+
@router.post("/tts/generate")
|
| 391 |
+
async def generate_tts(
|
| 392 |
+
request: TTSRequest,
|
| 393 |
+
current_user: User = Depends(get_current_user)
|
| 394 |
+
):
|
| 395 |
+
"""Generate text-to-speech audio."""
|
| 396 |
+
try:
|
| 397 |
+
tts_service = get_tts_service()
|
| 398 |
+
result = tts_service.generate_speech(
|
| 399 |
+
text=request.text,
|
| 400 |
+
provider=request.provider,
|
| 401 |
+
voice_id=request.voice_id
|
| 402 |
+
)
|
| 403 |
+
return result
|
| 404 |
+
except Exception as e:
|
| 405 |
+
logger.error(f"TTS generation error: {e}")
|
| 406 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 407 |
+
|
| 408 |
+
|
| 409 |
+
@router.get("/tts/audio/{filename}")
|
| 410 |
+
async def serve_tts_audio(filename: str):
|
| 411 |
+
"""Serve cached TTS audio files."""
|
| 412 |
+
audio_path = Path("data/tts_cache") / filename
|
| 413 |
+
|
| 414 |
+
if not audio_path.exists():
|
| 415 |
+
raise HTTPException(status_code=404, detail="Audio file not found")
|
| 416 |
+
|
| 417 |
+
return FileResponse(
|
| 418 |
+
path=audio_path,
|
| 419 |
+
media_type="audio/mpeg",
|
| 420 |
+
filename=filename
|
| 421 |
+
)
|
| 422 |
+
|
| 423 |
+
|
| 424 |
+
@router.get("/tts/voices")
|
| 425 |
+
async def get_tts_voices(
|
| 426 |
+
provider: str = "elevenlabs",
|
| 427 |
+
current_user: User = Depends(get_current_user)
|
| 428 |
+
):
|
| 429 |
+
"""Get available TTS voices for a provider."""
|
| 430 |
+
try:
|
| 431 |
+
tts_service = get_tts_service()
|
| 432 |
+
voices = tts_service.get_available_voices(provider)
|
| 433 |
+
return {"provider": provider, "voices": voices}
|
| 434 |
+
except Exception as e:
|
| 435 |
+
logger.error(f"Failed to fetch voices: {e}")
|
| 436 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 437 |
+
|
| 438 |
+
|
| 439 |
+
@router.get("/tts/quota")
|
| 440 |
+
async def get_tts_quota(current_user: User = Depends(get_current_user)):
|
| 441 |
+
"""Check TTS quota for ElevenLabs."""
|
| 442 |
+
try:
|
| 443 |
+
tts_service = get_tts_service()
|
| 444 |
+
quota = tts_service.check_quota()
|
| 445 |
+
return quota
|
| 446 |
+
except Exception as e:
|
| 447 |
+
logger.error(f"Failed to check quota: {e}")
|
| 448 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
# ===== LIVE AUDIO TRANSCRIPTION =====
|
| 452 |
+
|
| 453 |
+
@router.post("/transcribe")
|
| 454 |
+
async def transcribe_audio(
|
| 455 |
+
audio: UploadFile = File(...),
|
| 456 |
+
language: str = Form("en"),
|
| 457 |
+
current_user: User = Depends(get_current_user)
|
| 458 |
+
):
|
| 459 |
+
"""Transcribe uploaded audio (for live recording)."""
|
| 460 |
+
from modules.multimodal_processor import create_multimodal_processor
|
| 461 |
+
|
| 462 |
+
try:
|
| 463 |
+
# Save audio temporarily
|
| 464 |
+
temp_dir = Path("data/temp")
|
| 465 |
+
temp_dir.mkdir(parents=True, exist_ok=True)
|
| 466 |
+
|
| 467 |
+
temp_path = temp_dir / f"{uuid.uuid4()}{Path(audio.filename).suffix}"
|
| 468 |
+
|
| 469 |
+
with open(temp_path, "wb") as buffer:
|
| 470 |
+
shutil.copyfileobj(audio.file, buffer)
|
| 471 |
+
|
| 472 |
+
# Transcribe
|
| 473 |
+
processor = create_multimodal_processor()
|
| 474 |
+
result = processor.process_audio(str(temp_path), language)
|
| 475 |
+
|
| 476 |
+
# Clean up
|
| 477 |
+
try:
|
| 478 |
+
temp_path.unlink()
|
| 479 |
+
except:
|
| 480 |
+
pass
|
| 481 |
+
|
| 482 |
+
if result.get("success"):
|
| 483 |
+
return {
|
| 484 |
+
"success": True,
|
| 485 |
+
"transcript": result.get("transcript", ""),
|
| 486 |
+
"language": language,
|
| 487 |
+
"word_count": result.get("word_count", 0)
|
| 488 |
+
}
|
| 489 |
+
else:
|
| 490 |
+
raise HTTPException(status_code=500, detail=result.get("error", "Transcription failed"))
|
| 491 |
+
|
| 492 |
+
except Exception as e:
|
| 493 |
+
logger.error(f"Audio transcription error: {e}")
|
| 494 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 495 |
+
|
| 496 |
+
|
| 497 |
+
# ===== UTILITY FUNCTIONS =====
|
| 498 |
+
|
| 499 |
+
async def save_upload(file: UploadFile, base_path: str, subfolder: str) -> str:
|
| 500 |
+
"""Save an uploaded file and return its path."""
|
| 501 |
+
upload_dir = Path(base_path) / subfolder
|
| 502 |
+
upload_dir.mkdir(parents=True, exist_ok=True)
|
| 503 |
+
|
| 504 |
+
ext = Path(file.filename).suffix
|
| 505 |
+
filename = f"{uuid.uuid4()}{ext}"
|
| 506 |
+
file_path = upload_dir / filename
|
| 507 |
+
|
| 508 |
+
with open(file_path, "wb") as buffer:
|
| 509 |
+
shutil.copyfileobj(file.file, buffer)
|
| 510 |
+
|
| 511 |
+
return str(file_path)
|
backend/api/compile.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
from typing import List
|
| 3 |
+
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
|
| 4 |
+
from sqlalchemy.orm import Session
|
| 5 |
+
import tempfile
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
from core.database import get_db
|
| 9 |
+
from services.agent_service import agent_service
|
| 10 |
+
from services.storage_service import storage_service
|
| 11 |
+
from workers.compilation_worker import compilation_worker
|
| 12 |
+
from api.deps import get_current_user
|
| 13 |
+
from models.user import User
|
| 14 |
+
|
| 15 |
+
router = APIRouter(prefix="/api/compile", tags=["compile"])
|
| 16 |
+
|
| 17 |
+
@router.post("/")
|
| 18 |
+
async def compile_agent_v2(
|
| 19 |
+
files: List[UploadFile] = File(...),
|
| 20 |
+
agent_name: str = Form(...),
|
| 21 |
+
system_prompt: str = Form(...),
|
| 22 |
+
db: Session = Depends(get_db),
|
| 23 |
+
current_user: User = Depends(get_current_user)
|
| 24 |
+
):
|
| 25 |
+
"""
|
| 26 |
+
Compile an agent from uploaded files (Phase 2 - Database integrated).
|
| 27 |
+
|
| 28 |
+
Creates agent record in database and starts background compilation.
|
| 29 |
+
"""
|
| 30 |
+
if not files:
|
| 31 |
+
raise HTTPException(status_code=400, detail="No files uploaded")
|
| 32 |
+
if not agent_name or not agent_name.strip():
|
| 33 |
+
raise HTTPException(status_code=400, detail="Agent name is required")
|
| 34 |
+
if not system_prompt or not system_prompt.strip():
|
| 35 |
+
raise HTTPException(status_code=400, detail="System prompt is required")
|
| 36 |
+
|
| 37 |
+
try:
|
| 38 |
+
# Create agent record
|
| 39 |
+
agent = agent_service.create_agent(db, current_user, agent_name, system_prompt)
|
| 40 |
+
|
| 41 |
+
# Read file contents and upload to Supabase
|
| 42 |
+
files_data = []
|
| 43 |
+
for file in files:
|
| 44 |
+
content = await file.read()
|
| 45 |
+
|
| 46 |
+
# Upload to Supabase Storage (agent-uploads bucket)
|
| 47 |
+
try:
|
| 48 |
+
upload_result = await storage_service.upload_file(
|
| 49 |
+
file=file,
|
| 50 |
+
bucket="agent-uploads",
|
| 51 |
+
folder=f"raw/{agent.id}"
|
| 52 |
+
)
|
| 53 |
+
storage_path = upload_result["path"]
|
| 54 |
+
storage_url = upload_result["url"]
|
| 55 |
+
except Exception as e:
|
| 56 |
+
logger.error(f"Failed to upload raw file to Supabase: {e}")
|
| 57 |
+
storage_path = None
|
| 58 |
+
storage_url = None
|
| 59 |
+
|
| 60 |
+
files_data.append({
|
| 61 |
+
"filename": file.filename,
|
| 62 |
+
"content": content.decode("utf-8", errors="ignore"),
|
| 63 |
+
"storage_path": storage_path,
|
| 64 |
+
"storage_url": storage_url
|
| 65 |
+
})
|
| 66 |
+
|
| 67 |
+
# Start background compilation
|
| 68 |
+
job = compilation_worker.start_compilation(
|
| 69 |
+
db=db,
|
| 70 |
+
agent=agent,
|
| 71 |
+
files_data=files_data
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
return {
|
| 75 |
+
"success": True,
|
| 76 |
+
"message": f"Compilation started for agent '{agent.name}'",
|
| 77 |
+
"agent_id": agent.id,
|
| 78 |
+
"agent_name": agent.name,
|
| 79 |
+
"job_id": job.id
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
except ValueError as e:
|
| 83 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 84 |
+
except Exception as e:
|
| 85 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 86 |
+
|
| 87 |
+
@router.get("/{agent_name}/status")
|
| 88 |
+
def get_compilation_status(
|
| 89 |
+
agent_name: str,
|
| 90 |
+
db: Session = Depends(get_db),
|
| 91 |
+
current_user: User = Depends(get_current_user)
|
| 92 |
+
):
|
| 93 |
+
"""Get compilation status for an agent."""
|
| 94 |
+
agent = agent_service.get_agent(db, current_user, agent_name)
|
| 95 |
+
if not agent:
|
| 96 |
+
raise HTTPException(status_code=404, detail="Agent not found")
|
| 97 |
+
|
| 98 |
+
job_status = compilation_worker.get_job_status(db, agent.id)
|
| 99 |
+
|
| 100 |
+
if not job_status:
|
| 101 |
+
return {
|
| 102 |
+
"status": agent.status,
|
| 103 |
+
"message": "No compilation job found"
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
return {
|
| 107 |
+
"agent_status": agent.status,
|
| 108 |
+
"job": job_status
|
| 109 |
+
}
|
backend/api/deps.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
from fastapi import Depends, HTTPException, status
|
| 3 |
+
from fastapi.security import OAuth2PasswordBearer
|
| 4 |
+
from jose import JWTError, jwt
|
| 5 |
+
from sqlalchemy.orm import Session
|
| 6 |
+
from core.database import get_db
|
| 7 |
+
from core.config import settings
|
| 8 |
+
from models.user import User
|
| 9 |
+
from core.security import decode_token
|
| 10 |
+
|
| 11 |
+
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
| 12 |
+
|
| 13 |
+
async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
|
| 14 |
+
credentials_exception = HTTPException(
|
| 15 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 16 |
+
detail="Could not validate credentials",
|
| 17 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
payload = decode_token(token)
|
| 21 |
+
if payload is None:
|
| 22 |
+
raise credentials_exception
|
| 23 |
+
|
| 24 |
+
email: str = payload.get("sub")
|
| 25 |
+
if email is None:
|
| 26 |
+
raise credentials_exception
|
| 27 |
+
|
| 28 |
+
user = db.query(User).filter(User.email == email).first()
|
| 29 |
+
if user is None:
|
| 30 |
+
raise credentials_exception
|
| 31 |
+
|
| 32 |
+
return user
|
backend/api/diagnostics.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Compilation Health Monitoring API
|
| 3 |
+
|
| 4 |
+
Provides endpoints to monitor compilation job health and detect issues.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from fastapi import APIRouter, Depends
|
| 8 |
+
from sqlalchemy.orm import Session
|
| 9 |
+
from sqlalchemy import text
|
| 10 |
+
from core.database import get_db
|
| 11 |
+
from api.deps import get_current_user
|
| 12 |
+
from models.user import User
|
| 13 |
+
from datetime import datetime, timedelta
|
| 14 |
+
|
| 15 |
+
router = APIRouter(prefix="/api/diagnostics", tags=["diagnostics"])
|
| 16 |
+
|
| 17 |
+
@router.get("/compilation-health")
|
| 18 |
+
def get_compilation_health(
|
| 19 |
+
db: Session = Depends(get_db),
|
| 20 |
+
current_user: User = Depends(get_current_user)
|
| 21 |
+
):
|
| 22 |
+
"""
|
| 23 |
+
Get overall compilation health status.
|
| 24 |
+
Shows active jobs, stuck jobs, and recent failures.
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
# Active jobs
|
| 28 |
+
active_result = db.execute(text("""
|
| 29 |
+
SELECT COUNT(*) as count
|
| 30 |
+
FROM compilation_jobs cj
|
| 31 |
+
JOIN agents a ON cj.agent_id = a.id
|
| 32 |
+
WHERE cj.status = 'in_progress'
|
| 33 |
+
AND a.user_id = :user_id
|
| 34 |
+
"""), {"user_id": current_user.id})
|
| 35 |
+
active_count = active_result.fetchone().count
|
| 36 |
+
|
| 37 |
+
# Stuck jobs (running > 30 minutes)
|
| 38 |
+
stuck_result = db.execute(text("""
|
| 39 |
+
SELECT
|
| 40 |
+
cj.id,
|
| 41 |
+
a.name as agent_name,
|
| 42 |
+
cj.progress,
|
| 43 |
+
cj.current_step,
|
| 44 |
+
EXTRACT(EPOCH FROM (NOW() - cj.created_at)) / 60 as minutes_running
|
| 45 |
+
FROM compilation_jobs cj
|
| 46 |
+
JOIN agents a ON cj.agent_id = a.id
|
| 47 |
+
WHERE cj.status = 'in_progress'
|
| 48 |
+
AND a.user_id = :user_id
|
| 49 |
+
AND cj.created_at < NOW() - INTERVAL '30 minutes'
|
| 50 |
+
"""), {"user_id": current_user.id})
|
| 51 |
+
stuck_jobs = stuck_result.fetchall()
|
| 52 |
+
|
| 53 |
+
# Recent failures (last 24 hours)
|
| 54 |
+
failed_result = db.execute(text("""
|
| 55 |
+
SELECT
|
| 56 |
+
a.name as agent_name,
|
| 57 |
+
cj.error_message,
|
| 58 |
+
cj.created_at
|
| 59 |
+
FROM compilation_jobs cj
|
| 60 |
+
JOIN agents a ON cj.agent_id = a.id
|
| 61 |
+
WHERE cj.status = 'failed'
|
| 62 |
+
AND a.user_id = :user_id
|
| 63 |
+
AND cj.created_at > NOW() - INTERVAL '24 hours'
|
| 64 |
+
ORDER BY cj.created_at DESC
|
| 65 |
+
LIMIT 5
|
| 66 |
+
"""), {"user_id": current_user.id})
|
| 67 |
+
recent_failures = failed_result.fetchall()
|
| 68 |
+
|
| 69 |
+
# Success rate (last 24 hours)
|
| 70 |
+
stats_result = db.execute(text("""
|
| 71 |
+
SELECT
|
| 72 |
+
COUNT(*) as total,
|
| 73 |
+
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
|
| 74 |
+
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed
|
| 75 |
+
FROM compilation_jobs cj
|
| 76 |
+
JOIN agents a ON cj.agent_id = a.id
|
| 77 |
+
WHERE a.user_id = :user_id
|
| 78 |
+
AND cj.created_at > NOW() - INTERVAL '24 hours'
|
| 79 |
+
"""), {"user_id": current_user.id})
|
| 80 |
+
stats = stats_result.fetchone()
|
| 81 |
+
|
| 82 |
+
success_rate = (stats.completed / stats.total * 100) if stats.total > 0 else 0
|
| 83 |
+
|
| 84 |
+
return {
|
| 85 |
+
"status": "healthy" if len(stuck_jobs) == 0 else "warning",
|
| 86 |
+
"active_jobs": active_count,
|
| 87 |
+
"stuck_jobs": [
|
| 88 |
+
{
|
| 89 |
+
"id": job.id,
|
| 90 |
+
"agent_name": job.agent_name,
|
| 91 |
+
"progress": job.progress,
|
| 92 |
+
"current_step": job.current_step,
|
| 93 |
+
"minutes_running": round(job.minutes_running, 1)
|
| 94 |
+
}
|
| 95 |
+
for job in stuck_jobs
|
| 96 |
+
],
|
| 97 |
+
"recent_failures": [
|
| 98 |
+
{
|
| 99 |
+
"agent_name": f.agent_name,
|
| 100 |
+
"error": f.error_message,
|
| 101 |
+
"created_at": f.created_at.isoformat()
|
| 102 |
+
}
|
| 103 |
+
for f in recent_failures
|
| 104 |
+
],
|
| 105 |
+
"stats_24h": {
|
| 106 |
+
"total_jobs": stats.total,
|
| 107 |
+
"completed": stats.completed,
|
| 108 |
+
"failed": stats.failed,
|
| 109 |
+
"success_rate": round(success_rate, 1)
|
| 110 |
+
}
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
@router.get("/embedding-model-status")
|
| 114 |
+
def get_embedding_model_status():
|
| 115 |
+
"""Check if the embedding model is working"""
|
| 116 |
+
try:
|
| 117 |
+
from fastembed import TextEmbedding
|
| 118 |
+
|
| 119 |
+
model = TextEmbedding(model_name="BAAI/bge-small-en-v1.5")
|
| 120 |
+
test_text = ["Test sentence"]
|
| 121 |
+
embeddings = list(model.embed(test_text))
|
| 122 |
+
|
| 123 |
+
return {
|
| 124 |
+
"status": "healthy",
|
| 125 |
+
"model": "BAAI/bge-small-en-v1.5",
|
| 126 |
+
"dimension": len(embeddings[0]),
|
| 127 |
+
"message": "Embedding model is working correctly"
|
| 128 |
+
}
|
| 129 |
+
except Exception as e:
|
| 130 |
+
return {
|
| 131 |
+
"status": "error",
|
| 132 |
+
"message": str(e)
|
| 133 |
+
}
|
backend/api/prompts.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException
|
| 2 |
+
from pydantic import BaseModel
|
| 3 |
+
from modules.prompt_analyzer import create_prompt_analyzer, get_prompt_templates
|
| 4 |
+
|
| 5 |
+
router = APIRouter(prefix="/api", tags=["prompts"])
|
| 6 |
+
|
| 7 |
+
class AnalyzeRequest(BaseModel):
|
| 8 |
+
prompt: str
|
| 9 |
+
|
| 10 |
+
@router.get("/prompt-templates")
|
| 11 |
+
async def get_templates():
|
| 12 |
+
"""Get available system prompt templates."""
|
| 13 |
+
try:
|
| 14 |
+
templates = get_prompt_templates()
|
| 15 |
+
return {"templates": templates}
|
| 16 |
+
except Exception as e:
|
| 17 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 18 |
+
|
| 19 |
+
@router.post("/analyze-prompt")
|
| 20 |
+
async def analyze_prompt_endpoint(request: AnalyzeRequest):
|
| 21 |
+
"""Analyze a system prompt to extract domain and metadata."""
|
| 22 |
+
try:
|
| 23 |
+
analyzer = create_prompt_analyzer()
|
| 24 |
+
analysis = analyzer.analyze_prompt(request.prompt)
|
| 25 |
+
return {"analysis": analysis}
|
| 26 |
+
except Exception as e:
|
| 27 |
+
# Fallback is handled inside analyze_prompt, but just in case
|
| 28 |
+
raise HTTPException(status_code=500, detail=str(e))
|
backend/api/websocket.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends
|
| 3 |
+
from sqlalchemy.orm import Session
|
| 4 |
+
import asyncio
|
| 5 |
+
import json
|
| 6 |
+
|
| 7 |
+
from core.database import get_db, SessionLocal
|
| 8 |
+
from services.agent_service import agent_service
|
| 9 |
+
from workers.compilation_worker import compilation_worker
|
| 10 |
+
|
| 11 |
+
router = APIRouter(tags=["websocket"])
|
| 12 |
+
|
| 13 |
+
class ConnectionManager:
|
| 14 |
+
"""Manages WebSocket connections for real-time updates."""
|
| 15 |
+
|
| 16 |
+
def __init__(self):
|
| 17 |
+
self.active_connections: dict = {} # agent_name -> list of websockets
|
| 18 |
+
|
| 19 |
+
async def connect(self, websocket: WebSocket, agent_name: str):
|
| 20 |
+
await websocket.accept()
|
| 21 |
+
if agent_name not in self.active_connections:
|
| 22 |
+
self.active_connections[agent_name] = []
|
| 23 |
+
self.active_connections[agent_name].append(websocket)
|
| 24 |
+
|
| 25 |
+
def disconnect(self, websocket: WebSocket, agent_name: str):
|
| 26 |
+
if agent_name in self.active_connections:
|
| 27 |
+
if websocket in self.active_connections[agent_name]:
|
| 28 |
+
self.active_connections[agent_name].remove(websocket)
|
| 29 |
+
if not self.active_connections[agent_name]:
|
| 30 |
+
del self.active_connections[agent_name]
|
| 31 |
+
|
| 32 |
+
async def send_update(self, agent_name: str, data: dict):
|
| 33 |
+
if agent_name in self.active_connections:
|
| 34 |
+
for connection in self.active_connections[agent_name]:
|
| 35 |
+
try:
|
| 36 |
+
await connection.send_json(data)
|
| 37 |
+
except:
|
| 38 |
+
pass # Connection might be closed
|
| 39 |
+
|
| 40 |
+
manager = ConnectionManager()
|
| 41 |
+
|
| 42 |
+
@router.websocket("/ws/compile/{agent_name}")
|
| 43 |
+
async def websocket_compile_progress(websocket: WebSocket, agent_name: str):
|
| 44 |
+
"""WebSocket endpoint for real-time compilation progress."""
|
| 45 |
+
await manager.connect(websocket, agent_name)
|
| 46 |
+
|
| 47 |
+
try:
|
| 48 |
+
while True:
|
| 49 |
+
# Get current status
|
| 50 |
+
db = SessionLocal()
|
| 51 |
+
try:
|
| 52 |
+
# Find agent by name (without user check for WebSocket)
|
| 53 |
+
from models.agent import Agent
|
| 54 |
+
agent = db.query(Agent).filter(Agent.name == agent_name).first()
|
| 55 |
+
|
| 56 |
+
if agent:
|
| 57 |
+
job_status = compilation_worker.get_job_status(db, agent.id)
|
| 58 |
+
|
| 59 |
+
status_data = {
|
| 60 |
+
"type": "progress",
|
| 61 |
+
"agent_status": agent.status,
|
| 62 |
+
"job": job_status
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
await websocket.send_json(status_data)
|
| 66 |
+
|
| 67 |
+
# Stop polling if complete or failed
|
| 68 |
+
if agent.status in ["ready", "failed"]:
|
| 69 |
+
await websocket.send_json({
|
| 70 |
+
"type": "complete",
|
| 71 |
+
"agent_status": agent.status
|
| 72 |
+
})
|
| 73 |
+
break
|
| 74 |
+
finally:
|
| 75 |
+
db.close()
|
| 76 |
+
|
| 77 |
+
# Wait before next update
|
| 78 |
+
await asyncio.sleep(1)
|
| 79 |
+
|
| 80 |
+
# Check for client messages (for keepalive)
|
| 81 |
+
try:
|
| 82 |
+
await asyncio.wait_for(websocket.receive_text(), timeout=0.1)
|
| 83 |
+
except asyncio.TimeoutError:
|
| 84 |
+
pass
|
| 85 |
+
|
| 86 |
+
except WebSocketDisconnect:
|
| 87 |
+
manager.disconnect(websocket, agent_name)
|
| 88 |
+
except Exception as e:
|
| 89 |
+
print(f"WebSocket error: {e}")
|
| 90 |
+
manager.disconnect(websocket, agent_name)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
@router.websocket("/ws/chat/{agent_name}")
|
| 94 |
+
async def websocket_chat(websocket: WebSocket, agent_name: str):
|
| 95 |
+
"""WebSocket endpoint for real-time chat (future streaming support)."""
|
| 96 |
+
await websocket.accept()
|
| 97 |
+
|
| 98 |
+
try:
|
| 99 |
+
while True:
|
| 100 |
+
# Receive message from client
|
| 101 |
+
data = await websocket.receive_text()
|
| 102 |
+
message = json.loads(data)
|
| 103 |
+
|
| 104 |
+
# Echo back for now (streaming will be implemented later)
|
| 105 |
+
await websocket.send_json({
|
| 106 |
+
"type": "message",
|
| 107 |
+
"content": f"Received: {message.get('content', '')}"
|
| 108 |
+
})
|
| 109 |
+
|
| 110 |
+
except WebSocketDisconnect:
|
| 111 |
+
pass
|
| 112 |
+
except Exception as e:
|
| 113 |
+
print(f"Chat WebSocket error: {e}")
|
backend/core/cache.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
from functools import lru_cache
|
| 3 |
+
from datetime import datetime, timedelta
|
| 4 |
+
from typing import Any, Optional, Dict
|
| 5 |
+
import threading
|
| 6 |
+
|
| 7 |
+
class InMemoryCache:
|
| 8 |
+
"""
|
| 9 |
+
Simple in-memory cache with TTL support.
|
| 10 |
+
Replaces Redis for development environments.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
def __init__(self, default_ttl: int = 3600):
|
| 14 |
+
self._cache: Dict[str, dict] = {}
|
| 15 |
+
self._default_ttl = default_ttl
|
| 16 |
+
self._lock = threading.RLock()
|
| 17 |
+
|
| 18 |
+
def get(self, key: str) -> Optional[Any]:
|
| 19 |
+
"""Get a value from cache."""
|
| 20 |
+
with self._lock:
|
| 21 |
+
if key not in self._cache:
|
| 22 |
+
return None
|
| 23 |
+
|
| 24 |
+
entry = self._cache[key]
|
| 25 |
+
|
| 26 |
+
# Check if expired
|
| 27 |
+
if entry['expires_at'] and datetime.utcnow() > entry['expires_at']:
|
| 28 |
+
del self._cache[key]
|
| 29 |
+
return None
|
| 30 |
+
|
| 31 |
+
return entry['value']
|
| 32 |
+
|
| 33 |
+
def set(self, key: str, value: Any, ttl: int = None) -> None:
|
| 34 |
+
"""Set a value in cache with optional TTL."""
|
| 35 |
+
with self._lock:
|
| 36 |
+
ttl = ttl if ttl is not None else self._default_ttl
|
| 37 |
+
expires_at = datetime.utcnow() + timedelta(seconds=ttl) if ttl > 0 else None
|
| 38 |
+
|
| 39 |
+
self._cache[key] = {
|
| 40 |
+
'value': value,
|
| 41 |
+
'expires_at': expires_at,
|
| 42 |
+
'created_at': datetime.utcnow()
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
def delete(self, key: str) -> bool:
|
| 46 |
+
"""Delete a key from cache."""
|
| 47 |
+
with self._lock:
|
| 48 |
+
if key in self._cache:
|
| 49 |
+
del self._cache[key]
|
| 50 |
+
return True
|
| 51 |
+
return False
|
| 52 |
+
|
| 53 |
+
def clear(self) -> None:
|
| 54 |
+
"""Clear all cache entries."""
|
| 55 |
+
with self._lock:
|
| 56 |
+
self._cache.clear()
|
| 57 |
+
|
| 58 |
+
def exists(self, key: str) -> bool:
|
| 59 |
+
"""Check if key exists and is not expired."""
|
| 60 |
+
return self.get(key) is not None
|
| 61 |
+
|
| 62 |
+
def get_stats(self) -> dict:
|
| 63 |
+
"""Get cache statistics."""
|
| 64 |
+
with self._lock:
|
| 65 |
+
now = datetime.utcnow()
|
| 66 |
+
active = sum(1 for e in self._cache.values()
|
| 67 |
+
if not e['expires_at'] or e['expires_at'] > now)
|
| 68 |
+
return {
|
| 69 |
+
'total_keys': len(self._cache),
|
| 70 |
+
'active_keys': active,
|
| 71 |
+
'expired_keys': len(self._cache) - active
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
def cleanup(self) -> int:
|
| 75 |
+
"""Remove expired entries and return count removed."""
|
| 76 |
+
with self._lock:
|
| 77 |
+
now = datetime.utcnow()
|
| 78 |
+
expired_keys = [
|
| 79 |
+
k for k, v in self._cache.items()
|
| 80 |
+
if v['expires_at'] and v['expires_at'] < now
|
| 81 |
+
]
|
| 82 |
+
for key in expired_keys:
|
| 83 |
+
del self._cache[key]
|
| 84 |
+
return len(expired_keys)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
# Singleton instance
|
| 88 |
+
cache = InMemoryCache(default_ttl=3600) # 1 hour default
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
# Helper functions for common caching patterns
|
| 92 |
+
def cache_agent_artifacts(agent_id: int, artifacts: dict, ttl: int = 3600):
|
| 93 |
+
"""Cache agent artifacts (knowledge graph, etc.)"""
|
| 94 |
+
cache.set(f"agent:{agent_id}:artifacts", artifacts, ttl)
|
| 95 |
+
|
| 96 |
+
def get_cached_agent_artifacts(agent_id: int) -> Optional[dict]:
|
| 97 |
+
"""Get cached agent artifacts."""
|
| 98 |
+
return cache.get(f"agent:{agent_id}:artifacts")
|
| 99 |
+
|
| 100 |
+
def invalidate_agent_cache(agent_id: int):
|
| 101 |
+
"""Invalidate all cache entries for an agent."""
|
| 102 |
+
cache.delete(f"agent:{agent_id}:artifacts")
|
| 103 |
+
cache.delete(f"agent:{agent_id}:engine")
|
| 104 |
+
|
| 105 |
+
def cache_user_agents(user_id: int, agents: list, ttl: int = 60):
|
| 106 |
+
"""Cache user's agent list for quick dashboard loading."""
|
| 107 |
+
cache.set(f"user:{user_id}:agents", agents, ttl)
|
| 108 |
+
|
| 109 |
+
def get_cached_user_agents(user_id: int) -> Optional[list]:
|
| 110 |
+
"""Get cached user agents list."""
|
| 111 |
+
return cache.get(f"user:{user_id}:agents")
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
# LRU Cache for expensive computations
|
| 115 |
+
@lru_cache(maxsize=100)
|
| 116 |
+
def cached_domain_analysis(prompt_hash: str) -> dict:
|
| 117 |
+
"""
|
| 118 |
+
LRU cache for domain analysis results.
|
| 119 |
+
Use hash of prompt as key to avoid storing full prompts.
|
| 120 |
+
"""
|
| 121 |
+
# This is a placeholder - actual analysis happens in prompt_analyzer
|
| 122 |
+
return {}
|
backend/core/config.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import os
|
| 3 |
+
from dotenv import load_dotenv
|
| 4 |
+
|
| 5 |
+
load_dotenv()
|
| 6 |
+
|
| 7 |
+
class Config:
|
| 8 |
+
# Database (Default to SQLite for dev)
|
| 9 |
+
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./mexar.db")
|
| 10 |
+
|
| 11 |
+
# Security
|
| 12 |
+
SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-in-production")
|
| 13 |
+
ALGORITHM = "HS256"
|
| 14 |
+
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 1 day
|
| 15 |
+
|
| 16 |
+
# AI Services
|
| 17 |
+
GROQ_API_KEY = os.getenv("GROQ_API_KEY")
|
| 18 |
+
|
| 19 |
+
# Storage
|
| 20 |
+
STORAGE_PATH = os.getenv("STORAGE_PATH", "./data/storage")
|
| 21 |
+
|
| 22 |
+
# Caching (In-memory for dev, Redis for prod)
|
| 23 |
+
REDIS_URL = os.getenv("REDIS_URL") # Optional
|
| 24 |
+
|
| 25 |
+
settings = Config()
|
backend/core/database.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
from sqlalchemy import create_engine
|
| 3 |
+
from sqlalchemy.ext.declarative import declarative_base
|
| 4 |
+
from sqlalchemy.orm import sessionmaker
|
| 5 |
+
from .config import settings
|
| 6 |
+
|
| 7 |
+
# Create engine
|
| 8 |
+
# connect_args={"check_same_thread": False} is needed only for SQLite
|
| 9 |
+
connect_args = {"check_same_thread": False} if "sqlite" in settings.DATABASE_URL else {}
|
| 10 |
+
|
| 11 |
+
engine = create_engine(
|
| 12 |
+
settings.DATABASE_URL,
|
| 13 |
+
connect_args=connect_args
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
| 17 |
+
|
| 18 |
+
Base = declarative_base()
|
| 19 |
+
|
| 20 |
+
def get_db():
|
| 21 |
+
"""Dependency for API routes"""
|
| 22 |
+
db = SessionLocal()
|
| 23 |
+
try:
|
| 24 |
+
yield db
|
| 25 |
+
finally:
|
| 26 |
+
db.close()
|
backend/core/monitoring.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import logging
|
| 3 |
+
import json
|
| 4 |
+
import time
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from typing import Optional, Dict, Any
|
| 7 |
+
from functools import wraps
|
| 8 |
+
from fastapi import Request
|
| 9 |
+
import threading
|
| 10 |
+
|
| 11 |
+
# Configure structured logging
|
| 12 |
+
class JSONFormatter(logging.Formatter):
|
| 13 |
+
"""Custom JSON formatter for structured logging."""
|
| 14 |
+
|
| 15 |
+
def format(self, record):
|
| 16 |
+
log_data = {
|
| 17 |
+
'timestamp': datetime.utcnow().isoformat(),
|
| 18 |
+
'level': record.levelname,
|
| 19 |
+
'logger': record.name,
|
| 20 |
+
'message': record.getMessage(),
|
| 21 |
+
'module': record.module,
|
| 22 |
+
'function': record.funcName,
|
| 23 |
+
'line': record.lineno
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
# Add extra fields if present
|
| 27 |
+
if hasattr(record, 'extra'):
|
| 28 |
+
log_data.update(record.extra)
|
| 29 |
+
|
| 30 |
+
return json.dumps(log_data)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def setup_logging(json_format: bool = False):
|
| 34 |
+
"""Setup logging configuration."""
|
| 35 |
+
logger = logging.getLogger('mexar')
|
| 36 |
+
logger.setLevel(logging.INFO)
|
| 37 |
+
|
| 38 |
+
# Console handler
|
| 39 |
+
handler = logging.StreamHandler()
|
| 40 |
+
|
| 41 |
+
if json_format:
|
| 42 |
+
handler.setFormatter(JSONFormatter())
|
| 43 |
+
else:
|
| 44 |
+
handler.setFormatter(logging.Formatter(
|
| 45 |
+
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
| 46 |
+
))
|
| 47 |
+
|
| 48 |
+
logger.addHandler(handler)
|
| 49 |
+
return logger
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
# Analytics tracker
|
| 53 |
+
class AnalyticsTracker:
|
| 54 |
+
"""
|
| 55 |
+
Simple in-memory analytics for tracking usage patterns.
|
| 56 |
+
"""
|
| 57 |
+
|
| 58 |
+
def __init__(self):
|
| 59 |
+
self._metrics = {
|
| 60 |
+
'api_calls': {},
|
| 61 |
+
'chat_messages': 0,
|
| 62 |
+
'compilations': 0,
|
| 63 |
+
'errors': [],
|
| 64 |
+
'response_times': []
|
| 65 |
+
}
|
| 66 |
+
self._lock = threading.RLock()
|
| 67 |
+
|
| 68 |
+
def track_api_call(self, endpoint: str, method: str, status_code: int, duration_ms: float):
|
| 69 |
+
"""Track an API call."""
|
| 70 |
+
with self._lock:
|
| 71 |
+
key = f"{method}:{endpoint}"
|
| 72 |
+
if key not in self._metrics['api_calls']:
|
| 73 |
+
self._metrics['api_calls'][key] = {
|
| 74 |
+
'count': 0,
|
| 75 |
+
'success': 0,
|
| 76 |
+
'errors': 0,
|
| 77 |
+
'avg_duration_ms': 0
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
self._metrics['api_calls'][key]['count'] += 1
|
| 81 |
+
|
| 82 |
+
if 200 <= status_code < 400:
|
| 83 |
+
self._metrics['api_calls'][key]['success'] += 1
|
| 84 |
+
else:
|
| 85 |
+
self._metrics['api_calls'][key]['errors'] += 1
|
| 86 |
+
|
| 87 |
+
# Update rolling average
|
| 88 |
+
current = self._metrics['api_calls'][key]
|
| 89 |
+
current['avg_duration_ms'] = (
|
| 90 |
+
(current['avg_duration_ms'] * (current['count'] - 1) + duration_ms)
|
| 91 |
+
/ current['count']
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
def track_chat(self):
|
| 95 |
+
"""Track a chat message."""
|
| 96 |
+
with self._lock:
|
| 97 |
+
self._metrics['chat_messages'] += 1
|
| 98 |
+
|
| 99 |
+
def track_compilation(self):
|
| 100 |
+
"""Track a compilation."""
|
| 101 |
+
with self._lock:
|
| 102 |
+
self._metrics['compilations'] += 1
|
| 103 |
+
|
| 104 |
+
def track_error(self, error: str, endpoint: str = None):
|
| 105 |
+
"""Track an error."""
|
| 106 |
+
with self._lock:
|
| 107 |
+
self._metrics['errors'].append({
|
| 108 |
+
'timestamp': datetime.utcnow().isoformat(),
|
| 109 |
+
'error': error,
|
| 110 |
+
'endpoint': endpoint
|
| 111 |
+
})
|
| 112 |
+
# Keep only last 100 errors
|
| 113 |
+
if len(self._metrics['errors']) > 100:
|
| 114 |
+
self._metrics['errors'] = self._metrics['errors'][-100:]
|
| 115 |
+
|
| 116 |
+
def get_stats(self) -> dict:
|
| 117 |
+
"""Get current analytics stats."""
|
| 118 |
+
with self._lock:
|
| 119 |
+
total_calls = sum(v['count'] for v in self._metrics['api_calls'].values())
|
| 120 |
+
total_errors = sum(v['errors'] for v in self._metrics['api_calls'].values())
|
| 121 |
+
|
| 122 |
+
return {
|
| 123 |
+
'total_api_calls': total_calls,
|
| 124 |
+
'total_errors': total_errors,
|
| 125 |
+
'error_rate': total_errors / total_calls if total_calls > 0 else 0,
|
| 126 |
+
'chat_messages': self._metrics['chat_messages'],
|
| 127 |
+
'compilations': self._metrics['compilations'],
|
| 128 |
+
'endpoints': self._metrics['api_calls'],
|
| 129 |
+
'recent_errors': self._metrics['errors'][-10:]
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
def reset(self):
|
| 133 |
+
"""Reset all metrics."""
|
| 134 |
+
with self._lock:
|
| 135 |
+
self._metrics = {
|
| 136 |
+
'api_calls': {},
|
| 137 |
+
'chat_messages': 0,
|
| 138 |
+
'compilations': 0,
|
| 139 |
+
'errors': [],
|
| 140 |
+
'response_times': []
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
# Singleton instance
|
| 145 |
+
analytics = AnalyticsTracker()
|
| 146 |
+
logger = setup_logging()
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
# Middleware for request logging and analytics
|
| 150 |
+
async def logging_middleware(request: Request, call_next):
|
| 151 |
+
"""Log and track all requests."""
|
| 152 |
+
start_time = time.time()
|
| 153 |
+
|
| 154 |
+
# Process request
|
| 155 |
+
response = await call_next(request)
|
| 156 |
+
|
| 157 |
+
# Calculate duration
|
| 158 |
+
duration_ms = (time.time() - start_time) * 1000
|
| 159 |
+
|
| 160 |
+
# Track in analytics
|
| 161 |
+
analytics.track_api_call(
|
| 162 |
+
endpoint=request.url.path,
|
| 163 |
+
method=request.method,
|
| 164 |
+
status_code=response.status_code,
|
| 165 |
+
duration_ms=duration_ms
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
# Log request
|
| 169 |
+
logger.info(
|
| 170 |
+
f"{request.method} {request.url.path} - {response.status_code} - {duration_ms:.2f}ms"
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
return response
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
# Decorator for function-level logging
|
| 177 |
+
def log_function(func):
|
| 178 |
+
"""Decorator to log function calls."""
|
| 179 |
+
@wraps(func)
|
| 180 |
+
def wrapper(*args, **kwargs):
|
| 181 |
+
logger.info(f"Calling {func.__name__}")
|
| 182 |
+
try:
|
| 183 |
+
result = func(*args, **kwargs)
|
| 184 |
+
logger.info(f"{func.__name__} completed successfully")
|
| 185 |
+
return result
|
| 186 |
+
except Exception as e:
|
| 187 |
+
logger.error(f"{func.__name__} failed: {str(e)}")
|
| 188 |
+
analytics.track_error(str(e))
|
| 189 |
+
raise
|
| 190 |
+
return wrapper
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
async def async_log_function(func):
|
| 194 |
+
"""Decorator for async function logging."""
|
| 195 |
+
@wraps(func)
|
| 196 |
+
async def wrapper(*args, **kwargs):
|
| 197 |
+
logger.info(f"Calling {func.__name__}")
|
| 198 |
+
try:
|
| 199 |
+
result = await func(*args, **kwargs)
|
| 200 |
+
logger.info(f"{func.__name__} completed successfully")
|
| 201 |
+
return result
|
| 202 |
+
except Exception as e:
|
| 203 |
+
logger.error(f"{func.__name__} failed: {str(e)}")
|
| 204 |
+
analytics.track_error(str(e))
|
| 205 |
+
raise
|
| 206 |
+
return wrapper
|
backend/core/rate_limiter.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import time
|
| 3 |
+
from collections import defaultdict
|
| 4 |
+
from functools import wraps
|
| 5 |
+
from typing import Callable, Optional
|
| 6 |
+
import threading
|
| 7 |
+
|
| 8 |
+
from fastapi import Request, HTTPException, status
|
| 9 |
+
from fastapi.responses import JSONResponse
|
| 10 |
+
|
| 11 |
+
class RateLimiter:
|
| 12 |
+
"""
|
| 13 |
+
Simple in-memory rate limiter for API endpoints.
|
| 14 |
+
Uses a sliding window algorithm.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
def __init__(self):
|
| 18 |
+
self._requests = defaultdict(list)
|
| 19 |
+
self._lock = threading.RLock()
|
| 20 |
+
|
| 21 |
+
def is_allowed(
|
| 22 |
+
self,
|
| 23 |
+
key: str,
|
| 24 |
+
max_requests: int = 60,
|
| 25 |
+
window_seconds: int = 60
|
| 26 |
+
) -> tuple[bool, dict]:
|
| 27 |
+
"""
|
| 28 |
+
Check if a request is allowed under rate limits.
|
| 29 |
+
|
| 30 |
+
Returns: (is_allowed, info_dict)
|
| 31 |
+
"""
|
| 32 |
+
with self._lock:
|
| 33 |
+
now = time.time()
|
| 34 |
+
window_start = now - window_seconds
|
| 35 |
+
|
| 36 |
+
# Clean old requests
|
| 37 |
+
self._requests[key] = [
|
| 38 |
+
t for t in self._requests[key] if t > window_start
|
| 39 |
+
]
|
| 40 |
+
|
| 41 |
+
current_count = len(self._requests[key])
|
| 42 |
+
|
| 43 |
+
if current_count >= max_requests:
|
| 44 |
+
retry_after = self._requests[key][0] - window_start
|
| 45 |
+
return False, {
|
| 46 |
+
'limit': max_requests,
|
| 47 |
+
'remaining': 0,
|
| 48 |
+
'reset': int(self._requests[key][0] + window_seconds),
|
| 49 |
+
'retry_after': int(retry_after) + 1
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
# Add current request
|
| 53 |
+
self._requests[key].append(now)
|
| 54 |
+
|
| 55 |
+
return True, {
|
| 56 |
+
'limit': max_requests,
|
| 57 |
+
'remaining': max_requests - current_count - 1,
|
| 58 |
+
'reset': int(now + window_seconds)
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
def reset(self, key: str):
|
| 62 |
+
"""Reset rate limit for a key."""
|
| 63 |
+
with self._lock:
|
| 64 |
+
if key in self._requests:
|
| 65 |
+
del self._requests[key]
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
# Singleton instance
|
| 69 |
+
rate_limiter = RateLimiter()
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
# Rate limit configurations per endpoint type
|
| 73 |
+
RATE_LIMITS = {
|
| 74 |
+
'auth': {'max_requests': 10, 'window': 60}, # 10 per minute
|
| 75 |
+
'chat': {'max_requests': 30, 'window': 60}, # 30 per minute
|
| 76 |
+
'compile': {'max_requests': 5, 'window': 300}, # 5 per 5 minutes
|
| 77 |
+
'agents': {'max_requests': 60, 'window': 60}, # 60 per minute
|
| 78 |
+
'default': {'max_requests': 100, 'window': 60} # 100 per minute
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
async def rate_limit_middleware(request: Request, call_next):
|
| 83 |
+
"""
|
| 84 |
+
FastAPI middleware for rate limiting.
|
| 85 |
+
"""
|
| 86 |
+
# Get client identifier (IP or user ID if authenticated)
|
| 87 |
+
client_ip = request.client.host if request.client else "unknown"
|
| 88 |
+
|
| 89 |
+
# Determine endpoint type
|
| 90 |
+
path = request.url.path
|
| 91 |
+
if '/auth/' in path:
|
| 92 |
+
limit_type = 'auth'
|
| 93 |
+
elif '/chat/' in path:
|
| 94 |
+
limit_type = 'chat'
|
| 95 |
+
elif '/compile' in path:
|
| 96 |
+
limit_type = 'compile'
|
| 97 |
+
elif '/agents' in path:
|
| 98 |
+
limit_type = 'agents'
|
| 99 |
+
else:
|
| 100 |
+
limit_type = 'default'
|
| 101 |
+
|
| 102 |
+
# Check rate limit
|
| 103 |
+
limits = RATE_LIMITS[limit_type]
|
| 104 |
+
key = f"{client_ip}:{limit_type}"
|
| 105 |
+
|
| 106 |
+
allowed, info = rate_limiter.is_allowed(
|
| 107 |
+
key,
|
| 108 |
+
max_requests=limits['max_requests'],
|
| 109 |
+
window_seconds=limits['window']
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
if not allowed:
|
| 113 |
+
return JSONResponse(
|
| 114 |
+
status_code=429,
|
| 115 |
+
content={
|
| 116 |
+
'detail': 'Too many requests',
|
| 117 |
+
'retry_after': info['retry_after']
|
| 118 |
+
},
|
| 119 |
+
headers={
|
| 120 |
+
'X-RateLimit-Limit': str(info['limit']),
|
| 121 |
+
'X-RateLimit-Remaining': str(info['remaining']),
|
| 122 |
+
'X-RateLimit-Reset': str(info['reset']),
|
| 123 |
+
'Retry-After': str(info['retry_after'])
|
| 124 |
+
}
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
# Process request
|
| 128 |
+
response = await call_next(request)
|
| 129 |
+
|
| 130 |
+
# Add rate limit headers
|
| 131 |
+
response.headers['X-RateLimit-Limit'] = str(info['limit'])
|
| 132 |
+
response.headers['X-RateLimit-Remaining'] = str(info['remaining'])
|
| 133 |
+
response.headers['X-RateLimit-Reset'] = str(info['reset'])
|
| 134 |
+
|
| 135 |
+
return response
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
# File validation constants
|
| 139 |
+
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB
|
| 140 |
+
ALLOWED_EXTENSIONS = {'.csv', '.pdf', '.docx', '.txt', '.json', '.xlsx'}
|
| 141 |
+
|
| 142 |
+
def validate_file_upload(filename: str, file_size: int) -> Optional[str]:
|
| 143 |
+
"""
|
| 144 |
+
Validate an uploaded file.
|
| 145 |
+
Returns error message if invalid, None if valid.
|
| 146 |
+
"""
|
| 147 |
+
import os
|
| 148 |
+
|
| 149 |
+
# Check extension
|
| 150 |
+
ext = os.path.splitext(filename)[1].lower()
|
| 151 |
+
if ext not in ALLOWED_EXTENSIONS:
|
| 152 |
+
return f"File type '{ext}' not allowed. Allowed types: {', '.join(ALLOWED_EXTENSIONS)}"
|
| 153 |
+
|
| 154 |
+
# Check size
|
| 155 |
+
if file_size > MAX_FILE_SIZE:
|
| 156 |
+
max_mb = MAX_FILE_SIZE / (1024 * 1024)
|
| 157 |
+
return f"File too large. Maximum size is {max_mb}MB"
|
| 158 |
+
|
| 159 |
+
return None
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
# Security headers middleware
|
| 163 |
+
async def security_headers_middleware(request: Request, call_next):
|
| 164 |
+
"""Add security headers to all responses."""
|
| 165 |
+
response = await call_next(request)
|
| 166 |
+
|
| 167 |
+
response.headers['X-Content-Type-Options'] = 'nosniff'
|
| 168 |
+
response.headers['X-Frame-Options'] = 'DENY'
|
| 169 |
+
response.headers['X-XSS-Protection'] = '1; mode=block'
|
| 170 |
+
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
|
| 171 |
+
|
| 172 |
+
return response
|
backend/core/security.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
from datetime import datetime, timedelta
|
| 3 |
+
from typing import Optional
|
| 4 |
+
from jose import jwt, JWTError
|
| 5 |
+
from passlib.context import CryptContext
|
| 6 |
+
from core.config import settings
|
| 7 |
+
|
| 8 |
+
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
| 9 |
+
|
| 10 |
+
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
| 11 |
+
return plain_password == hashed_password
|
| 12 |
+
|
| 13 |
+
def get_password_hash(password: str) -> str:
|
| 14 |
+
return password
|
| 15 |
+
|
| 16 |
+
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
| 17 |
+
to_encode = data.copy()
|
| 18 |
+
if expires_delta:
|
| 19 |
+
expire = datetime.utcnow() + expires_delta
|
| 20 |
+
else:
|
| 21 |
+
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
| 22 |
+
|
| 23 |
+
to_encode.update({"exp": expire})
|
| 24 |
+
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
| 25 |
+
return encoded_jwt
|
| 26 |
+
|
| 27 |
+
def decode_token(token: str) -> Optional[dict]:
|
| 28 |
+
try:
|
| 29 |
+
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
| 30 |
+
return payload
|
| 31 |
+
except JWTError:
|
| 32 |
+
return None
|
backend/main.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MEXAR Core Engine - FastAPI Backend Application
|
| 3 |
+
Main entry point for the MEXAR Phase 2 API.
|
| 4 |
+
|
| 5 |
+
This is a clean, minimal main.py that only includes routers.
|
| 6 |
+
All endpoints are handled by the api/ modules.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
import logging
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from contextlib import asynccontextmanager
|
| 13 |
+
|
| 14 |
+
from fastapi import FastAPI
|
| 15 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 16 |
+
from fastapi.responses import JSONResponse
|
| 17 |
+
from dotenv import load_dotenv
|
| 18 |
+
|
| 19 |
+
# Load environment variables
|
| 20 |
+
load_dotenv()
|
| 21 |
+
|
| 22 |
+
# Configure logging
|
| 23 |
+
logging.basicConfig(
|
| 24 |
+
level=logging.INFO,
|
| 25 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
| 26 |
+
)
|
| 27 |
+
logger = logging.getLogger(__name__)
|
| 28 |
+
|
| 29 |
+
# Ensure data directories exist
|
| 30 |
+
DATA_DIRS = [
|
| 31 |
+
Path("data/storage"),
|
| 32 |
+
Path("data/temp"),
|
| 33 |
+
]
|
| 34 |
+
for dir_path in DATA_DIRS:
|
| 35 |
+
dir_path.mkdir(parents=True, exist_ok=True)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
# Lifespan context manager
|
| 39 |
+
@asynccontextmanager
|
| 40 |
+
async def lifespan(app: FastAPI):
|
| 41 |
+
"""Application lifespan handler - database initialization."""
|
| 42 |
+
logger.info("MEXAR Core Engine starting up...")
|
| 43 |
+
|
| 44 |
+
# Initialize database tables
|
| 45 |
+
try:
|
| 46 |
+
from core.database import engine, Base
|
| 47 |
+
from models.user import User
|
| 48 |
+
from models.agent import Agent, CompilationJob
|
| 49 |
+
from models.conversation import Conversation, Message
|
| 50 |
+
from models.chunk import DocumentChunk
|
| 51 |
+
from sqlalchemy import text
|
| 52 |
+
|
| 53 |
+
# Enable vector extension
|
| 54 |
+
with engine.connect() as conn:
|
| 55 |
+
conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
|
| 56 |
+
conn.commit()
|
| 57 |
+
|
| 58 |
+
Base.metadata.create_all(bind=engine)
|
| 59 |
+
logger.info("Database tables created/verified successfully")
|
| 60 |
+
except Exception as e:
|
| 61 |
+
logger.warning(f"Database initialization: {e}")
|
| 62 |
+
|
| 63 |
+
yield
|
| 64 |
+
logger.info("MEXAR Core Engine shutting down...")
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
# Create FastAPI app
|
| 68 |
+
app = FastAPI(
|
| 69 |
+
title="MEXAR Core Engine",
|
| 70 |
+
description="Multimodal Explainable AI Reasoning Assistant - Phase 2",
|
| 71 |
+
version="2.0.0",
|
| 72 |
+
lifespan=lifespan
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
# Configure CORS
|
| 76 |
+
app.add_middleware(
|
| 77 |
+
CORSMiddleware,
|
| 78 |
+
allow_origins=["*"],
|
| 79 |
+
allow_credentials=True,
|
| 80 |
+
allow_methods=["*"],
|
| 81 |
+
allow_headers=["*"],
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
# Import and include Phase 2 routers
|
| 85 |
+
from api import auth, agents, chat, compile, websocket, admin, prompts, diagnostics
|
| 86 |
+
|
| 87 |
+
app.include_router(auth.router)
|
| 88 |
+
app.include_router(agents.router)
|
| 89 |
+
app.include_router(chat.router)
|
| 90 |
+
app.include_router(compile.router)
|
| 91 |
+
app.include_router(websocket.router)
|
| 92 |
+
app.include_router(admin.router)
|
| 93 |
+
app.include_router(prompts.router)
|
| 94 |
+
app.include_router(diagnostics.router)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
# ===== CORE UTILITY ENDPOINTS =====
|
| 98 |
+
|
| 99 |
+
@app.get("/")
|
| 100 |
+
async def root():
|
| 101 |
+
"""Root endpoint - health check."""
|
| 102 |
+
return {
|
| 103 |
+
"name": "MEXAR Core Engine",
|
| 104 |
+
"version": "2.0.0",
|
| 105 |
+
"status": "operational",
|
| 106 |
+
"docs": "/docs"
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
@app.get("/api/health")
|
| 111 |
+
async def health_check():
|
| 112 |
+
"""Health check endpoint."""
|
| 113 |
+
return {
|
| 114 |
+
"status": "healthy",
|
| 115 |
+
"groq_configured": bool(os.getenv("GROQ_API_KEY"))
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
# ===== ERROR HANDLERS =====
|
| 123 |
+
|
| 124 |
+
@app.exception_handler(Exception)
|
| 125 |
+
async def global_exception_handler(request, exc):
|
| 126 |
+
"""Global exception handler."""
|
| 127 |
+
logger.error(f"Unhandled exception: {exc}")
|
| 128 |
+
return JSONResponse(
|
| 129 |
+
status_code=500,
|
| 130 |
+
content={
|
| 131 |
+
"success": False,
|
| 132 |
+
"error": "Internal server error",
|
| 133 |
+
"detail": str(exc)
|
| 134 |
+
}
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
# ===== MAIN ENTRY POINT =====
|
| 139 |
+
|
| 140 |
+
if __name__ == "__main__":
|
| 141 |
+
import uvicorn
|
| 142 |
+
uvicorn.run(
|
| 143 |
+
"main:app",
|
| 144 |
+
host="0.0.0.0",
|
| 145 |
+
port=8000,
|
| 146 |
+
reload=True,
|
| 147 |
+
log_level="info"
|
| 148 |
+
)
|
backend/migrations/README.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# MEXAR - Apply Hybrid Search Migration
|
| 2 |
+
|
| 3 |
+
## What This Does
|
| 4 |
+
|
| 5 |
+
This SQL script creates the `hybrid_search()` function in your Supabase database,
|
| 6 |
+
which combines semantic (vector) and keyword (full-text) search using
|
| 7 |
+
Reciprocal Rank Fusion (RRF) algorithm.
|
| 8 |
+
|
| 9 |
+
## Instructions
|
| 10 |
+
|
| 11 |
+
1. **Open Supabase Dashboard**
|
| 12 |
+
- Go to: https://supabase.com/dashboard
|
| 13 |
+
- Select your project: `xmfcidiwovxuihrkfzps`
|
| 14 |
+
|
| 15 |
+
2. **Navigate to SQL Editor**
|
| 16 |
+
- Click "SQL Editor" in the left sidebar
|
| 17 |
+
- Click "New Query"
|
| 18 |
+
|
| 19 |
+
3. **Copy and Paste**
|
| 20 |
+
- Open: `backend/migrations/hybrid_search_function.sql`
|
| 21 |
+
- Copy ALL the contents
|
| 22 |
+
- Paste into the Supabase SQL Editor
|
| 23 |
+
|
| 24 |
+
4. **Run the Migration**
|
| 25 |
+
- Click "Run" button (or press Ctrl+Enter)
|
| 26 |
+
- Wait for success message
|
| 27 |
+
|
| 28 |
+
5. **Verify**
|
| 29 |
+
- Run this query to check:
|
| 30 |
+
```sql
|
| 31 |
+
SELECT routine_name
|
| 32 |
+
FROM information_schema.routines
|
| 33 |
+
WHERE routine_name = 'hybrid_search';
|
| 34 |
+
```
|
| 35 |
+
- Should return one row
|
| 36 |
+
|
| 37 |
+
## Alternative: Run from Command Line (Optional)
|
| 38 |
+
|
| 39 |
+
If you have `psql` installed:
|
| 40 |
+
|
| 41 |
+
```bash
|
| 42 |
+
psql "postgresql://postgres.xmfcidiwovxuihrkfzps:Yogiji@20122004@aws-1-ap-south-1.pooler.supabase.com:5432/postgres" -f migrations/hybrid_search_function.sql
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
## What Gets Created
|
| 46 |
+
|
| 47 |
+
- **Function**: `hybrid_search(vector, text, integer, integer)`
|
| 48 |
+
- **Indexes**:
|
| 49 |
+
- `idx_document_chunks_content_tsvector` (GIN index for full-text search)
|
| 50 |
+
- `idx_document_chunks_agent_id` (B-tree index for filtering)
|
| 51 |
+
- `idx_document_chunks_embedding` (IVFFlat index for vector search)
|
| 52 |
+
|
| 53 |
+
## Troubleshooting
|
| 54 |
+
|
| 55 |
+
**Error: "type vector does not exist"**
|
| 56 |
+
- Run: `CREATE EXTENSION IF NOT EXISTS vector;`
|
| 57 |
+
- Then retry the migration
|
| 58 |
+
|
| 59 |
+
**Error: "table document_chunks does not exist"**
|
| 60 |
+
- Restart your backend server to create tables
|
| 61 |
+
- Then retry the migration
|
| 62 |
+
|
| 63 |
+
---
|
| 64 |
+
|
| 65 |
+
**After running this migration**, your system will be ready for hybrid search!
|
backend/migrations/__init__.py
ADDED
|
File without changes
|
backend/migrations/add_preferences.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
from core.database import engine, Base
|
| 3 |
+
from sqlalchemy import text, inspect
|
| 4 |
+
|
| 5 |
+
def run_migration():
|
| 6 |
+
print("Running migration: Add preferences to users table...")
|
| 7 |
+
inspector = inspect(engine)
|
| 8 |
+
columns = [col['name'] for col in inspector.get_columns('users')]
|
| 9 |
+
|
| 10 |
+
if 'preferences' not in columns:
|
| 11 |
+
try:
|
| 12 |
+
with engine.connect() as conn:
|
| 13 |
+
# Add JSON column for preferences
|
| 14 |
+
conn.execute(text("ALTER TABLE users ADD COLUMN preferences JSON DEFAULT '{}'"))
|
| 15 |
+
conn.commit()
|
| 16 |
+
print("✅ Successfully added 'preferences' column to 'users' table.")
|
| 17 |
+
except Exception as e:
|
| 18 |
+
print(f"❌ Error adding column: {e}")
|
| 19 |
+
else:
|
| 20 |
+
print("ℹ️ Column 'preferences' already exists in 'users' table.")
|
| 21 |
+
|
| 22 |
+
if __name__ == "__main__":
|
| 23 |
+
run_migration()
|
backend/migrations/fix_vector_dimension.sql
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- MEXAR - Fix Vector Dimension Mismatch
|
| 2 |
+
-- The embedding model (bge-small-en-v1.5) outputs 384 dimensions
|
| 3 |
+
-- But the table was created with 1024 dimensions
|
| 4 |
+
-- This script fixes the mismatch
|
| 5 |
+
|
| 6 |
+
-- Step 1: Drop existing embedding column
|
| 7 |
+
ALTER TABLE document_chunks DROP COLUMN IF EXISTS embedding;
|
| 8 |
+
|
| 9 |
+
-- Step 2: Add new embedding column with correct dimensions (384)
|
| 10 |
+
ALTER TABLE document_chunks ADD COLUMN embedding vector(384);
|
| 11 |
+
|
| 12 |
+
-- Step 3: Create index for the new column
|
| 13 |
+
CREATE INDEX IF NOT EXISTS idx_document_chunks_embedding
|
| 14 |
+
ON document_chunks USING ivfflat(embedding vector_cosine_ops)
|
| 15 |
+
WITH (lists = 100);
|
| 16 |
+
|
| 17 |
+
-- Verify the change
|
| 18 |
+
SELECT column_name, udt_name
|
| 19 |
+
FROM information_schema.columns
|
| 20 |
+
WHERE table_name = 'document_chunks' AND column_name = 'embedding';
|
backend/migrations/hybrid_search_function.sql
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- MEXAR - Hybrid Search Function for Supabase
|
| 2 |
+
-- Combines semantic (vector) and keyword (full-text) search using Reciprocal Rank Fusion (RRF)
|
| 3 |
+
|
| 4 |
+
CREATE OR REPLACE FUNCTION hybrid_search(
|
| 5 |
+
query_embedding vector(384),
|
| 6 |
+
query_text text,
|
| 7 |
+
match_agent_id integer,
|
| 8 |
+
match_count integer
|
| 9 |
+
)
|
| 10 |
+
RETURNS TABLE (
|
| 11 |
+
id integer,
|
| 12 |
+
agent_id integer,
|
| 13 |
+
content text,
|
| 14 |
+
source text,
|
| 15 |
+
chunk_index integer,
|
| 16 |
+
section_title text,
|
| 17 |
+
created_at timestamp with time zone,
|
| 18 |
+
rrf_score real
|
| 19 |
+
)
|
| 20 |
+
LANGUAGE plpgsql
|
| 21 |
+
AS $$
|
| 22 |
+
DECLARE
|
| 23 |
+
semantic_weight real := 0.6;
|
| 24 |
+
keyword_weight real := 0.4;
|
| 25 |
+
k_constant real := 60.0;
|
| 26 |
+
BEGIN
|
| 27 |
+
RETURN QUERY
|
| 28 |
+
WITH semantic_search AS (
|
| 29 |
+
SELECT
|
| 30 |
+
dc.id,
|
| 31 |
+
dc.agent_id,
|
| 32 |
+
dc.content,
|
| 33 |
+
dc.source,
|
| 34 |
+
dc.chunk_index,
|
| 35 |
+
dc.section_title,
|
| 36 |
+
dc.created_at,
|
| 37 |
+
ROW_NUMBER() OVER (ORDER BY dc.embedding <=> query_embedding) AS rank_num
|
| 38 |
+
FROM document_chunks dc
|
| 39 |
+
WHERE dc.agent_id = match_agent_id
|
| 40 |
+
ORDER BY dc.embedding <=> query_embedding
|
| 41 |
+
LIMIT match_count * 2
|
| 42 |
+
),
|
| 43 |
+
keyword_search AS (
|
| 44 |
+
SELECT
|
| 45 |
+
dc.id,
|
| 46 |
+
dc.agent_id,
|
| 47 |
+
dc.content,
|
| 48 |
+
dc.source,
|
| 49 |
+
dc.chunk_index,
|
| 50 |
+
dc.section_title,
|
| 51 |
+
dc.created_at,
|
| 52 |
+
ROW_NUMBER() OVER (ORDER BY ts_rank_cd(dc.content_tsvector, plainto_tsquery('english', query_text)) DESC) AS rank_num
|
| 53 |
+
FROM document_chunks dc
|
| 54 |
+
WHERE dc.agent_id = match_agent_id
|
| 55 |
+
AND dc.content_tsvector @@ plainto_tsquery('english', query_text)
|
| 56 |
+
ORDER BY ts_rank_cd(dc.content_tsvector, plainto_tsquery('english', query_text)) DESC
|
| 57 |
+
LIMIT match_count * 2
|
| 58 |
+
),
|
| 59 |
+
combined AS (
|
| 60 |
+
SELECT
|
| 61 |
+
COALESCE(s.id, k.id) AS id,
|
| 62 |
+
COALESCE(s.agent_id, k.agent_id) AS agent_id,
|
| 63 |
+
COALESCE(s.content, k.content) AS content,
|
| 64 |
+
COALESCE(s.source, k.source) AS source,
|
| 65 |
+
COALESCE(s.chunk_index, k.chunk_index) AS chunk_index,
|
| 66 |
+
COALESCE(s.section_title, k.section_title) AS section_title,
|
| 67 |
+
COALESCE(s.created_at, k.created_at) AS created_at,
|
| 68 |
+
(
|
| 69 |
+
COALESCE(semantic_weight / (k_constant + s.rank_num::real), 0.0) +
|
| 70 |
+
COALESCE(keyword_weight / (k_constant + k.rank_num::real), 0.0)
|
| 71 |
+
) AS rrf_score
|
| 72 |
+
FROM semantic_search s
|
| 73 |
+
FULL OUTER JOIN keyword_search k ON s.id = k.id
|
| 74 |
+
)
|
| 75 |
+
SELECT
|
| 76 |
+
c.id,
|
| 77 |
+
c.agent_id,
|
| 78 |
+
c.content,
|
| 79 |
+
c.source,
|
| 80 |
+
c.chunk_index,
|
| 81 |
+
c.section_title,
|
| 82 |
+
c.created_at,
|
| 83 |
+
c.rrf_score::real
|
| 84 |
+
FROM combined c
|
| 85 |
+
ORDER BY c.rrf_score DESC
|
| 86 |
+
LIMIT match_count;
|
| 87 |
+
END;
|
| 88 |
+
$$;
|
| 89 |
+
|
| 90 |
+
-- Add index on content_tsvector for better keyword search performance
|
| 91 |
+
CREATE INDEX IF NOT EXISTS idx_document_chunks_content_tsvector
|
| 92 |
+
ON document_chunks USING GIN(content_tsvector);
|
| 93 |
+
|
| 94 |
+
-- Add index on agent_id for filtering
|
| 95 |
+
CREATE INDEX IF NOT EXISTS idx_document_chunks_agent_id
|
| 96 |
+
ON document_chunks(agent_id);
|
| 97 |
+
|
| 98 |
+
-- Add index on embedding for vector similarity search
|
| 99 |
+
CREATE INDEX IF NOT EXISTS idx_document_chunks_embedding
|
| 100 |
+
ON document_chunks USING ivfflat(embedding vector_cosine_ops)
|
| 101 |
+
WITH (lists = 100);
|
| 102 |
+
|
| 103 |
+
COMMENT ON FUNCTION hybrid_search IS 'Combines semantic (vector) and keyword (full-text) search using Reciprocal Rank Fusion';
|
backend/migrations/rag_migration.sql
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- ============================================
|
| 2 |
+
-- MEXAR RAG Migration Script
|
| 3 |
+
-- Run this in Supabase SQL Editor
|
| 4 |
+
-- ============================================
|
| 5 |
+
|
| 6 |
+
-- 1. Enable pgvector extension (if not already)
|
| 7 |
+
CREATE EXTENSION IF NOT EXISTS vector;
|
| 8 |
+
|
| 9 |
+
-- 2. Clear existing chunks (required due to dimension change)
|
| 10 |
+
DELETE FROM document_chunks;
|
| 11 |
+
|
| 12 |
+
-- 3. Alter embedding dimension: 384 → 1024
|
| 13 |
+
ALTER TABLE document_chunks
|
| 14 |
+
ALTER COLUMN embedding TYPE vector(1024);
|
| 15 |
+
|
| 16 |
+
-- 4. Add tsvector column for keyword search
|
| 17 |
+
ALTER TABLE document_chunks
|
| 18 |
+
ADD COLUMN IF NOT EXISTS content_tsvector TSVECTOR;
|
| 19 |
+
|
| 20 |
+
-- 5. Add chunk metadata columns
|
| 21 |
+
ALTER TABLE document_chunks
|
| 22 |
+
ADD COLUMN IF NOT EXISTS chunk_index INTEGER,
|
| 23 |
+
ADD COLUMN IF NOT EXISTS section_title TEXT,
|
| 24 |
+
ADD COLUMN IF NOT EXISTS token_count INTEGER;
|
| 25 |
+
|
| 26 |
+
-- 6. Create HNSW index for fast cosine similarity
|
| 27 |
+
DROP INDEX IF EXISTS chunks_embedding_idx;
|
| 28 |
+
DROP INDEX IF EXISTS chunks_embedding_hnsw;
|
| 29 |
+
CREATE INDEX chunks_embedding_hnsw
|
| 30 |
+
ON document_chunks USING hnsw (embedding vector_cosine_ops)
|
| 31 |
+
WITH (m = 16, ef_construction = 64);
|
| 32 |
+
|
| 33 |
+
-- 7. Create GIN index for full-text search
|
| 34 |
+
CREATE INDEX IF NOT EXISTS chunks_content_gin
|
| 35 |
+
ON document_chunks USING GIN (content_tsvector);
|
| 36 |
+
|
| 37 |
+
-- 8. Create trigger to auto-update tsvector
|
| 38 |
+
CREATE OR REPLACE FUNCTION update_tsvector()
|
| 39 |
+
RETURNS TRIGGER AS $$
|
| 40 |
+
BEGIN
|
| 41 |
+
NEW.content_tsvector := to_tsvector('english', COALESCE(NEW.content, ''));
|
| 42 |
+
RETURN NEW;
|
| 43 |
+
END;
|
| 44 |
+
$$ LANGUAGE plpgsql;
|
| 45 |
+
|
| 46 |
+
DROP TRIGGER IF EXISTS tsvector_update ON document_chunks;
|
| 47 |
+
CREATE TRIGGER tsvector_update
|
| 48 |
+
BEFORE INSERT OR UPDATE ON document_chunks
|
| 49 |
+
FOR EACH ROW EXECUTE FUNCTION update_tsvector();
|
| 50 |
+
|
| 51 |
+
-- 9. Add agent metadata columns for full Supabase storage
|
| 52 |
+
ALTER TABLE agents
|
| 53 |
+
ADD COLUMN IF NOT EXISTS knowledge_graph_json JSONB,
|
| 54 |
+
ADD COLUMN IF NOT EXISTS domain_signature JSONB,
|
| 55 |
+
ADD COLUMN IF NOT EXISTS prompt_analysis JSONB,
|
| 56 |
+
ADD COLUMN IF NOT EXISTS compilation_stats JSONB,
|
| 57 |
+
ADD COLUMN IF NOT EXISTS chunk_count INTEGER DEFAULT 0;
|
| 58 |
+
|
| 59 |
+
-- 10. Update existing tsvector data
|
| 60 |
+
UPDATE document_chunks
|
| 61 |
+
SET content_tsvector = to_tsvector('english', content)
|
| 62 |
+
WHERE content_tsvector IS NULL;
|
| 63 |
+
|
| 64 |
+
-- 11. Create hybrid search function
|
| 65 |
+
CREATE OR REPLACE FUNCTION hybrid_search(
|
| 66 |
+
query_embedding vector(1024),
|
| 67 |
+
query_text text,
|
| 68 |
+
target_agent_id integer,
|
| 69 |
+
match_count integer DEFAULT 20
|
| 70 |
+
)
|
| 71 |
+
RETURNS TABLE (
|
| 72 |
+
id integer,
|
| 73 |
+
content text,
|
| 74 |
+
source text,
|
| 75 |
+
semantic_rank integer,
|
| 76 |
+
keyword_rank integer,
|
| 77 |
+
rrf_score float
|
| 78 |
+
) AS $$
|
| 79 |
+
BEGIN
|
| 80 |
+
RETURN QUERY
|
| 81 |
+
WITH semantic AS (
|
| 82 |
+
SELECT dc.id, dc.content, dc.source,
|
| 83 |
+
ROW_NUMBER() OVER (ORDER BY dc.embedding <=> query_embedding)::integer as rank
|
| 84 |
+
FROM document_chunks dc
|
| 85 |
+
WHERE dc.agent_id = target_agent_id
|
| 86 |
+
ORDER BY dc.embedding <=> query_embedding
|
| 87 |
+
LIMIT match_count
|
| 88 |
+
),
|
| 89 |
+
keyword AS (
|
| 90 |
+
SELECT dc.id, dc.content, dc.source,
|
| 91 |
+
ROW_NUMBER() OVER (ORDER BY ts_rank(dc.content_tsvector, plainto_tsquery('english', query_text)) DESC)::integer as rank
|
| 92 |
+
FROM document_chunks dc
|
| 93 |
+
WHERE dc.agent_id = target_agent_id
|
| 94 |
+
AND dc.content_tsvector @@ plainto_tsquery('english', query_text)
|
| 95 |
+
LIMIT match_count
|
| 96 |
+
)
|
| 97 |
+
SELECT
|
| 98 |
+
COALESCE(s.id, k.id) as id,
|
| 99 |
+
COALESCE(s.content, k.content) as content,
|
| 100 |
+
COALESCE(s.source, k.source) as source,
|
| 101 |
+
s.rank as semantic_rank,
|
| 102 |
+
k.rank as keyword_rank,
|
| 103 |
+
(COALESCE(1.0/(60 + s.rank), 0) + COALESCE(1.0/(60 + k.rank), 0))::float as rrf_score
|
| 104 |
+
FROM semantic s
|
| 105 |
+
FULL OUTER JOIN keyword k ON s.id = k.id
|
| 106 |
+
ORDER BY rrf_score DESC
|
| 107 |
+
LIMIT match_count;
|
| 108 |
+
END;
|
| 109 |
+
$$ LANGUAGE plpgsql;
|
| 110 |
+
|
| 111 |
+
-- Done! Verify with:
|
| 112 |
+
-- SELECT * FROM pg_indexes WHERE tablename = 'document_chunks';
|
backend/models/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MEXAR Models Package
|
| 3 |
+
Import all models in correct order to resolve relationships.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
# Import in correct order to resolve relationships
|
| 7 |
+
from models.user import User
|
| 8 |
+
from models.agent import Agent, CompilationJob
|
| 9 |
+
from models.conversation import Conversation, Message
|
| 10 |
+
from models.chunk import DocumentChunk
|
| 11 |
+
|
| 12 |
+
__all__ = [
|
| 13 |
+
"User",
|
| 14 |
+
"Agent",
|
| 15 |
+
"CompilationJob",
|
| 16 |
+
"Conversation",
|
| 17 |
+
"Message",
|
| 18 |
+
"DocumentChunk"
|
| 19 |
+
]
|
backend/models/agent.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey
|
| 2 |
+
from sqlalchemy.sql import func
|
| 3 |
+
from sqlalchemy.orm import relationship
|
| 4 |
+
from sqlalchemy.dialects.postgresql import JSONB
|
| 5 |
+
from core.database import Base
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class Agent(Base):
|
| 9 |
+
"""AI Agent with all metadata stored in Supabase"""
|
| 10 |
+
__tablename__ = "agents"
|
| 11 |
+
|
| 12 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 13 |
+
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
| 14 |
+
name = Column(String, nullable=False)
|
| 15 |
+
domain = Column(String, nullable=True)
|
| 16 |
+
system_prompt = Column(Text, nullable=False)
|
| 17 |
+
|
| 18 |
+
# All metadata stored in Supabase (no filesystem)
|
| 19 |
+
domain_keywords = Column(JSONB, nullable=True)
|
| 20 |
+
domain_signature = Column(JSONB, nullable=True)
|
| 21 |
+
prompt_analysis = Column(JSONB, nullable=True)
|
| 22 |
+
knowledge_graph_json = Column(JSONB, nullable=True)
|
| 23 |
+
compilation_stats = Column(JSONB, nullable=True)
|
| 24 |
+
|
| 25 |
+
status = Column(String, default="initializing") # initializing, compiling, ready, failed
|
| 26 |
+
storage_path = Column(String, nullable=True) # Deprecated, kept for compatibility
|
| 27 |
+
chunk_count = Column(Integer, default=0)
|
| 28 |
+
entity_count = Column(Integer, default=0)
|
| 29 |
+
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
| 30 |
+
|
| 31 |
+
# Relationships
|
| 32 |
+
user = relationship("User", backref="agents")
|
| 33 |
+
compilation_jobs = relationship("CompilationJob", back_populates="agent", cascade="all, delete-orphan")
|
| 34 |
+
conversations = relationship("Conversation", back_populates="agent", cascade="all, delete-orphan")
|
| 35 |
+
chunks = relationship("DocumentChunk", back_populates="agent", cascade="all, delete-orphan")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class CompilationJob(Base):
|
| 39 |
+
"""Background job for agent compilation"""
|
| 40 |
+
__tablename__ = "compilation_jobs"
|
| 41 |
+
|
| 42 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 43 |
+
agent_id = Column(Integer, ForeignKey("agents.id", ondelete="CASCADE"), nullable=False)
|
| 44 |
+
status = Column(String, default="queued") # queued, processing, completed, failed
|
| 45 |
+
progress = Column(Integer, default=0)
|
| 46 |
+
current_step = Column(String, nullable=True)
|
| 47 |
+
error_message = Column(Text, nullable=True)
|
| 48 |
+
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
| 49 |
+
completed_at = Column(DateTime(timezone=True), nullable=True)
|
| 50 |
+
|
| 51 |
+
agent = relationship("Agent", back_populates="compilation_jobs")
|
backend/models/chunk.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Index
|
| 2 |
+
from sqlalchemy.sql import func
|
| 3 |
+
from sqlalchemy.orm import relationship, mapped_column
|
| 4 |
+
from sqlalchemy.dialects.postgresql import TSVECTOR
|
| 5 |
+
from pgvector.sqlalchemy import Vector
|
| 6 |
+
from core.database import Base
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class DocumentChunk(Base):
|
| 10 |
+
"""Document chunk with embedding for RAG retrieval"""
|
| 11 |
+
__tablename__ = "document_chunks"
|
| 12 |
+
|
| 13 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 14 |
+
agent_id = Column(Integer, ForeignKey("agents.id", ondelete="CASCADE"), nullable=False)
|
| 15 |
+
content = Column(Text, nullable=False)
|
| 16 |
+
source = Column(String, nullable=True)
|
| 17 |
+
chunk_index = Column(Integer, nullable=True)
|
| 18 |
+
section_title = Column(String, nullable=True)
|
| 19 |
+
token_count = Column(Integer, nullable=True)
|
| 20 |
+
|
| 21 |
+
# 384 dimensions for bge-small-en-v1.5 (unifying for MEXAR Ultimate)
|
| 22 |
+
embedding = mapped_column(Vector(384))
|
| 23 |
+
|
| 24 |
+
# Full-text search column
|
| 25 |
+
content_tsvector = Column(TSVECTOR)
|
| 26 |
+
|
| 27 |
+
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
| 28 |
+
|
| 29 |
+
agent = relationship("Agent", back_populates="chunks")
|
backend/models/conversation.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, JSON, Float
|
| 3 |
+
from sqlalchemy.sql import func
|
| 4 |
+
from sqlalchemy.orm import relationship
|
| 5 |
+
from core.database import Base
|
| 6 |
+
|
| 7 |
+
class Conversation(Base):
|
| 8 |
+
__tablename__ = "conversations"
|
| 9 |
+
|
| 10 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 11 |
+
agent_id = Column(Integer, ForeignKey("agents.id", ondelete="CASCADE"), nullable=False)
|
| 12 |
+
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
| 13 |
+
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
| 14 |
+
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
| 15 |
+
|
| 16 |
+
# Relationships
|
| 17 |
+
agent = relationship("Agent", back_populates="conversations")
|
| 18 |
+
messages = relationship("Message", back_populates="conversation", cascade="all, delete-orphan")
|
| 19 |
+
|
| 20 |
+
class Message(Base):
|
| 21 |
+
__tablename__ = "messages"
|
| 22 |
+
|
| 23 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 24 |
+
conversation_id = Column(Integer, ForeignKey("conversations.id", ondelete="CASCADE"), nullable=False)
|
| 25 |
+
role = Column(String, nullable=False) # user, assistant
|
| 26 |
+
content = Column(Text, nullable=False)
|
| 27 |
+
|
| 28 |
+
# Advanced features
|
| 29 |
+
multimodal_data = Column(JSON, nullable=True) # Images, audio paths
|
| 30 |
+
explainability_data = Column(JSON, nullable=True) # Reasoning traces
|
| 31 |
+
confidence = Column(Float, nullable=True)
|
| 32 |
+
|
| 33 |
+
timestamp = Column(DateTime(timezone=True), server_default=func.now())
|
| 34 |
+
|
| 35 |
+
conversation = relationship("Conversation", back_populates="messages")
|
backend/models/user.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
from sqlalchemy import Column, Integer, String, DateTime, JSON, Boolean
|
| 3 |
+
from sqlalchemy.sql import func
|
| 4 |
+
from sqlalchemy.orm import relationship
|
| 5 |
+
from core.database import Base
|
| 6 |
+
|
| 7 |
+
class User(Base):
|
| 8 |
+
__tablename__ = "users"
|
| 9 |
+
|
| 10 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 11 |
+
email = Column(String, unique=True, index=True, nullable=False)
|
| 12 |
+
password = Column(String, nullable=False)
|
| 13 |
+
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
| 14 |
+
last_login = Column(DateTime(timezone=True), nullable=True)
|
| 15 |
+
preferences = Column(JSON, default={})
|
| 16 |
+
|
| 17 |
+
# Relationships
|
| 18 |
+
conversations = relationship("Conversation", backref="user", cascade="all, delete-orphan")
|
backend/modules/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MEXAR Core Engine - Backend Modules Package
|
| 3 |
+
"""
|
backend/modules/data_validator.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MEXAR Core Engine - Data Ingestion & Validation Module
|
| 3 |
+
Handles parsing and validation of uploaded files (CSV, PDF, DOCX, JSON, TXT).
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
import json
|
| 8 |
+
import logging
|
| 9 |
+
from typing import Dict, List, Any, Optional, Tuple
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
import pandas as pd
|
| 12 |
+
from PyPDF2 import PdfReader
|
| 13 |
+
from docx import Document
|
| 14 |
+
|
| 15 |
+
# Configure logging
|
| 16 |
+
logging.basicConfig(level=logging.INFO)
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class DataValidator:
|
| 21 |
+
"""
|
| 22 |
+
Validates and parses uploaded data files for knowledge compilation.
|
| 23 |
+
Supports: CSV, PDF, DOCX, JSON, TXT
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
# Minimum thresholds for data sufficiency
|
| 27 |
+
MIN_ENTRIES = 20
|
| 28 |
+
MIN_CHARACTERS = 2000
|
| 29 |
+
|
| 30 |
+
# Supported file extensions
|
| 31 |
+
SUPPORTED_EXTENSIONS = {'.csv', '.pdf', '.docx', '.json', '.txt'}
|
| 32 |
+
|
| 33 |
+
def __init__(self):
|
| 34 |
+
"""Initialize the data validator."""
|
| 35 |
+
self.parsed_data: List[Dict[str, Any]] = []
|
| 36 |
+
self.validation_results: List[Dict[str, Any]] = []
|
| 37 |
+
|
| 38 |
+
def parse_file(self, file_path: str) -> Dict[str, Any]:
|
| 39 |
+
"""
|
| 40 |
+
Parse a file based on its extension.
|
| 41 |
+
|
| 42 |
+
Args:
|
| 43 |
+
file_path: Path to the file to parse
|
| 44 |
+
|
| 45 |
+
Returns:
|
| 46 |
+
Dict containing:
|
| 47 |
+
- format: File format (csv, pdf, docx, json, txt)
|
| 48 |
+
- data: Parsed data (list of dicts for structured, None for text)
|
| 49 |
+
- text: Extracted text content
|
| 50 |
+
- entries_count: Number of entries/rows/paragraphs
|
| 51 |
+
- file_name: Original file name
|
| 52 |
+
"""
|
| 53 |
+
path = Path(file_path)
|
| 54 |
+
ext = path.suffix.lower()
|
| 55 |
+
|
| 56 |
+
if ext not in self.SUPPORTED_EXTENSIONS:
|
| 57 |
+
raise ValueError(f"Unsupported file format: {ext}. Supported: {self.SUPPORTED_EXTENSIONS}")
|
| 58 |
+
|
| 59 |
+
result = {
|
| 60 |
+
"format": ext.replace(".", ""),
|
| 61 |
+
"data": None,
|
| 62 |
+
"text": "",
|
| 63 |
+
"entries_count": 0,
|
| 64 |
+
"file_name": path.name
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
try:
|
| 68 |
+
if ext == '.csv':
|
| 69 |
+
result = self._parse_csv(file_path, result)
|
| 70 |
+
elif ext == '.pdf':
|
| 71 |
+
result = self._parse_pdf(file_path, result)
|
| 72 |
+
elif ext == '.docx':
|
| 73 |
+
result = self._parse_docx(file_path, result)
|
| 74 |
+
elif ext == '.json':
|
| 75 |
+
result = self._parse_json(file_path, result)
|
| 76 |
+
elif ext == '.txt':
|
| 77 |
+
result = self._parse_txt(file_path, result)
|
| 78 |
+
|
| 79 |
+
logger.info(f"Successfully parsed {path.name}: {result['entries_count']} entries, {len(result['text'])} chars")
|
| 80 |
+
|
| 81 |
+
except Exception as e:
|
| 82 |
+
logger.error(f"Error parsing {path.name}: {str(e)}")
|
| 83 |
+
result["error"] = str(e)
|
| 84 |
+
|
| 85 |
+
return result
|
| 86 |
+
|
| 87 |
+
def _parse_csv(self, file_path: str, result: Dict) -> Dict:
|
| 88 |
+
"""Parse CSV file into structured data."""
|
| 89 |
+
df = pd.read_csv(file_path)
|
| 90 |
+
|
| 91 |
+
# Convert to list of dicts
|
| 92 |
+
data = df.to_dict(orient='records')
|
| 93 |
+
|
| 94 |
+
# Generate text representation
|
| 95 |
+
text_parts = []
|
| 96 |
+
for i, row in enumerate(data):
|
| 97 |
+
row_text = f"Entry {i+1}: " + ", ".join([f"{k}={v}" for k, v in row.items() if pd.notna(v)])
|
| 98 |
+
text_parts.append(row_text)
|
| 99 |
+
|
| 100 |
+
result["data"] = data
|
| 101 |
+
result["text"] = "\n".join(text_parts)
|
| 102 |
+
result["entries_count"] = len(data)
|
| 103 |
+
result["columns"] = list(df.columns)
|
| 104 |
+
|
| 105 |
+
return result
|
| 106 |
+
|
| 107 |
+
def _parse_pdf(self, file_path: str, result: Dict) -> Dict:
|
| 108 |
+
"""Parse PDF file and extract text."""
|
| 109 |
+
reader = PdfReader(file_path)
|
| 110 |
+
|
| 111 |
+
text_parts = []
|
| 112 |
+
for i, page in enumerate(reader.pages):
|
| 113 |
+
page_text = page.extract_text()
|
| 114 |
+
if page_text:
|
| 115 |
+
text_parts.append(f"Page {i+1}:\n{page_text}")
|
| 116 |
+
|
| 117 |
+
full_text = "\n\n".join(text_parts)
|
| 118 |
+
|
| 119 |
+
# Count paragraphs as entries
|
| 120 |
+
paragraphs = [p.strip() for p in full_text.split('\n\n') if p.strip()]
|
| 121 |
+
|
| 122 |
+
result["text"] = full_text
|
| 123 |
+
result["entries_count"] = len(paragraphs)
|
| 124 |
+
result["page_count"] = len(reader.pages)
|
| 125 |
+
|
| 126 |
+
return result
|
| 127 |
+
|
| 128 |
+
def _parse_docx(self, file_path: str, result: Dict) -> Dict:
|
| 129 |
+
"""Parse DOCX file and extract text."""
|
| 130 |
+
doc = Document(file_path)
|
| 131 |
+
|
| 132 |
+
paragraphs = []
|
| 133 |
+
for para in doc.paragraphs:
|
| 134 |
+
if para.text.strip():
|
| 135 |
+
paragraphs.append(para.text.strip())
|
| 136 |
+
|
| 137 |
+
# Also extract tables
|
| 138 |
+
table_data = []
|
| 139 |
+
for table in doc.tables:
|
| 140 |
+
for row in table.rows:
|
| 141 |
+
row_data = [cell.text.strip() for cell in row.cells]
|
| 142 |
+
if any(row_data):
|
| 143 |
+
table_data.append(row_data)
|
| 144 |
+
|
| 145 |
+
result["text"] = "\n\n".join(paragraphs)
|
| 146 |
+
result["entries_count"] = len(paragraphs) + len(table_data)
|
| 147 |
+
result["table_data"] = table_data
|
| 148 |
+
|
| 149 |
+
return result
|
| 150 |
+
|
| 151 |
+
def _parse_json(self, file_path: str, result: Dict) -> Dict:
|
| 152 |
+
"""Parse JSON file into structured data."""
|
| 153 |
+
with open(file_path, 'r', encoding='utf-8') as f:
|
| 154 |
+
data = json.load(f)
|
| 155 |
+
|
| 156 |
+
# Handle different JSON structures
|
| 157 |
+
if isinstance(data, list):
|
| 158 |
+
entries = data
|
| 159 |
+
elif isinstance(data, dict):
|
| 160 |
+
# If it's a dict with a main data key, extract it
|
| 161 |
+
for key in ['data', 'items', 'records', 'entries']:
|
| 162 |
+
if key in data and isinstance(data[key], list):
|
| 163 |
+
entries = data[key]
|
| 164 |
+
break
|
| 165 |
+
else:
|
| 166 |
+
# Wrap single object in list
|
| 167 |
+
entries = [data]
|
| 168 |
+
else:
|
| 169 |
+
entries = [{"value": data}]
|
| 170 |
+
|
| 171 |
+
# Generate text representation
|
| 172 |
+
text_parts = []
|
| 173 |
+
for i, entry in enumerate(entries):
|
| 174 |
+
if isinstance(entry, dict):
|
| 175 |
+
entry_text = f"Entry {i+1}: " + json.dumps(entry, ensure_ascii=False)
|
| 176 |
+
else:
|
| 177 |
+
entry_text = f"Entry {i+1}: {entry}"
|
| 178 |
+
text_parts.append(entry_text)
|
| 179 |
+
|
| 180 |
+
result["data"] = entries
|
| 181 |
+
result["text"] = "\n".join(text_parts)
|
| 182 |
+
result["entries_count"] = len(entries)
|
| 183 |
+
|
| 184 |
+
return result
|
| 185 |
+
|
| 186 |
+
def _parse_txt(self, file_path: str, result: Dict) -> Dict:
|
| 187 |
+
"""Parse TXT file as plain text."""
|
| 188 |
+
with open(file_path, 'r', encoding='utf-8') as f:
|
| 189 |
+
text = f.read()
|
| 190 |
+
|
| 191 |
+
# Count lines as entries
|
| 192 |
+
lines = [line.strip() for line in text.split('\n') if line.strip()]
|
| 193 |
+
|
| 194 |
+
result["text"] = text
|
| 195 |
+
result["entries_count"] = len(lines)
|
| 196 |
+
|
| 197 |
+
return result
|
| 198 |
+
|
| 199 |
+
def validate_sufficiency(self, parsed_data: List[Dict[str, Any]]) -> Dict[str, Any]:
|
| 200 |
+
"""
|
| 201 |
+
Check if the combined data meets minimum requirements.
|
| 202 |
+
|
| 203 |
+
Args:
|
| 204 |
+
parsed_data: List of parsed file results
|
| 205 |
+
|
| 206 |
+
Returns:
|
| 207 |
+
Dict containing:
|
| 208 |
+
- sufficient: Boolean indicating if data is sufficient
|
| 209 |
+
- issues: List of issues found
|
| 210 |
+
- warnings: List of warnings
|
| 211 |
+
- stats: Statistics about the data
|
| 212 |
+
"""
|
| 213 |
+
total_entries = sum(p.get("entries_count", 0) for p in parsed_data)
|
| 214 |
+
total_chars = sum(len(p.get("text", "")) for p in parsed_data)
|
| 215 |
+
|
| 216 |
+
issues = []
|
| 217 |
+
warnings = []
|
| 218 |
+
|
| 219 |
+
# Check minimum thresholds
|
| 220 |
+
entries_ok = total_entries >= self.MIN_ENTRIES
|
| 221 |
+
chars_ok = total_chars >= self.MIN_CHARACTERS
|
| 222 |
+
|
| 223 |
+
if not entries_ok and not chars_ok:
|
| 224 |
+
issues.append(
|
| 225 |
+
f"Insufficient data: Found {total_entries} entries and {total_chars} characters. "
|
| 226 |
+
f"Need at least {self.MIN_ENTRIES} entries OR {self.MIN_CHARACTERS} characters."
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
# Check for empty files
|
| 230 |
+
empty_files = [p["file_name"] for p in parsed_data if p.get("entries_count", 0) == 0]
|
| 231 |
+
if empty_files:
|
| 232 |
+
issues.append(f"Empty or unreadable files: {', '.join(empty_files)}")
|
| 233 |
+
|
| 234 |
+
# Check for parsing errors
|
| 235 |
+
error_files = [p["file_name"] for p in parsed_data if "error" in p]
|
| 236 |
+
if error_files:
|
| 237 |
+
issues.append(f"Files with parsing errors: {', '.join(error_files)}")
|
| 238 |
+
|
| 239 |
+
# Add warnings for low-quality data
|
| 240 |
+
if total_entries < self.MIN_ENTRIES * 2:
|
| 241 |
+
warnings.append(
|
| 242 |
+
f"Consider adding more entries for better knowledge coverage. "
|
| 243 |
+
f"Current: {total_entries}, Recommended: {self.MIN_ENTRIES * 2}+"
|
| 244 |
+
)
|
| 245 |
+
|
| 246 |
+
# Calculate structure score (how well-structured the data is)
|
| 247 |
+
structured_count = sum(1 for p in parsed_data if p.get("data") is not None)
|
| 248 |
+
structure_score = structured_count / len(parsed_data) if parsed_data else 0
|
| 249 |
+
|
| 250 |
+
if structure_score < 0.5:
|
| 251 |
+
warnings.append(
|
| 252 |
+
"Most files are unstructured (PDF/TXT). "
|
| 253 |
+
"Structured data (CSV/JSON) provides better knowledge extraction."
|
| 254 |
+
)
|
| 255 |
+
|
| 256 |
+
# Compile statistics
|
| 257 |
+
stats = {
|
| 258 |
+
"total_files": len(parsed_data),
|
| 259 |
+
"total_entries": total_entries,
|
| 260 |
+
"total_characters": total_chars,
|
| 261 |
+
"structure_score": round(structure_score, 2),
|
| 262 |
+
"file_breakdown": [
|
| 263 |
+
{
|
| 264 |
+
"name": p["file_name"],
|
| 265 |
+
"format": p["format"],
|
| 266 |
+
"entries": p.get("entries_count", 0),
|
| 267 |
+
"characters": len(p.get("text", ""))
|
| 268 |
+
}
|
| 269 |
+
for p in parsed_data
|
| 270 |
+
]
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
return {
|
| 274 |
+
"sufficient": len(issues) == 0,
|
| 275 |
+
"issues": issues,
|
| 276 |
+
"warnings": warnings,
|
| 277 |
+
"stats": stats
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
def provide_feedback(self, validation_result: Dict[str, Any]) -> str:
|
| 281 |
+
"""
|
| 282 |
+
Generate user-friendly feedback message.
|
| 283 |
+
|
| 284 |
+
Args:
|
| 285 |
+
validation_result: Result from validate_sufficiency
|
| 286 |
+
|
| 287 |
+
Returns:
|
| 288 |
+
Formatted feedback message
|
| 289 |
+
"""
|
| 290 |
+
stats = validation_result["stats"]
|
| 291 |
+
|
| 292 |
+
if validation_result["sufficient"]:
|
| 293 |
+
# Success message
|
| 294 |
+
feedback = f"""✅ **Data Validation Passed!**
|
| 295 |
+
|
| 296 |
+
📊 **Statistics:**
|
| 297 |
+
- Total Files: {stats['total_files']}
|
| 298 |
+
- Total Entries: {stats['total_entries']}
|
| 299 |
+
- Total Characters: {stats['total_characters']:,}
|
| 300 |
+
- Structure Score: {stats['structure_score']*100:.0f}%
|
| 301 |
+
|
| 302 |
+
"""
|
| 303 |
+
# Add file breakdown
|
| 304 |
+
feedback += "📁 **File Breakdown:**\n"
|
| 305 |
+
for f in stats["file_breakdown"]:
|
| 306 |
+
feedback += f"- {f['name']} ({f['format'].upper()}): {f['entries']} entries\n"
|
| 307 |
+
|
| 308 |
+
# Add warnings if any
|
| 309 |
+
if validation_result["warnings"]:
|
| 310 |
+
feedback += "\n⚠️ **Suggestions:**\n"
|
| 311 |
+
for warning in validation_result["warnings"]:
|
| 312 |
+
feedback += f"- {warning}\n"
|
| 313 |
+
|
| 314 |
+
else:
|
| 315 |
+
# Failure message
|
| 316 |
+
feedback = f"""❌ **Data Validation Failed**
|
| 317 |
+
|
| 318 |
+
🔍 **Issues Found:**
|
| 319 |
+
"""
|
| 320 |
+
for issue in validation_result["issues"]:
|
| 321 |
+
feedback += f"- {issue}\n"
|
| 322 |
+
|
| 323 |
+
feedback += f"""
|
| 324 |
+
📊 **Current Statistics:**
|
| 325 |
+
- Total Entries: {stats['total_entries']} (minimum: {self.MIN_ENTRIES})
|
| 326 |
+
- Total Characters: {stats['total_characters']:,} (minimum: {self.MIN_CHARACTERS:,})
|
| 327 |
+
|
| 328 |
+
💡 **How to Fix:**
|
| 329 |
+
1. Add more data files (CSV, PDF, DOCX, JSON, or TXT)
|
| 330 |
+
2. Ensure files contain meaningful content
|
| 331 |
+
3. For best results, use structured formats like CSV or JSON
|
| 332 |
+
"""
|
| 333 |
+
|
| 334 |
+
return feedback
|
| 335 |
+
|
| 336 |
+
def parse_and_validate(self, file_paths: List[str]) -> Tuple[List[Dict], Dict, str]:
|
| 337 |
+
"""
|
| 338 |
+
Convenience method to parse all files and validate in one call.
|
| 339 |
+
|
| 340 |
+
Args:
|
| 341 |
+
file_paths: List of file paths to process
|
| 342 |
+
|
| 343 |
+
Returns:
|
| 344 |
+
Tuple of (parsed_data, validation_result, feedback_message)
|
| 345 |
+
"""
|
| 346 |
+
parsed_data = []
|
| 347 |
+
for path in file_paths:
|
| 348 |
+
result = self.parse_file(path)
|
| 349 |
+
parsed_data.append(result)
|
| 350 |
+
|
| 351 |
+
validation = self.validate_sufficiency(parsed_data)
|
| 352 |
+
feedback = self.provide_feedback(validation)
|
| 353 |
+
|
| 354 |
+
return parsed_data, validation, feedback
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
# Factory function for easy instantiation
|
| 358 |
+
def create_validator() -> DataValidator:
|
| 359 |
+
"""Create a new DataValidator instance."""
|
| 360 |
+
return DataValidator()
|
backend/modules/explainability.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MEXAR Core Engine - Explainability Generator Module
|
| 3 |
+
Packages reasoning traces for UI display.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import logging
|
| 7 |
+
from typing import Dict, List, Any, Optional
|
| 8 |
+
|
| 9 |
+
# Configure logging
|
| 10 |
+
logging.basicConfig(level=logging.INFO)
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class ExplainabilityGenerator:
|
| 15 |
+
"""
|
| 16 |
+
Generates structured explainability data for the UI.
|
| 17 |
+
Prepares reasoning traces and source citations.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
def __init__(self):
|
| 21 |
+
"""Initialize the explainability generator."""
|
| 22 |
+
pass
|
| 23 |
+
|
| 24 |
+
def generate(
|
| 25 |
+
self,
|
| 26 |
+
reasoning_result: Dict[str, Any]
|
| 27 |
+
) -> Dict[str, Any]:
|
| 28 |
+
"""
|
| 29 |
+
Generate comprehensive explainability data.
|
| 30 |
+
|
| 31 |
+
Args:
|
| 32 |
+
reasoning_result: Output from ReasoningEngine.reason()
|
| 33 |
+
|
| 34 |
+
Returns:
|
| 35 |
+
Structured explainability data for UI
|
| 36 |
+
"""
|
| 37 |
+
explainability = reasoning_result.get("explainability", {})
|
| 38 |
+
|
| 39 |
+
# Enhance the existing explainability data
|
| 40 |
+
enhanced = {
|
| 41 |
+
"summary": self._generate_summary(reasoning_result),
|
| 42 |
+
"inputs": self._format_inputs(explainability.get("inputs", {})),
|
| 43 |
+
"retrieval": self._format_retrieval(explainability.get("retrieval", {})),
|
| 44 |
+
"reasoning_steps": self._format_reasoning_steps(
|
| 45 |
+
explainability.get("reasoning_trace", [])
|
| 46 |
+
),
|
| 47 |
+
"confidence": self._format_confidence(
|
| 48 |
+
explainability.get("confidence_breakdown", {})
|
| 49 |
+
),
|
| 50 |
+
"sources": self._format_sources(explainability.get("sources_cited", []))
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
return enhanced
|
| 54 |
+
|
| 55 |
+
def _generate_summary(self, reasoning_result: Dict[str, Any]) -> Dict[str, Any]:
|
| 56 |
+
"""Generate a human-readable summary."""
|
| 57 |
+
confidence = reasoning_result.get("confidence", 0)
|
| 58 |
+
in_domain = reasoning_result.get("in_domain", True)
|
| 59 |
+
sources = reasoning_result.get("sources", [])
|
| 60 |
+
|
| 61 |
+
if not in_domain:
|
| 62 |
+
status = "rejected"
|
| 63 |
+
message = "Query was outside the agent's domain expertise"
|
| 64 |
+
color = "red"
|
| 65 |
+
elif confidence >= 0.8:
|
| 66 |
+
status = "high_confidence"
|
| 67 |
+
message = "Answer is well-supported by the knowledge base"
|
| 68 |
+
color = "green"
|
| 69 |
+
elif confidence >= 0.5:
|
| 70 |
+
status = "moderate_confidence"
|
| 71 |
+
message = "Answer is partially supported, some uncertainty exists"
|
| 72 |
+
color = "yellow"
|
| 73 |
+
else:
|
| 74 |
+
status = "low_confidence"
|
| 75 |
+
message = "Limited support in knowledge base, treat with caution"
|
| 76 |
+
color = "orange"
|
| 77 |
+
|
| 78 |
+
return {
|
| 79 |
+
"status": status,
|
| 80 |
+
"message": message,
|
| 81 |
+
"color": color,
|
| 82 |
+
"quick_stats": {
|
| 83 |
+
"sources_found": len(sources),
|
| 84 |
+
"confidence_percent": f"{confidence * 100:.0f}%"
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
def _format_inputs(self, inputs: Dict) -> Dict[str, Any]:
|
| 89 |
+
"""Format input information."""
|
| 90 |
+
return {
|
| 91 |
+
"query": inputs.get("original_query", ""),
|
| 92 |
+
"has_multimodal": inputs.get("has_multimodal", False),
|
| 93 |
+
"multimodal_type": self._detect_multimodal_type(inputs),
|
| 94 |
+
"multimodal_preview": inputs.get("multimodal_preview", "")
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
def _detect_multimodal_type(self, inputs: Dict) -> Optional[str]:
|
| 98 |
+
"""Detect the type of multimodal input."""
|
| 99 |
+
preview = inputs.get("multimodal_preview", "")
|
| 100 |
+
if not preview:
|
| 101 |
+
return None
|
| 102 |
+
|
| 103 |
+
if "[AUDIO" in preview:
|
| 104 |
+
return "audio"
|
| 105 |
+
elif "[IMAGE" in preview:
|
| 106 |
+
return "image"
|
| 107 |
+
elif "[VIDEO" in preview:
|
| 108 |
+
return "video"
|
| 109 |
+
return "text"
|
| 110 |
+
|
| 111 |
+
def _format_retrieval(self, retrieval: Dict) -> Dict[str, Any]:
|
| 112 |
+
"""Format retrieval information."""
|
| 113 |
+
return {
|
| 114 |
+
"chunks_retrieved": retrieval.get("chunks_retrieved", 0),
|
| 115 |
+
"previews": retrieval.get("chunk_previews", [])
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
def _format_reasoning_steps(self, trace: List[Dict]) -> List[Dict[str, Any]]:
|
| 119 |
+
"""Format reasoning trace into displayable steps."""
|
| 120 |
+
steps = []
|
| 121 |
+
|
| 122 |
+
for item in trace:
|
| 123 |
+
step = {
|
| 124 |
+
"step_number": item.get("step", len(steps) + 1),
|
| 125 |
+
"action": item.get("action", "unknown"),
|
| 126 |
+
"action_display": self._get_action_display(item.get("action", "unknown")),
|
| 127 |
+
"explanation": item.get("explanation", ""),
|
| 128 |
+
"icon": self._get_action_icon(item.get("action", "unknown"))
|
| 129 |
+
}
|
| 130 |
+
steps.append(step)
|
| 131 |
+
|
| 132 |
+
return steps
|
| 133 |
+
|
| 134 |
+
def _get_action_display(self, action: str) -> str:
|
| 135 |
+
"""Get display-friendly action name."""
|
| 136 |
+
action_map = {
|
| 137 |
+
"domain_check": "Domain Relevance Check",
|
| 138 |
+
"vector_retrieval": "Semantic Search",
|
| 139 |
+
"llm_generation": "Answer Generation",
|
| 140 |
+
"guardrail_rejection": "Domain Guardrail"
|
| 141 |
+
}
|
| 142 |
+
return action_map.get(action, action.replace("_", " ").title())
|
| 143 |
+
|
| 144 |
+
def _get_action_icon(self, action: str) -> str:
|
| 145 |
+
"""Get icon for reasoning action."""
|
| 146 |
+
icon_map = {
|
| 147 |
+
"domain_check": "✅",
|
| 148 |
+
"vector_retrieval": "🔍",
|
| 149 |
+
"llm_generation": "💬",
|
| 150 |
+
"guardrail_rejection": "🚫"
|
| 151 |
+
}
|
| 152 |
+
return icon_map.get(action, "▶️")
|
| 153 |
+
|
| 154 |
+
def _format_confidence(self, breakdown: Dict) -> Dict[str, Any]:
|
| 155 |
+
"""Format confidence breakdown for display."""
|
| 156 |
+
overall = breakdown.get("overall", 0)
|
| 157 |
+
|
| 158 |
+
# Determine confidence level
|
| 159 |
+
if overall >= 0.8:
|
| 160 |
+
level = "high"
|
| 161 |
+
color = "#22c55e" # Green
|
| 162 |
+
message = "High confidence answer"
|
| 163 |
+
elif overall >= 0.5:
|
| 164 |
+
level = "moderate"
|
| 165 |
+
color = "#eab308" # Yellow
|
| 166 |
+
message = "Moderate confidence"
|
| 167 |
+
else:
|
| 168 |
+
level = "low"
|
| 169 |
+
color = "#f97316" # Orange
|
| 170 |
+
message = "Low confidence - verify independently"
|
| 171 |
+
|
| 172 |
+
return {
|
| 173 |
+
"overall_score": overall,
|
| 174 |
+
"overall_percent": f"{overall * 100:.0f}%",
|
| 175 |
+
"level": level,
|
| 176 |
+
"color": color,
|
| 177 |
+
"message": message,
|
| 178 |
+
"factors": [
|
| 179 |
+
{
|
| 180 |
+
"name": "Domain Relevance",
|
| 181 |
+
"score": breakdown.get("domain_relevance", 0),
|
| 182 |
+
"percent": f"{breakdown.get('domain_relevance', 0) * 100:.0f}%",
|
| 183 |
+
"description": "How well the query matches the agent's domain"
|
| 184 |
+
},
|
| 185 |
+
{
|
| 186 |
+
"name": "Retrieval Quality",
|
| 187 |
+
"score": breakdown.get("retrieval_quality", 0),
|
| 188 |
+
"percent": f"{breakdown.get('retrieval_quality', 0) * 100:.0f}%",
|
| 189 |
+
"description": "Quality of retrieved context chunks"
|
| 190 |
+
}
|
| 191 |
+
]
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
def _format_sources(self, sources: List[str]) -> List[Dict[str, str]]:
|
| 195 |
+
"""Format source citations."""
|
| 196 |
+
formatted = []
|
| 197 |
+
|
| 198 |
+
for source in sources:
|
| 199 |
+
source_type = self._detect_source_type(source)
|
| 200 |
+
formatted.append({
|
| 201 |
+
"citation": source,
|
| 202 |
+
"type": source_type,
|
| 203 |
+
"icon": self._get_source_icon(source_type)
|
| 204 |
+
})
|
| 205 |
+
|
| 206 |
+
return formatted
|
| 207 |
+
|
| 208 |
+
def _detect_source_type(self, source: str) -> str:
|
| 209 |
+
"""Detect the type of source citation."""
|
| 210 |
+
source_lower = source.lower()
|
| 211 |
+
|
| 212 |
+
if ".csv" in source_lower:
|
| 213 |
+
return "csv"
|
| 214 |
+
elif ".pdf" in source_lower:
|
| 215 |
+
return "pdf"
|
| 216 |
+
elif ".json" in source_lower:
|
| 217 |
+
return "json"
|
| 218 |
+
elif ".docx" in source_lower or ".doc" in source_lower:
|
| 219 |
+
return "docx"
|
| 220 |
+
elif "entry" in source_lower or "row" in source_lower:
|
| 221 |
+
return "entry"
|
| 222 |
+
else:
|
| 223 |
+
return "text"
|
| 224 |
+
|
| 225 |
+
def _get_source_icon(self, source_type: str) -> str:
|
| 226 |
+
"""Get icon for source type."""
|
| 227 |
+
icon_map = {
|
| 228 |
+
"csv": "📊",
|
| 229 |
+
"pdf": "📄",
|
| 230 |
+
"json": "📋",
|
| 231 |
+
"docx": "📝",
|
| 232 |
+
"txt": "📃",
|
| 233 |
+
"entry": "📌"
|
| 234 |
+
}
|
| 235 |
+
return icon_map.get(source_type, "📎")
|
| 236 |
+
|
| 237 |
+
def format_for_display(
|
| 238 |
+
self,
|
| 239 |
+
explainability_data: Dict[str, Any],
|
| 240 |
+
format_type: str = "full"
|
| 241 |
+
) -> Dict[str, Any]:
|
| 242 |
+
"""
|
| 243 |
+
Format explainability data for specific display contexts.
|
| 244 |
+
|
| 245 |
+
Args:
|
| 246 |
+
explainability_data: Generated explainability data
|
| 247 |
+
format_type: 'full', 'compact', or 'minimal'
|
| 248 |
+
|
| 249 |
+
Returns:
|
| 250 |
+
Formatted data appropriate for the display context
|
| 251 |
+
"""
|
| 252 |
+
if format_type == "minimal":
|
| 253 |
+
return {
|
| 254 |
+
"summary": explainability_data.get("summary", {}),
|
| 255 |
+
"confidence": {
|
| 256 |
+
"score": explainability_data.get("confidence", {}).get("overall_percent", "0%"),
|
| 257 |
+
"level": explainability_data.get("confidence", {}).get("level", "unknown")
|
| 258 |
+
}
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
elif format_type == "compact":
|
| 262 |
+
return {
|
| 263 |
+
"summary": explainability_data.get("summary", {}),
|
| 264 |
+
"retrieval": explainability_data.get("retrieval", {}),
|
| 265 |
+
"confidence": explainability_data.get("confidence", {}),
|
| 266 |
+
"sources": explainability_data.get("sources", [])[:3]
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
# Full format
|
| 270 |
+
return explainability_data
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
# Factory function
|
| 274 |
+
def create_explainability_generator() -> ExplainabilityGenerator:
|
| 275 |
+
"""Create a new ExplainabilityGenerator instance."""
|
| 276 |
+
return ExplainabilityGenerator()
|
backend/modules/knowledge_compiler.py
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MEXAR Core Engine - Knowledge Compilation Module
|
| 3 |
+
Builds Vector embeddings from parsed data for semantic retrieval.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
import json
|
| 8 |
+
import logging
|
| 9 |
+
from typing import Dict, List, Any, Optional
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
from utils.groq_client import get_groq_client, GroqClient
|
| 13 |
+
from fastembed import TextEmbedding
|
| 14 |
+
from core.database import SessionLocal
|
| 15 |
+
from models.agent import Agent
|
| 16 |
+
from models.chunk import DocumentChunk
|
| 17 |
+
|
| 18 |
+
# Configure logging
|
| 19 |
+
logging.basicConfig(level=logging.INFO)
|
| 20 |
+
logger = logging.getLogger(__name__)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class KnowledgeCompiler:
|
| 24 |
+
"""
|
| 25 |
+
Compiles knowledge from parsed data into Vector embeddings.
|
| 26 |
+
Uses semantic similarity for retrieval-based reasoning.
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
def __init__(self, groq_client: Optional[GroqClient] = None, data_dir: str = "data/agents"):
|
| 30 |
+
"""
|
| 31 |
+
Initialize the knowledge compiler.
|
| 32 |
+
|
| 33 |
+
Args:
|
| 34 |
+
groq_client: Optional pre-configured Groq client
|
| 35 |
+
data_dir: Directory to store agent data
|
| 36 |
+
"""
|
| 37 |
+
self.client = groq_client or get_groq_client()
|
| 38 |
+
self.data_dir = Path(data_dir)
|
| 39 |
+
self.data_dir.mkdir(parents=True, exist_ok=True)
|
| 40 |
+
|
| 41 |
+
# Compilation progress tracking
|
| 42 |
+
self.progress = {
|
| 43 |
+
"status": "idle",
|
| 44 |
+
"percentage": 0,
|
| 45 |
+
"current_step": "",
|
| 46 |
+
"details": {}
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
# Initialize embedding model (384 dim default)
|
| 50 |
+
try:
|
| 51 |
+
self.embedding_model = TextEmbedding(model_name="BAAI/bge-small-en-v1.5")
|
| 52 |
+
logger.info("FastEmbed model loaded")
|
| 53 |
+
except Exception as e:
|
| 54 |
+
logger.warning(f"Failed to load embedding model: {e}")
|
| 55 |
+
self.embedding_model = None
|
| 56 |
+
|
| 57 |
+
def compile(
|
| 58 |
+
self,
|
| 59 |
+
agent_name: str,
|
| 60 |
+
parsed_data: List[Dict[str, Any]],
|
| 61 |
+
system_prompt: str,
|
| 62 |
+
prompt_analysis: Dict[str, Any]
|
| 63 |
+
) -> Dict[str, Any]:
|
| 64 |
+
"""
|
| 65 |
+
Main compilation function.
|
| 66 |
+
|
| 67 |
+
Args:
|
| 68 |
+
agent_name: Name of the agent being created
|
| 69 |
+
parsed_data: List of parsed file results from DataValidator
|
| 70 |
+
system_prompt: User's system prompt
|
| 71 |
+
prompt_analysis: Analysis from PromptAnalyzer
|
| 72 |
+
|
| 73 |
+
Returns:
|
| 74 |
+
Dict containing:
|
| 75 |
+
- domain_signature: Keywords for domain matching
|
| 76 |
+
- stats: Compilation statistics
|
| 77 |
+
"""
|
| 78 |
+
self._update_progress("starting", 0, "Initializing compilation...")
|
| 79 |
+
|
| 80 |
+
try:
|
| 81 |
+
# Step 1: Build text context (30%)
|
| 82 |
+
self._update_progress("building_context", 10, "Building text context...")
|
| 83 |
+
text_context = self._build_text_context(parsed_data)
|
| 84 |
+
self._update_progress("building_context", 30, f"Text context built: {len(text_context):,} characters")
|
| 85 |
+
|
| 86 |
+
# Step 2: Extract domain signature (50%)
|
| 87 |
+
self._update_progress("extracting_signature", 35, "Extracting domain signature...")
|
| 88 |
+
domain_signature = self._extract_domain_signature(parsed_data, prompt_analysis)
|
| 89 |
+
self._update_progress("extracting_signature", 50, f"Domain signature: {len(domain_signature)} keywords")
|
| 90 |
+
|
| 91 |
+
# Step 3: Calculate stats (60%)
|
| 92 |
+
self._update_progress("calculating_stats", 55, "Calculating statistics...")
|
| 93 |
+
stats = self._calculate_stats(text_context, parsed_data)
|
| 94 |
+
|
| 95 |
+
# Step 4: Save metadata (70%)
|
| 96 |
+
self._update_progress("saving", 65, "Saving agent metadata...")
|
| 97 |
+
self._save_agent(
|
| 98 |
+
agent_name=agent_name,
|
| 99 |
+
text_context=text_context,
|
| 100 |
+
domain_signature=domain_signature,
|
| 101 |
+
system_prompt=system_prompt,
|
| 102 |
+
prompt_analysis=prompt_analysis,
|
| 103 |
+
stats=stats
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
# Step 5: Save to Vector DB (95%)
|
| 107 |
+
if self.embedding_model:
|
| 108 |
+
self._update_progress("saving_vector", 75, "Saving to Vector Store...")
|
| 109 |
+
self._save_to_vector_db(agent_name, text_context)
|
| 110 |
+
|
| 111 |
+
self._update_progress("complete", 100, "Compilation complete!")
|
| 112 |
+
|
| 113 |
+
return {
|
| 114 |
+
"domain_signature": domain_signature,
|
| 115 |
+
"stats": stats,
|
| 116 |
+
"agent_path": str(self.data_dir / agent_name)
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
except Exception as e:
|
| 120 |
+
logger.error(f"Compilation failed: {e}")
|
| 121 |
+
self._update_progress("error", self.progress["percentage"], f"Error: {str(e)}")
|
| 122 |
+
raise
|
| 123 |
+
|
| 124 |
+
def _update_progress(self, status: str, percentage: int, step: str, details: Dict = None):
|
| 125 |
+
"""Update compilation progress."""
|
| 126 |
+
self.progress = {
|
| 127 |
+
"status": status,
|
| 128 |
+
"percentage": percentage,
|
| 129 |
+
"current_step": step,
|
| 130 |
+
"details": details or {}
|
| 131 |
+
}
|
| 132 |
+
logger.info(f"[{percentage}%] {step}")
|
| 133 |
+
|
| 134 |
+
def get_progress(self) -> Dict[str, Any]:
|
| 135 |
+
"""Get current compilation progress."""
|
| 136 |
+
return self.progress.copy()
|
| 137 |
+
|
| 138 |
+
def _build_text_context(self, parsed_data: List[Dict[str, Any]]) -> str:
|
| 139 |
+
"""
|
| 140 |
+
Build text context from parsed data.
|
| 141 |
+
|
| 142 |
+
Args:
|
| 143 |
+
parsed_data: Parsed file data
|
| 144 |
+
|
| 145 |
+
Returns:
|
| 146 |
+
Formatted text context
|
| 147 |
+
"""
|
| 148 |
+
context_parts = []
|
| 149 |
+
|
| 150 |
+
for i, file_data in enumerate(parsed_data):
|
| 151 |
+
file_name = file_data.get("file_name", file_data.get("source", f"Source_{i+1}"))
|
| 152 |
+
file_format = file_data.get("format", file_data.get("type", "unknown"))
|
| 153 |
+
|
| 154 |
+
context_parts.append(f"\n{'='*60}")
|
| 155 |
+
context_parts.append(f"SOURCE: {file_name} ({file_format.upper()})")
|
| 156 |
+
context_parts.append(f"{'='*60}\n")
|
| 157 |
+
|
| 158 |
+
# Handle structured data (CSV, JSON)
|
| 159 |
+
if file_data.get("data"):
|
| 160 |
+
for j, entry in enumerate(file_data["data"]):
|
| 161 |
+
if isinstance(entry, dict):
|
| 162 |
+
entry_lines = [f"[Entry {j+1}]"]
|
| 163 |
+
for key, value in entry.items():
|
| 164 |
+
if value is not None and str(value).strip():
|
| 165 |
+
entry_lines.append(f" {key}: {value}")
|
| 166 |
+
context_parts.append("\n".join(entry_lines))
|
| 167 |
+
else:
|
| 168 |
+
context_parts.append(f"[Entry {j+1}] {entry}")
|
| 169 |
+
|
| 170 |
+
# Handle unstructured text (PDF, DOCX, TXT)
|
| 171 |
+
elif file_data.get("text"):
|
| 172 |
+
context_parts.append(file_data["text"])
|
| 173 |
+
|
| 174 |
+
# Handle content field
|
| 175 |
+
elif file_data.get("content"):
|
| 176 |
+
context_parts.append(file_data["content"])
|
| 177 |
+
|
| 178 |
+
# Handle records field
|
| 179 |
+
elif file_data.get("records"):
|
| 180 |
+
for j, record in enumerate(file_data["records"]):
|
| 181 |
+
if record and record.strip():
|
| 182 |
+
context_parts.append(f"[Line {j+1}] {record}")
|
| 183 |
+
|
| 184 |
+
text_context = "\n".join(context_parts)
|
| 185 |
+
|
| 186 |
+
# Limit to prevent token overflow (approximately 128K tokens = 500K chars)
|
| 187 |
+
max_chars = 500000
|
| 188 |
+
if len(text_context) > max_chars:
|
| 189 |
+
logger.warning(f"Text context truncated from {len(text_context)} to {max_chars} characters")
|
| 190 |
+
text_context = text_context[:max_chars] + "\n\n[CONTEXT TRUNCATED DUE TO SIZE LIMITS]"
|
| 191 |
+
|
| 192 |
+
return text_context
|
| 193 |
+
|
| 194 |
+
def _extract_domain_signature(
|
| 195 |
+
self,
|
| 196 |
+
parsed_data: List[Dict[str, Any]],
|
| 197 |
+
prompt_analysis: Dict[str, Any]
|
| 198 |
+
) -> List[str]:
|
| 199 |
+
"""
|
| 200 |
+
Extract domain signature keywords for guardrail checking.
|
| 201 |
+
"""
|
| 202 |
+
# Start with analyzed keywords (highest priority)
|
| 203 |
+
domain_keywords = prompt_analysis.get("domain_keywords", [])
|
| 204 |
+
signature = list(domain_keywords)
|
| 205 |
+
|
| 206 |
+
# Add domain and sub-domains
|
| 207 |
+
domain = prompt_analysis.get("domain", "")
|
| 208 |
+
if domain and domain not in signature:
|
| 209 |
+
signature.insert(0, domain)
|
| 210 |
+
|
| 211 |
+
for sub_domain in prompt_analysis.get("sub_domains", []):
|
| 212 |
+
if sub_domain and sub_domain.lower() not in [s.lower() for s in signature]:
|
| 213 |
+
signature.append(sub_domain)
|
| 214 |
+
|
| 215 |
+
# Extract column headers from structured data
|
| 216 |
+
for file_data in parsed_data:
|
| 217 |
+
if file_data.get("data") and isinstance(file_data["data"], list):
|
| 218 |
+
if file_data["data"] and isinstance(file_data["data"][0], dict):
|
| 219 |
+
for key in file_data["data"][0].keys():
|
| 220 |
+
clean_key = key.lower().strip().replace("_", " ")
|
| 221 |
+
if clean_key and clean_key not in [s.lower() for s in signature]:
|
| 222 |
+
signature.append(clean_key)
|
| 223 |
+
|
| 224 |
+
return signature[:80] # Limit for efficiency
|
| 225 |
+
|
| 226 |
+
def _calculate_stats(
|
| 227 |
+
self,
|
| 228 |
+
text_context: str,
|
| 229 |
+
parsed_data: List[Dict[str, Any]]
|
| 230 |
+
) -> Dict[str, Any]:
|
| 231 |
+
"""Calculate compilation statistics."""
|
| 232 |
+
return {
|
| 233 |
+
"context_length": len(text_context),
|
| 234 |
+
"context_tokens": len(text_context) // 4, # Rough estimate
|
| 235 |
+
"source_files": len(parsed_data),
|
| 236 |
+
"total_entries": sum(
|
| 237 |
+
len(p.get("data", [])) or len(p.get("records", []))
|
| 238 |
+
for p in parsed_data
|
| 239 |
+
)
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
def _save_agent(
|
| 243 |
+
self,
|
| 244 |
+
agent_name: str,
|
| 245 |
+
text_context: str,
|
| 246 |
+
domain_signature: List[str],
|
| 247 |
+
system_prompt: str,
|
| 248 |
+
prompt_analysis: Dict[str, Any],
|
| 249 |
+
stats: Dict[str, Any]
|
| 250 |
+
):
|
| 251 |
+
"""Save agent artifacts to filesystem."""
|
| 252 |
+
agent_dir = self.data_dir / agent_name
|
| 253 |
+
agent_dir.mkdir(parents=True, exist_ok=True)
|
| 254 |
+
|
| 255 |
+
# Save text context (for backup/debugging)
|
| 256 |
+
with open(agent_dir / "context.txt", "w", encoding="utf-8") as f:
|
| 257 |
+
f.write(text_context)
|
| 258 |
+
|
| 259 |
+
# Save metadata
|
| 260 |
+
metadata = {
|
| 261 |
+
"agent_name": agent_name,
|
| 262 |
+
"system_prompt": system_prompt,
|
| 263 |
+
"prompt_analysis": prompt_analysis,
|
| 264 |
+
"domain_signature": domain_signature,
|
| 265 |
+
"stats": stats,
|
| 266 |
+
"created_at": self._get_timestamp()
|
| 267 |
+
}
|
| 268 |
+
with open(agent_dir / "metadata.json", "w", encoding="utf-8") as f:
|
| 269 |
+
json.dump(metadata, f, indent=2, ensure_ascii=False)
|
| 270 |
+
|
| 271 |
+
logger.info(f"Agent saved to: {agent_dir}")
|
| 272 |
+
|
| 273 |
+
def _get_timestamp(self) -> str:
|
| 274 |
+
"""Get current timestamp."""
|
| 275 |
+
from datetime import datetime
|
| 276 |
+
return datetime.now().isoformat()
|
| 277 |
+
|
| 278 |
+
def load_agent(self, agent_name: str) -> Dict[str, Any]:
|
| 279 |
+
"""
|
| 280 |
+
Load a previously compiled agent.
|
| 281 |
+
|
| 282 |
+
Args:
|
| 283 |
+
agent_name: Name of the agent to load
|
| 284 |
+
|
| 285 |
+
Returns:
|
| 286 |
+
Dict with agent artifacts
|
| 287 |
+
"""
|
| 288 |
+
agent_dir = self.data_dir / agent_name
|
| 289 |
+
|
| 290 |
+
if not agent_dir.exists():
|
| 291 |
+
raise FileNotFoundError(f"Agent '{agent_name}' not found")
|
| 292 |
+
|
| 293 |
+
# Load metadata
|
| 294 |
+
with open(agent_dir / "metadata.json", "r", encoding="utf-8") as f:
|
| 295 |
+
metadata = json.load(f)
|
| 296 |
+
|
| 297 |
+
return {
|
| 298 |
+
"metadata": metadata,
|
| 299 |
+
"domain_signature": metadata.get("domain_signature", []),
|
| 300 |
+
"system_prompt": metadata.get("system_prompt", ""),
|
| 301 |
+
"prompt_analysis": metadata.get("prompt_analysis", {})
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
def _save_to_vector_db(self, agent_name: str, context: str):
|
| 305 |
+
"""Chunk and save context to vector database."""
|
| 306 |
+
try:
|
| 307 |
+
chunks = self._chunk_text(context)
|
| 308 |
+
if not chunks:
|
| 309 |
+
logger.warning(f"No chunks generated for {agent_name}")
|
| 310 |
+
return
|
| 311 |
+
|
| 312 |
+
logger.info(f"Generating embeddings for {len(chunks)} chunks...")
|
| 313 |
+
|
| 314 |
+
# Generate embeddings with error handling
|
| 315 |
+
try:
|
| 316 |
+
embeddings = list(self.embedding_model.embed(chunks))
|
| 317 |
+
logger.info(f"Successfully generated {len(embeddings)} embeddings")
|
| 318 |
+
except Exception as embed_error:
|
| 319 |
+
logger.error(f"Embedding generation failed: {embed_error}")
|
| 320 |
+
# Don't fail the entire compilation if embeddings fail
|
| 321 |
+
return
|
| 322 |
+
|
| 323 |
+
with SessionLocal() as db:
|
| 324 |
+
agent = db.query(Agent).filter(Agent.name == agent_name).first()
|
| 325 |
+
if not agent:
|
| 326 |
+
logger.error(f"Agent {agent_name} not found in DB")
|
| 327 |
+
return
|
| 328 |
+
|
| 329 |
+
# Clear old chunks
|
| 330 |
+
try:
|
| 331 |
+
deleted_count = db.query(DocumentChunk).filter(DocumentChunk.agent_id == agent.id).delete()
|
| 332 |
+
logger.info(f"Deleted {deleted_count} old chunks for agent {agent_name}")
|
| 333 |
+
except Exception as delete_error:
|
| 334 |
+
logger.warning(f"Failed to delete old chunks: {delete_error}")
|
| 335 |
+
# Continue anyway
|
| 336 |
+
|
| 337 |
+
# Insert new chunks
|
| 338 |
+
try:
|
| 339 |
+
new_chunks = [
|
| 340 |
+
DocumentChunk(
|
| 341 |
+
agent_id=agent.id,
|
| 342 |
+
content=chunk,
|
| 343 |
+
embedding=embedding.tolist(),
|
| 344 |
+
source="context"
|
| 345 |
+
)
|
| 346 |
+
for chunk, embedding in zip(chunks, embeddings)
|
| 347 |
+
]
|
| 348 |
+
db.add_all(new_chunks)
|
| 349 |
+
|
| 350 |
+
# Update agent's chunk_count
|
| 351 |
+
agent.chunk_count = len(new_chunks)
|
| 352 |
+
|
| 353 |
+
db.commit()
|
| 354 |
+
logger.info(f"Saved {len(new_chunks)} chunks to vector store for {agent_name}")
|
| 355 |
+
except Exception as insert_error:
|
| 356 |
+
logger.error(f"Failed to insert chunks: {insert_error}")
|
| 357 |
+
db.rollback()
|
| 358 |
+
raise
|
| 359 |
+
|
| 360 |
+
except Exception as e:
|
| 361 |
+
logger.error(f"Vector save failed: {e}", exc_info=True)
|
| 362 |
+
# Don't raise - allow compilation to continue even if vector save fails
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
def _chunk_text(self, text: str, chunk_size: int = 1000, overlap: int = 100) -> List[str]:
|
| 366 |
+
"""Simple text chunker."""
|
| 367 |
+
chunks = []
|
| 368 |
+
if not text:
|
| 369 |
+
return []
|
| 370 |
+
|
| 371 |
+
start = 0
|
| 372 |
+
while start < len(text):
|
| 373 |
+
end = min(start + chunk_size, len(text))
|
| 374 |
+
chunks.append(text[start:end])
|
| 375 |
+
if end == len(text):
|
| 376 |
+
break
|
| 377 |
+
start += (chunk_size - overlap)
|
| 378 |
+
return chunks
|
| 379 |
+
|
| 380 |
+
def list_agents(self) -> List[Dict[str, Any]]:
|
| 381 |
+
"""List all compiled agents."""
|
| 382 |
+
agents = []
|
| 383 |
+
|
| 384 |
+
for agent_dir in self.data_dir.iterdir():
|
| 385 |
+
if agent_dir.is_dir():
|
| 386 |
+
metadata_path = agent_dir / "metadata.json"
|
| 387 |
+
if metadata_path.exists():
|
| 388 |
+
with open(metadata_path, "r", encoding="utf-8") as f:
|
| 389 |
+
metadata = json.load(f)
|
| 390 |
+
agents.append({
|
| 391 |
+
"name": agent_dir.name,
|
| 392 |
+
"domain": metadata.get("prompt_analysis", {}).get("domain", "unknown"),
|
| 393 |
+
"created_at": metadata.get("created_at"),
|
| 394 |
+
"stats": metadata.get("stats", {})
|
| 395 |
+
})
|
| 396 |
+
|
| 397 |
+
return agents
|
| 398 |
+
|
| 399 |
+
|
| 400 |
+
# Factory function
|
| 401 |
+
def create_knowledge_compiler(data_dir: str = "data/agents") -> KnowledgeCompiler:
|
| 402 |
+
"""Create a new KnowledgeCompiler instance."""
|
| 403 |
+
return KnowledgeCompiler(data_dir=data_dir)
|
backend/modules/multimodal_processor.py
ADDED
|
@@ -0,0 +1,415 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MEXAR Core Engine - Multimodal Input Processing Module
|
| 3 |
+
Handles audio, image, and video input conversion to text.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
import base64
|
| 8 |
+
import logging
|
| 9 |
+
import tempfile
|
| 10 |
+
from typing import Dict, List, Any, Optional
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
from utils.groq_client import get_groq_client, GroqClient
|
| 14 |
+
|
| 15 |
+
# Configure logging
|
| 16 |
+
logging.basicConfig(level=logging.INFO)
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class MultimodalProcessor:
|
| 21 |
+
"""
|
| 22 |
+
Processes multimodal inputs (audio, image, video) and converts them to text.
|
| 23 |
+
Uses Groq Whisper for audio and Groq Vision for images.
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
# Supported file types
|
| 27 |
+
AUDIO_EXTENSIONS = {'.mp3', '.wav', '.m4a', '.ogg', '.flac', '.webm'}
|
| 28 |
+
IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'}
|
| 29 |
+
VIDEO_EXTENSIONS = {'.mp4', '.avi', '.mov', '.mkv', '.webm'}
|
| 30 |
+
|
| 31 |
+
def __init__(self, groq_client: Optional[GroqClient] = None):
|
| 32 |
+
"""
|
| 33 |
+
Initialize the multimodal processor.
|
| 34 |
+
|
| 35 |
+
Args:
|
| 36 |
+
groq_client: Optional pre-configured Groq client
|
| 37 |
+
"""
|
| 38 |
+
self.client = groq_client or get_groq_client()
|
| 39 |
+
|
| 40 |
+
def process_audio(self, audio_path: str, language: str = "en") -> Dict[str, Any]:
|
| 41 |
+
"""
|
| 42 |
+
Transcribe audio file using Groq Whisper.
|
| 43 |
+
|
| 44 |
+
Args:
|
| 45 |
+
audio_path: Path to audio file
|
| 46 |
+
language: Language code for transcription
|
| 47 |
+
|
| 48 |
+
Returns:
|
| 49 |
+
Dict with transcription results
|
| 50 |
+
"""
|
| 51 |
+
path = Path(audio_path)
|
| 52 |
+
|
| 53 |
+
if not path.exists():
|
| 54 |
+
raise FileNotFoundError(f"Audio file not found: {audio_path}")
|
| 55 |
+
|
| 56 |
+
if path.suffix.lower() not in self.AUDIO_EXTENSIONS:
|
| 57 |
+
raise ValueError(f"Unsupported audio format: {path.suffix}")
|
| 58 |
+
|
| 59 |
+
try:
|
| 60 |
+
logger.info(f"Transcribing audio: {path.name}")
|
| 61 |
+
|
| 62 |
+
transcript = self.client.transcribe_audio(audio_path, language)
|
| 63 |
+
|
| 64 |
+
return {
|
| 65 |
+
"success": True,
|
| 66 |
+
"type": "audio",
|
| 67 |
+
"file_name": path.name,
|
| 68 |
+
"transcript": transcript,
|
| 69 |
+
"language": language,
|
| 70 |
+
"word_count": len(transcript.split())
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
except Exception as e:
|
| 74 |
+
logger.error(f"Audio transcription failed: {e}")
|
| 75 |
+
return {
|
| 76 |
+
"success": False,
|
| 77 |
+
"type": "audio",
|
| 78 |
+
"file_name": path.name,
|
| 79 |
+
"error": str(e)
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
def process_image(
|
| 83 |
+
self,
|
| 84 |
+
image_path: str,
|
| 85 |
+
prompt: str = "Describe this image in detail, including all visible text, objects, and relevant information."
|
| 86 |
+
) -> Dict[str, Any]:
|
| 87 |
+
"""
|
| 88 |
+
Describe image using Groq Vision.
|
| 89 |
+
|
| 90 |
+
Args:
|
| 91 |
+
image_path: Path to image file
|
| 92 |
+
prompt: Question or instruction for the vision model
|
| 93 |
+
|
| 94 |
+
Returns:
|
| 95 |
+
Dict with image description
|
| 96 |
+
"""
|
| 97 |
+
path = Path(image_path)
|
| 98 |
+
|
| 99 |
+
if not path.exists():
|
| 100 |
+
logger.error(f"Image file not found: {image_path}")
|
| 101 |
+
raise FileNotFoundError(f"Image file not found: {image_path}")
|
| 102 |
+
|
| 103 |
+
if path.suffix.lower() not in self.IMAGE_EXTENSIONS:
|
| 104 |
+
logger.error(f"Unsupported image format: {path.suffix}")
|
| 105 |
+
raise ValueError(f"Unsupported image format: {path.suffix}")
|
| 106 |
+
|
| 107 |
+
try:
|
| 108 |
+
logger.info(f"Analyzing image: {path.name} (size: {path.stat().st_size} bytes)")
|
| 109 |
+
|
| 110 |
+
# Call Groq Vision API
|
| 111 |
+
description = self.client.describe_image(image_path, prompt)
|
| 112 |
+
|
| 113 |
+
logger.info(f"Image analysis successful: {len(description)} chars returned")
|
| 114 |
+
|
| 115 |
+
return {
|
| 116 |
+
"success": True,
|
| 117 |
+
"type": "image",
|
| 118 |
+
"file_name": path.name,
|
| 119 |
+
"description": description,
|
| 120 |
+
"prompt_used": prompt
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
except Exception as e:
|
| 124 |
+
logger.error(f"Image analysis failed for {path.name}: {type(e).__name__}: {e}")
|
| 125 |
+
return {
|
| 126 |
+
"success": False,
|
| 127 |
+
"type": "image",
|
| 128 |
+
"file_name": path.name,
|
| 129 |
+
"error": str(e),
|
| 130 |
+
"error_type": type(e).__name__
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
def process_video(
|
| 134 |
+
self,
|
| 135 |
+
video_path: str,
|
| 136 |
+
max_frames: int = 5,
|
| 137 |
+
extract_audio: bool = True
|
| 138 |
+
) -> Dict[str, Any]:
|
| 139 |
+
"""
|
| 140 |
+
Process video by extracting keyframes and audio.
|
| 141 |
+
|
| 142 |
+
Args:
|
| 143 |
+
video_path: Path to video file
|
| 144 |
+
max_frames: Maximum number of keyframes to extract
|
| 145 |
+
extract_audio: Whether to extract and transcribe audio
|
| 146 |
+
|
| 147 |
+
Returns:
|
| 148 |
+
Dict with video analysis results
|
| 149 |
+
"""
|
| 150 |
+
path = Path(video_path)
|
| 151 |
+
|
| 152 |
+
if not path.exists():
|
| 153 |
+
raise FileNotFoundError(f"Video file not found: {video_path}")
|
| 154 |
+
|
| 155 |
+
if path.suffix.lower() not in self.VIDEO_EXTENSIONS:
|
| 156 |
+
raise ValueError(f"Unsupported video format: {path.suffix}")
|
| 157 |
+
|
| 158 |
+
result = {
|
| 159 |
+
"success": True,
|
| 160 |
+
"type": "video",
|
| 161 |
+
"file_name": path.name,
|
| 162 |
+
"frames": [],
|
| 163 |
+
"audio_transcript": None
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
try:
|
| 167 |
+
# Try to import OpenCV
|
| 168 |
+
try:
|
| 169 |
+
import cv2
|
| 170 |
+
has_opencv = True
|
| 171 |
+
except ImportError:
|
| 172 |
+
logger.warning("OpenCV not available, skipping video frame extraction")
|
| 173 |
+
has_opencv = False
|
| 174 |
+
|
| 175 |
+
if has_opencv:
|
| 176 |
+
# Extract keyframes
|
| 177 |
+
frames = self._extract_keyframes(video_path, max_frames)
|
| 178 |
+
|
| 179 |
+
# Analyze each frame
|
| 180 |
+
for i, frame_path in enumerate(frames):
|
| 181 |
+
frame_result = self.process_image(
|
| 182 |
+
frame_path,
|
| 183 |
+
f"This is frame {i+1} from a video. Describe what you see, focusing on actions, objects, and any text visible."
|
| 184 |
+
)
|
| 185 |
+
result["frames"].append(frame_result)
|
| 186 |
+
|
| 187 |
+
# Clean up temp frame
|
| 188 |
+
try:
|
| 189 |
+
os.remove(frame_path)
|
| 190 |
+
except:
|
| 191 |
+
pass
|
| 192 |
+
|
| 193 |
+
# Extract and transcribe audio
|
| 194 |
+
if extract_audio:
|
| 195 |
+
audio_path = self._extract_audio(video_path)
|
| 196 |
+
if audio_path:
|
| 197 |
+
audio_result = self.process_audio(audio_path)
|
| 198 |
+
result["audio_transcript"] = audio_result.get("transcript", "")
|
| 199 |
+
|
| 200 |
+
# Clean up temp audio
|
| 201 |
+
try:
|
| 202 |
+
os.remove(audio_path)
|
| 203 |
+
except:
|
| 204 |
+
pass
|
| 205 |
+
|
| 206 |
+
logger.info(f"Video processed: {len(result['frames'])} frames, audio: {result['audio_transcript'] is not None}")
|
| 207 |
+
|
| 208 |
+
except Exception as e:
|
| 209 |
+
logger.error(f"Video processing failed: {e}")
|
| 210 |
+
result["success"] = False
|
| 211 |
+
result["error"] = str(e)
|
| 212 |
+
|
| 213 |
+
return result
|
| 214 |
+
|
| 215 |
+
def _extract_keyframes(self, video_path: str, max_frames: int = 5) -> List[str]:
|
| 216 |
+
"""Extract keyframes from video using OpenCV."""
|
| 217 |
+
import cv2
|
| 218 |
+
|
| 219 |
+
cap = cv2.VideoCapture(video_path)
|
| 220 |
+
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
| 221 |
+
|
| 222 |
+
if total_frames == 0:
|
| 223 |
+
cap.release()
|
| 224 |
+
return []
|
| 225 |
+
|
| 226 |
+
# Calculate frame intervals
|
| 227 |
+
interval = max(1, total_frames // max_frames)
|
| 228 |
+
|
| 229 |
+
frame_paths = []
|
| 230 |
+
frame_count = 0
|
| 231 |
+
|
| 232 |
+
while cap.isOpened() and len(frame_paths) < max_frames:
|
| 233 |
+
ret, frame = cap.read()
|
| 234 |
+
if not ret:
|
| 235 |
+
break
|
| 236 |
+
|
| 237 |
+
if frame_count % interval == 0:
|
| 238 |
+
# Save frame to temp file
|
| 239 |
+
temp_path = tempfile.mktemp(suffix=".jpg")
|
| 240 |
+
cv2.imwrite(temp_path, frame)
|
| 241 |
+
frame_paths.append(temp_path)
|
| 242 |
+
|
| 243 |
+
frame_count += 1
|
| 244 |
+
|
| 245 |
+
cap.release()
|
| 246 |
+
return frame_paths
|
| 247 |
+
|
| 248 |
+
def _extract_audio(self, video_path: str) -> Optional[str]:
|
| 249 |
+
"""Extract audio track from video."""
|
| 250 |
+
try:
|
| 251 |
+
# Try using ffmpeg via subprocess
|
| 252 |
+
import subprocess
|
| 253 |
+
|
| 254 |
+
temp_audio = tempfile.mktemp(suffix=".mp3")
|
| 255 |
+
|
| 256 |
+
cmd = [
|
| 257 |
+
"ffmpeg",
|
| 258 |
+
"-i", video_path,
|
| 259 |
+
"-vn", # No video
|
| 260 |
+
"-acodec", "libmp3lame",
|
| 261 |
+
"-q:a", "2",
|
| 262 |
+
"-y", # Overwrite
|
| 263 |
+
temp_audio
|
| 264 |
+
]
|
| 265 |
+
|
| 266 |
+
result = subprocess.run(
|
| 267 |
+
cmd,
|
| 268 |
+
capture_output=True,
|
| 269 |
+
text=True,
|
| 270 |
+
timeout=120
|
| 271 |
+
)
|
| 272 |
+
|
| 273 |
+
if os.path.exists(temp_audio) and os.path.getsize(temp_audio) > 0:
|
| 274 |
+
return temp_audio
|
| 275 |
+
|
| 276 |
+
return None
|
| 277 |
+
|
| 278 |
+
except Exception as e:
|
| 279 |
+
logger.warning(f"Audio extraction failed: {e}")
|
| 280 |
+
return None
|
| 281 |
+
|
| 282 |
+
def fuse_inputs(
|
| 283 |
+
self,
|
| 284 |
+
text: str = "",
|
| 285 |
+
audio_result: Optional[Dict] = None,
|
| 286 |
+
image_result: Optional[Dict] = None,
|
| 287 |
+
video_result: Optional[Dict] = None
|
| 288 |
+
) -> str:
|
| 289 |
+
"""
|
| 290 |
+
Fuse all multimodal inputs into a unified text context.
|
| 291 |
+
|
| 292 |
+
Args:
|
| 293 |
+
text: Direct text input
|
| 294 |
+
audio_result: Result from process_audio
|
| 295 |
+
image_result: Result from process_image
|
| 296 |
+
video_result: Result from process_video
|
| 297 |
+
|
| 298 |
+
Returns:
|
| 299 |
+
Unified text context
|
| 300 |
+
"""
|
| 301 |
+
context_parts = []
|
| 302 |
+
|
| 303 |
+
# Add text input
|
| 304 |
+
if text and text.strip():
|
| 305 |
+
context_parts.append(f"[USER TEXT]\n{text.strip()}")
|
| 306 |
+
|
| 307 |
+
# Add audio transcript
|
| 308 |
+
if audio_result and audio_result.get("success"):
|
| 309 |
+
transcript = audio_result.get("transcript", "")
|
| 310 |
+
if transcript:
|
| 311 |
+
context_parts.append(f"[AUDIO TRANSCRIPT]\n{transcript}")
|
| 312 |
+
|
| 313 |
+
# Add image description
|
| 314 |
+
if image_result and image_result.get("success"):
|
| 315 |
+
description = image_result.get("description", "")
|
| 316 |
+
if description:
|
| 317 |
+
context_parts.append(f"[IMAGE DESCRIPTION]\n{description}")
|
| 318 |
+
|
| 319 |
+
# Add video content
|
| 320 |
+
if video_result and video_result.get("success"):
|
| 321 |
+
video_context = []
|
| 322 |
+
|
| 323 |
+
# Add frame descriptions
|
| 324 |
+
for i, frame in enumerate(video_result.get("frames", [])):
|
| 325 |
+
if frame.get("success"):
|
| 326 |
+
video_context.append(f"Frame {i+1}: {frame.get('description', '')}")
|
| 327 |
+
|
| 328 |
+
# Add audio transcript
|
| 329 |
+
if video_result.get("audio_transcript"):
|
| 330 |
+
video_context.append(f"Audio: {video_result['audio_transcript']}")
|
| 331 |
+
|
| 332 |
+
if video_context:
|
| 333 |
+
context_parts.append(f"[VIDEO ANALYSIS]\n" + "\n".join(video_context))
|
| 334 |
+
|
| 335 |
+
# Combine all parts
|
| 336 |
+
fused_context = "\n\n".join(context_parts)
|
| 337 |
+
|
| 338 |
+
logger.info(f"Fused context: {len(fused_context)} characters from {len(context_parts)} sources")
|
| 339 |
+
|
| 340 |
+
return fused_context
|
| 341 |
+
|
| 342 |
+
def process_upload(
|
| 343 |
+
self,
|
| 344 |
+
file_path: str,
|
| 345 |
+
additional_text: str = ""
|
| 346 |
+
) -> Dict[str, Any]:
|
| 347 |
+
"""
|
| 348 |
+
Automatically detect file type and process accordingly.
|
| 349 |
+
|
| 350 |
+
Args:
|
| 351 |
+
file_path: Path to uploaded file
|
| 352 |
+
additional_text: Additional text context
|
| 353 |
+
|
| 354 |
+
Returns:
|
| 355 |
+
Processing result with fused context
|
| 356 |
+
"""
|
| 357 |
+
path = Path(file_path)
|
| 358 |
+
ext = path.suffix.lower()
|
| 359 |
+
|
| 360 |
+
result = {
|
| 361 |
+
"success": True,
|
| 362 |
+
"file_type": "unknown",
|
| 363 |
+
"processing_result": None,
|
| 364 |
+
"fused_context": ""
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
try:
|
| 368 |
+
if ext in self.AUDIO_EXTENSIONS:
|
| 369 |
+
result["file_type"] = "audio"
|
| 370 |
+
audio_result = self.process_audio(file_path)
|
| 371 |
+
result["processing_result"] = audio_result
|
| 372 |
+
result["fused_context"] = self.fuse_inputs(
|
| 373 |
+
text=additional_text,
|
| 374 |
+
audio_result=audio_result
|
| 375 |
+
)
|
| 376 |
+
|
| 377 |
+
elif ext in self.IMAGE_EXTENSIONS:
|
| 378 |
+
result["file_type"] = "image"
|
| 379 |
+
image_result = self.process_image(file_path)
|
| 380 |
+
result["processing_result"] = image_result
|
| 381 |
+
result["fused_context"] = self.fuse_inputs(
|
| 382 |
+
text=additional_text,
|
| 383 |
+
image_result=image_result
|
| 384 |
+
)
|
| 385 |
+
|
| 386 |
+
elif ext in self.VIDEO_EXTENSIONS:
|
| 387 |
+
result["file_type"] = "video"
|
| 388 |
+
video_result = self.process_video(file_path)
|
| 389 |
+
result["processing_result"] = video_result
|
| 390 |
+
result["fused_context"] = self.fuse_inputs(
|
| 391 |
+
text=additional_text,
|
| 392 |
+
video_result=video_result
|
| 393 |
+
)
|
| 394 |
+
|
| 395 |
+
else:
|
| 396 |
+
# Treat as text file
|
| 397 |
+
result["file_type"] = "text"
|
| 398 |
+
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
|
| 399 |
+
file_text = f.read()
|
| 400 |
+
result["fused_context"] = self.fuse_inputs(
|
| 401 |
+
text=f"{additional_text}\n\n[FILE CONTENT]\n{file_text}"
|
| 402 |
+
)
|
| 403 |
+
|
| 404 |
+
except Exception as e:
|
| 405 |
+
result["success"] = False
|
| 406 |
+
result["error"] = str(e)
|
| 407 |
+
logger.error(f"Upload processing failed: {e}")
|
| 408 |
+
|
| 409 |
+
return result
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
# Factory function
|
| 413 |
+
def create_multimodal_processor() -> MultimodalProcessor:
|
| 414 |
+
"""Create a new MultimodalProcessor instance."""
|
| 415 |
+
return MultimodalProcessor()
|
backend/modules/prompt_analyzer.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MEXAR Core Engine - System Prompt Configuration Module
|
| 3 |
+
Analyzes system prompts to extract domain, personality, and constraints.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import json
|
| 7 |
+
import logging
|
| 8 |
+
from typing import Dict, List, Optional, Any
|
| 9 |
+
from utils.groq_client import get_groq_client, GroqClient
|
| 10 |
+
|
| 11 |
+
# Configure logging
|
| 12 |
+
logging.basicConfig(level=logging.INFO)
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class PromptAnalyzer:
|
| 17 |
+
"""
|
| 18 |
+
Analyzes system prompts to extract metadata for agent configuration.
|
| 19 |
+
Uses Groq LLM for intelligent prompt understanding.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
def __init__(self, groq_client: Optional[GroqClient] = None):
|
| 23 |
+
"""
|
| 24 |
+
Initialize the prompt analyzer.
|
| 25 |
+
|
| 26 |
+
Args:
|
| 27 |
+
groq_client: Optional pre-configured Groq client
|
| 28 |
+
"""
|
| 29 |
+
self.client = groq_client or get_groq_client()
|
| 30 |
+
|
| 31 |
+
def analyze_prompt(self, system_prompt: str) -> Dict[str, Any]:
|
| 32 |
+
"""
|
| 33 |
+
Analyze a system prompt to extract metadata.
|
| 34 |
+
|
| 35 |
+
Args:
|
| 36 |
+
system_prompt: The user's system prompt for the agent
|
| 37 |
+
|
| 38 |
+
Returns:
|
| 39 |
+
Dict containing:
|
| 40 |
+
- domain: Primary domain (e.g., 'medical', 'legal', 'cooking')
|
| 41 |
+
- sub_domains: Related sub-domains
|
| 42 |
+
- personality: Agent personality traits
|
| 43 |
+
- constraints: Behavioral constraints
|
| 44 |
+
- suggested_name: Auto-generated agent name
|
| 45 |
+
- domain_keywords: Keywords for domain detection
|
| 46 |
+
- tone: Communication tone
|
| 47 |
+
- capabilities: What the agent can do
|
| 48 |
+
"""
|
| 49 |
+
analysis_prompt = """You are a prompt analysis expert. Analyze the following system prompt and extract structured metadata.
|
| 50 |
+
|
| 51 |
+
SYSTEM PROMPT TO ANALYZE:
|
| 52 |
+
\"\"\"
|
| 53 |
+
{prompt}
|
| 54 |
+
\"\"\"
|
| 55 |
+
|
| 56 |
+
Respond with a JSON object containing:
|
| 57 |
+
{{
|
| 58 |
+
"domain": "primary domain (e.g., medical, legal, cooking, technology, finance, education)",
|
| 59 |
+
"sub_domains": ["list", "of", "related", "sub-domains"],
|
| 60 |
+
"personality": "brief personality description (e.g., friendly, professional, empathetic)",
|
| 61 |
+
"constraints": ["list", "of", "behavioral", "constraints"],
|
| 62 |
+
"suggested_name": "creative agent name based on domain and personality",
|
| 63 |
+
"domain_keywords": ["20", "keywords", "that", "define", "this", "domain"],
|
| 64 |
+
"tone": "communication tone (formal/casual/empathetic/technical)",
|
| 65 |
+
"capabilities": ["list", "of", "what", "agent", "can", "do"]
|
| 66 |
+
}}
|
| 67 |
+
|
| 68 |
+
Be thorough with domain_keywords - these are crucial for query filtering.
|
| 69 |
+
Make the suggested_name memorable and relevant.
|
| 70 |
+
"""
|
| 71 |
+
|
| 72 |
+
try:
|
| 73 |
+
response = self.client.analyze_with_system_prompt(
|
| 74 |
+
system_prompt="You are a JSON extraction assistant. Return only valid JSON, no markdown or explanation.",
|
| 75 |
+
user_message=analysis_prompt.format(prompt=system_prompt),
|
| 76 |
+
model="chat",
|
| 77 |
+
json_mode=True
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
result = json.loads(response)
|
| 81 |
+
|
| 82 |
+
# Validate and ensure all fields exist
|
| 83 |
+
result = self._ensure_fields(result)
|
| 84 |
+
|
| 85 |
+
logger.info(f"Prompt analyzed: domain={result['domain']}, name={result['suggested_name']}")
|
| 86 |
+
return result
|
| 87 |
+
|
| 88 |
+
except json.JSONDecodeError as e:
|
| 89 |
+
logger.error(f"Failed to parse LLM response as JSON: {e}")
|
| 90 |
+
return self._create_fallback_analysis(system_prompt)
|
| 91 |
+
except Exception as e:
|
| 92 |
+
logger.error(f"Error analyzing prompt: {e}")
|
| 93 |
+
return self._create_fallback_analysis(system_prompt)
|
| 94 |
+
|
| 95 |
+
def _ensure_fields(self, result: Dict) -> Dict:
|
| 96 |
+
"""Ensure all required fields exist in the result."""
|
| 97 |
+
defaults = {
|
| 98 |
+
"domain": "general",
|
| 99 |
+
"sub_domains": [],
|
| 100 |
+
"personality": "helpful and professional",
|
| 101 |
+
"constraints": [],
|
| 102 |
+
"suggested_name": "MEXAR Agent",
|
| 103 |
+
"domain_keywords": [],
|
| 104 |
+
"tone": "professional",
|
| 105 |
+
"capabilities": []
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
for key, default in defaults.items():
|
| 109 |
+
if key not in result or result[key] is None:
|
| 110 |
+
result[key] = default
|
| 111 |
+
|
| 112 |
+
# Ensure domain_keywords has at least 10 items
|
| 113 |
+
if len(result.get("domain_keywords", [])) < 10:
|
| 114 |
+
result["domain_keywords"] = self._expand_keywords(
|
| 115 |
+
result.get("domain_keywords", []),
|
| 116 |
+
result.get("domain", "general")
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
return result
|
| 120 |
+
|
| 121 |
+
def _expand_keywords(self, existing: List[str], domain: str) -> List[str]:
|
| 122 |
+
"""Expand keywords list if too short."""
|
| 123 |
+
# Common domain-specific keywords
|
| 124 |
+
domain_defaults = {
|
| 125 |
+
"medical": ["health", "patient", "doctor", "treatment", "diagnosis", "symptoms",
|
| 126 |
+
"medicine", "hospital", "disease", "therapy", "prescription", "clinic",
|
| 127 |
+
"medical", "healthcare", "wellness", "condition", "care", "physician",
|
| 128 |
+
"nurse", "medication"],
|
| 129 |
+
"legal": ["law", "court", "legal", "attorney", "lawyer", "case", "contract",
|
| 130 |
+
"rights", "litigation", "judge", "verdict", "lawsuit", "compliance",
|
| 131 |
+
"regulation", "statute", "defendant", "plaintiff", "trial", "evidence",
|
| 132 |
+
"testimony"],
|
| 133 |
+
"cooking": ["recipe", "cook", "ingredient", "food", "kitchen", "meal", "dish",
|
| 134 |
+
"flavor", "cuisine", "bake", "chef", "cooking", "taste", "serve",
|
| 135 |
+
"prepare", "dinner", "lunch", "breakfast", "snack", "dessert"],
|
| 136 |
+
"technology": ["software", "code", "programming", "computer", "system", "data",
|
| 137 |
+
"network", "security", "cloud", "application", "development",
|
| 138 |
+
"algorithm", "database", "API", "server", "hardware", "digital",
|
| 139 |
+
"technology", "tech", "IT"],
|
| 140 |
+
"finance": ["money", "investment", "bank", "finance", "budget", "tax", "stock",
|
| 141 |
+
"credit", "loan", "savings", "financial", "accounting", "capital",
|
| 142 |
+
"asset", "portfolio", "market", "trading", "insurance", "wealth",
|
| 143 |
+
"income"]
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
# Start with existing keywords
|
| 147 |
+
keywords = list(existing)
|
| 148 |
+
|
| 149 |
+
# Add domain defaults if available
|
| 150 |
+
if domain.lower() in domain_defaults:
|
| 151 |
+
for kw in domain_defaults[domain.lower()]:
|
| 152 |
+
if kw not in keywords and len(keywords) < 20:
|
| 153 |
+
keywords.append(kw)
|
| 154 |
+
|
| 155 |
+
# Add the domain itself if not present
|
| 156 |
+
if domain.lower() not in [k.lower() for k in keywords]:
|
| 157 |
+
keywords.append(domain)
|
| 158 |
+
|
| 159 |
+
return keywords[:20]
|
| 160 |
+
|
| 161 |
+
def _create_fallback_analysis(self, system_prompt: str) -> Dict[str, Any]:
|
| 162 |
+
"""Create a fallback analysis when LLM fails."""
|
| 163 |
+
# Simple keyword extraction
|
| 164 |
+
words = system_prompt.lower().split()
|
| 165 |
+
|
| 166 |
+
# Try to detect domain from common words
|
| 167 |
+
domain_indicators = {
|
| 168 |
+
"medical": ["medical", "doctor", "patient", "health", "hospital", "treatment"],
|
| 169 |
+
"legal": ["legal", "law", "attorney", "court", "contract", "rights"],
|
| 170 |
+
"cooking": ["cook", "recipe", "food", "chef", "kitchen", "ingredient"],
|
| 171 |
+
"technology": ["tech", "software", "code", "programming", "computer"],
|
| 172 |
+
"finance": ["finance", "money", "bank", "investment", "budget"]
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
detected_domain = "general"
|
| 176 |
+
for domain, indicators in domain_indicators.items():
|
| 177 |
+
if any(ind in words for ind in indicators):
|
| 178 |
+
detected_domain = domain
|
| 179 |
+
break
|
| 180 |
+
|
| 181 |
+
return {
|
| 182 |
+
"domain": detected_domain,
|
| 183 |
+
"sub_domains": [],
|
| 184 |
+
"personality": "helpful assistant",
|
| 185 |
+
"constraints": ["Stay within knowledge base", "Be accurate"],
|
| 186 |
+
"suggested_name": f"MEXAR {detected_domain.title()} Agent",
|
| 187 |
+
"domain_keywords": self._expand_keywords([], detected_domain),
|
| 188 |
+
"tone": "professional",
|
| 189 |
+
"capabilities": ["Answer questions", "Provide information"]
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
def generate_enhanced_system_prompt(
|
| 193 |
+
self,
|
| 194 |
+
original_prompt: str,
|
| 195 |
+
analysis: Dict[str, Any],
|
| 196 |
+
cag_context: str
|
| 197 |
+
) -> str:
|
| 198 |
+
"""
|
| 199 |
+
Generate an enhanced system prompt with CAG context.
|
| 200 |
+
|
| 201 |
+
Args:
|
| 202 |
+
original_prompt: User's original system prompt
|
| 203 |
+
analysis: Analysis result from analyze_prompt
|
| 204 |
+
cag_context: Compiled knowledge context
|
| 205 |
+
|
| 206 |
+
Returns:
|
| 207 |
+
Enhanced system prompt for the agent
|
| 208 |
+
"""
|
| 209 |
+
enhanced_prompt = f"""{original_prompt}
|
| 210 |
+
|
| 211 |
+
---
|
| 212 |
+
KNOWLEDGE BASE CONTEXT:
|
| 213 |
+
You have been provided with a comprehensive knowledge base containing domain-specific information.
|
| 214 |
+
Use this knowledge to answer questions accurately and cite sources when possible.
|
| 215 |
+
|
| 216 |
+
DOMAIN: {analysis['domain']}
|
| 217 |
+
DOMAIN KEYWORDS: {', '.join(analysis['domain_keywords'][:10])}
|
| 218 |
+
|
| 219 |
+
BEHAVIORAL GUIDELINES:
|
| 220 |
+
1. Only answer questions related to your domain and knowledge base
|
| 221 |
+
2. If a question is outside your domain, politely decline and explain your specialization
|
| 222 |
+
3. Always be {analysis['tone']} in your responses
|
| 223 |
+
4. When uncertain, acknowledge limitations rather than guessing
|
| 224 |
+
|
| 225 |
+
KNOWLEDGE CONTEXT:
|
| 226 |
+
{cag_context[:50000]} # Limit to prevent token overflow
|
| 227 |
+
"""
|
| 228 |
+
|
| 229 |
+
return enhanced_prompt
|
| 230 |
+
|
| 231 |
+
def get_system_prompt_templates(self) -> List[Dict[str, str]]:
|
| 232 |
+
"""
|
| 233 |
+
Return a list of system prompt templates for common domains.
|
| 234 |
+
|
| 235 |
+
Returns:
|
| 236 |
+
List of template dictionaries with name and content
|
| 237 |
+
"""
|
| 238 |
+
return [
|
| 239 |
+
{
|
| 240 |
+
"name": "Medical Assistant",
|
| 241 |
+
"domain": "medical",
|
| 242 |
+
"template": """You are a knowledgeable medical information assistant.
|
| 243 |
+
Your role is to provide accurate health information based on your knowledge base.
|
| 244 |
+
You should be empathetic, professional, and always recommend consulting healthcare professionals for personal medical advice.
|
| 245 |
+
Never provide diagnoses - only educational information."""
|
| 246 |
+
},
|
| 247 |
+
{
|
| 248 |
+
"name": "Legal Advisor",
|
| 249 |
+
"domain": "legal",
|
| 250 |
+
"template": """You are a legal information assistant providing general legal knowledge.
|
| 251 |
+
Be professional and precise in your explanations.
|
| 252 |
+
Always clarify that you provide educational information, not legal advice.
|
| 253 |
+
Recommend consulting a licensed attorney for specific legal matters."""
|
| 254 |
+
},
|
| 255 |
+
{
|
| 256 |
+
"name": "Recipe Chef",
|
| 257 |
+
"domain": "cooking",
|
| 258 |
+
"template": """You are a friendly culinary assistant with expertise in cooking and recipes.
|
| 259 |
+
Help users with cooking techniques, ingredient substitutions, and recipe adaptations.
|
| 260 |
+
Be enthusiastic about food and encourage culinary exploration.
|
| 261 |
+
Provide clear, step-by-step instructions when explaining recipes."""
|
| 262 |
+
},
|
| 263 |
+
{
|
| 264 |
+
"name": "Tech Support",
|
| 265 |
+
"domain": "technology",
|
| 266 |
+
"template": """You are a technical support specialist helping users with technology questions.
|
| 267 |
+
Explain complex concepts in simple terms.
|
| 268 |
+
Provide step-by-step troubleshooting guidance.
|
| 269 |
+
Be patient and thorough in your explanations."""
|
| 270 |
+
},
|
| 271 |
+
{
|
| 272 |
+
"name": "Financial Guide",
|
| 273 |
+
"domain": "finance",
|
| 274 |
+
"template": """You are a financial information assistant providing educational content about personal finance.
|
| 275 |
+
Be clear and professional when explaining financial concepts.
|
| 276 |
+
Always remind users that this is educational information, not financial advice.
|
| 277 |
+
Recommend consulting certified financial professionals for personal financial decisions."""
|
| 278 |
+
}
|
| 279 |
+
]
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
# Factory function
|
| 283 |
+
def create_prompt_analyzer() -> PromptAnalyzer:
|
| 284 |
+
"""Create a new PromptAnalyzer instance."""
|
| 285 |
+
return PromptAnalyzer()
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
def get_prompt_templates() -> List[Dict[str, str]]:
|
| 289 |
+
"""
|
| 290 |
+
Get system prompt templates without initializing Groq client.
|
| 291 |
+
|
| 292 |
+
Returns:
|
| 293 |
+
List of template dictionaries with name and content
|
| 294 |
+
"""
|
| 295 |
+
return [
|
| 296 |
+
{
|
| 297 |
+
"name": "Medical Assistant",
|
| 298 |
+
"domain": "medical",
|
| 299 |
+
"template": """You are a knowledgeable medical information assistant.
|
| 300 |
+
Your role is to provide accurate health information based on your knowledge base.
|
| 301 |
+
You should be empathetic, professional, and always recommend consulting healthcare professionals for personal medical advice.
|
| 302 |
+
Never provide diagnoses - only educational information."""
|
| 303 |
+
},
|
| 304 |
+
{
|
| 305 |
+
"name": "Legal Advisor",
|
| 306 |
+
"domain": "legal",
|
| 307 |
+
"template": """You are a legal information assistant providing general legal knowledge.
|
| 308 |
+
Be professional and precise in your explanations.
|
| 309 |
+
Always clarify that you provide educational information, not legal advice.
|
| 310 |
+
Recommend consulting a licensed attorney for specific legal matters."""
|
| 311 |
+
},
|
| 312 |
+
{
|
| 313 |
+
"name": "Recipe Chef",
|
| 314 |
+
"domain": "cooking",
|
| 315 |
+
"template": """You are a friendly culinary assistant with expertise in cooking and recipes.
|
| 316 |
+
Help users with cooking techniques, ingredient substitutions, and recipe adaptations.
|
| 317 |
+
Be enthusiastic about food and encourage culinary exploration.
|
| 318 |
+
Provide clear, step-by-step instructions when explaining recipes."""
|
| 319 |
+
},
|
| 320 |
+
{
|
| 321 |
+
"name": "Tech Support",
|
| 322 |
+
"domain": "technology",
|
| 323 |
+
"template": """You are a technical support specialist helping users with technology questions.
|
| 324 |
+
Explain complex concepts in simple terms.
|
| 325 |
+
Provide step-by-step troubleshooting guidance.
|
| 326 |
+
Be patient and thorough in your explanations."""
|
| 327 |
+
},
|
| 328 |
+
{
|
| 329 |
+
"name": "Financial Guide",
|
| 330 |
+
"domain": "finance",
|
| 331 |
+
"template": """You are a financial information assistant providing educational content about personal finance.
|
| 332 |
+
Be clear and professional when explaining financial concepts.
|
| 333 |
+
Always remind users that this is educational information, not financial advice.
|
| 334 |
+
Recommend consulting certified financial professionals for personal financial decisions."""
|
| 335 |
+
}
|
| 336 |
+
]
|
backend/modules/reasoning_engine.py
ADDED
|
@@ -0,0 +1,476 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MEXAR Core Engine - Hybrid Reasoning Engine (RAG Version)
|
| 3 |
+
Pure RAG with Source Attribution + Faithfulness scoring.
|
| 4 |
+
No CAG preloading - dynamic retrieval per query.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import json
|
| 8 |
+
import logging
|
| 9 |
+
from typing import Dict, List, Any, Optional, Tuple
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
import networkx as nx
|
| 12 |
+
from difflib import SequenceMatcher
|
| 13 |
+
import numpy as np
|
| 14 |
+
|
| 15 |
+
from utils.groq_client import get_groq_client, GroqClient
|
| 16 |
+
from utils.hybrid_search import HybridSearcher
|
| 17 |
+
from utils.reranker import Reranker
|
| 18 |
+
from utils.source_attribution import SourceAttributor
|
| 19 |
+
from utils.faithfulness import FaithfulnessScorer
|
| 20 |
+
from fastembed import TextEmbedding
|
| 21 |
+
from core.database import SessionLocal
|
| 22 |
+
from models.agent import Agent
|
| 23 |
+
from models.chunk import DocumentChunk
|
| 24 |
+
|
| 25 |
+
# Configure logging
|
| 26 |
+
logging.basicConfig(level=logging.INFO)
|
| 27 |
+
logger = logging.getLogger(__name__)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class ReasoningEngine:
|
| 31 |
+
"""
|
| 32 |
+
Pure RAG reasoning engine with:
|
| 33 |
+
1. Hybrid search (semantic + keyword)
|
| 34 |
+
2. Cross-encoder reranking
|
| 35 |
+
3. Source attribution (inline citations)
|
| 36 |
+
4. Faithfulness scoring
|
| 37 |
+
"""
|
| 38 |
+
|
| 39 |
+
# Domain guardrail threshold (lowered for better general question handling)
|
| 40 |
+
DOMAIN_SIMILARITY_THRESHOLD = 0.05
|
| 41 |
+
|
| 42 |
+
def __init__(
|
| 43 |
+
self,
|
| 44 |
+
groq_client: Optional[GroqClient] = None,
|
| 45 |
+
data_dir: str = "data/agents"
|
| 46 |
+
):
|
| 47 |
+
"""
|
| 48 |
+
Initialize the reasoning engine.
|
| 49 |
+
|
| 50 |
+
Args:
|
| 51 |
+
groq_client: Optional pre-configured Groq client
|
| 52 |
+
data_dir: Legacy parameter, kept for compatibility
|
| 53 |
+
"""
|
| 54 |
+
self.client = groq_client or get_groq_client()
|
| 55 |
+
self.data_dir = Path(data_dir)
|
| 56 |
+
|
| 57 |
+
# Initialize embedding model (384 dim - matches compiler)
|
| 58 |
+
try:
|
| 59 |
+
self.embedding_model = TextEmbedding(model_name="BAAI/bge-small-en-v1.5")
|
| 60 |
+
logger.info("FastEmbed bge-small-en-v1.5 loaded (384 dim)")
|
| 61 |
+
except Exception as e:
|
| 62 |
+
logger.error(f"Failed to load embedding model: {e}")
|
| 63 |
+
self.embedding_model = None
|
| 64 |
+
|
| 65 |
+
# Initialize RAG components
|
| 66 |
+
self.searcher = HybridSearcher(self.embedding_model) if self.embedding_model else None
|
| 67 |
+
self.reranker = Reranker()
|
| 68 |
+
self.attributor = SourceAttributor(self.embedding_model)
|
| 69 |
+
self.faithfulness_scorer = FaithfulnessScorer()
|
| 70 |
+
|
| 71 |
+
# Cache for loaded agents
|
| 72 |
+
self._agent_cache: Dict[str, Dict] = {}
|
| 73 |
+
|
| 74 |
+
def reason(
|
| 75 |
+
self,
|
| 76 |
+
agent_name: str,
|
| 77 |
+
query: str,
|
| 78 |
+
multimodal_context: str = ""
|
| 79 |
+
) -> Dict[str, Any]:
|
| 80 |
+
"""
|
| 81 |
+
Main reasoning function - Pure RAG with Attribution.
|
| 82 |
+
|
| 83 |
+
Args:
|
| 84 |
+
agent_name: Name of the agent to use
|
| 85 |
+
query: User's question
|
| 86 |
+
multimodal_context: Additional context from audio/image/video
|
| 87 |
+
|
| 88 |
+
Returns:
|
| 89 |
+
Dict containing:
|
| 90 |
+
- answer: Generated answer with citations
|
| 91 |
+
- confidence: Confidence score (0-1)
|
| 92 |
+
- in_domain: Whether query is in domain
|
| 93 |
+
- explainability: Full explainability data
|
| 94 |
+
"""
|
| 95 |
+
# Load agent from Supabase
|
| 96 |
+
agent = self._load_agent(agent_name)
|
| 97 |
+
|
| 98 |
+
# Combine query with multimodal context
|
| 99 |
+
full_query = query
|
| 100 |
+
if multimodal_context:
|
| 101 |
+
full_query = f"{query}\n\n[ADDITIONAL CONTEXT]\n{multimodal_context}"
|
| 102 |
+
|
| 103 |
+
# Step 1: Check domain guardrail
|
| 104 |
+
in_domain, domain_score = self._check_guardrail(
|
| 105 |
+
full_query,
|
| 106 |
+
agent["domain_signature"],
|
| 107 |
+
agent["prompt_analysis"]
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
if not in_domain:
|
| 111 |
+
return self._create_out_of_domain_response(
|
| 112 |
+
query=query,
|
| 113 |
+
domain=agent["prompt_analysis"].get("domain", "unknown"),
|
| 114 |
+
domain_score=domain_score
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
# Step 2: Hybrid Search (semantic + keyword)
|
| 118 |
+
search_results = []
|
| 119 |
+
if self.searcher:
|
| 120 |
+
search_results = self.searcher.search(full_query, agent["id"], top_k=20)
|
| 121 |
+
|
| 122 |
+
if not search_results:
|
| 123 |
+
# Fallback to simple query
|
| 124 |
+
return self._create_no_results_response(query, agent)
|
| 125 |
+
|
| 126 |
+
# Step 3: Rerank with cross-encoder
|
| 127 |
+
chunks = [r[0] for r in search_results]
|
| 128 |
+
rrf_scores = [r[1] for r in search_results]
|
| 129 |
+
|
| 130 |
+
reranked = self.reranker.rerank(full_query, chunks, top_k=5)
|
| 131 |
+
top_chunks = [r[0] for r in reranked]
|
| 132 |
+
rerank_scores = [r[1] for r in reranked]
|
| 133 |
+
|
| 134 |
+
# Step 4: Generate answer with focused context
|
| 135 |
+
context = "\n\n---\n\n".join([c.content for c in top_chunks])
|
| 136 |
+
answer = self._generate_answer(
|
| 137 |
+
query=query, # Use original query, not full_query
|
| 138 |
+
context=context,
|
| 139 |
+
system_prompt=agent["system_prompt"],
|
| 140 |
+
multimodal_context=multimodal_context # Pass multimodal context separately
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
# Step 5: Source Attribution
|
| 144 |
+
chunk_embeddings = None
|
| 145 |
+
if self.embedding_model:
|
| 146 |
+
try:
|
| 147 |
+
chunk_embeddings = list(self.embedding_model.embed([c.content for c in top_chunks]))
|
| 148 |
+
except:
|
| 149 |
+
pass
|
| 150 |
+
|
| 151 |
+
attribution = self.attributor.attribute(answer, top_chunks, chunk_embeddings)
|
| 152 |
+
|
| 153 |
+
# Step 6: Faithfulness Scoring
|
| 154 |
+
faithfulness_result = self.faithfulness_scorer.score(answer, context)
|
| 155 |
+
|
| 156 |
+
# Step 7: Calculate Confidence
|
| 157 |
+
top_similarity = rrf_scores[0] if rrf_scores else 0
|
| 158 |
+
top_rerank = rerank_scores[0] if rerank_scores else 0
|
| 159 |
+
|
| 160 |
+
confidence = self._calculate_confidence(
|
| 161 |
+
top_similarity=top_similarity,
|
| 162 |
+
rerank_score=top_rerank,
|
| 163 |
+
faithfulness=faithfulness_result.score
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
# Step 8: Build Explainability
|
| 167 |
+
explainability = self._build_explainability(
|
| 168 |
+
query=query,
|
| 169 |
+
multimodal_context=multimodal_context,
|
| 170 |
+
chunks=top_chunks,
|
| 171 |
+
rrf_scores=rrf_scores[:5],
|
| 172 |
+
rerank_scores=rerank_scores,
|
| 173 |
+
attribution=attribution,
|
| 174 |
+
faithfulness=faithfulness_result,
|
| 175 |
+
confidence=confidence,
|
| 176 |
+
domain_score=domain_score
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
logger.info(f"Reasoning complete: confidence={confidence:.2f}, chunks={len(top_chunks)}, faithfulness={faithfulness_result.score:.2f}")
|
| 180 |
+
|
| 181 |
+
return {
|
| 182 |
+
"answer": attribution.answer_with_citations,
|
| 183 |
+
"confidence": confidence,
|
| 184 |
+
"in_domain": True,
|
| 185 |
+
"reasoning_paths": [], # Legacy, kept for compatibility
|
| 186 |
+
"entities_found": [], # Legacy, kept for compatibility
|
| 187 |
+
"explainability": explainability
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
def _load_agent(self, agent_name: str) -> Dict[str, Any]:
|
| 191 |
+
"""Load agent from Supabase (with caching)."""
|
| 192 |
+
if agent_name in self._agent_cache:
|
| 193 |
+
return self._agent_cache[agent_name]
|
| 194 |
+
|
| 195 |
+
db = SessionLocal()
|
| 196 |
+
try:
|
| 197 |
+
agent = db.query(Agent).filter(Agent.name == agent_name).first()
|
| 198 |
+
|
| 199 |
+
if not agent:
|
| 200 |
+
raise ValueError(f"Agent '{agent_name}' not found")
|
| 201 |
+
|
| 202 |
+
agent_data = {
|
| 203 |
+
"id": agent.id,
|
| 204 |
+
"name": agent.name,
|
| 205 |
+
"system_prompt": agent.system_prompt,
|
| 206 |
+
"domain": agent.domain,
|
| 207 |
+
"domain_signature": agent.domain_signature or [],
|
| 208 |
+
"prompt_analysis": agent.prompt_analysis or {},
|
| 209 |
+
"knowledge_graph": agent.knowledge_graph_json or {},
|
| 210 |
+
"chunk_count": agent.chunk_count or 0
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
self._agent_cache[agent_name] = agent_data
|
| 214 |
+
return agent_data
|
| 215 |
+
finally:
|
| 216 |
+
db.close()
|
| 217 |
+
|
| 218 |
+
def _check_guardrail(
|
| 219 |
+
self,
|
| 220 |
+
query: str,
|
| 221 |
+
domain_signature: List[str],
|
| 222 |
+
prompt_analysis: Dict[str, Any]
|
| 223 |
+
) -> Tuple[bool, float]:
|
| 224 |
+
"""Check if query matches the domain."""
|
| 225 |
+
query_lower = query.lower()
|
| 226 |
+
query_words = set(query_lower.split())
|
| 227 |
+
|
| 228 |
+
matches = 0
|
| 229 |
+
bonus_matches = 0
|
| 230 |
+
|
| 231 |
+
# Check domain match
|
| 232 |
+
domain = prompt_analysis.get("domain", "")
|
| 233 |
+
if domain.lower() in query_lower:
|
| 234 |
+
bonus_matches += 3
|
| 235 |
+
|
| 236 |
+
# Check sub-domains
|
| 237 |
+
for sub_domain in prompt_analysis.get("sub_domains", []):
|
| 238 |
+
if sub_domain.lower() in query_lower:
|
| 239 |
+
bonus_matches += 2
|
| 240 |
+
|
| 241 |
+
# Check domain keywords
|
| 242 |
+
for keyword in prompt_analysis.get("domain_keywords", []):
|
| 243 |
+
if keyword.lower() in query_lower:
|
| 244 |
+
bonus_matches += 1.5
|
| 245 |
+
|
| 246 |
+
# Check signature keywords with fuzzy matching
|
| 247 |
+
signature_lower = [kw.lower() for kw in (domain_signature or [])]
|
| 248 |
+
|
| 249 |
+
for word in query_words:
|
| 250 |
+
if len(word) < 3:
|
| 251 |
+
continue
|
| 252 |
+
for kw in signature_lower[:100]:
|
| 253 |
+
if self._fuzzy_match(word, kw) > 0.75:
|
| 254 |
+
matches += 1
|
| 255 |
+
break
|
| 256 |
+
if word in kw or kw in word:
|
| 257 |
+
matches += 0.5
|
| 258 |
+
break
|
| 259 |
+
|
| 260 |
+
# Calculate score
|
| 261 |
+
max_possible = max(1, min(len(query_words), 10))
|
| 262 |
+
base_score = matches / max_possible
|
| 263 |
+
bonus_score = min(0.5, bonus_matches * 0.1)
|
| 264 |
+
score = min(1.0, base_score + bonus_score)
|
| 265 |
+
|
| 266 |
+
if bonus_matches >= 1:
|
| 267 |
+
score = max(score, 0.2)
|
| 268 |
+
|
| 269 |
+
is_in_domain = score >= self.DOMAIN_SIMILARITY_THRESHOLD
|
| 270 |
+
|
| 271 |
+
logger.info(f"Guardrail: score={score:.2f}, matches={matches}, bonus={bonus_matches}, in_domain={is_in_domain}")
|
| 272 |
+
|
| 273 |
+
return is_in_domain, score
|
| 274 |
+
|
| 275 |
+
def _fuzzy_match(self, s1: str, s2: str) -> float:
|
| 276 |
+
"""Calculate fuzzy match ratio."""
|
| 277 |
+
return SequenceMatcher(None, s1, s2).ratio()
|
| 278 |
+
|
| 279 |
+
def _generate_answer(
|
| 280 |
+
self,
|
| 281 |
+
query: str,
|
| 282 |
+
context: str,
|
| 283 |
+
system_prompt: str,
|
| 284 |
+
multimodal_context: str = ""
|
| 285 |
+
) -> str:
|
| 286 |
+
"""Generate answer using LLM with retrieved context and multimodal data."""
|
| 287 |
+
|
| 288 |
+
# Build multimodal section if present
|
| 289 |
+
multimodal_section = ""
|
| 290 |
+
if multimodal_context:
|
| 291 |
+
multimodal_section = f"""\n\nMULTIMODAL INPUT (User uploaded media):
|
| 292 |
+
{multimodal_context}
|
| 293 |
+
|
| 294 |
+
IMPORTANT: When the user asks about images, audio, or other uploaded media,
|
| 295 |
+
use the descriptions above to answer their questions. The multimodal input
|
| 296 |
+
contains AI-generated descriptions of what the user has uploaded."""
|
| 297 |
+
|
| 298 |
+
full_system_prompt = f"""{system_prompt}
|
| 299 |
+
|
| 300 |
+
RETRIEVED KNOWLEDGE BASE CONTEXT:
|
| 301 |
+
{context[:80000]}
|
| 302 |
+
{multimodal_section}
|
| 303 |
+
|
| 304 |
+
IMPORTANT INSTRUCTIONS:
|
| 305 |
+
1. Answer using the retrieved context AND any multimodal input provided
|
| 306 |
+
2. If the user asks about uploaded images/audio, use the MULTIMODAL INPUT section
|
| 307 |
+
3. If asking about knowledge base topics, use the RETRIEVED CONTEXT
|
| 308 |
+
4. If information is not available in any source, say "I don't have information about that"
|
| 309 |
+
5. Be specific and cite sources when possible
|
| 310 |
+
6. Be concise but comprehensive
|
| 311 |
+
7. If you quote directly, use quotation marks
|
| 312 |
+
"""
|
| 313 |
+
|
| 314 |
+
try:
|
| 315 |
+
answer = self.client.analyze_with_system_prompt(
|
| 316 |
+
system_prompt=full_system_prompt,
|
| 317 |
+
user_message=query,
|
| 318 |
+
model="chat"
|
| 319 |
+
)
|
| 320 |
+
return answer
|
| 321 |
+
except Exception as e:
|
| 322 |
+
logger.error(f"Answer generation failed: {e}")
|
| 323 |
+
return "I apologize, but I encountered an error processing your query. Please try again."
|
| 324 |
+
|
| 325 |
+
def _calculate_confidence(
|
| 326 |
+
self,
|
| 327 |
+
top_similarity: float,
|
| 328 |
+
rerank_score: float,
|
| 329 |
+
faithfulness: float
|
| 330 |
+
) -> float:
|
| 331 |
+
"""
|
| 332 |
+
Calculate confidence score based on RAG metrics.
|
| 333 |
+
|
| 334 |
+
Calibrated to provide meaningful scores:
|
| 335 |
+
- High retrieval + high faithfulness = high confidence
|
| 336 |
+
- Low retrieval = capped confidence
|
| 337 |
+
"""
|
| 338 |
+
# Normalize rerank score (cross-encoder outputs vary)
|
| 339 |
+
# Typical range is -10 to +10, normalize to 0-1
|
| 340 |
+
norm_rerank = min(1.0, max(0, (rerank_score + 10) / 20))
|
| 341 |
+
|
| 342 |
+
# Normalize RRF score (typically 0 to 0.03)
|
| 343 |
+
norm_similarity = min(1.0, top_similarity * 30)
|
| 344 |
+
|
| 345 |
+
# Weighted combination
|
| 346 |
+
confidence = (
|
| 347 |
+
norm_similarity * 0.35 + # Retrieval quality
|
| 348 |
+
norm_rerank * 0.30 + # Rerank confidence
|
| 349 |
+
faithfulness * 0.25 + # Grounding quality
|
| 350 |
+
0.10 # Base floor for in-domain
|
| 351 |
+
)
|
| 352 |
+
|
| 353 |
+
# Apply thresholds
|
| 354 |
+
if norm_similarity > 0.7 and faithfulness > 0.8:
|
| 355 |
+
confidence = max(confidence, 0.75)
|
| 356 |
+
elif norm_similarity < 0.3:
|
| 357 |
+
confidence = min(confidence, 0.45)
|
| 358 |
+
|
| 359 |
+
return round(min(0.95, max(0.15, confidence)), 2)
|
| 360 |
+
|
| 361 |
+
def _build_explainability(
|
| 362 |
+
self,
|
| 363 |
+
query: str,
|
| 364 |
+
multimodal_context: str,
|
| 365 |
+
chunks: List,
|
| 366 |
+
rrf_scores: List[float],
|
| 367 |
+
rerank_scores: List[float],
|
| 368 |
+
attribution,
|
| 369 |
+
faithfulness,
|
| 370 |
+
confidence: float,
|
| 371 |
+
domain_score: float
|
| 372 |
+
) -> Dict[str, Any]:
|
| 373 |
+
"""Build comprehensive explainability output."""
|
| 374 |
+
return {
|
| 375 |
+
"why_this_answer": {
|
| 376 |
+
"summary": f"Answer derived from {len(chunks)} retrieved sources with {faithfulness.score*100:.0f}% faithfulness",
|
| 377 |
+
"sources": [
|
| 378 |
+
{
|
| 379 |
+
"citation": src["citation"],
|
| 380 |
+
"source_file": src["source"],
|
| 381 |
+
"content_preview": src["preview"][:150] if src.get("preview") else "",
|
| 382 |
+
"relevance_score": f"{src.get('similarity', 0)*100:.0f}%"
|
| 383 |
+
}
|
| 384 |
+
for src in attribution.sources
|
| 385 |
+
]
|
| 386 |
+
},
|
| 387 |
+
"confidence_breakdown": {
|
| 388 |
+
"overall": f"{confidence*100:.0f}%",
|
| 389 |
+
"domain_relevance": f"{domain_score*100:.0f}%",
|
| 390 |
+
"retrieval_quality": f"{rrf_scores[0]*100:.1f}%" if rrf_scores else "N/A",
|
| 391 |
+
"rerank_score": f"{rerank_scores[0]:.2f}" if rerank_scores else "N/A",
|
| 392 |
+
"faithfulness": f"{faithfulness.score*100:.0f}%",
|
| 393 |
+
"claims_supported": f"{faithfulness.supported_claims}/{faithfulness.total_claims}"
|
| 394 |
+
},
|
| 395 |
+
"unsupported_claims": faithfulness.unsupported_claims[:3],
|
| 396 |
+
"inputs": {
|
| 397 |
+
"original_query": query,
|
| 398 |
+
"has_multimodal": bool(multimodal_context),
|
| 399 |
+
"chunks_retrieved": len(chunks)
|
| 400 |
+
},
|
| 401 |
+
"knowledge_graph": None # Optional, can be populated for visualization
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
def _create_out_of_domain_response(
|
| 405 |
+
self,
|
| 406 |
+
query: str,
|
| 407 |
+
domain: str,
|
| 408 |
+
domain_score: float
|
| 409 |
+
) -> Dict[str, Any]:
|
| 410 |
+
"""Create response for out-of-domain queries."""
|
| 411 |
+
return {
|
| 412 |
+
"answer": f"""I apologize, but your question appears to be outside my area of expertise.
|
| 413 |
+
|
| 414 |
+
I am a specialized **{domain.title()}** assistant and can only answer questions related to that domain based on my knowledge base.
|
| 415 |
+
|
| 416 |
+
Your query doesn't seem to match the topics I'm trained on (relevance score: {domain_score*100:.0f}%).
|
| 417 |
+
|
| 418 |
+
**How I can help:**
|
| 419 |
+
- Ask questions related to {domain}
|
| 420 |
+
- Query information from my knowledge base
|
| 421 |
+
- Get explanations about {domain}-related topics
|
| 422 |
+
|
| 423 |
+
Would you like to rephrase your question to focus on {domain}?""",
|
| 424 |
+
"confidence": 0.1,
|
| 425 |
+
"in_domain": False,
|
| 426 |
+
"reasoning_paths": [],
|
| 427 |
+
"entities_found": [],
|
| 428 |
+
"explainability": {
|
| 429 |
+
"why_this_answer": {
|
| 430 |
+
"summary": "Query rejected - outside domain expertise",
|
| 431 |
+
"sources": []
|
| 432 |
+
},
|
| 433 |
+
"confidence_breakdown": {
|
| 434 |
+
"overall": "10%",
|
| 435 |
+
"domain_relevance": f"{domain_score*100:.0f}%",
|
| 436 |
+
"rejection_reason": "out_of_domain"
|
| 437 |
+
},
|
| 438 |
+
"inputs": {"original_query": query}
|
| 439 |
+
}
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
def _create_no_results_response(
|
| 443 |
+
self,
|
| 444 |
+
query: str,
|
| 445 |
+
agent: Dict
|
| 446 |
+
) -> Dict[str, Any]:
|
| 447 |
+
"""Create response when no relevant chunks found."""
|
| 448 |
+
return {
|
| 449 |
+
"answer": f"""I couldn't find relevant information in my knowledge base to answer your question.
|
| 450 |
+
|
| 451 |
+
This could mean:
|
| 452 |
+
- The topic isn't covered in my training data
|
| 453 |
+
- Try rephrasing your question with different keywords
|
| 454 |
+
- Ask about a more specific aspect of {agent.get('domain', 'the domain')}""",
|
| 455 |
+
"confidence": 0.2,
|
| 456 |
+
"in_domain": True,
|
| 457 |
+
"reasoning_paths": [],
|
| 458 |
+
"entities_found": [],
|
| 459 |
+
"explainability": {
|
| 460 |
+
"why_this_answer": {
|
| 461 |
+
"summary": "No relevant chunks found in knowledge base",
|
| 462 |
+
"sources": []
|
| 463 |
+
},
|
| 464 |
+
"confidence_breakdown": {
|
| 465 |
+
"overall": "20%",
|
| 466 |
+
"issue": "no_relevant_retrieval"
|
| 467 |
+
},
|
| 468 |
+
"inputs": {"original_query": query}
|
| 469 |
+
}
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
|
| 473 |
+
# Factory function
|
| 474 |
+
def create_reasoning_engine(data_dir: str = "data/agents") -> ReasoningEngine:
|
| 475 |
+
"""Create a new ReasoningEngine instance."""
|
| 476 |
+
return ReasoningEngine(data_dir=data_dir)
|
backend/quick_test.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Quick test to see the full multimodal response
|
| 3 |
+
"""
|
| 4 |
+
import requests
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
BASE_URL = 'http://127.0.0.1:8000'
|
| 8 |
+
|
| 9 |
+
# Login
|
| 10 |
+
login_resp = requests.post(f'{BASE_URL}/api/auth/login', json={'email': 'dev@gmail.com', 'password': '123456'})
|
| 11 |
+
token = login_resp.json().get('access_token')
|
| 12 |
+
headers = {'Authorization': f'Bearer {token}'}
|
| 13 |
+
|
| 14 |
+
# Get agent
|
| 15 |
+
agents_resp = requests.get(f'{BASE_URL}/api/agents/', headers=headers)
|
| 16 |
+
agent_name = agents_resp.json()[0]['name']
|
| 17 |
+
print(f"Using agent: {agent_name}")
|
| 18 |
+
|
| 19 |
+
# Test with image
|
| 20 |
+
test_image = Path('data/temp/test_multimodal.png')
|
| 21 |
+
with open(test_image, 'rb') as f:
|
| 22 |
+
files = {'image': (test_image.name, f, 'image/png')}
|
| 23 |
+
data = {'agent_name': agent_name, 'message': 'What color is this image?', 'include_explainability': 'true'}
|
| 24 |
+
response = requests.post(f'{BASE_URL}/api/chat/multimodal', files=files, data=data, headers=headers, timeout=120)
|
| 25 |
+
|
| 26 |
+
result = response.json()
|
| 27 |
+
print('=== FULL RESPONSE ===')
|
| 28 |
+
print(f"Success: {result.get('success')}")
|
| 29 |
+
print(f"Answer: {result.get('answer')}")
|
| 30 |
+
print(f"Confidence: {result.get('confidence')}")
|
| 31 |
+
print(f"Image URL: {result.get('image_url')}")
|
| 32 |
+
print(f"In Domain: {result.get('in_domain')}")
|
backend/requirements.txt
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# MEXAR Phase 1 - Backend Dependencies
|
| 2 |
+
|
| 3 |
+
# Web Framework
|
| 4 |
+
fastapi==0.109.0
|
| 5 |
+
uvicorn[standard]==0.27.0
|
| 6 |
+
|
| 7 |
+
# Groq API
|
| 8 |
+
groq==0.4.2
|
| 9 |
+
httpx==0.27.0 # Pin to compatible version for groq SDK
|
| 10 |
+
|
| 11 |
+
# Knowledge Graph
|
| 12 |
+
networkx==3.2.1
|
| 13 |
+
|
| 14 |
+
# Data Processing
|
| 15 |
+
pandas==2.1.4
|
| 16 |
+
PyPDF2==3.0.1
|
| 17 |
+
python-docx==1.1.0
|
| 18 |
+
|
| 19 |
+
# File Upload
|
| 20 |
+
python-multipart==0.0.6
|
| 21 |
+
|
| 22 |
+
# Video Processing
|
| 23 |
+
opencv-python==4.9.0.80
|
| 24 |
+
|
| 25 |
+
# Environment
|
| 26 |
+
python-dotenv==1.0.0
|
| 27 |
+
|
| 28 |
+
# JSON handling
|
| 29 |
+
orjson==3.9.10
|
| 30 |
+
|
| 31 |
+
# Async support
|
| 32 |
+
aiofiles==23.2.1
|
| 33 |
+
|
| 34 |
+
# Database (Supabase/PostgreSQL)
|
| 35 |
+
SQLAlchemy==2.0.25
|
| 36 |
+
psycopg2-binary==2.9.9
|
| 37 |
+
|
| 38 |
+
# Authentication & Security
|
| 39 |
+
passlib[bcrypt]==1.7.4
|
| 40 |
+
python-jose[cryptography]==3.3.0
|
| 41 |
+
bcrypt==4.1.2
|
| 42 |
+
email-validator==2.1.0
|
| 43 |
+
|
| 44 |
+
# Supabase Client
|
| 45 |
+
supabase==2.24.0
|
| 46 |
+
|
| 47 |
+
# Vector Support
|
| 48 |
+
fastembed>=0.7.0 # Updated from 0.2.0 (was yanked)
|
| 49 |
+
pgvector==0.2.4
|
| 50 |
+
|
| 51 |
+
# RAG Components (NEW)
|
| 52 |
+
sentence-transformers>=2.2.0 # Cross-encoder reranking
|
| 53 |
+
numpy>=1.24.0 # Vector operations
|
backend/services/agent_service.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import shutil
|
| 3 |
+
import json
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import List, Optional
|
| 6 |
+
from sqlalchemy.orm import Session
|
| 7 |
+
from models.agent import Agent
|
| 8 |
+
from models.user import User
|
| 9 |
+
from core.config import settings
|
| 10 |
+
|
| 11 |
+
class AgentService:
|
| 12 |
+
def __init__(self):
|
| 13 |
+
self.storage_path = Path(settings.STORAGE_PATH)
|
| 14 |
+
self.storage_path.mkdir(parents=True, exist_ok=True)
|
| 15 |
+
|
| 16 |
+
def create_agent(self, db: Session, user: User, name: str, system_prompt: str) -> Agent:
|
| 17 |
+
"""Create a new agent entry in database."""
|
| 18 |
+
# Sanitize name
|
| 19 |
+
clean_name = name.strip().replace(" ", "_").lower()
|
| 20 |
+
|
| 21 |
+
# Check if agent already exists for this user
|
| 22 |
+
existing = db.query(Agent).filter(
|
| 23 |
+
Agent.user_id == user.id,
|
| 24 |
+
Agent.name == clean_name
|
| 25 |
+
).first()
|
| 26 |
+
|
| 27 |
+
if existing:
|
| 28 |
+
raise ValueError(f"You already have an agent named '{clean_name}'")
|
| 29 |
+
|
| 30 |
+
# Create agent storage directory
|
| 31 |
+
agent_dir = self.storage_path / str(user.id) / clean_name
|
| 32 |
+
agent_dir.mkdir(parents=True, exist_ok=True)
|
| 33 |
+
|
| 34 |
+
# Create DB record
|
| 35 |
+
new_agent = Agent(
|
| 36 |
+
user_id=user.id,
|
| 37 |
+
name=clean_name,
|
| 38 |
+
system_prompt=system_prompt,
|
| 39 |
+
storage_path=str(agent_dir),
|
| 40 |
+
status="initializing"
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
db.add(new_agent)
|
| 44 |
+
db.commit()
|
| 45 |
+
db.refresh(new_agent)
|
| 46 |
+
|
| 47 |
+
return new_agent
|
| 48 |
+
|
| 49 |
+
def get_agent(self, db: Session, user: User, agent_name: str) -> Optional[Agent]:
|
| 50 |
+
"""Get a specific agent owned by the user."""
|
| 51 |
+
return db.query(Agent).filter(
|
| 52 |
+
Agent.user_id == user.id,
|
| 53 |
+
Agent.name == agent_name
|
| 54 |
+
).first()
|
| 55 |
+
|
| 56 |
+
def get_agent_by_id(self, db: Session, agent_id: int, user_id: int) -> Optional[Agent]:
|
| 57 |
+
"""Get agent by ID with ownership check."""
|
| 58 |
+
return db.query(Agent).filter(
|
| 59 |
+
Agent.id == agent_id,
|
| 60 |
+
Agent.user_id == user_id
|
| 61 |
+
).first()
|
| 62 |
+
|
| 63 |
+
def list_agents(self, db: Session, user: User) -> List[Agent]:
|
| 64 |
+
"""List all agents owned by the user."""
|
| 65 |
+
return db.query(Agent).filter(Agent.user_id == user.id).all()
|
| 66 |
+
|
| 67 |
+
def delete_agent(self, db: Session, user: User, agent_name: str):
|
| 68 |
+
"""Delete an agent and its files."""
|
| 69 |
+
agent = self.get_agent(db, user, agent_name)
|
| 70 |
+
if not agent:
|
| 71 |
+
raise ValueError("Agent not found")
|
| 72 |
+
|
| 73 |
+
# Delete files
|
| 74 |
+
try:
|
| 75 |
+
if agent.storage_path and Path(agent.storage_path).exists():
|
| 76 |
+
shutil.rmtree(agent.storage_path)
|
| 77 |
+
except Exception as e:
|
| 78 |
+
print(f"Error deleting files for agent {agent.name}: {e}")
|
| 79 |
+
# Continue to delete DB record even if file deletion fails
|
| 80 |
+
|
| 81 |
+
db.delete(agent)
|
| 82 |
+
db.commit()
|
| 83 |
+
|
| 84 |
+
agent_service = AgentService()
|
backend/services/auth_service.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from models.user import User
|
| 5 |
+
from core.security import get_password_hash, verify_password, create_access_token
|
| 6 |
+
|
| 7 |
+
class AuthService:
|
| 8 |
+
def register_user(self, db: Session, email: str, password: str) -> User:
|
| 9 |
+
"""Register a new user."""
|
| 10 |
+
# Check if user exists
|
| 11 |
+
existing_user = db.query(User).filter(User.email == email).first()
|
| 12 |
+
if existing_user:
|
| 13 |
+
raise ValueError("Email already registered")
|
| 14 |
+
|
| 15 |
+
# Create user
|
| 16 |
+
hashed_pw = get_password_hash(password)
|
| 17 |
+
new_user = User(email=email, password=hashed_pw)
|
| 18 |
+
|
| 19 |
+
db.add(new_user)
|
| 20 |
+
db.commit()
|
| 21 |
+
db.refresh(new_user)
|
| 22 |
+
return new_user
|
| 23 |
+
|
| 24 |
+
def authenticate_user(self, db: Session, email: str, password: str) -> dict:
|
| 25 |
+
"""Authenticate user and return token."""
|
| 26 |
+
user = db.query(User).filter(User.email == email).first()
|
| 27 |
+
if not user or not verify_password(password, user.password):
|
| 28 |
+
return None
|
| 29 |
+
|
| 30 |
+
# Update login time
|
| 31 |
+
user.last_login = datetime.utcnow()
|
| 32 |
+
db.commit()
|
| 33 |
+
|
| 34 |
+
# Create token
|
| 35 |
+
access_token = create_access_token(data={"sub": user.email, "user_id": user.id})
|
| 36 |
+
|
| 37 |
+
return {
|
| 38 |
+
"access_token": access_token,
|
| 39 |
+
"token_type": "bearer",
|
| 40 |
+
"user": {
|
| 41 |
+
"id": user.id,
|
| 42 |
+
"email": user.email,
|
| 43 |
+
"created_at": user.created_at
|
| 44 |
+
}
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
def change_password(self, db: Session, user_email: str, old_password: str, new_password: str):
|
| 48 |
+
"""Change user password."""
|
| 49 |
+
user = db.query(User).filter(User.email == user_email).first()
|
| 50 |
+
if not user:
|
| 51 |
+
raise ValueError("User not found")
|
| 52 |
+
|
| 53 |
+
if not verify_password(old_password, user.password):
|
| 54 |
+
raise ValueError("Incorrect current password")
|
| 55 |
+
|
| 56 |
+
user.password = get_password_hash(new_password)
|
| 57 |
+
db.commit()
|
| 58 |
+
return True
|
| 59 |
+
|
| 60 |
+
auth_service = AuthService()
|
backend/services/conversation_service.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
from typing import List, Optional
|
| 3 |
+
from sqlalchemy.orm import Session
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
|
| 6 |
+
from models.conversation import Conversation, Message
|
| 7 |
+
from models.agent import Agent
|
| 8 |
+
from models.user import User
|
| 9 |
+
|
| 10 |
+
class ConversationService:
|
| 11 |
+
"""
|
| 12 |
+
Service for managing conversations and messages.
|
| 13 |
+
Handles auto-creation of conversations and message persistence.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
def get_or_create_conversation(
|
| 17 |
+
self,
|
| 18 |
+
db: Session,
|
| 19 |
+
agent_id: int,
|
| 20 |
+
user_id: int
|
| 21 |
+
) -> Conversation:
|
| 22 |
+
"""Get existing conversation or create a new one."""
|
| 23 |
+
conversation = db.query(Conversation).filter(
|
| 24 |
+
Conversation.agent_id == agent_id,
|
| 25 |
+
Conversation.user_id == user_id
|
| 26 |
+
).first()
|
| 27 |
+
|
| 28 |
+
if not conversation:
|
| 29 |
+
conversation = Conversation(
|
| 30 |
+
agent_id=agent_id,
|
| 31 |
+
user_id=user_id
|
| 32 |
+
)
|
| 33 |
+
db.add(conversation)
|
| 34 |
+
db.commit()
|
| 35 |
+
db.refresh(conversation)
|
| 36 |
+
|
| 37 |
+
return conversation
|
| 38 |
+
|
| 39 |
+
def add_message(
|
| 40 |
+
self,
|
| 41 |
+
db: Session,
|
| 42 |
+
conversation_id: int,
|
| 43 |
+
role: str,
|
| 44 |
+
content: str,
|
| 45 |
+
multimodal_data: dict = None,
|
| 46 |
+
explainability_data: dict = None,
|
| 47 |
+
confidence: float = None
|
| 48 |
+
) -> Message:
|
| 49 |
+
"""Add a message to a conversation."""
|
| 50 |
+
message = Message(
|
| 51 |
+
conversation_id=conversation_id,
|
| 52 |
+
role=role,
|
| 53 |
+
content=content,
|
| 54 |
+
multimodal_data=multimodal_data,
|
| 55 |
+
explainability_data=explainability_data,
|
| 56 |
+
confidence=confidence
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
db.add(message)
|
| 60 |
+
|
| 61 |
+
# Update conversation timestamp
|
| 62 |
+
conversation = db.query(Conversation).filter(
|
| 63 |
+
Conversation.id == conversation_id
|
| 64 |
+
).first()
|
| 65 |
+
if conversation:
|
| 66 |
+
conversation.updated_at = datetime.utcnow()
|
| 67 |
+
|
| 68 |
+
db.commit()
|
| 69 |
+
db.refresh(message)
|
| 70 |
+
|
| 71 |
+
return message
|
| 72 |
+
|
| 73 |
+
def get_messages(
|
| 74 |
+
self,
|
| 75 |
+
db: Session,
|
| 76 |
+
conversation_id: int,
|
| 77 |
+
limit: int = 50
|
| 78 |
+
) -> List[Message]:
|
| 79 |
+
"""Get messages from a conversation."""
|
| 80 |
+
return db.query(Message).filter(
|
| 81 |
+
Message.conversation_id == conversation_id
|
| 82 |
+
).order_by(Message.timestamp.asc()).limit(limit).all()
|
| 83 |
+
|
| 84 |
+
def get_conversation_history(
|
| 85 |
+
self,
|
| 86 |
+
db: Session,
|
| 87 |
+
agent_id: int,
|
| 88 |
+
user_id: int,
|
| 89 |
+
limit: int = 50
|
| 90 |
+
) -> List[dict]:
|
| 91 |
+
"""Get conversation history for an agent-user pair."""
|
| 92 |
+
conversation = db.query(Conversation).filter(
|
| 93 |
+
Conversation.agent_id == agent_id,
|
| 94 |
+
Conversation.user_id == user_id
|
| 95 |
+
).first()
|
| 96 |
+
|
| 97 |
+
if not conversation:
|
| 98 |
+
return []
|
| 99 |
+
|
| 100 |
+
messages = self.get_messages(db, conversation.id, limit)
|
| 101 |
+
|
| 102 |
+
return [
|
| 103 |
+
{
|
| 104 |
+
"id": msg.id,
|
| 105 |
+
"role": msg.role,
|
| 106 |
+
"content": msg.content,
|
| 107 |
+
"timestamp": msg.timestamp,
|
| 108 |
+
"confidence": msg.confidence,
|
| 109 |
+
"explainability": msg.explainability_data
|
| 110 |
+
}
|
| 111 |
+
for msg in messages
|
| 112 |
+
]
|
| 113 |
+
|
| 114 |
+
def list_conversations(
|
| 115 |
+
self,
|
| 116 |
+
db: Session,
|
| 117 |
+
user_id: int
|
| 118 |
+
) -> List[dict]:
|
| 119 |
+
"""List all conversations for a user."""
|
| 120 |
+
conversations = db.query(Conversation).filter(
|
| 121 |
+
Conversation.user_id == user_id
|
| 122 |
+
).order_by(Conversation.updated_at.desc()).all()
|
| 123 |
+
|
| 124 |
+
return [
|
| 125 |
+
{
|
| 126 |
+
"id": conv.id,
|
| 127 |
+
"agent_id": conv.agent_id,
|
| 128 |
+
"created_at": conv.created_at,
|
| 129 |
+
"updated_at": conv.updated_at,
|
| 130 |
+
"message_count": len(conv.messages)
|
| 131 |
+
}
|
| 132 |
+
for conv in conversations
|
| 133 |
+
]
|
| 134 |
+
|
| 135 |
+
def delete_conversation(self, db: Session, conversation_id: int, user_id: int) -> bool:
|
| 136 |
+
"""Delete a conversation (with ownership check)."""
|
| 137 |
+
conversation = db.query(Conversation).filter(
|
| 138 |
+
Conversation.id == conversation_id,
|
| 139 |
+
Conversation.user_id == user_id
|
| 140 |
+
).first()
|
| 141 |
+
|
| 142 |
+
if not conversation:
|
| 143 |
+
return False
|
| 144 |
+
|
| 145 |
+
db.delete(conversation)
|
| 146 |
+
db.commit()
|
| 147 |
+
return True
|
| 148 |
+
|
| 149 |
+
# Singleton instance
|
| 150 |
+
conversation_service = ConversationService()
|
backend/services/inference_service.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
from typing import Optional
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from sqlalchemy.orm import Session
|
| 5 |
+
|
| 6 |
+
from models.agent import Agent
|
| 7 |
+
from models.user import User
|
| 8 |
+
from services.conversation_service import conversation_service
|
| 9 |
+
from modules.reasoning_engine import ReasoningEngine, create_reasoning_engine
|
| 10 |
+
|
| 11 |
+
class InferenceService:
|
| 12 |
+
"""
|
| 13 |
+
Service for running inference with AI agents.
|
| 14 |
+
Wraps the Phase 1 ReasoningEngine with multi-tenancy support.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
def __init__(self):
|
| 18 |
+
self.engine_cache = {} # agent_id -> ReasoningEngine
|
| 19 |
+
|
| 20 |
+
def get_engine(self, agent: Agent) -> ReasoningEngine:
|
| 21 |
+
"""Get or create a reasoning engine for an agent."""
|
| 22 |
+
if agent.id in self.engine_cache:
|
| 23 |
+
return self.engine_cache[agent.id]
|
| 24 |
+
|
| 25 |
+
# Create new engine
|
| 26 |
+
engine = create_reasoning_engine(agent.storage_path)
|
| 27 |
+
self.engine_cache[agent.id] = engine
|
| 28 |
+
return engine
|
| 29 |
+
|
| 30 |
+
def clear_cache(self, agent_id: int = None):
|
| 31 |
+
"""Clear engine cache."""
|
| 32 |
+
if agent_id:
|
| 33 |
+
if agent_id in self.engine_cache:
|
| 34 |
+
del self.engine_cache[agent_id]
|
| 35 |
+
else:
|
| 36 |
+
self.engine_cache.clear()
|
| 37 |
+
|
| 38 |
+
def chat(
|
| 39 |
+
self,
|
| 40 |
+
db: Session,
|
| 41 |
+
agent: Agent,
|
| 42 |
+
user: User,
|
| 43 |
+
message: str,
|
| 44 |
+
image_path: Optional[str] = None,
|
| 45 |
+
audio_path: Optional[str] = None
|
| 46 |
+
) -> dict:
|
| 47 |
+
"""
|
| 48 |
+
Process a chat message with the agent.
|
| 49 |
+
|
| 50 |
+
Returns:
|
| 51 |
+
dict with answer, confidence, explainability, etc.
|
| 52 |
+
"""
|
| 53 |
+
# Check agent status
|
| 54 |
+
if agent.status != "ready":
|
| 55 |
+
return {
|
| 56 |
+
"answer": f"Agent is not ready. Current status: {agent.status}",
|
| 57 |
+
"confidence": 0.0,
|
| 58 |
+
"in_domain": False,
|
| 59 |
+
"explainability": None
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
# Get or create conversation
|
| 63 |
+
conversation = conversation_service.get_or_create_conversation(
|
| 64 |
+
db, agent.id, user.id
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
# Save user message
|
| 68 |
+
conversation_service.add_message(
|
| 69 |
+
db, conversation.id, "user", message,
|
| 70 |
+
multimodal_data={"image": image_path, "audio": audio_path} if image_path or audio_path else None
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
# Get reasoning engine
|
| 74 |
+
engine = self.get_engine(agent)
|
| 75 |
+
|
| 76 |
+
# Run inference
|
| 77 |
+
try:
|
| 78 |
+
# Build multimodal context
|
| 79 |
+
multimodal_context = ""
|
| 80 |
+
if image_path:
|
| 81 |
+
multimodal_context += f"[IMAGE: {image_path}]\n"
|
| 82 |
+
if audio_path:
|
| 83 |
+
multimodal_context += f"[AUDIO: {audio_path}]\n"
|
| 84 |
+
|
| 85 |
+
result = engine.reason(
|
| 86 |
+
agent_name=agent.name,
|
| 87 |
+
query=message,
|
| 88 |
+
multimodal_context=multimodal_context
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
# Save assistant response
|
| 92 |
+
conversation_service.add_message(
|
| 93 |
+
db, conversation.id, "assistant", result.get("answer", ""),
|
| 94 |
+
explainability_data=result.get("explainability"),
|
| 95 |
+
confidence=result.get("confidence", 0.0)
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
return result
|
| 99 |
+
|
| 100 |
+
except Exception as e:
|
| 101 |
+
error_response = {
|
| 102 |
+
"answer": f"Error processing query: {str(e)}",
|
| 103 |
+
"confidence": 0.0,
|
| 104 |
+
"in_domain": False,
|
| 105 |
+
"explainability": None
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
# Save error response
|
| 109 |
+
conversation_service.add_message(
|
| 110 |
+
db, conversation.id, "assistant", error_response["answer"],
|
| 111 |
+
confidence=0.0
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
return error_response
|
| 115 |
+
|
| 116 |
+
def get_history(
|
| 117 |
+
self,
|
| 118 |
+
db: Session,
|
| 119 |
+
agent: Agent,
|
| 120 |
+
user: User,
|
| 121 |
+
limit: int = 50
|
| 122 |
+
) -> list:
|
| 123 |
+
"""Get conversation history for agent-user pair."""
|
| 124 |
+
return conversation_service.get_conversation_history(
|
| 125 |
+
db, agent.id, user.id, limit
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
# Singleton instance
|
| 130 |
+
inference_service = InferenceService()
|
backend/services/storage_service.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MEXAR Core Engine - Storage Service
|
| 3 |
+
Handles file uploads to Supabase Storage.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
import logging
|
| 8 |
+
from typing import Optional
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
import uuid
|
| 11 |
+
from fastapi import UploadFile, HTTPException
|
| 12 |
+
from supabase import create_client, Client
|
| 13 |
+
|
| 14 |
+
# Configure logging
|
| 15 |
+
logging.basicConfig(level=logging.INFO)
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class StorageService:
|
| 20 |
+
"""Service for managing file uploads to Supabase Storage."""
|
| 21 |
+
|
| 22 |
+
def __init__(self):
|
| 23 |
+
"""Initialize Supabase client."""
|
| 24 |
+
supabase_url = os.getenv("SUPABASE_URL")
|
| 25 |
+
supabase_key = os.getenv("SUPABASE_KEY")
|
| 26 |
+
|
| 27 |
+
if not supabase_url or not supabase_key:
|
| 28 |
+
raise ValueError("SUPABASE_URL and SUPABASE_KEY must be set in environment variables")
|
| 29 |
+
|
| 30 |
+
self.client: Client = create_client(supabase_url, supabase_key)
|
| 31 |
+
logger.info("Supabase Storage client initialized")
|
| 32 |
+
|
| 33 |
+
async def upload_file(
|
| 34 |
+
self,
|
| 35 |
+
file: UploadFile,
|
| 36 |
+
bucket: str,
|
| 37 |
+
folder: str = ""
|
| 38 |
+
) -> dict:
|
| 39 |
+
"""
|
| 40 |
+
Upload file to Supabase Storage and return file info.
|
| 41 |
+
|
| 42 |
+
Args:
|
| 43 |
+
file: FastAPI UploadFile object
|
| 44 |
+
bucket: Bucket name (e.g., 'agent-uploads', 'chat-media')
|
| 45 |
+
folder: Optional folder path within bucket
|
| 46 |
+
|
| 47 |
+
Returns:
|
| 48 |
+
Dict containing:
|
| 49 |
+
- path: File path in storage
|
| 50 |
+
- url: Public URL (if bucket is public)
|
| 51 |
+
- size: File size in bytes
|
| 52 |
+
"""
|
| 53 |
+
try:
|
| 54 |
+
# Generate unique filename
|
| 55 |
+
ext = Path(file.filename).suffix
|
| 56 |
+
filename = f"{uuid.uuid4()}{ext}"
|
| 57 |
+
path = f"{folder}/{filename}" if folder else filename
|
| 58 |
+
|
| 59 |
+
# Read file content
|
| 60 |
+
content = await file.read()
|
| 61 |
+
file_size = len(content)
|
| 62 |
+
|
| 63 |
+
# Upload to Supabase
|
| 64 |
+
logger.info(f"Uploading file to {bucket}/{path}")
|
| 65 |
+
response = self.client.storage.from_(bucket).upload(
|
| 66 |
+
path=path,
|
| 67 |
+
file=content,
|
| 68 |
+
file_options={"content-type": file.content_type or "application/octet-stream"}
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
# Get public URL (works for public buckets)
|
| 72 |
+
public_url = self.client.storage.from_(bucket).get_public_url(path)
|
| 73 |
+
|
| 74 |
+
logger.info(f"File uploaded successfully: {path}")
|
| 75 |
+
|
| 76 |
+
return {
|
| 77 |
+
"path": path,
|
| 78 |
+
"url": public_url,
|
| 79 |
+
"size": file_size,
|
| 80 |
+
"bucket": bucket,
|
| 81 |
+
"original_filename": file.filename
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
except Exception as e:
|
| 85 |
+
logger.error(f"Error uploading file to Supabase Storage: {str(e)}")
|
| 86 |
+
raise HTTPException(
|
| 87 |
+
status_code=500,
|
| 88 |
+
detail=f"Failed to upload file: {str(e)}"
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
def delete_file(self, bucket: str, path: str) -> bool:
|
| 92 |
+
"""
|
| 93 |
+
Delete file from storage.
|
| 94 |
+
|
| 95 |
+
Args:
|
| 96 |
+
bucket: Bucket name
|
| 97 |
+
path: File path in bucket
|
| 98 |
+
|
| 99 |
+
Returns:
|
| 100 |
+
True if successful
|
| 101 |
+
"""
|
| 102 |
+
try:
|
| 103 |
+
logger.info(f"Deleting file from {bucket}/{path}")
|
| 104 |
+
self.client.storage.from_(bucket).remove([path])
|
| 105 |
+
logger.info(f"File deleted successfully: {path}")
|
| 106 |
+
return True
|
| 107 |
+
except Exception as e:
|
| 108 |
+
logger.error(f"Error deleting file: {str(e)}")
|
| 109 |
+
return False
|
| 110 |
+
|
| 111 |
+
def get_signed_url(self, bucket: str, path: str, expires_in: int = 3600) -> str:
|
| 112 |
+
"""
|
| 113 |
+
Generate a signed URL for private files.
|
| 114 |
+
|
| 115 |
+
Args:
|
| 116 |
+
bucket: Bucket name
|
| 117 |
+
path: File path
|
| 118 |
+
expires_in: URL expiration time in seconds (default: 1 hour)
|
| 119 |
+
|
| 120 |
+
Returns:
|
| 121 |
+
Signed URL string
|
| 122 |
+
"""
|
| 123 |
+
try:
|
| 124 |
+
response = self.client.storage.from_(bucket).create_signed_url(
|
| 125 |
+
path=path,
|
| 126 |
+
expires_in=expires_in
|
| 127 |
+
)
|
| 128 |
+
return response.get("signedURL", "")
|
| 129 |
+
except Exception as e:
|
| 130 |
+
logger.error(f"Error generating signed URL: {str(e)}")
|
| 131 |
+
raise HTTPException(
|
| 132 |
+
status_code=500,
|
| 133 |
+
detail=f"Failed to generate signed URL: {str(e)}"
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
# Factory function for easy instantiation
|
| 138 |
+
def create_storage_service() -> StorageService:
|
| 139 |
+
"""Create a new StorageService instance."""
|
| 140 |
+
return StorageService()
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
# Global instance
|
| 144 |
+
storage_service = create_storage_service()
|
backend/services/tts_service.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MEXAR Core Engine - Text-to-Speech Service
|
| 3 |
+
Provides text-to-speech capabilities with multiple provider support.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
import logging
|
| 8 |
+
import hashlib
|
| 9 |
+
import requests
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Optional, Dict, Any, List
|
| 12 |
+
from dotenv import load_dotenv
|
| 13 |
+
|
| 14 |
+
load_dotenv()
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class TTSService:
|
| 19 |
+
"""
|
| 20 |
+
Text-to-Speech service supporting multiple providers:
|
| 21 |
+
- ElevenLabs (high quality, free tier: 10k chars/month)
|
| 22 |
+
- Web Speech API (browser-based, unlimited, handled client-side)
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
def __init__(self, cache_dir: str = "data/tts_cache"):
|
| 26 |
+
"""
|
| 27 |
+
Initialize TTS service.
|
| 28 |
+
|
| 29 |
+
Args:
|
| 30 |
+
cache_dir: Directory to cache generated audio files
|
| 31 |
+
"""
|
| 32 |
+
self.cache_dir = Path(cache_dir)
|
| 33 |
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
| 34 |
+
|
| 35 |
+
# ElevenLabs configuration
|
| 36 |
+
self.elevenlabs_api_key = os.getenv("ELEVENLABS_API_KEY")
|
| 37 |
+
self.elevenlabs_base_url = "https://api.elevenlabs.io/v1"
|
| 38 |
+
|
| 39 |
+
# Default voices
|
| 40 |
+
self.default_voices = {
|
| 41 |
+
"elevenlabs": "21m00Tcm4TlvDq8ikWAM", # Rachel - neutral
|
| 42 |
+
"web_speech": "default" # Browser default
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
def generate_speech(
|
| 46 |
+
self,
|
| 47 |
+
text: str,
|
| 48 |
+
provider: str = "elevenlabs",
|
| 49 |
+
voice_id: Optional[str] = None,
|
| 50 |
+
model_id: str = "eleven_monolingual_v1"
|
| 51 |
+
) -> Dict[str, Any]:
|
| 52 |
+
"""
|
| 53 |
+
Generate speech from text using specified provider.
|
| 54 |
+
|
| 55 |
+
Args:
|
| 56 |
+
text: Text to convert to speech
|
| 57 |
+
provider: "elevenlabs" or "web_speech"
|
| 58 |
+
voice_id: Voice ID (provider-specific)
|
| 59 |
+
model_id: Model ID for ElevenLabs
|
| 60 |
+
|
| 61 |
+
Returns:
|
| 62 |
+
Dict with audio file path, provider info, and metadata
|
| 63 |
+
"""
|
| 64 |
+
if not text or not text.strip():
|
| 65 |
+
return {
|
| 66 |
+
"success": False,
|
| 67 |
+
"error": "Empty text provided"
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
# Check cache first
|
| 71 |
+
cache_key = self._get_cache_key(text, provider, voice_id)
|
| 72 |
+
cached_file = self.cache_dir / f"{cache_key}.mp3"
|
| 73 |
+
|
| 74 |
+
if cached_file.exists():
|
| 75 |
+
logger.info(f"Using cached TTS audio: {cache_key}")
|
| 76 |
+
return {
|
| 77 |
+
"success": True,
|
| 78 |
+
"provider": provider,
|
| 79 |
+
"audio_path": str(cached_file),
|
| 80 |
+
"audio_url": f"/api/chat/tts/audio/{cache_key}.mp3",
|
| 81 |
+
"cached": True,
|
| 82 |
+
"text_length": len(text)
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
# Generate new audio
|
| 86 |
+
if provider == "elevenlabs":
|
| 87 |
+
return self._generate_elevenlabs(text, voice_id, model_id, cached_file)
|
| 88 |
+
elif provider == "web_speech":
|
| 89 |
+
# Web Speech API is client-side only
|
| 90 |
+
return {
|
| 91 |
+
"success": True,
|
| 92 |
+
"provider": "web_speech",
|
| 93 |
+
"client_side": True,
|
| 94 |
+
"text": text,
|
| 95 |
+
"voice_id": voice_id or self.default_voices["web_speech"],
|
| 96 |
+
"message": "Use browser Web Speech API for playback"
|
| 97 |
+
}
|
| 98 |
+
else:
|
| 99 |
+
return {
|
| 100 |
+
"success": False,
|
| 101 |
+
"error": f"Unknown provider: {provider}"
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
def _generate_elevenlabs(
|
| 105 |
+
self,
|
| 106 |
+
text: str,
|
| 107 |
+
voice_id: Optional[str],
|
| 108 |
+
model_id: str,
|
| 109 |
+
output_path: Path
|
| 110 |
+
) -> Dict[str, Any]:
|
| 111 |
+
"""Generate speech using ElevenLabs API."""
|
| 112 |
+
if not self.elevenlabs_api_key:
|
| 113 |
+
return {
|
| 114 |
+
"success": False,
|
| 115 |
+
"error": "ElevenLabs API key not configured",
|
| 116 |
+
"fallback": "web_speech"
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
voice = voice_id or self.default_voices["elevenlabs"]
|
| 120 |
+
|
| 121 |
+
try:
|
| 122 |
+
url = f"{self.elevenlabs_base_url}/text-to-speech/{voice}"
|
| 123 |
+
|
| 124 |
+
headers = {
|
| 125 |
+
"Accept": "audio/mpeg",
|
| 126 |
+
"Content-Type": "application/json",
|
| 127 |
+
"xi-api-key": self.elevenlabs_api_key
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
data = {
|
| 131 |
+
"text": text,
|
| 132 |
+
"model_id": model_id,
|
| 133 |
+
"voice_settings": {
|
| 134 |
+
"stability": 0.5,
|
| 135 |
+
"similarity_boost": 0.75
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
response = requests.post(url, json=data, headers=headers, timeout=30)
|
| 140 |
+
|
| 141 |
+
if response.status_code == 200:
|
| 142 |
+
# Save audio file
|
| 143 |
+
with open(output_path, "wb") as f:
|
| 144 |
+
f.write(response.content)
|
| 145 |
+
|
| 146 |
+
logger.info(f"Generated ElevenLabs TTS: {len(text)} chars")
|
| 147 |
+
|
| 148 |
+
return {
|
| 149 |
+
"success": True,
|
| 150 |
+
"provider": "elevenlabs",
|
| 151 |
+
"audio_path": str(output_path),
|
| 152 |
+
"audio_url": f"/api/chat/tts/audio/{output_path.name}",
|
| 153 |
+
"cached": False,
|
| 154 |
+
"text_length": len(text),
|
| 155 |
+
"voice_id": voice
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
elif response.status_code == 401:
|
| 159 |
+
return {
|
| 160 |
+
"success": False,
|
| 161 |
+
"error": "Invalid ElevenLabs API key",
|
| 162 |
+
"fallback": "web_speech"
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
elif response.status_code == 429:
|
| 166 |
+
return {
|
| 167 |
+
"success": False,
|
| 168 |
+
"error": "ElevenLabs quota exceeded",
|
| 169 |
+
"fallback": "web_speech"
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
else:
|
| 173 |
+
return {
|
| 174 |
+
"success": False,
|
| 175 |
+
"error": f"ElevenLabs API error: {response.status_code}",
|
| 176 |
+
"fallback": "web_speech"
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
except Exception as e:
|
| 180 |
+
logger.error(f"ElevenLabs TTS failed: {e}")
|
| 181 |
+
return {
|
| 182 |
+
"success": False,
|
| 183 |
+
"error": str(e),
|
| 184 |
+
"fallback": "web_speech"
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
def get_available_voices(self, provider: str = "elevenlabs") -> List[Dict[str, str]]:
|
| 188 |
+
"""
|
| 189 |
+
Get list of available voices for a provider.
|
| 190 |
+
|
| 191 |
+
Args:
|
| 192 |
+
provider: "elevenlabs" or "web_speech"
|
| 193 |
+
|
| 194 |
+
Returns:
|
| 195 |
+
List of voice dictionaries with id, name, and metadata
|
| 196 |
+
"""
|
| 197 |
+
if provider == "elevenlabs":
|
| 198 |
+
if not self.elevenlabs_api_key:
|
| 199 |
+
return []
|
| 200 |
+
|
| 201 |
+
try:
|
| 202 |
+
url = f"{self.elevenlabs_base_url}/voices"
|
| 203 |
+
headers = {"xi-api-key": self.elevenlabs_api_key}
|
| 204 |
+
|
| 205 |
+
response = requests.get(url, headers=headers, timeout=10)
|
| 206 |
+
|
| 207 |
+
if response.status_code == 200:
|
| 208 |
+
data = response.json()
|
| 209 |
+
return [
|
| 210 |
+
{
|
| 211 |
+
"id": voice["voice_id"],
|
| 212 |
+
"name": voice["name"],
|
| 213 |
+
"category": voice.get("category", "general"),
|
| 214 |
+
"preview_url": voice.get("preview_url")
|
| 215 |
+
}
|
| 216 |
+
for voice in data.get("voices", [])
|
| 217 |
+
]
|
| 218 |
+
|
| 219 |
+
except Exception as e:
|
| 220 |
+
logger.error(f"Failed to fetch ElevenLabs voices: {e}")
|
| 221 |
+
return []
|
| 222 |
+
|
| 223 |
+
elif provider == "web_speech":
|
| 224 |
+
# Web Speech API voices are browser-specific
|
| 225 |
+
return [
|
| 226 |
+
{"id": "default", "name": "Browser Default", "category": "system"}
|
| 227 |
+
]
|
| 228 |
+
|
| 229 |
+
return []
|
| 230 |
+
|
| 231 |
+
def check_quota(self) -> Dict[str, Any]:
|
| 232 |
+
"""
|
| 233 |
+
Check remaining quota for ElevenLabs.
|
| 234 |
+
|
| 235 |
+
Returns:
|
| 236 |
+
Dict with quota information
|
| 237 |
+
"""
|
| 238 |
+
if not self.elevenlabs_api_key:
|
| 239 |
+
return {
|
| 240 |
+
"provider": "elevenlabs",
|
| 241 |
+
"configured": False
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
try:
|
| 245 |
+
url = f"{self.elevenlabs_base_url}/user"
|
| 246 |
+
headers = {"xi-api-key": self.elevenlabs_api_key}
|
| 247 |
+
|
| 248 |
+
response = requests.get(url, headers=headers, timeout=10)
|
| 249 |
+
|
| 250 |
+
if response.status_code == 200:
|
| 251 |
+
data = response.json()
|
| 252 |
+
subscription = data.get("subscription", {})
|
| 253 |
+
|
| 254 |
+
return {
|
| 255 |
+
"provider": "elevenlabs",
|
| 256 |
+
"configured": True,
|
| 257 |
+
"character_count": subscription.get("character_count", 0),
|
| 258 |
+
"character_limit": subscription.get("character_limit", 10000),
|
| 259 |
+
"remaining": subscription.get("character_limit", 10000) - subscription.get("character_count", 0),
|
| 260 |
+
"tier": subscription.get("tier", "free")
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
except Exception as e:
|
| 264 |
+
logger.error(f"Failed to check ElevenLabs quota: {e}")
|
| 265 |
+
|
| 266 |
+
return {
|
| 267 |
+
"provider": "elevenlabs",
|
| 268 |
+
"configured": True,
|
| 269 |
+
"error": "Failed to fetch quota"
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
def _get_cache_key(self, text: str, provider: str, voice_id: Optional[str]) -> str:
|
| 273 |
+
"""Generate cache key for audio file."""
|
| 274 |
+
content = f"{provider}:{voice_id or 'default'}:{text}"
|
| 275 |
+
return hashlib.md5(content.encode()).hexdigest()
|
| 276 |
+
|
| 277 |
+
def clear_cache(self) -> int:
|
| 278 |
+
"""
|
| 279 |
+
Clear all cached audio files.
|
| 280 |
+
|
| 281 |
+
Returns:
|
| 282 |
+
Number of files deleted
|
| 283 |
+
"""
|
| 284 |
+
count = 0
|
| 285 |
+
for file in self.cache_dir.glob("*.mp3"):
|
| 286 |
+
try:
|
| 287 |
+
file.unlink()
|
| 288 |
+
count += 1
|
| 289 |
+
except Exception as e:
|
| 290 |
+
logger.warning(f"Failed to delete cache file {file}: {e}")
|
| 291 |
+
|
| 292 |
+
logger.info(f"Cleared {count} cached TTS files")
|
| 293 |
+
return count
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
# Singleton instance
|
| 297 |
+
_tts_service_instance: Optional[TTSService] = None
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
def get_tts_service() -> TTSService:
|
| 301 |
+
"""Get or create the singleton TTS service instance."""
|
| 302 |
+
global _tts_service_instance
|
| 303 |
+
if _tts_service_instance is None:
|
| 304 |
+
_tts_service_instance = TTSService()
|
| 305 |
+
return _tts_service_instance
|
backend/utils/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MEXAR Core Engine - Utility Functions Package
|
| 3 |
+
"""
|