Spaces:
Sleeping
Sleeping
Merge feature/production-upgrade: HF Spaces deployment with modern UI
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .env.example +61 -0
- .gitattributes +2 -0
- .gitignore +5 -2
- .pre-commit-config.yaml +29 -0
- DEPLOY_HUGGINGFACE.md +203 -0
- Dockerfile +66 -0
- Makefile +137 -0
- README.md +19 -0
- airflow/dags/ingest_pdfs.py +64 -0
- airflow/dags/sop_evolution.py +43 -0
- alembic.ini +149 -0
- alembic/README +1 -0
- alembic/env.py +95 -0
- alembic/script.py.mako +28 -0
- data/vector_stores/medical_knowledge.faiss +3 -0
- data/vector_stores/medical_knowledge.pkl +3 -0
- docker-compose.yml +168 -0
- huggingface/.env.example +21 -0
- huggingface/Dockerfile +66 -0
- huggingface/README.md +109 -0
- huggingface/app.py +1025 -0
- huggingface/requirements.txt +42 -0
- pyproject.toml +117 -0
- scripts/deploy_huggingface.ps1 +139 -0
- src/database.py +50 -0
- src/dependencies.py +36 -0
- src/exceptions.py +149 -0
- src/gradio_app.py +121 -0
- src/llm_config.py +34 -4
- src/main.py +220 -0
- src/repositories/__init__.py +1 -0
- src/repositories/analysis.py +41 -0
- src/repositories/document.py +48 -0
- src/routers/__init__.py +1 -0
- src/routers/analyze.py +88 -0
- src/routers/ask.py +53 -0
- src/routers/health.py +101 -0
- src/routers/search.py +72 -0
- src/schemas/__init__.py +1 -0
- src/schemas/schemas.py +247 -0
- src/services/agents/__init__.py +1 -0
- src/services/agents/agentic_rag.py +158 -0
- src/services/agents/context.py +23 -0
- src/services/agents/medical/__init__.py +1 -0
- src/services/agents/nodes/__init__.py +1 -0
- src/services/agents/nodes/generate_answer_node.py +60 -0
- src/services/agents/nodes/grade_documents_node.py +64 -0
- src/services/agents/nodes/guardrail_node.py +57 -0
- src/services/agents/nodes/out_of_scope_node.py +16 -0
- src/services/agents/nodes/retrieve_node.py +68 -0
.env.example
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ===========================================================================
|
| 2 |
+
# MediGuard AI — Environment Variables
|
| 3 |
+
# ===========================================================================
|
| 4 |
+
# Copy this file to .env and fill in your values.
|
| 5 |
+
# ===========================================================================
|
| 6 |
+
|
| 7 |
+
# --- API ---
|
| 8 |
+
API__HOST=0.0.0.0
|
| 9 |
+
API__PORT=8000
|
| 10 |
+
API__DEBUG=true
|
| 11 |
+
CORS_ALLOWED_ORIGINS=*
|
| 12 |
+
|
| 13 |
+
# --- PostgreSQL ---
|
| 14 |
+
POSTGRES__HOST=localhost
|
| 15 |
+
POSTGRES__PORT=5432
|
| 16 |
+
POSTGRES__DATABASE=mediguard
|
| 17 |
+
POSTGRES__USER=mediguard
|
| 18 |
+
POSTGRES__PASSWORD=mediguard_secret
|
| 19 |
+
|
| 20 |
+
# --- OpenSearch ---
|
| 21 |
+
OPENSEARCH__HOST=localhost
|
| 22 |
+
OPENSEARCH__PORT=9200
|
| 23 |
+
|
| 24 |
+
# --- Redis ---
|
| 25 |
+
REDIS__HOST=localhost
|
| 26 |
+
REDIS__PORT=6379
|
| 27 |
+
REDIS__ENABLED=true
|
| 28 |
+
|
| 29 |
+
# --- Ollama ---
|
| 30 |
+
OLLAMA__BASE_URL=http://localhost:11434
|
| 31 |
+
OLLAMA__MODEL=llama3.2
|
| 32 |
+
|
| 33 |
+
# --- LLM (Groq / Gemini — existing providers) ---
|
| 34 |
+
LLM__PRIMARY_PROVIDER=groq
|
| 35 |
+
LLM__GROQ_API_KEY=
|
| 36 |
+
LLM__GROQ_MODEL=llama-3.3-70b-versatile
|
| 37 |
+
LLM__GEMINI_API_KEY=
|
| 38 |
+
LLM__GEMINI_MODEL=gemini-2.0-flash
|
| 39 |
+
|
| 40 |
+
# --- Embeddings ---
|
| 41 |
+
EMBEDDING__PROVIDER=jina
|
| 42 |
+
EMBEDDING__JINA_API_KEY=
|
| 43 |
+
EMBEDDING__MODEL_NAME=jina-embeddings-v3
|
| 44 |
+
EMBEDDING__DIMENSION=1024
|
| 45 |
+
|
| 46 |
+
# --- Langfuse ---
|
| 47 |
+
LANGFUSE__ENABLED=true
|
| 48 |
+
LANGFUSE__PUBLIC_KEY=
|
| 49 |
+
LANGFUSE__SECRET_KEY=
|
| 50 |
+
LANGFUSE__HOST=http://localhost:3000
|
| 51 |
+
|
| 52 |
+
# --- Chunking ---
|
| 53 |
+
CHUNKING__CHUNK_SIZE=1024
|
| 54 |
+
CHUNKING__CHUNK_OVERLAP=128
|
| 55 |
+
|
| 56 |
+
# --- Telegram Bot (optional) ---
|
| 57 |
+
TELEGRAM__BOT_TOKEN=
|
| 58 |
+
TELEGRAM__API_BASE_URL=http://localhost:8000
|
| 59 |
+
|
| 60 |
+
# --- Medical PDFs ---
|
| 61 |
+
MEDICAL_PDFS__DIRECTORY=data/medical_pdfs
|
.gitattributes
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.faiss filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
.gitignore
CHANGED
|
@@ -221,10 +221,13 @@ $RECYCLE.BIN/
|
|
| 221 |
# Project Specific
|
| 222 |
# ==============================================================================
|
| 223 |
# Vector stores (large files, regenerate locally)
|
|
|
|
| 224 |
data/vector_stores/*.faiss
|
| 225 |
data/vector_stores/*.pkl
|
| 226 |
-
|
| 227 |
-
|
|
|
|
|
|
|
| 228 |
|
| 229 |
# Medical PDFs (proprietary/large)
|
| 230 |
data/medical_pdfs/*.pdf
|
|
|
|
| 221 |
# Project Specific
|
| 222 |
# ==============================================================================
|
| 223 |
# Vector stores (large files, regenerate locally)
|
| 224 |
+
# BUT allow medical_knowledge for HuggingFace deployment
|
| 225 |
data/vector_stores/*.faiss
|
| 226 |
data/vector_stores/*.pkl
|
| 227 |
+
!data/vector_stores/medical_knowledge.faiss
|
| 228 |
+
!data/vector_stores/medical_knowledge.pkl
|
| 229 |
+
# *.faiss # Commented out to allow medical_knowledge
|
| 230 |
+
# *.pkl # Commented out to allow medical_knowledge
|
| 231 |
|
| 232 |
# Medical PDFs (proprietary/large)
|
| 233 |
data/medical_pdfs/*.pdf
|
.pre-commit-config.yaml
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# MediGuard AI — Pre-commit hooks
|
| 2 |
+
# Install: pre-commit install
|
| 3 |
+
# Run all: pre-commit run --all-files
|
| 4 |
+
|
| 5 |
+
repos:
|
| 6 |
+
- repo: https://github.com/pre-commit/pre-commit-hooks
|
| 7 |
+
rev: v4.6.0
|
| 8 |
+
hooks:
|
| 9 |
+
- id: trailing-whitespace
|
| 10 |
+
- id: end-of-file-fixer
|
| 11 |
+
- id: check-yaml
|
| 12 |
+
- id: check-toml
|
| 13 |
+
- id: check-json
|
| 14 |
+
- id: check-merge-conflict
|
| 15 |
+
- id: detect-private-key
|
| 16 |
+
|
| 17 |
+
- repo: https://github.com/astral-sh/ruff-pre-commit
|
| 18 |
+
rev: v0.7.0
|
| 19 |
+
hooks:
|
| 20 |
+
- id: ruff
|
| 21 |
+
args: [--fix]
|
| 22 |
+
- id: ruff-format
|
| 23 |
+
|
| 24 |
+
- repo: https://github.com/pre-commit/mirrors-mypy
|
| 25 |
+
rev: v1.12.0
|
| 26 |
+
hooks:
|
| 27 |
+
- id: mypy
|
| 28 |
+
additional_dependencies: [pydantic>=2.0]
|
| 29 |
+
args: [--ignore-missing-imports]
|
DEPLOY_HUGGINGFACE.md
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 🚀 Deploy MediGuard AI to Hugging Face Spaces
|
| 2 |
+
|
| 3 |
+
This guide walks you through deploying MediGuard AI to Hugging Face Spaces using Docker.
|
| 4 |
+
|
| 5 |
+
## Prerequisites
|
| 6 |
+
|
| 7 |
+
1. **Hugging Face Account** — [Sign up free](https://huggingface.co/join)
|
| 8 |
+
2. **Git** — Installed on your machine
|
| 9 |
+
3. **API Key** — Either:
|
| 10 |
+
- **Groq** (recommended) — [Get free key](https://console.groq.com/keys)
|
| 11 |
+
- **Google Gemini** — [Get free key](https://aistudio.google.com/app/apikey)
|
| 12 |
+
|
| 13 |
+
## Step 1: Create a New Space
|
| 14 |
+
|
| 15 |
+
1. Go to [huggingface.co/new-space](https://huggingface.co/new-space)
|
| 16 |
+
2. Fill in:
|
| 17 |
+
- **Space name**: `mediguard-ai` (or your choice)
|
| 18 |
+
- **License**: MIT
|
| 19 |
+
- **SDK**: Select **Docker**
|
| 20 |
+
- **Hardware**: **CPU Basic** (free tier works!)
|
| 21 |
+
3. Click **Create Space**
|
| 22 |
+
|
| 23 |
+
## Step 2: Clone Your Space
|
| 24 |
+
|
| 25 |
+
```bash
|
| 26 |
+
# Clone the empty space
|
| 27 |
+
git clone https://huggingface.co/spaces/YOUR_USERNAME/mediguard-ai
|
| 28 |
+
cd mediguard-ai
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
## Step 3: Copy Project Files
|
| 32 |
+
|
| 33 |
+
Copy all files from this repository to your space folder:
|
| 34 |
+
|
| 35 |
+
```bash
|
| 36 |
+
# Option A: If you have the RagBot repo locally
|
| 37 |
+
cp -r /path/to/RagBot/* .
|
| 38 |
+
|
| 39 |
+
# Option B: Clone fresh
|
| 40 |
+
git clone https://github.com/yourusername/ragbot temp
|
| 41 |
+
cp -r temp/* .
|
| 42 |
+
rm -rf temp
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
## Step 4: Set Up Dockerfile for Spaces
|
| 46 |
+
|
| 47 |
+
Hugging Face Spaces expects the Dockerfile in the root. Copy the HF-optimized Dockerfile:
|
| 48 |
+
|
| 49 |
+
```bash
|
| 50 |
+
# Copy the HF Spaces Dockerfile to root
|
| 51 |
+
cp huggingface/Dockerfile ./Dockerfile
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
**Or** update your root `Dockerfile` to match the HF Spaces version.
|
| 55 |
+
|
| 56 |
+
## Step 5: Set Up README (Important!)
|
| 57 |
+
|
| 58 |
+
The README.md must have the HF Spaces metadata header. Copy the HF README:
|
| 59 |
+
|
| 60 |
+
```bash
|
| 61 |
+
# Backup original README
|
| 62 |
+
mv README.md README_original.md
|
| 63 |
+
|
| 64 |
+
# Use HF Spaces README
|
| 65 |
+
cp huggingface/README.md ./README.md
|
| 66 |
+
```
|
| 67 |
+
|
| 68 |
+
## Step 6: Add Your API Key (Secret)
|
| 69 |
+
|
| 70 |
+
1. Go to your Space: `https://huggingface.co/spaces/YOUR_USERNAME/mediguard-ai`
|
| 71 |
+
2. Click **Settings** tab
|
| 72 |
+
3. Scroll to **Repository Secrets**
|
| 73 |
+
4. Add a new secret:
|
| 74 |
+
- **Name**: `GROQ_API_KEY` (or `GOOGLE_API_KEY`)
|
| 75 |
+
- **Value**: Your API key
|
| 76 |
+
5. Click **Add**
|
| 77 |
+
|
| 78 |
+
## Step 7: Push to Deploy
|
| 79 |
+
|
| 80 |
+
```bash
|
| 81 |
+
# Add all files
|
| 82 |
+
git add .
|
| 83 |
+
|
| 84 |
+
# Commit
|
| 85 |
+
git commit -m "Deploy MediGuard AI"
|
| 86 |
+
|
| 87 |
+
# Push to Hugging Face
|
| 88 |
+
git push
|
| 89 |
+
```
|
| 90 |
+
|
| 91 |
+
## Step 8: Monitor Deployment
|
| 92 |
+
|
| 93 |
+
1. Go to your Space: `https://huggingface.co/spaces/YOUR_USERNAME/mediguard-ai`
|
| 94 |
+
2. Click the **Logs** tab to watch the build
|
| 95 |
+
3. Build takes ~5-10 minutes (first time)
|
| 96 |
+
4. Once "Running", your app is live! 🎉
|
| 97 |
+
|
| 98 |
+
## 🔧 Troubleshooting
|
| 99 |
+
|
| 100 |
+
### "No LLM API key configured"
|
| 101 |
+
|
| 102 |
+
- Make sure you added `GROQ_API_KEY` or `GOOGLE_API_KEY` in Space Settings → Secrets
|
| 103 |
+
- Secret names are case-sensitive
|
| 104 |
+
|
| 105 |
+
### Build fails with "No space disk"
|
| 106 |
+
|
| 107 |
+
- Hugging Face free tier has limited disk space
|
| 108 |
+
- The FAISS vector store might be too large
|
| 109 |
+
- Solution: Upgrade to a paid tier or reduce vector store size
|
| 110 |
+
|
| 111 |
+
### "ModuleNotFoundError"
|
| 112 |
+
|
| 113 |
+
- Check that all dependencies are in `huggingface/requirements.txt`
|
| 114 |
+
- The Dockerfile should install from this file
|
| 115 |
+
|
| 116 |
+
### App crashes on startup
|
| 117 |
+
|
| 118 |
+
- Check Logs for the actual error
|
| 119 |
+
- Common issue: Missing environment variables
|
| 120 |
+
- Increase Space hardware if OOM error
|
| 121 |
+
|
| 122 |
+
## 📁 File Structure for Deployment
|
| 123 |
+
|
| 124 |
+
Your Space should have this structure:
|
| 125 |
+
|
| 126 |
+
```
|
| 127 |
+
your-space/
|
| 128 |
+
├── Dockerfile # HF Spaces Dockerfile (from huggingface/)
|
| 129 |
+
├── README.md # HF Spaces README with metadata
|
| 130 |
+
├── huggingface/
|
| 131 |
+
│ ├── app.py # Standalone Gradio app
|
| 132 |
+
│ ├── requirements.txt # Minimal deps for HF
|
| 133 |
+
│ └── README.md # Original HF README
|
| 134 |
+
├── src/ # Core application code
|
| 135 |
+
│ ├── workflow.py
|
| 136 |
+
│ ├── state.py
|
| 137 |
+
│ ├── llm_config.py
|
| 138 |
+
│ ├── pdf_processor.py
|
| 139 |
+
│ ├── agents/
|
| 140 |
+
│ └── ...
|
| 141 |
+
├── data/
|
| 142 |
+
│ └── vector_stores/
|
| 143 |
+
│ ├── medical_knowledge.faiss
|
| 144 |
+
│ └── medical_knowledge.pkl
|
| 145 |
+
└── config/
|
| 146 |
+
└── biomarker_references.json
|
| 147 |
+
```
|
| 148 |
+
|
| 149 |
+
## 🔄 Updating Your Space
|
| 150 |
+
|
| 151 |
+
To update after making changes:
|
| 152 |
+
|
| 153 |
+
```bash
|
| 154 |
+
git add .
|
| 155 |
+
git commit -m "Update: description of changes"
|
| 156 |
+
git push
|
| 157 |
+
```
|
| 158 |
+
|
| 159 |
+
Hugging Face will automatically rebuild and redeploy.
|
| 160 |
+
|
| 161 |
+
## 💰 Hardware Options
|
| 162 |
+
|
| 163 |
+
| Tier | RAM | vCPU | Cost | Best For |
|
| 164 |
+
|------|-----|------|------|----------|
|
| 165 |
+
| CPU Basic | 2GB | 2 | Free | Demo/Testing |
|
| 166 |
+
| CPU Upgrade | 8GB | 4 | ~$0.03/hr | Production |
|
| 167 |
+
| T4 Small | 16GB | 4 | ~$0.06/hr | Heavy usage |
|
| 168 |
+
|
| 169 |
+
The free tier works for demos. Upgrade if you experience timeouts.
|
| 170 |
+
|
| 171 |
+
## 🎉 Your Space is Live!
|
| 172 |
+
|
| 173 |
+
Once deployed, share your Space URL:
|
| 174 |
+
|
| 175 |
+
```
|
| 176 |
+
https://huggingface.co/spaces/YOUR_USERNAME/mediguard-ai
|
| 177 |
+
```
|
| 178 |
+
|
| 179 |
+
Anyone can now use MediGuard AI without any setup!
|
| 180 |
+
|
| 181 |
+
---
|
| 182 |
+
|
| 183 |
+
## Quick Commands Reference
|
| 184 |
+
|
| 185 |
+
```bash
|
| 186 |
+
# Clone your space
|
| 187 |
+
git clone https://huggingface.co/spaces/YOUR_USERNAME/mediguard-ai
|
| 188 |
+
|
| 189 |
+
# Set up remote (if needed)
|
| 190 |
+
git remote add origin https://huggingface.co/spaces/YOUR_USERNAME/mediguard-ai
|
| 191 |
+
|
| 192 |
+
# Push changes
|
| 193 |
+
git push origin main
|
| 194 |
+
|
| 195 |
+
# Force rebuild (if stuck)
|
| 196 |
+
# Go to Settings → Factory Reset
|
| 197 |
+
```
|
| 198 |
+
|
| 199 |
+
## Need Help?
|
| 200 |
+
|
| 201 |
+
- [Hugging Face Spaces Docs](https://huggingface.co/docs/hub/spaces)
|
| 202 |
+
- [Docker on Spaces](https://huggingface.co/docs/hub/spaces-sdks-docker)
|
| 203 |
+
- [Spaces Secrets](https://huggingface.co/docs/hub/spaces-secrets)
|
Dockerfile
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ===========================================================================
|
| 2 |
+
# MediGuard AI — Hugging Face Spaces Dockerfile
|
| 3 |
+
# ===========================================================================
|
| 4 |
+
# Optimized single-container deployment for Hugging Face Spaces.
|
| 5 |
+
# Uses FAISS vector store + Cloud LLMs (Groq/Gemini) - no external services.
|
| 6 |
+
# ===========================================================================
|
| 7 |
+
|
| 8 |
+
FROM python:3.11-slim
|
| 9 |
+
|
| 10 |
+
# Non-interactive apt
|
| 11 |
+
ENV DEBIAN_FRONTEND=noninteractive
|
| 12 |
+
|
| 13 |
+
# Python settings
|
| 14 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 15 |
+
PYTHONUNBUFFERED=1 \
|
| 16 |
+
PIP_NO_CACHE_DIR=1 \
|
| 17 |
+
PIP_DISABLE_PIP_VERSION_CHECK=1
|
| 18 |
+
|
| 19 |
+
# HuggingFace Spaces runs on port 7860
|
| 20 |
+
ENV GRADIO_SERVER_NAME="0.0.0.0" \
|
| 21 |
+
GRADIO_SERVER_PORT=7860
|
| 22 |
+
|
| 23 |
+
# Default to HuggingFace embeddings (local, no API key needed)
|
| 24 |
+
ENV EMBEDDING_PROVIDER=huggingface
|
| 25 |
+
|
| 26 |
+
WORKDIR /app
|
| 27 |
+
|
| 28 |
+
# System dependencies
|
| 29 |
+
RUN apt-get update && \
|
| 30 |
+
apt-get install -y --no-install-recommends \
|
| 31 |
+
build-essential \
|
| 32 |
+
curl \
|
| 33 |
+
git \
|
| 34 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 35 |
+
|
| 36 |
+
# Copy requirements first (cache layer)
|
| 37 |
+
COPY huggingface/requirements.txt ./requirements.txt
|
| 38 |
+
RUN pip install --upgrade pip && \
|
| 39 |
+
pip install -r requirements.txt
|
| 40 |
+
|
| 41 |
+
# Copy the entire project
|
| 42 |
+
COPY . .
|
| 43 |
+
|
| 44 |
+
# Create necessary directories and ensure vector store exists
|
| 45 |
+
RUN mkdir -p data/medical_pdfs data/vector_stores data/chat_reports
|
| 46 |
+
|
| 47 |
+
# Create non-root user (HF Spaces requirement)
|
| 48 |
+
RUN useradd -m -u 1000 user
|
| 49 |
+
|
| 50 |
+
# Make app writable by user
|
| 51 |
+
RUN chown -R user:user /app
|
| 52 |
+
|
| 53 |
+
USER user
|
| 54 |
+
ENV HOME=/home/user \
|
| 55 |
+
PATH=/home/user/.local/bin:$PATH
|
| 56 |
+
|
| 57 |
+
WORKDIR /app
|
| 58 |
+
|
| 59 |
+
EXPOSE 7860
|
| 60 |
+
|
| 61 |
+
# Health check
|
| 62 |
+
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
|
| 63 |
+
CMD curl -sf http://localhost:7860/ || exit 1
|
| 64 |
+
|
| 65 |
+
# Launch Gradio app
|
| 66 |
+
CMD ["python", "huggingface/app.py"]
|
Makefile
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ===========================================================================
|
| 2 |
+
# MediGuard AI — Makefile
|
| 3 |
+
# ===========================================================================
|
| 4 |
+
# Usage:
|
| 5 |
+
# make help — show all targets
|
| 6 |
+
# make setup — install deps + pre-commit hooks
|
| 7 |
+
# make dev — run API in dev mode with reload
|
| 8 |
+
# make test — run full test suite
|
| 9 |
+
# make lint — ruff check + mypy
|
| 10 |
+
# make docker-up — spin up all Docker services
|
| 11 |
+
# make docker-down — tear down Docker services
|
| 12 |
+
# ===========================================================================
|
| 13 |
+
|
| 14 |
+
.DEFAULT_GOAL := help
|
| 15 |
+
SHELL := /bin/bash
|
| 16 |
+
|
| 17 |
+
# Python / UV
|
| 18 |
+
PYTHON ?= python
|
| 19 |
+
UV ?= uv
|
| 20 |
+
PIP ?= pip
|
| 21 |
+
|
| 22 |
+
# Docker
|
| 23 |
+
COMPOSE := docker compose
|
| 24 |
+
|
| 25 |
+
# ---------------------------------------------------------------------------
|
| 26 |
+
# Help
|
| 27 |
+
# ---------------------------------------------------------------------------
|
| 28 |
+
.PHONY: help
|
| 29 |
+
help: ## Show this help
|
| 30 |
+
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
|
| 31 |
+
|
| 32 |
+
# ---------------------------------------------------------------------------
|
| 33 |
+
# Setup
|
| 34 |
+
# ---------------------------------------------------------------------------
|
| 35 |
+
.PHONY: setup
|
| 36 |
+
setup: ## Install all deps (pip) + pre-commit hooks
|
| 37 |
+
$(PIP) install -e ".[all]"
|
| 38 |
+
pre-commit install
|
| 39 |
+
|
| 40 |
+
.PHONY: setup-uv
|
| 41 |
+
setup-uv: ## Install all deps with UV
|
| 42 |
+
$(UV) pip install -e ".[all]"
|
| 43 |
+
pre-commit install
|
| 44 |
+
|
| 45 |
+
# ---------------------------------------------------------------------------
|
| 46 |
+
# Development
|
| 47 |
+
# ---------------------------------------------------------------------------
|
| 48 |
+
.PHONY: dev
|
| 49 |
+
dev: ## Run API in dev mode (auto-reload)
|
| 50 |
+
uvicorn src.main:app --host 0.0.0.0 --port 8000 --reload
|
| 51 |
+
|
| 52 |
+
.PHONY: gradio
|
| 53 |
+
gradio: ## Launch Gradio web UI
|
| 54 |
+
$(PYTHON) -m src.gradio_app
|
| 55 |
+
|
| 56 |
+
.PHONY: telegram
|
| 57 |
+
telegram: ## Start Telegram bot
|
| 58 |
+
$(PYTHON) -c "from src.services.telegram.bot import MediGuardTelegramBot; MediGuardTelegramBot().run()"
|
| 59 |
+
|
| 60 |
+
# ---------------------------------------------------------------------------
|
| 61 |
+
# Quality
|
| 62 |
+
# ---------------------------------------------------------------------------
|
| 63 |
+
.PHONY: lint
|
| 64 |
+
lint: ## Ruff check + MyPy
|
| 65 |
+
ruff check src/ tests/
|
| 66 |
+
mypy src/ --ignore-missing-imports
|
| 67 |
+
|
| 68 |
+
.PHONY: format
|
| 69 |
+
format: ## Ruff format
|
| 70 |
+
ruff format src/ tests/
|
| 71 |
+
ruff check --fix src/ tests/
|
| 72 |
+
|
| 73 |
+
.PHONY: test
|
| 74 |
+
test: ## Run pytest with coverage
|
| 75 |
+
pytest tests/ -v --tb=short --cov=src --cov-report=term-missing
|
| 76 |
+
|
| 77 |
+
.PHONY: test-quick
|
| 78 |
+
test-quick: ## Run only fast unit tests
|
| 79 |
+
pytest tests/ -v --tb=short -m "not slow"
|
| 80 |
+
|
| 81 |
+
# ---------------------------------------------------------------------------
|
| 82 |
+
# Docker
|
| 83 |
+
# ---------------------------------------------------------------------------
|
| 84 |
+
.PHONY: docker-up
|
| 85 |
+
docker-up: ## Start all Docker services (detached)
|
| 86 |
+
$(COMPOSE) up -d
|
| 87 |
+
|
| 88 |
+
.PHONY: docker-down
|
| 89 |
+
docker-down: ## Stop and remove Docker services
|
| 90 |
+
$(COMPOSE) down -v
|
| 91 |
+
|
| 92 |
+
.PHONY: docker-build
|
| 93 |
+
docker-build: ## Build Docker images
|
| 94 |
+
$(COMPOSE) build
|
| 95 |
+
|
| 96 |
+
.PHONY: docker-logs
|
| 97 |
+
docker-logs: ## Tail Docker logs
|
| 98 |
+
$(COMPOSE) logs -f
|
| 99 |
+
|
| 100 |
+
# ---------------------------------------------------------------------------
|
| 101 |
+
# Database
|
| 102 |
+
# ---------------------------------------------------------------------------
|
| 103 |
+
.PHONY: db-upgrade
|
| 104 |
+
db-upgrade: ## Run Alembic migrations
|
| 105 |
+
alembic upgrade head
|
| 106 |
+
|
| 107 |
+
.PHONY: db-revision
|
| 108 |
+
db-revision: ## Create a new Alembic migration
|
| 109 |
+
alembic revision --autogenerate -m "$(msg)"
|
| 110 |
+
|
| 111 |
+
# ---------------------------------------------------------------------------
|
| 112 |
+
# Indexing
|
| 113 |
+
# ---------------------------------------------------------------------------
|
| 114 |
+
.PHONY: index-pdfs
|
| 115 |
+
index-pdfs: ## Parse and index all medical PDFs
|
| 116 |
+
$(PYTHON) -c "\
|
| 117 |
+
from pathlib import Path; \
|
| 118 |
+
from src.services.pdf_parser.service import make_pdf_parser_service; \
|
| 119 |
+
from src.services.indexing.service import IndexingService; \
|
| 120 |
+
from src.services.embeddings.service import make_embedding_service; \
|
| 121 |
+
from src.services.opensearch.client import make_opensearch_client; \
|
| 122 |
+
parser = make_pdf_parser_service(); \
|
| 123 |
+
idx = IndexingService(make_embedding_service(), make_opensearch_client()); \
|
| 124 |
+
docs = parser.parse_directory(Path('data/medical_pdfs')); \
|
| 125 |
+
[idx.index_text(d.full_text, {'title': d.filename}) for d in docs if d.full_text]; \
|
| 126 |
+
print(f'Indexed {len(docs)} documents')"
|
| 127 |
+
|
| 128 |
+
# ---------------------------------------------------------------------------
|
| 129 |
+
# Clean
|
| 130 |
+
# ---------------------------------------------------------------------------
|
| 131 |
+
.PHONY: clean
|
| 132 |
+
clean: ## Remove build artifacts and caches
|
| 133 |
+
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
| 134 |
+
find . -type d -name .pytest_cache -exec rm -rf {} + 2>/dev/null || true
|
| 135 |
+
find . -type d -name .mypy_cache -exec rm -rf {} + 2>/dev/null || true
|
| 136 |
+
find . -type d -name .ruff_cache -exec rm -rf {} + 2>/dev/null || true
|
| 137 |
+
rm -rf dist/ build/ *.egg-info
|
README.md
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
# RagBot: Multi-Agent RAG System for Medical Biomarker Analysis
|
| 2 |
|
| 3 |
A production-ready biomarker analysis system combining 6 specialized AI agents with medical knowledge retrieval to provide evidence-based insights on blood test results in **15-25 seconds**.
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Agentic RagBot
|
| 3 |
+
emoji: 🏥
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: true
|
| 8 |
+
license: mit
|
| 9 |
+
app_port: 7860
|
| 10 |
+
tags:
|
| 11 |
+
- medical
|
| 12 |
+
- biomarker
|
| 13 |
+
- rag
|
| 14 |
+
- healthcare
|
| 15 |
+
- langgraph
|
| 16 |
+
- agents
|
| 17 |
+
short_description: Multi-Agent RAG System for Medical Biomarker Analysis
|
| 18 |
+
---
|
| 19 |
+
|
| 20 |
# RagBot: Multi-Agent RAG System for Medical Biomarker Analysis
|
| 21 |
|
| 22 |
A production-ready biomarker analysis system combining 6 specialized AI agents with medical knowledge retrieval to provide evidence-based insights on blood test results in **15-25 seconds**.
|
airflow/dags/ingest_pdfs.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MediGuard AI — Airflow DAG: Ingest Medical PDFs
|
| 3 |
+
|
| 4 |
+
Periodically scans the medical_pdfs directory, parses new PDFs,
|
| 5 |
+
chunks them, generates embeddings, and indexes into OpenSearch.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from datetime import datetime, timedelta
|
| 11 |
+
|
| 12 |
+
from airflow import DAG
|
| 13 |
+
from airflow.operators.python import PythonOperator
|
| 14 |
+
|
| 15 |
+
default_args = {
|
| 16 |
+
"owner": "mediguard",
|
| 17 |
+
"retries": 2,
|
| 18 |
+
"retry_delay": timedelta(minutes=5),
|
| 19 |
+
"email_on_failure": False,
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _ingest_pdfs(**kwargs):
|
| 24 |
+
"""Parse all PDFs and index into OpenSearch."""
|
| 25 |
+
from pathlib import Path
|
| 26 |
+
|
| 27 |
+
from src.services.embeddings.service import make_embedding_service
|
| 28 |
+
from src.services.indexing.service import IndexingService
|
| 29 |
+
from src.services.opensearch.client import make_opensearch_client
|
| 30 |
+
from src.services.pdf_parser.service import make_pdf_parser_service
|
| 31 |
+
from src.settings import get_settings
|
| 32 |
+
|
| 33 |
+
settings = get_settings()
|
| 34 |
+
pdf_dir = Path(settings.medical_pdfs.directory)
|
| 35 |
+
|
| 36 |
+
parser = make_pdf_parser_service()
|
| 37 |
+
embedding_svc = make_embedding_service()
|
| 38 |
+
os_client = make_opensearch_client()
|
| 39 |
+
indexing_svc = IndexingService(embedding_svc, os_client)
|
| 40 |
+
|
| 41 |
+
docs = parser.parse_directory(pdf_dir)
|
| 42 |
+
indexed = 0
|
| 43 |
+
for doc in docs:
|
| 44 |
+
if doc.full_text and not doc.error:
|
| 45 |
+
indexing_svc.index_text(doc.full_text, {"title": doc.filename})
|
| 46 |
+
indexed += 1
|
| 47 |
+
|
| 48 |
+
print(f"Ingested {indexed}/{len(docs)} documents")
|
| 49 |
+
return {"total": len(docs), "indexed": indexed}
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
with DAG(
|
| 53 |
+
dag_id="mediguard_ingest_pdfs",
|
| 54 |
+
default_args=default_args,
|
| 55 |
+
description="Parse and index medical PDFs into OpenSearch",
|
| 56 |
+
schedule="@daily",
|
| 57 |
+
start_date=datetime(2025, 1, 1),
|
| 58 |
+
catchup=False,
|
| 59 |
+
tags=["mediguard", "indexing"],
|
| 60 |
+
) as dag:
|
| 61 |
+
ingest = PythonOperator(
|
| 62 |
+
task_id="ingest_medical_pdfs",
|
| 63 |
+
python_callable=_ingest_pdfs,
|
| 64 |
+
)
|
airflow/dags/sop_evolution.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MediGuard AI — Airflow DAG: SOP Evolution Cycle
|
| 3 |
+
|
| 4 |
+
Runs the evolutionary SOP optimisation loop periodically.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from datetime import datetime, timedelta
|
| 10 |
+
|
| 11 |
+
from airflow import DAG
|
| 12 |
+
from airflow.operators.python import PythonOperator
|
| 13 |
+
|
| 14 |
+
default_args = {
|
| 15 |
+
"owner": "mediguard",
|
| 16 |
+
"retries": 1,
|
| 17 |
+
"retry_delay": timedelta(minutes=10),
|
| 18 |
+
"email_on_failure": False,
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _run_evolution(**kwargs):
|
| 23 |
+
"""Execute one SOP evolution cycle."""
|
| 24 |
+
from src.evolution.director import run_evolution_cycle
|
| 25 |
+
|
| 26 |
+
result = run_evolution_cycle()
|
| 27 |
+
print(f"Evolution cycle complete: {result}")
|
| 28 |
+
return result
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
with DAG(
|
| 32 |
+
dag_id="mediguard_sop_evolution",
|
| 33 |
+
default_args=default_args,
|
| 34 |
+
description="Run SOP evolutionary optimisation",
|
| 35 |
+
schedule="@weekly",
|
| 36 |
+
start_date=datetime(2025, 1, 1),
|
| 37 |
+
catchup=False,
|
| 38 |
+
tags=["mediguard", "evolution"],
|
| 39 |
+
) as dag:
|
| 40 |
+
evolve = PythonOperator(
|
| 41 |
+
task_id="run_sop_evolution",
|
| 42 |
+
python_callable=_run_evolution,
|
| 43 |
+
)
|
alembic.ini
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# A generic, single database configuration.
|
| 2 |
+
|
| 3 |
+
[alembic]
|
| 4 |
+
# path to migration scripts.
|
| 5 |
+
# this is typically a path given in POSIX (e.g. forward slashes)
|
| 6 |
+
# format, relative to the token %(here)s which refers to the location of this
|
| 7 |
+
# ini file
|
| 8 |
+
script_location = %(here)s/alembic
|
| 9 |
+
|
| 10 |
+
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
| 11 |
+
# Uncomment the line below if you want the files to be prepended with date and time
|
| 12 |
+
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
| 13 |
+
# for all available tokens
|
| 14 |
+
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
| 15 |
+
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
|
| 16 |
+
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
|
| 17 |
+
|
| 18 |
+
# sys.path path, will be prepended to sys.path if present.
|
| 19 |
+
# defaults to the current working directory. for multiple paths, the path separator
|
| 20 |
+
# is defined by "path_separator" below.
|
| 21 |
+
prepend_sys_path = .
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# timezone to use when rendering the date within the migration file
|
| 25 |
+
# as well as the filename.
|
| 26 |
+
# If specified, requires the tzdata library which can be installed by adding
|
| 27 |
+
# `alembic[tz]` to the pip requirements.
|
| 28 |
+
# string value is passed to ZoneInfo()
|
| 29 |
+
# leave blank for localtime
|
| 30 |
+
# timezone =
|
| 31 |
+
|
| 32 |
+
# max length of characters to apply to the "slug" field
|
| 33 |
+
# truncate_slug_length = 40
|
| 34 |
+
|
| 35 |
+
# set to 'true' to run the environment during
|
| 36 |
+
# the 'revision' command, regardless of autogenerate
|
| 37 |
+
# revision_environment = false
|
| 38 |
+
|
| 39 |
+
# set to 'true' to allow .pyc and .pyo files without
|
| 40 |
+
# a source .py file to be detected as revisions in the
|
| 41 |
+
# versions/ directory
|
| 42 |
+
# sourceless = false
|
| 43 |
+
|
| 44 |
+
# version location specification; This defaults
|
| 45 |
+
# to <script_location>/versions. When using multiple version
|
| 46 |
+
# directories, initial revisions must be specified with --version-path.
|
| 47 |
+
# The path separator used here should be the separator specified by "path_separator"
|
| 48 |
+
# below.
|
| 49 |
+
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
| 50 |
+
|
| 51 |
+
# path_separator; This indicates what character is used to split lists of file
|
| 52 |
+
# paths, including version_locations and prepend_sys_path within configparser
|
| 53 |
+
# files such as alembic.ini.
|
| 54 |
+
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
| 55 |
+
# to provide os-dependent path splitting.
|
| 56 |
+
#
|
| 57 |
+
# Note that in order to support legacy alembic.ini files, this default does NOT
|
| 58 |
+
# take place if path_separator is not present in alembic.ini. If this
|
| 59 |
+
# option is omitted entirely, fallback logic is as follows:
|
| 60 |
+
#
|
| 61 |
+
# 1. Parsing of the version_locations option falls back to using the legacy
|
| 62 |
+
# "version_path_separator" key, which if absent then falls back to the legacy
|
| 63 |
+
# behavior of splitting on spaces and/or commas.
|
| 64 |
+
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
| 65 |
+
# behavior of splitting on spaces, commas, or colons.
|
| 66 |
+
#
|
| 67 |
+
# Valid values for path_separator are:
|
| 68 |
+
#
|
| 69 |
+
# path_separator = :
|
| 70 |
+
# path_separator = ;
|
| 71 |
+
# path_separator = space
|
| 72 |
+
# path_separator = newline
|
| 73 |
+
#
|
| 74 |
+
# Use os.pathsep. Default configuration used for new projects.
|
| 75 |
+
path_separator = os
|
| 76 |
+
|
| 77 |
+
# set to 'true' to search source files recursively
|
| 78 |
+
# in each "version_locations" directory
|
| 79 |
+
# new in Alembic version 1.10
|
| 80 |
+
# recursive_version_locations = false
|
| 81 |
+
|
| 82 |
+
# the output encoding used when revision files
|
| 83 |
+
# are written from script.py.mako
|
| 84 |
+
# output_encoding = utf-8
|
| 85 |
+
|
| 86 |
+
# database URL. This is consumed by the user-maintained env.py script only.
|
| 87 |
+
# other means of configuring database URLs may be customized within the env.py
|
| 88 |
+
# file.
|
| 89 |
+
sqlalchemy.url = driver://user:pass@localhost/dbname
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
[post_write_hooks]
|
| 93 |
+
# post_write_hooks defines scripts or Python functions that are run
|
| 94 |
+
# on newly generated revision scripts. See the documentation for further
|
| 95 |
+
# detail and examples
|
| 96 |
+
|
| 97 |
+
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
| 98 |
+
# hooks = black
|
| 99 |
+
# black.type = console_scripts
|
| 100 |
+
# black.entrypoint = black
|
| 101 |
+
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
| 102 |
+
|
| 103 |
+
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
| 104 |
+
# hooks = ruff
|
| 105 |
+
# ruff.type = module
|
| 106 |
+
# ruff.module = ruff
|
| 107 |
+
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
| 108 |
+
|
| 109 |
+
# Alternatively, use the exec runner to execute a binary found on your PATH
|
| 110 |
+
# hooks = ruff
|
| 111 |
+
# ruff.type = exec
|
| 112 |
+
# ruff.executable = ruff
|
| 113 |
+
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
| 114 |
+
|
| 115 |
+
# Logging configuration. This is also consumed by the user-maintained
|
| 116 |
+
# env.py script only.
|
| 117 |
+
[loggers]
|
| 118 |
+
keys = root,sqlalchemy,alembic
|
| 119 |
+
|
| 120 |
+
[handlers]
|
| 121 |
+
keys = console
|
| 122 |
+
|
| 123 |
+
[formatters]
|
| 124 |
+
keys = generic
|
| 125 |
+
|
| 126 |
+
[logger_root]
|
| 127 |
+
level = WARNING
|
| 128 |
+
handlers = console
|
| 129 |
+
qualname =
|
| 130 |
+
|
| 131 |
+
[logger_sqlalchemy]
|
| 132 |
+
level = WARNING
|
| 133 |
+
handlers =
|
| 134 |
+
qualname = sqlalchemy.engine
|
| 135 |
+
|
| 136 |
+
[logger_alembic]
|
| 137 |
+
level = INFO
|
| 138 |
+
handlers =
|
| 139 |
+
qualname = alembic
|
| 140 |
+
|
| 141 |
+
[handler_console]
|
| 142 |
+
class = StreamHandler
|
| 143 |
+
args = (sys.stderr,)
|
| 144 |
+
level = NOTSET
|
| 145 |
+
formatter = generic
|
| 146 |
+
|
| 147 |
+
[formatter_generic]
|
| 148 |
+
format = %(levelname)-5.5s [%(name)s] %(message)s
|
| 149 |
+
datefmt = %H:%M:%S
|
alembic/README
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
Generic single-database configuration.
|
alembic/env.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from logging.config import fileConfig
|
| 2 |
+
|
| 3 |
+
from sqlalchemy import engine_from_config
|
| 4 |
+
from sqlalchemy import pool, create_engine
|
| 5 |
+
|
| 6 |
+
from alembic import context
|
| 7 |
+
|
| 8 |
+
# ---------------------------------------------------------------------------
|
| 9 |
+
# MediGuard AI — Alembic env.py
|
| 10 |
+
# Pull DB URL from settings so we never hard-code credentials.
|
| 11 |
+
# ---------------------------------------------------------------------------
|
| 12 |
+
import sys
|
| 13 |
+
import os
|
| 14 |
+
|
| 15 |
+
# Make sure the project root is on sys.path
|
| 16 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
| 17 |
+
|
| 18 |
+
from src.settings import get_settings # noqa: E402
|
| 19 |
+
from src.database import Base # noqa: E402
|
| 20 |
+
|
| 21 |
+
# Import all models so Alembic's autogenerate can see them
|
| 22 |
+
import src.models.analysis # noqa: F401, E402
|
| 23 |
+
|
| 24 |
+
# this is the Alembic Config object, which provides
|
| 25 |
+
# access to the values within the .ini file in use.
|
| 26 |
+
config = context.config
|
| 27 |
+
|
| 28 |
+
# Interpret the config file for Python logging.
|
| 29 |
+
# This line sets up loggers basically.
|
| 30 |
+
if config.config_file_name is not None:
|
| 31 |
+
fileConfig(config.config_file_name)
|
| 32 |
+
|
| 33 |
+
# Override sqlalchemy.url from our Pydantic Settings
|
| 34 |
+
_settings = get_settings()
|
| 35 |
+
config.set_main_option("sqlalchemy.url", _settings.postgres.database_url)
|
| 36 |
+
|
| 37 |
+
# Metadata used for autogenerate
|
| 38 |
+
target_metadata = Base.metadata
|
| 39 |
+
|
| 40 |
+
# other values from the config, defined by the needs of env.py,
|
| 41 |
+
# can be acquired:
|
| 42 |
+
# my_important_option = config.get_main_option("my_important_option")
|
| 43 |
+
# ... etc.
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def run_migrations_offline() -> None:
|
| 47 |
+
"""Run migrations in 'offline' mode.
|
| 48 |
+
|
| 49 |
+
This configures the context with just a URL
|
| 50 |
+
and not an Engine, though an Engine is acceptable
|
| 51 |
+
here as well. By skipping the Engine creation
|
| 52 |
+
we don't even need a DBAPI to be available.
|
| 53 |
+
|
| 54 |
+
Calls to context.execute() here emit the given string to the
|
| 55 |
+
script output.
|
| 56 |
+
|
| 57 |
+
"""
|
| 58 |
+
url = config.get_main_option("sqlalchemy.url")
|
| 59 |
+
context.configure(
|
| 60 |
+
url=url,
|
| 61 |
+
target_metadata=target_metadata,
|
| 62 |
+
literal_binds=True,
|
| 63 |
+
dialect_opts={"paramstyle": "named"},
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
with context.begin_transaction():
|
| 67 |
+
context.run_migrations()
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def run_migrations_online() -> None:
|
| 71 |
+
"""Run migrations in 'online' mode.
|
| 72 |
+
|
| 73 |
+
In this scenario we need to create an Engine
|
| 74 |
+
and associate a connection with the context.
|
| 75 |
+
|
| 76 |
+
"""
|
| 77 |
+
connectable = engine_from_config(
|
| 78 |
+
config.get_section(config.config_ini_section, {}),
|
| 79 |
+
prefix="sqlalchemy.",
|
| 80 |
+
poolclass=pool.NullPool,
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
with connectable.connect() as connection:
|
| 84 |
+
context.configure(
|
| 85 |
+
connection=connection, target_metadata=target_metadata
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
with context.begin_transaction():
|
| 89 |
+
context.run_migrations()
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
if context.is_offline_mode():
|
| 93 |
+
run_migrations_offline()
|
| 94 |
+
else:
|
| 95 |
+
run_migrations_online()
|
alembic/script.py.mako
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""${message}
|
| 2 |
+
|
| 3 |
+
Revision ID: ${up_revision}
|
| 4 |
+
Revises: ${down_revision | comma,n}
|
| 5 |
+
Create Date: ${create_date}
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from typing import Sequence, Union
|
| 9 |
+
|
| 10 |
+
from alembic import op
|
| 11 |
+
import sqlalchemy as sa
|
| 12 |
+
${imports if imports else ""}
|
| 13 |
+
|
| 14 |
+
# revision identifiers, used by Alembic.
|
| 15 |
+
revision: str = ${repr(up_revision)}
|
| 16 |
+
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
| 17 |
+
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
| 18 |
+
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def upgrade() -> None:
|
| 22 |
+
"""Upgrade schema."""
|
| 23 |
+
${upgrades if upgrades else "pass"}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def downgrade() -> None:
|
| 27 |
+
"""Downgrade schema."""
|
| 28 |
+
${downgrades if downgrades else "pass"}
|
data/vector_stores/medical_knowledge.faiss
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:e9dee84846c00eda0f0a5487b61c2dd9cc85588ee0cbbcb576df24e8881969e1
|
| 3 |
+
size 4007469
|
data/vector_stores/medical_knowledge.pkl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:690fa693a48c3eb5e0a1fc11b7008a9037630928d9c8a634a31e7f90d8e2f7fb
|
| 3 |
+
size 2727206
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ===========================================================================
|
| 2 |
+
# MediGuard AI — Docker Compose (development / CI)
|
| 3 |
+
# ===========================================================================
|
| 4 |
+
# Usage:
|
| 5 |
+
# docker compose up -d — start all services
|
| 6 |
+
# docker compose down -v — stop and remove volumes
|
| 7 |
+
# docker compose logs -f api — follow API logs
|
| 8 |
+
# ===========================================================================
|
| 9 |
+
|
| 10 |
+
services:
|
| 11 |
+
# -----------------------------------------------------------------------
|
| 12 |
+
# Application
|
| 13 |
+
# -----------------------------------------------------------------------
|
| 14 |
+
api:
|
| 15 |
+
build:
|
| 16 |
+
context: .
|
| 17 |
+
dockerfile: Dockerfile
|
| 18 |
+
target: production
|
| 19 |
+
container_name: mediguard-api
|
| 20 |
+
ports:
|
| 21 |
+
- "${API_PORT:-8000}:8000"
|
| 22 |
+
env_file: .env
|
| 23 |
+
environment:
|
| 24 |
+
- POSTGRES__HOST=postgres
|
| 25 |
+
- OPENSEARCH__HOST=opensearch
|
| 26 |
+
- OPENSEARCH__PORT=9200
|
| 27 |
+
- REDIS__HOST=redis
|
| 28 |
+
- REDIS__PORT=6379
|
| 29 |
+
- OLLAMA__BASE_URL=http://ollama:11434
|
| 30 |
+
- LANGFUSE__HOST=http://langfuse:3000
|
| 31 |
+
depends_on:
|
| 32 |
+
postgres:
|
| 33 |
+
condition: service_healthy
|
| 34 |
+
opensearch:
|
| 35 |
+
condition: service_healthy
|
| 36 |
+
redis:
|
| 37 |
+
condition: service_healthy
|
| 38 |
+
volumes:
|
| 39 |
+
- ./data/medical_pdfs:/app/data/medical_pdfs:ro
|
| 40 |
+
restart: unless-stopped
|
| 41 |
+
|
| 42 |
+
gradio:
|
| 43 |
+
build:
|
| 44 |
+
context: .
|
| 45 |
+
dockerfile: Dockerfile
|
| 46 |
+
target: production
|
| 47 |
+
container_name: mediguard-gradio
|
| 48 |
+
command: python -m src.gradio_app
|
| 49 |
+
ports:
|
| 50 |
+
- "${GRADIO_PORT:-7860}:7860"
|
| 51 |
+
environment:
|
| 52 |
+
- MEDIGUARD_API_URL=http://api:8000
|
| 53 |
+
depends_on:
|
| 54 |
+
- api
|
| 55 |
+
restart: unless-stopped
|
| 56 |
+
|
| 57 |
+
# -----------------------------------------------------------------------
|
| 58 |
+
# Backing services
|
| 59 |
+
# -----------------------------------------------------------------------
|
| 60 |
+
postgres:
|
| 61 |
+
image: postgres:16-alpine
|
| 62 |
+
container_name: mediguard-postgres
|
| 63 |
+
environment:
|
| 64 |
+
POSTGRES_DB: ${POSTGRES__DATABASE:-mediguard}
|
| 65 |
+
POSTGRES_USER: ${POSTGRES__USER:-mediguard}
|
| 66 |
+
POSTGRES_PASSWORD: ${POSTGRES__PASSWORD:-mediguard_secret}
|
| 67 |
+
ports:
|
| 68 |
+
- "${POSTGRES_PORT:-5432}:5432"
|
| 69 |
+
volumes:
|
| 70 |
+
- pg_data:/var/lib/postgresql/data
|
| 71 |
+
healthcheck:
|
| 72 |
+
test: ["CMD-SHELL", "pg_isready -U mediguard"]
|
| 73 |
+
interval: 5s
|
| 74 |
+
timeout: 3s
|
| 75 |
+
retries: 10
|
| 76 |
+
restart: unless-stopped
|
| 77 |
+
|
| 78 |
+
opensearch:
|
| 79 |
+
image: opensearchproject/opensearch:2.11.1
|
| 80 |
+
container_name: mediguard-opensearch
|
| 81 |
+
environment:
|
| 82 |
+
- discovery.type=single-node
|
| 83 |
+
- DISABLE_SECURITY_PLUGIN=true
|
| 84 |
+
- plugins.security.disabled=true
|
| 85 |
+
- "OPENSEARCH_JAVA_OPTS=-Xms256m -Xmx256m"
|
| 86 |
+
- bootstrap.memory_lock=true
|
| 87 |
+
ulimits:
|
| 88 |
+
memlock: { soft: -1, hard: -1 }
|
| 89 |
+
nofile: { soft: 65536, hard: 65536 }
|
| 90 |
+
ports:
|
| 91 |
+
- "${OPENSEARCH_PORT:-9200}:9200"
|
| 92 |
+
volumes:
|
| 93 |
+
- os_data:/usr/share/opensearch/data
|
| 94 |
+
healthcheck:
|
| 95 |
+
test: ["CMD-SHELL", "curl -sf http://localhost:9200/_cluster/health || exit 1"]
|
| 96 |
+
interval: 10s
|
| 97 |
+
timeout: 5s
|
| 98 |
+
retries: 24
|
| 99 |
+
restart: unless-stopped
|
| 100 |
+
|
| 101 |
+
# opensearch-dashboards: disabled by default — uncomment if you need the UI
|
| 102 |
+
# opensearch-dashboards:
|
| 103 |
+
# image: opensearchproject/opensearch-dashboards:2.11.1
|
| 104 |
+
# container_name: mediguard-os-dashboards
|
| 105 |
+
# environment:
|
| 106 |
+
# - OPENSEARCH_HOSTS=["http://opensearch:9200"]
|
| 107 |
+
# - DISABLE_SECURITY_DASHBOARDS_PLUGIN=true
|
| 108 |
+
# ports:
|
| 109 |
+
# - "${OS_DASHBOARDS_PORT:-5601}:5601"
|
| 110 |
+
# depends_on:
|
| 111 |
+
# opensearch:
|
| 112 |
+
# condition: service_healthy
|
| 113 |
+
# restart: unless-stopped
|
| 114 |
+
|
| 115 |
+
redis:
|
| 116 |
+
image: redis:7-alpine
|
| 117 |
+
container_name: mediguard-redis
|
| 118 |
+
ports:
|
| 119 |
+
- "${REDIS_PORT:-6379}:6379"
|
| 120 |
+
volumes:
|
| 121 |
+
- redis_data:/data
|
| 122 |
+
healthcheck:
|
| 123 |
+
test: ["CMD", "redis-cli", "ping"]
|
| 124 |
+
interval: 5s
|
| 125 |
+
timeout: 3s
|
| 126 |
+
retries: 10
|
| 127 |
+
restart: unless-stopped
|
| 128 |
+
|
| 129 |
+
ollama:
|
| 130 |
+
image: ollama/ollama:latest
|
| 131 |
+
container_name: mediguard-ollama
|
| 132 |
+
ports:
|
| 133 |
+
- "${OLLAMA_PORT:-11434}:11434"
|
| 134 |
+
volumes:
|
| 135 |
+
- ollama_data:/root/.ollama
|
| 136 |
+
restart: unless-stopped
|
| 137 |
+
# Uncomment for GPU support:
|
| 138 |
+
# deploy:
|
| 139 |
+
# resources:
|
| 140 |
+
# reservations:
|
| 141 |
+
# devices:
|
| 142 |
+
# - driver: nvidia
|
| 143 |
+
# count: 1
|
| 144 |
+
# capabilities: [gpu]
|
| 145 |
+
|
| 146 |
+
# -----------------------------------------------------------------------
|
| 147 |
+
# Observability
|
| 148 |
+
# -----------------------------------------------------------------------
|
| 149 |
+
langfuse:
|
| 150 |
+
image: langfuse/langfuse:2
|
| 151 |
+
container_name: mediguard-langfuse
|
| 152 |
+
environment:
|
| 153 |
+
- DATABASE_URL=postgresql://mediguard:mediguard_secret@postgres:5432/langfuse
|
| 154 |
+
- NEXTAUTH_URL=http://localhost:3000
|
| 155 |
+
- NEXTAUTH_SECRET=mediguard-langfuse-secret-change-me
|
| 156 |
+
- SALT=mediguard-langfuse-salt-change-me
|
| 157 |
+
ports:
|
| 158 |
+
- "${LANGFUSE_PORT:-3000}:3000"
|
| 159 |
+
depends_on:
|
| 160 |
+
postgres:
|
| 161 |
+
condition: service_healthy
|
| 162 |
+
restart: unless-stopped
|
| 163 |
+
|
| 164 |
+
volumes:
|
| 165 |
+
pg_data:
|
| 166 |
+
os_data:
|
| 167 |
+
redis_data:
|
| 168 |
+
ollama_data:
|
huggingface/.env.example
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ===========================================================================
|
| 2 |
+
# MediGuard AI — HuggingFace Spaces Environment Variables
|
| 3 |
+
# ===========================================================================
|
| 4 |
+
# MINIMAL config for HuggingFace Spaces deployment.
|
| 5 |
+
# Only the LLM API key is required — everything else has sensible defaults.
|
| 6 |
+
# ===========================================================================
|
| 7 |
+
|
| 8 |
+
# --- LLM Provider (choose ONE) ---
|
| 9 |
+
# Option 1: Groq (RECOMMENDED - fast, free)
|
| 10 |
+
GROQ_API_KEY=your_groq_api_key_here
|
| 11 |
+
|
| 12 |
+
# Option 2: Google Gemini (alternative free option)
|
| 13 |
+
# GOOGLE_API_KEY=your_google_api_key_here
|
| 14 |
+
|
| 15 |
+
# --- Provider Selection (auto-detected from keys) ---
|
| 16 |
+
LLM_PROVIDER=groq
|
| 17 |
+
|
| 18 |
+
# --- Embedding Provider (must match vector store) ---
|
| 19 |
+
# The bundled vector store uses HuggingFace embeddings (384 dim)
|
| 20 |
+
# DO NOT CHANGE THIS unless you rebuild the vector store!
|
| 21 |
+
EMBEDDING_PROVIDER=huggingface
|
huggingface/Dockerfile
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ===========================================================================
|
| 2 |
+
# MediGuard AI — Hugging Face Spaces Dockerfile
|
| 3 |
+
# ===========================================================================
|
| 4 |
+
# Optimized single-container deployment for Hugging Face Spaces.
|
| 5 |
+
# Uses FAISS vector store + Cloud LLMs (Groq/Gemini) - no external services.
|
| 6 |
+
# ===========================================================================
|
| 7 |
+
|
| 8 |
+
FROM python:3.11-slim
|
| 9 |
+
|
| 10 |
+
# Non-interactive apt
|
| 11 |
+
ENV DEBIAN_FRONTEND=noninteractive
|
| 12 |
+
|
| 13 |
+
# Python settings
|
| 14 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 15 |
+
PYTHONUNBUFFERED=1 \
|
| 16 |
+
PIP_NO_CACHE_DIR=1 \
|
| 17 |
+
PIP_DISABLE_PIP_VERSION_CHECK=1
|
| 18 |
+
|
| 19 |
+
# HuggingFace Spaces runs on port 7860
|
| 20 |
+
ENV GRADIO_SERVER_NAME="0.0.0.0" \
|
| 21 |
+
GRADIO_SERVER_PORT=7860
|
| 22 |
+
|
| 23 |
+
# Default to HuggingFace embeddings (local, no API key needed)
|
| 24 |
+
ENV EMBEDDING_PROVIDER=huggingface
|
| 25 |
+
|
| 26 |
+
WORKDIR /app
|
| 27 |
+
|
| 28 |
+
# System dependencies
|
| 29 |
+
RUN apt-get update && \
|
| 30 |
+
apt-get install -y --no-install-recommends \
|
| 31 |
+
build-essential \
|
| 32 |
+
curl \
|
| 33 |
+
git \
|
| 34 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 35 |
+
|
| 36 |
+
# Copy requirements first (cache layer)
|
| 37 |
+
COPY huggingface/requirements.txt ./requirements.txt
|
| 38 |
+
RUN pip install --upgrade pip && \
|
| 39 |
+
pip install -r requirements.txt
|
| 40 |
+
|
| 41 |
+
# Copy the entire project
|
| 42 |
+
COPY . .
|
| 43 |
+
|
| 44 |
+
# Create necessary directories and ensure vector store exists
|
| 45 |
+
RUN mkdir -p data/medical_pdfs data/vector_stores data/chat_reports
|
| 46 |
+
|
| 47 |
+
# Create non-root user (HF Spaces requirement)
|
| 48 |
+
RUN useradd -m -u 1000 user
|
| 49 |
+
|
| 50 |
+
# Make app writable by user
|
| 51 |
+
RUN chown -R user:user /app
|
| 52 |
+
|
| 53 |
+
USER user
|
| 54 |
+
ENV HOME=/home/user \
|
| 55 |
+
PATH=/home/user/.local/bin:$PATH
|
| 56 |
+
|
| 57 |
+
WORKDIR /app
|
| 58 |
+
|
| 59 |
+
EXPOSE 7860
|
| 60 |
+
|
| 61 |
+
# Health check
|
| 62 |
+
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
|
| 63 |
+
CMD curl -sf http://localhost:7860/ || exit 1
|
| 64 |
+
|
| 65 |
+
# Launch Gradio app
|
| 66 |
+
CMD ["python", "huggingface/app.py"]
|
huggingface/README.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Agentic RagBot
|
| 3 |
+
emoji: 🏥
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: true
|
| 8 |
+
license: mit
|
| 9 |
+
app_port: 7860
|
| 10 |
+
tags:
|
| 11 |
+
- medical
|
| 12 |
+
- biomarker
|
| 13 |
+
- rag
|
| 14 |
+
- healthcare
|
| 15 |
+
- langgraph
|
| 16 |
+
- agents
|
| 17 |
+
short_description: Multi-Agent RAG System for Medical Biomarker Analysis
|
| 18 |
+
---
|
| 19 |
+
|
| 20 |
+
# 🏥 MediGuard AI — Medical Biomarker Analysis
|
| 21 |
+
|
| 22 |
+
A production-ready **Multi-Agent RAG System** that analyzes blood test biomarkers using 6 specialized AI agents with medical knowledge retrieval.
|
| 23 |
+
|
| 24 |
+
## ✨ Features
|
| 25 |
+
|
| 26 |
+
- **6 Specialist AI Agents** — Biomarker validation, disease prediction, RAG-powered analysis, confidence assessment
|
| 27 |
+
- **Medical Knowledge Base** — 750+ pages of clinical guidelines (FAISS vector store)
|
| 28 |
+
- **Evidence-Based** — All recommendations backed by retrieved medical literature
|
| 29 |
+
- **Free Cloud LLMs** — Uses Groq (LLaMA 3.3-70B) or Google Gemini
|
| 30 |
+
|
| 31 |
+
## 🚀 Quick Start
|
| 32 |
+
|
| 33 |
+
1. **Enter your biomarkers** in any format:
|
| 34 |
+
- `Glucose: 140, HbA1c: 7.5`
|
| 35 |
+
- `My glucose is 140 and HbA1c is 7.5`
|
| 36 |
+
- `{"Glucose": 140, "HbA1c": 7.5}`
|
| 37 |
+
|
| 38 |
+
2. **Click Analyze** and get:
|
| 39 |
+
- Primary diagnosis with confidence score
|
| 40 |
+
- Critical alerts and safety flags
|
| 41 |
+
- Biomarker analysis with normal ranges
|
| 42 |
+
- Evidence-based recommendations
|
| 43 |
+
- Disease pathophysiology explanation
|
| 44 |
+
|
| 45 |
+
## 🔧 Configuration
|
| 46 |
+
|
| 47 |
+
This Space requires an LLM API key. Add one of these secrets in Space Settings:
|
| 48 |
+
|
| 49 |
+
| Secret | Provider | Get Free Key |
|
| 50 |
+
|--------|----------|--------------|
|
| 51 |
+
| `GROQ_API_KEY` | Groq (recommended) | [console.groq.com/keys](https://console.groq.com/keys) |
|
| 52 |
+
| `GOOGLE_API_KEY` | Google Gemini | [aistudio.google.com](https://aistudio.google.com/app/apikey) |
|
| 53 |
+
|
| 54 |
+
## 🏗️ Architecture
|
| 55 |
+
|
| 56 |
+
```
|
| 57 |
+
┌─────────────────────────────────────────────────────────┐
|
| 58 |
+
│ Clinical Insight Guild │
|
| 59 |
+
├─────────────────────────────────────────────────────────┤
|
| 60 |
+
│ ┌───────────────────────────────────────────────────┐ │
|
| 61 |
+
│ │ 1. Biomarker Analyzer │ │
|
| 62 |
+
│ │ Validates values, flags abnormalities │ │
|
| 63 |
+
│ └───────────────────┬───────────────────────────────┘ │
|
| 64 |
+
│ │ │
|
| 65 |
+
│ ┌────────────┼────────────┐ │
|
| 66 |
+
│ ▼ ▼ ▼ │
|
| 67 |
+
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
| 68 |
+
│ │ Disease │ │Biomarker │ │ Clinical │ │
|
| 69 |
+
│ │Explainer │ │ Linker │ │Guidelines│ │
|
| 70 |
+
│ │ (RAG) │ │ │ │ (RAG) │ │
|
| 71 |
+
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
|
| 72 |
+
│ │ │ │ │
|
| 73 |
+
│ └────────────┼────────────┘ │
|
| 74 |
+
│ ▼ │
|
| 75 |
+
│ ┌───────────────────────────────────────────────────┐ │
|
| 76 |
+
│ │ 4. Confidence Assessor │ │
|
| 77 |
+
│ │ Evaluates reliability, assigns scores │ │
|
| 78 |
+
│ └───────────────────┬───────────────────────────────┘ │
|
| 79 |
+
│ ▼ │
|
| 80 |
+
│ ┌───────────────────────────────────────────────────┐ │
|
| 81 |
+
│ │ 5. Response Synthesizer │ │
|
| 82 |
+
│ │ Compiles patient-friendly summary │ │
|
| 83 |
+
│ └───────────────────────────────────────────────────┘ │
|
| 84 |
+
└─────────────────────────────────────────────────────────┘
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
## 📊 Supported Biomarkers
|
| 88 |
+
|
| 89 |
+
| Category | Biomarkers |
|
| 90 |
+
|----------|------------|
|
| 91 |
+
| **Diabetes** | Glucose, HbA1c, Fasting Glucose, Insulin |
|
| 92 |
+
| **Lipids** | Cholesterol, LDL, HDL, Triglycerides |
|
| 93 |
+
| **Kidney** | Creatinine, BUN, eGFR |
|
| 94 |
+
| **Liver** | ALT, AST, Bilirubin, Albumin |
|
| 95 |
+
| **Thyroid** | TSH, T3, T4, Free T4 |
|
| 96 |
+
| **Blood** | Hemoglobin, WBC, RBC, Platelets |
|
| 97 |
+
| **Cardiac** | Troponin, BNP, CRP |
|
| 98 |
+
|
| 99 |
+
## ⚠️ Medical Disclaimer
|
| 100 |
+
|
| 101 |
+
This tool is for **informational purposes only** and does not replace professional medical advice, diagnosis, or treatment. Always consult a qualified healthcare provider with questions regarding a medical condition.
|
| 102 |
+
|
| 103 |
+
## 📄 License
|
| 104 |
+
|
| 105 |
+
MIT License — See [GitHub Repository](https://github.com/yourusername/ragbot) for details.
|
| 106 |
+
|
| 107 |
+
## 🙏 Acknowledgments
|
| 108 |
+
|
| 109 |
+
Built with [LangGraph](https://langchain-ai.github.io/langgraph/), [FAISS](https://faiss.ai/), [Gradio](https://gradio.app/), and [Groq](https://groq.com/).
|
huggingface/app.py
ADDED
|
@@ -0,0 +1,1025 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MediGuard AI — Hugging Face Spaces Gradio App
|
| 3 |
+
|
| 4 |
+
Standalone deployment that uses:
|
| 5 |
+
- FAISS vector store (local)
|
| 6 |
+
- Cloud LLMs (Groq or Gemini - FREE tiers)
|
| 7 |
+
- No external services required
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import json
|
| 13 |
+
import logging
|
| 14 |
+
import os
|
| 15 |
+
import sys
|
| 16 |
+
import time
|
| 17 |
+
import traceback
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
from typing import Any, Optional
|
| 20 |
+
|
| 21 |
+
# Ensure project root is in path
|
| 22 |
+
_project_root = str(Path(__file__).parent.parent)
|
| 23 |
+
if _project_root not in sys.path:
|
| 24 |
+
sys.path.insert(0, _project_root)
|
| 25 |
+
os.chdir(_project_root)
|
| 26 |
+
|
| 27 |
+
import gradio as gr
|
| 28 |
+
|
| 29 |
+
logging.basicConfig(
|
| 30 |
+
level=logging.INFO,
|
| 31 |
+
format="%(asctime)s | %(name)-20s | %(levelname)-7s | %(message)s",
|
| 32 |
+
)
|
| 33 |
+
logger = logging.getLogger("mediguard.huggingface")
|
| 34 |
+
|
| 35 |
+
# ---------------------------------------------------------------------------
|
| 36 |
+
# Configuration
|
| 37 |
+
# ---------------------------------------------------------------------------
|
| 38 |
+
|
| 39 |
+
def get_api_keys():
|
| 40 |
+
"""Get API keys dynamically (HuggingFace injects secrets after module load)."""
|
| 41 |
+
groq_key = os.getenv("GROQ_API_KEY", "")
|
| 42 |
+
google_key = os.getenv("GOOGLE_API_KEY", "")
|
| 43 |
+
return groq_key, google_key
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def setup_llm_provider():
|
| 47 |
+
"""Set LLM provider based on available keys."""
|
| 48 |
+
groq_key, google_key = get_api_keys()
|
| 49 |
+
|
| 50 |
+
if groq_key:
|
| 51 |
+
os.environ["LLM_PROVIDER"] = "groq"
|
| 52 |
+
os.environ["GROQ_API_KEY"] = groq_key # Ensure it's set
|
| 53 |
+
return "groq"
|
| 54 |
+
elif google_key:
|
| 55 |
+
os.environ["LLM_PROVIDER"] = "gemini"
|
| 56 |
+
os.environ["GOOGLE_API_KEY"] = google_key
|
| 57 |
+
return "gemini"
|
| 58 |
+
return None
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
# Log status at startup (keys may not be available yet)
|
| 62 |
+
_groq, _google = get_api_keys()
|
| 63 |
+
if not _groq and not _google:
|
| 64 |
+
logger.warning(
|
| 65 |
+
"No LLM API key found at startup. Will check again when analyzing."
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
# ---------------------------------------------------------------------------
|
| 70 |
+
# Guild Initialization (lazy)
|
| 71 |
+
# ---------------------------------------------------------------------------
|
| 72 |
+
|
| 73 |
+
_guild = None
|
| 74 |
+
_guild_error = None
|
| 75 |
+
_guild_provider = None # Track which provider was used
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def reset_guild():
|
| 79 |
+
"""Reset guild to force re-initialization (e.g., when API key changes)."""
|
| 80 |
+
global _guild, _guild_error, _guild_provider
|
| 81 |
+
_guild = None
|
| 82 |
+
_guild_error = None
|
| 83 |
+
_guild_provider = None
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def get_guild():
|
| 87 |
+
"""Lazy initialization of the Clinical Insight Guild."""
|
| 88 |
+
global _guild, _guild_error, _guild_provider
|
| 89 |
+
|
| 90 |
+
# Check if we need to reinitialize (provider changed)
|
| 91 |
+
current_provider = os.getenv("LLM_PROVIDER")
|
| 92 |
+
if _guild_provider and _guild_provider != current_provider:
|
| 93 |
+
logger.info(f"Provider changed from {_guild_provider} to {current_provider}, reinitializing...")
|
| 94 |
+
reset_guild()
|
| 95 |
+
|
| 96 |
+
if _guild is not None:
|
| 97 |
+
return _guild
|
| 98 |
+
|
| 99 |
+
if _guild_error is not None:
|
| 100 |
+
# Don't cache errors forever - allow retry
|
| 101 |
+
logger.warning("Previous initialization failed, retrying...")
|
| 102 |
+
_guild_error = None
|
| 103 |
+
|
| 104 |
+
try:
|
| 105 |
+
logger.info("Initializing Clinical Insight Guild...")
|
| 106 |
+
logger.info(f"LLM_PROVIDER={os.getenv('LLM_PROVIDER')}")
|
| 107 |
+
logger.info(f"GROQ_API_KEY={'set' if os.getenv('GROQ_API_KEY') else 'NOT SET'}")
|
| 108 |
+
logger.info(f"GOOGLE_API_KEY={'set' if os.getenv('GOOGLE_API_KEY') else 'NOT SET'}")
|
| 109 |
+
|
| 110 |
+
start = time.time()
|
| 111 |
+
|
| 112 |
+
from src.workflow import create_guild
|
| 113 |
+
_guild = create_guild()
|
| 114 |
+
_guild_provider = current_provider
|
| 115 |
+
|
| 116 |
+
elapsed = time.time() - start
|
| 117 |
+
logger.info(f"Guild initialized in {elapsed:.1f}s")
|
| 118 |
+
return _guild
|
| 119 |
+
|
| 120 |
+
except Exception as exc:
|
| 121 |
+
logger.error(f"Failed to initialize guild: {exc}")
|
| 122 |
+
_guild_error = exc
|
| 123 |
+
raise
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
# ---------------------------------------------------------------------------
|
| 127 |
+
# Analysis Functions
|
| 128 |
+
# ---------------------------------------------------------------------------
|
| 129 |
+
|
| 130 |
+
def parse_biomarkers(text: str) -> dict[str, float]:
|
| 131 |
+
"""
|
| 132 |
+
Parse biomarkers from natural language text.
|
| 133 |
+
|
| 134 |
+
Supports formats like:
|
| 135 |
+
- "Glucose: 140, HbA1c: 7.5"
|
| 136 |
+
- "glucose 140 hba1c 7.5"
|
| 137 |
+
- {"Glucose": 140, "HbA1c": 7.5}
|
| 138 |
+
"""
|
| 139 |
+
text = text.strip()
|
| 140 |
+
|
| 141 |
+
# Try JSON first
|
| 142 |
+
if text.startswith("{"):
|
| 143 |
+
try:
|
| 144 |
+
return json.loads(text)
|
| 145 |
+
except json.JSONDecodeError:
|
| 146 |
+
pass
|
| 147 |
+
|
| 148 |
+
# Parse natural language
|
| 149 |
+
import re
|
| 150 |
+
|
| 151 |
+
# Common biomarker patterns
|
| 152 |
+
patterns = [
|
| 153 |
+
# "Glucose: 140" or "Glucose = 140"
|
| 154 |
+
r"([A-Za-z0-9_]+)\s*[:=]\s*([\d.]+)",
|
| 155 |
+
# "Glucose 140 mg/dL"
|
| 156 |
+
r"([A-Za-z0-9_]+)\s+([\d.]+)\s*(?:mg/dL|mmol/L|%|g/dL|U/L|mIU/L)?",
|
| 157 |
+
]
|
| 158 |
+
|
| 159 |
+
biomarkers = {}
|
| 160 |
+
|
| 161 |
+
for pattern in patterns:
|
| 162 |
+
matches = re.findall(pattern, text, re.IGNORECASE)
|
| 163 |
+
for name, value in matches:
|
| 164 |
+
try:
|
| 165 |
+
biomarkers[name.strip()] = float(value)
|
| 166 |
+
except ValueError:
|
| 167 |
+
continue
|
| 168 |
+
|
| 169 |
+
return biomarkers
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def analyze_biomarkers(input_text: str, progress=gr.Progress()) -> tuple[str, str, str]:
|
| 173 |
+
"""
|
| 174 |
+
Analyze biomarkers using the Clinical Insight Guild.
|
| 175 |
+
|
| 176 |
+
Returns: (summary, details_json, status)
|
| 177 |
+
"""
|
| 178 |
+
if not input_text.strip():
|
| 179 |
+
return "", "", """
|
| 180 |
+
<div style="background: linear-gradient(135deg, #f0f4f8 0%, #e2e8f0 100%); border: 1px solid #cbd5e1; border-radius: 10px; padding: 16px; text-align: center;">
|
| 181 |
+
<span style="font-size: 2em;">✍️</span>
|
| 182 |
+
<p style="margin: 8px 0 0 0; color: #64748b;">Please enter biomarkers to analyze.</p>
|
| 183 |
+
</div>
|
| 184 |
+
"""
|
| 185 |
+
|
| 186 |
+
# Check API key dynamically (HF injects secrets after startup)
|
| 187 |
+
groq_key, google_key = get_api_keys()
|
| 188 |
+
|
| 189 |
+
if not groq_key and not google_key:
|
| 190 |
+
return "", "", """
|
| 191 |
+
<div style="background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%); border: 1px solid #ef4444; border-radius: 10px; padding: 16px;">
|
| 192 |
+
<strong style="color: #dc2626;">❌ No API Key Configured</strong>
|
| 193 |
+
<p style="margin: 12px 0 8px 0; color: #991b1b;">Please add your API key in Space Settings → Secrets:</p>
|
| 194 |
+
<ul style="margin: 0; color: #7f1d1d;">
|
| 195 |
+
<li><code>GROQ_API_KEY</code> - <a href="https://console.groq.com/keys" target="_blank" style="color: #2563eb;">Get free key →</a></li>
|
| 196 |
+
<li><code>GOOGLE_API_KEY</code> - <a href="https://aistudio.google.com/app/apikey" target="_blank" style="color: #2563eb;">Get free key →</a></li>
|
| 197 |
+
</ul>
|
| 198 |
+
</div>
|
| 199 |
+
"""
|
| 200 |
+
|
| 201 |
+
# Setup provider based on available key
|
| 202 |
+
provider = setup_llm_provider()
|
| 203 |
+
logger.info(f"Using LLM provider: {provider}")
|
| 204 |
+
|
| 205 |
+
try:
|
| 206 |
+
progress(0.1, desc="📝 Parsing biomarkers...")
|
| 207 |
+
biomarkers = parse_biomarkers(input_text)
|
| 208 |
+
|
| 209 |
+
if not biomarkers:
|
| 210 |
+
return "", "", """
|
| 211 |
+
<div style="background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); border: 1px solid #fbbf24; border-radius: 10px; padding: 16px;">
|
| 212 |
+
<strong>⚠️ Could not parse biomarkers</strong>
|
| 213 |
+
<p style="margin: 8px 0 0 0; color: #92400e;">Try formats like:</p>
|
| 214 |
+
<ul style="margin: 8px 0 0 0; color: #92400e;">
|
| 215 |
+
<li><code>Glucose: 140, HbA1c: 7.5</code></li>
|
| 216 |
+
<li><code>{"Glucose": 140, "HbA1c": 7.5}</code></li>
|
| 217 |
+
</ul>
|
| 218 |
+
</div>
|
| 219 |
+
"""
|
| 220 |
+
|
| 221 |
+
progress(0.2, desc="🔧 Initializing AI agents...")
|
| 222 |
+
|
| 223 |
+
# Initialize guild
|
| 224 |
+
guild = get_guild()
|
| 225 |
+
|
| 226 |
+
# Prepare input
|
| 227 |
+
from src.state import PatientInput
|
| 228 |
+
|
| 229 |
+
# Auto-generate prediction based on common patterns
|
| 230 |
+
prediction = auto_predict(biomarkers)
|
| 231 |
+
|
| 232 |
+
patient_input = PatientInput(
|
| 233 |
+
biomarkers=biomarkers,
|
| 234 |
+
model_prediction=prediction,
|
| 235 |
+
patient_context={"patient_id": "HF_User", "source": "huggingface_spaces"}
|
| 236 |
+
)
|
| 237 |
+
|
| 238 |
+
progress(0.4, desc="🤖 Running Clinical Insight Guild...")
|
| 239 |
+
|
| 240 |
+
# Run analysis
|
| 241 |
+
start = time.time()
|
| 242 |
+
result = guild.run(patient_input)
|
| 243 |
+
elapsed = time.time() - start
|
| 244 |
+
|
| 245 |
+
progress(0.9, desc="✨ Formatting results...")
|
| 246 |
+
|
| 247 |
+
# Extract response
|
| 248 |
+
final_response = result.get("final_response", {})
|
| 249 |
+
|
| 250 |
+
# Format summary
|
| 251 |
+
summary = format_summary(final_response, elapsed)
|
| 252 |
+
|
| 253 |
+
# Format details
|
| 254 |
+
details = json.dumps(final_response, indent=2, default=str)
|
| 255 |
+
|
| 256 |
+
status = f"""
|
| 257 |
+
<div style="background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%); border: 1px solid #10b981; border-radius: 10px; padding: 12px; display: flex; align-items: center; gap: 10px;">
|
| 258 |
+
<span style="font-size: 1.5em;">✅</span>
|
| 259 |
+
<div>
|
| 260 |
+
<strong style="color: #047857;">Analysis Complete</strong>
|
| 261 |
+
<span style="color: #065f46; margin-left: 8px;">({elapsed:.1f}s)</span>
|
| 262 |
+
</div>
|
| 263 |
+
</div>
|
| 264 |
+
"""
|
| 265 |
+
|
| 266 |
+
return summary, details, status
|
| 267 |
+
|
| 268 |
+
except Exception as exc:
|
| 269 |
+
logger.error(f"Analysis error: {exc}", exc_info=True)
|
| 270 |
+
error_msg = f"""
|
| 271 |
+
<div style="background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%); border: 1px solid #ef4444; border-radius: 10px; padding: 16px;">
|
| 272 |
+
<strong style="color: #dc2626;">❌ Analysis Error</strong>
|
| 273 |
+
<p style="margin: 8px 0 0 0; color: #991b1b;">{exc}</p>
|
| 274 |
+
<details style="margin-top: 12px;">
|
| 275 |
+
<summary style="cursor: pointer; color: #7f1d1d;">Show details</summary>
|
| 276 |
+
<pre style="margin-top: 8px; padding: 12px; background: #fef2f2; border-radius: 6px; overflow-x: auto; font-size: 0.8em;">{traceback.format_exc()}</pre>
|
| 277 |
+
</details>
|
| 278 |
+
</div>
|
| 279 |
+
"""
|
| 280 |
+
return "", "", error_msg
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def auto_predict(biomarkers: dict[str, float]) -> dict[str, Any]:
|
| 284 |
+
"""
|
| 285 |
+
Auto-generate a disease prediction based on biomarkers.
|
| 286 |
+
This simulates what an ML model would provide.
|
| 287 |
+
"""
|
| 288 |
+
# Normalize biomarker names for matching
|
| 289 |
+
normalized = {k.lower().replace(" ", ""): v for k, v in biomarkers.items()}
|
| 290 |
+
|
| 291 |
+
# Check for diabetes indicators
|
| 292 |
+
glucose = normalized.get("glucose", normalized.get("fastingglucose", 0))
|
| 293 |
+
hba1c = normalized.get("hba1c", normalized.get("hemoglobina1c", 0))
|
| 294 |
+
|
| 295 |
+
if hba1c >= 6.5 or glucose >= 126:
|
| 296 |
+
return {
|
| 297 |
+
"disease": "Diabetes",
|
| 298 |
+
"confidence": min(0.95, 0.7 + (hba1c - 6.5) * 0.1) if hba1c else 0.85,
|
| 299 |
+
"severity": "high" if hba1c >= 8 or glucose >= 200 else "moderate"
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
# Check for lipid disorders
|
| 303 |
+
cholesterol = normalized.get("cholesterol", normalized.get("totalcholesterol", 0))
|
| 304 |
+
ldl = normalized.get("ldl", normalized.get("ldlcholesterol", 0))
|
| 305 |
+
triglycerides = normalized.get("triglycerides", 0)
|
| 306 |
+
|
| 307 |
+
if cholesterol >= 240 or ldl >= 160 or triglycerides >= 200:
|
| 308 |
+
return {
|
| 309 |
+
"disease": "Dyslipidemia",
|
| 310 |
+
"confidence": 0.85,
|
| 311 |
+
"severity": "moderate"
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
# Check for anemia
|
| 315 |
+
hemoglobin = normalized.get("hemoglobin", normalized.get("hgb", normalized.get("hb", 0)))
|
| 316 |
+
|
| 317 |
+
if hemoglobin and hemoglobin < 12:
|
| 318 |
+
return {
|
| 319 |
+
"disease": "Anemia",
|
| 320 |
+
"confidence": 0.80,
|
| 321 |
+
"severity": "moderate"
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
# Check for thyroid issues
|
| 325 |
+
tsh = normalized.get("tsh", 0)
|
| 326 |
+
|
| 327 |
+
if tsh > 4.5:
|
| 328 |
+
return {
|
| 329 |
+
"disease": "Hypothyroidism",
|
| 330 |
+
"confidence": 0.75,
|
| 331 |
+
"severity": "moderate"
|
| 332 |
+
}
|
| 333 |
+
elif tsh and tsh < 0.4:
|
| 334 |
+
return {
|
| 335 |
+
"disease": "Hyperthyroidism",
|
| 336 |
+
"confidence": 0.75,
|
| 337 |
+
"severity": "moderate"
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
# Default - general health screening
|
| 341 |
+
return {
|
| 342 |
+
"disease": "General Health Screening",
|
| 343 |
+
"confidence": 0.70,
|
| 344 |
+
"severity": "low"
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
def format_summary(response: dict, elapsed: float) -> str:
|
| 349 |
+
"""Format the analysis response as beautiful HTML/markdown."""
|
| 350 |
+
if not response:
|
| 351 |
+
return """
|
| 352 |
+
<div style="text-align: center; padding: 40px; color: #94a3b8;">
|
| 353 |
+
<div style="font-size: 3em;">❌</div>
|
| 354 |
+
<p>No analysis results available.</p>
|
| 355 |
+
</div>
|
| 356 |
+
"""
|
| 357 |
+
|
| 358 |
+
parts = []
|
| 359 |
+
|
| 360 |
+
# Header with primary finding and confidence
|
| 361 |
+
primary = response.get("primary_finding", "Analysis Complete")
|
| 362 |
+
confidence = response.get("confidence", {})
|
| 363 |
+
conf_score = confidence.get("overall_score", 0) if isinstance(confidence, dict) else 0
|
| 364 |
+
|
| 365 |
+
# Determine severity color
|
| 366 |
+
severity = response.get("severity", "low")
|
| 367 |
+
severity_colors = {
|
| 368 |
+
"critical": ("#dc2626", "#fef2f2", "🔴"),
|
| 369 |
+
"high": ("#ea580c", "#fff7ed", "🟠"),
|
| 370 |
+
"moderate": ("#ca8a04", "#fefce8", "🟡"),
|
| 371 |
+
"low": ("#16a34a", "#f0fdf4", "🟢")
|
| 372 |
+
}
|
| 373 |
+
color, bg_color, emoji = severity_colors.get(severity, severity_colors["low"])
|
| 374 |
+
|
| 375 |
+
# Confidence badge
|
| 376 |
+
conf_badge = ""
|
| 377 |
+
if conf_score:
|
| 378 |
+
conf_pct = int(conf_score * 100)
|
| 379 |
+
conf_color = "#16a34a" if conf_pct >= 80 else "#ca8a04" if conf_pct >= 60 else "#dc2626"
|
| 380 |
+
conf_badge = f'<span style="background: {conf_color}; color: white; padding: 4px 12px; border-radius: 20px; font-size: 0.85em; margin-left: 12px;">{conf_pct}% confidence</span>'
|
| 381 |
+
|
| 382 |
+
parts.append(f"""
|
| 383 |
+
<div style="background: linear-gradient(135deg, {bg_color} 0%, white 100%); border-left: 4px solid {color}; border-radius: 12px; padding: 20px; margin-bottom: 20px;">
|
| 384 |
+
<div style="display: flex; align-items: center; flex-wrap: wrap;">
|
| 385 |
+
<span style="font-size: 1.5em; margin-right: 12px;">{emoji}</span>
|
| 386 |
+
<h2 style="margin: 0; color: {color}; font-size: 1.4em;">{primary}</h2>
|
| 387 |
+
{conf_badge}
|
| 388 |
+
</div>
|
| 389 |
+
</div>
|
| 390 |
+
""")
|
| 391 |
+
|
| 392 |
+
# Critical Alerts
|
| 393 |
+
alerts = response.get("safety_alerts", [])
|
| 394 |
+
if alerts:
|
| 395 |
+
alert_items = ""
|
| 396 |
+
for alert in alerts[:5]:
|
| 397 |
+
if isinstance(alert, dict):
|
| 398 |
+
alert_items += f'<li><strong>{alert.get("alert_type", "Alert")}:</strong> {alert.get("message", "")}</li>'
|
| 399 |
+
else:
|
| 400 |
+
alert_items += f'<li>{alert}</li>'
|
| 401 |
+
|
| 402 |
+
parts.append(f"""
|
| 403 |
+
<div style="background: linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%); border: 1px solid #fecaca; border-radius: 12px; padding: 16px; margin-bottom: 16px;">
|
| 404 |
+
<h4 style="margin: 0 0 12px 0; color: #dc2626; display: flex; align-items: center; gap: 8px;">
|
| 405 |
+
⚠️ Critical Alerts
|
| 406 |
+
</h4>
|
| 407 |
+
<ul style="margin: 0; padding-left: 20px; color: #991b1b;">{alert_items}</ul>
|
| 408 |
+
</div>
|
| 409 |
+
""")
|
| 410 |
+
|
| 411 |
+
# Key Findings
|
| 412 |
+
findings = response.get("key_findings", [])
|
| 413 |
+
if findings:
|
| 414 |
+
finding_items = "".join([f'<li style="margin-bottom: 8px;">{f}</li>' for f in findings[:5]])
|
| 415 |
+
parts.append(f"""
|
| 416 |
+
<div style="background: #f8fafc; border-radius: 12px; padding: 16px; margin-bottom: 16px;">
|
| 417 |
+
<h4 style="margin: 0 0 12px 0; color: #1e3a5f;">🔍 Key Findings</h4>
|
| 418 |
+
<ul style="margin: 0; padding-left: 20px; color: #475569;">{finding_items}</ul>
|
| 419 |
+
</div>
|
| 420 |
+
""")
|
| 421 |
+
|
| 422 |
+
# Biomarker Flags - as a visual grid
|
| 423 |
+
flags = response.get("biomarker_flags", [])
|
| 424 |
+
if flags:
|
| 425 |
+
flag_cards = ""
|
| 426 |
+
for flag in flags[:8]:
|
| 427 |
+
if isinstance(flag, dict):
|
| 428 |
+
name = flag.get("biomarker", "Unknown")
|
| 429 |
+
status = flag.get("status", "normal")
|
| 430 |
+
value = flag.get("value", "N/A")
|
| 431 |
+
|
| 432 |
+
status_styles = {
|
| 433 |
+
"critical": ("🔴", "#dc2626", "#fef2f2"),
|
| 434 |
+
"abnormal": ("🟡", "#ca8a04", "#fefce8"),
|
| 435 |
+
"normal": ("🟢", "#16a34a", "#f0fdf4")
|
| 436 |
+
}
|
| 437 |
+
s_emoji, s_color, s_bg = status_styles.get(status, status_styles["normal"])
|
| 438 |
+
|
| 439 |
+
flag_cards += f"""
|
| 440 |
+
<div style="background: {s_bg}; border: 1px solid {s_color}33; border-radius: 8px; padding: 12px; text-align: center;">
|
| 441 |
+
<div style="font-size: 1.2em;">{s_emoji}</div>
|
| 442 |
+
<div style="font-weight: 600; color: #1e3a5f; margin: 4px 0;">{name}</div>
|
| 443 |
+
<div style="font-size: 1.1em; color: {s_color};">{value}</div>
|
| 444 |
+
<div style="font-size: 0.8em; color: #64748b; text-transform: uppercase;">{status}</div>
|
| 445 |
+
</div>
|
| 446 |
+
"""
|
| 447 |
+
|
| 448 |
+
parts.append(f"""
|
| 449 |
+
<div style="margin-bottom: 16px;">
|
| 450 |
+
<h4 style="margin: 0 0 12px 0; color: #1e3a5f;">📊 Biomarker Analysis</h4>
|
| 451 |
+
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); gap: 12px;">
|
| 452 |
+
{flag_cards}
|
| 453 |
+
</div>
|
| 454 |
+
</div>
|
| 455 |
+
""")
|
| 456 |
+
|
| 457 |
+
# Recommendations - organized sections
|
| 458 |
+
recs = response.get("recommendations", {})
|
| 459 |
+
if recs:
|
| 460 |
+
rec_sections = ""
|
| 461 |
+
|
| 462 |
+
immediate = recs.get("immediate_actions", [])
|
| 463 |
+
if immediate:
|
| 464 |
+
items = "".join([f'<li style="margin-bottom: 6px;">{a}</li>' for a in immediate[:3]])
|
| 465 |
+
rec_sections += f"""
|
| 466 |
+
<div style="margin-bottom: 12px;">
|
| 467 |
+
<h5 style="margin: 0 0 8px 0; color: #dc2626;">🚨 Immediate Actions</h5>
|
| 468 |
+
<ul style="margin: 0; padding-left: 20px; color: #475569;">{items}</ul>
|
| 469 |
+
</div>
|
| 470 |
+
"""
|
| 471 |
+
|
| 472 |
+
lifestyle = recs.get("lifestyle_modifications", [])
|
| 473 |
+
if lifestyle:
|
| 474 |
+
items = "".join([f'<li style="margin-bottom: 6px;">{m}</li>' for m in lifestyle[:3]])
|
| 475 |
+
rec_sections += f"""
|
| 476 |
+
<div style="margin-bottom: 12px;">
|
| 477 |
+
<h5 style="margin: 0 0 8px 0; color: #16a34a;">🌿 Lifestyle Modifications</h5>
|
| 478 |
+
<ul style="margin: 0; padding-left: 20px; color: #475569;">{items}</ul>
|
| 479 |
+
</div>
|
| 480 |
+
"""
|
| 481 |
+
|
| 482 |
+
followup = recs.get("follow_up", [])
|
| 483 |
+
if followup:
|
| 484 |
+
items = "".join([f'<li style="margin-bottom: 6px;">{f}</li>' for f in followup[:3]])
|
| 485 |
+
rec_sections += f"""
|
| 486 |
+
<div>
|
| 487 |
+
<h5 style="margin: 0 0 8px 0; color: #2563eb;">📅 Follow-up</h5>
|
| 488 |
+
<ul style="margin: 0; padding-left: 20px; color: #475569;">{items}</ul>
|
| 489 |
+
</div>
|
| 490 |
+
"""
|
| 491 |
+
|
| 492 |
+
if rec_sections:
|
| 493 |
+
parts.append(f"""
|
| 494 |
+
<div style="background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%); border-radius: 12px; padding: 16px; margin-bottom: 16px;">
|
| 495 |
+
<h4 style="margin: 0 0 16px 0; color: #1e3a5f;">💡 Recommendations</h4>
|
| 496 |
+
{rec_sections}
|
| 497 |
+
</div>
|
| 498 |
+
""")
|
| 499 |
+
|
| 500 |
+
# Disease Explanation
|
| 501 |
+
explanation = response.get("disease_explanation", {})
|
| 502 |
+
if explanation and isinstance(explanation, dict):
|
| 503 |
+
pathophys = explanation.get("pathophysiology", "")
|
| 504 |
+
if pathophys:
|
| 505 |
+
parts.append(f"""
|
| 506 |
+
<div style="background: #f8fafc; border-radius: 12px; padding: 16px; margin-bottom: 16px;">
|
| 507 |
+
<h4 style="margin: 0 0 12px 0; color: #1e3a5f;">📖 Understanding Your Results</h4>
|
| 508 |
+
<p style="margin: 0; color: #475569; line-height: 1.6;">{pathophys[:600]}{'...' if len(pathophys) > 600 else ''}</p>
|
| 509 |
+
</div>
|
| 510 |
+
""")
|
| 511 |
+
|
| 512 |
+
# Conversational Summary
|
| 513 |
+
conv_summary = response.get("conversational_summary", "")
|
| 514 |
+
if conv_summary:
|
| 515 |
+
parts.append(f"""
|
| 516 |
+
<div style="background: linear-gradient(135deg, #faf5ff 0%, #f3e8ff 100%); border-radius: 12px; padding: 16px; margin-bottom: 16px;">
|
| 517 |
+
<h4 style="margin: 0 0 12px 0; color: #7c3aed;">📝 Summary</h4>
|
| 518 |
+
<p style="margin: 0; color: #475569; line-height: 1.6;">{conv_summary[:1000]}</p>
|
| 519 |
+
</div>
|
| 520 |
+
""")
|
| 521 |
+
|
| 522 |
+
# Footer
|
| 523 |
+
parts.append(f"""
|
| 524 |
+
<div style="border-top: 1px solid #e2e8f0; padding-top: 16px; margin-top: 8px; text-align: center;">
|
| 525 |
+
<p style="margin: 0 0 8px 0; color: #94a3b8; font-size: 0.9em;">
|
| 526 |
+
✨ Analysis completed in <strong>{elapsed:.1f}s</strong> using Agentic RagBot
|
| 527 |
+
</p>
|
| 528 |
+
<p style="margin: 0; color: #f59e0b; font-size: 0.85em;">
|
| 529 |
+
⚠️ <em>This is for informational purposes only. Consult a healthcare professional for medical advice.</em>
|
| 530 |
+
</p>
|
| 531 |
+
</div>
|
| 532 |
+
""")
|
| 533 |
+
|
| 534 |
+
return "\n".join(parts)
|
| 535 |
+
|
| 536 |
+
|
| 537 |
+
# ---------------------------------------------------------------------------
|
| 538 |
+
# Gradio Interface
|
| 539 |
+
# ---------------------------------------------------------------------------
|
| 540 |
+
|
| 541 |
+
# Custom CSS for modern medical UI
|
| 542 |
+
CUSTOM_CSS = """
|
| 543 |
+
/* Global Styles */
|
| 544 |
+
.gradio-container {
|
| 545 |
+
max-width: 1400px !important;
|
| 546 |
+
margin: auto !important;
|
| 547 |
+
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif !important;
|
| 548 |
+
}
|
| 549 |
+
|
| 550 |
+
/* Hide footer */
|
| 551 |
+
footer { display: none !important; }
|
| 552 |
+
|
| 553 |
+
/* Header styling */
|
| 554 |
+
.header-container {
|
| 555 |
+
background: linear-gradient(135deg, #1e3a5f 0%, #2d5a87 50%, #3d7ab5 100%);
|
| 556 |
+
border-radius: 16px;
|
| 557 |
+
padding: 32px;
|
| 558 |
+
margin-bottom: 24px;
|
| 559 |
+
color: white;
|
| 560 |
+
text-align: center;
|
| 561 |
+
box-shadow: 0 8px 32px rgba(30, 58, 95, 0.3);
|
| 562 |
+
}
|
| 563 |
+
|
| 564 |
+
.header-container h1 {
|
| 565 |
+
margin: 0 0 12px 0;
|
| 566 |
+
font-size: 2.5em;
|
| 567 |
+
font-weight: 700;
|
| 568 |
+
text-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
| 569 |
+
}
|
| 570 |
+
|
| 571 |
+
.header-container p {
|
| 572 |
+
margin: 0;
|
| 573 |
+
opacity: 0.95;
|
| 574 |
+
font-size: 1.1em;
|
| 575 |
+
}
|
| 576 |
+
|
| 577 |
+
/* Input panel */
|
| 578 |
+
.input-panel {
|
| 579 |
+
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
|
| 580 |
+
border-radius: 16px;
|
| 581 |
+
padding: 24px;
|
| 582 |
+
border: 1px solid #e2e8f0;
|
| 583 |
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.05);
|
| 584 |
+
}
|
| 585 |
+
|
| 586 |
+
/* Output panel */
|
| 587 |
+
.output-panel {
|
| 588 |
+
background: white;
|
| 589 |
+
border-radius: 16px;
|
| 590 |
+
padding: 24px;
|
| 591 |
+
border: 1px solid #e2e8f0;
|
| 592 |
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.05);
|
| 593 |
+
min-height: 500px;
|
| 594 |
+
}
|
| 595 |
+
|
| 596 |
+
/* Status badges */
|
| 597 |
+
.status-success {
|
| 598 |
+
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
| 599 |
+
color: white;
|
| 600 |
+
padding: 12px 20px;
|
| 601 |
+
border-radius: 10px;
|
| 602 |
+
font-weight: 600;
|
| 603 |
+
display: inline-block;
|
| 604 |
+
}
|
| 605 |
+
|
| 606 |
+
.status-error {
|
| 607 |
+
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
| 608 |
+
color: white;
|
| 609 |
+
padding: 12px 20px;
|
| 610 |
+
border-radius: 10px;
|
| 611 |
+
font-weight: 600;
|
| 612 |
+
}
|
| 613 |
+
|
| 614 |
+
.status-warning {
|
| 615 |
+
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
|
| 616 |
+
color: white;
|
| 617 |
+
padding: 12px 20px;
|
| 618 |
+
border-radius: 10px;
|
| 619 |
+
font-weight: 600;
|
| 620 |
+
}
|
| 621 |
+
|
| 622 |
+
/* Info banner */
|
| 623 |
+
.info-banner {
|
| 624 |
+
background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%);
|
| 625 |
+
border: 1px solid #93c5fd;
|
| 626 |
+
border-radius: 12px;
|
| 627 |
+
padding: 16px 20px;
|
| 628 |
+
margin: 16px 0;
|
| 629 |
+
display: flex;
|
| 630 |
+
align-items: center;
|
| 631 |
+
gap: 12px;
|
| 632 |
+
}
|
| 633 |
+
|
| 634 |
+
.info-banner-icon {
|
| 635 |
+
font-size: 1.5em;
|
| 636 |
+
}
|
| 637 |
+
|
| 638 |
+
/* Agent cards */
|
| 639 |
+
.agent-grid {
|
| 640 |
+
display: grid;
|
| 641 |
+
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
| 642 |
+
gap: 16px;
|
| 643 |
+
margin: 20px 0;
|
| 644 |
+
}
|
| 645 |
+
|
| 646 |
+
.agent-card {
|
| 647 |
+
background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
|
| 648 |
+
border: 1px solid #e2e8f0;
|
| 649 |
+
border-radius: 12px;
|
| 650 |
+
padding: 20px;
|
| 651 |
+
transition: all 0.3s ease;
|
| 652 |
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
| 653 |
+
}
|
| 654 |
+
|
| 655 |
+
.agent-card:hover {
|
| 656 |
+
transform: translateY(-2px);
|
| 657 |
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
|
| 658 |
+
border-color: #3b82f6;
|
| 659 |
+
}
|
| 660 |
+
|
| 661 |
+
.agent-card h4 {
|
| 662 |
+
margin: 0 0 8px 0;
|
| 663 |
+
color: #1e3a5f;
|
| 664 |
+
font-size: 1em;
|
| 665 |
+
}
|
| 666 |
+
|
| 667 |
+
.agent-card p {
|
| 668 |
+
margin: 0;
|
| 669 |
+
color: #64748b;
|
| 670 |
+
font-size: 0.9em;
|
| 671 |
+
}
|
| 672 |
+
|
| 673 |
+
/* Example buttons */
|
| 674 |
+
.example-btn {
|
| 675 |
+
background: #f1f5f9;
|
| 676 |
+
border: 1px solid #cbd5e1;
|
| 677 |
+
border-radius: 8px;
|
| 678 |
+
padding: 10px 14px;
|
| 679 |
+
cursor: pointer;
|
| 680 |
+
transition: all 0.2s ease;
|
| 681 |
+
text-align: left;
|
| 682 |
+
font-size: 0.85em;
|
| 683 |
+
}
|
| 684 |
+
|
| 685 |
+
.example-btn:hover {
|
| 686 |
+
background: #e2e8f0;
|
| 687 |
+
border-color: #94a3b8;
|
| 688 |
+
}
|
| 689 |
+
|
| 690 |
+
/* Buttons */
|
| 691 |
+
.primary-btn {
|
| 692 |
+
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important;
|
| 693 |
+
border: none !important;
|
| 694 |
+
border-radius: 12px !important;
|
| 695 |
+
padding: 14px 28px !important;
|
| 696 |
+
font-weight: 600 !important;
|
| 697 |
+
font-size: 1.1em !important;
|
| 698 |
+
box-shadow: 0 4px 14px rgba(59, 130, 246, 0.4) !important;
|
| 699 |
+
transition: all 0.3s ease !important;
|
| 700 |
+
}
|
| 701 |
+
|
| 702 |
+
.primary-btn:hover {
|
| 703 |
+
transform: translateY(-2px) !important;
|
| 704 |
+
box-shadow: 0 6px 20px rgba(59, 130, 246, 0.5) !important;
|
| 705 |
+
}
|
| 706 |
+
|
| 707 |
+
.secondary-btn {
|
| 708 |
+
background: #f1f5f9 !important;
|
| 709 |
+
border: 1px solid #cbd5e1 !important;
|
| 710 |
+
border-radius: 12px !important;
|
| 711 |
+
padding: 14px 28px !important;
|
| 712 |
+
font-weight: 500 !important;
|
| 713 |
+
transition: all 0.2s ease !important;
|
| 714 |
+
}
|
| 715 |
+
|
| 716 |
+
.secondary-btn:hover {
|
| 717 |
+
background: #e2e8f0 !important;
|
| 718 |
+
}
|
| 719 |
+
|
| 720 |
+
/* Results tabs */
|
| 721 |
+
.results-tabs {
|
| 722 |
+
border-radius: 12px;
|
| 723 |
+
overflow: hidden;
|
| 724 |
+
}
|
| 725 |
+
|
| 726 |
+
/* Disclaimer */
|
| 727 |
+
.disclaimer {
|
| 728 |
+
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
|
| 729 |
+
border: 1px solid #fbbf24;
|
| 730 |
+
border-radius: 12px;
|
| 731 |
+
padding: 16px 20px;
|
| 732 |
+
margin-top: 24px;
|
| 733 |
+
font-size: 0.9em;
|
| 734 |
+
}
|
| 735 |
+
|
| 736 |
+
/* Feature badges */
|
| 737 |
+
.feature-badge {
|
| 738 |
+
display: inline-block;
|
| 739 |
+
background: linear-gradient(135deg, #e0e7ff 0%, #c7d2fe 100%);
|
| 740 |
+
color: #4338ca;
|
| 741 |
+
padding: 6px 12px;
|
| 742 |
+
border-radius: 20px;
|
| 743 |
+
font-size: 0.8em;
|
| 744 |
+
font-weight: 600;
|
| 745 |
+
margin: 4px;
|
| 746 |
+
}
|
| 747 |
+
|
| 748 |
+
/* Section titles */
|
| 749 |
+
.section-title {
|
| 750 |
+
font-size: 1.25em;
|
| 751 |
+
font-weight: 600;
|
| 752 |
+
color: #1e3a5f;
|
| 753 |
+
margin-bottom: 16px;
|
| 754 |
+
display: flex;
|
| 755 |
+
align-items: center;
|
| 756 |
+
gap: 8px;
|
| 757 |
+
}
|
| 758 |
+
|
| 759 |
+
/* Animations */
|
| 760 |
+
@keyframes pulse {
|
| 761 |
+
0%, 100% { opacity: 1; }
|
| 762 |
+
50% { opacity: 0.7; }
|
| 763 |
+
}
|
| 764 |
+
|
| 765 |
+
.analyzing {
|
| 766 |
+
animation: pulse 1.5s ease-in-out infinite;
|
| 767 |
+
}
|
| 768 |
+
"""
|
| 769 |
+
|
| 770 |
+
|
| 771 |
+
def create_demo() -> gr.Blocks:
|
| 772 |
+
"""Create the Gradio Blocks interface with modern medical UI."""
|
| 773 |
+
|
| 774 |
+
with gr.Blocks(
|
| 775 |
+
title="Agentic RagBot - Medical Biomarker Analysis",
|
| 776 |
+
theme=gr.themes.Soft(
|
| 777 |
+
primary_hue=gr.themes.colors.blue,
|
| 778 |
+
secondary_hue=gr.themes.colors.slate,
|
| 779 |
+
neutral_hue=gr.themes.colors.slate,
|
| 780 |
+
font=gr.themes.GoogleFont("Inter"),
|
| 781 |
+
font_mono=gr.themes.GoogleFont("JetBrains Mono"),
|
| 782 |
+
).set(
|
| 783 |
+
body_background_fill="linear-gradient(135deg, #f0f4f8 0%, #e2e8f0 100%)",
|
| 784 |
+
block_background_fill="white",
|
| 785 |
+
block_border_width="0px",
|
| 786 |
+
block_shadow="0 4px 16px rgba(0, 0, 0, 0.08)",
|
| 787 |
+
block_radius="16px",
|
| 788 |
+
button_primary_background_fill="linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)",
|
| 789 |
+
button_primary_background_fill_hover="linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%)",
|
| 790 |
+
button_primary_text_color="white",
|
| 791 |
+
button_primary_shadow="0 4px 14px rgba(59, 130, 246, 0.4)",
|
| 792 |
+
input_background_fill="#f8fafc",
|
| 793 |
+
input_border_width="1px",
|
| 794 |
+
input_border_color="#e2e8f0",
|
| 795 |
+
input_radius="12px",
|
| 796 |
+
),
|
| 797 |
+
css=CUSTOM_CSS,
|
| 798 |
+
) as demo:
|
| 799 |
+
|
| 800 |
+
# ===== HEADER =====
|
| 801 |
+
gr.HTML("""
|
| 802 |
+
<div class="header-container">
|
| 803 |
+
<h1>🏥 Agentic RagBot</h1>
|
| 804 |
+
<p>Multi-Agent RAG System for Medical Biomarker Analysis</p>
|
| 805 |
+
<div style="margin-top: 16px;">
|
| 806 |
+
<span class="feature-badge">🤖 6 AI Agents</span>
|
| 807 |
+
<span class="feature-badge">📚 RAG-Powered</span>
|
| 808 |
+
<span class="feature-badge">⚡ Real-time Analysis</span>
|
| 809 |
+
<span class="feature-badge">🔬 Evidence-Based</span>
|
| 810 |
+
</div>
|
| 811 |
+
</div>
|
| 812 |
+
""")
|
| 813 |
+
|
| 814 |
+
# ===== API KEY INFO =====
|
| 815 |
+
gr.HTML("""
|
| 816 |
+
<div class="info-banner">
|
| 817 |
+
<span class="info-banner-icon">🔑</span>
|
| 818 |
+
<div>
|
| 819 |
+
<strong>Setup Required:</strong> Add your <code>GROQ_API_KEY</code> or
|
| 820 |
+
<code>GOOGLE_API_KEY</code> in Space Settings → Secrets to enable analysis.
|
| 821 |
+
<a href="https://console.groq.com/keys" target="_blank" style="color: #2563eb;">Get free Groq key →</a>
|
| 822 |
+
</div>
|
| 823 |
+
</div>
|
| 824 |
+
""")
|
| 825 |
+
|
| 826 |
+
# ===== MAIN CONTENT =====
|
| 827 |
+
with gr.Row(equal_height=False):
|
| 828 |
+
|
| 829 |
+
# ----- LEFT PANEL: INPUT -----
|
| 830 |
+
with gr.Column(scale=2, min_width=400):
|
| 831 |
+
gr.HTML('<div class="section-title">📝 Enter Your Biomarkers</div>')
|
| 832 |
+
|
| 833 |
+
with gr.Group():
|
| 834 |
+
input_text = gr.Textbox(
|
| 835 |
+
label="",
|
| 836 |
+
placeholder="Enter biomarkers in any format:\n\n• Glucose: 140, HbA1c: 7.5, Cholesterol: 210\n• My glucose is 140 and HbA1c is 7.5\n• {\"Glucose\": 140, \"HbA1c\": 7.5}",
|
| 837 |
+
lines=6,
|
| 838 |
+
max_lines=12,
|
| 839 |
+
show_label=False,
|
| 840 |
+
)
|
| 841 |
+
|
| 842 |
+
with gr.Row():
|
| 843 |
+
analyze_btn = gr.Button(
|
| 844 |
+
"🔬 Analyze Biomarkers",
|
| 845 |
+
variant="primary",
|
| 846 |
+
size="lg",
|
| 847 |
+
scale=3,
|
| 848 |
+
)
|
| 849 |
+
clear_btn = gr.Button(
|
| 850 |
+
"🗑️ Clear",
|
| 851 |
+
variant="secondary",
|
| 852 |
+
size="lg",
|
| 853 |
+
scale=1,
|
| 854 |
+
)
|
| 855 |
+
|
| 856 |
+
# Status display
|
| 857 |
+
status_output = gr.Markdown(
|
| 858 |
+
value="",
|
| 859 |
+
elem_classes="status-box"
|
| 860 |
+
)
|
| 861 |
+
|
| 862 |
+
# Quick Examples
|
| 863 |
+
gr.HTML('<div class="section-title" style="margin-top: 24px;">⚡ Quick Examples</div>')
|
| 864 |
+
gr.HTML('<p style="color: #64748b; font-size: 0.9em; margin-bottom: 12px;">Click any example to load it instantly</p>')
|
| 865 |
+
|
| 866 |
+
examples = gr.Examples(
|
| 867 |
+
examples=[
|
| 868 |
+
["Glucose: 185, HbA1c: 8.2, Cholesterol: 245, LDL: 165"],
|
| 869 |
+
["Glucose: 95, HbA1c: 5.4, Cholesterol: 180, HDL: 55, LDL: 100"],
|
| 870 |
+
["Hemoglobin: 9.5, Iron: 40, Ferritin: 15"],
|
| 871 |
+
["TSH: 8.5, T4: 4.0, T3: 80"],
|
| 872 |
+
["Creatinine: 2.5, BUN: 45, eGFR: 35"],
|
| 873 |
+
],
|
| 874 |
+
inputs=input_text,
|
| 875 |
+
label="",
|
| 876 |
+
)
|
| 877 |
+
|
| 878 |
+
# Supported Biomarkers
|
| 879 |
+
with gr.Accordion("📊 Supported Biomarkers", open=False):
|
| 880 |
+
gr.HTML("""
|
| 881 |
+
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; padding: 12px;">
|
| 882 |
+
<div>
|
| 883 |
+
<h4 style="color: #1e3a5f; margin: 0 0 8px 0;">🩸 Diabetes</h4>
|
| 884 |
+
<p style="color: #64748b; font-size: 0.85em; margin: 0;">Glucose, HbA1c, Fasting Glucose, Insulin</p>
|
| 885 |
+
</div>
|
| 886 |
+
<div>
|
| 887 |
+
<h4 style="color: #1e3a5f; margin: 0 0 8px 0;">❤️ Cardiovascular</h4>
|
| 888 |
+
<p style="color: #64748b; font-size: 0.85em; margin: 0;">Cholesterol, LDL, HDL, Triglycerides</p>
|
| 889 |
+
</div>
|
| 890 |
+
<div>
|
| 891 |
+
<h4 style="color: #1e3a5f; margin: 0 0 8px 0;">🫘 Kidney</h4>
|
| 892 |
+
<p style="color: #64748b; font-size: 0.85em; margin: 0;">Creatinine, BUN, eGFR, Uric Acid</p>
|
| 893 |
+
</div>
|
| 894 |
+
<div>
|
| 895 |
+
<h4 style="color: #1e3a5f; margin: 0 0 8px 0;">🦴 Liver</h4>
|
| 896 |
+
<p style="color: #64748b; font-size: 0.85em; margin: 0;">ALT, AST, Bilirubin, Albumin</p>
|
| 897 |
+
</div>
|
| 898 |
+
<div>
|
| 899 |
+
<h4 style="color: #1e3a5f; margin: 0 0 8px 0;">🦋 Thyroid</h4>
|
| 900 |
+
<p style="color: #64748b; font-size: 0.85em; margin: 0;">TSH, T3, T4, Free T4</p>
|
| 901 |
+
</div>
|
| 902 |
+
<div>
|
| 903 |
+
<h4 style="color: #1e3a5f; margin: 0 0 8px 0;">💉 Blood</h4>
|
| 904 |
+
<p style="color: #64748b; font-size: 0.85em; margin: 0;">Hemoglobin, WBC, RBC, Platelets</p>
|
| 905 |
+
</div>
|
| 906 |
+
</div>
|
| 907 |
+
""")
|
| 908 |
+
|
| 909 |
+
# ----- RIGHT PANEL: RESULTS -----
|
| 910 |
+
with gr.Column(scale=3, min_width=500):
|
| 911 |
+
gr.HTML('<div class="section-title">📊 Analysis Results</div>')
|
| 912 |
+
|
| 913 |
+
with gr.Tabs() as result_tabs:
|
| 914 |
+
with gr.Tab("📋 Summary", id="summary"):
|
| 915 |
+
summary_output = gr.Markdown(
|
| 916 |
+
value="""
|
| 917 |
+
<div style="text-align: center; padding: 60px 20px; color: #94a3b8;">
|
| 918 |
+
<div style="font-size: 4em; margin-bottom: 16px;">🔬</div>
|
| 919 |
+
<h3 style="color: #64748b; font-weight: 500;">Ready to Analyze</h3>
|
| 920 |
+
<p>Enter your biomarkers on the left and click <strong>Analyze</strong> to get your personalized health insights.</p>
|
| 921 |
+
</div>
|
| 922 |
+
""",
|
| 923 |
+
elem_classes="summary-output"
|
| 924 |
+
)
|
| 925 |
+
|
| 926 |
+
with gr.Tab("🔍 Detailed JSON", id="json"):
|
| 927 |
+
details_output = gr.Code(
|
| 928 |
+
label="",
|
| 929 |
+
language="json",
|
| 930 |
+
lines=30,
|
| 931 |
+
show_label=False,
|
| 932 |
+
)
|
| 933 |
+
|
| 934 |
+
# ===== HOW IT WORKS =====
|
| 935 |
+
gr.HTML('<div class="section-title" style="margin-top: 32px;">🤖 How It Works</div>')
|
| 936 |
+
|
| 937 |
+
gr.HTML("""
|
| 938 |
+
<div class="agent-grid">
|
| 939 |
+
<div class="agent-card">
|
| 940 |
+
<h4>🔬 Biomarker Analyzer</h4>
|
| 941 |
+
<p>Validates your biomarker values against clinical reference ranges and flags any abnormalities.</p>
|
| 942 |
+
</div>
|
| 943 |
+
<div class="agent-card">
|
| 944 |
+
<h4>📚 Disease Explainer</h4>
|
| 945 |
+
<p>Uses RAG to retrieve relevant medical literature and explain potential conditions.</p>
|
| 946 |
+
</div>
|
| 947 |
+
<div class="agent-card">
|
| 948 |
+
<h4>🔗 Biomarker Linker</h4>
|
| 949 |
+
<p>Connects your specific biomarker patterns to disease predictions with clinical evidence.</p>
|
| 950 |
+
</div>
|
| 951 |
+
<div class="agent-card">
|
| 952 |
+
<h4>📋 Clinical Guidelines</h4>
|
| 953 |
+
<p>Retrieves evidence-based recommendations from 750+ pages of medical guidelines.</p>
|
| 954 |
+
</div>
|
| 955 |
+
<div class="agent-card">
|
| 956 |
+
<h4>✅ Confidence Assessor</h4>
|
| 957 |
+
<p>Evaluates the reliability of findings based on data quality and evidence strength.</p>
|
| 958 |
+
</div>
|
| 959 |
+
<div class="agent-card">
|
| 960 |
+
<h4>📝 Response Synthesizer</h4>
|
| 961 |
+
<p>Compiles all insights into a comprehensive, easy-to-understand patient report.</p>
|
| 962 |
+
</div>
|
| 963 |
+
</div>
|
| 964 |
+
""")
|
| 965 |
+
|
| 966 |
+
# ===== DISCLAIMER =====
|
| 967 |
+
gr.HTML("""
|
| 968 |
+
<div class="disclaimer">
|
| 969 |
+
<strong>⚠️ Medical Disclaimer:</strong> This tool is for <strong>informational purposes only</strong>
|
| 970 |
+
and does not replace professional medical advice, diagnosis, or treatment. Always consult a qualified
|
| 971 |
+
healthcare provider with questions regarding a medical condition. The AI analysis is based on general
|
| 972 |
+
clinical guidelines and may not account for your specific medical history.
|
| 973 |
+
</div>
|
| 974 |
+
""")
|
| 975 |
+
|
| 976 |
+
# ===== FOOTER =====
|
| 977 |
+
gr.HTML("""
|
| 978 |
+
<div style="text-align: center; padding: 24px; color: #94a3b8; font-size: 0.85em; margin-top: 24px;">
|
| 979 |
+
<p>Built with ❤️ using
|
| 980 |
+
<a href="https://langchain-ai.github.io/langgraph/" target="_blank" style="color: #3b82f6;">LangGraph</a>,
|
| 981 |
+
<a href="https://faiss.ai/" target="_blank" style="color: #3b82f6;">FAISS</a>, and
|
| 982 |
+
<a href="https://gradio.app/" target="_blank" style="color: #3b82f6;">Gradio</a>
|
| 983 |
+
</p>
|
| 984 |
+
<p style="margin-top: 8px;">Powered by <strong>Groq</strong> (LLaMA 3.3-70B) • Open Source on GitHub</p>
|
| 985 |
+
</div>
|
| 986 |
+
""")
|
| 987 |
+
|
| 988 |
+
# ===== EVENT HANDLERS =====
|
| 989 |
+
analyze_btn.click(
|
| 990 |
+
fn=analyze_biomarkers,
|
| 991 |
+
inputs=[input_text],
|
| 992 |
+
outputs=[summary_output, details_output, status_output],
|
| 993 |
+
show_progress="full",
|
| 994 |
+
)
|
| 995 |
+
|
| 996 |
+
clear_btn.click(
|
| 997 |
+
fn=lambda: ("", """
|
| 998 |
+
<div style="text-align: center; padding: 60px 20px; color: #94a3b8;">
|
| 999 |
+
<div style="font-size: 4em; margin-bottom: 16px;">🔬</div>
|
| 1000 |
+
<h3 style="color: #64748b; font-weight: 500;">Ready to Analyze</h3>
|
| 1001 |
+
<p>Enter your biomarkers on the left and click <strong>Analyze</strong> to get your personalized health insights.</p>
|
| 1002 |
+
</div>
|
| 1003 |
+
""", "", ""),
|
| 1004 |
+
outputs=[input_text, summary_output, details_output, status_output],
|
| 1005 |
+
)
|
| 1006 |
+
|
| 1007 |
+
return demo
|
| 1008 |
+
|
| 1009 |
+
|
| 1010 |
+
# ---------------------------------------------------------------------------
|
| 1011 |
+
# Main Entry Point
|
| 1012 |
+
# ---------------------------------------------------------------------------
|
| 1013 |
+
|
| 1014 |
+
if __name__ == "__main__":
|
| 1015 |
+
logger.info("Starting MediGuard AI Gradio App...")
|
| 1016 |
+
|
| 1017 |
+
demo = create_demo()
|
| 1018 |
+
|
| 1019 |
+
# Launch with HF Spaces compatible settings
|
| 1020 |
+
demo.launch(
|
| 1021 |
+
server_name="0.0.0.0",
|
| 1022 |
+
server_port=7860,
|
| 1023 |
+
show_error=True,
|
| 1024 |
+
# share=False on HF Spaces
|
| 1025 |
+
)
|
huggingface/requirements.txt
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ===========================================================================
|
| 2 |
+
# MediGuard AI — Hugging Face Spaces Dependencies
|
| 3 |
+
# ===========================================================================
|
| 4 |
+
# Minimal dependencies for standalone Gradio deployment.
|
| 5 |
+
# No postgres, redis, opensearch, ollama required.
|
| 6 |
+
# ===========================================================================
|
| 7 |
+
|
| 8 |
+
# --- Gradio UI ---
|
| 9 |
+
gradio>=5.0.0
|
| 10 |
+
|
| 11 |
+
# --- LangChain Core ---
|
| 12 |
+
langchain>=0.3.0
|
| 13 |
+
langchain-community>=0.3.0
|
| 14 |
+
langchain-core>=0.3.0
|
| 15 |
+
langchain-text-splitters>=0.3.0
|
| 16 |
+
langgraph>=0.2.0
|
| 17 |
+
|
| 18 |
+
# --- Cloud LLM Providers (FREE tiers) ---
|
| 19 |
+
langchain-groq>=0.2.0
|
| 20 |
+
langchain-google-genai>=2.0.0
|
| 21 |
+
|
| 22 |
+
# --- Vector Store ---
|
| 23 |
+
faiss-cpu>=1.8.0
|
| 24 |
+
|
| 25 |
+
# --- Embeddings (local - no API key needed) ---
|
| 26 |
+
sentence-transformers>=3.0.0
|
| 27 |
+
langchain-huggingface>=0.1.0
|
| 28 |
+
|
| 29 |
+
# --- Document Processing ---
|
| 30 |
+
pypdf>=4.0.0
|
| 31 |
+
|
| 32 |
+
# --- Pydantic ---
|
| 33 |
+
pydantic>=2.9.0
|
| 34 |
+
pydantic-settings>=2.5.0
|
| 35 |
+
|
| 36 |
+
# --- HTTP Client ---
|
| 37 |
+
httpx>=0.27.0
|
| 38 |
+
|
| 39 |
+
# --- Utilities ---
|
| 40 |
+
python-dotenv>=1.0.0
|
| 41 |
+
tenacity>=8.0.0
|
| 42 |
+
numpy<2.0.0
|
pyproject.toml
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["hatchling"]
|
| 3 |
+
build-backend = "hatchling.build"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "mediguard-ai"
|
| 7 |
+
version = "2.0.0"
|
| 8 |
+
description = "Production medical biomarker analysis — agentic RAG + multi-agent workflow"
|
| 9 |
+
readme = "README.md"
|
| 10 |
+
license = { text = "MIT" }
|
| 11 |
+
requires-python = ">=3.11"
|
| 12 |
+
authors = [{ name = "MediGuard AI Team" }]
|
| 13 |
+
|
| 14 |
+
dependencies = [
|
| 15 |
+
# --- Core ---
|
| 16 |
+
"fastapi>=0.115.0",
|
| 17 |
+
"uvicorn[standard]>=0.30.0",
|
| 18 |
+
"pydantic>=2.9.0",
|
| 19 |
+
"pydantic-settings>=2.5.0",
|
| 20 |
+
# --- LLM / LangChain ---
|
| 21 |
+
"langchain>=0.3.0",
|
| 22 |
+
"langchain-community>=0.3.0",
|
| 23 |
+
"langgraph>=0.2.0",
|
| 24 |
+
# --- Vector / Search ---
|
| 25 |
+
"opensearch-py>=2.7.0",
|
| 26 |
+
"faiss-cpu>=1.8.0",
|
| 27 |
+
# --- Embeddings ---
|
| 28 |
+
"httpx>=0.27.0",
|
| 29 |
+
# --- Database ---
|
| 30 |
+
"sqlalchemy>=2.0.0",
|
| 31 |
+
"psycopg2-binary>=2.9.0",
|
| 32 |
+
"alembic>=1.13.0",
|
| 33 |
+
# --- Cache ---
|
| 34 |
+
"redis>=5.0.0",
|
| 35 |
+
# --- PDF ---
|
| 36 |
+
"pypdf>=4.0.0",
|
| 37 |
+
# --- Observability ---
|
| 38 |
+
"langfuse>=2.0.0",
|
| 39 |
+
# --- Utilities ---
|
| 40 |
+
"python-dotenv>=1.0.0",
|
| 41 |
+
"tenacity>=8.0.0",
|
| 42 |
+
]
|
| 43 |
+
|
| 44 |
+
[project.optional-dependencies]
|
| 45 |
+
docling = ["docling>=2.0.0"]
|
| 46 |
+
telegram = ["python-telegram-bot>=21.0", "httpx>=0.27.0"]
|
| 47 |
+
gradio = ["gradio>=5.0.0", "httpx>=0.27.0"]
|
| 48 |
+
airflow = ["apache-airflow>=2.9.0"]
|
| 49 |
+
google = ["langchain-google-genai>=2.0.0"]
|
| 50 |
+
groq = ["langchain-groq>=0.2.0"]
|
| 51 |
+
huggingface = ["sentence-transformers>=3.0.0"]
|
| 52 |
+
dev = [
|
| 53 |
+
"pytest>=8.0.0",
|
| 54 |
+
"pytest-asyncio>=0.23.0",
|
| 55 |
+
"pytest-cov>=5.0.0",
|
| 56 |
+
"ruff>=0.7.0",
|
| 57 |
+
"mypy>=1.12.0",
|
| 58 |
+
"pre-commit>=3.8.0",
|
| 59 |
+
"httpx>=0.27.0",
|
| 60 |
+
]
|
| 61 |
+
all = [
|
| 62 |
+
"mediguard-ai[docling,telegram,gradio,google,groq,huggingface,dev]",
|
| 63 |
+
]
|
| 64 |
+
|
| 65 |
+
[project.scripts]
|
| 66 |
+
mediguard = "src.main:app"
|
| 67 |
+
mediguard-telegram = "src.services.telegram.bot:MediGuardTelegramBot"
|
| 68 |
+
mediguard-gradio = "src.gradio_app:launch_gradio"
|
| 69 |
+
|
| 70 |
+
# --------------------------------------------------------------------------
|
| 71 |
+
# Ruff
|
| 72 |
+
# --------------------------------------------------------------------------
|
| 73 |
+
[tool.ruff]
|
| 74 |
+
target-version = "py311"
|
| 75 |
+
line-length = 120
|
| 76 |
+
fix = true
|
| 77 |
+
|
| 78 |
+
[tool.ruff.lint]
|
| 79 |
+
select = [
|
| 80 |
+
"E", # pycodestyle errors
|
| 81 |
+
"W", # pycodestyle warnings
|
| 82 |
+
"F", # pyflakes
|
| 83 |
+
"I", # isort
|
| 84 |
+
"N", # pep8-naming
|
| 85 |
+
"UP", # pyupgrade
|
| 86 |
+
"B", # flake8-bugbear
|
| 87 |
+
"SIM", # flake8-simplify
|
| 88 |
+
"RUF", # ruff-specific
|
| 89 |
+
]
|
| 90 |
+
ignore = [
|
| 91 |
+
"E501", # line too long — handled by formatter
|
| 92 |
+
"B008", # do not perform function calls in argument defaults (Depends)
|
| 93 |
+
"SIM108", # ternary operator
|
| 94 |
+
]
|
| 95 |
+
|
| 96 |
+
[tool.ruff.lint.isort]
|
| 97 |
+
known-first-party = ["src"]
|
| 98 |
+
|
| 99 |
+
# --------------------------------------------------------------------------
|
| 100 |
+
# MyPy
|
| 101 |
+
# --------------------------------------------------------------------------
|
| 102 |
+
[tool.mypy]
|
| 103 |
+
python_version = "3.11"
|
| 104 |
+
warn_return_any = true
|
| 105 |
+
warn_unused_configs = true
|
| 106 |
+
disallow_untyped_defs = false # gradually enable
|
| 107 |
+
ignore_missing_imports = true
|
| 108 |
+
|
| 109 |
+
# --------------------------------------------------------------------------
|
| 110 |
+
# Pytest
|
| 111 |
+
# --------------------------------------------------------------------------
|
| 112 |
+
[tool.pytest.ini_options]
|
| 113 |
+
testpaths = ["tests"]
|
| 114 |
+
python_files = ["test_*.py"]
|
| 115 |
+
python_functions = ["test_*"]
|
| 116 |
+
addopts = "-v --tb=short -q"
|
| 117 |
+
filterwarnings = ["ignore::DeprecationWarning"]
|
scripts/deploy_huggingface.ps1
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<#
|
| 2 |
+
.SYNOPSIS
|
| 3 |
+
Deploy MediGuard AI to Hugging Face Spaces
|
| 4 |
+
.DESCRIPTION
|
| 5 |
+
This script automates the deployment of MediGuard AI to Hugging Face Spaces.
|
| 6 |
+
It handles copying files, setting up the Dockerfile, and pushing to the Space.
|
| 7 |
+
.PARAMETER SpaceName
|
| 8 |
+
Name of your Hugging Face Space (e.g., "mediguard-ai")
|
| 9 |
+
.PARAMETER Username
|
| 10 |
+
Your Hugging Face username
|
| 11 |
+
.PARAMETER SkipClone
|
| 12 |
+
Skip cloning if you've already cloned the Space
|
| 13 |
+
.EXAMPLE
|
| 14 |
+
.\deploy_huggingface.ps1 -Username "your-username" -SpaceName "mediguard-ai"
|
| 15 |
+
#>
|
| 16 |
+
|
| 17 |
+
param(
|
| 18 |
+
[Parameter(Mandatory=$true)]
|
| 19 |
+
[string]$Username,
|
| 20 |
+
|
| 21 |
+
[Parameter(Mandatory=$false)]
|
| 22 |
+
[string]$SpaceName = "mediguard-ai",
|
| 23 |
+
|
| 24 |
+
[switch]$SkipClone
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
$ErrorActionPreference = "Stop"
|
| 28 |
+
|
| 29 |
+
Write-Host "========================================" -ForegroundColor Cyan
|
| 30 |
+
Write-Host " MediGuard AI - Hugging Face Deployment" -ForegroundColor Cyan
|
| 31 |
+
Write-Host "========================================" -ForegroundColor Cyan
|
| 32 |
+
Write-Host ""
|
| 33 |
+
|
| 34 |
+
# Configuration
|
| 35 |
+
$ProjectRoot = Split-Path -Parent $PSScriptRoot
|
| 36 |
+
$DeployDir = Join-Path $ProjectRoot "hf-deploy"
|
| 37 |
+
$SpaceUrl = "https://huggingface.co/spaces/$Username/$SpaceName"
|
| 38 |
+
|
| 39 |
+
Write-Host "Project Root: $ProjectRoot" -ForegroundColor Gray
|
| 40 |
+
Write-Host "Deploy Dir: $DeployDir" -ForegroundColor Gray
|
| 41 |
+
Write-Host "Space URL: $SpaceUrl" -ForegroundColor Gray
|
| 42 |
+
Write-Host ""
|
| 43 |
+
|
| 44 |
+
# Step 1: Clone or use existing Space
|
| 45 |
+
if (-not $SkipClone) {
|
| 46 |
+
Write-Host "[1/6] Cloning Hugging Face Space..." -ForegroundColor Yellow
|
| 47 |
+
|
| 48 |
+
if (Test-Path $DeployDir) {
|
| 49 |
+
Write-Host " Removing existing deploy directory..." -ForegroundColor Gray
|
| 50 |
+
Remove-Item -Recurse -Force $DeployDir
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
git clone "https://huggingface.co/spaces/$Username/$SpaceName" $DeployDir
|
| 54 |
+
|
| 55 |
+
if ($LASTEXITCODE -ne 0) {
|
| 56 |
+
Write-Host "ERROR: Failed to clone Space. Make sure it exists!" -ForegroundColor Red
|
| 57 |
+
Write-Host "Create it at: https://huggingface.co/new-space" -ForegroundColor Yellow
|
| 58 |
+
exit 1
|
| 59 |
+
}
|
| 60 |
+
} else {
|
| 61 |
+
Write-Host "[1/6] Using existing deploy directory..." -ForegroundColor Yellow
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
# Step 2: Copy project files
|
| 65 |
+
Write-Host "[2/6] Copying project files..." -ForegroundColor Yellow
|
| 66 |
+
|
| 67 |
+
# Core directories
|
| 68 |
+
$CoreDirs = @("src", "config", "data", "huggingface")
|
| 69 |
+
foreach ($dir in $CoreDirs) {
|
| 70 |
+
$source = Join-Path $ProjectRoot $dir
|
| 71 |
+
$dest = Join-Path $DeployDir $dir
|
| 72 |
+
if (Test-Path $source) {
|
| 73 |
+
Write-Host " Copying $dir..." -ForegroundColor Gray
|
| 74 |
+
Copy-Item -Path $source -Destination $dest -Recurse -Force
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
# Copy specific files
|
| 79 |
+
$CoreFiles = @("pyproject.toml", ".dockerignore")
|
| 80 |
+
foreach ($file in $CoreFiles) {
|
| 81 |
+
$source = Join-Path $ProjectRoot $file
|
| 82 |
+
if (Test-Path $source) {
|
| 83 |
+
Write-Host " Copying $file..." -ForegroundColor Gray
|
| 84 |
+
Copy-Item -Path $source -Destination (Join-Path $DeployDir $file) -Force
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
# Step 3: Set up Dockerfile (HF Spaces expects it in root)
|
| 89 |
+
Write-Host "[3/6] Setting up Dockerfile..." -ForegroundColor Yellow
|
| 90 |
+
$HfDockerfile = Join-Path $DeployDir "huggingface/Dockerfile"
|
| 91 |
+
$RootDockerfile = Join-Path $DeployDir "Dockerfile"
|
| 92 |
+
Copy-Item -Path $HfDockerfile -Destination $RootDockerfile -Force
|
| 93 |
+
Write-Host " Copied huggingface/Dockerfile to Dockerfile" -ForegroundColor Gray
|
| 94 |
+
|
| 95 |
+
# Step 4: Set up README with HF metadata
|
| 96 |
+
Write-Host "[4/6] Setting up README.md..." -ForegroundColor Yellow
|
| 97 |
+
$HfReadme = Join-Path $DeployDir "huggingface/README.md"
|
| 98 |
+
$RootReadme = Join-Path $DeployDir "README.md"
|
| 99 |
+
Copy-Item -Path $HfReadme -Destination $RootReadme -Force
|
| 100 |
+
Write-Host " Copied huggingface/README.md to README.md" -ForegroundColor Gray
|
| 101 |
+
|
| 102 |
+
# Step 5: Verify vector store exists
|
| 103 |
+
Write-Host "[5/6] Verifying vector store..." -ForegroundColor Yellow
|
| 104 |
+
$VectorStore = Join-Path $DeployDir "data/vector_stores/medical_knowledge.faiss"
|
| 105 |
+
if (Test-Path $VectorStore) {
|
| 106 |
+
$size = (Get-Item $VectorStore).Length / 1MB
|
| 107 |
+
Write-Host " Vector store found: $([math]::Round($size, 2)) MB" -ForegroundColor Green
|
| 108 |
+
} else {
|
| 109 |
+
Write-Host " WARNING: Vector store not found!" -ForegroundColor Red
|
| 110 |
+
Write-Host " Run 'python scripts/setup_embeddings.py' first to create it." -ForegroundColor Yellow
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
# Step 6: Commit and push
|
| 114 |
+
Write-Host "[6/6] Committing and pushing to Hugging Face..." -ForegroundColor Yellow
|
| 115 |
+
|
| 116 |
+
Push-Location $DeployDir
|
| 117 |
+
|
| 118 |
+
git add .
|
| 119 |
+
git commit -m "Deploy MediGuard AI - $(Get-Date -Format 'yyyy-MM-dd HH:mm')"
|
| 120 |
+
|
| 121 |
+
Write-Host ""
|
| 122 |
+
Write-Host "Ready to push! Run the following command:" -ForegroundColor Green
|
| 123 |
+
Write-Host ""
|
| 124 |
+
Write-Host " cd $DeployDir" -ForegroundColor Cyan
|
| 125 |
+
Write-Host " git push" -ForegroundColor Cyan
|
| 126 |
+
Write-Host ""
|
| 127 |
+
Write-Host "After pushing, add your API key as a Secret in Space Settings:" -ForegroundColor Yellow
|
| 128 |
+
Write-Host " Name: GROQ_API_KEY (or GOOGLE_API_KEY)" -ForegroundColor Gray
|
| 129 |
+
Write-Host " Value: your-api-key" -ForegroundColor Gray
|
| 130 |
+
Write-Host ""
|
| 131 |
+
Write-Host "Your Space will be live at:" -ForegroundColor Green
|
| 132 |
+
Write-Host " $SpaceUrl" -ForegroundColor Cyan
|
| 133 |
+
|
| 134 |
+
Pop-Location
|
| 135 |
+
|
| 136 |
+
Write-Host ""
|
| 137 |
+
Write-Host "========================================" -ForegroundColor Cyan
|
| 138 |
+
Write-Host " Deployment prepared successfully!" -ForegroundColor Green
|
| 139 |
+
Write-Host "========================================" -ForegroundColor Cyan
|
src/database.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MediGuard AI — Database layer
|
| 3 |
+
|
| 4 |
+
Provides SQLAlchemy engine/session factories and the declarative Base.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from functools import lru_cache
|
| 10 |
+
from typing import Generator
|
| 11 |
+
|
| 12 |
+
from sqlalchemy import create_engine
|
| 13 |
+
from sqlalchemy.orm import Session, sessionmaker, DeclarativeBase
|
| 14 |
+
|
| 15 |
+
from src.settings import get_settings
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class Base(DeclarativeBase):
|
| 19 |
+
"""Shared declarative base for all ORM models."""
|
| 20 |
+
pass
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@lru_cache(maxsize=1)
|
| 24 |
+
def _engine():
|
| 25 |
+
settings = get_settings()
|
| 26 |
+
return create_engine(
|
| 27 |
+
settings.postgres.database_url,
|
| 28 |
+
pool_pre_ping=True,
|
| 29 |
+
pool_size=5,
|
| 30 |
+
max_overflow=10,
|
| 31 |
+
echo=settings.debug,
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@lru_cache(maxsize=1)
|
| 36 |
+
def _session_factory() -> sessionmaker[Session]:
|
| 37 |
+
return sessionmaker(bind=_engine(), autocommit=False, autoflush=False)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def get_db() -> Generator[Session, None, None]:
|
| 41 |
+
"""FastAPI dependency — yields a DB session and commits/rolls back."""
|
| 42 |
+
session = _session_factory()()
|
| 43 |
+
try:
|
| 44 |
+
yield session
|
| 45 |
+
session.commit()
|
| 46 |
+
except Exception:
|
| 47 |
+
session.rollback()
|
| 48 |
+
raise
|
| 49 |
+
finally:
|
| 50 |
+
session.close()
|
src/dependencies.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MediGuard AI — FastAPI Dependency Injection
|
| 3 |
+
|
| 4 |
+
Provides factory functions and ``Depends()`` for services used across routers.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from functools import lru_cache
|
| 10 |
+
|
| 11 |
+
from src.settings import Settings, get_settings
|
| 12 |
+
from src.services.cache.redis_cache import RedisCache, make_redis_cache
|
| 13 |
+
from src.services.embeddings.service import EmbeddingService, make_embedding_service
|
| 14 |
+
from src.services.langfuse.tracer import LangfuseTracer, make_langfuse_tracer
|
| 15 |
+
from src.services.ollama.client import OllamaClient, make_ollama_client
|
| 16 |
+
from src.services.opensearch.client import OpenSearchClient, make_opensearch_client
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def get_opensearch_client() -> OpenSearchClient:
|
| 20 |
+
return make_opensearch_client()
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def get_embedding_service() -> EmbeddingService:
|
| 24 |
+
return make_embedding_service()
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def get_redis_cache() -> RedisCache:
|
| 28 |
+
return make_redis_cache()
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def get_ollama_client() -> OllamaClient:
|
| 32 |
+
return make_ollama_client()
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def get_langfuse_tracer() -> LangfuseTracer:
|
| 36 |
+
return make_langfuse_tracer()
|
src/exceptions.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MediGuard AI — Domain Exception Hierarchy
|
| 3 |
+
|
| 4 |
+
Production-grade exception classes for the medical RAG system.
|
| 5 |
+
Each service layer raises its own exception type so callers can handle
|
| 6 |
+
failures precisely without leaking implementation details.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from typing import Any, Dict, Optional
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
# ── Base ──────────────────────────────────────────────────────────────────────
|
| 13 |
+
|
| 14 |
+
class MediGuardError(Exception):
|
| 15 |
+
"""Root exception for the entire MediGuard AI application."""
|
| 16 |
+
|
| 17 |
+
def __init__(self, message: str = "", *, details: Optional[Dict[str, Any]] = None):
|
| 18 |
+
self.details = details or {}
|
| 19 |
+
super().__init__(message)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
# ── Configuration / startup ──────────────────────────────────────────────────
|
| 23 |
+
|
| 24 |
+
class ConfigurationError(MediGuardError):
|
| 25 |
+
"""Raised when a required setting is missing or invalid."""
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class ServiceInitError(MediGuardError):
|
| 29 |
+
"""Raised when a service fails to initialise during app startup."""
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
# ── Database ─────────────────────────────────────────────────────────────────
|
| 33 |
+
|
| 34 |
+
class DatabaseError(MediGuardError):
|
| 35 |
+
"""Base class for all database-related errors."""
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class ConnectionError(DatabaseError):
|
| 39 |
+
"""Could not connect to PostgreSQL."""
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class RecordNotFoundError(DatabaseError):
|
| 43 |
+
"""Expected record does not exist."""
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
# ── Search engine ────────────────────────────────────────────────────────────
|
| 47 |
+
|
| 48 |
+
class SearchError(MediGuardError):
|
| 49 |
+
"""Base class for search-engine (OpenSearch) errors."""
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class IndexNotFoundError(SearchError):
|
| 53 |
+
"""The requested OpenSearch index does not exist."""
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class SearchQueryError(SearchError):
|
| 57 |
+
"""The search query was malformed or returned an error."""
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# ── Embeddings ───────────────────────────────────────────────────────────────
|
| 61 |
+
|
| 62 |
+
class EmbeddingError(MediGuardError):
|
| 63 |
+
"""Failed to generate embeddings."""
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class EmbeddingProviderError(EmbeddingError):
|
| 67 |
+
"""The upstream embedding provider returned an error."""
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
# ── PDF / document parsing ───────────────────────────────────────────────────
|
| 71 |
+
|
| 72 |
+
class PDFParsingError(MediGuardError):
|
| 73 |
+
"""Base class for PDF-processing errors."""
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
class PDFExtractionError(PDFParsingError):
|
| 77 |
+
"""Could not extract text from a PDF document."""
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
class PDFValidationError(PDFParsingError):
|
| 81 |
+
"""Uploaded PDF failed validation (size, format, etc.)."""
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
# ── LLM / Ollama ─────────────────────────────────────────────────────────────
|
| 85 |
+
|
| 86 |
+
class LLMError(MediGuardError):
|
| 87 |
+
"""Base class for LLM-related errors."""
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
class OllamaConnectionError(LLMError):
|
| 91 |
+
"""Could not reach the Ollama server."""
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
class OllamaModelNotFoundError(LLMError):
|
| 95 |
+
"""The requested Ollama model is not pulled/available."""
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
class LLMResponseError(LLMError):
|
| 99 |
+
"""The LLM returned an unparseable or empty response."""
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
# ── Biomarker domain ─────────────────────────────────────────────────────────
|
| 103 |
+
|
| 104 |
+
class BiomarkerError(MediGuardError):
|
| 105 |
+
"""Base class for biomarker-related errors."""
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
class BiomarkerValidationError(BiomarkerError):
|
| 109 |
+
"""A biomarker value is physiologically implausible."""
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
class BiomarkerNotFoundError(BiomarkerError):
|
| 113 |
+
"""The biomarker name is unknown to the system."""
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
# ── Medical analysis / workflow ──────────────────────────────────────────────
|
| 117 |
+
|
| 118 |
+
class AnalysisError(MediGuardError):
|
| 119 |
+
"""The clinical-analysis workflow encountered an error."""
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
class GuardrailError(MediGuardError):
|
| 123 |
+
"""A safety guardrail was triggered (input or output)."""
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
class OutOfScopeError(GuardrailError):
|
| 127 |
+
"""The user query falls outside the medical domain."""
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
# ── Cache ────────────────────────────────────────────────────────────────────
|
| 131 |
+
|
| 132 |
+
class CacheError(MediGuardError):
|
| 133 |
+
"""Base class for cache (Redis) errors."""
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
class CacheConnectionError(CacheError):
|
| 137 |
+
"""Could not connect to Redis."""
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
# ── Observability ────────────────────────────────────────────────────────────
|
| 141 |
+
|
| 142 |
+
class ObservabilityError(MediGuardError):
|
| 143 |
+
"""Langfuse or metrics reporting failed (non-fatal)."""
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
# ── Telegram bot ─────────────────────────────────────────────────────────────
|
| 147 |
+
|
| 148 |
+
class TelegramError(MediGuardError):
|
| 149 |
+
"""Error from the Telegram bot integration."""
|
src/gradio_app.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MediGuard AI — Gradio Web UI
|
| 3 |
+
|
| 4 |
+
Provides a simple chat interface and biomarker analysis panel.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
import logging
|
| 11 |
+
import os
|
| 12 |
+
|
| 13 |
+
import httpx
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
API_BASE = os.getenv("MEDIGUARD_API_URL", "http://localhost:8000")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _call_ask(question: str) -> str:
|
| 21 |
+
"""Call the /ask endpoint."""
|
| 22 |
+
try:
|
| 23 |
+
with httpx.Client(timeout=60.0) as client:
|
| 24 |
+
resp = client.post(f"{API_BASE}/ask", json={"question": question})
|
| 25 |
+
resp.raise_for_status()
|
| 26 |
+
return resp.json().get("answer", "No answer returned.")
|
| 27 |
+
except Exception as exc:
|
| 28 |
+
return f"Error: {exc}"
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _call_analyze(biomarkers_json: str) -> str:
|
| 32 |
+
"""Call the /analyze/structured endpoint."""
|
| 33 |
+
try:
|
| 34 |
+
biomarkers = json.loads(biomarkers_json)
|
| 35 |
+
with httpx.Client(timeout=60.0) as client:
|
| 36 |
+
resp = client.post(
|
| 37 |
+
f"{API_BASE}/analyze/structured",
|
| 38 |
+
json={"biomarkers": biomarkers},
|
| 39 |
+
)
|
| 40 |
+
resp.raise_for_status()
|
| 41 |
+
data = resp.json()
|
| 42 |
+
summary = data.get("conversational_summary") or json.dumps(data, indent=2)
|
| 43 |
+
return summary
|
| 44 |
+
except json.JSONDecodeError:
|
| 45 |
+
return "Invalid JSON. Please enter biomarkers as: {\"Glucose\": 185, \"HbA1c\": 8.2}"
|
| 46 |
+
except Exception as exc:
|
| 47 |
+
return f"Error: {exc}"
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def launch_gradio(share: bool = False) -> None:
|
| 51 |
+
"""Launch the Gradio interface."""
|
| 52 |
+
try:
|
| 53 |
+
import gradio as gr
|
| 54 |
+
except ImportError:
|
| 55 |
+
raise ImportError("gradio is required. Install: pip install gradio")
|
| 56 |
+
|
| 57 |
+
with gr.Blocks(title="MediGuard AI", theme=gr.themes.Soft()) as demo:
|
| 58 |
+
gr.Markdown("# 🏥 MediGuard AI — Medical Analysis")
|
| 59 |
+
gr.Markdown(
|
| 60 |
+
"**Disclaimer**: This tool is for informational purposes only and does not "
|
| 61 |
+
"replace professional medical advice."
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
with gr.Tab("Ask a Question"):
|
| 65 |
+
question_input = gr.Textbox(
|
| 66 |
+
label="Medical Question",
|
| 67 |
+
placeholder="e.g., What does a high HbA1c level indicate?",
|
| 68 |
+
lines=3,
|
| 69 |
+
)
|
| 70 |
+
ask_btn = gr.Button("Ask", variant="primary")
|
| 71 |
+
answer_output = gr.Textbox(label="Answer", lines=15, interactive=False)
|
| 72 |
+
ask_btn.click(fn=_call_ask, inputs=question_input, outputs=answer_output)
|
| 73 |
+
|
| 74 |
+
with gr.Tab("Analyze Biomarkers"):
|
| 75 |
+
bio_input = gr.Textbox(
|
| 76 |
+
label="Biomarkers (JSON)",
|
| 77 |
+
placeholder='{"Glucose": 185, "HbA1c": 8.2, "Cholesterol": 210}',
|
| 78 |
+
lines=5,
|
| 79 |
+
)
|
| 80 |
+
analyze_btn = gr.Button("Analyze", variant="primary")
|
| 81 |
+
analysis_output = gr.Textbox(label="Analysis", lines=20, interactive=False)
|
| 82 |
+
analyze_btn.click(fn=_call_analyze, inputs=bio_input, outputs=analysis_output)
|
| 83 |
+
|
| 84 |
+
with gr.Tab("Search Knowledge Base"):
|
| 85 |
+
search_input = gr.Textbox(
|
| 86 |
+
label="Search Query",
|
| 87 |
+
placeholder="e.g., diabetes management guidelines",
|
| 88 |
+
lines=2,
|
| 89 |
+
)
|
| 90 |
+
search_btn = gr.Button("Search", variant="primary")
|
| 91 |
+
search_output = gr.Textbox(label="Results", lines=15, interactive=False)
|
| 92 |
+
|
| 93 |
+
def _call_search(query: str) -> str:
|
| 94 |
+
try:
|
| 95 |
+
with httpx.Client(timeout=30.0) as client:
|
| 96 |
+
resp = client.post(
|
| 97 |
+
f"{API_BASE}/search",
|
| 98 |
+
json={"query": query, "top_k": 5, "mode": "hybrid"},
|
| 99 |
+
)
|
| 100 |
+
resp.raise_for_status()
|
| 101 |
+
data = resp.json()
|
| 102 |
+
results = data.get("results", [])
|
| 103 |
+
if not results:
|
| 104 |
+
return "No results found."
|
| 105 |
+
parts = []
|
| 106 |
+
for i, r in enumerate(results, 1):
|
| 107 |
+
parts.append(
|
| 108 |
+
f"**[{i}] {r.get('title', 'Untitled')}** (score: {r.get('score', 0):.3f})\n"
|
| 109 |
+
f"{r.get('text', '')}\n"
|
| 110 |
+
)
|
| 111 |
+
return "\n---\n".join(parts)
|
| 112 |
+
except Exception as exc:
|
| 113 |
+
return f"Error: {exc}"
|
| 114 |
+
|
| 115 |
+
search_btn.click(fn=_call_search, inputs=search_input, outputs=search_output)
|
| 116 |
+
|
| 117 |
+
demo.launch(server_name="0.0.0.0", server_port=7860, share=share)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
if __name__ == "__main__":
|
| 121 |
+
launch_gradio()
|
src/llm_config.py
CHANGED
|
@@ -19,8 +19,14 @@ load_dotenv()
|
|
| 19 |
# Configure LangSmith tracing
|
| 20 |
os.environ["LANGCHAIN_PROJECT"] = os.getenv("LANGCHAIN_PROJECT", "MediGuard_AI_RAG_Helper")
|
| 21 |
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
|
| 26 |
def get_chat_model(
|
|
@@ -41,7 +47,8 @@ def get_chat_model(
|
|
| 41 |
Returns:
|
| 42 |
LangChain chat model instance
|
| 43 |
"""
|
| 44 |
-
|
|
|
|
| 45 |
|
| 46 |
if provider == "groq":
|
| 47 |
from langchain_groq import ChatGroq
|
|
@@ -164,9 +171,11 @@ class LLMConfig:
|
|
| 164 |
provider: LLM provider - "groq" (free), "gemini" (free), or "ollama" (local)
|
| 165 |
lazy: If True, defer model initialization until first use (avoids API key errors at import)
|
| 166 |
"""
|
| 167 |
-
|
|
|
|
| 168 |
self._lazy = lazy
|
| 169 |
self._initialized = False
|
|
|
|
| 170 |
self._lock = threading.Lock()
|
| 171 |
|
| 172 |
# Lazy-initialized model instances
|
|
@@ -181,8 +190,28 @@ class LLMConfig:
|
|
| 181 |
if not lazy:
|
| 182 |
self._initialize_models()
|
| 183 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
def _initialize_models(self):
|
| 185 |
"""Initialize all model clients (called on first use if lazy)"""
|
|
|
|
|
|
|
| 186 |
if self._initialized:
|
| 187 |
return
|
| 188 |
|
|
@@ -234,6 +263,7 @@ class LLMConfig:
|
|
| 234 |
self._embedding_model = get_embedding_model()
|
| 235 |
|
| 236 |
self._initialized = True
|
|
|
|
| 237 |
|
| 238 |
@property
|
| 239 |
def planner(self):
|
|
|
|
| 19 |
# Configure LangSmith tracing
|
| 20 |
os.environ["LANGCHAIN_PROJECT"] = os.getenv("LANGCHAIN_PROJECT", "MediGuard_AI_RAG_Helper")
|
| 21 |
|
| 22 |
+
|
| 23 |
+
def get_default_llm_provider() -> str:
|
| 24 |
+
"""Get default LLM provider dynamically from environment."""
|
| 25 |
+
return os.getenv("LLM_PROVIDER", "groq")
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# For backward compatibility (but prefer using get_default_llm_provider())
|
| 29 |
+
DEFAULT_LLM_PROVIDER = get_default_llm_provider()
|
| 30 |
|
| 31 |
|
| 32 |
def get_chat_model(
|
|
|
|
| 47 |
Returns:
|
| 48 |
LangChain chat model instance
|
| 49 |
"""
|
| 50 |
+
# Use dynamic lookup to get current provider from environment
|
| 51 |
+
provider = provider or get_default_llm_provider()
|
| 52 |
|
| 53 |
if provider == "groq":
|
| 54 |
from langchain_groq import ChatGroq
|
|
|
|
| 171 |
provider: LLM provider - "groq" (free), "gemini" (free), or "ollama" (local)
|
| 172 |
lazy: If True, defer model initialization until first use (avoids API key errors at import)
|
| 173 |
"""
|
| 174 |
+
# Store explicit provider or None to use dynamic lookup later
|
| 175 |
+
self._explicit_provider = provider
|
| 176 |
self._lazy = lazy
|
| 177 |
self._initialized = False
|
| 178 |
+
self._initialized_provider = None # Track which provider was initialized
|
| 179 |
self._lock = threading.Lock()
|
| 180 |
|
| 181 |
# Lazy-initialized model instances
|
|
|
|
| 190 |
if not lazy:
|
| 191 |
self._initialize_models()
|
| 192 |
|
| 193 |
+
@property
|
| 194 |
+
def provider(self) -> str:
|
| 195 |
+
"""Get current provider (dynamic lookup if not explicitly set)."""
|
| 196 |
+
return self._explicit_provider or get_default_llm_provider()
|
| 197 |
+
|
| 198 |
+
def _check_provider_change(self):
|
| 199 |
+
"""Check if provider changed and reinitialize if needed."""
|
| 200 |
+
current = self.provider
|
| 201 |
+
if self._initialized and self._initialized_provider != current:
|
| 202 |
+
print(f"Provider changed from {self._initialized_provider} to {current}, reinitializing...")
|
| 203 |
+
self._initialized = False
|
| 204 |
+
self._planner = None
|
| 205 |
+
self._analyzer = None
|
| 206 |
+
self._explainer = None
|
| 207 |
+
self._synthesizer_7b = None
|
| 208 |
+
self._synthesizer_8b = None
|
| 209 |
+
self._director = None
|
| 210 |
+
|
| 211 |
def _initialize_models(self):
|
| 212 |
"""Initialize all model clients (called on first use if lazy)"""
|
| 213 |
+
self._check_provider_change()
|
| 214 |
+
|
| 215 |
if self._initialized:
|
| 216 |
return
|
| 217 |
|
|
|
|
| 263 |
self._embedding_model = get_embedding_model()
|
| 264 |
|
| 265 |
self._initialized = True
|
| 266 |
+
self._initialized_provider = self.provider
|
| 267 |
|
| 268 |
@property
|
| 269 |
def planner(self):
|
src/main.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MediGuard AI — Production FastAPI Application
|
| 3 |
+
|
| 4 |
+
Central app factory with lifespan that initialises all production services
|
| 5 |
+
(OpenSearch, Redis, Ollama, Langfuse, RAG pipeline) and gracefully shuts
|
| 6 |
+
them down. The existing ``api/`` package is kept as-is — this new module
|
| 7 |
+
becomes the primary production entry-point.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import logging
|
| 13 |
+
import os
|
| 14 |
+
import time
|
| 15 |
+
from contextlib import asynccontextmanager
|
| 16 |
+
from datetime import datetime, timezone
|
| 17 |
+
|
| 18 |
+
from fastapi import FastAPI, Request, status
|
| 19 |
+
from fastapi.exceptions import RequestValidationError
|
| 20 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 21 |
+
from fastapi.responses import JSONResponse
|
| 22 |
+
|
| 23 |
+
from src.settings import get_settings
|
| 24 |
+
|
| 25 |
+
# ---------------------------------------------------------------------------
|
| 26 |
+
# Logging
|
| 27 |
+
# ---------------------------------------------------------------------------
|
| 28 |
+
logging.basicConfig(
|
| 29 |
+
level=logging.INFO,
|
| 30 |
+
format="%(asctime)s | %(name)-30s | %(levelname)-7s | %(message)s",
|
| 31 |
+
)
|
| 32 |
+
logger = logging.getLogger("mediguard")
|
| 33 |
+
|
| 34 |
+
# ---------------------------------------------------------------------------
|
| 35 |
+
# Lifespan
|
| 36 |
+
# ---------------------------------------------------------------------------
|
| 37 |
+
|
| 38 |
+
@asynccontextmanager
|
| 39 |
+
async def lifespan(app: FastAPI):
|
| 40 |
+
"""Initialise production services on startup, tear them down on shutdown."""
|
| 41 |
+
settings = get_settings()
|
| 42 |
+
app.state.start_time = time.time()
|
| 43 |
+
app.state.version = "2.0.0"
|
| 44 |
+
|
| 45 |
+
logger.info("=" * 70)
|
| 46 |
+
logger.info("MediGuard AI — starting production server v%s", app.state.version)
|
| 47 |
+
logger.info("=" * 70)
|
| 48 |
+
|
| 49 |
+
# --- OpenSearch ---
|
| 50 |
+
try:
|
| 51 |
+
from src.services.opensearch.client import make_opensearch_client
|
| 52 |
+
app.state.opensearch_client = make_opensearch_client()
|
| 53 |
+
logger.info("OpenSearch client ready")
|
| 54 |
+
except Exception as exc:
|
| 55 |
+
logger.warning("OpenSearch unavailable: %s", exc)
|
| 56 |
+
app.state.opensearch_client = None
|
| 57 |
+
|
| 58 |
+
# --- Embedding service ---
|
| 59 |
+
try:
|
| 60 |
+
from src.services.embeddings.service import make_embedding_service
|
| 61 |
+
app.state.embedding_service = make_embedding_service()
|
| 62 |
+
logger.info("Embedding service ready (provider=%s)", app.state.embedding_service._provider)
|
| 63 |
+
except Exception as exc:
|
| 64 |
+
logger.warning("Embedding service unavailable: %s", exc)
|
| 65 |
+
app.state.embedding_service = None
|
| 66 |
+
|
| 67 |
+
# --- Redis cache ---
|
| 68 |
+
try:
|
| 69 |
+
from src.services.cache.redis_cache import make_redis_cache
|
| 70 |
+
app.state.cache = make_redis_cache()
|
| 71 |
+
logger.info("Redis cache ready")
|
| 72 |
+
except Exception as exc:
|
| 73 |
+
logger.warning("Redis cache unavailable: %s", exc)
|
| 74 |
+
app.state.cache = None
|
| 75 |
+
|
| 76 |
+
# --- Ollama LLM ---
|
| 77 |
+
try:
|
| 78 |
+
from src.services.ollama.client import make_ollama_client
|
| 79 |
+
app.state.ollama_client = make_ollama_client()
|
| 80 |
+
logger.info("Ollama client ready")
|
| 81 |
+
except Exception as exc:
|
| 82 |
+
logger.warning("Ollama client unavailable: %s", exc)
|
| 83 |
+
app.state.ollama_client = None
|
| 84 |
+
|
| 85 |
+
# --- Langfuse tracer ---
|
| 86 |
+
try:
|
| 87 |
+
from src.services.langfuse.tracer import make_langfuse_tracer
|
| 88 |
+
app.state.tracer = make_langfuse_tracer()
|
| 89 |
+
logger.info("Langfuse tracer ready")
|
| 90 |
+
except Exception as exc:
|
| 91 |
+
logger.warning("Langfuse tracer unavailable: %s", exc)
|
| 92 |
+
app.state.tracer = None
|
| 93 |
+
|
| 94 |
+
# --- Agentic RAG service ---
|
| 95 |
+
try:
|
| 96 |
+
from src.services.agents.agentic_rag import AgenticRAGService
|
| 97 |
+
from src.services.agents.context import AgenticContext
|
| 98 |
+
|
| 99 |
+
if app.state.ollama_client and app.state.opensearch_client and app.state.embedding_service:
|
| 100 |
+
llm = app.state.ollama_client.get_langchain_model()
|
| 101 |
+
ctx = AgenticContext(
|
| 102 |
+
llm=llm,
|
| 103 |
+
embedding_service=app.state.embedding_service,
|
| 104 |
+
opensearch_client=app.state.opensearch_client,
|
| 105 |
+
cache=app.state.cache,
|
| 106 |
+
tracer=app.state.tracer,
|
| 107 |
+
)
|
| 108 |
+
app.state.rag_service = AgenticRAGService(ctx)
|
| 109 |
+
logger.info("Agentic RAG service ready")
|
| 110 |
+
else:
|
| 111 |
+
app.state.rag_service = None
|
| 112 |
+
logger.warning("Agentic RAG service skipped — missing backing services")
|
| 113 |
+
except Exception as exc:
|
| 114 |
+
logger.warning("Agentic RAG service failed: %s", exc)
|
| 115 |
+
app.state.rag_service = None
|
| 116 |
+
|
| 117 |
+
# --- Legacy RagBot service (backward-compatible /analyze) ---
|
| 118 |
+
try:
|
| 119 |
+
from api.app.services.ragbot import get_ragbot_service
|
| 120 |
+
ragbot = get_ragbot_service()
|
| 121 |
+
ragbot.initialize()
|
| 122 |
+
app.state.ragbot_service = ragbot
|
| 123 |
+
logger.info("Legacy RagBot service ready")
|
| 124 |
+
except Exception as exc:
|
| 125 |
+
logger.warning("Legacy RagBot service unavailable: %s", exc)
|
| 126 |
+
app.state.ragbot_service = None
|
| 127 |
+
|
| 128 |
+
logger.info("All services initialised — ready to serve")
|
| 129 |
+
logger.info("=" * 70)
|
| 130 |
+
|
| 131 |
+
yield # ---- server running ----
|
| 132 |
+
|
| 133 |
+
logger.info("Shutting down MediGuard AI …")
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
# ---------------------------------------------------------------------------
|
| 137 |
+
# App factory
|
| 138 |
+
# ---------------------------------------------------------------------------
|
| 139 |
+
|
| 140 |
+
def create_app() -> FastAPI:
|
| 141 |
+
"""Build and return the configured FastAPI application."""
|
| 142 |
+
settings = get_settings()
|
| 143 |
+
|
| 144 |
+
app = FastAPI(
|
| 145 |
+
title="MediGuard AI",
|
| 146 |
+
description="Production medical biomarker analysis — agentic RAG + multi-agent workflow",
|
| 147 |
+
version="2.0.0",
|
| 148 |
+
lifespan=lifespan,
|
| 149 |
+
docs_url="/docs",
|
| 150 |
+
redoc_url="/redoc",
|
| 151 |
+
openapi_url="/openapi.json",
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
# --- CORS ---
|
| 155 |
+
origins = os.getenv("CORS_ALLOWED_ORIGINS", "*").split(",")
|
| 156 |
+
app.add_middleware(
|
| 157 |
+
CORSMiddleware,
|
| 158 |
+
allow_origins=origins,
|
| 159 |
+
allow_credentials=origins != ["*"],
|
| 160 |
+
allow_methods=["*"],
|
| 161 |
+
allow_headers=["*"],
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
# --- Exception handlers ---
|
| 165 |
+
@app.exception_handler(RequestValidationError)
|
| 166 |
+
async def validation_error(request: Request, exc: RequestValidationError):
|
| 167 |
+
return JSONResponse(
|
| 168 |
+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
| 169 |
+
content={
|
| 170 |
+
"status": "error",
|
| 171 |
+
"error_code": "VALIDATION_ERROR",
|
| 172 |
+
"message": "Request validation failed",
|
| 173 |
+
"details": exc.errors(),
|
| 174 |
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
| 175 |
+
},
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
@app.exception_handler(Exception)
|
| 179 |
+
async def catch_all(request: Request, exc: Exception):
|
| 180 |
+
logger.error("Unhandled exception: %s", exc, exc_info=True)
|
| 181 |
+
return JSONResponse(
|
| 182 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 183 |
+
content={
|
| 184 |
+
"status": "error",
|
| 185 |
+
"error_code": "INTERNAL_SERVER_ERROR",
|
| 186 |
+
"message": "An unexpected error occurred. Please try again later.",
|
| 187 |
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
| 188 |
+
},
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
# --- Routers ---
|
| 192 |
+
from src.routers import health, analyze, ask, search
|
| 193 |
+
|
| 194 |
+
app.include_router(health.router)
|
| 195 |
+
app.include_router(analyze.router)
|
| 196 |
+
app.include_router(ask.router)
|
| 197 |
+
app.include_router(search.router)
|
| 198 |
+
|
| 199 |
+
@app.get("/")
|
| 200 |
+
async def root():
|
| 201 |
+
return {
|
| 202 |
+
"name": "MediGuard AI",
|
| 203 |
+
"version": "2.0.0",
|
| 204 |
+
"status": "online",
|
| 205 |
+
"endpoints": {
|
| 206 |
+
"health": "/health",
|
| 207 |
+
"health_ready": "/health/ready",
|
| 208 |
+
"analyze_natural": "/analyze/natural",
|
| 209 |
+
"analyze_structured": "/analyze/structured",
|
| 210 |
+
"ask": "/ask",
|
| 211 |
+
"search": "/search",
|
| 212 |
+
"docs": "/docs",
|
| 213 |
+
},
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
return app
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
# Module-level app for ``uvicorn src.main:app``
|
| 220 |
+
app = create_app()
|
src/repositories/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""MediGuard AI — Repositories package."""
|
src/repositories/analysis.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MediGuard AI — Analysis repository (data-access layer).
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
from typing import List, Optional
|
| 8 |
+
|
| 9 |
+
from sqlalchemy.orm import Session
|
| 10 |
+
|
| 11 |
+
from src.models.analysis import PatientAnalysis
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class AnalysisRepository:
|
| 15 |
+
"""CRUD operations for patient analyses."""
|
| 16 |
+
|
| 17 |
+
def __init__(self, db: Session):
|
| 18 |
+
self.db = db
|
| 19 |
+
|
| 20 |
+
def create(self, analysis: PatientAnalysis) -> PatientAnalysis:
|
| 21 |
+
self.db.add(analysis)
|
| 22 |
+
self.db.flush()
|
| 23 |
+
return analysis
|
| 24 |
+
|
| 25 |
+
def get_by_request_id(self, request_id: str) -> Optional[PatientAnalysis]:
|
| 26 |
+
return (
|
| 27 |
+
self.db.query(PatientAnalysis)
|
| 28 |
+
.filter(PatientAnalysis.request_id == request_id)
|
| 29 |
+
.first()
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
def list_recent(self, limit: int = 20) -> List[PatientAnalysis]:
|
| 33 |
+
return (
|
| 34 |
+
self.db.query(PatientAnalysis)
|
| 35 |
+
.order_by(PatientAnalysis.created_at.desc())
|
| 36 |
+
.limit(limit)
|
| 37 |
+
.all()
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
def count(self) -> int:
|
| 41 |
+
return self.db.query(PatientAnalysis).count()
|
src/repositories/document.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MediGuard AI — Document repository.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
from typing import List, Optional
|
| 8 |
+
|
| 9 |
+
from sqlalchemy.orm import Session
|
| 10 |
+
|
| 11 |
+
from src.models.analysis import MedicalDocument
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class DocumentRepository:
|
| 15 |
+
"""CRUD for ingested medical documents."""
|
| 16 |
+
|
| 17 |
+
def __init__(self, db: Session):
|
| 18 |
+
self.db = db
|
| 19 |
+
|
| 20 |
+
def upsert(self, doc: MedicalDocument) -> MedicalDocument:
|
| 21 |
+
existing = (
|
| 22 |
+
self.db.query(MedicalDocument)
|
| 23 |
+
.filter(MedicalDocument.content_hash == doc.content_hash)
|
| 24 |
+
.first()
|
| 25 |
+
)
|
| 26 |
+
if existing:
|
| 27 |
+
existing.parse_status = doc.parse_status
|
| 28 |
+
existing.chunk_count = doc.chunk_count
|
| 29 |
+
existing.indexed_at = doc.indexed_at
|
| 30 |
+
self.db.flush()
|
| 31 |
+
return existing
|
| 32 |
+
self.db.add(doc)
|
| 33 |
+
self.db.flush()
|
| 34 |
+
return doc
|
| 35 |
+
|
| 36 |
+
def get_by_id(self, doc_id: str) -> Optional[MedicalDocument]:
|
| 37 |
+
return self.db.query(MedicalDocument).filter(MedicalDocument.id == doc_id).first()
|
| 38 |
+
|
| 39 |
+
def list_all(self, limit: int = 100) -> List[MedicalDocument]:
|
| 40 |
+
return (
|
| 41 |
+
self.db.query(MedicalDocument)
|
| 42 |
+
.order_by(MedicalDocument.created_at.desc())
|
| 43 |
+
.limit(limit)
|
| 44 |
+
.all()
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
def count(self) -> int:
|
| 48 |
+
return self.db.query(MedicalDocument).count()
|
src/routers/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""MediGuard AI — Production API routers."""
|
src/routers/analyze.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MediGuard AI — Analyze Router
|
| 3 |
+
|
| 4 |
+
Backward-compatible /analyze/natural and /analyze/structured endpoints
|
| 5 |
+
that delegate to the existing ClinicalInsightGuild workflow.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import logging
|
| 11 |
+
import time
|
| 12 |
+
import uuid
|
| 13 |
+
from datetime import datetime, timezone
|
| 14 |
+
from typing import Any, Dict
|
| 15 |
+
|
| 16 |
+
from fastapi import APIRouter, HTTPException, Request
|
| 17 |
+
|
| 18 |
+
from src.schemas.schemas import (
|
| 19 |
+
AnalysisResponse,
|
| 20 |
+
ErrorResponse,
|
| 21 |
+
NaturalAnalysisRequest,
|
| 22 |
+
StructuredAnalysisRequest,
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
logger = logging.getLogger(__name__)
|
| 26 |
+
router = APIRouter(prefix="/analyze", tags=["analysis"])
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
async def _run_guild_analysis(
|
| 30 |
+
request: Request,
|
| 31 |
+
biomarkers: Dict[str, float],
|
| 32 |
+
patient_ctx: Dict[str, Any],
|
| 33 |
+
extracted_biomarkers: Dict[str, float] | None = None,
|
| 34 |
+
) -> AnalysisResponse:
|
| 35 |
+
"""Execute the ClinicalInsightGuild and build the response envelope."""
|
| 36 |
+
request_id = f"req_{uuid.uuid4().hex[:12]}"
|
| 37 |
+
t0 = time.time()
|
| 38 |
+
|
| 39 |
+
ragbot = getattr(request.app.state, "ragbot_service", None)
|
| 40 |
+
if ragbot is None:
|
| 41 |
+
raise HTTPException(status_code=503, detail="Analysis service unavailable")
|
| 42 |
+
|
| 43 |
+
try:
|
| 44 |
+
result = await ragbot.analyze(biomarkers, patient_ctx)
|
| 45 |
+
except Exception as exc:
|
| 46 |
+
logger.exception("Guild analysis failed: %s", exc)
|
| 47 |
+
raise HTTPException(
|
| 48 |
+
status_code=500,
|
| 49 |
+
detail=f"Analysis pipeline error: {exc}",
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
elapsed = (time.time() - t0) * 1000
|
| 53 |
+
|
| 54 |
+
# The guild returns a dict shaped like AnalysisResponse — pass through
|
| 55 |
+
return AnalysisResponse(
|
| 56 |
+
status="success",
|
| 57 |
+
request_id=request_id,
|
| 58 |
+
timestamp=datetime.now(timezone.utc).isoformat(),
|
| 59 |
+
extracted_biomarkers=extracted_biomarkers,
|
| 60 |
+
input_biomarkers=biomarkers,
|
| 61 |
+
patient_context=patient_ctx,
|
| 62 |
+
processing_time_ms=round(elapsed, 1),
|
| 63 |
+
**{k: v for k, v in result.items() if k not in ("status", "request_id", "timestamp", "extracted_biomarkers", "input_biomarkers", "patient_context", "processing_time_ms")},
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
@router.post("/natural", response_model=AnalysisResponse)
|
| 68 |
+
async def analyze_natural(body: NaturalAnalysisRequest, request: Request):
|
| 69 |
+
"""Extract biomarkers from natural language and run full analysis."""
|
| 70 |
+
extraction_svc = getattr(request.app.state, "extraction_service", None)
|
| 71 |
+
if extraction_svc is None:
|
| 72 |
+
raise HTTPException(status_code=503, detail="Extraction service unavailable")
|
| 73 |
+
|
| 74 |
+
try:
|
| 75 |
+
extracted = await extraction_svc.extract_biomarkers(body.message)
|
| 76 |
+
except Exception as exc:
|
| 77 |
+
logger.exception("Biomarker extraction failed: %s", exc)
|
| 78 |
+
raise HTTPException(status_code=422, detail=f"Could not extract biomarkers: {exc}")
|
| 79 |
+
|
| 80 |
+
patient_ctx = body.patient_context.model_dump(exclude_none=True) if body.patient_context else {}
|
| 81 |
+
return await _run_guild_analysis(request, extracted, patient_ctx, extracted_biomarkers=extracted)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
@router.post("/structured", response_model=AnalysisResponse)
|
| 85 |
+
async def analyze_structured(body: StructuredAnalysisRequest, request: Request):
|
| 86 |
+
"""Run full analysis on pre-structured biomarker data."""
|
| 87 |
+
patient_ctx = body.patient_context.model_dump(exclude_none=True) if body.patient_context else {}
|
| 88 |
+
return await _run_guild_analysis(request, body.biomarkers, patient_ctx)
|
src/routers/ask.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MediGuard AI — Ask Router
|
| 3 |
+
|
| 4 |
+
Free-form medical Q&A powered by the agentic RAG pipeline.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import logging
|
| 10 |
+
import time
|
| 11 |
+
import uuid
|
| 12 |
+
from datetime import datetime, timezone
|
| 13 |
+
|
| 14 |
+
from fastapi import APIRouter, HTTPException, Request
|
| 15 |
+
|
| 16 |
+
from src.schemas.schemas import AskRequest, AskResponse
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
router = APIRouter(tags=["ask"])
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@router.post("/ask", response_model=AskResponse)
|
| 23 |
+
async def ask_medical_question(body: AskRequest, request: Request):
|
| 24 |
+
"""Answer a free-form medical question via agentic RAG."""
|
| 25 |
+
rag_service = getattr(request.app.state, "rag_service", None)
|
| 26 |
+
if rag_service is None:
|
| 27 |
+
raise HTTPException(status_code=503, detail="RAG service unavailable")
|
| 28 |
+
|
| 29 |
+
request_id = f"req_{uuid.uuid4().hex[:12]}"
|
| 30 |
+
t0 = time.time()
|
| 31 |
+
|
| 32 |
+
try:
|
| 33 |
+
result = rag_service.ask(
|
| 34 |
+
query=body.question,
|
| 35 |
+
biomarkers=body.biomarkers,
|
| 36 |
+
patient_context=body.patient_context or "",
|
| 37 |
+
)
|
| 38 |
+
except Exception as exc:
|
| 39 |
+
logger.exception("Agentic RAG failed: %s", exc)
|
| 40 |
+
raise HTTPException(status_code=500, detail=f"RAG pipeline error: {exc}")
|
| 41 |
+
|
| 42 |
+
elapsed = (time.time() - t0) * 1000
|
| 43 |
+
|
| 44 |
+
return AskResponse(
|
| 45 |
+
status="success",
|
| 46 |
+
request_id=request_id,
|
| 47 |
+
question=body.question,
|
| 48 |
+
answer=result.get("final_answer", ""),
|
| 49 |
+
guardrail_score=result.get("guardrail_score"),
|
| 50 |
+
documents_retrieved=len(result.get("retrieved_documents", [])),
|
| 51 |
+
documents_relevant=len(result.get("relevant_documents", [])),
|
| 52 |
+
processing_time_ms=round(elapsed, 1),
|
| 53 |
+
)
|
src/routers/health.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MediGuard AI — Health Router
|
| 3 |
+
|
| 4 |
+
Provides /health and /health/ready with per-service checks.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import time
|
| 10 |
+
from datetime import datetime, timezone
|
| 11 |
+
|
| 12 |
+
from fastapi import APIRouter, Request
|
| 13 |
+
|
| 14 |
+
from src.schemas.schemas import HealthResponse, ServiceHealth
|
| 15 |
+
|
| 16 |
+
router = APIRouter(tags=["health"])
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@router.get("/health", response_model=HealthResponse)
|
| 20 |
+
async def health_check(request: Request) -> HealthResponse:
|
| 21 |
+
"""Shallow liveness probe."""
|
| 22 |
+
app_state = request.app.state
|
| 23 |
+
uptime = time.time() - getattr(app_state, "start_time", time.time())
|
| 24 |
+
return HealthResponse(
|
| 25 |
+
status="healthy",
|
| 26 |
+
timestamp=datetime.now(timezone.utc).isoformat(),
|
| 27 |
+
version=getattr(app_state, "version", "2.0.0"),
|
| 28 |
+
uptime_seconds=round(uptime, 2),
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@router.get("/health/ready", response_model=HealthResponse)
|
| 33 |
+
async def readiness_check(request: Request) -> HealthResponse:
|
| 34 |
+
"""Deep readiness probe — checks all backing services."""
|
| 35 |
+
app_state = request.app.state
|
| 36 |
+
uptime = time.time() - getattr(app_state, "start_time", time.time())
|
| 37 |
+
services: list[ServiceHealth] = []
|
| 38 |
+
overall = "healthy"
|
| 39 |
+
|
| 40 |
+
# --- OpenSearch ---
|
| 41 |
+
try:
|
| 42 |
+
os_client = getattr(app_state, "opensearch_client", None)
|
| 43 |
+
if os_client is not None:
|
| 44 |
+
t0 = time.time()
|
| 45 |
+
info = os_client.health()
|
| 46 |
+
latency = (time.time() - t0) * 1000
|
| 47 |
+
os_status = info.get("status", "unknown")
|
| 48 |
+
services.append(ServiceHealth(name="opensearch", status="ok" if os_status in ("green", "yellow") else "degraded", latency_ms=round(latency, 1)))
|
| 49 |
+
else:
|
| 50 |
+
services.append(ServiceHealth(name="opensearch", status="unavailable"))
|
| 51 |
+
except Exception as exc:
|
| 52 |
+
services.append(ServiceHealth(name="opensearch", status="unavailable", detail=str(exc)))
|
| 53 |
+
overall = "degraded"
|
| 54 |
+
|
| 55 |
+
# --- Redis ---
|
| 56 |
+
try:
|
| 57 |
+
cache = getattr(app_state, "cache", None)
|
| 58 |
+
if cache is not None:
|
| 59 |
+
t0 = time.time()
|
| 60 |
+
cache.set("__health__", "ok", ttl=10)
|
| 61 |
+
latency = (time.time() - t0) * 1000
|
| 62 |
+
services.append(ServiceHealth(name="redis", status="ok", latency_ms=round(latency, 1)))
|
| 63 |
+
else:
|
| 64 |
+
services.append(ServiceHealth(name="redis", status="unavailable"))
|
| 65 |
+
except Exception as exc:
|
| 66 |
+
services.append(ServiceHealth(name="redis", status="unavailable", detail=str(exc)))
|
| 67 |
+
|
| 68 |
+
# --- Ollama ---
|
| 69 |
+
try:
|
| 70 |
+
ollama = getattr(app_state, "ollama_client", None)
|
| 71 |
+
if ollama is not None:
|
| 72 |
+
t0 = time.time()
|
| 73 |
+
healthy = ollama.health()
|
| 74 |
+
latency = (time.time() - t0) * 1000
|
| 75 |
+
services.append(ServiceHealth(name="ollama", status="ok" if healthy else "degraded", latency_ms=round(latency, 1)))
|
| 76 |
+
else:
|
| 77 |
+
services.append(ServiceHealth(name="ollama", status="unavailable"))
|
| 78 |
+
except Exception as exc:
|
| 79 |
+
services.append(ServiceHealth(name="ollama", status="unavailable", detail=str(exc)))
|
| 80 |
+
overall = "degraded"
|
| 81 |
+
|
| 82 |
+
# --- Langfuse ---
|
| 83 |
+
try:
|
| 84 |
+
tracer = getattr(app_state, "tracer", None)
|
| 85 |
+
if tracer is not None:
|
| 86 |
+
services.append(ServiceHealth(name="langfuse", status="ok"))
|
| 87 |
+
else:
|
| 88 |
+
services.append(ServiceHealth(name="langfuse", status="unavailable"))
|
| 89 |
+
except Exception as exc:
|
| 90 |
+
services.append(ServiceHealth(name="langfuse", status="unavailable", detail=str(exc)))
|
| 91 |
+
|
| 92 |
+
if any(s.status == "unavailable" for s in services if s.name in ("opensearch", "ollama")):
|
| 93 |
+
overall = "unhealthy"
|
| 94 |
+
|
| 95 |
+
return HealthResponse(
|
| 96 |
+
status=overall,
|
| 97 |
+
timestamp=datetime.now(timezone.utc).isoformat(),
|
| 98 |
+
version=getattr(app_state, "version", "2.0.0"),
|
| 99 |
+
uptime_seconds=round(uptime, 2),
|
| 100 |
+
services=services,
|
| 101 |
+
)
|
src/routers/search.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MediGuard AI — Search Router
|
| 3 |
+
|
| 4 |
+
Direct hybrid search endpoint (no LLM generation).
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import logging
|
| 10 |
+
import time
|
| 11 |
+
|
| 12 |
+
from fastapi import APIRouter, HTTPException, Request
|
| 13 |
+
|
| 14 |
+
from src.schemas.schemas import SearchRequest, SearchResponse
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
router = APIRouter(tags=["search"])
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@router.post("/search", response_model=SearchResponse)
|
| 21 |
+
async def hybrid_search(body: SearchRequest, request: Request):
|
| 22 |
+
"""Execute a direct hybrid search against the OpenSearch index."""
|
| 23 |
+
os_client = getattr(request.app.state, "opensearch_client", None)
|
| 24 |
+
embedding_service = getattr(request.app.state, "embedding_service", None)
|
| 25 |
+
|
| 26 |
+
if os_client is None:
|
| 27 |
+
raise HTTPException(status_code=503, detail="Search service unavailable")
|
| 28 |
+
|
| 29 |
+
t0 = time.time()
|
| 30 |
+
|
| 31 |
+
try:
|
| 32 |
+
if body.mode == "bm25":
|
| 33 |
+
results = os_client.search_bm25(query_text=body.query, top_k=body.top_k)
|
| 34 |
+
elif body.mode == "vector":
|
| 35 |
+
if embedding_service is None:
|
| 36 |
+
raise HTTPException(status_code=503, detail="Embedding service unavailable for vector search")
|
| 37 |
+
vec = embedding_service.embed_query(body.query)
|
| 38 |
+
results = os_client.search_vector(query_vector=vec, top_k=body.top_k)
|
| 39 |
+
else:
|
| 40 |
+
# hybrid
|
| 41 |
+
if embedding_service is None:
|
| 42 |
+
logger.warning("Embedding service unavailable — falling back to BM25")
|
| 43 |
+
results = os_client.search_bm25(query_text=body.query, top_k=body.top_k)
|
| 44 |
+
else:
|
| 45 |
+
vec = embedding_service.embed_query(body.query)
|
| 46 |
+
results = os_client.search_hybrid(query_text=body.query, query_vector=vec, top_k=body.top_k)
|
| 47 |
+
except HTTPException:
|
| 48 |
+
raise
|
| 49 |
+
except Exception as exc:
|
| 50 |
+
logger.exception("Search failed: %s", exc)
|
| 51 |
+
raise HTTPException(status_code=500, detail=f"Search error: {exc}")
|
| 52 |
+
|
| 53 |
+
elapsed = (time.time() - t0) * 1000
|
| 54 |
+
|
| 55 |
+
formatted = [
|
| 56 |
+
{
|
| 57 |
+
"id": hit.get("_id", ""),
|
| 58 |
+
"score": hit.get("_score", 0.0),
|
| 59 |
+
"title": hit.get("_source", {}).get("title", ""),
|
| 60 |
+
"section": hit.get("_source", {}).get("section_title", ""),
|
| 61 |
+
"text": hit.get("_source", {}).get("chunk_text", "")[:500],
|
| 62 |
+
}
|
| 63 |
+
for hit in results
|
| 64 |
+
]
|
| 65 |
+
|
| 66 |
+
return SearchResponse(
|
| 67 |
+
query=body.query,
|
| 68 |
+
mode=body.mode,
|
| 69 |
+
total_hits=len(formatted),
|
| 70 |
+
results=formatted,
|
| 71 |
+
processing_time_ms=round(elapsed, 1),
|
| 72 |
+
)
|
src/schemas/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""MediGuard AI — API request/response schemas."""
|
src/schemas/schemas.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MediGuard AI — Production API Schemas
|
| 3 |
+
|
| 4 |
+
Pydantic v2 request/response models for the new production API layer.
|
| 5 |
+
Keeps backward compatibility with existing schemas where possible.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from datetime import datetime
|
| 11 |
+
from typing import Any, Dict, List, Optional
|
| 12 |
+
|
| 13 |
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
# ============================================================================
|
| 17 |
+
# REQUEST MODELS
|
| 18 |
+
# ============================================================================
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class PatientContext(BaseModel):
|
| 22 |
+
"""Patient demographic and context information."""
|
| 23 |
+
|
| 24 |
+
age: Optional[int] = Field(None, ge=0, le=120, description="Patient age in years")
|
| 25 |
+
gender: Optional[str] = Field(None, description="Patient gender (male/female)")
|
| 26 |
+
bmi: Optional[float] = Field(None, ge=10, le=60, description="Body Mass Index")
|
| 27 |
+
patient_id: Optional[str] = Field(None, description="Patient identifier")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class NaturalAnalysisRequest(BaseModel):
|
| 31 |
+
"""Natural language biomarker analysis request."""
|
| 32 |
+
|
| 33 |
+
message: str = Field(
|
| 34 |
+
..., min_length=5, max_length=2000,
|
| 35 |
+
description="Natural language message with biomarker values",
|
| 36 |
+
)
|
| 37 |
+
patient_context: Optional[PatientContext] = Field(
|
| 38 |
+
default_factory=PatientContext,
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class StructuredAnalysisRequest(BaseModel):
|
| 43 |
+
"""Structured biomarker analysis request."""
|
| 44 |
+
|
| 45 |
+
biomarkers: Dict[str, float] = Field(
|
| 46 |
+
..., description="Dict of biomarker name → measured value",
|
| 47 |
+
)
|
| 48 |
+
patient_context: Optional[PatientContext] = Field(
|
| 49 |
+
default_factory=PatientContext,
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
@field_validator("biomarkers")
|
| 53 |
+
@classmethod
|
| 54 |
+
def biomarkers_not_empty(cls, v: Dict[str, float]) -> Dict[str, float]:
|
| 55 |
+
if not v:
|
| 56 |
+
raise ValueError("biomarkers must contain at least one entry")
|
| 57 |
+
return v
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class AskRequest(BaseModel):
|
| 61 |
+
"""Free‑form medical question (agentic RAG pipeline)."""
|
| 62 |
+
|
| 63 |
+
question: str = Field(
|
| 64 |
+
..., min_length=3, max_length=4000,
|
| 65 |
+
description="Medical question",
|
| 66 |
+
)
|
| 67 |
+
biomarkers: Optional[Dict[str, float]] = Field(
|
| 68 |
+
None, description="Optional biomarker context",
|
| 69 |
+
)
|
| 70 |
+
patient_context: Optional[str] = Field(
|
| 71 |
+
None, description="Free‑text patient context",
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class SearchRequest(BaseModel):
|
| 76 |
+
"""Direct hybrid search (no LLM generation)."""
|
| 77 |
+
|
| 78 |
+
query: str = Field(..., min_length=2, max_length=1000)
|
| 79 |
+
top_k: int = Field(10, ge=1, le=100)
|
| 80 |
+
mode: str = Field("hybrid", description="Search mode: bm25 | vector | hybrid")
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
# ============================================================================
|
| 84 |
+
# RESPONSE BUILDING BLOCKS
|
| 85 |
+
# ============================================================================
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
class BiomarkerFlag(BaseModel):
|
| 89 |
+
name: str
|
| 90 |
+
value: float
|
| 91 |
+
unit: str
|
| 92 |
+
status: str
|
| 93 |
+
reference_range: str
|
| 94 |
+
warning: Optional[str] = None
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
class SafetyAlert(BaseModel):
|
| 98 |
+
severity: str
|
| 99 |
+
biomarker: Optional[str] = None
|
| 100 |
+
message: str
|
| 101 |
+
action: str
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
class KeyDriver(BaseModel):
|
| 105 |
+
biomarker: str
|
| 106 |
+
value: Any
|
| 107 |
+
contribution: Optional[str] = None
|
| 108 |
+
explanation: str
|
| 109 |
+
evidence: Optional[str] = None
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
class Prediction(BaseModel):
|
| 113 |
+
disease: str
|
| 114 |
+
confidence: float = Field(ge=0, le=1)
|
| 115 |
+
probabilities: Dict[str, float]
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
class DiseaseExplanation(BaseModel):
|
| 119 |
+
pathophysiology: str
|
| 120 |
+
citations: List[str] = Field(default_factory=list)
|
| 121 |
+
retrieved_chunks: Optional[List[Dict[str, Any]]] = None
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
class Recommendations(BaseModel):
|
| 125 |
+
immediate_actions: List[str] = Field(default_factory=list)
|
| 126 |
+
lifestyle_changes: List[str] = Field(default_factory=list)
|
| 127 |
+
monitoring: List[str] = Field(default_factory=list)
|
| 128 |
+
follow_up: Optional[str] = None
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
class ConfidenceAssessment(BaseModel):
|
| 132 |
+
prediction_reliability: str
|
| 133 |
+
evidence_strength: str
|
| 134 |
+
limitations: List[str] = Field(default_factory=list)
|
| 135 |
+
reasoning: Optional[str] = None
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
class AgentOutput(BaseModel):
|
| 139 |
+
agent_name: str
|
| 140 |
+
findings: Any
|
| 141 |
+
metadata: Optional[Dict[str, Any]] = None
|
| 142 |
+
execution_time_ms: Optional[float] = None
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
class Analysis(BaseModel):
|
| 146 |
+
biomarker_flags: List[BiomarkerFlag]
|
| 147 |
+
safety_alerts: List[SafetyAlert]
|
| 148 |
+
key_drivers: List[KeyDriver]
|
| 149 |
+
disease_explanation: DiseaseExplanation
|
| 150 |
+
recommendations: Recommendations
|
| 151 |
+
confidence_assessment: ConfidenceAssessment
|
| 152 |
+
alternative_diagnoses: Optional[List[Dict[str, Any]]] = None
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
# ============================================================================
|
| 156 |
+
# TOP‑LEVEL RESPONSES
|
| 157 |
+
# ============================================================================
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
class AnalysisResponse(BaseModel):
|
| 161 |
+
"""Full clinical analysis response (backward‑compatible)."""
|
| 162 |
+
|
| 163 |
+
status: str
|
| 164 |
+
request_id: str
|
| 165 |
+
timestamp: str
|
| 166 |
+
extracted_biomarkers: Optional[Dict[str, float]] = None
|
| 167 |
+
input_biomarkers: Dict[str, float]
|
| 168 |
+
patient_context: Dict[str, Any]
|
| 169 |
+
prediction: Prediction
|
| 170 |
+
analysis: Analysis
|
| 171 |
+
agent_outputs: List[AgentOutput]
|
| 172 |
+
workflow_metadata: Dict[str, Any]
|
| 173 |
+
conversational_summary: Optional[str] = None
|
| 174 |
+
processing_time_ms: float
|
| 175 |
+
sop_version: Optional[str] = None
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
class AskResponse(BaseModel):
|
| 179 |
+
"""Response from the agentic RAG /ask endpoint."""
|
| 180 |
+
|
| 181 |
+
status: str = "success"
|
| 182 |
+
request_id: str
|
| 183 |
+
question: str
|
| 184 |
+
answer: str
|
| 185 |
+
guardrail_score: Optional[float] = None
|
| 186 |
+
documents_retrieved: int = 0
|
| 187 |
+
documents_relevant: int = 0
|
| 188 |
+
processing_time_ms: float = 0.0
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
class SearchResponse(BaseModel):
|
| 192 |
+
"""Direct hybrid search response."""
|
| 193 |
+
|
| 194 |
+
status: str = "success"
|
| 195 |
+
query: str
|
| 196 |
+
mode: str
|
| 197 |
+
total_hits: int
|
| 198 |
+
results: List[Dict[str, Any]]
|
| 199 |
+
processing_time_ms: float = 0.0
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
class ErrorResponse(BaseModel):
|
| 203 |
+
"""Error envelope."""
|
| 204 |
+
|
| 205 |
+
status: str = "error"
|
| 206 |
+
error_code: str
|
| 207 |
+
message: str
|
| 208 |
+
details: Optional[Dict[str, Any]] = None
|
| 209 |
+
timestamp: str
|
| 210 |
+
request_id: Optional[str] = None
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
# ============================================================================
|
| 214 |
+
# HEALTH / INFO
|
| 215 |
+
# ============================================================================
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
class ServiceHealth(BaseModel):
|
| 219 |
+
name: str
|
| 220 |
+
status: str # ok | degraded | unavailable
|
| 221 |
+
latency_ms: Optional[float] = None
|
| 222 |
+
detail: Optional[str] = None
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
class HealthResponse(BaseModel):
|
| 226 |
+
"""Production health check."""
|
| 227 |
+
|
| 228 |
+
status: str # healthy | degraded | unhealthy
|
| 229 |
+
timestamp: str
|
| 230 |
+
version: str
|
| 231 |
+
uptime_seconds: float
|
| 232 |
+
services: List[ServiceHealth] = Field(default_factory=list)
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
class BiomarkerReferenceRange(BaseModel):
|
| 236 |
+
min: Optional[float] = None
|
| 237 |
+
max: Optional[float] = None
|
| 238 |
+
male: Optional[Dict[str, float]] = None
|
| 239 |
+
female: Optional[Dict[str, float]] = None
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
class BiomarkerInfo(BaseModel):
|
| 243 |
+
name: str
|
| 244 |
+
unit: str
|
| 245 |
+
normal_range: BiomarkerReferenceRange
|
| 246 |
+
critical_low: Optional[float] = None
|
| 247 |
+
critical_high: Optional[float] = None
|
src/services/agents/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""MediGuard AI — Agentic RAG agents package."""
|
src/services/agents/agentic_rag.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MediGuard AI — Agentic RAG Orchestrator
|
| 3 |
+
|
| 4 |
+
LangGraph StateGraph that wires all nodes into the guardrail → retrieve → grade → generate pipeline.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import logging
|
| 10 |
+
from functools import lru_cache, partial
|
| 11 |
+
from typing import Any
|
| 12 |
+
|
| 13 |
+
from langgraph.graph import END, StateGraph
|
| 14 |
+
|
| 15 |
+
from src.services.agents.context import AgenticContext
|
| 16 |
+
from src.services.agents.nodes.generate_answer_node import generate_answer_node
|
| 17 |
+
from src.services.agents.nodes.grade_documents_node import grade_documents_node
|
| 18 |
+
from src.services.agents.nodes.guardrail_node import guardrail_node
|
| 19 |
+
from src.services.agents.nodes.out_of_scope_node import out_of_scope_node
|
| 20 |
+
from src.services.agents.nodes.retrieve_node import retrieve_node
|
| 21 |
+
from src.services.agents.nodes.rewrite_query_node import rewrite_query_node
|
| 22 |
+
from src.services.agents.state import AgenticRAGState
|
| 23 |
+
|
| 24 |
+
logger = logging.getLogger(__name__)
|
| 25 |
+
|
| 26 |
+
# ---------------------------------------------------------------------------
|
| 27 |
+
# Edge routing helpers
|
| 28 |
+
# ---------------------------------------------------------------------------
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _route_after_guardrail(state: dict) -> str:
|
| 32 |
+
"""Decide path after guardrail evaluation."""
|
| 33 |
+
if state.get("routing_decision") == "analyze":
|
| 34 |
+
# Biomarker analysis pathway — goes straight to retrieve
|
| 35 |
+
return "retrieve"
|
| 36 |
+
if state.get("is_in_scope"):
|
| 37 |
+
return "retrieve"
|
| 38 |
+
return "out_of_scope"
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _route_after_grading(state: dict) -> str:
|
| 42 |
+
"""Decide whether to rewrite query or proceed to generation."""
|
| 43 |
+
if state.get("needs_rewrite"):
|
| 44 |
+
return "rewrite_query"
|
| 45 |
+
if not state.get("relevant_documents"):
|
| 46 |
+
return "generate_answer" # will produce a "no evidence found" answer
|
| 47 |
+
return "generate_answer"
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
# ---------------------------------------------------------------------------
|
| 51 |
+
# Graph builder
|
| 52 |
+
# ---------------------------------------------------------------------------
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def build_agentic_rag_graph(context: AgenticContext) -> Any:
|
| 56 |
+
"""Construct the compiled LangGraph for the agentic RAG pipeline.
|
| 57 |
+
|
| 58 |
+
Parameters
|
| 59 |
+
----------
|
| 60 |
+
context:
|
| 61 |
+
Runtime dependencies (LLM, OpenSearch, embeddings, cache, tracer).
|
| 62 |
+
|
| 63 |
+
Returns
|
| 64 |
+
-------
|
| 65 |
+
Compiled LangGraph graph ready for ``.invoke()`` / ``.stream()``.
|
| 66 |
+
"""
|
| 67 |
+
workflow = StateGraph(AgenticRAGState)
|
| 68 |
+
|
| 69 |
+
# Bind context to every node via functools.partial
|
| 70 |
+
workflow.add_node("guardrail", partial(guardrail_node, context=context))
|
| 71 |
+
workflow.add_node("retrieve", partial(retrieve_node, context=context))
|
| 72 |
+
workflow.add_node("grade_documents", partial(grade_documents_node, context=context))
|
| 73 |
+
workflow.add_node("rewrite_query", partial(rewrite_query_node, context=context))
|
| 74 |
+
workflow.add_node("generate_answer", partial(generate_answer_node, context=context))
|
| 75 |
+
workflow.add_node("out_of_scope", partial(out_of_scope_node, context=context))
|
| 76 |
+
|
| 77 |
+
# Entry point
|
| 78 |
+
workflow.set_entry_point("guardrail")
|
| 79 |
+
|
| 80 |
+
# Conditional edges
|
| 81 |
+
workflow.add_conditional_edges(
|
| 82 |
+
"guardrail",
|
| 83 |
+
_route_after_guardrail,
|
| 84 |
+
{
|
| 85 |
+
"retrieve": "retrieve",
|
| 86 |
+
"out_of_scope": "out_of_scope",
|
| 87 |
+
},
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
workflow.add_edge("retrieve", "grade_documents")
|
| 91 |
+
|
| 92 |
+
workflow.add_conditional_edges(
|
| 93 |
+
"grade_documents",
|
| 94 |
+
_route_after_grading,
|
| 95 |
+
{
|
| 96 |
+
"rewrite_query": "rewrite_query",
|
| 97 |
+
"generate_answer": "generate_answer",
|
| 98 |
+
},
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
# After rewrite, loop back to retrieve
|
| 102 |
+
workflow.add_edge("rewrite_query", "retrieve")
|
| 103 |
+
|
| 104 |
+
# Terminal edges
|
| 105 |
+
workflow.add_edge("generate_answer", END)
|
| 106 |
+
workflow.add_edge("out_of_scope", END)
|
| 107 |
+
|
| 108 |
+
return workflow.compile()
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
# ---------------------------------------------------------------------------
|
| 112 |
+
# Public API
|
| 113 |
+
# ---------------------------------------------------------------------------
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
class AgenticRAGService:
|
| 117 |
+
"""High-level wrapper around the compiled RAG graph."""
|
| 118 |
+
|
| 119 |
+
def __init__(self, context: AgenticContext) -> None:
|
| 120 |
+
self._context = context
|
| 121 |
+
self._graph = build_agentic_rag_graph(context)
|
| 122 |
+
|
| 123 |
+
def ask(
|
| 124 |
+
self,
|
| 125 |
+
query: str,
|
| 126 |
+
biomarkers: dict | None = None,
|
| 127 |
+
patient_context: str = "",
|
| 128 |
+
) -> dict:
|
| 129 |
+
"""Run the full agentic RAG pipeline and return the final state."""
|
| 130 |
+
initial_state: dict[str, Any] = {
|
| 131 |
+
"query": query,
|
| 132 |
+
"biomarkers": biomarkers,
|
| 133 |
+
"patient_context": patient_context,
|
| 134 |
+
"errors": [],
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
span = None
|
| 138 |
+
try:
|
| 139 |
+
if self._context.tracer:
|
| 140 |
+
span = self._context.tracer.start_span(
|
| 141 |
+
name="agentic_rag_ask",
|
| 142 |
+
metadata={"query": query},
|
| 143 |
+
)
|
| 144 |
+
result = self._graph.invoke(initial_state)
|
| 145 |
+
return result
|
| 146 |
+
except Exception as exc:
|
| 147 |
+
logger.error("Agentic RAG pipeline failed: %s", exc)
|
| 148 |
+
return {
|
| 149 |
+
**initial_state,
|
| 150 |
+
"final_answer": (
|
| 151 |
+
"I apologize, but I'm temporarily unable to process your request. "
|
| 152 |
+
"Please consult a healthcare professional."
|
| 153 |
+
),
|
| 154 |
+
"errors": [str(exc)],
|
| 155 |
+
}
|
| 156 |
+
finally:
|
| 157 |
+
if span is not None:
|
| 158 |
+
self._context.tracer.end_span(span)
|
src/services/agents/context.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MediGuard AI — Agentic RAG Context
|
| 3 |
+
|
| 4 |
+
Runtime dependency injection dataclass — passed to every LangGraph node
|
| 5 |
+
so nodes can access services without globals.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from dataclasses import dataclass
|
| 11 |
+
from typing import Any, Optional
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@dataclass(frozen=True)
|
| 15 |
+
class AgenticContext:
|
| 16 |
+
"""Immutable runtime context for agentic RAG nodes."""
|
| 17 |
+
|
| 18 |
+
llm: Any # LangChain chat model
|
| 19 |
+
embedding_service: Any # EmbeddingService
|
| 20 |
+
opensearch_client: Any # OpenSearchClient
|
| 21 |
+
cache: Any # RedisCache
|
| 22 |
+
tracer: Any # LangfuseTracer
|
| 23 |
+
guild: Optional[Any] = None # ClinicalInsightGuild (original workflow)
|
src/services/agents/medical/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""MediGuard AI — Medical agents (original 6 agents, re-exported)."""
|
src/services/agents/nodes/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""MediGuard AI — Agentic RAG nodes package."""
|
src/services/agents/nodes/generate_answer_node.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MediGuard AI — Generate Answer Node
|
| 3 |
+
|
| 4 |
+
Produces a RAG-grounded medical answer with citations.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import logging
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
from src.services.agents.prompts import RAG_GENERATION_SYSTEM
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def generate_answer_node(state: dict, *, context: Any) -> dict:
|
| 18 |
+
"""Generate a cited medical answer from relevant documents."""
|
| 19 |
+
query = state.get("rewritten_query") or state.get("query", "")
|
| 20 |
+
documents = state.get("relevant_documents", [])
|
| 21 |
+
biomarkers = state.get("biomarkers")
|
| 22 |
+
patient_context = state.get("patient_context", "")
|
| 23 |
+
|
| 24 |
+
# Build evidence block
|
| 25 |
+
evidence_parts: list[str] = []
|
| 26 |
+
for i, doc in enumerate(documents, 1):
|
| 27 |
+
title = doc.get("title", "Unknown")
|
| 28 |
+
section = doc.get("section", "")
|
| 29 |
+
text = doc.get("text", "")[:2000]
|
| 30 |
+
header = f"[{i}] {title}"
|
| 31 |
+
if section:
|
| 32 |
+
header += f" — {section}"
|
| 33 |
+
evidence_parts.append(f"{header}\n{text}")
|
| 34 |
+
evidence_block = "\n\n---\n\n".join(evidence_parts) if evidence_parts else "(No evidence retrieved)"
|
| 35 |
+
|
| 36 |
+
# Build user message
|
| 37 |
+
user_msg = f"Question: {query}\n\n"
|
| 38 |
+
if biomarkers:
|
| 39 |
+
user_msg += f"Biomarkers: {biomarkers}\n\n"
|
| 40 |
+
if patient_context:
|
| 41 |
+
user_msg += f"Patient context: {patient_context}\n\n"
|
| 42 |
+
user_msg += f"Evidence:\n{evidence_block}"
|
| 43 |
+
|
| 44 |
+
try:
|
| 45 |
+
response = context.llm.invoke(
|
| 46 |
+
[
|
| 47 |
+
{"role": "system", "content": RAG_GENERATION_SYSTEM},
|
| 48 |
+
{"role": "user", "content": user_msg},
|
| 49 |
+
]
|
| 50 |
+
)
|
| 51 |
+
answer = response.content.strip()
|
| 52 |
+
except Exception as exc:
|
| 53 |
+
logger.error("Generation LLM failed: %s", exc)
|
| 54 |
+
answer = (
|
| 55 |
+
"I apologize, but I'm temporarily unable to generate a response. "
|
| 56 |
+
"Please consult a healthcare professional for guidance."
|
| 57 |
+
)
|
| 58 |
+
return {"final_answer": answer, "errors": [str(exc)]}
|
| 59 |
+
|
| 60 |
+
return {"final_answer": answer}
|
src/services/agents/nodes/grade_documents_node.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MediGuard AI — Grade Documents Node
|
| 3 |
+
|
| 4 |
+
Uses the LLM to judge whether each retrieved document is relevant to the query.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
import logging
|
| 11 |
+
from typing import Any
|
| 12 |
+
|
| 13 |
+
from src.services.agents.prompts import GRADING_SYSTEM
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def grade_documents_node(state: dict, *, context: Any) -> dict:
|
| 19 |
+
"""Grade each retrieved document for relevance."""
|
| 20 |
+
query = state.get("rewritten_query") or state.get("query", "")
|
| 21 |
+
documents = state.get("retrieved_documents", [])
|
| 22 |
+
|
| 23 |
+
if not documents:
|
| 24 |
+
return {
|
| 25 |
+
"grading_results": [],
|
| 26 |
+
"relevant_documents": [],
|
| 27 |
+
"needs_rewrite": True,
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
relevant: list[dict] = []
|
| 31 |
+
grading_results: list[dict] = []
|
| 32 |
+
|
| 33 |
+
for doc in documents:
|
| 34 |
+
text = doc.get("text", "")
|
| 35 |
+
user_msg = f"Query: {query}\n\nDocument:\n{text[:2000]}"
|
| 36 |
+
try:
|
| 37 |
+
response = context.llm.invoke(
|
| 38 |
+
[
|
| 39 |
+
{"role": "system", "content": GRADING_SYSTEM},
|
| 40 |
+
{"role": "user", "content": user_msg},
|
| 41 |
+
]
|
| 42 |
+
)
|
| 43 |
+
content = response.content.strip()
|
| 44 |
+
if "```" in content:
|
| 45 |
+
content = content.split("```")[1].split("```")[0].strip()
|
| 46 |
+
if content.startswith("json"):
|
| 47 |
+
content = content[4:].strip()
|
| 48 |
+
data = json.loads(content)
|
| 49 |
+
is_relevant = str(data.get("relevant", "false")).lower() == "true"
|
| 50 |
+
except Exception as exc:
|
| 51 |
+
logger.warning("Grading LLM failed for doc %s: %s — marking relevant", doc.get("id"), exc)
|
| 52 |
+
is_relevant = True # benefit of the doubt
|
| 53 |
+
|
| 54 |
+
grading_results.append({"doc_id": doc.get("id"), "relevant": is_relevant})
|
| 55 |
+
if is_relevant:
|
| 56 |
+
relevant.append(doc)
|
| 57 |
+
|
| 58 |
+
needs_rewrite = len(relevant) < 2 and not state.get("rewritten_query")
|
| 59 |
+
|
| 60 |
+
return {
|
| 61 |
+
"grading_results": grading_results,
|
| 62 |
+
"relevant_documents": relevant,
|
| 63 |
+
"needs_rewrite": needs_rewrite,
|
| 64 |
+
}
|
src/services/agents/nodes/guardrail_node.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MediGuard AI — Guardrail Node
|
| 3 |
+
|
| 4 |
+
Validates that the user query is within the medical domain (score 0-100).
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
import logging
|
| 11 |
+
from typing import Any
|
| 12 |
+
|
| 13 |
+
from src.services.agents.prompts import GUARDRAIL_SYSTEM
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def guardrail_node(state: dict, *, context: Any) -> dict:
|
| 19 |
+
"""Score the query for medical relevance (0-100)."""
|
| 20 |
+
query = state.get("query", "")
|
| 21 |
+
biomarkers = state.get("biomarkers")
|
| 22 |
+
|
| 23 |
+
# Fast path: if biomarkers are provided, it's definitely medical
|
| 24 |
+
if biomarkers:
|
| 25 |
+
return {
|
| 26 |
+
"guardrail_score": 95.0,
|
| 27 |
+
"is_in_scope": True,
|
| 28 |
+
"routing_decision": "analyze",
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
try:
|
| 32 |
+
response = context.llm.invoke(
|
| 33 |
+
[
|
| 34 |
+
{"role": "system", "content": GUARDRAIL_SYSTEM},
|
| 35 |
+
{"role": "user", "content": query},
|
| 36 |
+
]
|
| 37 |
+
)
|
| 38 |
+
content = response.content.strip()
|
| 39 |
+
# Parse JSON response
|
| 40 |
+
if "```" in content:
|
| 41 |
+
content = content.split("```")[1].split("```")[0].strip()
|
| 42 |
+
if content.startswith("json"):
|
| 43 |
+
content = content[4:].strip()
|
| 44 |
+
data = json.loads(content)
|
| 45 |
+
score = float(data.get("score", 0))
|
| 46 |
+
except Exception as exc:
|
| 47 |
+
logger.warning("Guardrail LLM failed: %s — defaulting to in-scope", exc)
|
| 48 |
+
score = 70.0 # benefit of the doubt
|
| 49 |
+
|
| 50 |
+
is_in_scope = score >= 40
|
| 51 |
+
routing = "rag_answer" if is_in_scope else "out_of_scope"
|
| 52 |
+
|
| 53 |
+
return {
|
| 54 |
+
"guardrail_score": score,
|
| 55 |
+
"is_in_scope": is_in_scope,
|
| 56 |
+
"routing_decision": routing,
|
| 57 |
+
}
|
src/services/agents/nodes/out_of_scope_node.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MediGuard AI — Out-of-Scope Node
|
| 3 |
+
|
| 4 |
+
Returns a polite rejection for non-medical queries.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from typing import Any
|
| 10 |
+
|
| 11 |
+
from src.services.agents.prompts import OUT_OF_SCOPE_RESPONSE
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def out_of_scope_node(state: dict, *, context: Any) -> dict:
|
| 15 |
+
"""Return polite out-of-scope message."""
|
| 16 |
+
return {"final_answer": OUT_OF_SCOPE_RESPONSE}
|
src/services/agents/nodes/retrieve_node.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MediGuard AI — Retrieve Node
|
| 3 |
+
|
| 4 |
+
Performs hybrid search (BM25 + vector KNN) and merges results.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import logging
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def retrieve_node(state: dict, *, context: Any) -> dict:
|
| 16 |
+
"""Retrieve documents from OpenSearch via hybrid search."""
|
| 17 |
+
query = state.get("rewritten_query") or state.get("query", "")
|
| 18 |
+
|
| 19 |
+
# 1. Try cache first
|
| 20 |
+
cache_key = f"retrieve:{query}"
|
| 21 |
+
if context.cache:
|
| 22 |
+
cached = context.cache.get(cache_key)
|
| 23 |
+
if cached is not None:
|
| 24 |
+
logger.debug("Cache hit for retrieve query")
|
| 25 |
+
return {"retrieved_documents": cached}
|
| 26 |
+
|
| 27 |
+
# 2. Embed the query
|
| 28 |
+
try:
|
| 29 |
+
query_embedding = context.embedding_service.embed_query(query)
|
| 30 |
+
except Exception as exc:
|
| 31 |
+
logger.error("Embedding failed: %s", exc)
|
| 32 |
+
return {"retrieved_documents": [], "errors": [str(exc)]}
|
| 33 |
+
|
| 34 |
+
# 3. Hybrid search
|
| 35 |
+
try:
|
| 36 |
+
results = context.opensearch_client.search_hybrid(
|
| 37 |
+
query_text=query,
|
| 38 |
+
query_vector=query_embedding,
|
| 39 |
+
top_k=10,
|
| 40 |
+
)
|
| 41 |
+
except Exception as exc:
|
| 42 |
+
logger.error("OpenSearch hybrid search failed: %s — falling back to BM25", exc)
|
| 43 |
+
try:
|
| 44 |
+
results = context.opensearch_client.search_bm25(
|
| 45 |
+
query_text=query,
|
| 46 |
+
top_k=10,
|
| 47 |
+
)
|
| 48 |
+
except Exception as exc2:
|
| 49 |
+
logger.error("BM25 fallback also failed: %s", exc2)
|
| 50 |
+
return {"retrieved_documents": [], "errors": [str(exc), str(exc2)]}
|
| 51 |
+
|
| 52 |
+
documents = [
|
| 53 |
+
{
|
| 54 |
+
"id": hit.get("_id", ""),
|
| 55 |
+
"score": hit.get("_score", 0.0),
|
| 56 |
+
"text": hit.get("_source", {}).get("chunk_text", ""),
|
| 57 |
+
"title": hit.get("_source", {}).get("title", ""),
|
| 58 |
+
"section": hit.get("_source", {}).get("section_title", ""),
|
| 59 |
+
"metadata": hit.get("_source", {}),
|
| 60 |
+
}
|
| 61 |
+
for hit in results
|
| 62 |
+
]
|
| 63 |
+
|
| 64 |
+
# 4. Store in cache (5 min TTL)
|
| 65 |
+
if context.cache:
|
| 66 |
+
context.cache.set(cache_key, documents, ttl=300)
|
| 67 |
+
|
| 68 |
+
return {"retrieved_documents": documents}
|