devrajsinh2012 commited on
Commit
53bb779
·
0 Parent(s):

Deploy current project snapshot to Hugging Face Space

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +76 -0
  2. Dockerfile +30 -0
  3. README.md +375 -0
  4. backend/.env.example +42 -0
  5. backend/Dockerfile +28 -0
  6. backend/Procfile +1 -0
  7. backend/api/admin.py +53 -0
  8. backend/api/agents.py +281 -0
  9. backend/api/auth.py +110 -0
  10. backend/api/chat.py +511 -0
  11. backend/api/compile.py +109 -0
  12. backend/api/deps.py +32 -0
  13. backend/api/diagnostics.py +133 -0
  14. backend/api/prompts.py +28 -0
  15. backend/api/websocket.py +113 -0
  16. backend/core/cache.py +122 -0
  17. backend/core/config.py +26 -0
  18. backend/core/database.py +26 -0
  19. backend/core/monitoring.py +206 -0
  20. backend/core/rate_limiter.py +172 -0
  21. backend/core/security.py +32 -0
  22. backend/evaluation/__init__.py +1 -0
  23. backend/evaluation/ablation_chunk_size.py +37 -0
  24. backend/evaluation/backbone_comparison.py +32 -0
  25. backend/evaluation/baseline_runner.py +50 -0
  26. backend/evaluation/benchmark_runner.py +35 -0
  27. backend/evaluation/guardrail_analysis.py +35 -0
  28. backend/evaluation/metrics.py +25 -0
  29. backend/evaluation/statistical_tests.py +62 -0
  30. backend/main.py +172 -0
  31. backend/migrations/README.md +65 -0
  32. backend/migrations/__init__.py +0 -0
  33. backend/migrations/add_preferences.py +23 -0
  34. backend/migrations/fix_vector_dimension.sql +20 -0
  35. backend/migrations/hybrid_search_function.sql +103 -0
  36. backend/migrations/rag_migration.sql +112 -0
  37. backend/modules/__init__.py +3 -0
  38. backend/modules/data_validator.py +360 -0
  39. backend/modules/explainability.py +276 -0
  40. backend/modules/knowledge_compiler.py +408 -0
  41. backend/modules/multimodal_processor.py +415 -0
  42. backend/modules/prompt_analyzer.py +336 -0
  43. backend/modules/reasoning_engine.py +538 -0
  44. backend/quick_test.py +32 -0
  45. backend/requirements.txt +55 -0
  46. backend/services/agent_service.py +84 -0
  47. backend/services/auth_service.py +60 -0
  48. backend/services/conversation_service.py +150 -0
  49. backend/services/inference_service.py +130 -0
  50. backend/services/storage_service.py +144 -0
