Spaces:
Sleeping
feat: production upgrade — agentic RAG, OpenSearch, Redis, Langfuse, Docker, Gradio, Telegram
Browse filesPhase 1: Project structure & dev tooling
- pyproject.toml with Ruff/MyPy/pytest config and optional deps
- Makefile with dev/test/docker/lint targets
- Multi-stage Dockerfile + docker-compose.yml (12 services)
- .env.example, .pre-commit-config.yaml
Phase 2: Core infrastructure
- src/settings.py: hierarchical Pydantic Settings (env-driven)
- src/exceptions.py: domain exception hierarchy (15+ classes)
- src/database.py: SQLAlchemy engine/session factory
- src/models/analysis.py: ORM models (PatientAnalysis, MedicalDocument, SOPVersion)
- src/repositories/: data access layer
Phase 3: Production services
- OpenSearch client with BM25, KNN vector, and hybrid RRF search
- Medical synonym analyzer + KNN index mapping (1024d, HNSW)
- Multi-provider embedding service (Jina/Google/HuggingFace) with fallback
- Redis cache with graceful degradation (NullCache)
- Langfuse v3 observability tracer (NullSpan for no-ops)
- Ollama REST client with streaming + LangChain integration
- Medical-aware text chunker with biomarker/condition detection
- Indexing pipeline (chunk → embed → OpenSearch)
Phase 4: Agentic RAG pipeline (LangGraph)
- Guardrail node: medical domain validation (0-100 scoring)
- Retrieve node: hybrid search with cache
- Grade documents node: LLM relevance grading
- Rewrite query node: query improvement loop
- Generate answer node: RAG with citations + safety disclaimers
- Out-of-scope node: polite rejection
- AgenticRAGService orchestrator with compiled StateGraph
Phase 5: Production FastAPI application
- src/main.py: app factory with lifespan (all services init/teardown)
- Routers: /health, /health/ready, /analyze/*, /ask, /search
- src/schemas/schemas.py: full Pydantic v2 request/response models
- src/dependencies.py: DI factories
Phase 6: Additional services
- Biomarker validation service (wraps existing validator)
- PDF parser service (Docling → PyPDF fallback)
- Telegram bot (proxies to /ask endpoint)
- Gradio web UI (ask/analyze/search tabs)
Phase 7: Orchestration
- Airflow DAGs: PDF ingestion + SOP evolution
Tests: 94 passed (11 new test files, 60+ new test cases)
- .env.example +61 -0
- .pre-commit-config.yaml +29 -0
- Dockerfile +66 -0
- Makefile +137 -0
- airflow/dags/ingest_pdfs.py +64 -0
- airflow/dags/sop_evolution.py +43 -0
- docker-compose.yml +166 -0
- pyproject.toml +117 -0
- src/database.py +50 -0
- src/dependencies.py +36 -0
- src/exceptions.py +149 -0
- src/gradio_app.py +121 -0
- src/main.py +220 -0
- src/repositories/__init__.py +1 -0
- src/repositories/analysis.py +41 -0
- src/repositories/document.py +48 -0
- src/routers/__init__.py +1 -0
- src/routers/analyze.py +88 -0
- src/routers/ask.py +53 -0
- src/routers/health.py +101 -0
- src/routers/search.py +72 -0
- src/schemas/__init__.py +1 -0
- src/schemas/schemas.py +247 -0
- src/services/agents/__init__.py +1 -0
- src/services/agents/agentic_rag.py +158 -0
- src/services/agents/context.py +23 -0
- src/services/agents/medical/__init__.py +1 -0
- src/services/agents/nodes/__init__.py +1 -0
- src/services/agents/nodes/generate_answer_node.py +60 -0
- src/services/agents/nodes/grade_documents_node.py +64 -0
- src/services/agents/nodes/guardrail_node.py +57 -0
- src/services/agents/nodes/out_of_scope_node.py +16 -0
- src/services/agents/nodes/retrieve_node.py +68 -0
- src/services/agents/nodes/rewrite_query_node.py +40 -0
- src/services/agents/prompts.py +72 -0
- src/services/agents/state.py +47 -0
- src/services/biomarker/__init__.py +1 -0
- src/services/biomarker/service.py +110 -0
- src/services/cache/__init__.py +4 -0
- src/services/cache/redis_cache.py +123 -0
- src/services/embeddings/__init__.py +4 -0
- src/services/embeddings/service.py +147 -0
- src/services/indexing/__init__.py +5 -0
- src/services/indexing/service.py +84 -0
- src/services/indexing/text_chunker.py +178 -0
- src/services/langfuse/__init__.py +4 -0
- src/services/langfuse/tracer.py +97 -0
- src/services/ollama/__init__.py +4 -0
- src/services/ollama/client.py +160 -0
- src/services/opensearch/__init__.py +5 -0
|
@@ -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=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
|
| 36 |
+
LLM__GROQ_MODEL=llama-3.3-70b-versatile
|
| 37 |
+
LLM__GEMINI_API_KEY=AIzaSyBbWG-vy44GXuZL-PgNjtvKLXrhdINCgwg
|
| 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
|
|
@@ -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]
|
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ===========================================================================
|
| 2 |
+
# MediGuard AI — Multi-stage Dockerfile
|
| 3 |
+
# ===========================================================================
|
| 4 |
+
# Build stages:
|
| 5 |
+
# base — Python + system deps
|
| 6 |
+
# production — slim runtime image
|
| 7 |
+
# ===========================================================================
|
| 8 |
+
|
| 9 |
+
# ---------------------------------------------------------------------------
|
| 10 |
+
# Stage 1: base
|
| 11 |
+
# ---------------------------------------------------------------------------
|
| 12 |
+
FROM python:3.11-slim AS base
|
| 13 |
+
|
| 14 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 15 |
+
PYTHONUNBUFFERED=1 \
|
| 16 |
+
PIP_NO_CACHE_DIR=1
|
| 17 |
+
|
| 18 |
+
WORKDIR /app
|
| 19 |
+
|
| 20 |
+
# System dependencies
|
| 21 |
+
RUN apt-get update && \
|
| 22 |
+
apt-get install -y --no-install-recommends \
|
| 23 |
+
build-essential \
|
| 24 |
+
curl \
|
| 25 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 26 |
+
|
| 27 |
+
# Install Python dependencies
|
| 28 |
+
COPY pyproject.toml ./
|
| 29 |
+
RUN pip install --upgrade pip && \
|
| 30 |
+
pip install ".[all]"
|
| 31 |
+
|
| 32 |
+
# ---------------------------------------------------------------------------
|
| 33 |
+
# Stage 2: production
|
| 34 |
+
# ---------------------------------------------------------------------------
|
| 35 |
+
FROM python:3.11-slim AS production
|
| 36 |
+
|
| 37 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 38 |
+
PYTHONUNBUFFERED=1
|
| 39 |
+
|
| 40 |
+
WORKDIR /app
|
| 41 |
+
|
| 42 |
+
# Copy installed packages from base
|
| 43 |
+
COPY --from=base /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
|
| 44 |
+
COPY --from=base /usr/local/bin /usr/local/bin
|
| 45 |
+
|
| 46 |
+
# Copy application code
|
| 47 |
+
COPY . .
|
| 48 |
+
|
| 49 |
+
# Runtime dependencies only
|
| 50 |
+
RUN apt-get update && \
|
| 51 |
+
apt-get install -y --no-install-recommends curl && \
|
| 52 |
+
rm -rf /var/lib/apt/lists/*
|
| 53 |
+
|
| 54 |
+
# Create non-root user
|
| 55 |
+
RUN groupadd -r mediguard && \
|
| 56 |
+
useradd -r -g mediguard -d /app -s /sbin/nologin mediguard && \
|
| 57 |
+
chown -R mediguard:mediguard /app
|
| 58 |
+
|
| 59 |
+
USER mediguard
|
| 60 |
+
|
| 61 |
+
EXPOSE 8000
|
| 62 |
+
|
| 63 |
+
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
| 64 |
+
CMD curl -sf http://localhost:8000/health || exit 1
|
| 65 |
+
|
| 66 |
+
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]
|
|
@@ -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
|
|
@@ -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 |
+
)
|
|
@@ -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 |
+
)
|
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.19.0
|
| 80 |
+
container_name: mediguard-opensearch
|
| 81 |
+
environment:
|
| 82 |
+
- discovery.type=single-node
|
| 83 |
+
- DISABLE_SECURITY_PLUGIN=true
|
| 84 |
+
- "OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m"
|
| 85 |
+
- bootstrap.memory_lock=true
|
| 86 |
+
ulimits:
|
| 87 |
+
memlock: { soft: -1, hard: -1 }
|
| 88 |
+
nofile: { soft: 65536, hard: 65536 }
|
| 89 |
+
ports:
|
| 90 |
+
- "${OPENSEARCH_PORT:-9200}:9200"
|
| 91 |
+
volumes:
|
| 92 |
+
- os_data:/usr/share/opensearch/data
|
| 93 |
+
healthcheck:
|
| 94 |
+
test: ["CMD-SHELL", "curl -sf http://localhost:9200/_cluster/health || exit 1"]
|
| 95 |
+
interval: 10s
|
| 96 |
+
timeout: 5s
|
| 97 |
+
retries: 20
|
| 98 |
+
restart: unless-stopped
|
| 99 |
+
|
| 100 |
+
opensearch-dashboards:
|
| 101 |
+
image: opensearchproject/opensearch-dashboards:2.19.0
|
| 102 |
+
container_name: mediguard-os-dashboards
|
| 103 |
+
environment:
|
| 104 |
+
- OPENSEARCH_HOSTS=["http://opensearch:9200"]
|
| 105 |
+
- DISABLE_SECURITY_DASHBOARDS_PLUGIN=true
|
| 106 |
+
ports:
|
| 107 |
+
- "${OS_DASHBOARDS_PORT:-5601}:5601"
|
| 108 |
+
depends_on:
|
| 109 |
+
opensearch:
|
| 110 |
+
condition: service_healthy
|
| 111 |
+
restart: unless-stopped
|
| 112 |
+
|
| 113 |
+
redis:
|
| 114 |
+
image: redis:7-alpine
|
| 115 |
+
container_name: mediguard-redis
|
| 116 |
+
ports:
|
| 117 |
+
- "${REDIS_PORT:-6379}:6379"
|
| 118 |
+
volumes:
|
| 119 |
+
- redis_data:/data
|
| 120 |
+
healthcheck:
|
| 121 |
+
test: ["CMD", "redis-cli", "ping"]
|
| 122 |
+
interval: 5s
|
| 123 |
+
timeout: 3s
|
| 124 |
+
retries: 10
|
| 125 |
+
restart: unless-stopped
|
| 126 |
+
|
| 127 |
+
ollama:
|
| 128 |
+
image: ollama/ollama:latest
|
| 129 |
+
container_name: mediguard-ollama
|
| 130 |
+
ports:
|
| 131 |
+
- "${OLLAMA_PORT:-11434}:11434"
|
| 132 |
+
volumes:
|
| 133 |
+
- ollama_data:/root/.ollama
|
| 134 |
+
restart: unless-stopped
|
| 135 |
+
# Uncomment for GPU support:
|
| 136 |
+
# deploy:
|
| 137 |
+
# resources:
|
| 138 |
+
# reservations:
|
| 139 |
+
# devices:
|
| 140 |
+
# - driver: nvidia
|
| 141 |
+
# count: 1
|
| 142 |
+
# capabilities: [gpu]
|
| 143 |
+
|
| 144 |
+
# -----------------------------------------------------------------------
|
| 145 |
+
# Observability
|
| 146 |
+
# -----------------------------------------------------------------------
|
| 147 |
+
langfuse:
|
| 148 |
+
image: langfuse/langfuse:2
|
| 149 |
+
container_name: mediguard-langfuse
|
| 150 |
+
environment:
|
| 151 |
+
- DATABASE_URL=postgresql://mediguard:mediguard_secret@postgres:5432/langfuse
|
| 152 |
+
- NEXTAUTH_URL=http://localhost:3000
|
| 153 |
+
- NEXTAUTH_SECRET=mediguard-langfuse-secret-change-me
|
| 154 |
+
- SALT=mediguard-langfuse-salt-change-me
|
| 155 |
+
ports:
|
| 156 |
+
- "${LANGFUSE_PORT:-3000}:3000"
|
| 157 |
+
depends_on:
|
| 158 |
+
postgres:
|
| 159 |
+
condition: service_healthy
|
| 160 |
+
restart: unless-stopped
|
| 161 |
+
|
| 162 |
+
volumes:
|
| 163 |
+
pg_data:
|
| 164 |
+
os_data:
|
| 165 |
+
redis_data:
|
| 166 |
+
ollama_data:
|
|
@@ -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"]
|
|
@@ -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()
|
|
@@ -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()
|
|
@@ -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."""
|
|
@@ -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()
|
|
@@ -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()
|
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""MediGuard AI — Repositories package."""
|
|
@@ -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()
|
|
@@ -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()
|
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""MediGuard AI — Production API routers."""
|
|
@@ -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)
|
|
@@ -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 |
+
)
|
|
@@ -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 |
+
)
|
|
@@ -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 |
+
)
|
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""MediGuard AI — API request/response schemas."""
|
|
@@ -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
|
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""MediGuard AI — Agentic RAG agents package."""
|
|
@@ -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)
|
|
@@ -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)
|
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""MediGuard AI — Medical agents (original 6 agents, re-exported)."""
|
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""MediGuard AI — Agentic RAG nodes package."""
|
|
@@ -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}
|
|
@@ -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 |
+
}
|
|
@@ -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 |
+
}
|
|
@@ -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}
|
|
@@ -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}
|
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MediGuard AI — Rewrite Query Node
|
| 3 |
+
|
| 4 |
+
Reformulates the user query to improve retrieval recall.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import logging
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
from src.services.agents.prompts import REWRITE_SYSTEM
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def rewrite_query_node(state: dict, *, context: Any) -> dict:
|
| 18 |
+
"""Rewrite the original query for better retrieval."""
|
| 19 |
+
original = state.get("query", "")
|
| 20 |
+
patient_context = state.get("patient_context", "")
|
| 21 |
+
|
| 22 |
+
user_msg = f"Original query: {original}"
|
| 23 |
+
if patient_context:
|
| 24 |
+
user_msg += f"\n\nPatient context: {patient_context}"
|
| 25 |
+
|
| 26 |
+
try:
|
| 27 |
+
response = context.llm.invoke(
|
| 28 |
+
[
|
| 29 |
+
{"role": "system", "content": REWRITE_SYSTEM},
|
| 30 |
+
{"role": "user", "content": user_msg},
|
| 31 |
+
]
|
| 32 |
+
)
|
| 33 |
+
rewritten = response.content.strip()
|
| 34 |
+
if not rewritten:
|
| 35 |
+
rewritten = original
|
| 36 |
+
except Exception as exc:
|
| 37 |
+
logger.warning("Rewrite LLM failed: %s — keeping original query", exc)
|
| 38 |
+
rewritten = original
|
| 39 |
+
|
| 40 |
+
return {"rewritten_query": rewritten}
|
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MediGuard AI — Agentic RAG Prompts
|
| 3 |
+
|
| 4 |
+
Medical-domain prompts for guardrail, grading, rewriting, and generation.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
# ── Guardrail prompt ─────────────────────────────────────────────────────────
|
| 8 |
+
|
| 9 |
+
GUARDRAIL_SYSTEM = """\
|
| 10 |
+
You are a medical-domain classifier. Determine whether the user query is
|
| 11 |
+
about health, biomarkers, medical conditions, clinical guidelines, or
|
| 12 |
+
wellness — topics that MediGuard AI can help with.
|
| 13 |
+
|
| 14 |
+
Score the query from 0 to 100:
|
| 15 |
+
90-100 Clearly medical (biomarker values, disease questions, symptoms)
|
| 16 |
+
60-89 Health-adjacent (nutrition, fitness, wellness)
|
| 17 |
+
30-59 Loosely related (general biology, anatomy trivia)
|
| 18 |
+
0-29 Not medical at all (weather, coding, sports)
|
| 19 |
+
|
| 20 |
+
Respond ONLY with JSON:
|
| 21 |
+
{{"score": <int>, "reason": "<one-sentence explanation>"}}
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
# ── Document grading prompt ──────────────────────────────────────────────────
|
| 25 |
+
|
| 26 |
+
GRADING_SYSTEM = """\
|
| 27 |
+
You are a medical-relevance grader. Given a user question and a retrieved
|
| 28 |
+
document chunk, decide whether the document is relevant to answering the
|
| 29 |
+
medical question.
|
| 30 |
+
|
| 31 |
+
Respond ONLY with JSON:
|
| 32 |
+
{{"relevant": true/false, "reason": "<one sentence>"}}
|
| 33 |
+
"""
|
| 34 |
+
|
| 35 |
+
# ── Query rewriting prompt ───────────────────────────────────────────────────
|
| 36 |
+
|
| 37 |
+
REWRITE_SYSTEM = """\
|
| 38 |
+
You are a medical-query optimiser. The original user query did not
|
| 39 |
+
retrieve relevant medical documents. Rewrite it to improve retrieval from
|
| 40 |
+
a medical knowledge base.
|
| 41 |
+
|
| 42 |
+
Guidelines:
|
| 43 |
+
- Use standard medical terminology
|
| 44 |
+
- Add synonyms for biomarker names
|
| 45 |
+
- Make the intent clearer
|
| 46 |
+
|
| 47 |
+
Respond with ONLY the rewritten query (no explanation, no quotes).
|
| 48 |
+
"""
|
| 49 |
+
|
| 50 |
+
# ── RAG generation prompt ────────────────────────────────────────────────────
|
| 51 |
+
|
| 52 |
+
RAG_GENERATION_SYSTEM = """\
|
| 53 |
+
You are MediGuard AI, a clinical-information assistant.
|
| 54 |
+
Answer the user's medical question using ONLY the provided context documents.
|
| 55 |
+
If the context is insufficient, say so honestly.
|
| 56 |
+
|
| 57 |
+
Rules:
|
| 58 |
+
1. Cite specific documents with [Source: filename, Page X].
|
| 59 |
+
2. Use patient-friendly language.
|
| 60 |
+
3. Never provide a definitive diagnosis — use "may indicate", "suggests".
|
| 61 |
+
4. Always end with: "Please consult a healthcare professional for diagnosis."
|
| 62 |
+
5. If biomarker values are critical, highlight them as safety alerts.
|
| 63 |
+
"""
|
| 64 |
+
|
| 65 |
+
# ── Out-of-scope response ───────────────────────────────────────────────────
|
| 66 |
+
|
| 67 |
+
OUT_OF_SCOPE_RESPONSE = (
|
| 68 |
+
"I'm MediGuard AI — I specialise in medical biomarker analysis and "
|
| 69 |
+
"health-related questions. Your query doesn't appear to be about a "
|
| 70 |
+
"medical or health topic I can help with. Please try asking about "
|
| 71 |
+
"biomarker values, disease information, or clinical guidelines."
|
| 72 |
+
)
|
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MediGuard AI — Agentic RAG State
|
| 3 |
+
|
| 4 |
+
Enhanced LangGraph state for the guardrail → retrieve → grade → generate
|
| 5 |
+
pipeline that wraps the existing 6-agent clinical workflow.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from typing import Any, Dict, List, Optional, Annotated
|
| 11 |
+
from typing_extensions import TypedDict
|
| 12 |
+
import operator
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class AgenticRAGState(TypedDict):
|
| 16 |
+
"""State flowing through the agentic RAG graph."""
|
| 17 |
+
|
| 18 |
+
# ── Input ────────────────────────────────────────────────────────────
|
| 19 |
+
query: str
|
| 20 |
+
biomarkers: Optional[Dict[str, float]]
|
| 21 |
+
patient_context: Optional[Dict[str, Any]]
|
| 22 |
+
|
| 23 |
+
# ── Guardrail ────────────────────────────────────────────────────────
|
| 24 |
+
guardrail_score: float # 0-100 medical-relevance score
|
| 25 |
+
is_in_scope: bool # passed guardrail?
|
| 26 |
+
|
| 27 |
+
# ── Retrieval ────────────────────────────────────────────────────────
|
| 28 |
+
retrieved_documents: List[Dict[str, Any]]
|
| 29 |
+
retrieval_attempts: int
|
| 30 |
+
max_retrieval_attempts: int
|
| 31 |
+
|
| 32 |
+
# ── Grading ──────────────────────────────────────────────────────────
|
| 33 |
+
grading_results: List[Dict[str, Any]]
|
| 34 |
+
relevant_documents: List[Dict[str, Any]]
|
| 35 |
+
needs_rewrite: bool
|
| 36 |
+
|
| 37 |
+
# ── Rewriting ────────────────────────────────────────────────────────
|
| 38 |
+
rewritten_query: Optional[str]
|
| 39 |
+
|
| 40 |
+
# ── Generation / routing ─────────────────────────────────────────────
|
| 41 |
+
routing_decision: str # "analyze" | "rag_answer" | "out_of_scope"
|
| 42 |
+
final_answer: Optional[str]
|
| 43 |
+
analysis_result: Optional[Dict[str, Any]]
|
| 44 |
+
|
| 45 |
+
# ── Metadata ─────────────────────────────────────────────────────────
|
| 46 |
+
trace_id: Optional[str]
|
| 47 |
+
errors: Annotated[List[str], operator.add]
|
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""MediGuard AI — Biomarker validation service."""
|
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MediGuard AI — Biomarker Validation Service
|
| 3 |
+
|
| 4 |
+
Wraps the existing BiomarkerValidator as a production service with caching,
|
| 5 |
+
observability, and Pydantic-typed outputs.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import logging
|
| 11 |
+
from dataclasses import dataclass, field
|
| 12 |
+
from functools import lru_cache
|
| 13 |
+
from typing import Any, Dict, List, Optional
|
| 14 |
+
|
| 15 |
+
from src.biomarker_validator import BiomarkerValidator
|
| 16 |
+
from src.biomarker_normalization import normalize_biomarker_name
|
| 17 |
+
from src.settings import get_settings
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@dataclass(frozen=True)
|
| 23 |
+
class BiomarkerResult:
|
| 24 |
+
"""Validated result for a single biomarker."""
|
| 25 |
+
|
| 26 |
+
name: str
|
| 27 |
+
value: float
|
| 28 |
+
unit: str
|
| 29 |
+
status: str # NORMAL | HIGH | LOW | CRITICAL_HIGH | CRITICAL_LOW
|
| 30 |
+
reference_range: str
|
| 31 |
+
warning: Optional[str] = None
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@dataclass
|
| 35 |
+
class ValidationReport:
|
| 36 |
+
"""Complete biomarker validation report."""
|
| 37 |
+
|
| 38 |
+
results: List[BiomarkerResult] = field(default_factory=list)
|
| 39 |
+
safety_alerts: List[Dict[str, Any]] = field(default_factory=list)
|
| 40 |
+
recognized_count: int = 0
|
| 41 |
+
unrecognized: List[str] = field(default_factory=list)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class BiomarkerService:
|
| 45 |
+
"""Production biomarker validation service."""
|
| 46 |
+
|
| 47 |
+
def __init__(self) -> None:
|
| 48 |
+
self._validator = BiomarkerValidator()
|
| 49 |
+
|
| 50 |
+
# --------------------------------------------------------------------- #
|
| 51 |
+
# Public API
|
| 52 |
+
# --------------------------------------------------------------------- #
|
| 53 |
+
|
| 54 |
+
def validate(
|
| 55 |
+
self,
|
| 56 |
+
biomarkers: Dict[str, float],
|
| 57 |
+
gender: Optional[str] = None,
|
| 58 |
+
) -> ValidationReport:
|
| 59 |
+
"""Validate a dict of biomarker name → value and return a report."""
|
| 60 |
+
report = ValidationReport()
|
| 61 |
+
|
| 62 |
+
for raw_name, value in biomarkers.items():
|
| 63 |
+
normalized = normalize_biomarker_name(raw_name)
|
| 64 |
+
flag = self._validator.validate_biomarker(normalized, value, gender=gender)
|
| 65 |
+
if flag is None:
|
| 66 |
+
report.unrecognized.append(raw_name)
|
| 67 |
+
continue
|
| 68 |
+
if flag.status == "UNKNOWN":
|
| 69 |
+
report.unrecognized.append(raw_name)
|
| 70 |
+
continue
|
| 71 |
+
report.recognized_count += 1
|
| 72 |
+
report.results.append(
|
| 73 |
+
BiomarkerResult(
|
| 74 |
+
name=flag.name,
|
| 75 |
+
value=flag.value,
|
| 76 |
+
unit=flag.unit,
|
| 77 |
+
status=flag.status,
|
| 78 |
+
reference_range=flag.reference_range,
|
| 79 |
+
warning=flag.warning,
|
| 80 |
+
)
|
| 81 |
+
)
|
| 82 |
+
if flag.status.startswith("CRITICAL"):
|
| 83 |
+
report.safety_alerts.append(
|
| 84 |
+
{
|
| 85 |
+
"severity": "CRITICAL",
|
| 86 |
+
"biomarker": normalized,
|
| 87 |
+
"message": flag.warning or f"{normalized} is critically out of range",
|
| 88 |
+
"action": "Seek immediate medical attention",
|
| 89 |
+
}
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
return report
|
| 93 |
+
|
| 94 |
+
def list_supported(self) -> List[Dict[str, Any]]:
|
| 95 |
+
"""Return metadata for all supported biomarkers."""
|
| 96 |
+
result = []
|
| 97 |
+
for name, ref in self._validator.references.items():
|
| 98 |
+
result.append({
|
| 99 |
+
"name": name,
|
| 100 |
+
"unit": ref.get("unit", ""),
|
| 101 |
+
"normal_range": ref.get("normal_range", {}),
|
| 102 |
+
"critical_low": ref.get("critical_low"),
|
| 103 |
+
"critical_high": ref.get("critical_high"),
|
| 104 |
+
})
|
| 105 |
+
return result
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
@lru_cache(maxsize=1)
|
| 109 |
+
def make_biomarker_service() -> BiomarkerService:
|
| 110 |
+
return BiomarkerService()
|
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MediGuard AI — Redis cache service package."""
|
| 2 |
+
from src.services.cache.redis_cache import RedisCache, make_redis_cache
|
| 3 |
+
|
| 4 |
+
__all__ = ["RedisCache", "make_redis_cache"]
|
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MediGuard AI — Redis Cache
|
| 3 |
+
|
| 4 |
+
Exact-match caching with SHA-256 keys for RAG and analysis responses.
|
| 5 |
+
Gracefully degrades when Redis is unavailable.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import hashlib
|
| 11 |
+
import json
|
| 12 |
+
import logging
|
| 13 |
+
from functools import lru_cache
|
| 14 |
+
from typing import Any, Dict, Optional
|
| 15 |
+
|
| 16 |
+
from src.settings import get_settings
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
try:
|
| 21 |
+
import redis as _redis
|
| 22 |
+
except ImportError: # pragma: no cover
|
| 23 |
+
_redis = None # type: ignore[assignment]
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class RedisCache:
|
| 27 |
+
"""Thin Redis wrapper with SHA-256 key generation and JSON ser/de."""
|
| 28 |
+
|
| 29 |
+
def __init__(self, client: Any, default_ttl: int = 21600):
|
| 30 |
+
self._client = client
|
| 31 |
+
self._default_ttl = default_ttl
|
| 32 |
+
self._enabled = client is not None
|
| 33 |
+
|
| 34 |
+
@property
|
| 35 |
+
def enabled(self) -> bool:
|
| 36 |
+
return self._enabled
|
| 37 |
+
|
| 38 |
+
def ping(self) -> bool:
|
| 39 |
+
if not self._enabled:
|
| 40 |
+
return False
|
| 41 |
+
try:
|
| 42 |
+
return self._client.ping()
|
| 43 |
+
except Exception:
|
| 44 |
+
return False
|
| 45 |
+
|
| 46 |
+
@staticmethod
|
| 47 |
+
def _make_key(*parts: str) -> str:
|
| 48 |
+
raw = "|".join(parts)
|
| 49 |
+
return f"mediguard:{hashlib.sha256(raw.encode()).hexdigest()}"
|
| 50 |
+
|
| 51 |
+
def get(self, *key_parts: str) -> Optional[Dict[str, Any]]:
|
| 52 |
+
if not self._enabled:
|
| 53 |
+
return None
|
| 54 |
+
key = self._make_key(*key_parts)
|
| 55 |
+
try:
|
| 56 |
+
value = self._client.get(key)
|
| 57 |
+
if value is None:
|
| 58 |
+
return None
|
| 59 |
+
return json.loads(value)
|
| 60 |
+
except Exception as exc:
|
| 61 |
+
logger.warning("Cache GET failed: %s", exc)
|
| 62 |
+
return None
|
| 63 |
+
|
| 64 |
+
def set(self, value: Dict[str, Any], *key_parts: str, ttl: Optional[int] = None) -> bool:
|
| 65 |
+
if not self._enabled:
|
| 66 |
+
return False
|
| 67 |
+
key = self._make_key(*key_parts)
|
| 68 |
+
try:
|
| 69 |
+
self._client.setex(key, ttl or self._default_ttl, json.dumps(value, default=str))
|
| 70 |
+
return True
|
| 71 |
+
except Exception as exc:
|
| 72 |
+
logger.warning("Cache SET failed: %s", exc)
|
| 73 |
+
return False
|
| 74 |
+
|
| 75 |
+
def delete(self, *key_parts: str) -> bool:
|
| 76 |
+
if not self._enabled:
|
| 77 |
+
return False
|
| 78 |
+
key = self._make_key(*key_parts)
|
| 79 |
+
try:
|
| 80 |
+
self._client.delete(key)
|
| 81 |
+
return True
|
| 82 |
+
except Exception as exc:
|
| 83 |
+
logger.warning("Cache DELETE failed: %s", exc)
|
| 84 |
+
return False
|
| 85 |
+
|
| 86 |
+
def flush(self) -> bool:
|
| 87 |
+
if not self._enabled:
|
| 88 |
+
return False
|
| 89 |
+
try:
|
| 90 |
+
self._client.flushdb()
|
| 91 |
+
return True
|
| 92 |
+
except Exception:
|
| 93 |
+
return False
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
class _NullCache(RedisCache):
|
| 97 |
+
"""No-op cache returned when Redis is disabled or unavailable."""
|
| 98 |
+
|
| 99 |
+
def __init__(self):
|
| 100 |
+
super().__init__(client=None)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
@lru_cache(maxsize=1)
|
| 104 |
+
def make_redis_cache() -> RedisCache:
|
| 105 |
+
"""Factory — returns a live cache or a silent null-cache."""
|
| 106 |
+
settings = get_settings()
|
| 107 |
+
if not settings.redis.enabled or _redis is None:
|
| 108 |
+
logger.info("Redis caching disabled")
|
| 109 |
+
return _NullCache()
|
| 110 |
+
try:
|
| 111 |
+
client = _redis.Redis(
|
| 112 |
+
host=settings.redis.host,
|
| 113 |
+
port=settings.redis.port,
|
| 114 |
+
db=settings.redis.db,
|
| 115 |
+
decode_responses=True,
|
| 116 |
+
socket_connect_timeout=3,
|
| 117 |
+
)
|
| 118 |
+
client.ping()
|
| 119 |
+
logger.info("Redis connected (%s:%d)", settings.redis.host, settings.redis.port)
|
| 120 |
+
return RedisCache(client, settings.redis.ttl_seconds)
|
| 121 |
+
except Exception as exc:
|
| 122 |
+
logger.warning("Redis unavailable (%s), running without cache", exc)
|
| 123 |
+
return _NullCache()
|
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MediGuard AI — Embeddings service package."""
|
| 2 |
+
from src.services.embeddings.service import EmbeddingService, make_embedding_service
|
| 3 |
+
|
| 4 |
+
__all__ = ["EmbeddingService", "make_embedding_service"]
|
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MediGuard AI — Embedding Service
|
| 3 |
+
|
| 4 |
+
Supports Jina AI, Google, HuggingFace, and Ollama embeddings with
|
| 5 |
+
automatic fallback chain: Jina → Google → HuggingFace.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import logging
|
| 11 |
+
from functools import lru_cache
|
| 12 |
+
from typing import List
|
| 13 |
+
|
| 14 |
+
from src.exceptions import EmbeddingError, EmbeddingProviderError
|
| 15 |
+
from src.settings import get_settings
|
| 16 |
+
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class EmbeddingService:
|
| 21 |
+
"""Unified embedding interface — delegates to the configured provider."""
|
| 22 |
+
|
| 23 |
+
def __init__(self, model, provider_name: str, dimension: int):
|
| 24 |
+
self._model = model
|
| 25 |
+
self.provider_name = provider_name
|
| 26 |
+
self.dimension = dimension
|
| 27 |
+
|
| 28 |
+
def embed_query(self, text: str) -> List[float]:
|
| 29 |
+
"""Embed a single query text."""
|
| 30 |
+
try:
|
| 31 |
+
return self._model.embed_query(text)
|
| 32 |
+
except Exception as exc:
|
| 33 |
+
raise EmbeddingProviderError(f"{self.provider_name} embed_query failed: {exc}")
|
| 34 |
+
|
| 35 |
+
def embed_documents(self, texts: List[str]) -> List[List[float]]:
|
| 36 |
+
"""Batch-embed a list of texts."""
|
| 37 |
+
try:
|
| 38 |
+
return self._model.embed_documents(texts)
|
| 39 |
+
except Exception as exc:
|
| 40 |
+
raise EmbeddingProviderError(f"{self.provider_name} embed_documents failed: {exc}")
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _make_google_embeddings():
|
| 44 |
+
settings = get_settings()
|
| 45 |
+
api_key = settings.embedding.google_api_key or settings.llm.google_api_key
|
| 46 |
+
if not api_key:
|
| 47 |
+
raise EmbeddingError("GOOGLE_API_KEY not set for Google embeddings")
|
| 48 |
+
from langchain_google_genai import GoogleGenerativeAIEmbeddings
|
| 49 |
+
|
| 50 |
+
model = GoogleGenerativeAIEmbeddings(
|
| 51 |
+
model="models/text-embedding-004",
|
| 52 |
+
google_api_key=api_key,
|
| 53 |
+
)
|
| 54 |
+
return EmbeddingService(model, "google", 768)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _make_huggingface_embeddings():
|
| 58 |
+
settings = get_settings()
|
| 59 |
+
try:
|
| 60 |
+
from langchain_huggingface import HuggingFaceEmbeddings
|
| 61 |
+
except ImportError:
|
| 62 |
+
from langchain_community.embeddings import HuggingFaceEmbeddings
|
| 63 |
+
|
| 64 |
+
model = HuggingFaceEmbeddings(model_name=settings.embedding.huggingface_model)
|
| 65 |
+
return EmbeddingService(model, "huggingface", 384)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _make_ollama_embeddings():
|
| 69 |
+
settings = get_settings()
|
| 70 |
+
try:
|
| 71 |
+
from langchain_ollama import OllamaEmbeddings
|
| 72 |
+
except ImportError:
|
| 73 |
+
from langchain_community.embeddings import OllamaEmbeddings
|
| 74 |
+
|
| 75 |
+
model = OllamaEmbeddings(
|
| 76 |
+
model=settings.ollama.embedding_model,
|
| 77 |
+
base_url=settings.ollama.host,
|
| 78 |
+
)
|
| 79 |
+
return EmbeddingService(model, "ollama", 768)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _make_jina_embeddings():
|
| 83 |
+
settings = get_settings()
|
| 84 |
+
api_key = settings.embedding.jina_api_key
|
| 85 |
+
if not api_key:
|
| 86 |
+
raise EmbeddingError("JINA_API_KEY not set for Jina embeddings")
|
| 87 |
+
# Jina v3 via httpx (lightweight, no extra SDK)
|
| 88 |
+
import httpx
|
| 89 |
+
|
| 90 |
+
class _JinaModel:
|
| 91 |
+
"""Minimal Jina AI embedding adapter."""
|
| 92 |
+
|
| 93 |
+
def __init__(self, api_key: str, model: str):
|
| 94 |
+
self._api_key = api_key
|
| 95 |
+
self._model = model
|
| 96 |
+
self._url = "https://api.jina.ai/v1/embeddings"
|
| 97 |
+
|
| 98 |
+
def _call(self, texts: list[str], task: str = "retrieval.passage") -> list[list[float]]:
|
| 99 |
+
headers = {"Authorization": f"Bearer {self._api_key}", "Content-Type": "application/json"}
|
| 100 |
+
payload = {"model": self._model, "input": texts, "task": task}
|
| 101 |
+
resp = httpx.post(self._url, json=payload, headers=headers, timeout=60)
|
| 102 |
+
resp.raise_for_status()
|
| 103 |
+
data = resp.json()["data"]
|
| 104 |
+
return [item["embedding"] for item in sorted(data, key=lambda x: x["index"])]
|
| 105 |
+
|
| 106 |
+
def embed_query(self, text: str) -> list[float]:
|
| 107 |
+
return self._call([text], task="retrieval.query")[0]
|
| 108 |
+
|
| 109 |
+
def embed_documents(self, texts: list[str]) -> list[list[float]]:
|
| 110 |
+
return self._call(texts, task="retrieval.passage")
|
| 111 |
+
|
| 112 |
+
model = _JinaModel(api_key, settings.embedding.jina_model)
|
| 113 |
+
return EmbeddingService(model, "jina", settings.embedding.dimension)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
# ── Fallback chain factory ───────────────────────────────────────────────────
|
| 117 |
+
|
| 118 |
+
_PROVIDERS = {
|
| 119 |
+
"jina": _make_jina_embeddings,
|
| 120 |
+
"google": _make_google_embeddings,
|
| 121 |
+
"huggingface": _make_huggingface_embeddings,
|
| 122 |
+
"ollama": _make_ollama_embeddings,
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
FALLBACK_ORDER = ["jina", "google", "huggingface"]
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
@lru_cache(maxsize=1)
|
| 129 |
+
def make_embedding_service() -> EmbeddingService:
|
| 130 |
+
"""Create an embedding service with automatic fallback."""
|
| 131 |
+
settings = get_settings()
|
| 132 |
+
preferred = settings.embedding.provider
|
| 133 |
+
|
| 134 |
+
# Try preferred first, then fallbacks
|
| 135 |
+
order = [preferred] + [p for p in FALLBACK_ORDER if p != preferred]
|
| 136 |
+
for provider in order:
|
| 137 |
+
factory = _PROVIDERS.get(provider)
|
| 138 |
+
if factory is None:
|
| 139 |
+
continue
|
| 140 |
+
try:
|
| 141 |
+
svc = factory()
|
| 142 |
+
logger.info("Embedding provider: %s (dim=%d)", svc.provider_name, svc.dimension)
|
| 143 |
+
return svc
|
| 144 |
+
except Exception as exc:
|
| 145 |
+
logger.warning("Embedding provider '%s' failed: %s — trying next", provider, exc)
|
| 146 |
+
|
| 147 |
+
raise EmbeddingError("All embedding providers failed. Check your API keys and configuration.")
|
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MediGuard AI — Indexing (chunking + embedding + OpenSearch) package."""
|
| 2 |
+
from src.services.indexing.text_chunker import MedicalTextChunker
|
| 3 |
+
from src.services.indexing.service import IndexingService
|
| 4 |
+
|
| 5 |
+
__all__ = ["MedicalTextChunker", "IndexingService"]
|
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MediGuard AI — Indexing Service
|
| 3 |
+
|
| 4 |
+
Orchestrates: PDF parse → chunk → embed → index into OpenSearch.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import logging
|
| 10 |
+
import uuid
|
| 11 |
+
from datetime import datetime, timezone
|
| 12 |
+
from typing import Dict, List
|
| 13 |
+
|
| 14 |
+
from src.services.indexing.text_chunker import MedicalChunk, MedicalTextChunker
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class IndexingService:
|
| 20 |
+
"""Coordinates chunking → embedding → OpenSearch indexing."""
|
| 21 |
+
|
| 22 |
+
def __init__(self, chunker, embedding_service, opensearch_client):
|
| 23 |
+
self.chunker = chunker
|
| 24 |
+
self.embedding_service = embedding_service
|
| 25 |
+
self.opensearch_client = opensearch_client
|
| 26 |
+
|
| 27 |
+
def index_text(
|
| 28 |
+
self,
|
| 29 |
+
text: str,
|
| 30 |
+
*,
|
| 31 |
+
document_id: str = "",
|
| 32 |
+
title: str = "",
|
| 33 |
+
source_file: str = "",
|
| 34 |
+
) -> int:
|
| 35 |
+
"""Chunk, embed, and index a single document's text. Returns count of indexed chunks."""
|
| 36 |
+
if not document_id:
|
| 37 |
+
document_id = str(uuid.uuid4())
|
| 38 |
+
|
| 39 |
+
chunks = self.chunker.chunk_text(
|
| 40 |
+
text,
|
| 41 |
+
document_id=document_id,
|
| 42 |
+
title=title,
|
| 43 |
+
source_file=source_file,
|
| 44 |
+
)
|
| 45 |
+
if not chunks:
|
| 46 |
+
logger.warning("No chunks generated for document '%s'", title)
|
| 47 |
+
return 0
|
| 48 |
+
|
| 49 |
+
# Embed all chunks
|
| 50 |
+
texts = [c.text for c in chunks]
|
| 51 |
+
embeddings = self.embedding_service.embed_documents(texts)
|
| 52 |
+
|
| 53 |
+
# Prepare OpenSearch documents
|
| 54 |
+
now = datetime.now(timezone.utc).isoformat()
|
| 55 |
+
docs: List[Dict] = []
|
| 56 |
+
for chunk, emb in zip(chunks, embeddings):
|
| 57 |
+
doc = chunk.to_dict()
|
| 58 |
+
doc["_id"] = f"{document_id}_{chunk.chunk_index}"
|
| 59 |
+
doc["embedding"] = emb
|
| 60 |
+
doc["indexed_at"] = now
|
| 61 |
+
docs.append(doc)
|
| 62 |
+
|
| 63 |
+
indexed = self.opensearch_client.bulk_index(docs)
|
| 64 |
+
logger.info(
|
| 65 |
+
"Indexed %d chunks for '%s' (document_id=%s)",
|
| 66 |
+
indexed, title, document_id,
|
| 67 |
+
)
|
| 68 |
+
return indexed
|
| 69 |
+
|
| 70 |
+
def index_chunks(self, chunks: List[MedicalChunk]) -> int:
|
| 71 |
+
"""Embed and index pre-built chunks."""
|
| 72 |
+
if not chunks:
|
| 73 |
+
return 0
|
| 74 |
+
texts = [c.text for c in chunks]
|
| 75 |
+
embeddings = self.embedding_service.embed_documents(texts)
|
| 76 |
+
now = datetime.now(timezone.utc).isoformat()
|
| 77 |
+
docs: List[Dict] = []
|
| 78 |
+
for chunk, emb in zip(chunks, embeddings):
|
| 79 |
+
doc = chunk.to_dict()
|
| 80 |
+
doc["_id"] = f"{chunk.document_id}_{chunk.chunk_index}"
|
| 81 |
+
doc["embedding"] = emb
|
| 82 |
+
doc["indexed_at"] = now
|
| 83 |
+
docs.append(doc)
|
| 84 |
+
return self.opensearch_client.bulk_index(docs)
|
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MediGuard AI — Medical-Aware Text Chunker
|
| 3 |
+
|
| 4 |
+
Section-aware chunking with biomarker / condition metadata extraction.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import re
|
| 10 |
+
from dataclasses import dataclass, field
|
| 11 |
+
from typing import Dict, List, Optional, Set
|
| 12 |
+
|
| 13 |
+
# Biomarker names to detect in chunk text
|
| 14 |
+
_BIOMARKER_NAMES: Set[str] = {
|
| 15 |
+
"Glucose", "Cholesterol", "Triglycerides", "HbA1c", "LDL", "HDL",
|
| 16 |
+
"Insulin", "BMI", "Hemoglobin", "Platelets", "WBC", "RBC",
|
| 17 |
+
"Hematocrit", "MCV", "MCH", "MCHC", "Heart Rate", "Systolic",
|
| 18 |
+
"Diastolic", "Troponin", "CRP", "C-reactive Protein", "ALT", "AST",
|
| 19 |
+
"Creatinine", "TSH", "T3", "T4", "Sodium", "Potassium", "Calcium",
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
_CONDITION_KEYWORDS: Dict[str, str] = {
|
| 23 |
+
"diabetes": "diabetes",
|
| 24 |
+
"diabetic": "diabetes",
|
| 25 |
+
"hyperglycemia": "diabetes",
|
| 26 |
+
"insulin resistance": "diabetes",
|
| 27 |
+
"anemia": "anemia",
|
| 28 |
+
"anaemia": "anemia",
|
| 29 |
+
"iron deficiency": "anemia",
|
| 30 |
+
"thalassemia": "thalassemia",
|
| 31 |
+
"thalassaemia": "thalassemia",
|
| 32 |
+
"thrombocytopenia": "thrombocytopenia",
|
| 33 |
+
"heart disease": "heart_disease",
|
| 34 |
+
"cardiovascular": "heart_disease",
|
| 35 |
+
"coronary": "heart_disease",
|
| 36 |
+
"hypertension": "heart_disease",
|
| 37 |
+
"atherosclerosis": "heart_disease",
|
| 38 |
+
"hyperlipidemia": "heart_disease",
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
_SECTION_RE = re.compile(
|
| 42 |
+
r"^(?:#+\s*)?("
|
| 43 |
+
r"abstract|introduction|background|methods?|methodology|materials?"
|
| 44 |
+
r"|results?|findings|discussion|conclusion|summary"
|
| 45 |
+
r"|guidelines?|recommendations?|references?|bibliography"
|
| 46 |
+
r"|clinical\s*presentation|pathophysiology|diagnosis|treatment|prognosis"
|
| 47 |
+
r")\b",
|
| 48 |
+
re.IGNORECASE | re.MULTILINE,
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@dataclass
|
| 53 |
+
class MedicalChunk:
|
| 54 |
+
"""A single chunk with medical metadata."""
|
| 55 |
+
text: str
|
| 56 |
+
chunk_index: int
|
| 57 |
+
document_id: str = ""
|
| 58 |
+
title: str = ""
|
| 59 |
+
source_file: str = ""
|
| 60 |
+
page_number: Optional[int] = None
|
| 61 |
+
section_title: str = ""
|
| 62 |
+
biomarkers_mentioned: List[str] = field(default_factory=list)
|
| 63 |
+
condition_tags: List[str] = field(default_factory=list)
|
| 64 |
+
word_count: int = 0
|
| 65 |
+
|
| 66 |
+
def to_dict(self) -> Dict:
|
| 67 |
+
return {
|
| 68 |
+
"chunk_text": self.text,
|
| 69 |
+
"chunk_index": self.chunk_index,
|
| 70 |
+
"document_id": self.document_id,
|
| 71 |
+
"title": self.title,
|
| 72 |
+
"source_file": self.source_file,
|
| 73 |
+
"page_number": self.page_number,
|
| 74 |
+
"section_title": self.section_title,
|
| 75 |
+
"biomarkers_mentioned": self.biomarkers_mentioned,
|
| 76 |
+
"condition_tags": self.condition_tags,
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
class MedicalTextChunker:
|
| 81 |
+
"""Section-aware text chunker optimised for medical documents."""
|
| 82 |
+
|
| 83 |
+
def __init__(
|
| 84 |
+
self,
|
| 85 |
+
target_words: int = 600,
|
| 86 |
+
overlap_words: int = 100,
|
| 87 |
+
min_words: int = 50,
|
| 88 |
+
):
|
| 89 |
+
self.target_words = target_words
|
| 90 |
+
self.overlap_words = overlap_words
|
| 91 |
+
self.min_words = min_words
|
| 92 |
+
|
| 93 |
+
def chunk_text(
|
| 94 |
+
self,
|
| 95 |
+
text: str,
|
| 96 |
+
*,
|
| 97 |
+
document_id: str = "",
|
| 98 |
+
title: str = "",
|
| 99 |
+
source_file: str = "",
|
| 100 |
+
) -> List[MedicalChunk]:
|
| 101 |
+
"""Split text into enriched medical chunks."""
|
| 102 |
+
sections = self._split_sections(text)
|
| 103 |
+
chunks: List[MedicalChunk] = []
|
| 104 |
+
idx = 0
|
| 105 |
+
for section_title, section_text in sections:
|
| 106 |
+
words = section_text.split()
|
| 107 |
+
if not words:
|
| 108 |
+
continue
|
| 109 |
+
start = 0
|
| 110 |
+
while start < len(words):
|
| 111 |
+
end = min(start + self.target_words, len(words))
|
| 112 |
+
chunk_words = words[start:end]
|
| 113 |
+
if len(chunk_words) < self.min_words and chunks:
|
| 114 |
+
# merge tiny tail into previous chunk
|
| 115 |
+
chunks[-1].text += " " + " ".join(chunk_words)
|
| 116 |
+
chunks[-1].word_count = len(chunks[-1].text.split())
|
| 117 |
+
break
|
| 118 |
+
|
| 119 |
+
chunk_text = " ".join(chunk_words)
|
| 120 |
+
biomarkers = self._detect_biomarkers(chunk_text)
|
| 121 |
+
conditions = self._detect_conditions(chunk_text)
|
| 122 |
+
|
| 123 |
+
chunks.append(
|
| 124 |
+
MedicalChunk(
|
| 125 |
+
text=chunk_text,
|
| 126 |
+
chunk_index=idx,
|
| 127 |
+
document_id=document_id,
|
| 128 |
+
title=title,
|
| 129 |
+
source_file=source_file,
|
| 130 |
+
section_title=section_title,
|
| 131 |
+
biomarkers_mentioned=biomarkers,
|
| 132 |
+
condition_tags=conditions,
|
| 133 |
+
word_count=len(chunk_words),
|
| 134 |
+
)
|
| 135 |
+
)
|
| 136 |
+
idx += 1
|
| 137 |
+
start = end - self.overlap_words if end < len(words) else len(words)
|
| 138 |
+
return chunks
|
| 139 |
+
|
| 140 |
+
# ── internal helpers ─────────────────────────────────────────────────
|
| 141 |
+
|
| 142 |
+
@staticmethod
|
| 143 |
+
def _split_sections(text: str) -> List[tuple[str, str]]:
|
| 144 |
+
"""Split text by detected section headers."""
|
| 145 |
+
matches = list(_SECTION_RE.finditer(text))
|
| 146 |
+
if not matches:
|
| 147 |
+
return [("", text)]
|
| 148 |
+
sections: List[tuple[str, str]] = []
|
| 149 |
+
# text before first section header
|
| 150 |
+
if matches[0].start() > 0:
|
| 151 |
+
preamble = text[: matches[0].start()].strip()
|
| 152 |
+
if preamble:
|
| 153 |
+
sections.append(("", preamble))
|
| 154 |
+
for i, match in enumerate(matches):
|
| 155 |
+
header = match.group(1).strip().title()
|
| 156 |
+
start = match.end()
|
| 157 |
+
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
|
| 158 |
+
body = text[start:end].strip()
|
| 159 |
+
# Skip reference/bibliography sections
|
| 160 |
+
if header.lower() in ("references", "bibliography"):
|
| 161 |
+
continue
|
| 162 |
+
if body:
|
| 163 |
+
sections.append((header, body))
|
| 164 |
+
return sections or [("", text)]
|
| 165 |
+
|
| 166 |
+
@staticmethod
|
| 167 |
+
def _detect_biomarkers(text: str) -> List[str]:
|
| 168 |
+
text_lower = text.lower()
|
| 169 |
+
return sorted(
|
| 170 |
+
{name for name in _BIOMARKER_NAMES if name.lower() in text_lower}
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
@staticmethod
|
| 174 |
+
def _detect_conditions(text: str) -> List[str]:
|
| 175 |
+
text_lower = text.lower()
|
| 176 |
+
return sorted(
|
| 177 |
+
{tag for kw, tag in _CONDITION_KEYWORDS.items() if kw in text_lower}
|
| 178 |
+
)
|
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MediGuard AI — Langfuse observability package."""
|
| 2 |
+
from src.services.langfuse.tracer import LangfuseTracer, make_langfuse_tracer
|
| 3 |
+
|
| 4 |
+
__all__ = ["LangfuseTracer", "make_langfuse_tracer"]
|
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MediGuard AI — Langfuse Observability Tracer
|
| 3 |
+
|
| 4 |
+
Wraps Langfuse v3 SDK for end-to-end tracing of the RAG pipeline.
|
| 5 |
+
Silently no-ops when Langfuse is disabled or unreachable.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import logging
|
| 11 |
+
from contextlib import contextmanager
|
| 12 |
+
from functools import lru_cache
|
| 13 |
+
from typing import Any, Dict, Optional
|
| 14 |
+
|
| 15 |
+
from src.settings import get_settings
|
| 16 |
+
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
+
try:
|
| 20 |
+
from langfuse import Langfuse as _Langfuse
|
| 21 |
+
except ImportError:
|
| 22 |
+
_Langfuse = None # type: ignore[assignment,misc]
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class LangfuseTracer:
|
| 26 |
+
"""Thin wrapper around Langfuse for MediGuard pipeline tracing."""
|
| 27 |
+
|
| 28 |
+
def __init__(self, client: Any | None):
|
| 29 |
+
self._client = client
|
| 30 |
+
self._enabled = client is not None
|
| 31 |
+
|
| 32 |
+
@property
|
| 33 |
+
def enabled(self) -> bool:
|
| 34 |
+
return self._enabled
|
| 35 |
+
|
| 36 |
+
def trace(self, name: str, **kwargs: Any):
|
| 37 |
+
"""Create a new trace (top-level span)."""
|
| 38 |
+
if not self._enabled:
|
| 39 |
+
return _NullSpan()
|
| 40 |
+
return self._client.trace(name=name, **kwargs)
|
| 41 |
+
|
| 42 |
+
@contextmanager
|
| 43 |
+
def span(self, trace, name: str, **kwargs):
|
| 44 |
+
"""Context manager for creating a span within a trace."""
|
| 45 |
+
if not self._enabled or trace is None:
|
| 46 |
+
yield _NullSpan()
|
| 47 |
+
return
|
| 48 |
+
s = trace.span(name=name, **kwargs)
|
| 49 |
+
try:
|
| 50 |
+
yield s
|
| 51 |
+
finally:
|
| 52 |
+
s.end()
|
| 53 |
+
|
| 54 |
+
def score(self, trace_id: str, name: str, value: float, comment: str = ""):
|
| 55 |
+
"""Attach a score to a trace (for evaluation feedback)."""
|
| 56 |
+
if not self._enabled:
|
| 57 |
+
return
|
| 58 |
+
try:
|
| 59 |
+
self._client.score(trace_id=trace_id, name=name, value=value, comment=comment)
|
| 60 |
+
except Exception as exc:
|
| 61 |
+
logger.warning("Langfuse score failed: %s", exc)
|
| 62 |
+
|
| 63 |
+
def flush(self):
|
| 64 |
+
if self._enabled:
|
| 65 |
+
try:
|
| 66 |
+
self._client.flush()
|
| 67 |
+
except Exception:
|
| 68 |
+
pass
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class _NullSpan:
|
| 72 |
+
"""Dummy span object that silently swallows calls."""
|
| 73 |
+
|
| 74 |
+
def __getattr__(self, name: str):
|
| 75 |
+
return lambda *a, **kw: _NullSpan()
|
| 76 |
+
|
| 77 |
+
def end(self):
|
| 78 |
+
pass
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
@lru_cache(maxsize=1)
|
| 82 |
+
def make_langfuse_tracer() -> LangfuseTracer:
|
| 83 |
+
settings = get_settings()
|
| 84 |
+
if not settings.langfuse.enabled or _Langfuse is None:
|
| 85 |
+
logger.info("Langfuse tracing disabled")
|
| 86 |
+
return LangfuseTracer(None)
|
| 87 |
+
try:
|
| 88 |
+
client = _Langfuse(
|
| 89 |
+
public_key=settings.langfuse.public_key,
|
| 90 |
+
secret_key=settings.langfuse.secret_key,
|
| 91 |
+
host=settings.langfuse.host,
|
| 92 |
+
)
|
| 93 |
+
logger.info("Langfuse connected (%s)", settings.langfuse.host)
|
| 94 |
+
return LangfuseTracer(client)
|
| 95 |
+
except Exception as exc:
|
| 96 |
+
logger.warning("Langfuse unavailable: %s", exc)
|
| 97 |
+
return LangfuseTracer(None)
|
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MediGuard AI — Ollama client package."""
|
| 2 |
+
from src.services.ollama.client import OllamaClient, make_ollama_client
|
| 3 |
+
|
| 4 |
+
__all__ = ["OllamaClient", "make_ollama_client"]
|
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MediGuard AI — Ollama Client
|
| 3 |
+
|
| 4 |
+
Production-grade wrapper for the Ollama API with health checks,
|
| 5 |
+
streaming, and LangChain integration.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import logging
|
| 11 |
+
from functools import lru_cache
|
| 12 |
+
from typing import Any, Dict, Iterator, List, Optional
|
| 13 |
+
|
| 14 |
+
import httpx
|
| 15 |
+
|
| 16 |
+
from src.exceptions import OllamaConnectionError, OllamaModelNotFoundError
|
| 17 |
+
from src.settings import get_settings
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class OllamaClient:
|
| 23 |
+
"""Wrapper around the Ollama REST API."""
|
| 24 |
+
|
| 25 |
+
def __init__(self, base_url: str, *, timeout: int = 120):
|
| 26 |
+
self.base_url = base_url.rstrip("/")
|
| 27 |
+
self.timeout = timeout
|
| 28 |
+
self._http = httpx.Client(base_url=self.base_url, timeout=timeout)
|
| 29 |
+
|
| 30 |
+
# ── Health ───────────────────────────────────────────────────────────
|
| 31 |
+
|
| 32 |
+
def ping(self) -> bool:
|
| 33 |
+
try:
|
| 34 |
+
resp = self._http.get("/api/version")
|
| 35 |
+
return resp.status_code == 200
|
| 36 |
+
except Exception:
|
| 37 |
+
return False
|
| 38 |
+
|
| 39 |
+
def health(self) -> Dict[str, Any]:
|
| 40 |
+
try:
|
| 41 |
+
resp = self._http.get("/api/version")
|
| 42 |
+
resp.raise_for_status()
|
| 43 |
+
return resp.json()
|
| 44 |
+
except Exception as exc:
|
| 45 |
+
raise OllamaConnectionError(f"Cannot reach Ollama: {exc}")
|
| 46 |
+
|
| 47 |
+
def list_models(self) -> List[str]:
|
| 48 |
+
try:
|
| 49 |
+
resp = self._http.get("/api/tags")
|
| 50 |
+
resp.raise_for_status()
|
| 51 |
+
return [m["name"] for m in resp.json().get("models", [])]
|
| 52 |
+
except Exception as exc:
|
| 53 |
+
logger.warning("Failed to list Ollama models: %s", exc)
|
| 54 |
+
return []
|
| 55 |
+
|
| 56 |
+
# ── Generation ───────────────────────────────────────────────────────
|
| 57 |
+
|
| 58 |
+
def generate(
|
| 59 |
+
self,
|
| 60 |
+
prompt: str,
|
| 61 |
+
*,
|
| 62 |
+
model: Optional[str] = None,
|
| 63 |
+
system: str = "",
|
| 64 |
+
temperature: float = 0.0,
|
| 65 |
+
num_ctx: int = 8192,
|
| 66 |
+
) -> Dict[str, Any]:
|
| 67 |
+
"""Synchronous generation — returns the full response dict."""
|
| 68 |
+
model = model or get_settings().ollama.model
|
| 69 |
+
payload: Dict[str, Any] = {
|
| 70 |
+
"model": model,
|
| 71 |
+
"prompt": prompt,
|
| 72 |
+
"stream": False,
|
| 73 |
+
"options": {"temperature": temperature, "num_ctx": num_ctx},
|
| 74 |
+
}
|
| 75 |
+
if system:
|
| 76 |
+
payload["system"] = system
|
| 77 |
+
try:
|
| 78 |
+
resp = self._http.post("/api/generate", json=payload)
|
| 79 |
+
resp.raise_for_status()
|
| 80 |
+
return resp.json()
|
| 81 |
+
except httpx.HTTPStatusError as exc:
|
| 82 |
+
if exc.response.status_code == 404:
|
| 83 |
+
raise OllamaModelNotFoundError(f"Model '{model}' not found on Ollama server")
|
| 84 |
+
raise OllamaConnectionError(str(exc))
|
| 85 |
+
except Exception as exc:
|
| 86 |
+
raise OllamaConnectionError(str(exc))
|
| 87 |
+
|
| 88 |
+
def generate_stream(
|
| 89 |
+
self,
|
| 90 |
+
prompt: str,
|
| 91 |
+
*,
|
| 92 |
+
model: Optional[str] = None,
|
| 93 |
+
system: str = "",
|
| 94 |
+
temperature: float = 0.0,
|
| 95 |
+
num_ctx: int = 8192,
|
| 96 |
+
) -> Iterator[str]:
|
| 97 |
+
"""Streaming generation — yields text tokens."""
|
| 98 |
+
model = model or get_settings().ollama.model
|
| 99 |
+
payload: Dict[str, Any] = {
|
| 100 |
+
"model": model,
|
| 101 |
+
"prompt": prompt,
|
| 102 |
+
"stream": True,
|
| 103 |
+
"options": {"temperature": temperature, "num_ctx": num_ctx},
|
| 104 |
+
}
|
| 105 |
+
if system:
|
| 106 |
+
payload["system"] = system
|
| 107 |
+
try:
|
| 108 |
+
with self._http.stream("POST", "/api/generate", json=payload) as resp:
|
| 109 |
+
resp.raise_for_status()
|
| 110 |
+
import json
|
| 111 |
+
for line in resp.iter_lines():
|
| 112 |
+
if line:
|
| 113 |
+
data = json.loads(line)
|
| 114 |
+
token = data.get("response", "")
|
| 115 |
+
if token:
|
| 116 |
+
yield token
|
| 117 |
+
if data.get("done", False):
|
| 118 |
+
break
|
| 119 |
+
except Exception as exc:
|
| 120 |
+
raise OllamaConnectionError(str(exc))
|
| 121 |
+
|
| 122 |
+
# ── LangChain integration ────────────────────────────────────────────
|
| 123 |
+
|
| 124 |
+
def get_langchain_model(
|
| 125 |
+
self,
|
| 126 |
+
*,
|
| 127 |
+
model: Optional[str] = None,
|
| 128 |
+
temperature: float = 0.0,
|
| 129 |
+
json_mode: bool = False,
|
| 130 |
+
):
|
| 131 |
+
"""Return a LangChain ChatOllama instance."""
|
| 132 |
+
model = model or get_settings().ollama.model
|
| 133 |
+
try:
|
| 134 |
+
from langchain_ollama import ChatOllama
|
| 135 |
+
except ImportError:
|
| 136 |
+
from langchain_community.chat_models import ChatOllama
|
| 137 |
+
|
| 138 |
+
return ChatOllama(
|
| 139 |
+
model=model,
|
| 140 |
+
temperature=temperature,
|
| 141 |
+
base_url=self.base_url,
|
| 142 |
+
format="json" if json_mode else None,
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
def close(self):
|
| 146 |
+
self._http.close()
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
@lru_cache(maxsize=1)
|
| 150 |
+
def make_ollama_client() -> OllamaClient:
|
| 151 |
+
settings = get_settings()
|
| 152 |
+
client = OllamaClient(
|
| 153 |
+
base_url=settings.ollama.host,
|
| 154 |
+
timeout=settings.ollama.timeout,
|
| 155 |
+
)
|
| 156 |
+
if client.ping():
|
| 157 |
+
logger.info("Ollama connected at %s", settings.ollama.host)
|
| 158 |
+
else:
|
| 159 |
+
logger.warning("Ollama not reachable at %s", settings.ollama.host)
|
| 160 |
+
return client
|
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MediGuard AI — OpenSearch service package."""
|
| 2 |
+
from src.services.opensearch.client import OpenSearchClient, make_opensearch_client
|
| 3 |
+
from src.services.opensearch.index_config import MEDICAL_CHUNKS_MAPPING
|
| 4 |
+
|
| 5 |
+
__all__ = ["OpenSearchClient", "make_opensearch_client", "MEDICAL_CHUNKS_MAPPING"]
|