Nikhil Pravin Pise commited on
Commit
9794258
·
2 Parent(s): ad2e847 193fabd

Merge feature/production-upgrade: HF Spaces deployment with modern UI

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env.example +61 -0
  2. .gitattributes +2 -0
  3. .gitignore +5 -2
  4. .pre-commit-config.yaml +29 -0
  5. DEPLOY_HUGGINGFACE.md +203 -0
  6. Dockerfile +66 -0
  7. Makefile +137 -0
  8. README.md +19 -0
  9. airflow/dags/ingest_pdfs.py +64 -0
  10. airflow/dags/sop_evolution.py +43 -0
  11. alembic.ini +149 -0
  12. alembic/README +1 -0
  13. alembic/env.py +95 -0
  14. alembic/script.py.mako +28 -0
  15. data/vector_stores/medical_knowledge.faiss +3 -0
  16. data/vector_stores/medical_knowledge.pkl +3 -0
  17. docker-compose.yml +168 -0
  18. huggingface/.env.example +21 -0
  19. huggingface/Dockerfile +66 -0
  20. huggingface/README.md +109 -0
  21. huggingface/app.py +1025 -0
  22. huggingface/requirements.txt +42 -0
  23. pyproject.toml +117 -0
  24. scripts/deploy_huggingface.ps1 +139 -0
  25. src/database.py +50 -0
  26. src/dependencies.py +36 -0
  27. src/exceptions.py +149 -0
  28. src/gradio_app.py +121 -0
  29. src/llm_config.py +34 -4
  30. src/main.py +220 -0
  31. src/repositories/__init__.py +1 -0
  32. src/repositories/analysis.py +41 -0
  33. src/repositories/document.py +48 -0
  34. src/routers/__init__.py +1 -0
  35. src/routers/analyze.py +88 -0
  36. src/routers/ask.py +53 -0
  37. src/routers/health.py +101 -0
  38. src/routers/search.py +72 -0
  39. src/schemas/__init__.py +1 -0
  40. src/schemas/schemas.py +247 -0
  41. src/services/agents/__init__.py +1 -0
  42. src/services/agents/agentic_rag.py +158 -0
  43. src/services/agents/context.py +23 -0
  44. src/services/agents/medical/__init__.py +1 -0
  45. src/services/agents/nodes/__init__.py +1 -0
  46. src/services/agents/nodes/generate_answer_node.py +60 -0
  47. src/services/agents/nodes/grade_documents_node.py +64 -0
  48. src/services/agents/nodes/guardrail_node.py +57 -0
  49. src/services/agents/nodes/out_of_scope_node.py +16 -0
  50. 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
- *.faiss
227
- *.pkl
 
 
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
- # Default provider (can be overridden via env)
23
- DEFAULT_LLM_PROVIDER = os.getenv("LLM_PROVIDER", "groq")
 
 
 
 
 
 
24
 
25
 
26
  def get_chat_model(
@@ -41,7 +47,8 @@ def get_chat_model(
41
  Returns:
42
  LangChain chat model instance
43
  """
44
- provider = provider or DEFAULT_LLM_PROVIDER
 
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
- self.provider = provider or DEFAULT_LLM_PROVIDER
 
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}