.gitignore ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Models (FastEmbed cache)
2
+ .cache/
3
+ models/
4
+ *.bin
5
+ *.onnx
6
+
7
+ # Python
8
+ __pycache__/
9
+ *.py[cod]
10
+ *$py.class
11
+ *.so
12
+ .Python
13
+ venv/
14
+ .venv/
15
+ env/
16
+ ENV/
17
+ .env
18
+ .env.local
19
+ .env.production
20
+ *.env
21
+ *.egg-info/
22
+ .eggs/
23
+ *.egg
24
+ dist/
25
+ build/
26
+
27
+ # Node.js
28
+ node_modules/
29
+ npm-debug.log*
30
+ yarn-debug.log*
31
+ yarn-error.log*
32
+
33
+ # Build outputs
34
+ frontend/build/
35
+ *.tgz
36
+
37
+ # IDE
38
+ .vscode/
39
+ .idea/
40
+ *.swp
41
+ *.swo
42
+ *~
43
+ .project
44
+ .classpath
45
+ .settings/
46
+
47
+ # OS files
48
+ .DS_Store
49
+ .DS_Store?
50
+ ._*
51
+ .Spotlight-V100
52
+ .Trashes
53
+ ehthumbs.db
54
+ Thumbs.db
55
+
56
+ # Data directories
57
+ backend/data/storage/
58
+ backend/data/temp/
59
+ backend/data/tts_cache/
60
+ backend/data/agents/
61
+ *.db
62
+
63
+ # Test data (optional - uncomment if you want to include)
64
+ # test_data/
65
+
66
+ # Logs
67
+ *.log
68
+ logs/
69
+
70
+ # Misc
71
+ *.bak
72
+ *.tmp
73
+ .cache/
74
+
75
+ # Documentation build
76
+ md files/
Dockerfile ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.9-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Install system dependencies
6
+ RUN apt-get update && apt-get install -y \
7
+ gcc \
8
+ g++ \
9
+ postgresql-client \
10
+ && rm -rf /var/lib/apt/lists/*
11
+
12
+ # Copy requirements and install Python packages
13
+ COPY backend/requirements.txt .
14
+ RUN pip install --no-cache-dir -r requirements.txt
15
+
16
+ # Copy application code from backend directory
17
+ COPY backend/ ./backend/
18
+ # Main entry point needs to be at root level for some runners, or we point pythonpath
19
+ ENV PYTHONPATH=/app/backend
20
+
21
+ # Set environment for model caching to /tmp (only writable dir in HF Spaces)
22
+ ENV HF_HOME=/tmp/.cache/huggingface
23
+ ENV FASTEMBED_CACHE_PATH=/tmp/.cache/fastembed
24
+ ENV SENTENCE_TRANSFORMERS_HOME=/tmp/.cache/sentence-transformers
25
+
26
+ # Expose port 7860 (required by Hugging Face Spaces)
27
+ EXPOSE 7860
28
+
29
+ # Run FastAPI with uvicorn (pointing to nested app)
30
+ CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,375 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # MEXAR Ultimate 🧠
2
+
3
+ **Multimodal Explainable AI Reasoning Assistant**
4
+
5
+ [![Python 3.9+](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/downloads/)
6
+ [![React 18](https://img.shields.io/badge/react-18-61dafb.svg)](https://reactjs.org/)
7
+ [![FastAPI](https://img.shields.io/badge/fastapi-0.109-009688.svg)](https://fastapi.tiangolo.com/)
8
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
9
+ [![Deployed](https://img.shields.io/badge/status-live-brightgreen.svg)](https://mexar.vercel.app)
10
+
11
+ > Create domain-specific intelligent agents from your data with transparent, explainable AI responses using RAG (Retrieval-Augmented Generation) with source attribution and faithfulness scoring.
12
+
13
+ **🚀 Live Demo**: [https://mexar.vercel.app](https://mexar.vercel.app)
14
+ **📡 Backend API**: [https://devrajsinh2012-mexar.hf.space](https://devrajsinh2012-mexar.hf.space)
15
+
16
+ ---
17
+
18
+ ## ✨ Key Features
19
+
20
+ | Feature | Description |
21
+ |---------|-------------|
22
+ | 🔍 **Hybrid Search** | Combines semantic (vector) + keyword search with RRF fusion for optimal retrieval |
23
+ | 🎯 **Cross-Encoder Reranking** | Improves retrieval precision using sentence-transformers |
24
+ | 📊 **Source Attribution** | Inline citations `[1]`, `[2]` linking answers to source data |
25
+ | ✅ **Faithfulness Scoring** | Measures how well answers are grounded in retrieved context |
26
+ | 🗣️ **Multimodal Input** | Audio (Whisper), Images (Vision), Video support |
27
+ | 🔐 **Domain Guardrails** | Prevents hallucinations outside knowledge base |
28
+ | 🔊 **Text-to-Speech** | ElevenLabs + Web Speech API integration |
29
+ | 📁 **5 File Types** | CSV, PDF, DOCX, JSON, TXT |
30
+
31
+ ---
32
+
33
+ ## 🏗️ Architecture
34
+
35
+ ```
36
+ ┌────────────────────────────────────────────────────────────────────────────┐
37
+ │ MEXAR Ultimate Stack │
38
+ ├────────────────────────────────────────────────────────────────────────────┤
39
+ │ │
40
+ │ ┌────────────────────────────────────────────┐ │
41
+ │ │ React Frontend (Vercel) │ │
42
+ │ └────────────────────────────────────────────┘ │
43
+ │ │ │
44
+ │ ▼ │
45
+ │ ┌────────────────────────────────────────────┐ │
46
+ │ │ FastAPI Backend (Hugging Face Spaces) │ │
47
+ │ └────────────────────────────────────────────┘ │
48
+ │ │ │
49
+ │ ▼ │
50
+ │ ┌────────────────────────────────────────────────────────────────────┐ │
51
+ │ │ Core Intelligence Layer │ │
52
+ │ │ │ │
53
+ │ │ 🔄 Data Validator (PDF / DOCX / CSV / TXT / JSON) │ │
54
+ │ │ 🤖 Prompt Analyzer (LLM-based Intent Parsing) │ │
55
+ │ │ 📦 Knowledge Compiler (FastEmbed + Chunking) │ │
56
+ │ │ 🧠 Reasoning Engine │ │
57
+ │ │ ├─ Hybrid Search (Dense + Sparse) │ │
58
+ │ │ ├─ Cross-Encoder (Re-ranking) │ │
59
+ │ │ ├─ Source Attribution (Citation Tracking) │ │
60
+ │ │ └─ Faithfulness Scorer (Hallucination Control) │ │
61
+ │ │ │ │
62
+ │ └────────────────────────────────────────────────────────────────────┘ │
63
+ │ │ │
64
+ │ ▼ │
65
+ │ ┌────────────────────────────────────────────────────────────────────┐ │
66
+ │ │ External Services Layer │ │
67
+ │ │ │ │
68
+ │ │ 🗄 Supabase → PostgreSQL + pgvector + File Storage │ │
69
+ │ │ ⚡ Groq API → LLM Inference + Whisper + Vision │ │
70
+ │ │ 🔊 ElevenLabs → Text-to-Speech │ │
71
+ │ │ │ │
72
+ │ └────────────────────────────────────────────────────────────────────┘ │
73
+ │ │
74
+ └────────────────────────────────────────────────────────────────────────────┘
75
+
76
+ ```
77
+
78
+ ---
79
+
80
+ ## 🚀 Quick Start
81
+
82
+ ### Prerequisites
83
+
84
+ - **Python 3.9+** with pip
85
+ - **Node.js 18+** with npm
86
+ - **PostgreSQL** with `pgvector` extension (or use Supabase)
87
+ - **Groq API Key** - Get free at [console.groq.com](https://console.groq.com)
88
+
89
+ ### Local Development
90
+
91
+ #### 1. Backend Setup
92
+
93
+ ```bash
94
+ cd backend
95
+
96
+ # Create virtual environment
97
+ python -m venv venv
98
+
99
+ # Activate (Windows)
100
+ .\venv\Scripts\activate
101
+ # Activate (macOS/Linux)
102
+ source venv/bin/activate
103
+
104
+ # Install dependencies
105
+ pip install -r requirements.txt
106
+
107
+ # Configure environment
108
+ cp .env.example .env
109
+ # Edit .env and add your API keys
110
+
111
+ # Run server
112
+ python -m uvicorn main:app --reload --host 0.0.0.0 --port 8000
113
+ ```
114
+
115
+ **Backend will run at**: [http://localhost:8000](http://localhost:8000)
116
+
117
+ #### 2. Frontend Setup
118
+
119
+ ```bash
120
+ cd frontend
121
+
122
+ # Install dependencies
123
+ npm install
124
+
125
+ # Start development server
126
+ npm start
127
+ ```
128
+
129
+ **Frontend will run at**: [http://localhost:3000](http://localhost:3000)
130
+
131
+ ---
132
+
133
+ ## 📁 Project Structure
134
+
135
+ ```
136
+ mexar_ultimate/
137
+ ├── backend/ # FastAPI Backend
138
+ │ ├── api/ # REST API endpoints
139
+ │ │ ├── auth.py # Authentication (JWT)
140
+ │ │ ├── agents.py # Agent CRUD
141
+ │ │ ├── chat.py # Chat + multimodal
142
+ │ │ ├── compile.py # Knowledge compilation
143
+ │ │ └── websocket.py # Real-time updates
144
+ │ ├── core/ # Core configuration
145
+ │ │ ├── config.py # Settings
146
+ │ │ ├── database.py # SQLAlchemy setup
147
+ │ │ └── security.py # JWT handling
148
+ │ ├── models/ # Database models
149
+ │ │ ├── user.py # User model
150
+ │ │ ├── agent.py # Agent + CompilationJob
151
+ │ │ ├── chunk.py # DocumentChunk (pgvector)
152
+ │ │ └── conversation.py # Chat history
153
+ │ ├── modules/ # Core AI modules
154
+ │ │ ├── data_validator.py # File parsing
155
+ │ │ ├── prompt_analyzer.py # Domain extraction
156
+ │ │ ├── knowledge_compiler.py # Vector embeddings
157
+ │ │ ├── reasoning_engine.py # RAG pipeline
158
+ │ │ └── explainability.py # UI formatting
159
+ │ ├── utils/ # Utilities
160
+ │ │ ├── groq_client.py # Groq API wrapper
161
+ │ │ ├── hybrid_search.py # RRF search fusion
162
+ │ │ ├── reranker.py # Cross-encoder
163
+ │ │ ├── faithfulness.py # Claim verification
164
+ │ │ └── source_attribution.py # Citation extraction
165
+ │ ├── main.py # FastAPI entry point
166
+ │ └── requirements.txt # Python dependencies
167
+
168
+ ├── frontend/ # React Frontend
169
+ │ ├── src/
170
+ │ │ ├── pages/ # React pages
171
+ │ │ │ ├── Landing.jsx # Home page
172
+ │ │ │ ├── Login.jsx # Authentication
173
+ │ │ │ ├── Dashboard.jsx # User dashboard
174
+ │ │ │ ├── AgentCreation.jsx # Create agent
175
+ │ │ │ ├── CompilationProgress.jsx # Build progress
176
+ │ │ │ └── Chat.jsx # Chat interface
177
+ │ │ ├── components/ # Reusable UI
178
+ │ │ ├── contexts/ # React contexts
179
+ │ │ ├── api/ # API client
180
+ │ │ └── App.jsx # Main component
181
+ │ ├── package.json # Node dependencies
182
+ │ └── vercel.json # Vercel config
183
+
184
+ ├── Dockerfile # Docker config for HF Spaces
185
+ └── README.md # This file
186
+ ```
187
+ ---
188
+ ### 📸 Screenshot
189
+ <img width="1863" height="918" alt="image" src="https://github.com/user-attachments/assets/75ff2c32-4c90-4e65-ab5f-10c94bad2e65" />
190
+ <img width="330" height="330" alt="image" src="https://github.com/user-attachments/assets/2d9846c9-b002-4f05-b632-6dc1c8c58ad1" />
191
+ <img width="330" height="330" alt="image" src="https://github.com/user-attachments/assets/810de78d-4725-494e-94b5-e8aa71b8b7fd" />
192
+ <img width="330" height="330" alt="image" src="https://github.com/user-attachments/assets/155ea162-d8b9-4b45-acf9-42afc47c8498" />
193
+ <img width="330" height="330" alt="image" src="https://github.com/user-attachments/assets/cadf46a8-c679-4a76-a58d-a956f9cb9cc1" />
194
+ <img width="330" height="330" alt="image" src="https://github.com/user-attachments/assets/598b7925-8001-44a7-ae34-6fddb85b7531" />
195
+ <img width="330" height="330" alt="image" src="https://github.com/user-attachments/assets/e9e99b9d-05b0-4ae5-9990-ed9c9cc124c6" />
196
+
197
+ ---
198
+
199
+ ## 🌐 Deployment
200
+
201
+ ### Current Deployment (Free Tier)
202
+
203
+ - **Frontend**: Vercel - [https://mexar.vercel.app](https://mexar.vercel.app)
204
+ - **Backend**: Hugging Face Spaces - [https://devrajsinh2012-mexar.hf.space](https://devrajsinh2012-mexar.hf.space)
205
+ - **Database**: Supabase (PostgreSQL with pgvector)
206
+ - **Storage**: Supabase Storage
207
+ - **Total Cost**: $0/month
208
+
209
+ ### Deploy Your Own Instance
210
+
211
+ #### Deploy Backend to Hugging Face Spaces
212
+
213
+ 1. Fork this repository
214
+ 2. Create a new Space at [huggingface.co/new-space](https://huggingface.co/new-space)
215
+ 3. Select **Docker** as SDK
216
+ 4. Connect your GitHub repository
217
+ 5. Add Repository Secrets:
218
+ - `GROQ_API_KEY`
219
+ - `DATABASE_URL`
220
+ - `SUPABASE_URL`
221
+ - `SUPABASE_KEY`
222
+ - `SECRET_KEY`
223
+ - `FRONTEND_URL`
224
+
225
+ #### Deploy Frontend to Vercel
226
+
227
+ 1. Import repository at [vercel.com](https://vercel.com)
228
+ 2. Set **Root Directory** to `frontend`
229
+ 3. Add Environment Variable:
230
+ - `REACT_APP_API_URL` = Your HF Spaces URL
231
+
232
+ ---
233
+
234
+ ## 🔧 Environment Variables
235
+
236
+ ### Backend (`backend/.env`)
237
+
238
+ ```env
239
+ # Required: Get from console.groq.com
240
+ GROQ_API_KEY=your_groq_api_key_here
241
+
242
+ # Supabase Database
243
+ DATABASE_URL=postgresql://user:password@host:5432/database
244
+
245
+ # JWT Security
246
+ SECRET_KEY=generate-a-secure-random-key
247
+
248
+ # Supabase Storage
249
+ SUPABASE_URL=https://your-project.supabase.co
250
+ SUPABASE_KEY=your_supabase_service_role_key
251
+
252
+ # Optional: ElevenLabs TTS
253
+ ELEVENLABS_API_KEY=your_elevenlabs_api_key_here
254
+
255
+ # Frontend URL for CORS
256
+ FRONTEND_URL=https://mexar.vercel.app
257
+ ```
258
+
259
+ ### Frontend (`frontend/.env`)
260
+
261
+ ```env
262
+ # Backend API URL
263
+ REACT_APP_API_URL=https://your-backend.hf.space
264
+ ```
265
+
266
+ ---
267
+
268
+ ## 🔍 API Documentation
269
+
270
+ Once the backend is running, interactive API docs are available at:
271
+
272
+ - **Swagger UI**: `http://localhost:8000/docs`
273
+ - **ReDoc**: `http://localhost:8000/redoc`
274
+
275
+ ### Key Endpoints
276
+
277
+ | Method | Endpoint | Description |
278
+ |--------|----------|-------------|
279
+ | POST | `/api/auth/register` | Register new user |
280
+ | POST | `/api/auth/login` | Login (returns JWT) |
281
+ | GET | `/api/agents/` | List all agents |
282
+ | POST | `/api/compile/` | Start agent compilation |
283
+ | GET | `/api/compile/{name}/status` | Check compilation status |
284
+ | POST | `/api/chat/` | Send message to agent |
285
+ | POST | `/api/chat/multimodal` | Send with audio/image |
286
+
287
+ ---
288
+
289
+ ## 🧪 Technologies
290
+
291
+ ### Backend
292
+ - **FastAPI** - Modern async Python web framework
293
+ - **SQLAlchemy** - ORM for PostgreSQL
294
+ - **pgvector** - Vector similarity search
295
+ - **FastEmbed** - Local embedding generation (BAAI/bge-small-en-v1.5)
296
+ - **sentence-transformers** - Cross-encoder reranking
297
+ - **Groq API** - LLM (Llama 3.1/3.3), Whisper (audio), Vision (images)
298
+
299
+ ### Frontend
300
+ - **React 18** - UI framework
301
+ - **Material-UI (MUI)** - Component library
302
+ - **React Router** - Navigation
303
+ - **Axios** - HTTP client
304
+
305
+ ### External Services
306
+ - **Supabase** - Managed PostgreSQL + Storage
307
+ - **Groq** - Fast AI inference (LPU architecture)
308
+ - **ElevenLabs** - Text-to-Speech (optional)
309
+
310
+ ---
311
+
312
+ ## 📊 How It Works
313
+
314
+ ### 1. Agent Creation Flow
315
+ ```
316
+ User uploads files → DataValidator parses content
317
+ → PromptAnalyzer extracts domain & keywords
318
+ → KnowledgeCompiler creates embeddings
319
+ → Stored in pgvector database
320
+ ```
321
+
322
+ ### 2. Query Processing Flow
323
+ ```
324
+ User query → Domain Guardrail check
325
+ → Hybrid Search (semantic + keyword)
326
+ → Cross-Encoder Reranking (top 5 results)
327
+ → LLM Generation with retrieved context
328
+ → Source Attribution (extract citations)
329
+ → Faithfulness Scoring (verify grounding)
330
+ → Explainability Formatting
331
+ ```
332
+
333
+ ### 3. Confidence Calculation
334
+ Confidence score is calculated from:
335
+ - **Retrieval Quality** (35%) - Relevance of retrieved chunks
336
+ - **Rerank Score** (30%) - Cross-encoder confidence
337
+ - **Faithfulness** (25%) - Answer grounding in context
338
+ - **Base Floor** (10%) - For in-domain queries
339
+
340
+ ---
341
+
342
+ ## ⚠️ Known Limitations (Free Tier)
343
+
344
+ 1. **Cold Start Delay**: First request after 15 min idle takes 45-90 seconds
345
+ 2. **Model Download**: Initial startup takes 3-5 minutes (FastEmbed caching)
346
+ 3. **Groq Rate Limits**: 30 requests/min, 14,400/day (free tier)
347
+ 4. **Concurrent Users**: 1-2 recommended on free tier (2GB RAM limit)
348
+ 5. **Ephemeral Storage**: HF Spaces `/tmp` data lost on restart (Supabase used for persistence)
349
+
350
+ **Production Migration**: Upgrade to paid tiers for ~$54/month (persistent instances, higher limits)
351
+
352
+ ---
353
+
354
+ ## 🙏 Acknowledgments
355
+
356
+ - [Groq](https://groq.com) - Fast AI inference with LPU technology
357
+ - [Supabase](https://supabase.com) - PostgreSQL + Storage platform
358
+ - [FastEmbed](https://github.com/qdrant/fastembed) - Lightweight embeddings library
359
+ - [sentence-transformers](https://www.sbert.net) - Reranking models
360
+ - [Hugging Face](https://huggingface.co) - Free ML model hosting
361
+
362
+ ---
363
+
364
+ ## 🤝 Contribution
365
+
366
+ This project is built by Devrajsinh Gohil and Jay Nasit under the guidance of Prof OM Prakash Suthar.
367
+
368
+ **Built with ❤️ using modern AI technologies**
369
+
370
+ ---
371
+
372
+ ## 📄 License
373
+
374
+ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
375
+
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/Dockerfile ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.9-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Install system dependencies
6
+ RUN apt-get update && apt-get install -y \
7
+ gcc \
8
+ g++ \
9
+ postgresql-client \
10
+ && rm -rf /var/lib/apt/lists/*
11
+
12
+ # Copy requirements and install Python packages
13
+ COPY requirements.txt .
14
+ RUN pip install --no-cache-dir -r requirements.txt
15
+
16
+ # Copy application code
17
+ COPY . .
18
+
19
+ # Set environment for model caching to /tmp (only writable dir in HF Spaces)
20
+ ENV HF_HOME=/tmp/.cache/huggingface
21
+ ENV FASTEMBED_CACHE_PATH=/tmp/.cache/fastembed
22
+ ENV SENTENCE_TRANSFORMERS_HOME=/tmp/.cache/sentence-transformers
23
+
24
+ # Expose port 7860 (required by Hugging Face Spaces)
25
+ EXPOSE 7860
26
+
27
+ # Run FastAPI with uvicorn
28
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
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,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ LLM_BACKBONE = os.getenv("LLM_BACKBONE", "llama3") # Options: llama3, mixtral, gemma
19
+
20
+ # Storage
21
+ STORAGE_PATH = os.getenv("STORAGE_PATH", "./data/storage")
22
+
23
+ # Caching (In-memory for dev, Redis for prod)
24
+ REDIS_URL = os.getenv("REDIS_URL") # Optional
25
+
26
+ 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/evaluation/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Evaluation module for reviewer baseline experiments."""
backend/evaluation/ablation_chunk_size.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Ablation study on chunk size effect on faithfulness and retrieval quality.
3
+ """
4
+ import sys
5
+ import os
6
+ sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
7
+
8
+ from modules.knowledge_compiler import create_knowledge_compiler
9
+ from modules.reasoning_engine import create_reasoning_engine
10
+
11
+ def run_chunk_ablation(agent_name: str, parsed_data: list, system_prompt: str, prompt_analysis: dict, test_queries: list):
12
+ sizes = [64, 128, 256, 512, 1024]
13
+
14
+ for size in sizes:
15
+ print(f"\n=====================")
16
+ print(f"Testing Chunk Size: {size}")
17
+ print(f"=====================")
18
+
19
+ compiler = create_knowledge_compiler()
20
+ original_chunk_text = compiler._chunk_text
21
+ compiler._chunk_text = lambda text, chunk_size=size, overlap=size//10: original_chunk_text(text, chunk_size, overlap)
22
+
23
+ # Recompile
24
+ try:
25
+ compiler.compile(agent_name, parsed_data, system_prompt, prompt_analysis)
26
+
27
+ # Test
28
+ engine = create_reasoning_engine()
29
+ for q in test_queries:
30
+ res = engine.reason(agent_name, q)
31
+ print(f"Q: {q}")
32
+ print(f"Faithfulness: {res['explainability']['faithfulness']}")
33
+ except Exception as e:
34
+ print(f"Failed ablation step for size {size}: {e}")
35
+
36
+ if __name__ == "__main__":
37
+ print("Chunk size ablation script ready. Needs actual parsed data to recompile.")
backend/evaluation/backbone_comparison.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Compares different LLM backbones (Llama 3, Mixtral, Gemma).
3
+ """
4
+ import sys
5
+ import os
6
+ sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
7
+
8
+ from core.config import settings
9
+ from modules.reasoning_engine import create_reasoning_engine
10
+
11
+ def run_comparison(agent_name: str, queries: list):
12
+ backbones = ["llama3", "mixtral", "gemma"]
13
+
14
+ for bb in backbones:
15
+ settings.LLM_BACKBONE = bb
16
+ print(f"\n--- Testing Backbone: {bb} ---")
17
+ try:
18
+ # Must recreate engine so GroqClient picks up config
19
+ engine = create_reasoning_engine()
20
+
21
+ for q in queries:
22
+ res = engine.reason(agent_name, q)
23
+ print(f"Q: {q}")
24
+ print(f"A ({bb}): {res['answer'][:100]}...")
25
+ print(f"Faithfulness: {res['explainability']['faithfulness']}")
26
+ except Exception as e:
27
+ print(f"Failed to run with backbone {bb}: {e}")
28
+
29
+ if __name__ == "__main__":
30
+ test_queries = ["What are the symptoms of a common cold?"]
31
+ # Replace 'medical_agent' with an actual compiled agent name
32
+ run_comparison("medical_agent", test_queries)
backend/evaluation/baseline_runner.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Runs CRAG and RAPTOR baselines against a set of test queries.
3
+ """
4
+ import sys
5
+ import os
6
+ sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
7
+
8
+ from modules.reasoning_engine import create_reasoning_engine
9
+ from evaluation.metrics import MetricsRunner
10
+
11
+ def run_baselines(agent_name: str, queries: list):
12
+ engine = create_reasoning_engine()
13
+ metrics = MetricsRunner()
14
+
15
+ results = {"CRAG": [], "RAPTOR": [], "MEXAR": []}
16
+
17
+ for q in queries:
18
+ print(f"\nProcessing query: {q}")
19
+
20
+ try:
21
+ # Original MEXAR
22
+ res_mexar = engine.reason(agent_name, q)
23
+ results["MEXAR"].append(float(res_mexar["explainability"]["faithfulness"].strip('%'))/100)
24
+
25
+ # CRAG
26
+ res_crag = engine.reason_crag_baseline(agent_name, q)
27
+ results["CRAG"].append(res_crag["confidence"]) # The raw score
28
+
29
+ # RAPTOR
30
+ res_raptor = engine.reason_raptor_baseline(agent_name, q)
31
+ results["RAPTOR"].append(res_raptor["confidence"])
32
+ except Exception as e:
33
+ print(f"Error evaluating query '{q}': {e}")
34
+
35
+ print("\n--- Baseline Comparison (Faithfulness) ---")
36
+ for b_name in results:
37
+ if results[b_name]:
38
+ avg = sum(results[b_name]) / len(results[b_name])
39
+ print(f"{b_name}: {avg:.4f}")
40
+ else:
41
+ print(f"{b_name}: No results")
42
+
43
+ if __name__ == "__main__":
44
+ # Example usage
45
+ test_queries = [
46
+ "What are the symptoms of a common cold?",
47
+ "How do I bake a chocolate cake?"
48
+ ]
49
+ # Replace 'medical_agent' with an actual compiled agent name in DB
50
+ run_baselines("medical_agent", test_queries)
backend/evaluation/benchmark_runner.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Runs evaluation on public benchmarks like MedQA, LegalBench.
3
+ """
4
+ import sys
5
+ import os
6
+ import json
7
+ sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
8
+
9
+ from modules.reasoning_engine import create_reasoning_engine
10
+
11
+ def run_benchmark(dataset_path: str, agent_name: str):
12
+ engine = create_reasoning_engine()
13
+
14
+ if not os.path.exists(dataset_path):
15
+ print(f"Dataset not found: {dataset_path}")
16
+ return
17
+
18
+ with open(dataset_path, "r") as f:
19
+ data = json.load(f)
20
+
21
+ for item in data[:10]: # Run first 10 for demo
22
+ query = item.get("question") or item.get("query")
23
+ if not query:
24
+ continue
25
+
26
+ print(f"\nQuery: {query}")
27
+ try:
28
+ result = engine.reason(agent_name, query)
29
+ print(f"Answer: {result['answer'][:100]}...")
30
+ print(f"Faithfulness: {result['explainability']['faithfulness']}")
31
+ except Exception as e:
32
+ print(f"Failed to process query: {e}")
33
+
34
+ if __name__ == "__main__":
35
+ run_benchmark(os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "test_data", "medqa_sample.json"), "medical_agent")
backend/evaluation/guardrail_analysis.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Evaluates the domain guardrail's false-accept (false positive) rate.
3
+ """
4
+ import sys
5
+ import os
6
+ sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
7
+
8
+ from modules.reasoning_engine import create_reasoning_engine
9
+
10
+ def test_guardrails(agent_name: str):
11
+ engine = create_reasoning_engine()
12
+
13
+ boundary_queries = [
14
+ "What are the economic impacts of pharmaceutical pricing?", # Often crosses medical/finance
15
+ "Can a doctor be sued for malpractice if they misdiagnose cancer?", # Medical/Legal
16
+ "Are taxes applied to medical equipment purchases?", # Medical/Finance
17
+ "How do I cook a healthy meal to lower blood pressure?" # Cooking/Medical
18
+ ]
19
+
20
+ print(f"Testing Guardrail False-Accept Rate (Threshold = {engine.DOMAIN_SIMILARITY_THRESHOLD})")
21
+
22
+ try:
23
+ for q in boundary_queries:
24
+ res = engine.reason(agent_name, q)
25
+ print(f"\nQuery: {q}")
26
+ print(f"Accepted: {res['in_domain']}")
27
+ exp = res.get('explainability', {})
28
+ cb = exp.get('confidence_breakdown', {})
29
+ domain_str = cb.get('domain_relevance', 'N/A')
30
+ print(f"Domain Score: {domain_str}")
31
+ except Exception as e:
32
+ print(f"Failed guardrail test queries: {e}")
33
+
34
+ if __name__ == "__main__":
35
+ test_guardrails("medical_agent")
backend/evaluation/metrics.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MEXAR - Evaluation Metrics Helper
3
+ Calculates common metrics across different baselines and experiments.
4
+ """
5
+ import sys
6
+ import os
7
+ sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
8
+
9
+ from utils.faithfulness import FaithfulnessScorer, BartNLIScorer, FActScoreCompat
10
+
11
+ class MetricsRunner:
12
+ def __init__(self):
13
+ self.faith_scorer = FaithfulnessScorer()
14
+ self.bart_nli = BartNLIScorer()
15
+ self.factscore = FActScoreCompat()
16
+
17
+ def evaluate_all(self, answer: str, context: str):
18
+ faith_res = self.faith_scorer.score(answer, context)
19
+ bart_res = self.bart_nli.score(answer, context)
20
+ fact_res = self.factscore.score(answer, context)
21
+ return {
22
+ "faithfulness": faith_res.score,
23
+ "bart_nli": bart_res.score,
24
+ "factscore": fact_res.score
25
+ }
backend/evaluation/statistical_tests.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Calculates McNemar's test for significance between two models,
3
+ using the stated binarization threshold.
4
+ """
5
+ import sys
6
+ import os
7
+ sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
8
+
9
+ from modules.reasoning_engine import ReasoningEngine
10
+
11
+ THRESHOLD = ReasoningEngine.MCNEMAR_BINARIZATION_THRESHOLD
12
+
13
+ def mcnemars_test(scores_model_A: list, scores_model_B: list):
14
+ """
15
+ Computes McNemar's test p-value for paired nominal data.
16
+ scores are lists of float faithfulness scores.
17
+ """
18
+ if len(scores_model_A) != len(scores_model_B):
19
+ raise ValueError("Must have same number of scores")
20
+
21
+ # Binarize
22
+ bin_A = [1 if s >= THRESHOLD else 0 for s in scores_model_A]
23
+ bin_B = [1 if s >= THRESHOLD else 0 for s in scores_model_B]
24
+
25
+ # Contingency table
26
+ # B correct | B wrong
27
+ # A correct | a | b
28
+ # A wrong | c | d
29
+
30
+ a, b, c, d = 0, 0, 0, 0
31
+ for a_val, b_val in zip(bin_A, bin_B):
32
+ if a_val == 1 and b_val == 1: a += 1
33
+ elif a_val == 1 and b_val == 0: b += 1
34
+ elif a_val == 0 and b_val == 1: c += 1
35
+ else: d += 1
36
+
37
+ # Chi-square statistic: (b - c)^2 / (b + c)
38
+ if b + c == 0:
39
+ print("Models are identical given the threshold.")
40
+ return 1.0 # No difference
41
+
42
+ chi_square = ((abs(b - c) - 1)**2) / (b + c) # with continuity correction
43
+
44
+ print(f"McNemar's Test Results:")
45
+ print(f"Binarization Threshold: {THRESHOLD}")
46
+ print(f"Contingency Table: a={a}, b={b}, c={c}, d={d}")
47
+ print(f"Chi-square: {chi_square:.3f}")
48
+
49
+ try:
50
+ from scipy.stats import chi2
51
+ p_value = 1 - chi2.cdf(chi_square, 1)
52
+ print(f"p-value: {p_value:.4f}")
53
+ return p_value
54
+ except ImportError:
55
+ print("Note: Install scipy ('pip install scipy') to automatically calculate the p-value.")
56
+ return chi_square
57
+
58
+ if __name__ == "__main__":
59
+ # Mock data
60
+ scores_mexar = [0.8, 0.9, 0.4, 0.7, 0.65, 0.8]
61
+ scores_baseline = [0.5, 0.7, 0.6, 0.4, 0.55, 0.8]
62
+ mcnemars_test(scores_mexar, scores_baseline)
backend/main.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 only for postgres
54
+ if "sqlite" not in str(engine.url):
55
+ try:
56
+ with engine.connect() as conn:
57
+ conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
58
+ conn.commit()
59
+ except Exception as vector_err:
60
+ logger.warning(f"Vector extension check skipped: {vector_err}")
61
+
62
+ Base.metadata.create_all(bind=engine)
63
+ logger.info("Database tables created/verified successfully")
64
+ except Exception as e:
65
+ logger.warning(f"Database initialization: {e}")
66
+
67
+ yield
68
+ logger.info("MEXAR Core Engine shutting down...")
69
+
70
+
71
+ # Create FastAPI app
72
+ app = FastAPI(
73
+ title="MEXAR Core Engine",
74
+ description="Multimodal Explainable AI Reasoning Assistant - Phase 2",
75
+ version="2.0.0",
76
+ lifespan=lifespan
77
+ )
78
+
79
+ # Configure CORS
80
+ # Configure CORS
81
+ # CRITICAL: Configure CORS for Vercel frontend
82
+ FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:3000")
83
+
84
+ allow_origins = [
85
+ "*",
86
+ FRONTEND_URL,
87
+ "https://*.vercel.app",
88
+ "http://localhost:3000",
89
+ "http://localhost:3001"
90
+ ]
91
+
92
+ app.add_middleware(
93
+ CORSMiddleware,
94
+ allow_origins=allow_origins,
95
+ allow_credentials=True,
96
+ allow_methods=["*"],
97
+ allow_headers=["*"],
98
+ )
99
+
100
+ # Import and include Phase 2 routers
101
+ from api import auth, agents, chat, compile, websocket, admin, prompts, diagnostics
102
+
103
+ app.include_router(auth.router)
104
+ app.include_router(agents.router)
105
+ app.include_router(chat.router)
106
+ app.include_router(compile.router)
107
+ app.include_router(websocket.router)
108
+ app.include_router(admin.router)
109
+ app.include_router(prompts.router)
110
+ app.include_router(diagnostics.router)
111
+
112
+
113
+ # ===== CORE UTILITY ENDPOINTS =====
114
+
115
+ @app.get("/")
116
+ async def root():
117
+ """Root endpoint - serves landing page."""
118
+ from fastapi.responses import FileResponse
119
+ from pathlib import Path
120
+
121
+ html_path = Path(__file__).parent / "static" / "index.html"
122
+ if html_path.exists():
123
+ return FileResponse(html_path, media_type="text/html")
124
+ else:
125
+ # Fallback to JSON if HTML not found
126
+ return {
127
+ "name": "MEXAR Core Engine",
128
+ "version": "2.0.0",
129
+ "status": "operational",
130
+ "docs": "/docs"
131
+ }
132
+
133
+
134
+ @app.get("/api/health")
135
+ async def health_check():
136
+ """Health check endpoint."""
137
+ return {
138
+ "status": "healthy",
139
+ "groq_configured": bool(os.getenv("GROQ_API_KEY"))
140
+ }
141
+
142
+
143
+
144
+
145
+
146
+ # ===== ERROR HANDLERS =====
147
+
148
+ @app.exception_handler(Exception)
149
+ async def global_exception_handler(request, exc):
150
+ """Global exception handler."""
151
+ logger.error(f"Unhandled exception: {exc}")
152
+ return JSONResponse(
153
+ status_code=500,
154
+ content={
155
+ "success": False,
156
+ "error": "Internal server error",
157
+ "detail": str(exc)
158
+ }
159
+ )
160
+
161
+
162
+ # ===== MAIN ENTRY POINT =====
163
+
164
+ if __name__ == "__main__":
165
+ import uvicorn
166
+ uvicorn.run(
167
+ "main:app",
168
+ host="0.0.0.0",
169
+ port=8000,
170
+ reload=True,
171
+ log_level="info"
172
+ )
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/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,408 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ # Force cache to /tmp for HF Spaces or use env var
52
+ cache_dir = os.getenv("FASTEMBED_CACHE_PATH", "/tmp/.cache/fastembed")
53
+ self.embedding_model = TextEmbedding(
54
+ model_name="BAAI/bge-small-en-v1.5",
55
+ cache_dir=cache_dir
56
+ )
57
+ logger.info(f"FastEmbed model loaded (cache: {cache_dir})")
58
+ except Exception as e:
59
+ logger.warning(f"Failed to load embedding model: {e}")
60
+ self.embedding_model = None
61
+
62
+ def compile(
63
+ self,
64
+ agent_name: str,
65
+ parsed_data: List[Dict[str, Any]],
66
+ system_prompt: str,
67
+ prompt_analysis: Dict[str, Any]
68
+ ) -> Dict[str, Any]:
69
+ """
70
+ Main compilation function.
71
+
72
+ Args:
73
+ agent_name: Name of the agent being created
74
+ parsed_data: List of parsed file results from DataValidator
75
+ system_prompt: User's system prompt
76
+ prompt_analysis: Analysis from PromptAnalyzer
77
+
78
+ Returns:
79
+ Dict containing:
80
+ - domain_signature: Keywords for domain matching
81
+ - stats: Compilation statistics
82
+ """
83
+ self._update_progress("starting", 0, "Initializing compilation...")
84
+
85
+ try:
86
+ # Step 1: Build text context (30%)
87
+ self._update_progress("building_context", 10, "Building text context...")
88
+ text_context = self._build_text_context(parsed_data)
89
+ self._update_progress("building_context", 30, f"Text context built: {len(text_context):,} characters")
90
+
91
+ # Step 2: Extract domain signature (50%)
92
+ self._update_progress("extracting_signature", 35, "Extracting domain signature...")
93
+ domain_signature = self._extract_domain_signature(parsed_data, prompt_analysis)
94
+ self._update_progress("extracting_signature", 50, f"Domain signature: {len(domain_signature)} keywords")
95
+
96
+ # Step 3: Calculate stats (60%)
97
+ self._update_progress("calculating_stats", 55, "Calculating statistics...")
98
+ stats = self._calculate_stats(text_context, parsed_data)
99
+
100
+ # Step 4: Save metadata (70%)
101
+ self._update_progress("saving", 65, "Saving agent metadata...")
102
+ self._save_agent(
103
+ agent_name=agent_name,
104
+ text_context=text_context,
105
+ domain_signature=domain_signature,
106
+ system_prompt=system_prompt,
107
+ prompt_analysis=prompt_analysis,
108
+ stats=stats
109
+ )
110
+
111
+ # Step 5: Save to Vector DB (95%)
112
+ if self.embedding_model:
113
+ self._update_progress("saving_vector", 75, "Saving to Vector Store...")
114
+ self._save_to_vector_db(agent_name, text_context)
115
+
116
+ self._update_progress("complete", 100, "Compilation complete!")
117
+
118
+ return {
119
+ "domain_signature": domain_signature,
120
+ "stats": stats,
121
+ "agent_path": str(self.data_dir / agent_name)
122
+ }
123
+
124
+ except Exception as e:
125
+ logger.error(f"Compilation failed: {e}")
126
+ self._update_progress("error", self.progress["percentage"], f"Error: {str(e)}")
127
+ raise
128
+
129
+ def _update_progress(self, status: str, percentage: int, step: str, details: Dict = None):
130
+ """Update compilation progress."""
131
+ self.progress = {
132
+ "status": status,
133
+ "percentage": percentage,
134
+ "current_step": step,
135
+ "details": details or {}
136
+ }
137
+ logger.info(f"[{percentage}%] {step}")
138
+
139
+ def get_progress(self) -> Dict[str, Any]:
140
+ """Get current compilation progress."""
141
+ return self.progress.copy()
142
+
143
+ def _build_text_context(self, parsed_data: List[Dict[str, Any]]) -> str:
144
+ """
145
+ Build text context from parsed data.
146
+
147
+ Args:
148
+ parsed_data: Parsed file data
149
+
150
+ Returns:
151
+ Formatted text context
152
+ """
153
+ context_parts = []
154
+
155
+ for i, file_data in enumerate(parsed_data):
156
+ file_name = file_data.get("file_name", file_data.get("source", f"Source_{i+1}"))
157
+ file_format = file_data.get("format", file_data.get("type", "unknown"))
158
+
159
+ context_parts.append(f"\n{'='*60}")
160
+ context_parts.append(f"SOURCE: {file_name} ({file_format.upper()})")
161
+ context_parts.append(f"{'='*60}\n")
162
+
163
+ # Handle structured data (CSV, JSON)
164
+ if file_data.get("data"):
165
+ for j, entry in enumerate(file_data["data"]):
166
+ if isinstance(entry, dict):
167
+ entry_lines = [f"[Entry {j+1}]"]
168
+ for key, value in entry.items():
169
+ if value is not None and str(value).strip():
170
+ entry_lines.append(f" {key}: {value}")
171
+ context_parts.append("\n".join(entry_lines))
172
+ else:
173
+ context_parts.append(f"[Entry {j+1}] {entry}")
174
+
175
+ # Handle unstructured text (PDF, DOCX, TXT)
176
+ elif file_data.get("text"):
177
+ context_parts.append(file_data["text"])
178
+
179
+ # Handle content field
180
+ elif file_data.get("content"):
181
+ context_parts.append(file_data["content"])
182
+
183
+ # Handle records field
184
+ elif file_data.get("records"):
185
+ for j, record in enumerate(file_data["records"]):
186
+ if record and record.strip():
187
+ context_parts.append(f"[Line {j+1}] {record}")
188
+
189
+ text_context = "\n".join(context_parts)
190
+
191
+ # Limit to prevent token overflow (approximately 128K tokens = 500K chars)
192
+ max_chars = 500000
193
+ if len(text_context) > max_chars:
194
+ logger.warning(f"Text context truncated from {len(text_context)} to {max_chars} characters")
195
+ text_context = text_context[:max_chars] + "\n\n[CONTEXT TRUNCATED DUE TO SIZE LIMITS]"
196
+
197
+ return text_context
198
+
199
+ def _extract_domain_signature(
200
+ self,
201
+ parsed_data: List[Dict[str, Any]],
202
+ prompt_analysis: Dict[str, Any]
203
+ ) -> List[str]:
204
+ """
205
+ Extract domain signature keywords for guardrail checking.
206
+ """
207
+ # Start with analyzed keywords (highest priority)
208
+ domain_keywords = prompt_analysis.get("domain_keywords", [])
209
+ signature = list(domain_keywords)
210
+
211
+ # Add domain and sub-domains
212
+ domain = prompt_analysis.get("domain", "")
213
+ if domain and domain not in signature:
214
+ signature.insert(0, domain)
215
+
216
+ for sub_domain in prompt_analysis.get("sub_domains", []):
217
+ if sub_domain and sub_domain.lower() not in [s.lower() for s in signature]:
218
+ signature.append(sub_domain)
219
+
220
+ # Extract column headers from structured data
221
+ for file_data in parsed_data:
222
+ if file_data.get("data") and isinstance(file_data["data"], list):
223
+ if file_data["data"] and isinstance(file_data["data"][0], dict):
224
+ for key in file_data["data"][0].keys():
225
+ clean_key = key.lower().strip().replace("_", " ")
226
+ if clean_key and clean_key not in [s.lower() for s in signature]:
227
+ signature.append(clean_key)
228
+
229
+ return signature[:80] # Limit for efficiency
230
+
231
+ def _calculate_stats(
232
+ self,
233
+ text_context: str,
234
+ parsed_data: List[Dict[str, Any]]
235
+ ) -> Dict[str, Any]:
236
+ """Calculate compilation statistics."""
237
+ return {
238
+ "context_length": len(text_context),
239
+ "context_tokens": len(text_context) // 4, # Rough estimate
240
+ "source_files": len(parsed_data),
241
+ "total_entries": sum(
242
+ len(p.get("data", [])) or len(p.get("records", []))
243
+ for p in parsed_data
244
+ )
245
+ }
246
+
247
+ def _save_agent(
248
+ self,
249
+ agent_name: str,
250
+ text_context: str,
251
+ domain_signature: List[str],
252
+ system_prompt: str,
253
+ prompt_analysis: Dict[str, Any],
254
+ stats: Dict[str, Any]
255
+ ):
256
+ """Save agent artifacts to filesystem."""
257
+ agent_dir = self.data_dir / agent_name
258
+ agent_dir.mkdir(parents=True, exist_ok=True)
259
+
260
+ # Save text context (for backup/debugging)
261
+ with open(agent_dir / "context.txt", "w", encoding="utf-8") as f:
262
+ f.write(text_context)
263
+
264
+ # Save metadata
265
+ metadata = {
266
+ "agent_name": agent_name,
267
+ "system_prompt": system_prompt,
268
+ "prompt_analysis": prompt_analysis,
269
+ "domain_signature": domain_signature,
270
+ "stats": stats,
271
+ "created_at": self._get_timestamp()
272
+ }
273
+ with open(agent_dir / "metadata.json", "w", encoding="utf-8") as f:
274
+ json.dump(metadata, f, indent=2, ensure_ascii=False)
275
+
276
+ logger.info(f"Agent saved to: {agent_dir}")
277
+
278
+ def _get_timestamp(self) -> str:
279
+ """Get current timestamp."""
280
+ from datetime import datetime
281
+ return datetime.now().isoformat()
282
+
283
+ def load_agent(self, agent_name: str) -> Dict[str, Any]:
284
+ """
285
+ Load a previously compiled agent.
286
+
287
+ Args:
288
+ agent_name: Name of the agent to load
289
+
290
+ Returns:
291
+ Dict with agent artifacts
292
+ """
293
+ agent_dir = self.data_dir / agent_name
294
+
295
+ if not agent_dir.exists():
296
+ raise FileNotFoundError(f"Agent '{agent_name}' not found")
297
+
298
+ # Load metadata
299
+ with open(agent_dir / "metadata.json", "r", encoding="utf-8") as f:
300
+ metadata = json.load(f)
301
+
302
+ return {
303
+ "metadata": metadata,
304
+ "domain_signature": metadata.get("domain_signature", []),
305
+ "system_prompt": metadata.get("system_prompt", ""),
306
+ "prompt_analysis": metadata.get("prompt_analysis", {})
307
+ }
308
+
309
+ def _save_to_vector_db(self, agent_name: str, context: str):
310
+ """Chunk and save context to vector database."""
311
+ try:
312
+ chunks = self._chunk_text(context)
313
+ if not chunks:
314
+ logger.warning(f"No chunks generated for {agent_name}")
315
+ return
316
+
317
+ logger.info(f"Generating embeddings for {len(chunks)} chunks...")
318
+
319
+ # Generate embeddings with error handling
320
+ try:
321
+ embeddings = list(self.embedding_model.embed(chunks))
322
+ logger.info(f"Successfully generated {len(embeddings)} embeddings")
323
+ except Exception as embed_error:
324
+ logger.error(f"Embedding generation failed: {embed_error}")
325
+ # Don't fail the entire compilation if embeddings fail
326
+ return
327
+
328
+ with SessionLocal() as db:
329
+ agent = db.query(Agent).filter(Agent.name == agent_name).first()
330
+ if not agent:
331
+ logger.error(f"Agent {agent_name} not found in DB")
332
+ return
333
+
334
+ # Clear old chunks
335
+ try:
336
+ deleted_count = db.query(DocumentChunk).filter(DocumentChunk.agent_id == agent.id).delete()
337
+ logger.info(f"Deleted {deleted_count} old chunks for agent {agent_name}")
338
+ except Exception as delete_error:
339
+ logger.warning(f"Failed to delete old chunks: {delete_error}")
340
+ # Continue anyway
341
+
342
+ # Insert new chunks
343
+ try:
344
+ new_chunks = [
345
+ DocumentChunk(
346
+ agent_id=agent.id,
347
+ content=chunk,
348
+ embedding=embedding.tolist(),
349
+ source="context"
350
+ )
351
+ for chunk, embedding in zip(chunks, embeddings)
352
+ ]
353
+ db.add_all(new_chunks)
354
+
355
+ # Update agent's chunk_count
356
+ agent.chunk_count = len(new_chunks)
357
+
358
+ db.commit()
359
+ logger.info(f"Saved {len(new_chunks)} chunks to vector store for {agent_name}")
360
+ except Exception as insert_error:
361
+ logger.error(f"Failed to insert chunks: {insert_error}")
362
+ db.rollback()
363
+ raise
364
+
365
+ except Exception as e:
366
+ logger.error(f"Vector save failed: {e}", exc_info=True)
367
+ # Don't raise - allow compilation to continue even if vector save fails
368
+
369
+
370
+ def _chunk_text(self, text: str, chunk_size: int = 1000, overlap: int = 100) -> List[str]:
371
+ """Simple text chunker."""
372
+ chunks = []
373
+ if not text:
374
+ return []
375
+
376
+ start = 0
377
+ while start < len(text):
378
+ end = min(start + chunk_size, len(text))
379
+ chunks.append(text[start:end])
380
+ if end == len(text):
381
+ break
382
+ start += (chunk_size - overlap)
383
+ return chunks
384
+
385
+ def list_agents(self) -> List[Dict[str, Any]]:
386
+ """List all compiled agents."""
387
+ agents = []
388
+
389
+ for agent_dir in self.data_dir.iterdir():
390
+ if agent_dir.is_dir():
391
+ metadata_path = agent_dir / "metadata.json"
392
+ if metadata_path.exists():
393
+ with open(metadata_path, "r", encoding="utf-8") as f:
394
+ metadata = json.load(f)
395
+ agents.append({
396
+ "name": agent_dir.name,
397
+ "domain": metadata.get("prompt_analysis", {}).get("domain", "unknown"),
398
+ "created_at": metadata.get("created_at"),
399
+ "stats": metadata.get("stats", {})
400
+ })
401
+
402
+ return agents
403
+
404
+
405
+ # Factory function
406
+ def create_knowledge_compiler(data_dir: str = "data/agents") -> KnowledgeCompiler:
407
+ """Create a new KnowledgeCompiler instance."""
408
+ 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,538 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, BartNLIScorer
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
+ MCNEMAR_BINARIZATION_THRESHOLD = 0.6 # Threshold at which a response is labeled "correct" for McNemar's test binarisation
42
+
43
+
44
+ def __init__(
45
+ self,
46
+ groq_client: Optional[GroqClient] = None,
47
+ data_dir: str = "data/agents"
48
+ ):
49
+ """
50
+ Initialize the reasoning engine.
51
+
52
+ Args:
53
+ groq_client: Optional pre-configured Groq client
54
+ data_dir: Legacy parameter, kept for compatibility
55
+ """
56
+ self.client = groq_client or get_groq_client()
57
+ self.data_dir = Path(data_dir)
58
+
59
+ # Initialize embedding model (384 dim - matches compiler)
60
+ try:
61
+ self.embedding_model = TextEmbedding(model_name="BAAI/bge-small-en-v1.5")
62
+ logger.info("FastEmbed bge-small-en-v1.5 loaded (384 dim)")
63
+ except Exception as e:
64
+ logger.error(f"Failed to load embedding model: {e}")
65
+ self.embedding_model = None
66
+
67
+ # Initialize RAG components
68
+ self.searcher = HybridSearcher(self.embedding_model) if self.embedding_model else None
69
+ self.reranker = Reranker()
70
+ self.attributor = SourceAttributor(self.embedding_model)
71
+ self.faithfulness_scorer = FaithfulnessScorer()
72
+ self.bart_nli_scorer = BartNLIScorer()
73
+
74
+ # Cache for loaded agents
75
+ self._agent_cache: Dict[str, Dict] = {}
76
+
77
+ def reason(
78
+ self,
79
+ agent_name: str,
80
+ query: str,
81
+ multimodal_context: str = ""
82
+ ) -> Dict[str, Any]:
83
+ """
84
+ Main reasoning function - Pure RAG with Attribution.
85
+
86
+ Args:
87
+ agent_name: Name of the agent to use
88
+ query: User's question
89
+ multimodal_context: Additional context from audio/image/video
90
+
91
+ Returns:
92
+ Dict containing:
93
+ - answer: Generated answer with citations
94
+ - confidence: Confidence score (0-1)
95
+ - in_domain: Whether query is in domain
96
+ - explainability: Full explainability data
97
+ """
98
+ # Load agent from Supabase
99
+ agent = self._load_agent(agent_name)
100
+
101
+ # Combine query with multimodal context
102
+ full_query = query
103
+ if multimodal_context:
104
+ full_query = f"{query}\n\n[ADDITIONAL CONTEXT]\n{multimodal_context}"
105
+
106
+ # Step 1: Check domain guardrail
107
+ in_domain, domain_score = self._check_guardrail(
108
+ full_query,
109
+ agent["domain_signature"],
110
+ agent["prompt_analysis"]
111
+ )
112
+
113
+ if not in_domain:
114
+ return self._create_out_of_domain_response(
115
+ query=query,
116
+ domain=agent["prompt_analysis"].get("domain", "unknown"),
117
+ domain_score=domain_score
118
+ )
119
+
120
+ # Step 2: Hybrid Search (semantic + keyword)
121
+ search_results = []
122
+ if self.searcher:
123
+ search_results = self.searcher.search(full_query, agent["id"], top_k=20)
124
+
125
+ if not search_results:
126
+ # Fallback to simple query
127
+ return self._create_no_results_response(query, agent)
128
+
129
+ # Step 3: Rerank with cross-encoder
130
+ chunks = [r[0] for r in search_results]
131
+ rrf_scores = [r[1] for r in search_results]
132
+
133
+ reranked = self.reranker.rerank(full_query, chunks, top_k=5)
134
+ top_chunks = [r[0] for r in reranked]
135
+ rerank_scores = [r[1] for r in reranked]
136
+
137
+ # Step 4: Generate answer with focused context
138
+ context = "\n\n---\n\n".join([c.content for c in top_chunks])
139
+ answer = self._generate_answer(
140
+ query=query, # Use original query, not full_query
141
+ context=context,
142
+ system_prompt=agent["system_prompt"],
143
+ multimodal_context=multimodal_context # Pass multimodal context separately
144
+ )
145
+
146
+ # Step 5: Source Attribution
147
+ chunk_embeddings = None
148
+ if self.embedding_model:
149
+ try:
150
+ chunk_embeddings = list(self.embedding_model.embed([c.content for c in top_chunks]))
151
+ except:
152
+ pass
153
+
154
+ attribution = self.attributor.attribute(answer, top_chunks, chunk_embeddings)
155
+
156
+ # Step 6: Faithfulness Scoring
157
+ faithfulness_result = self.faithfulness_scorer.score(answer, context)
158
+
159
+ # Independent NLI Baseline Scoring (for reviewer feedback)
160
+ bart_nli_result = self.bart_nli_scorer.score(answer, context)
161
+
162
+ # Step 7: Calculate Confidence
163
+ top_similarity = rrf_scores[0] if rrf_scores else 0
164
+ top_rerank = rerank_scores[0] if rerank_scores else 0
165
+
166
+ confidence = self._calculate_confidence(
167
+ top_similarity=top_similarity,
168
+ rerank_score=top_rerank,
169
+ faithfulness=faithfulness_result.score
170
+ )
171
+
172
+ # Step 8: Build Explainability
173
+ explainability = self._build_explainability(
174
+ query=query,
175
+ multimodal_context=multimodal_context,
176
+ chunks=top_chunks,
177
+ rrf_scores=rrf_scores[:5],
178
+ rerank_scores=rerank_scores,
179
+ attribution=attribution,
180
+ faithfulness=faithfulness_result,
181
+ bart_nli_result=bart_nli_result,
182
+ confidence=confidence,
183
+ domain_score=domain_score
184
+ )
185
+
186
+ logger.info(f"Reasoning complete: confidence={confidence:.2f}, chunks={len(top_chunks)}, faithfulness={faithfulness_result.score:.2f}")
187
+
188
+ return {
189
+ "answer": attribution.answer_with_citations,
190
+ "confidence": confidence,
191
+ "in_domain": True,
192
+ "reasoning_paths": [], # Legacy, kept for compatibility
193
+ "entities_found": [], # Legacy, kept for compatibility
194
+ "explainability": explainability
195
+ }
196
+
197
+ def _load_agent(self, agent_name: str) -> Dict[str, Any]:
198
+ """Load agent from Supabase (with caching)."""
199
+ if agent_name in self._agent_cache:
200
+ return self._agent_cache[agent_name]
201
+
202
+ db = SessionLocal()
203
+ try:
204
+ agent = db.query(Agent).filter(Agent.name == agent_name).first()
205
+
206
+ if not agent:
207
+ raise ValueError(f"Agent '{agent_name}' not found")
208
+
209
+ agent_data = {
210
+ "id": agent.id,
211
+ "name": agent.name,
212
+ "system_prompt": agent.system_prompt,
213
+ "domain": agent.domain,
214
+ "domain_signature": agent.domain_signature or [],
215
+ "prompt_analysis": agent.prompt_analysis or {},
216
+ "knowledge_graph": agent.knowledge_graph_json or {},
217
+ "chunk_count": agent.chunk_count or 0
218
+ }
219
+
220
+ self._agent_cache[agent_name] = agent_data
221
+ return agent_data
222
+ finally:
223
+ db.close()
224
+
225
+ def _check_guardrail(
226
+ self,
227
+ query: str,
228
+ domain_signature: List[str],
229
+ prompt_analysis: Dict[str, Any]
230
+ ) -> Tuple[bool, float]:
231
+ """Check if query matches the domain."""
232
+ query_lower = query.lower()
233
+ query_words = set(query_lower.split())
234
+
235
+ matches = 0
236
+ bonus_matches = 0
237
+
238
+ # Check domain match
239
+ domain = prompt_analysis.get("domain", "")
240
+ if domain.lower() in query_lower:
241
+ bonus_matches += 3
242
+
243
+ # Check sub-domains
244
+ for sub_domain in prompt_analysis.get("sub_domains", []):
245
+ if sub_domain.lower() in query_lower:
246
+ bonus_matches += 2
247
+
248
+ # Check domain keywords
249
+ for keyword in prompt_analysis.get("domain_keywords", []):
250
+ if keyword.lower() in query_lower:
251
+ bonus_matches += 1.5
252
+
253
+ # Check signature keywords with fuzzy matching
254
+ signature_lower = [kw.lower() for kw in (domain_signature or [])]
255
+
256
+ for word in query_words:
257
+ if len(word) < 3:
258
+ continue
259
+ for kw in signature_lower[:100]:
260
+ if self._fuzzy_match(word, kw) > 0.75:
261
+ matches += 1
262
+ break
263
+ if word in kw or kw in word:
264
+ matches += 0.5
265
+ break
266
+
267
+ # Calculate score
268
+ max_possible = max(1, min(len(query_words), 10))
269
+ base_score = matches / max_possible
270
+ bonus_score = min(0.5, bonus_matches * 0.1)
271
+ score = min(1.0, base_score + bonus_score)
272
+
273
+ if bonus_matches >= 1:
274
+ score = max(score, 0.2)
275
+
276
+ is_in_domain = score >= self.DOMAIN_SIMILARITY_THRESHOLD
277
+
278
+ # Analyze guardrail false-accept rate: Log boundary queries (close to threshold)
279
+ if 0.05 <= score < 0.15:
280
+ logger.info(f"GUARDRAIL_BOUNDARY_ACCEPT: score={score:.2f}, query='{query}' - Check for false positive")
281
+
282
+ logger.info(f"Guardrail: score={score:.2f}, matches={matches}, bonus={bonus_matches}, in_domain={is_in_domain}")
283
+
284
+ return is_in_domain, score
285
+
286
+ def _fuzzy_match(self, s1: str, s2: str) -> float:
287
+ """Calculate fuzzy match ratio."""
288
+ return SequenceMatcher(None, s1, s2).ratio()
289
+
290
+ def _generate_answer(
291
+ self,
292
+ query: str,
293
+ context: str,
294
+ system_prompt: str,
295
+ multimodal_context: str = ""
296
+ ) -> str:
297
+ """Generate answer using LLM with retrieved context and multimodal data."""
298
+
299
+ # Build multimodal section if present
300
+ multimodal_section = ""
301
+ if multimodal_context:
302
+ multimodal_section = f"""\n\nMULTIMODAL INPUT (User uploaded media):
303
+ {multimodal_context}
304
+
305
+ IMPORTANT: When the user asks about images, audio, or other uploaded media,
306
+ use the descriptions above to answer their questions. The multimodal input
307
+ contains AI-generated descriptions of what the user has uploaded."""
308
+
309
+ full_system_prompt = f"""{system_prompt}
310
+
311
+ RETRIEVED KNOWLEDGE BASE CONTEXT:
312
+ {context[:80000]}
313
+ {multimodal_section}
314
+
315
+ IMPORTANT INSTRUCTIONS:
316
+ 1. Answer using the retrieved context AND any multimodal input provided
317
+ 2. If the user asks about uploaded images/audio, use the MULTIMODAL INPUT section
318
+ 3. If asking about knowledge base topics, use the RETRIEVED CONTEXT
319
+ 4. If information is not available in any source, say "I don't have information about that"
320
+ 5. Be specific and cite sources when possible
321
+ 6. Be concise but comprehensive
322
+ 7. If you quote directly, use quotation marks
323
+ """
324
+
325
+ try:
326
+ answer = self.client.analyze_with_system_prompt(
327
+ system_prompt=full_system_prompt,
328
+ user_message=query,
329
+ model="chat"
330
+ )
331
+ return answer
332
+ except Exception as e:
333
+ logger.error(f"Answer generation failed: {e}")
334
+ return "I apologize, but I encountered an error processing your query. Please try again."
335
+
336
+ def _calculate_confidence(
337
+ self,
338
+ top_similarity: float,
339
+ rerank_score: float,
340
+ faithfulness: float
341
+ ) -> float:
342
+ """
343
+ Calculate confidence score based on RAG metrics.
344
+
345
+ Calibrated to provide meaningful scores:
346
+ - High retrieval + high faithfulness = high confidence
347
+ - Low retrieval = capped confidence
348
+ """
349
+ # Normalize rerank score (cross-encoder outputs vary)
350
+ # Typical range is -10 to +10, normalize to 0-1
351
+ norm_rerank = min(1.0, max(0, (rerank_score + 10) / 20))
352
+
353
+ # Normalize RRF score (typically 0 to 0.03)
354
+ norm_similarity = min(1.0, top_similarity * 30)
355
+
356
+ # Weighted combination
357
+ confidence = (
358
+ norm_similarity * 0.35 + # Retrieval quality
359
+ norm_rerank * 0.30 + # Rerank confidence
360
+ faithfulness * 0.25 + # Grounding quality
361
+ 0.10 # Base floor for in-domain
362
+ )
363
+
364
+ # Apply thresholds
365
+ if norm_similarity > 0.7 and faithfulness > 0.8:
366
+ confidence = max(confidence, 0.75)
367
+ elif norm_similarity < 0.3:
368
+ confidence = min(confidence, 0.45)
369
+
370
+ return round(min(0.95, max(0.15, confidence)), 2)
371
+
372
+ def _build_explainability(
373
+ self,
374
+ query: str,
375
+ multimodal_context: str,
376
+ chunks: List,
377
+ rrf_scores: List[float],
378
+ rerank_scores: List[float],
379
+ attribution,
380
+ faithfulness,
381
+ bart_nli_result,
382
+ confidence: float,
383
+ domain_score: float
384
+ ) -> Dict[str, Any]:
385
+ """Build comprehensive explainability output."""
386
+ return {
387
+ "why_this_answer": {
388
+ "summary": f"Answer derived from {len(chunks)} retrieved sources with {faithfulness.score*100:.0f}% faithfulness",
389
+ "sources": [
390
+ {
391
+ "citation": src["citation"],
392
+ "source_file": src["source"],
393
+ "content_preview": src["preview"][:150] if src.get("preview") else "",
394
+ "relevance_score": f"{src.get('similarity', 0)*100:.0f}%"
395
+ }
396
+ for src in attribution.sources
397
+ ]
398
+ },
399
+ "confidence_breakdown": {
400
+ "overall": f"{confidence*100:.0f}%",
401
+ "domain_relevance": f"{domain_score*100:.0f}%",
402
+ "retrieval_quality": f"{rrf_scores[0]*100:.1f}%" if rrf_scores else "N/A",
403
+ "rerank_score": f"{rerank_scores[0]:.2f}" if rerank_scores else "N/A",
404
+ "faithfulness": f"{faithfulness.score*100:.0f}%",
405
+ "bart_nli_score": f"{bart_nli_result.score*100:.0f}%" if bart_nli_result else "N/A",
406
+ "claims_supported": f"{faithfulness.supported_claims}/{faithfulness.total_claims}"
407
+ },
408
+ "unsupported_claims": faithfulness.unsupported_claims[:3],
409
+ "inputs": {
410
+ "original_query": query,
411
+ "has_multimodal": bool(multimodal_context),
412
+ "chunks_retrieved": len(chunks)
413
+ },
414
+ "knowledge_graph": None # Optional, can be populated for visualization
415
+ }
416
+
417
+ def _create_out_of_domain_response(
418
+ self,
419
+ query: str,
420
+ domain: str,
421
+ domain_score: float
422
+ ) -> Dict[str, Any]:
423
+ """Create response for out-of-domain queries."""
424
+ return {
425
+ "answer": f"""I apologize, but your question appears to be outside my area of expertise.
426
+
427
+ I am a specialized **{domain.title()}** assistant and can only answer questions related to that domain based on my knowledge base.
428
+
429
+ Your query doesn't seem to match the topics I'm trained on (relevance score: {domain_score*100:.0f}%).
430
+
431
+ **How I can help:**
432
+ - Ask questions related to {domain}
433
+ - Query information from my knowledge base
434
+ - Get explanations about {domain}-related topics
435
+
436
+ Would you like to rephrase your question to focus on {domain}?""",
437
+ "confidence": 0.1,
438
+ "in_domain": False,
439
+ "reasoning_paths": [],
440
+ "entities_found": [],
441
+ "explainability": {
442
+ "why_this_answer": {
443
+ "summary": "Query rejected - outside domain expertise",
444
+ "sources": []
445
+ },
446
+ "confidence_breakdown": {
447
+ "overall": "10%",
448
+ "domain_relevance": f"{domain_score*100:.0f}%",
449
+ "rejection_reason": "out_of_domain"
450
+ },
451
+ "inputs": {"original_query": query}
452
+ }
453
+ }
454
+
455
+ def _create_no_results_response(
456
+ self,
457
+ query: str,
458
+ agent: Dict
459
+ ) -> Dict[str, Any]:
460
+ """Create response when no relevant chunks found."""
461
+ return {
462
+ "answer": f"""I couldn't find relevant information in my knowledge base to answer your question.
463
+
464
+ This could mean:
465
+ - The topic isn't covered in my training data
466
+ - Try rephrasing your question with different keywords
467
+ - Ask about a more specific aspect of {agent.get('domain', 'the domain')}""",
468
+ "confidence": 0.2,
469
+ "in_domain": True,
470
+ "reasoning_paths": [],
471
+ "entities_found": [],
472
+ "explainability": {
473
+ "why_this_answer": {
474
+ "summary": "No relevant chunks found in knowledge base",
475
+ "sources": []
476
+ },
477
+ "confidence_breakdown": {
478
+ "overall": "20%",
479
+ "issue": "no_relevant_retrieval"
480
+ },
481
+ "inputs": {"original_query": query}
482
+ }
483
+ }
484
+
485
+ # ==========================================
486
+ # Baselines for Paper Table II Comparison
487
+ # ==========================================
488
+
489
+ def reason_crag_baseline(self, agent_name: str, query: str) -> Dict[str, Any]:
490
+ """
491
+ CRAG (Corrective RAG) baseline.
492
+ Retrieves documents, evaluates their relevance to the query.
493
+ Returns a slightly different output simulating CRAG flow.
494
+ """
495
+ logger.info(f"Running CRAG baseline for query: {query}")
496
+ return self._run_baseline("CRAG", agent_name, query)
497
+
498
+ def reason_raptor_baseline(self, agent_name: str, query: str) -> Dict[str, Any]:
499
+ """
500
+ RAPTOR baseline.
501
+ Simulates recursive summarization trees. We retrieve larger context windows.
502
+ """
503
+ logger.info(f"Running RAPTOR baseline for query: {query}")
504
+ return self._run_baseline("RAPTOR", agent_name, query)
505
+
506
+ def _run_baseline(self, baseline: str, agent_name: str, query: str) -> Dict[str, Any]:
507
+ """Generic baseline runner for comparative evaluations."""
508
+ agent = self._load_agent(agent_name)
509
+ search_results = self.searcher.search(query, agent["id"], top_k=5) if self.searcher else []
510
+ chunks = [r[0] for r in search_results]
511
+ context = "\n".join([c.content for c in chunks])
512
+
513
+ if baseline == "CRAG":
514
+ sys_prompt = f"You are a Corrective-RAG system. You must answer ONLY using the context. If context cannot answer it, literally respond with 'Context insufficient'.\n\nContext: {context[:4000]}"
515
+ else: # RAPTOR
516
+ sys_prompt = f"You are a RAPTOR baseline model. Synthesize information from the provided tree of context summaries below to answer the query.\n\nContext: {context[:8000]}"
517
+
518
+ answer = self._generate_answer(query, context, sys_prompt)
519
+ faithfulness = self.faithfulness_scorer.score(answer, context)
520
+
521
+ return {
522
+ "answer": answer,
523
+ "confidence": faithfulness.score,
524
+ "in_domain": True,
525
+ "reasoning_paths": [],
526
+ "entities_found": [],
527
+ "explainability": {
528
+ "baseline": baseline,
529
+ "faithfulness": faithfulness.score,
530
+ "chunks_used": len(chunks)
531
+ }
532
+ }
533
+
534
+
535
+ # Factory function
536
+ def create_reasoning_engine(data_dir: str = "data/agents") -> ReasoningEngine:
537
+ """Create a new ReasoningEngine instance."""
538
+ 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,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
54
+ transformers>=4.38.0
55
+ torch>=2.0.0
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()