Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- .gitattributes +38 -35
- .gitignore +14 -0
- Dockerfile +34 -0
- README.md +100 -10
- app/__init__.py +1 -0
- app/api/__init__.py +1 -0
- app/api/analyze.py +377 -0
- app/api/chat.py +222 -0
- app/api/health.py +31 -0
- app/config.py +47 -0
- app/core/__init__.py +1 -0
- app/core/ai_router.py +484 -0
- app/core/biomarker_parser.py +497 -0
- app/core/pdf_extractor.py +436 -0
- app/core/range_db.py +395 -0
- app/core/scorer.py +399 -0
- app/core/unit_validator.py +110 -0
- app/db/__init__.py +1 -0
- app/db/supabase_client.py +302 -0
- app/main.py +47 -0
- app/models/__init__.py +1 -0
- app/models/biomarker.py +103 -0
- app/models/report.py +152 -0
- data/icmr_ranges.json +214 -0
- requirements.txt +37 -0
- supabase_schema.sql +177 -0
- tests/__init__.py +1 -0
- tests/test_parser.py +374 -0
- tests/test_scorer.py +311 -0
- tests/test_unit_noise.py +62 -0
- venv/Lib/site-packages/pymupdf/mupdfcpp64.dll +3 -0
- venv/Scripts/python.exe +3 -0
- venv/Scripts/uvicorn.exe +3 -0
.gitattributes
CHANGED
|
@@ -1,35 +1,38 @@
|
|
| 1 |
-
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
-
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
-
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
-
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
-
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
-
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
-
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
-
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
-
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
-
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
-
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
-
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
-
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
-
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
-
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
-
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
-
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
-
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
-
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
-
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
-
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
-
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
-
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
-
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
-
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
-
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
-
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
-
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
-
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
-
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
-
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
-
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
-
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
-
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
-
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
venv/Lib/site-packages/pymupdf/mupdfcpp64.dll filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
venv/Scripts/python.exe filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
venv/Scripts/uvicorn.exe filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.env
|
| 2 |
+
.env.local
|
| 3 |
+
__pycache__/
|
| 4 |
+
*.py[cod]
|
| 5 |
+
*.pyo
|
| 6 |
+
.pytest_cache/
|
| 7 |
+
.mypy_cache/
|
| 8 |
+
*.egg-info/
|
| 9 |
+
dist/
|
| 10 |
+
build/
|
| 11 |
+
venv/
|
| 12 |
+
.venv/
|
| 13 |
+
*.log
|
| 14 |
+
.DS_Store
|
Dockerfile
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# LabCard AI Backend — Hugging Face Spaces Docker
|
| 2 |
+
# HF Spaces: non-root user, port 7860, /tmp writable only
|
| 3 |
+
|
| 4 |
+
FROM python:3.11-slim
|
| 5 |
+
|
| 6 |
+
WORKDIR /app
|
| 7 |
+
|
| 8 |
+
# System deps for PyMuPDF (fitz) and pdfplumber
|
| 9 |
+
RUN apt-get update && apt-get install -y \
|
| 10 |
+
libmupdf-dev \
|
| 11 |
+
libfreetype6-dev \
|
| 12 |
+
libharfbuzz-dev \
|
| 13 |
+
libffi-dev \
|
| 14 |
+
gcc \
|
| 15 |
+
g++ \
|
| 16 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 17 |
+
|
| 18 |
+
# HF Spaces non-root user requirement
|
| 19 |
+
RUN useradd -m -u 1000 user
|
| 20 |
+
USER user
|
| 21 |
+
ENV PATH="/home/user/.local/bin:$PATH"
|
| 22 |
+
ENV HOME="/home/user"
|
| 23 |
+
|
| 24 |
+
WORKDIR /home/user/app
|
| 25 |
+
|
| 26 |
+
COPY --chown=user requirements.txt .
|
| 27 |
+
RUN pip install --no-cache-dir --upgrade -r requirements.txt
|
| 28 |
+
|
| 29 |
+
COPY --chown=user . .
|
| 30 |
+
|
| 31 |
+
# HF Spaces MUST use port 7860
|
| 32 |
+
EXPOSE 7860
|
| 33 |
+
|
| 34 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1"]
|
README.md
CHANGED
|
@@ -1,10 +1,100 @@
|
|
| 1 |
-
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk: docker
|
| 7 |
-
pinned: false
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: LabCard AI Backend
|
| 3 |
+
emoji: 🩺
|
| 4 |
+
colorFrom: purple
|
| 5 |
+
colorTo: green
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
app_port: 7860
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# LabCard AI Backend
|
| 12 |
+
|
| 13 |
+
FastAPI backend for [LabCard AI](https://labcardai.vercel.app) — India's first AI-powered lab report health card.
|
| 14 |
+
|
| 15 |
+
## Architecture
|
| 16 |
+
|
| 17 |
+
- **Tier 1–2**: Deterministic PDF parsing + biomarker scoring (zero LLM cost)
|
| 18 |
+
- **Tier 3**: Gemini 2.5 Flash / Groq for explanations (free)
|
| 19 |
+
- **Tier 4**: Claude Sonnet for premium deep analysis
|
| 20 |
+
|
| 21 |
+
## API Docs
|
| 22 |
+
|
| 23 |
+
Visit `/docs` for Swagger UI.
|
| 24 |
+
|
| 25 |
+
## Stack
|
| 26 |
+
|
| 27 |
+
- FastAPI 0.111 + Python 3.11
|
| 28 |
+
- pdfplumber + PyMuPDF (dual PDF engine)
|
| 29 |
+
- Supabase PostgreSQL + Upstash Redis
|
| 30 |
+
- Deployed on Hugging Face Spaces (Docker SDK)
|
| 31 |
+
|
| 32 |
+
---
|
| 33 |
+
|
| 34 |
+
## Deploy to Hugging Face Spaces
|
| 35 |
+
|
| 36 |
+
### 1. Create HF Space
|
| 37 |
+
|
| 38 |
+
```bash
|
| 39 |
+
# Install HF CLI
|
| 40 |
+
pip install huggingface_hub
|
| 41 |
+
|
| 42 |
+
# Login
|
| 43 |
+
huggingface-cli login
|
| 44 |
+
|
| 45 |
+
# Create Space (Docker SDK, public)
|
| 46 |
+
huggingface-cli repo create labcard-backend --type space --space-sdk docker
|
| 47 |
+
```
|
| 48 |
+
|
| 49 |
+
### 2. Push code
|
| 50 |
+
|
| 51 |
+
```bash
|
| 52 |
+
cd labcard-backend
|
| 53 |
+
git init
|
| 54 |
+
git add .
|
| 55 |
+
git commit -m "Initial LabCard AI backend"
|
| 56 |
+
git remote add origin https://huggingface.co/spaces/YOUR-USERNAME/labcard-backend
|
| 57 |
+
git push -u origin main
|
| 58 |
+
```
|
| 59 |
+
|
| 60 |
+
### 3. Add Secrets in HF Space Settings
|
| 61 |
+
|
| 62 |
+
Go to: https://huggingface.co/spaces/YOUR-USERNAME/labcard-backend/settings
|
| 63 |
+
|
| 64 |
+
Add these secrets (same as .env.example):
|
| 65 |
+
```
|
| 66 |
+
ANTHROPIC_API_KEY=sk-ant-...
|
| 67 |
+
GEMINI_API_KEY=AIza...
|
| 68 |
+
GROQ_API_KEY=gsk_...
|
| 69 |
+
SUPABASE_URL=https://xxx.supabase.co
|
| 70 |
+
SUPABASE_SERVICE_KEY=eyJ...
|
| 71 |
+
SUPABASE_JWT_SECRET=your-jwt-secret
|
| 72 |
+
UPSTASH_REDIS_REST_URL=https://xxx.upstash.io
|
| 73 |
+
UPSTASH_REDIS_REST_TOKEN=...
|
| 74 |
+
ALLOWED_ORIGINS=https://labcardai.vercel.app,http://localhost:3000
|
| 75 |
+
```
|
| 76 |
+
|
| 77 |
+
### 4. Your backend URL
|
| 78 |
+
|
| 79 |
+
```
|
| 80 |
+
https://YOUR-USERNAME-labcard-backend.hf.space
|
| 81 |
+
```
|
| 82 |
+
|
| 83 |
+
Update frontend `.env.local`:
|
| 84 |
+
```
|
| 85 |
+
NEXT_PUBLIC_BACKEND_URL=https://YOUR-USERNAME-labcard-backend.hf.space
|
| 86 |
+
```
|
| 87 |
+
|
| 88 |
+
### 5. Verify
|
| 89 |
+
|
| 90 |
+
```bash
|
| 91 |
+
curl https://YOUR-USERNAME-labcard-backend.hf.space/api/health
|
| 92 |
+
# Should return: {"status":"healthy","is_warm":true,...}
|
| 93 |
+
```
|
| 94 |
+
|
| 95 |
+
### Notes
|
| 96 |
+
|
| 97 |
+
- **Cold start**: HF Spaces free tier sleeps after 48h inactivity. First request after sleep takes ~30s.
|
| 98 |
+
- **Port**: Must be 7860 (HF Spaces requirement — already set in Dockerfile).
|
| 99 |
+
- **Non-root user**: Dockerfile already uses `user 1000` as required by HF Spaces.
|
| 100 |
+
- **Logs**: View at https://huggingface.co/spaces/YOUR-USERNAME/labcard-backend/logs
|
app/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# LabCard AI Backend
|
app/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# LabCard AI Backend
|
app/api/analyze.py
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
LabCard AI — POST /api/analyze
|
| 3 |
+
================================
|
| 4 |
+
Full 4-tier pipeline orchestrator.
|
| 5 |
+
|
| 6 |
+
Tier 1 — PDF extraction (pdfplumber + PyMuPDF) [always, zero LLM]
|
| 7 |
+
Tier 2 — Biomarker parse + score (deterministic) [always, zero LLM]
|
| 8 |
+
Tier 3 — AI explanations (Gemini Flash → Groq) [always, ₹0]
|
| 9 |
+
Tier 4 — Deep analysis (Claude Sonnet) [premium only, ~₹4–5]
|
| 10 |
+
|
| 11 |
+
Performance target: p50 < 3s for free tier (no Claude call)
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import asyncio
|
| 16 |
+
import logging
|
| 17 |
+
import time
|
| 18 |
+
from typing import Any
|
| 19 |
+
|
| 20 |
+
from fastapi import APIRouter, File, Form, Header, HTTPException, UploadFile
|
| 21 |
+
from fastapi.responses import JSONResponse
|
| 22 |
+
|
| 23 |
+
from app.core.ai_router import generate_deep_analysis, generate_explanations
|
| 24 |
+
from app.core.biomarker_parser import parse_biomarkers, resolve_and_classify
|
| 25 |
+
from app.core.pdf_extractor import extract_patient_metadata, extract_text_from_pdf
|
| 26 |
+
from app.core.scorer import compute_health_metrics
|
| 27 |
+
from app.models.report import AnalyzeResponse, LabReport, UserTier
|
| 28 |
+
|
| 29 |
+
log = logging.getLogger("labcard.analyze")
|
| 30 |
+
|
| 31 |
+
# ── Supabase persistence (optional — non-blocking) ────────────────────────────
|
| 32 |
+
# Imported lazily so the route still works if Supabase is not configured.
|
| 33 |
+
async def _persist_report(token: str, report: LabReport) -> None:
|
| 34 |
+
"""
|
| 35 |
+
Fire-and-forget Supabase write.
|
| 36 |
+
Runs concurrently — NEVER blocks the HTTP response.
|
| 37 |
+
Failures are logged as warnings only.
|
| 38 |
+
"""
|
| 39 |
+
try:
|
| 40 |
+
from app.db.supabase_client import save_report
|
| 41 |
+
await save_report(token, report)
|
| 42 |
+
except Exception as exc: # noqa: BLE001
|
| 43 |
+
log.warning("Supabase persist failed (non-fatal): %s", exc)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
# ── Template summaries (free tier — zero LLM cost) ────────────────────────────
|
| 47 |
+
def _template_summary(
|
| 48 |
+
abnormal_count: int,
|
| 49 |
+
score: int,
|
| 50 |
+
grade: str,
|
| 51 |
+
lang: str,
|
| 52 |
+
) -> tuple[str, str]:
|
| 53 |
+
"""Return (summary_en, summary_hi) template strings for free tier."""
|
| 54 |
+
en = (
|
| 55 |
+
f"Your report shows {abnormal_count} value(s) outside the normal range. "
|
| 56 |
+
f"Your health score is {score}/100 ({grade}). "
|
| 57 |
+
"Review the color-coded cards below and consult your doctor."
|
| 58 |
+
)
|
| 59 |
+
hi = (
|
| 60 |
+
f"आपकी रिपोर्ट में {abnormal_count} मान सामान्य सीमा से बाहर हैं। "
|
| 61 |
+
f"आपका स्वास्थ्य स्कोर {score}/100 ({grade}) है। "
|
| 62 |
+
"नीचे दिए गए कार्ड देखें और अपने डॉक्टर से मिलें।"
|
| 63 |
+
)
|
| 64 |
+
return en, hi
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
# ── Timing helper ─────────────────────────────────────────────────────────────
|
| 68 |
+
class _Timer:
|
| 69 |
+
"""Lightweight step timer for pipeline instrumentation."""
|
| 70 |
+
def __init__(self) -> None:
|
| 71 |
+
self._steps: dict[str, float] = {}
|
| 72 |
+
self._start = time.monotonic()
|
| 73 |
+
|
| 74 |
+
def mark(self, step: str) -> None:
|
| 75 |
+
self._steps[step] = round((time.monotonic() - self._start) * 1000)
|
| 76 |
+
|
| 77 |
+
def log_summary(self, extra_fields: dict[str, Any] | None = None) -> None:
|
| 78 |
+
summary = {**self._steps, **(extra_fields or {})}
|
| 79 |
+
log.info("Pipeline timing (ms): %s", summary)
|
| 80 |
+
|
| 81 |
+
@property
|
| 82 |
+
def total_ms(self) -> int:
|
| 83 |
+
return round((time.monotonic() - self._start) * 1000)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
# ── Route ──────────────────────────────────────────────────────────────────────
|
| 87 |
+
|
| 88 |
+
router = APIRouter()
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
@router.post("/analyze", response_model=AnalyzeResponse, summary="Analyze a lab report PDF or text")
|
| 92 |
+
async def analyze(
|
| 93 |
+
file: UploadFile | None = File(None, description="PDF file upload (optional if text provided)"),
|
| 94 |
+
text: str | None = Form(None, description="Raw report text (optional if file provided)"),
|
| 95 |
+
lang: str = Form("en", description="Response language: 'en' or 'hi'"),
|
| 96 |
+
tier: str = Form("free", description="User tier: 'free' or 'premium'"),
|
| 97 |
+
authorization: str | None = Header(None, description="Bearer JWT from Supabase Auth"),
|
| 98 |
+
x_user_tier: str | None = Header(None, alias="x-user-tier"),
|
| 99 |
+
) -> AnalyzeResponse:
|
| 100 |
+
|
| 101 |
+
timer = _Timer()
|
| 102 |
+
|
| 103 |
+
# ── Resolve tier (header takes precedence over form field) ───────────────
|
| 104 |
+
resolved_tier_str = (x_user_tier or tier or "free").lower().strip()
|
| 105 |
+
resolved_tier = UserTier.PREMIUM if resolved_tier_str == "premium" else UserTier.FREE
|
| 106 |
+
is_premium = resolved_tier == UserTier.PREMIUM
|
| 107 |
+
|
| 108 |
+
# ── Step 1: Input validation ─────────���────────────────────────────────────
|
| 109 |
+
if file is None and not text:
|
| 110 |
+
raise HTTPException(
|
| 111 |
+
status_code=400,
|
| 112 |
+
detail="Either a PDF file or report text is required.",
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
if file is not None:
|
| 116 |
+
ctype = (file.content_type or "").lower()
|
| 117 |
+
if ctype not in ("application/pdf", "text/plain", ""):
|
| 118 |
+
raise HTTPException(
|
| 119 |
+
status_code=415,
|
| 120 |
+
detail=f"Unsupported file type: {ctype}. Only PDF and .txt are supported.",
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
timer.mark("step1_validation")
|
| 124 |
+
|
| 125 |
+
# ── Step 2: Text extraction (Tier 1) ──────────────────────────────────────
|
| 126 |
+
extracted_text = ""
|
| 127 |
+
extraction_warnings: list[str] = []
|
| 128 |
+
|
| 129 |
+
if file is not None:
|
| 130 |
+
file_bytes = await file.read()
|
| 131 |
+
|
| 132 |
+
# Guard: 10 MB max
|
| 133 |
+
if len(file_bytes) > 10 * 1024 * 1024:
|
| 134 |
+
raise HTTPException(
|
| 135 |
+
status_code=413,
|
| 136 |
+
detail="File too large. Maximum size is 10 MB.",
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
ctype = (file.content_type or "").lower()
|
| 140 |
+
|
| 141 |
+
if ctype == "text/plain":
|
| 142 |
+
try:
|
| 143 |
+
extracted_text = file_bytes.decode("utf-8", errors="replace")
|
| 144 |
+
except Exception as exc: # noqa: BLE001
|
| 145 |
+
raise HTTPException(
|
| 146 |
+
status_code=422,
|
| 147 |
+
detail=f"Could not read text file: {exc}",
|
| 148 |
+
) from exc
|
| 149 |
+
else:
|
| 150 |
+
# Treat as PDF
|
| 151 |
+
try:
|
| 152 |
+
result = await extract_text_from_pdf(file_bytes)
|
| 153 |
+
extracted_text = result.text
|
| 154 |
+
extraction_warnings = result.warnings
|
| 155 |
+
|
| 156 |
+
if result.is_likely_scanned:
|
| 157 |
+
log.warning(
|
| 158 |
+
"Possible scanned PDF (char_count=%d, pages=%d). "
|
| 159 |
+
"OCR not yet supported.",
|
| 160 |
+
result.char_count, result.page_count,
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
log.info(
|
| 164 |
+
"PDF extracted: method=%s pages=%d chars=%d",
|
| 165 |
+
result.method_used, result.page_count, result.char_count,
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
except Exception as exc: # noqa: BLE001
|
| 169 |
+
log.error("PDF extraction error: %s", exc)
|
| 170 |
+
raise HTTPException(
|
| 171 |
+
status_code=422,
|
| 172 |
+
detail=(
|
| 173 |
+
"Could not parse this PDF format. "
|
| 174 |
+
"Try copy-pasting the report text instead."
|
| 175 |
+
),
|
| 176 |
+
) from exc
|
| 177 |
+
else:
|
| 178 |
+
# Direct text submission
|
| 179 |
+
extracted_text = str(text or "").strip()
|
| 180 |
+
|
| 181 |
+
if len(extracted_text.strip()) < 50:
|
| 182 |
+
raise HTTPException(
|
| 183 |
+
status_code=400,
|
| 184 |
+
detail="Could not extract meaningful text from the uploaded file. "
|
| 185 |
+
"Please try pasting the report text manually.",
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
timer.mark("step2_extraction")
|
| 189 |
+
|
| 190 |
+
# ── Step 3: Patient metadata ──────────────────────────────────────────────
|
| 191 |
+
metadata = extract_patient_metadata(extracted_text)
|
| 192 |
+
patient_gender = metadata.get("patient_gender", "unknown")
|
| 193 |
+
patient_age = metadata.get("patient_age", "")
|
| 194 |
+
|
| 195 |
+
log.info(
|
| 196 |
+
"Patient: name=%r age=%r gender=%r lab=%r",
|
| 197 |
+
metadata.get("patient_name"),
|
| 198 |
+
patient_age,
|
| 199 |
+
patient_gender,
|
| 200 |
+
metadata.get("lab_name"),
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
timer.mark("step3_metadata")
|
| 204 |
+
|
| 205 |
+
# ── Step 4: Biomarker parsing + classification (Tier 2) ───────────────────
|
| 206 |
+
raw_biomarkers = parse_biomarkers(extracted_text)
|
| 207 |
+
classified = resolve_and_classify(raw_biomarkers, gender=patient_gender)
|
| 208 |
+
|
| 209 |
+
log.info(
|
| 210 |
+
"Biomarkers: raw=%d classified=%d abnormal=%d",
|
| 211 |
+
len(raw_biomarkers),
|
| 212 |
+
len(classified),
|
| 213 |
+
sum(1 for b in classified if b.status.value != "Normal"),
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
if not classified:
|
| 217 |
+
raise HTTPException(
|
| 218 |
+
status_code=422,
|
| 219 |
+
detail=(
|
| 220 |
+
"No biomarkers found in this report. "
|
| 221 |
+
"Please ensure this is a standard Indian pathology report "
|
| 222 |
+
"(Thyrocare, Dr. Lal, Apollo, SRL, etc.)."
|
| 223 |
+
),
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
timer.mark("step4_parsing")
|
| 227 |
+
|
| 228 |
+
# ── Step 5: Health scoring (Tier 2 — deterministic) ───────────────────────
|
| 229 |
+
metrics = compute_health_metrics(classified, patient_age, patient_gender)
|
| 230 |
+
|
| 231 |
+
log.info(
|
| 232 |
+
"Metrics: score=%d grade=%s bio_age=%d critical=%s",
|
| 233 |
+
metrics.health_score,
|
| 234 |
+
metrics.health_grade,
|
| 235 |
+
metrics.biological_age,
|
| 236 |
+
metrics.has_critical_alert,
|
| 237 |
+
)
|
| 238 |
+
|
| 239 |
+
timer.mark("step5_scoring")
|
| 240 |
+
|
| 241 |
+
# ── Step 6: AI explanations (Tier 3 — free for all) ───────────────────────
|
| 242 |
+
patient_context = (
|
| 243 |
+
f"{patient_gender.capitalize()}, {patient_age} years, "
|
| 244 |
+
f"{metadata.get('lab_name', 'Indian lab')} report"
|
| 245 |
+
)
|
| 246 |
+
|
| 247 |
+
try:
|
| 248 |
+
classified_enriched = await generate_explanations(
|
| 249 |
+
classified, patient_context, lang
|
| 250 |
+
)
|
| 251 |
+
except Exception as exc: # noqa: BLE001
|
| 252 |
+
# AI explanations failed — still return deterministic data
|
| 253 |
+
log.error("Tier 3 explanation generation failed (non-fatal): %s", exc)
|
| 254 |
+
classified_enriched = classified # use unenriched biomarkers
|
| 255 |
+
|
| 256 |
+
timer.mark("step6_explanations")
|
| 257 |
+
|
| 258 |
+
# ── Step 7: Deep analysis (Tier 4 — premium only) ─────────────────────────
|
| 259 |
+
abnormal_count = sum(1 for b in classified_enriched if b.status.value != "Normal")
|
| 260 |
+
|
| 261 |
+
# Build preliminary report (will be updated with deep analysis if premium)
|
| 262 |
+
summary_en, summary_hi = _template_summary(
|
| 263 |
+
abnormal_count, metrics.health_score, metrics.health_grade, lang
|
| 264 |
+
)
|
| 265 |
+
|
| 266 |
+
# Preliminary bio_age_insight from scorer factors
|
| 267 |
+
bio_age_insight = ""
|
| 268 |
+
if metrics.chronological_age > 0 and metrics.biological_age > 0:
|
| 269 |
+
diff = metrics.biological_age - metrics.chronological_age
|
| 270 |
+
if diff > 0:
|
| 271 |
+
bio_age_insight = (
|
| 272 |
+
f"Your body is functioning approximately {diff} year(s) older than your "
|
| 273 |
+
f"actual age of {metrics.chronological_age}, primarily due to nutritional deficiencies."
|
| 274 |
+
)
|
| 275 |
+
elif diff < 0:
|
| 276 |
+
bio_age_insight = (
|
| 277 |
+
f"Great news — your body is functioning {abs(diff)} year(s) younger than "
|
| 278 |
+
f"your actual age of {metrics.chronological_age}!"
|
| 279 |
+
)
|
| 280 |
+
else:
|
| 281 |
+
bio_age_insight = (
|
| 282 |
+
f"Your biological age matches your chronological age of {metrics.chronological_age}."
|
| 283 |
+
)
|
| 284 |
+
|
| 285 |
+
preliminary_report = LabReport(
|
| 286 |
+
# Patient
|
| 287 |
+
patient_name=metadata.get("patient_name", ""),
|
| 288 |
+
patient_age=patient_age,
|
| 289 |
+
patient_gender=patient_gender,
|
| 290 |
+
report_date=metadata.get("report_date", ""),
|
| 291 |
+
lab_name=metadata.get("lab_name", ""),
|
| 292 |
+
# Scores (deterministic)
|
| 293 |
+
health_score=metrics.health_score,
|
| 294 |
+
health_grade=metrics.health_grade, # type: ignore[arg-type]
|
| 295 |
+
# Bio age (deterministic baseline)
|
| 296 |
+
biological_age=metrics.biological_age,
|
| 297 |
+
chronological_age=metrics.chronological_age,
|
| 298 |
+
bio_age_insight=bio_age_insight,
|
| 299 |
+
bio_age_protocol=[],
|
| 300 |
+
# Summaries
|
| 301 |
+
summary=summary_en,
|
| 302 |
+
summary_hindi=summary_hi,
|
| 303 |
+
doctor_note="",
|
| 304 |
+
top_priority=metrics.top_priority,
|
| 305 |
+
# Alerts
|
| 306 |
+
has_critical_alert=metrics.has_critical_alert,
|
| 307 |
+
critical_alert_text=metrics.critical_alert_text,
|
| 308 |
+
# Biomarkers
|
| 309 |
+
biomarkers=classified_enriched,
|
| 310 |
+
# Metadata
|
| 311 |
+
tier_used=resolved_tier,
|
| 312 |
+
processing_time_ms=timer.total_ms,
|
| 313 |
+
ai_provider_used="tier2_only",
|
| 314 |
+
)
|
| 315 |
+
|
| 316 |
+
final_report = preliminary_report
|
| 317 |
+
|
| 318 |
+
if is_premium:
|
| 319 |
+
try:
|
| 320 |
+
deep = await generate_deep_analysis(preliminary_report, lang)
|
| 321 |
+
# Merge Tier 4 fields into report
|
| 322 |
+
final_report = preliminary_report.model_copy(update={
|
| 323 |
+
"summary": deep.summary or summary_en,
|
| 324 |
+
"summary_hindi": deep.summary_hindi or summary_hi,
|
| 325 |
+
"doctor_note": deep.doctor_note,
|
| 326 |
+
"bio_age_insight": deep.bio_age_insight or bio_age_insight,
|
| 327 |
+
"bio_age_protocol": deep.bio_age_protocol,
|
| 328 |
+
"ai_provider_used": "claude",
|
| 329 |
+
})
|
| 330 |
+
log.info("Tier 4 deep analysis merged successfully")
|
| 331 |
+
except Exception as exc: # noqa: BLE001
|
| 332 |
+
log.error("Tier 4 deep analysis failed (non-fatal): %s", exc)
|
| 333 |
+
# Return deterministic + Tier 3 data — deep analysis fields stay as templates
|
| 334 |
+
final_report = preliminary_report.model_copy(update={
|
| 335 |
+
"ai_provider_used": "tier3_only",
|
| 336 |
+
})
|
| 337 |
+
else:
|
| 338 |
+
# Free tier — use template summaries, empty protocol
|
| 339 |
+
final_report = preliminary_report.model_copy(update={
|
| 340 |
+
"ai_provider_used": "gemini_groq",
|
| 341 |
+
})
|
| 342 |
+
|
| 343 |
+
timer.mark("step7_deep_analysis")
|
| 344 |
+
|
| 345 |
+
# ── Step 8: Set final processing time ─────────────────────────────────────
|
| 346 |
+
final_report = final_report.model_copy(update={
|
| 347 |
+
"processing_time_ms": timer.total_ms,
|
| 348 |
+
})
|
| 349 |
+
|
| 350 |
+
timer.mark("step8_assemble")
|
| 351 |
+
|
| 352 |
+
# ── Step 9: Persist to Supabase (fire-and-forget) ─────────────────────────
|
| 353 |
+
if authorization and authorization.startswith("Bearer "):
|
| 354 |
+
token = authorization.removeprefix("Bearer ").strip()
|
| 355 |
+
asyncio.create_task(_persist_report(token, final_report))
|
| 356 |
+
|
| 357 |
+
timer.mark("step9_persist_scheduled")
|
| 358 |
+
|
| 359 |
+
# ���─ Step 10: Log timing + return ──────────────────────────────────────────
|
| 360 |
+
timer.log_summary({
|
| 361 |
+
"tier": resolved_tier_str,
|
| 362 |
+
"total_ms": timer.total_ms,
|
| 363 |
+
"biomarkers": len(classified_enriched),
|
| 364 |
+
"abnormal": abnormal_count,
|
| 365 |
+
"health_score": metrics.health_score,
|
| 366 |
+
"is_premium": is_premium,
|
| 367 |
+
})
|
| 368 |
+
|
| 369 |
+
log.info(
|
| 370 |
+
"Analysis complete: score=%d grade=%s tier=%s total_ms=%d",
|
| 371 |
+
metrics.health_score,
|
| 372 |
+
metrics.health_grade,
|
| 373 |
+
resolved_tier_str,
|
| 374 |
+
timer.total_ms,
|
| 375 |
+
)
|
| 376 |
+
|
| 377 |
+
return final_report # type: ignore[return-value]
|
app/api/chat.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
LabCard AI — POST /api/chat
|
| 3 |
+
============================
|
| 4 |
+
Contextual chat with the patient's report as grounded context.
|
| 5 |
+
|
| 6 |
+
Supports:
|
| 7 |
+
- Pure English ("why is my Vitamin D low?")
|
| 8 |
+
- Pure Hindi ("मेरा विटामिन D कम क्यों है?")
|
| 9 |
+
- Hinglish ("mera vitamin D low kyun hai?")
|
| 10 |
+
- Misspelled medical terms ("haemogloobin", "thyriod")
|
| 11 |
+
|
| 12 |
+
Tier routing:
|
| 13 |
+
- Free → Gemini 2.5 Flash (₹0) → Groq fallback (₹0)
|
| 14 |
+
- Premium → Claude Sonnet → Gemini → Groq
|
| 15 |
+
|
| 16 |
+
Rate limit:
|
| 17 |
+
- Free: 10 messages per session (checked from message history length)
|
| 18 |
+
- Premium: unlimited
|
| 19 |
+
"""
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import json
|
| 23 |
+
import logging
|
| 24 |
+
|
| 25 |
+
from fastapi import APIRouter, HTTPException
|
| 26 |
+
|
| 27 |
+
from app.core.ai_router import call_with_fallback
|
| 28 |
+
from app.models.biomarker import BiomarkerStatus
|
| 29 |
+
from app.models.report import ChatRequest, ChatResponse, LabReport, UserTier
|
| 30 |
+
|
| 31 |
+
log = logging.getLogger("labcard.chat")
|
| 32 |
+
|
| 33 |
+
# ── Rate limits ────────────────────────────────────────────────────────────────
|
| 34 |
+
FREE_CHAT_LIMIT = 10 # max user turns per session for free tier
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# ── Hinglish normalization + chat system prompt ────────────────────────────────
|
| 38 |
+
HINGLISH_NORMALIZER_PROMPT = """
|
| 39 |
+
You are processing a patient's question about their lab report.
|
| 40 |
+
The question may be in:
|
| 41 |
+
- Pure Hindi (Devanagari: "मेरा हीमोग्लोबिन कम क्यों है?")
|
| 42 |
+
- Pure English ("why is my hemoglobin low?")
|
| 43 |
+
- Hinglish — Hindi-English mix, often romanized ("mera sugar high hai kyun?")
|
| 44 |
+
- Medical terms misspelled ("haemogloobin", "thyriod", "शुगर", "खून की कमी")
|
| 45 |
+
|
| 46 |
+
Your task: understand the patient's intent regardless of language or spelling.
|
| 47 |
+
|
| 48 |
+
Rules:
|
| 49 |
+
- Keep responses under 80 words
|
| 50 |
+
- Never diagnose any disease or condition
|
| 51 |
+
- Never recommend specific medications or dosages
|
| 52 |
+
- For serious concerns, always say "please consult your doctor"
|
| 53 |
+
- Be warm and reassuring — you are talking to a non-expert who may be worried
|
| 54 |
+
- Use simple words, not medical jargon
|
| 55 |
+
- If asked something unrelated to the report, gently redirect to the report context
|
| 56 |
+
""".strip()
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _build_system_prompt(report: LabReport, lang: str) -> str:
|
| 60 |
+
"""
|
| 61 |
+
Inject classified report data as grounded context.
|
| 62 |
+
AI does NOT re-analyze — it only explains what the algorithms already found.
|
| 63 |
+
"""
|
| 64 |
+
# Compact abnormal list — avoid sending the full biomarker object list
|
| 65 |
+
abnormal = [
|
| 66 |
+
f"{b.name}: {b.value_raw} {b.unit} ({b.status.value}, normal: {b.normal_range_text})"
|
| 67 |
+
for b in report.biomarkers
|
| 68 |
+
if b.status != BiomarkerStatus.NORMAL
|
| 69 |
+
]
|
| 70 |
+
normal_count = sum(1 for b in report.biomarkers if b.status == BiomarkerStatus.NORMAL)
|
| 71 |
+
abnormal_count = len(abnormal)
|
| 72 |
+
|
| 73 |
+
lang_instruction = (
|
| 74 |
+
"Hindi using proper Devanagari script (not romanized Hindi)"
|
| 75 |
+
if lang == "hi"
|
| 76 |
+
else "simple conversational Indian English"
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
# Build concise context block (controls token cost)
|
| 80 |
+
context_lines = [
|
| 81 |
+
f"Patient: {report.patient_name or 'Patient'}, {report.patient_age or 'unknown age'}, {report.patient_gender}",
|
| 82 |
+
f"Lab: {report.lab_name or 'Indian lab'}, Date: {report.report_date or 'unknown'}",
|
| 83 |
+
f"Health Score: {report.health_score}/100 ({report.health_grade})",
|
| 84 |
+
f"Biomarkers: {normal_count} normal, {abnormal_count} abnormal",
|
| 85 |
+
]
|
| 86 |
+
|
| 87 |
+
if report.top_priority:
|
| 88 |
+
context_lines.append(f"Top priority: {report.top_priority}")
|
| 89 |
+
|
| 90 |
+
if abnormal:
|
| 91 |
+
# Cap at 12 to control context length
|
| 92 |
+
shown = abnormal[:12]
|
| 93 |
+
context_lines.append("Abnormal findings:")
|
| 94 |
+
context_lines.extend(f" - {item}" for item in shown)
|
| 95 |
+
if len(abnormal) > 12:
|
| 96 |
+
context_lines.append(f" ... and {len(abnormal) - 12} more")
|
| 97 |
+
|
| 98 |
+
if report.biological_age and report.chronological_age:
|
| 99 |
+
context_lines.append(
|
| 100 |
+
f"Biological age: {report.biological_age} years "
|
| 101 |
+
f"(actual: {report.chronological_age} years)"
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
context_block = "\n".join(context_lines)
|
| 105 |
+
|
| 106 |
+
return f"""{HINGLISH_NORMALIZER_PROMPT}
|
| 107 |
+
|
| 108 |
+
Patient's Classified Report (pre-analyzed — do NOT re-analyze these values, they are correct):
|
| 109 |
+
{context_block}
|
| 110 |
+
|
| 111 |
+
Language instruction: Respond ONLY in {lang_instruction}.
|
| 112 |
+
Keep your response under 80 words.
|
| 113 |
+
If the patient asks about something not in the report, say you can only discuss what's in their report."""
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _rate_limit_reply(lang: str) -> str:
|
| 117 |
+
"""Return the rate-limit message in the user's language."""
|
| 118 |
+
if lang == "hi":
|
| 119 |
+
return (
|
| 120 |
+
"आपकी 10 free messages समाप्त हो गई हैं। "
|
| 121 |
+
"अधिक chat के लिए premium upgrade करें। 🙏"
|
| 122 |
+
)
|
| 123 |
+
return (
|
| 124 |
+
"You've reached the 10-message free chat limit. "
|
| 125 |
+
"Upgrade to premium for unlimited chat with Claude AI."
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _error_reply(lang: str) -> str:
|
| 130 |
+
"""Return the AI connection error message in the user's language."""
|
| 131 |
+
if lang == "hi":
|
| 132 |
+
return (
|
| 133 |
+
"अभी कनेक्शन में समस्या है। "
|
| 134 |
+
"कृपया थोड़ी देर बाद पुनः प्रयास करें। 🙏"
|
| 135 |
+
)
|
| 136 |
+
return (
|
| 137 |
+
"I'm having trouble connecting right now. "
|
| 138 |
+
"Please try again in a moment."
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
# ── Route ──────────────────────────────────────────────────────────────────────
|
| 143 |
+
|
| 144 |
+
router = APIRouter()
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
@router.post("/chat", response_model=ChatResponse, summary="Chat about a lab report")
|
| 148 |
+
async def chat(request: ChatRequest) -> ChatResponse:
|
| 149 |
+
"""
|
| 150 |
+
Contextual chat endpoint.
|
| 151 |
+
|
| 152 |
+
The patient's classified report is injected as system context.
|
| 153 |
+
AI only generates language — all medical values are pre-verified
|
| 154 |
+
by the deterministic scoring pipeline.
|
| 155 |
+
"""
|
| 156 |
+
lang = request.lang if request.lang in ("en", "hi") else "en"
|
| 157 |
+
|
| 158 |
+
# ── Validate messages ─────────────────────────────────────────────────────
|
| 159 |
+
if not request.messages:
|
| 160 |
+
raise HTTPException(
|
| 161 |
+
status_code=400,
|
| 162 |
+
detail="messages list cannot be empty.",
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
# ── Rate limiting (session-level — free tier only) ────────────────────────
|
| 166 |
+
# Count user turns from the message history sent by the client
|
| 167 |
+
if request.tier == UserTier.FREE:
|
| 168 |
+
user_turn_count = sum(1 for m in request.messages if m.role == "user")
|
| 169 |
+
if user_turn_count > FREE_CHAT_LIMIT:
|
| 170 |
+
log.info("Free chat rate limit hit (user_turns=%d)", user_turn_count)
|
| 171 |
+
return ChatResponse(
|
| 172 |
+
reply=_rate_limit_reply(lang),
|
| 173 |
+
lang=lang,
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
# ── Parse report_data ─────────────────────────────────────────────────────
|
| 177 |
+
try:
|
| 178 |
+
report_dict = json.loads(request.report_data)
|
| 179 |
+
report = LabReport.model_validate(report_dict)
|
| 180 |
+
except (json.JSONDecodeError, ValueError) as exc:
|
| 181 |
+
log.warning("Could not parse report_data: %s", exc)
|
| 182 |
+
# Degrade gracefully — chat without full report context
|
| 183 |
+
report = LabReport(
|
| 184 |
+
health_score=0,
|
| 185 |
+
health_grade="Fair", # type: ignore[arg-type]
|
| 186 |
+
biomarkers=[],
|
| 187 |
+
)
|
| 188 |
+
|
| 189 |
+
# ── Build system prompt with report context ───────────────────────────────
|
| 190 |
+
system_prompt = _build_system_prompt(report, lang)
|
| 191 |
+
|
| 192 |
+
# ── Convert ChatMessage list → provider dict format ───────────────────────
|
| 193 |
+
messages = [
|
| 194 |
+
{"role": m.role, "content": m.content}
|
| 195 |
+
for m in request.messages
|
| 196 |
+
]
|
| 197 |
+
|
| 198 |
+
# ── Route by tier ─────────────────────────────────────────────────────────
|
| 199 |
+
preferred = "claude" if request.tier == UserTier.PREMIUM else "gemini"
|
| 200 |
+
|
| 201 |
+
try:
|
| 202 |
+
raw_reply, provider = await call_with_fallback(
|
| 203 |
+
messages=messages,
|
| 204 |
+
system=system_prompt,
|
| 205 |
+
preferred=preferred,
|
| 206 |
+
max_tokens=300, # 80-word limit → 300 tokens is generous
|
| 207 |
+
)
|
| 208 |
+
reply = raw_reply.strip()
|
| 209 |
+
|
| 210 |
+
log.info(
|
| 211 |
+
"Chat reply: tier=%s provider=%s lang=%s chars=%d",
|
| 212 |
+
request.tier.value,
|
| 213 |
+
provider,
|
| 214 |
+
lang,
|
| 215 |
+
len(reply),
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
except Exception as exc: # noqa: BLE001
|
| 219 |
+
log.error("Chat AI failure (all providers): %s", exc)
|
| 220 |
+
reply = _error_reply(lang)
|
| 221 |
+
|
| 222 |
+
return ChatResponse(reply=reply, lang=lang)
|
app/api/health.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
LabCard AI — GET /api/health
|
| 3 |
+
Health check + warm-up endpoint.
|
| 4 |
+
HF Spaces free tier sleeps after 48h inactivity.
|
| 5 |
+
Frontend pings this on mount to wake the container before the user uploads.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import sys
|
| 10 |
+
import time
|
| 11 |
+
from datetime import datetime, timezone
|
| 12 |
+
|
| 13 |
+
from fastapi import APIRouter
|
| 14 |
+
|
| 15 |
+
router = APIRouter()
|
| 16 |
+
|
| 17 |
+
_START_TIME = time.monotonic()
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@router.get("/health", summary="Health check + warm-up ping")
|
| 21 |
+
async def health_check() -> dict[str, str | float | bool]:
|
| 22 |
+
uptime_seconds = round(time.monotonic() - _START_TIME, 1)
|
| 23 |
+
return {
|
| 24 |
+
"status": "healthy",
|
| 25 |
+
"service": "labcard-backend",
|
| 26 |
+
"version": "1.0.0",
|
| 27 |
+
"python": sys.version.split()[0],
|
| 28 |
+
"uptime_seconds": uptime_seconds,
|
| 29 |
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
| 30 |
+
"is_warm": uptime_seconds > 5, # False on cold start
|
| 31 |
+
}
|
app/config.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
LabCard AI Backend — Configuration
|
| 3 |
+
All settings loaded from environment variables via pydantic-settings.
|
| 4 |
+
"""
|
| 5 |
+
from pydantic_settings import BaseSettings
|
| 6 |
+
from functools import lru_cache
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class Settings(BaseSettings):
|
| 10 |
+
# AI keys
|
| 11 |
+
anthropic_api_key: str = ""
|
| 12 |
+
gemini_api_key: str = ""
|
| 13 |
+
groq_api_key: str = ""
|
| 14 |
+
|
| 15 |
+
# Supabase
|
| 16 |
+
supabase_url: str = ""
|
| 17 |
+
supabase_service_key: str = ""
|
| 18 |
+
supabase_jwt_secret: str = ""
|
| 19 |
+
|
| 20 |
+
# Upstash Redis
|
| 21 |
+
upstash_redis_rest_url: str = ""
|
| 22 |
+
upstash_redis_rest_token: str = ""
|
| 23 |
+
|
| 24 |
+
# CORS — comma-separated Vercel + localhost
|
| 25 |
+
allowed_origins: str = "http://localhost:3000"
|
| 26 |
+
|
| 27 |
+
# App
|
| 28 |
+
environment: str = "development"
|
| 29 |
+
log_level: str = "info"
|
| 30 |
+
|
| 31 |
+
@property
|
| 32 |
+
def origins_list(self) -> list[str]:
|
| 33 |
+
return [o.strip() for o in self.allowed_origins.split(",")]
|
| 34 |
+
|
| 35 |
+
@property
|
| 36 |
+
def is_production(self) -> bool:
|
| 37 |
+
return self.environment == "production"
|
| 38 |
+
|
| 39 |
+
model_config = {"env_file": ".env", "extra": "ignore"}
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@lru_cache()
|
| 43 |
+
def get_settings() -> Settings:
|
| 44 |
+
return Settings()
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
settings = get_settings()
|
app/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# LabCard AI Backend
|
app/core/ai_router.py
ADDED
|
@@ -0,0 +1,484 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
LabCard AI — AI Router (Tier 3 + Tier 4)
|
| 3 |
+
==========================================
|
| 4 |
+
Tier 3 — Gemini 2.5 Flash (FREE, 10 RPM) → Groq fallback
|
| 5 |
+
Used ONLY for language generation (explanations, advice, foods).
|
| 6 |
+
Status/classification already done by deterministic scorer.py.
|
| 7 |
+
|
| 8 |
+
Tier 4 — Claude Sonnet (premium users only)
|
| 9 |
+
Deep analysis: bio-age narrative, doctor note, summaries.
|
| 10 |
+
Falls back to Gemini → Groq if Claude fails.
|
| 11 |
+
|
| 12 |
+
Key insight: AI here generates WORDS, not medical judgments.
|
| 13 |
+
If AI hallucinates, only the advice text is wrong — the health card is always correct.
|
| 14 |
+
"""
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import asyncio
|
| 18 |
+
import json
|
| 19 |
+
import logging
|
| 20 |
+
import time
|
| 21 |
+
from dataclasses import dataclass, field
|
| 22 |
+
from typing import Literal
|
| 23 |
+
|
| 24 |
+
import httpx
|
| 25 |
+
from anthropic import AsyncAnthropic
|
| 26 |
+
from groq import AsyncGroq
|
| 27 |
+
|
| 28 |
+
from app.config import settings
|
| 29 |
+
from app.models.biomarker import Biomarker, BiomarkerStatus
|
| 30 |
+
from app.models.report import LabReport
|
| 31 |
+
|
| 32 |
+
log = logging.getLogger("labcard.ai_router")
|
| 33 |
+
|
| 34 |
+
# ── Client singletons (initialized once at module load) ──────────────────────
|
| 35 |
+
# Connection pooling: reuse connections across requests
|
| 36 |
+
_groq_client: AsyncGroq | None = None
|
| 37 |
+
_claude_client: AsyncAnthropic | None = None
|
| 38 |
+
_http_client: httpx.AsyncClient | None = None # for Gemini REST
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _get_groq() -> AsyncGroq:
|
| 42 |
+
global _groq_client
|
| 43 |
+
if _groq_client is None:
|
| 44 |
+
_groq_client = AsyncGroq(api_key=settings.groq_api_key)
|
| 45 |
+
return _groq_client
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _get_claude() -> AsyncAnthropic:
|
| 49 |
+
global _claude_client
|
| 50 |
+
if _claude_client is None:
|
| 51 |
+
_claude_client = AsyncAnthropic(api_key=settings.anthropic_api_key)
|
| 52 |
+
return _claude_client
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _get_http() -> httpx.AsyncClient:
|
| 56 |
+
global _http_client
|
| 57 |
+
if _http_client is None:
|
| 58 |
+
_http_client = httpx.AsyncClient(timeout=30.0)
|
| 59 |
+
return _http_client
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
# ── Output dataclass ──────────────────────────────────────────────────────────
|
| 63 |
+
|
| 64 |
+
@dataclass
|
| 65 |
+
class DeepAnalysisResult:
|
| 66 |
+
"""Output of Tier 4 Claude analysis."""
|
| 67 |
+
summary: str
|
| 68 |
+
summary_hindi: str
|
| 69 |
+
doctor_note: str
|
| 70 |
+
bio_age_insight: str
|
| 71 |
+
bio_age_protocol: list[str] = field(default_factory=list)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
# ── System prompts ────────────────────────────────────────────────────────────
|
| 75 |
+
|
| 76 |
+
_TIER3_SYSTEM = """You are a medical report interpreter for Indian patients. You receive pre-classified
|
| 77 |
+
biomarker data (status already determined by validated medical algorithms).
|
| 78 |
+
Your ONLY job is to write patient-friendly language. Do NOT reclassify or second-guess
|
| 79 |
+
the status values — they are correct.
|
| 80 |
+
|
| 81 |
+
Rules:
|
| 82 |
+
- Keep each explanation under 25 words
|
| 83 |
+
- Use simple language a non-medical person understands
|
| 84 |
+
- Hindi must be in Devanagari script (not romanized)
|
| 85 |
+
- Indian foods: use common names + Hindi name in brackets e.g. "Palak (पालक)"
|
| 86 |
+
- Never use words: "alarming", "dangerous", "serious", "critical" — status is already shown
|
| 87 |
+
- Never diagnose. Just explain and advise.
|
| 88 |
+
- Respond ONLY with valid JSON array. No markdown, no preamble, no code fences."""
|
| 89 |
+
|
| 90 |
+
_TIER4_SYSTEM = """You are a senior physician reviewing an Indian patient's lab report. The report has
|
| 91 |
+
already been analyzed and classified by validated algorithms. Your task is ONLY to:
|
| 92 |
+
1. Write a 2-sentence patient summary (English)
|
| 93 |
+
2. Write the same summary in Hindi (Devanagari)
|
| 94 |
+
3. Write a brief clinical note for a doctor (max 50 words)
|
| 95 |
+
4. Write one sentence comparing the patient's biological age to chronological age
|
| 96 |
+
5. List exactly 3 actionable protocol steps to improve biological age
|
| 97 |
+
|
| 98 |
+
Return ONLY valid JSON (no markdown, no code fences):
|
| 99 |
+
{
|
| 100 |
+
"summary": "...",
|
| 101 |
+
"summary_hindi": "...",
|
| 102 |
+
"doctor_note": "...",
|
| 103 |
+
"bio_age_insight": "...",
|
| 104 |
+
"bio_age_protocol": ["step 1", "step 2", "step 3"]
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
You are NOT re-analyzing the report. The classifications are correct. Focus on
|
| 108 |
+
clear, warm, actionable communication for an Indian patient.
|
| 109 |
+
Never diagnose. Always recommend consulting a doctor for treatment decisions."""
|
| 110 |
+
|
| 111 |
+
# ── Normal biomarker template (zero AI cost) ──────────────────────────────────
|
| 112 |
+
|
| 113 |
+
def _normal_template(bm: Biomarker, lang: str) -> dict[str, str | list[str]]:
|
| 114 |
+
"""
|
| 115 |
+
Hardcoded template for NORMAL biomarkers.
|
| 116 |
+
Saves API quota — no need to send normal values to AI.
|
| 117 |
+
"""
|
| 118 |
+
en = (
|
| 119 |
+
f"Your {bm.name} is {bm.value_raw} {bm.unit}, "
|
| 120 |
+
f"which is within the normal range ({bm.normal_range_text}). Keep it up!"
|
| 121 |
+
)
|
| 122 |
+
hi = (
|
| 123 |
+
f"आपका {bm.name} {bm.value_raw} {bm.unit} है, "
|
| 124 |
+
f"जो सामान्य सीमा ({bm.normal_range_text}) में है। बढ़िया है!"
|
| 125 |
+
)
|
| 126 |
+
return {
|
| 127 |
+
"name": bm.name,
|
| 128 |
+
"explanation": en,
|
| 129 |
+
"explanation_hindi": hi,
|
| 130 |
+
"advice": "Continue your current healthy habits.",
|
| 131 |
+
"indian_foods": [],
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
# ── Provider: Gemini 2.5 Flash (REST) ────────────────────────────────────────
|
| 136 |
+
|
| 137 |
+
async def call_gemini(prompt: str, max_tokens: int = 2000) -> str:
|
| 138 |
+
"""
|
| 139 |
+
Call Gemini 2.5 Flash via REST API (no SDK — lighter dependency).
|
| 140 |
+
Model: gemini-2.5-flash (GA as of 2026; gemini-1.5-flash is retired → 404)
|
| 141 |
+
Free tier: 10 RPM, 1M tokens/day on Google AI Studio key.
|
| 142 |
+
"""
|
| 143 |
+
if not settings.gemini_api_key:
|
| 144 |
+
raise ValueError("GEMINI_API_KEY not set")
|
| 145 |
+
|
| 146 |
+
url = (
|
| 147 |
+
f"https://generativelanguage.googleapis.com/v1beta/models/"
|
| 148 |
+
f"gemini-2.5-flash:generateContent?key={settings.gemini_api_key}"
|
| 149 |
+
)
|
| 150 |
+
body = {
|
| 151 |
+
"contents": [{"parts": [{"text": prompt}]}],
|
| 152 |
+
"generationConfig": {
|
| 153 |
+
"maxOutputTokens": max_tokens,
|
| 154 |
+
"temperature": 0.2, # low temp for consistent JSON
|
| 155 |
+
"topP": 0.8,
|
| 156 |
+
},
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
http = _get_http()
|
| 160 |
+
resp = await http.post(url, json=body)
|
| 161 |
+
resp.raise_for_status()
|
| 162 |
+
data = resp.json()
|
| 163 |
+
|
| 164 |
+
text: str = data["candidates"][0]["content"]["parts"][0]["text"]
|
| 165 |
+
return text.strip()
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
# ── Provider: Groq ────────────────────────────────────────────────────────────
|
| 169 |
+
|
| 170 |
+
async def call_groq(
|
| 171 |
+
messages: list[dict[str, str]],
|
| 172 |
+
system: str,
|
| 173 |
+
max_tokens: int = 2000,
|
| 174 |
+
) -> str:
|
| 175 |
+
"""
|
| 176 |
+
Call Groq Llama-3.3-70b (free: 30 RPM, 6000 TPM).
|
| 177 |
+
Used as Tier 3 fallback when Gemini quota exceeded.
|
| 178 |
+
"""
|
| 179 |
+
if not settings.groq_api_key:
|
| 180 |
+
raise ValueError("GROQ_API_KEY not set")
|
| 181 |
+
|
| 182 |
+
client = _get_groq()
|
| 183 |
+
resp = await client.chat.completions.create(
|
| 184 |
+
model="llama-3.3-70b-versatile",
|
| 185 |
+
messages=[{"role": "system", "content": system}, *messages],
|
| 186 |
+
max_tokens=max_tokens,
|
| 187 |
+
temperature=0.2,
|
| 188 |
+
)
|
| 189 |
+
content = resp.choices[0].message.content
|
| 190 |
+
if not content:
|
| 191 |
+
raise ValueError("Groq returned empty response")
|
| 192 |
+
return content.strip()
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
# ── Provider: Claude ──────────────────────────────────────────────────────────
|
| 196 |
+
|
| 197 |
+
async def call_claude(
|
| 198 |
+
messages: list[dict[str, str]],
|
| 199 |
+
system: str,
|
| 200 |
+
max_tokens: int = 1000,
|
| 201 |
+
) -> str:
|
| 202 |
+
"""
|
| 203 |
+
Call Claude Sonnet 4 (Tier 4 — premium users only).
|
| 204 |
+
Best medical reasoning. ~₹4–5 per analysis.
|
| 205 |
+
"""
|
| 206 |
+
if not settings.anthropic_api_key:
|
| 207 |
+
raise ValueError("ANTHROPIC_API_KEY not set")
|
| 208 |
+
|
| 209 |
+
client = _get_claude()
|
| 210 |
+
resp = await client.messages.create(
|
| 211 |
+
model="claude-sonnet-4-20250514",
|
| 212 |
+
max_tokens=max_tokens,
|
| 213 |
+
system=system,
|
| 214 |
+
messages=messages, # type: ignore[arg-type]
|
| 215 |
+
)
|
| 216 |
+
text = resp.content[0].text if resp.content else ""
|
| 217 |
+
if not text:
|
| 218 |
+
raise ValueError("Claude returned empty response")
|
| 219 |
+
return text.strip()
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
# ── Fallback orchestrator ─────────────────────────────────────────────────────
|
| 223 |
+
|
| 224 |
+
async def call_with_fallback(
|
| 225 |
+
messages: list[dict[str, str]],
|
| 226 |
+
system: str,
|
| 227 |
+
preferred: Literal["gemini", "claude"],
|
| 228 |
+
max_tokens: int = 2000,
|
| 229 |
+
) -> tuple[str, str]:
|
| 230 |
+
"""
|
| 231 |
+
Route AI call with automatic fallback.
|
| 232 |
+
|
| 233 |
+
Tier 3 (preferred="gemini"):
|
| 234 |
+
gemini-2.5-flash → groq/llama-3.3 → raise
|
| 235 |
+
|
| 236 |
+
Tier 4 (preferred="claude"):
|
| 237 |
+
claude-sonnet-4 → gemini-2.5-flash → groq/llama-3.3 → raise
|
| 238 |
+
|
| 239 |
+
Returns: (response_text, provider_name_used)
|
| 240 |
+
Logs: provider, latency_ms, success/failure
|
| 241 |
+
"""
|
| 242 |
+
user_prompt = "\n".join(m["content"] for m in messages if m["role"] == "user")
|
| 243 |
+
|
| 244 |
+
# Build provider chain based on preferred
|
| 245 |
+
if preferred == "gemini":
|
| 246 |
+
chain: list[tuple[str, object]] = [
|
| 247 |
+
("gemini", lambda: call_gemini(f"{system}\n\n{user_prompt}", max_tokens)),
|
| 248 |
+
("groq", lambda: call_groq(messages, system, max_tokens)),
|
| 249 |
+
]
|
| 250 |
+
else: # claude
|
| 251 |
+
chain = [
|
| 252 |
+
("claude", lambda: call_claude(messages, system, max_tokens)),
|
| 253 |
+
("gemini", lambda: call_gemini(f"{system}\n\n{user_prompt}", max_tokens)),
|
| 254 |
+
("groq", lambda: call_groq(messages, system, max_tokens)),
|
| 255 |
+
]
|
| 256 |
+
|
| 257 |
+
last_error: Exception | None = None
|
| 258 |
+
|
| 259 |
+
for provider_name, fn in chain:
|
| 260 |
+
t0 = time.monotonic()
|
| 261 |
+
try:
|
| 262 |
+
result: str = await fn() # type: ignore[operator]
|
| 263 |
+
latency = round((time.monotonic() - t0) * 1000)
|
| 264 |
+
log.info(
|
| 265 |
+
"AI call success",
|
| 266 |
+
extra={
|
| 267 |
+
"provider": provider_name,
|
| 268 |
+
"latency_ms": latency,
|
| 269 |
+
"tokens_est": len(result.split()) * 1.3,
|
| 270 |
+
},
|
| 271 |
+
)
|
| 272 |
+
return result, provider_name
|
| 273 |
+
except Exception as exc: # noqa: BLE001
|
| 274 |
+
latency = round((time.monotonic() - t0) * 1000)
|
| 275 |
+
log.warning(
|
| 276 |
+
"AI provider failed — trying next",
|
| 277 |
+
extra={
|
| 278 |
+
"provider": provider_name,
|
| 279 |
+
"latency_ms": latency,
|
| 280 |
+
"error": str(exc)[:120],
|
| 281 |
+
},
|
| 282 |
+
)
|
| 283 |
+
last_error = exc
|
| 284 |
+
|
| 285 |
+
raise RuntimeError(
|
| 286 |
+
f"All AI providers failed. Last error: {last_error}"
|
| 287 |
+
) from last_error
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
# ── Tier 3: Generate biomarker explanations ───────────────────────────────────
|
| 291 |
+
|
| 292 |
+
async def generate_explanations(
|
| 293 |
+
biomarkers: list[Biomarker],
|
| 294 |
+
patient_context: str,
|
| 295 |
+
lang: str = "en",
|
| 296 |
+
) -> list[Biomarker]:
|
| 297 |
+
"""
|
| 298 |
+
Tier 3 — Gemini Flash (free) + Groq fallback.
|
| 299 |
+
|
| 300 |
+
Strategy:
|
| 301 |
+
- Normal biomarkers → hardcoded template (zero API cost)
|
| 302 |
+
- Abnormal biomarkers → single batched Gemini/Groq call
|
| 303 |
+
- Merges results back into biomarker list
|
| 304 |
+
|
| 305 |
+
patient_context: brief string e.g. "Male, 28 years, Thyrocare report"
|
| 306 |
+
"""
|
| 307 |
+
abnormal = [b for b in biomarkers if b.status != BiomarkerStatus.NORMAL]
|
| 308 |
+
normal = [b for b in biomarkers if b.status == BiomarkerStatus.NORMAL]
|
| 309 |
+
|
| 310 |
+
# ── Normal → template (free) ──────────────────────────────────────────────
|
| 311 |
+
enriched_normal: dict[str, dict[str, str | list[str]]] = {
|
| 312 |
+
b.name: _normal_template(b, lang)
|
| 313 |
+
for b in normal
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
# ── Abnormal → AI call ────────────────────────────────────────────────────
|
| 317 |
+
enriched_abnormal: dict[str, dict[str, str | list[str]]] = {}
|
| 318 |
+
|
| 319 |
+
if abnormal:
|
| 320 |
+
payload = [
|
| 321 |
+
{
|
| 322 |
+
"name": b.name,
|
| 323 |
+
"value": b.value_raw,
|
| 324 |
+
"unit": b.unit,
|
| 325 |
+
"status": b.status.value,
|
| 326 |
+
"range": b.normal_range_text,
|
| 327 |
+
}
|
| 328 |
+
for b in abnormal
|
| 329 |
+
]
|
| 330 |
+
|
| 331 |
+
user_msg = (
|
| 332 |
+
f"Patient context: {patient_context}\n\n"
|
| 333 |
+
f"Classify and explain these abnormal biomarkers:\n"
|
| 334 |
+
f"{json.dumps(payload, ensure_ascii=False)}"
|
| 335 |
+
)
|
| 336 |
+
|
| 337 |
+
messages = [{"role": "user", "content": user_msg}]
|
| 338 |
+
|
| 339 |
+
try:
|
| 340 |
+
raw, provider = await call_with_fallback(
|
| 341 |
+
messages=messages,
|
| 342 |
+
system=_TIER3_SYSTEM,
|
| 343 |
+
preferred="gemini",
|
| 344 |
+
max_tokens=2000,
|
| 345 |
+
)
|
| 346 |
+
|
| 347 |
+
# Strip markdown fences if present
|
| 348 |
+
clean = raw.strip()
|
| 349 |
+
clean = clean.removeprefix("```json").removeprefix("```").removesuffix("```").strip()
|
| 350 |
+
|
| 351 |
+
items: list[dict] = json.loads(clean)
|
| 352 |
+
for item in items:
|
| 353 |
+
name = item.get("name", "")
|
| 354 |
+
if name:
|
| 355 |
+
enriched_abnormal[name] = item
|
| 356 |
+
|
| 357 |
+
except Exception as exc: # noqa: BLE001
|
| 358 |
+
log.error("Tier 3 explanation generation failed: %s", exc)
|
| 359 |
+
# Fallback: give abnormal markers a generic message
|
| 360 |
+
for b in abnormal:
|
| 361 |
+
enriched_abnormal[b.name] = {
|
| 362 |
+
"name": b.name,
|
| 363 |
+
"explanation": f"Your {b.name} is {b.value_raw} {b.unit} ({b.status.value}). Consult your doctor.",
|
| 364 |
+
"explanation_hindi": f"आपका {b.name} {b.value_raw} {b.unit} ({b.status.value}) है। अपने डॉक्टर से मिलें।",
|
| 365 |
+
"advice": "Please consult your doctor for guidance.",
|
| 366 |
+
"indian_foods": [],
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
# ── Merge enrichments back into biomarker objects ─────────────────────────
|
| 370 |
+
all_enriched = {**enriched_normal, **enriched_abnormal}
|
| 371 |
+
result: list[Biomarker] = []
|
| 372 |
+
|
| 373 |
+
for b in biomarkers:
|
| 374 |
+
enrichment = all_enriched.get(b.name)
|
| 375 |
+
if enrichment:
|
| 376 |
+
# Return new Biomarker with AI fields populated
|
| 377 |
+
result.append(b.model_copy(update={
|
| 378 |
+
"explanation": str(enrichment.get("explanation", "")),
|
| 379 |
+
"explanation_hindi": str(enrichment.get("explanation_hindi", "")),
|
| 380 |
+
"advice": str(enrichment.get("advice", "")),
|
| 381 |
+
"indian_foods": list(enrichment.get("indian_foods", [])),
|
| 382 |
+
}))
|
| 383 |
+
else:
|
| 384 |
+
result.append(b)
|
| 385 |
+
|
| 386 |
+
return result
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
# ── Tier 4: Deep analysis (Claude — premium only) ─────────────────────────────
|
| 390 |
+
|
| 391 |
+
async def generate_deep_analysis(
|
| 392 |
+
report: LabReport,
|
| 393 |
+
lang: str = "en",
|
| 394 |
+
) -> DeepAnalysisResult:
|
| 395 |
+
"""
|
| 396 |
+
Tier 4 — Claude Sonnet (premium users only). Falls back to Gemini/Groq.
|
| 397 |
+
|
| 398 |
+
Sends the full classified report to Claude for:
|
| 399 |
+
- Patient summaries (EN + HI)
|
| 400 |
+
- Doctor handoff note
|
| 401 |
+
- Biological age insight sentence
|
| 402 |
+
- 3-step improvement protocol
|
| 403 |
+
"""
|
| 404 |
+
# Build concise report digest for Claude
|
| 405 |
+
# Don't send raw PDF text — only classified results
|
| 406 |
+
abnormal = [b for b in report.biomarkers if b.status != BiomarkerStatus.NORMAL]
|
| 407 |
+
normal_count = len(report.biomarkers) - len(abnormal)
|
| 408 |
+
critical_count = sum(1 for b in abnormal if b.status.value == "Critical")
|
| 409 |
+
|
| 410 |
+
abnormal_summary = [
|
| 411 |
+
f"{b.name}: {b.value_raw} {b.unit} [{b.status.value}] (normal: {b.normal_range_text})"
|
| 412 |
+
for b in abnormal
|
| 413 |
+
]
|
| 414 |
+
|
| 415 |
+
report_digest = {
|
| 416 |
+
"patient_age": report.patient_age,
|
| 417 |
+
"patient_gender": report.patient_gender,
|
| 418 |
+
"lab_name": report.lab_name,
|
| 419 |
+
"health_score": report.health_score,
|
| 420 |
+
"health_grade": report.health_grade,
|
| 421 |
+
"biological_age": report.biological_age,
|
| 422 |
+
"chronological_age": report.chronological_age,
|
| 423 |
+
"total_biomarkers": len(report.biomarkers),
|
| 424 |
+
"normal_count": normal_count,
|
| 425 |
+
"abnormal_count": len(abnormal),
|
| 426 |
+
"critical_count": critical_count,
|
| 427 |
+
"abnormal_findings": abnormal_summary[:15], # cap at 15 to control tokens
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
user_msg = (
|
| 431 |
+
f"Generate patient communication for this analyzed report:\n"
|
| 432 |
+
f"{json.dumps(report_digest, ensure_ascii=False, indent=2)}"
|
| 433 |
+
)
|
| 434 |
+
|
| 435 |
+
messages = [{"role": "user", "content": user_msg}]
|
| 436 |
+
|
| 437 |
+
raw, provider = await call_with_fallback(
|
| 438 |
+
messages=messages,
|
| 439 |
+
system=_TIER4_SYSTEM,
|
| 440 |
+
preferred="claude",
|
| 441 |
+
max_tokens=1000,
|
| 442 |
+
)
|
| 443 |
+
|
| 444 |
+
log.info("Tier 4 analysis completed via %s", provider)
|
| 445 |
+
|
| 446 |
+
# Parse response
|
| 447 |
+
clean = raw.strip()
|
| 448 |
+
clean = clean.removeprefix("```json").removeprefix("```").removesuffix("```").strip()
|
| 449 |
+
|
| 450 |
+
try:
|
| 451 |
+
data: dict = json.loads(clean)
|
| 452 |
+
except json.JSONDecodeError as exc:
|
| 453 |
+
log.error("Tier 4 JSON parse failed: %s\nRaw: %s", exc, clean[:300])
|
| 454 |
+
# Return safe defaults
|
| 455 |
+
return DeepAnalysisResult(
|
| 456 |
+
summary=f"Your health score is {report.health_score}/100 ({report.health_grade}). "
|
| 457 |
+
f"Please review the findings below and consult your doctor.",
|
| 458 |
+
summary_hindi=(
|
| 459 |
+
f"आपका स्वास्थ्य स्कोर {report.health_score}/100 ({report.health_grade}) है। "
|
| 460 |
+
"कृपया नीचे दिए गए परिणामों की समीक्षा करें और अपने डॉक्टर से परामर्श लें।"
|
| 461 |
+
),
|
| 462 |
+
doctor_note=(
|
| 463 |
+
f"Patient: {report.patient_age}, {report.patient_gender}. "
|
| 464 |
+
f"Health score: {report.health_score}/100. "
|
| 465 |
+
f"{len(abnormal)} abnormal findings. Please review."
|
| 466 |
+
),
|
| 467 |
+
bio_age_insight=(
|
| 468 |
+
"Biological age estimated from blood biomarkers. "
|
| 469 |
+
"Consult your doctor for detailed interpretation."
|
| 470 |
+
),
|
| 471 |
+
bio_age_protocol=[
|
| 472 |
+
"Follow up with your doctor about abnormal findings.",
|
| 473 |
+
"Maintain a balanced diet with adequate iron and vitamins.",
|
| 474 |
+
"Get re-tested in 3 months to track improvement.",
|
| 475 |
+
],
|
| 476 |
+
)
|
| 477 |
+
|
| 478 |
+
return DeepAnalysisResult(
|
| 479 |
+
summary=data.get("summary", ""),
|
| 480 |
+
summary_hindi=data.get("summary_hindi", ""),
|
| 481 |
+
doctor_note=data.get("doctor_note", ""),
|
| 482 |
+
bio_age_insight=data.get("bio_age_insight", ""),
|
| 483 |
+
bio_age_protocol=data.get("bio_age_protocol", [])[:3],
|
| 484 |
+
)
|
app/core/biomarker_parser.py
ADDED
|
@@ -0,0 +1,497 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
LabCard AI — Biomarker Regex Parser
|
| 3 |
+
=====================================
|
| 4 |
+
100% deterministic. Zero LLM. Zero I/O.
|
| 5 |
+
|
| 6 |
+
Handles all major Indian lab report formats:
|
| 7 |
+
Format A (Thyrocare): "Hemoglobin : 10.2 g/dL [13.0 - 17.0]"
|
| 8 |
+
Format B (Dr. Lal): "HEMOGLOBIN (Hb) 10.2 g/dL 13.0 - 17.0 L"
|
| 9 |
+
Format C (Apollo): "Haemoglobin 10.2g/dL Ref: 13.0-17.0"
|
| 10 |
+
Format D (plain): "Hb: 10.2 g/dL (N: 13-17)"
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import re
|
| 15 |
+
from typing import Optional
|
| 16 |
+
|
| 17 |
+
from app.models.biomarker import Biomarker, BiomarkerCategory, BiomarkerRaw, BiomarkerStatus
|
| 18 |
+
|
| 19 |
+
# ── Skip keywords — lines containing these are headers/footers, not data ───────
|
| 20 |
+
_SKIP_KEYWORDS: frozenset[str] = frozenset({
|
| 21 |
+
"unit", "parameter", "test name", "test name", "result", "reference",
|
| 22 |
+
"ref. range", "ref range", "normal range", "biological ref",
|
| 23 |
+
"---", "===", "page", "report", "printed", "signature",
|
| 24 |
+
"interpretation", "note:", "footnote", "pathologist",
|
| 25 |
+
"dr.", "mbbs", "md ", "phd", "this is", "generated",
|
| 26 |
+
"authorised", "authorized", "checked by",
|
| 27 |
+
})
|
| 28 |
+
|
| 29 |
+
# Textbook / footer / contact noise — block before parsing
|
| 30 |
+
_NOISE_SUBSTRINGS: frozenset[str] = frozenset({
|
| 31 |
+
"edition", "chapter", "diagnosis", "fifth", "page", "disclaimer",
|
| 32 |
+
"signature", "queries", "www.", "http", "©", "tel:", "mob:",
|
| 33 |
+
"copyright", "all rights", "pathology", "laboratory report end",
|
| 34 |
+
})
|
| 35 |
+
|
| 36 |
+
# Biomarker names containing these are never valid analytes
|
| 37 |
+
_NOISE_NAME_SUBSTRINGS: frozenset[str] = frozenset({
|
| 38 |
+
"edition", "chapter", "diagnosis", "fifth", "molecular diagnosis",
|
| 39 |
+
"disclaimer", "signature", "www.", "http", "copyright", "page ",
|
| 40 |
+
"printed on", "report id", "barcode", "specimen",
|
| 41 |
+
})
|
| 42 |
+
|
| 43 |
+
# ── Parenthetical suffixes to strip from test names ────────────────────────────
|
| 44 |
+
_STRIP_SUFFIXES: tuple[str, ...] = (
|
| 45 |
+
r"\(automated\)",
|
| 46 |
+
r"\(quantitative\)",
|
| 47 |
+
r"\(serum\)",
|
| 48 |
+
r"\(blood\)",
|
| 49 |
+
r"\(plasma\)",
|
| 50 |
+
r"\(urine\)",
|
| 51 |
+
r"\(random\)",
|
| 52 |
+
r"\(fasting\)",
|
| 53 |
+
r"\(whole blood\)",
|
| 54 |
+
r"\(enzymatic\)",
|
| 55 |
+
r"\(colorimetric\)",
|
| 56 |
+
r"\(nephelometry\)",
|
| 57 |
+
r"\(calculated\)",
|
| 58 |
+
r"\(direct\)",
|
| 59 |
+
r"\(indirect\)",
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
# ── Compiled regex patterns ────────────────────────────────────────────────────
|
| 63 |
+
|
| 64 |
+
# Format A + B: colon or tabular — most common Thyrocare / Dr. Lal layout
|
| 65 |
+
# Groups: (name)(value)(unit)(range)
|
| 66 |
+
PRIMARY_PATTERN = re.compile(
|
| 67 |
+
r"([A-Za-z][A-Za-z0-9\s\(\)\.\/\-]{2,40}?)" # test name (non-greedy, 3-41 chars)
|
| 68 |
+
r"\s*:?\s*" # optional colon separator
|
| 69 |
+
r"([\d]+\.?[\d]*)" # numeric value
|
| 70 |
+
r"\s+" # mandatory space
|
| 71 |
+
r"([\w\/\%µμ]+(?:\/[\w]+)?)" # unit (handles g/dL, mEq/L, µg/mL)
|
| 72 |
+
r"\s*[\[\(]?\s*" # optional opening bracket
|
| 73 |
+
r"([\d\.]+\s*[-–]\s*[\d\.]+|[<>]\s*[\d\.]+)" # range: X-Y or <Y or >X
|
| 74 |
+
r"\s*[\]\)]?", # optional closing bracket
|
| 75 |
+
re.IGNORECASE,
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
# Format C: no brackets, optional "Ref:" prefix
|
| 79 |
+
# Groups: (name)(value)(unit)(range)
|
| 80 |
+
SECONDARY_PATTERN = re.compile(
|
| 81 |
+
r"([A-Za-z][A-Za-z0-9\s\(\)\.]{2,40}?)" # test name
|
| 82 |
+
r"\s+"
|
| 83 |
+
r"([\d]+\.?[\d]*)" # value
|
| 84 |
+
r"\s*"
|
| 85 |
+
r"([\w\/\%]+)" # unit
|
| 86 |
+
r"\s+"
|
| 87 |
+
r"(?:[Rr]ef\s*[:\-]\s*)?" # optional "Ref:"
|
| 88 |
+
r"([\d\.]+\s*[-–]\s*[\d\.]+)", # range X-Y only
|
| 89 |
+
re.IGNORECASE,
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
# Format D: inline abbreviated "Hb: 10.2 g/dL (N: 13-17)"
|
| 93 |
+
# Groups: (name)(value)(unit)(range)
|
| 94 |
+
INLINE_PATTERN = re.compile(
|
| 95 |
+
r"([A-Za-z][A-Za-z0-9\s]{1,30}?)" # test name (shorter)
|
| 96 |
+
r"\s*:\s*" # colon required
|
| 97 |
+
r"([\d\.]+)" # value
|
| 98 |
+
r"\s*"
|
| 99 |
+
r"([\w\/\%]+)" # unit
|
| 100 |
+
r"\s+"
|
| 101 |
+
r"\(?" # optional opening paren
|
| 102 |
+
r"(?:[Nn]\s*[:\-]\s*)?" # optional "N:" prefix
|
| 103 |
+
r"([\d\.]+\s*[-–]\s*[\d\.]+)" # range X-Y
|
| 104 |
+
r"\)?", # optional closing paren
|
| 105 |
+
re.IGNORECASE,
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
# TSV format from pdfplumber table extraction
|
| 109 |
+
# "Hemoglobin\t10.2\tg/dL\t13.0 - 17.0"
|
| 110 |
+
TSV_PATTERN = re.compile(
|
| 111 |
+
r"^(.+?)\t" # name (tab separated)
|
| 112 |
+
r"([\d]+\.?[\d]*)\t" # value
|
| 113 |
+
r"([\w\/\%µμ]+)\t" # unit
|
| 114 |
+
r"([\d\.]+\s*[-–]\s*[\d\.]+|[<>]\s*[\d\.]+)", # range
|
| 115 |
+
re.IGNORECASE,
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
# All patterns in priority order
|
| 119 |
+
_ALL_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
|
| 120 |
+
("tsv", TSV_PATTERN),
|
| 121 |
+
("primary", PRIMARY_PATTERN),
|
| 122 |
+
("secondary", SECONDARY_PATTERN),
|
| 123 |
+
("inline", INLINE_PATTERN),
|
| 124 |
+
]
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
# ── Helper functions ──────────────────────────────────────────────────────────
|
| 128 |
+
|
| 129 |
+
def clean_test_name(raw: str) -> str:
|
| 130 |
+
"""
|
| 131 |
+
Normalize a raw test name string.
|
| 132 |
+
- Strip whitespace
|
| 133 |
+
- Remove meaningless parenthetical suffixes
|
| 134 |
+
- Title case
|
| 135 |
+
- Max 50 chars
|
| 136 |
+
"""
|
| 137 |
+
name = raw.strip()
|
| 138 |
+
|
| 139 |
+
# Remove meaningless suffixes (case-insensitive)
|
| 140 |
+
for suffix_pat in _STRIP_SUFFIXES:
|
| 141 |
+
name = re.sub(suffix_pat, "", name, flags=re.IGNORECASE)
|
| 142 |
+
|
| 143 |
+
# Collapse multiple spaces
|
| 144 |
+
name = re.sub(r"\s{2,}", " ", name).strip()
|
| 145 |
+
|
| 146 |
+
# Title case (handles ALL CAPS from some labs)
|
| 147 |
+
# Only title-case if predominantly uppercase
|
| 148 |
+
if name.upper() == name and len(name) > 3:
|
| 149 |
+
name = name.title()
|
| 150 |
+
|
| 151 |
+
# Trim to 50 chars
|
| 152 |
+
return name[:50]
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def parse_value(raw: str) -> float | None:
|
| 156 |
+
"""
|
| 157 |
+
Parse a value string to float.
|
| 158 |
+
|
| 159 |
+
Handles:
|
| 160 |
+
"10.2" → 10.2
|
| 161 |
+
"10,200" → 10200.0 (Indian comma for thousands)
|
| 162 |
+
"<0.1" → 0.05 (half of the threshold)
|
| 163 |
+
">100" → 101.0 (just above threshold)
|
| 164 |
+
"10.2 L" → 10.2 (strip trailing letter flags)
|
| 165 |
+
"""
|
| 166 |
+
if not raw:
|
| 167 |
+
return None
|
| 168 |
+
|
| 169 |
+
raw = raw.strip()
|
| 170 |
+
|
| 171 |
+
# Strip trailing single-letter flags like "L" (Low) or "H" (High)
|
| 172 |
+
raw = re.sub(r"\s+[LHlh]$", "", raw)
|
| 173 |
+
|
| 174 |
+
# Handle "< X" — report as half of value (below detectable)
|
| 175 |
+
m = re.match(r"^[<≤]\s*([\d,.]+)$", raw)
|
| 176 |
+
if m:
|
| 177 |
+
try:
|
| 178 |
+
v = float(m.group(1).replace(",", ""))
|
| 179 |
+
return round(v * 0.5, 4)
|
| 180 |
+
except ValueError:
|
| 181 |
+
return None
|
| 182 |
+
|
| 183 |
+
# Handle "> X"
|
| 184 |
+
m = re.match(r"^[>≥]\s*([\d,.]+)$", raw)
|
| 185 |
+
if m:
|
| 186 |
+
try:
|
| 187 |
+
v = float(m.group(1).replace(",", ""))
|
| 188 |
+
return round(v + 1, 4)
|
| 189 |
+
except ValueError:
|
| 190 |
+
return None
|
| 191 |
+
|
| 192 |
+
# Standard float — remove Indian comma notation
|
| 193 |
+
cleaned = raw.replace(",", "")
|
| 194 |
+
try:
|
| 195 |
+
return float(cleaned)
|
| 196 |
+
except ValueError:
|
| 197 |
+
return None
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def parse_range(raw: str) -> tuple[float | None, float | None]:
|
| 201 |
+
"""
|
| 202 |
+
Parse range text → (low, high).
|
| 203 |
+
|
| 204 |
+
Handles all real-world Indian lab formats:
|
| 205 |
+
"13.0 - 17.0" → (13.0, 17.0)
|
| 206 |
+
"13.0 – 17.0" → (13.0, 17.0) [en-dash]
|
| 207 |
+
"< 200" → (None, 200.0)
|
| 208 |
+
"> 40" → (40.0, None)
|
| 209 |
+
"150000-400000" → (150000.0, 400000.0)
|
| 210 |
+
"0.4 - 4.0" → (0.4, 4.0)
|
| 211 |
+
"""
|
| 212 |
+
if not raw:
|
| 213 |
+
return None, None
|
| 214 |
+
|
| 215 |
+
t = raw.strip()
|
| 216 |
+
|
| 217 |
+
# "< X" or "<X"
|
| 218 |
+
m = re.match(r"^[<≤]\s*([\d,\.]+)$", t)
|
| 219 |
+
if m:
|
| 220 |
+
try:
|
| 221 |
+
return None, float(m.group(1).replace(",", ""))
|
| 222 |
+
except ValueError:
|
| 223 |
+
return None, None
|
| 224 |
+
|
| 225 |
+
# "> X" or ">X"
|
| 226 |
+
m = re.match(r"^[>≥]\s*([\d,\.]+)$", t)
|
| 227 |
+
if m:
|
| 228 |
+
try:
|
| 229 |
+
return float(m.group(1).replace(",", "")), None
|
| 230 |
+
except ValueError:
|
| 231 |
+
return None, None
|
| 232 |
+
|
| 233 |
+
# "X - Y" or "X – Y" or "X~Y" or "X to Y"
|
| 234 |
+
m = re.match(
|
| 235 |
+
r"^([\d,\.]+)\s*(?:[-–~]|[Tt][Oo])\s*([\d,\.]+)$",
|
| 236 |
+
t.replace(" ", ""), # collapse spaces for match, restore values via groups
|
| 237 |
+
)
|
| 238 |
+
# Try with spaces preserved too
|
| 239 |
+
if not m:
|
| 240 |
+
m = re.match(
|
| 241 |
+
r"^([\d,\.]+)\s*[-–~]\s*([\d,\.]+)$",
|
| 242 |
+
t,
|
| 243 |
+
)
|
| 244 |
+
if m:
|
| 245 |
+
try:
|
| 246 |
+
low = float(m.group(1).replace(",", ""))
|
| 247 |
+
high = float(m.group(2).replace(",", ""))
|
| 248 |
+
if low > high:
|
| 249 |
+
low, high = high, low # swap if inverted
|
| 250 |
+
return low, high
|
| 251 |
+
except ValueError:
|
| 252 |
+
return None, None
|
| 253 |
+
|
| 254 |
+
return None, None
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
def _should_skip_line(line: str) -> bool:
|
| 258 |
+
"""Return True if this line is a header/footer that should not be parsed."""
|
| 259 |
+
line_lower = line.lower().strip()
|
| 260 |
+
|
| 261 |
+
if not line_lower or len(line_lower) < 5:
|
| 262 |
+
return True
|
| 263 |
+
|
| 264 |
+
for kw in _SKIP_KEYWORDS:
|
| 265 |
+
if kw in line_lower:
|
| 266 |
+
return True
|
| 267 |
+
|
| 268 |
+
if re.match(r"^[-=_\s]{3,}$", line_lower):
|
| 269 |
+
return True
|
| 270 |
+
|
| 271 |
+
return False
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
def _is_noise_line(line: str) -> bool:
|
| 275 |
+
"""Footer/textbook/contact noise — drop before regex parsing."""
|
| 276 |
+
stripped = line.strip()
|
| 277 |
+
if not stripped:
|
| 278 |
+
return True
|
| 279 |
+
|
| 280 |
+
line_lower = stripped.lower()
|
| 281 |
+
for sub in _NOISE_SUBSTRINGS:
|
| 282 |
+
if sub in line_lower:
|
| 283 |
+
return True
|
| 284 |
+
|
| 285 |
+
words = stripped.split()
|
| 286 |
+
if len(words) < 2:
|
| 287 |
+
return True
|
| 288 |
+
|
| 289 |
+
# Purely numeric line with no analyte name (≥3 letters)
|
| 290 |
+
if not re.search(r"[a-zA-Z]{3,}", stripped):
|
| 291 |
+
if re.match(r"^[\d\s\.\,\-\+\<\>\[\]\(\)\%\/]+$", stripped):
|
| 292 |
+
return True
|
| 293 |
+
|
| 294 |
+
return False
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
def _is_noise_biomarker(name: str) -> bool:
|
| 298 |
+
"""Reject parsed names that are clearly PDF footer/textbook garbage."""
|
| 299 |
+
n = name.lower().strip()
|
| 300 |
+
if len(n) < 2:
|
| 301 |
+
return True
|
| 302 |
+
for sub in _NOISE_NAME_SUBSTRINGS:
|
| 303 |
+
if sub in n:
|
| 304 |
+
return True
|
| 305 |
+
# Long prose fragments are not analyte names
|
| 306 |
+
if len(name.split()) > 5:
|
| 307 |
+
return True
|
| 308 |
+
return False
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
def filter_report_noise(text: str) -> str:
|
| 312 |
+
"""Remove noise lines from extracted PDF text before biomarker parsing."""
|
| 313 |
+
if not text:
|
| 314 |
+
return ""
|
| 315 |
+
kept: list[str] = []
|
| 316 |
+
for line in text.split("\n"):
|
| 317 |
+
line = line.strip()
|
| 318 |
+
if not line:
|
| 319 |
+
continue
|
| 320 |
+
if _should_skip_line(line) or _is_noise_line(line):
|
| 321 |
+
continue
|
| 322 |
+
kept.append(line)
|
| 323 |
+
return "\n".join(kept)
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
def _dedup_key(name: str, value_raw: str) -> str:
|
| 327 |
+
"""Canonical dedup key — merges Serum Creatinine / Creatinine duplicates."""
|
| 328 |
+
from app.core.range_db import resolve_test_name
|
| 329 |
+
canonical = resolve_test_name(name).lower()
|
| 330 |
+
value_key = re.sub(r"\s+", "", value_raw.strip().lower())
|
| 331 |
+
return f"{canonical}|{value_key}"
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
# ── Main parser ───────────────────────────────────────────────────────────────
|
| 335 |
+
|
| 336 |
+
def parse_biomarkers(text: str) -> list[BiomarkerRaw]:
|
| 337 |
+
"""
|
| 338 |
+
Parse extracted report text into BiomarkerRaw objects.
|
| 339 |
+
|
| 340 |
+
Strategy:
|
| 341 |
+
1. Split into lines (and handle TSV rows from table extraction)
|
| 342 |
+
2. Skip header/footer lines
|
| 343 |
+
3. Try all 4 patterns in priority order on each line
|
| 344 |
+
4. Validate: value must parse as float
|
| 345 |
+
5. De-duplicate by canonical test name (prefer match with range)
|
| 346 |
+
"""
|
| 347 |
+
if not text:
|
| 348 |
+
return []
|
| 349 |
+
|
| 350 |
+
text = filter_report_noise(text)
|
| 351 |
+
lines = text.split("\n")
|
| 352 |
+
seen: dict[str, BiomarkerRaw] = {}
|
| 353 |
+
|
| 354 |
+
for line in lines:
|
| 355 |
+
line = line.strip()
|
| 356 |
+
|
| 357 |
+
if _should_skip_line(line) or _is_noise_line(line):
|
| 358 |
+
continue
|
| 359 |
+
|
| 360 |
+
for pattern_name, pattern in _ALL_PATTERNS:
|
| 361 |
+
m = pattern.search(line)
|
| 362 |
+
if not m:
|
| 363 |
+
continue
|
| 364 |
+
|
| 365 |
+
raw_name = m.group(1)
|
| 366 |
+
raw_value = m.group(2)
|
| 367 |
+
raw_unit = m.group(3) if m.lastindex and m.lastindex >= 3 else ""
|
| 368 |
+
raw_range = m.group(4) if m.lastindex and m.lastindex >= 4 else ""
|
| 369 |
+
|
| 370 |
+
name = clean_test_name(raw_name)
|
| 371 |
+
if not name or len(name) < 2 or _is_noise_biomarker(name):
|
| 372 |
+
continue
|
| 373 |
+
|
| 374 |
+
value_float = parse_value(raw_value)
|
| 375 |
+
if value_float is None:
|
| 376 |
+
continue
|
| 377 |
+
|
| 378 |
+
key = _dedup_key(name, raw_value)
|
| 379 |
+
existing = seen.get(key)
|
| 380 |
+
|
| 381 |
+
candidate = BiomarkerRaw(
|
| 382 |
+
name=name,
|
| 383 |
+
value_raw=raw_value.strip(),
|
| 384 |
+
unit=raw_unit.strip(),
|
| 385 |
+
range_raw=raw_range.strip(),
|
| 386 |
+
line_text=line,
|
| 387 |
+
page_num=0,
|
| 388 |
+
)
|
| 389 |
+
|
| 390 |
+
if existing is None:
|
| 391 |
+
seen[key] = candidate
|
| 392 |
+
elif not existing.range_raw and candidate.range_raw:
|
| 393 |
+
seen[key] = candidate
|
| 394 |
+
|
| 395 |
+
break
|
| 396 |
+
|
| 397 |
+
return list(seen.values())
|
| 398 |
+
|
| 399 |
+
|
| 400 |
+
# ── Post-processing: classify and build Biomarker models ─────────────────────
|
| 401 |
+
|
| 402 |
+
def resolve_and_classify(
|
| 403 |
+
raw_list: list[BiomarkerRaw],
|
| 404 |
+
gender: str = "unknown",
|
| 405 |
+
db: Optional[dict] = None,
|
| 406 |
+
) -> list[Biomarker]:
|
| 407 |
+
"""
|
| 408 |
+
Convert BiomarkerRaw → Biomarker with status + category.
|
| 409 |
+
AI-generated fields (explanation, advice, etc.) are left empty here —
|
| 410 |
+
they are filled in Tier 3/4 by the AI router.
|
| 411 |
+
|
| 412 |
+
Steps per biomarker:
|
| 413 |
+
1. Resolve canonical name via ALIASES
|
| 414 |
+
2. Get reference range (lab range → ICMR fallback)
|
| 415 |
+
3. Parse value as float
|
| 416 |
+
4. Classify status (deterministic)
|
| 417 |
+
5. Build Biomarker model
|
| 418 |
+
"""
|
| 419 |
+
from app.core.range_db import (
|
| 420 |
+
classify_status,
|
| 421 |
+
get_reference_range,
|
| 422 |
+
resolve_test_name,
|
| 423 |
+
)
|
| 424 |
+
from app.core.unit_validator import validate_unit
|
| 425 |
+
|
| 426 |
+
results: list[Biomarker] = []
|
| 427 |
+
|
| 428 |
+
for raw in raw_list:
|
| 429 |
+
value = parse_value(raw.value_raw)
|
| 430 |
+
if value is None:
|
| 431 |
+
continue
|
| 432 |
+
|
| 433 |
+
if _is_noise_biomarker(raw.name):
|
| 434 |
+
continue
|
| 435 |
+
|
| 436 |
+
canonical = resolve_test_name(raw.name)
|
| 437 |
+
|
| 438 |
+
unit, unit_error = validate_unit(canonical, raw.unit)
|
| 439 |
+
|
| 440 |
+
ref = get_reference_range(
|
| 441 |
+
test_name=canonical,
|
| 442 |
+
gender=gender,
|
| 443 |
+
lab_range_text=raw.range_raw or None,
|
| 444 |
+
)
|
| 445 |
+
|
| 446 |
+
# Parse range for Biomarker fields
|
| 447 |
+
low, high = parse_range(raw.range_raw) if raw.range_raw else (None, None)
|
| 448 |
+
if ref and low is None:
|
| 449 |
+
low = ref.get("low")
|
| 450 |
+
if ref and high is None:
|
| 451 |
+
high = ref.get("high")
|
| 452 |
+
|
| 453 |
+
# Classify status
|
| 454 |
+
status = classify_status(value, ref, canonical) if ref else BiomarkerStatus.NORMAL
|
| 455 |
+
|
| 456 |
+
# Map category string → enum
|
| 457 |
+
cat_str = (ref or {}).get("category", "Other")
|
| 458 |
+
try:
|
| 459 |
+
category = BiomarkerCategory(cat_str)
|
| 460 |
+
except ValueError:
|
| 461 |
+
category = BiomarkerCategory.OTHER
|
| 462 |
+
|
| 463 |
+
# Source of range used
|
| 464 |
+
source = (ref or {}).get("source", "icmr")
|
| 465 |
+
|
| 466 |
+
# Build normalized range text
|
| 467 |
+
if raw.range_raw:
|
| 468 |
+
range_text = raw.range_raw
|
| 469 |
+
elif low is not None and high is not None:
|
| 470 |
+
range_text = f"{low} - {high}"
|
| 471 |
+
elif high is not None:
|
| 472 |
+
range_text = f"< {high}"
|
| 473 |
+
elif low is not None:
|
| 474 |
+
range_text = f"> {low}"
|
| 475 |
+
else:
|
| 476 |
+
range_text = ""
|
| 477 |
+
|
| 478 |
+
biomarker = Biomarker(
|
| 479 |
+
name=canonical,
|
| 480 |
+
value=value,
|
| 481 |
+
value_raw=raw.value_raw,
|
| 482 |
+
unit=unit,
|
| 483 |
+
normal_range_low=low,
|
| 484 |
+
normal_range_high=high,
|
| 485 |
+
normal_range_text=range_text,
|
| 486 |
+
status=status,
|
| 487 |
+
category=category,
|
| 488 |
+
source=source,
|
| 489 |
+
explanation="",
|
| 490 |
+
explanation_hindi="",
|
| 491 |
+
advice="",
|
| 492 |
+
indian_foods=[],
|
| 493 |
+
parsing_error=unit_error,
|
| 494 |
+
)
|
| 495 |
+
results.append(biomarker)
|
| 496 |
+
|
| 497 |
+
return results
|
app/core/pdf_extractor.py
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
LabCard AI — PDF Extraction Pipeline
|
| 3 |
+
=====================================
|
| 4 |
+
ZERO LLM — pure deterministic text extraction.
|
| 5 |
+
|
| 6 |
+
Strategy:
|
| 7 |
+
1. pdfplumber → best for columnar/tabular Indian lab layouts (Thyrocare, Dr. Lal, SRL)
|
| 8 |
+
2. PyMuPDF → fallback, faster, handles more complex layouts and encodings
|
| 9 |
+
3. Scanned PDF → detected and flagged (char_count < 200) for future OCR
|
| 10 |
+
|
| 11 |
+
All CPU-bound PDF work runs in asyncio.to_thread() to avoid blocking FastAPI's event loop.
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import asyncio
|
| 16 |
+
import io
|
| 17 |
+
import re
|
| 18 |
+
import unicodedata
|
| 19 |
+
from dataclasses import dataclass, field
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@dataclass
|
| 23 |
+
class ExtractionResult:
|
| 24 |
+
"""Output of the PDF extraction pipeline."""
|
| 25 |
+
text: str
|
| 26 |
+
page_count: int
|
| 27 |
+
method_used: str # "pdfplumber" | "pymupdf" | "direct_text"
|
| 28 |
+
char_count: int
|
| 29 |
+
is_likely_scanned: bool # True if char_count < 200 and page_count > 0
|
| 30 |
+
warnings: list[str] = field(default_factory=list)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
# ── Internal sync workers (run inside asyncio.to_thread) ─────────────────────
|
| 34 |
+
|
| 35 |
+
def _group_words_into_lines(words: list[dict], y_tolerance: float = 4.0) -> list[str]:
|
| 36 |
+
"""
|
| 37 |
+
Group pdfplumber word dicts into reading-order lines by Y-coordinate.
|
| 38 |
+
Fixes multi-column PDFs where extract_text() interleaves columns.
|
| 39 |
+
"""
|
| 40 |
+
if not words:
|
| 41 |
+
return []
|
| 42 |
+
|
| 43 |
+
sorted_words = sorted(
|
| 44 |
+
words,
|
| 45 |
+
key=lambda w: (round(float(w.get("top", 0)) / y_tolerance), float(w.get("x0", 0))),
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
lines_of_words: list[list[dict]] = []
|
| 49 |
+
current_key: int | None = None
|
| 50 |
+
current: list[dict] = []
|
| 51 |
+
|
| 52 |
+
for w in sorted_words:
|
| 53 |
+
row_key = round(float(w.get("top", 0)) / y_tolerance)
|
| 54 |
+
if current_key is None:
|
| 55 |
+
current_key = row_key
|
| 56 |
+
current = [w]
|
| 57 |
+
elif row_key == current_key:
|
| 58 |
+
current.append(w)
|
| 59 |
+
else:
|
| 60 |
+
if current:
|
| 61 |
+
lines_of_words.append(current)
|
| 62 |
+
current = [w]
|
| 63 |
+
current_key = row_key
|
| 64 |
+
|
| 65 |
+
if current:
|
| 66 |
+
lines_of_words.append(current)
|
| 67 |
+
|
| 68 |
+
result: list[str] = []
|
| 69 |
+
for line_words in lines_of_words:
|
| 70 |
+
line_words.sort(key=lambda w: float(w.get("x0", 0)))
|
| 71 |
+
text = " ".join(
|
| 72 |
+
str(w.get("text", "")).strip()
|
| 73 |
+
for w in line_words
|
| 74 |
+
if w.get("text")
|
| 75 |
+
)
|
| 76 |
+
if text.strip():
|
| 77 |
+
result.append(text)
|
| 78 |
+
return result
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _pdfplumber_worker(pdf_bytes: bytes) -> str:
|
| 82 |
+
"""
|
| 83 |
+
Sync worker for pdfplumber extraction.
|
| 84 |
+
Uses extract_words() + Y-coordinate row grouping (not extract_text layout mode).
|
| 85 |
+
Also extracts tabular data as TSV when tables are detected.
|
| 86 |
+
"""
|
| 87 |
+
import pdfplumber # type: ignore[import-untyped]
|
| 88 |
+
|
| 89 |
+
parts: list[str] = []
|
| 90 |
+
|
| 91 |
+
with pdfplumber.open(io.BytesIO(pdf_bytes)) as pdf:
|
| 92 |
+
for page in pdf.pages:
|
| 93 |
+
words = page.extract_words(
|
| 94 |
+
x_tolerance=2,
|
| 95 |
+
y_tolerance=3,
|
| 96 |
+
keep_blank_chars=False,
|
| 97 |
+
use_text_flow=False,
|
| 98 |
+
)
|
| 99 |
+
for line in _group_words_into_lines(words):
|
| 100 |
+
parts.append(line)
|
| 101 |
+
|
| 102 |
+
tables = page.extract_tables()
|
| 103 |
+
for table in tables:
|
| 104 |
+
for row in table:
|
| 105 |
+
if row:
|
| 106 |
+
row_str = "\t".join(
|
| 107 |
+
(cell.strip() if cell else "") for cell in row
|
| 108 |
+
)
|
| 109 |
+
if row_str.strip():
|
| 110 |
+
parts.append(row_str)
|
| 111 |
+
|
| 112 |
+
return "\n\n".join(parts)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def _pymupdf_worker(pdf_bytes: bytes) -> str:
|
| 116 |
+
"""
|
| 117 |
+
Sync worker for PyMuPDF extraction.
|
| 118 |
+
sort=True ensures reading order is preserved left-to-right, top-to-bottom.
|
| 119 |
+
"""
|
| 120 |
+
import fitz # type: ignore[import-untyped] # PyMuPDF
|
| 121 |
+
|
| 122 |
+
parts: list[str] = []
|
| 123 |
+
page_count = 0
|
| 124 |
+
|
| 125 |
+
with fitz.open(stream=pdf_bytes, filetype="pdf") as doc:
|
| 126 |
+
page_count = doc.page_count
|
| 127 |
+
for page in doc:
|
| 128 |
+
text = page.get_text("text", sort=True)
|
| 129 |
+
if text.strip():
|
| 130 |
+
parts.append(text)
|
| 131 |
+
|
| 132 |
+
return "\n\n".join(parts)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def _pymupdf_page_count(pdf_bytes: bytes) -> int:
|
| 136 |
+
"""Get page count via PyMuPDF (used as fallback metadata)."""
|
| 137 |
+
import fitz # type: ignore[import-untyped]
|
| 138 |
+
with fitz.open(stream=pdf_bytes, filetype="pdf") as doc:
|
| 139 |
+
return doc.page_count
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
# ── Async extraction functions ────────────────────────────────────────────────
|
| 143 |
+
|
| 144 |
+
async def _extract_pdfplumber(pdf_bytes: bytes) -> str:
|
| 145 |
+
"""
|
| 146 |
+
Async wrapper — runs pdfplumber in thread pool to avoid blocking event loop.
|
| 147 |
+
Best for: Thyrocare, Dr. Lal PathLabs, SRL, Metropolis (columnar layouts).
|
| 148 |
+
"""
|
| 149 |
+
try:
|
| 150 |
+
text = await asyncio.to_thread(_pdfplumber_worker, pdf_bytes)
|
| 151 |
+
return text
|
| 152 |
+
except Exception as exc: # noqa: BLE001
|
| 153 |
+
raise RuntimeError(f"pdfplumber extraction failed: {exc}") from exc
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
async def _extract_pymupdf(pdf_bytes: bytes) -> str:
|
| 157 |
+
"""
|
| 158 |
+
Async wrapper — PyMuPDF fallback.
|
| 159 |
+
Handles: complex layouts, embedded fonts, some encrypted PDFs.
|
| 160 |
+
"""
|
| 161 |
+
try:
|
| 162 |
+
text = await asyncio.to_thread(_pymupdf_worker, pdf_bytes)
|
| 163 |
+
return text
|
| 164 |
+
except Exception as exc: # noqa: BLE001
|
| 165 |
+
raise RuntimeError(f"PyMuPDF extraction failed: {exc}") from exc
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
# ── Text normalizer ───────────────────────────────────────────────────────────
|
| 169 |
+
|
| 170 |
+
def _normalize_text(raw: str) -> str:
|
| 171 |
+
"""
|
| 172 |
+
Normalize extracted text for the regex biomarker parser.
|
| 173 |
+
|
| 174 |
+
Operations (in order):
|
| 175 |
+
1. Strip BOM
|
| 176 |
+
2. Unicode normalize → NFC (handles composed characters)
|
| 177 |
+
3. Normalize Unicode dashes → ASCII hyphen
|
| 178 |
+
4. Replace tabs with spaces
|
| 179 |
+
5. Collapse 3+ consecutive newlines → 2
|
| 180 |
+
6. Strip trailing whitespace from each line
|
| 181 |
+
7. Strip leading/trailing blank lines
|
| 182 |
+
8. Fix common OCR misreads in numeric contexts only
|
| 183 |
+
"""
|
| 184 |
+
if not raw:
|
| 185 |
+
return ""
|
| 186 |
+
|
| 187 |
+
# 1. Strip BOM
|
| 188 |
+
text = raw.lstrip("\ufeff\ufffe")
|
| 189 |
+
|
| 190 |
+
# 2. Unicode NFC normalization
|
| 191 |
+
text = unicodedata.normalize("NFC", text)
|
| 192 |
+
|
| 193 |
+
# 3. Normalize all dash/hyphen variants to ASCII hyphen
|
| 194 |
+
# en-dash, em-dash, minus sign, figure dash, etc.
|
| 195 |
+
text = re.sub(r"[–—−‐‑‒―]", "-", text)
|
| 196 |
+
|
| 197 |
+
# 4. Tabs → spaces (but not newlines)
|
| 198 |
+
text = text.replace("\t", " ")
|
| 199 |
+
|
| 200 |
+
# 5. Collapse 3+ newlines → 2
|
| 201 |
+
text = re.sub(r"\n{3,}", "\n\n", text)
|
| 202 |
+
|
| 203 |
+
# 6. Strip trailing whitespace from each line
|
| 204 |
+
lines = [line.rstrip() for line in text.split("\n")]
|
| 205 |
+
text = "\n".join(lines)
|
| 206 |
+
|
| 207 |
+
# 7. Strip leading/trailing blank
|
| 208 |
+
text = text.strip()
|
| 209 |
+
|
| 210 |
+
# 8. Fix common OCR misreads — ONLY in positions where a digit is expected
|
| 211 |
+
# Pattern: number-like contexts where "l" → "1" or "O" → "0"
|
| 212 |
+
# Example: "l0.2" → "10.2", "O.4" → "0.4"
|
| 213 |
+
# Only fix when surrounded by digits or decimal points
|
| 214 |
+
# OCR fix: 'l' between digit and digit/dot → '1'
|
| 215 |
+
text = re.sub(r"(?<=[0-9])l(?=[0-9.])", "1", text)
|
| 216 |
+
# OCR fix: 'O' at word boundary before decimal → '0' (e.g. O.4 → 0.4)
|
| 217 |
+
text = re.sub(r"\bO(?=\.[0-9])", "0", text)
|
| 218 |
+
|
| 219 |
+
return text
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
# ── Main entry point ──────────────────────────────────────────────────────────
|
| 223 |
+
|
| 224 |
+
async def extract_text_from_pdf(pdf_bytes: bytes) -> ExtractionResult:
|
| 225 |
+
"""
|
| 226 |
+
Main extraction pipeline.
|
| 227 |
+
|
| 228 |
+
1. Try pdfplumber (layout-aware, best for Indian lab tables)
|
| 229 |
+
2. Fall back to PyMuPDF if result is < 100 chars
|
| 230 |
+
3. Normalize text in both paths
|
| 231 |
+
4. Flag scanned PDFs
|
| 232 |
+
"""
|
| 233 |
+
warnings: list[str] = []
|
| 234 |
+
page_count = 0
|
| 235 |
+
|
| 236 |
+
# ── Path 1: pdfplumber ───────────────────────────────────────────────────
|
| 237 |
+
plumber_text = ""
|
| 238 |
+
try:
|
| 239 |
+
plumber_text = await _extract_pdfplumber(pdf_bytes)
|
| 240 |
+
except RuntimeError as exc:
|
| 241 |
+
warnings.append(f"pdfplumber error: {exc}")
|
| 242 |
+
|
| 243 |
+
if len(plumber_text.strip()) >= 100:
|
| 244 |
+
normalized = _normalize_text(plumber_text)
|
| 245 |
+
# Get page count via PyMuPDF (lightweight)
|
| 246 |
+
try:
|
| 247 |
+
page_count = await asyncio.to_thread(_pymupdf_page_count, pdf_bytes)
|
| 248 |
+
except Exception: # noqa: BLE001
|
| 249 |
+
page_count = 1
|
| 250 |
+
|
| 251 |
+
char_count = len(normalized)
|
| 252 |
+
return ExtractionResult(
|
| 253 |
+
text=normalized,
|
| 254 |
+
page_count=page_count,
|
| 255 |
+
method_used="pdfplumber",
|
| 256 |
+
char_count=char_count,
|
| 257 |
+
is_likely_scanned=(char_count < 200 and page_count > 0),
|
| 258 |
+
warnings=warnings,
|
| 259 |
+
)
|
| 260 |
+
|
| 261 |
+
# ── Path 2: PyMuPDF fallback ─────────────────────────────────────────────
|
| 262 |
+
if len(plumber_text.strip()) < 100:
|
| 263 |
+
warnings.append(
|
| 264 |
+
f"pdfplumber returned {len(plumber_text.strip())} chars — "
|
| 265 |
+
"falling back to PyMuPDF"
|
| 266 |
+
)
|
| 267 |
+
|
| 268 |
+
pymupdf_text = ""
|
| 269 |
+
try:
|
| 270 |
+
pymupdf_text = await _extract_pymupdf(pdf_bytes)
|
| 271 |
+
page_count_raw = await asyncio.to_thread(_pymupdf_page_count, pdf_bytes)
|
| 272 |
+
page_count = page_count_raw
|
| 273 |
+
except RuntimeError as exc:
|
| 274 |
+
warnings.append(f"PyMuPDF error: {exc}")
|
| 275 |
+
page_count = 0
|
| 276 |
+
|
| 277 |
+
# Use whichever gave more content
|
| 278 |
+
best_raw = (
|
| 279 |
+
plumber_text if len(plumber_text) > len(pymupdf_text) else pymupdf_text
|
| 280 |
+
)
|
| 281 |
+
normalized = _normalize_text(best_raw)
|
| 282 |
+
char_count = len(normalized)
|
| 283 |
+
|
| 284 |
+
if char_count < 200 and page_count > 0:
|
| 285 |
+
warnings.append(
|
| 286 |
+
"Very little text extracted — PDF may be image-based (scanned). "
|
| 287 |
+
"OCR support coming in a future version. "
|
| 288 |
+
"Please try copy-pasting the report text instead."
|
| 289 |
+
)
|
| 290 |
+
|
| 291 |
+
return ExtractionResult(
|
| 292 |
+
text=normalized,
|
| 293 |
+
page_count=page_count,
|
| 294 |
+
method_used="pymupdf",
|
| 295 |
+
char_count=char_count,
|
| 296 |
+
is_likely_scanned=(char_count < 200 and page_count > 0),
|
| 297 |
+
warnings=warnings,
|
| 298 |
+
)
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
# ── Patient metadata extractor ────────────────────────────────────────────────
|
| 302 |
+
|
| 303 |
+
# Lab name patterns — checked against first 500 chars of report
|
| 304 |
+
_LAB_PATTERNS: list[tuple[str, str]] = [
|
| 305 |
+
(r"THYROCARE", "Thyrocare Technologies"),
|
| 306 |
+
(r"DR\.?\s*LAL\s*PATH", "Dr. Lal PathLabs"),
|
| 307 |
+
(r"APOLLO\s*DIAG", "Apollo Diagnostics"),
|
| 308 |
+
(r"\bSRL\b", "SRL Diagnostics"),
|
| 309 |
+
(r"METROPOLIS", "Metropolis Healthcare"),
|
| 310 |
+
(r"AGILUS", "Agilus Diagnostics"),
|
| 311 |
+
(r"VIJAYA\s*DIAG", "Vijaya Diagnostics"),
|
| 312 |
+
(r"SUBURBAN\s*DIAG", "Suburban Diagnostics"),
|
| 313 |
+
(r"MEDANTA", "Medanta Diagnostics"),
|
| 314 |
+
(r"FORTIS\s*DIAG", "Fortis Diagnostics"),
|
| 315 |
+
(r"MAX\s*LAB", "Max Lab"),
|
| 316 |
+
(r"MANIPAL\s*PATH", "Manipal Pathology"),
|
| 317 |
+
(r"NATIONAL\s*REFERENCE\s*LAB", "National Reference Laboratory"),
|
| 318 |
+
(r"CORE\s*DIAG", "Core Diagnostics"),
|
| 319 |
+
(r"HEALTHIANS", "Healthians"),
|
| 320 |
+
]
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
def extract_patient_metadata(text: str) -> dict[str, str]:
|
| 324 |
+
"""
|
| 325 |
+
Extract patient metadata from report text using ordered regex.
|
| 326 |
+
|
| 327 |
+
Returns:
|
| 328 |
+
patient_name, patient_age, patient_gender, report_date, lab_name
|
| 329 |
+
All values are strings (empty string if not found).
|
| 330 |
+
"""
|
| 331 |
+
result: dict[str, str] = {
|
| 332 |
+
"patient_name": "",
|
| 333 |
+
"patient_age": "",
|
| 334 |
+
"patient_gender": "",
|
| 335 |
+
"report_date": "",
|
| 336 |
+
"lab_name": "",
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
if not text:
|
| 340 |
+
return result
|
| 341 |
+
|
| 342 |
+
# Work on first 3000 chars — patient info is always at the top
|
| 343 |
+
header = text[:3000]
|
| 344 |
+
|
| 345 |
+
# ── Patient name ─────────────────────────────────────────────────────────
|
| 346 |
+
name_patterns = [
|
| 347 |
+
r"Patient\s+Name\s*[:\-]\s*(.+?)(?:\n|$)",
|
| 348 |
+
r"Patient\s*[:\-]\s*(.+?)(?:\n|$)",
|
| 349 |
+
r"Name\s*[:\-]\s*(.+?)(?:\n|$)",
|
| 350 |
+
r"Mr\.?\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)",
|
| 351 |
+
r"Mrs\.?\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)",
|
| 352 |
+
r"Ms\.?\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)",
|
| 353 |
+
]
|
| 354 |
+
for pattern in name_patterns:
|
| 355 |
+
m = re.search(pattern, header, re.IGNORECASE)
|
| 356 |
+
if m:
|
| 357 |
+
raw = m.group(1).strip()
|
| 358 |
+
# Clean up: remove lab codes, numbers appended
|
| 359 |
+
raw = re.sub(r"\s*\|.*$", "", raw)
|
| 360 |
+
raw = re.sub(r"\s{2,}.*$", "", raw)
|
| 361 |
+
raw = raw.strip()
|
| 362 |
+
if 2 < len(raw) < 60 and not re.search(r"\d{4}", raw):
|
| 363 |
+
result["patient_name"] = raw
|
| 364 |
+
break
|
| 365 |
+
|
| 366 |
+
# ── Age + Gender (often same line) ───────────────────────────────────────
|
| 367 |
+
age_gender_patterns = [
|
| 368 |
+
# "Age / Gender : 28 Years / Male"
|
| 369 |
+
r"Age\s*/\s*(?:Sex|Gender)\s*[:\-]\s*(\d+)\s*(?:Year|Yr)s?\s*/\s*(Male|Female|M|F)",
|
| 370 |
+
# "28 Years / Male" standalone
|
| 371 |
+
r"(\d{1,3})\s*(?:Year|Yr)s?\s*/\s*(Male|Female|M|F)",
|
| 372 |
+
# "Age : 28 Gender : Male" on separate parts
|
| 373 |
+
r"Age\s*[:\-]\s*(\d{1,3})\s*(?:Year|Yr)s?",
|
| 374 |
+
]
|
| 375 |
+
for pattern in age_gender_patterns:
|
| 376 |
+
m = re.search(pattern, header, re.IGNORECASE)
|
| 377 |
+
if m:
|
| 378 |
+
result["patient_age"] = m.group(1).strip()
|
| 379 |
+
if m.lastindex and m.lastindex >= 2:
|
| 380 |
+
gender_raw = m.group(2).strip().upper()
|
| 381 |
+
result["patient_gender"] = (
|
| 382 |
+
"Male" if gender_raw in ("M", "MALE") else "Female"
|
| 383 |
+
)
|
| 384 |
+
break
|
| 385 |
+
|
| 386 |
+
# ── Gender standalone (if not found above) ────────────────────────────────
|
| 387 |
+
if not result["patient_gender"]:
|
| 388 |
+
gender_patterns = [
|
| 389 |
+
r"(?:Sex|Gender)\s*[:\-]\s*(Male|Female|M|F)\b",
|
| 390 |
+
r"\b(Male|Female)\b",
|
| 391 |
+
]
|
| 392 |
+
for pattern in gender_patterns:
|
| 393 |
+
m = re.search(pattern, header, re.IGNORECASE)
|
| 394 |
+
if m:
|
| 395 |
+
g = m.group(1).strip().upper()
|
| 396 |
+
result["patient_gender"] = "Male" if g in ("M", "MALE") else "Female"
|
| 397 |
+
break
|
| 398 |
+
|
| 399 |
+
# ── Report date ───────────────────────────────────────────────────────────
|
| 400 |
+
date_patterns = [
|
| 401 |
+
r"Report\s+Date\s*[:\-]\s*(\d{1,2}[\s\-/]\w{3,9}[\s\-/]\d{4})",
|
| 402 |
+
r"Date\s+of\s+Report\s*[:\-]\s*(\d{1,2}[\s\-/]\w{3,9}[\s\-/]\d{4})",
|
| 403 |
+
r"Sample\s+(?:Collected|Date)\s*[:\-]\s*(\d{1,2}[\s\-/]\w{3,9}[\s\-/]\d{4})",
|
| 404 |
+
r"Collection\s+Date\s*[:\-]\s*(\d{1,2}[\s\-/]\w{3,9}[\s\-/]\d{4})",
|
| 405 |
+
r"Date\s*[:\-]\s*(\d{1,2}[/\-]\d{1,2}[/\-]\d{2,4})",
|
| 406 |
+
r"Dated\s*[:\-]\s*(\d{1,2}[\s\-/]\w{3,9}[\s\-/]\d{4})",
|
| 407 |
+
# Catch "15 May 2025" style anywhere in header
|
| 408 |
+
r"(\d{1,2}\s+(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\w*\s+\d{4})",
|
| 409 |
+
]
|
| 410 |
+
for pattern in date_patterns:
|
| 411 |
+
m = re.search(pattern, header, re.IGNORECASE)
|
| 412 |
+
if m:
|
| 413 |
+
result["report_date"] = m.group(1).strip()
|
| 414 |
+
break
|
| 415 |
+
|
| 416 |
+
# ── Lab name ──────────────────────────────────────────────────────────────
|
| 417 |
+
header_upper = header[:500].upper()
|
| 418 |
+
for pattern, lab_name in _LAB_PATTERNS:
|
| 419 |
+
if re.search(pattern, header_upper):
|
| 420 |
+
result["lab_name"] = lab_name
|
| 421 |
+
break
|
| 422 |
+
|
| 423 |
+
# Fallback: first all-caps line in first 10 lines
|
| 424 |
+
if not result["lab_name"]:
|
| 425 |
+
for line in header.split("\n")[:10]:
|
| 426 |
+
stripped = line.strip()
|
| 427 |
+
if (
|
| 428 |
+
stripped
|
| 429 |
+
and stripped.isupper()
|
| 430 |
+
and len(stripped) > 5
|
| 431 |
+
and not re.match(r"^[\d\W]+$", stripped)
|
| 432 |
+
):
|
| 433 |
+
result["lab_name"] = stripped.title()
|
| 434 |
+
break
|
| 435 |
+
|
| 436 |
+
return result
|
app/core/range_db.py
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
LabCard AI — Reference Range Database + Status Classifier
|
| 3 |
+
=========================================================
|
| 4 |
+
ZERO LLM — all logic is deterministic Python.
|
| 5 |
+
|
| 6 |
+
Priority order for reference range resolution:
|
| 7 |
+
1. Lab's own printed range (extracted from report text)
|
| 8 |
+
2. ICMR/AIIMS hardcoded fallback
|
| 9 |
+
|
| 10 |
+
Status classification is also 100% deterministic — no AI involved.
|
| 11 |
+
If this function gets the status wrong, the report card is wrong.
|
| 12 |
+
AI (Tier 3/4) only enriches explanations and advice — never the status.
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import json
|
| 17 |
+
import re
|
| 18 |
+
from functools import lru_cache
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
|
| 21 |
+
from app.models.biomarker import BiomarkerCategory, BiomarkerStatus
|
| 22 |
+
|
| 23 |
+
# ── Alias table ────────────────────────────────────────────────────────────────
|
| 24 |
+
# Maps every real-world Indian lab variant → canonical ICMR key.
|
| 25 |
+
# Extend this table when new lab formats are encountered.
|
| 26 |
+
ALIASES: dict[str, str] = {
|
| 27 |
+
# ── Hemoglobin ─────────────────────────────────────────────────────────
|
| 28 |
+
"Hb": "Hemoglobin",
|
| 29 |
+
"HGB": "Hemoglobin",
|
| 30 |
+
"Haemoglobin": "Hemoglobin",
|
| 31 |
+
"Hgb": "Hemoglobin",
|
| 32 |
+
# ── CBC derived ────────────────────────────────────────────────────────
|
| 33 |
+
"RBC": "RBC Count",
|
| 34 |
+
"WBC": "WBC Count",
|
| 35 |
+
"TLC": "WBC Count", # Total Leucocyte Count
|
| 36 |
+
"Total Leucocyte Count": "WBC Count",
|
| 37 |
+
"Total WBC Count": "WBC Count",
|
| 38 |
+
"Platelets": "Platelet Count",
|
| 39 |
+
"Platelet": "Platelet Count",
|
| 40 |
+
"PLT": "Platelet Count",
|
| 41 |
+
"Thrombocytes": "Platelet Count",
|
| 42 |
+
"PCV": "Hematocrit",
|
| 43 |
+
"Packed Cell Volume": "Hematocrit",
|
| 44 |
+
"HCT": "Hematocrit",
|
| 45 |
+
# ── Thyroid ────────────────────────────────────────────────────────────
|
| 46 |
+
"TSH (Ultrasensitive)": "TSH",
|
| 47 |
+
"TSH 3rd Gen": "TSH",
|
| 48 |
+
"TSH Ultrasensitive": "TSH",
|
| 49 |
+
"T3 (Triiodothyronine)": "T3",
|
| 50 |
+
"T4 (Thyroxine)": "T4",
|
| 51 |
+
"Triiodothyronine": "T3",
|
| 52 |
+
"Thyroxine": "T4",
|
| 53 |
+
"FT3": "Free T3",
|
| 54 |
+
"FT4": "Free T4",
|
| 55 |
+
"Free Triiodothyronine": "Free T3",
|
| 56 |
+
"Free Thyroxine": "Free T4",
|
| 57 |
+
# ── Vitamins ───────────────────────────────────────────────────────────
|
| 58 |
+
"Vitamin D (25-OH)": "Vitamin D",
|
| 59 |
+
"25-OH Vitamin D": "Vitamin D",
|
| 60 |
+
"Vit D3": "Vitamin D",
|
| 61 |
+
"Vit D": "Vitamin D",
|
| 62 |
+
"25 Hydroxy Vitamin D": "Vitamin D",
|
| 63 |
+
"25(OH)D": "Vitamin D",
|
| 64 |
+
"Cholecalciferol": "Vitamin D",
|
| 65 |
+
"Vit B12": "Vitamin B12",
|
| 66 |
+
"B12": "Vitamin B12",
|
| 67 |
+
"Cyanocobalamin": "Vitamin B12",
|
| 68 |
+
"Cobalamin": "Vitamin B12",
|
| 69 |
+
"Folate": "Folic Acid",
|
| 70 |
+
"Serum Folate": "Folic Acid",
|
| 71 |
+
"Folic Acid Serum": "Folic Acid",
|
| 72 |
+
# ── Sugar / Diabetes ───────────────────────────────────────────────────
|
| 73 |
+
"Fasting Glucose": "Fasting Blood Glucose",
|
| 74 |
+
"FBS": "Fasting Blood Glucose",
|
| 75 |
+
"Blood Sugar Fasting": "Fasting Blood Glucose",
|
| 76 |
+
"BSF": "Fasting Blood Glucose",
|
| 77 |
+
"Glucose Fasting": "Fasting Blood Glucose",
|
| 78 |
+
"Fasting Blood Sugar": "Fasting Blood Glucose",
|
| 79 |
+
"Glycated Hemoglobin": "HbA1c",
|
| 80 |
+
"Glycosylated Hemoglobin": "HbA1c",
|
| 81 |
+
"Hemoglobin A1c": "HbA1c",
|
| 82 |
+
"PPBS": "Post Prandial Glucose",
|
| 83 |
+
"Post Prandial Blood Sugar": "Post Prandial Glucose",
|
| 84 |
+
"RBS": "Random Blood Glucose",
|
| 85 |
+
"Random Blood Sugar": "Random Blood Glucose",
|
| 86 |
+
# ── Liver ──────────────────────────────────────────────────────────────
|
| 87 |
+
"ALT": "SGPT",
|
| 88 |
+
"SGPT (ALT)": "SGPT",
|
| 89 |
+
"Alanine Aminotransferase": "SGPT",
|
| 90 |
+
"ALT (SGPT)": "SGPT",
|
| 91 |
+
"AST": "SGOT",
|
| 92 |
+
"SGOT (AST)": "SGOT",
|
| 93 |
+
"Aspartate Aminotransferase": "SGOT",
|
| 94 |
+
"AST (SGOT)": "SGOT",
|
| 95 |
+
"ALP": "Alkaline Phosphatase",
|
| 96 |
+
"Bilirubin Total": "Total Bilirubin",
|
| 97 |
+
"Bilirubin (Total)": "Total Bilirubin",
|
| 98 |
+
"S. Bilirubin Total": "Total Bilirubin",
|
| 99 |
+
"Bilirubin Direct": "Direct Bilirubin",
|
| 100 |
+
"Bilirubin Indirect": "Indirect Bilirubin",
|
| 101 |
+
"GGT": "GGT",
|
| 102 |
+
"Gamma GT": "GGT",
|
| 103 |
+
"Gamma Glutamyl Transferase": "GGT",
|
| 104 |
+
# ── Kidney ─────────────────────────────────────────────────────────────
|
| 105 |
+
"Creatinine": "Serum Creatinine",
|
| 106 |
+
"S. Creatinine": "Serum Creatinine",
|
| 107 |
+
"Serum Creatinine (S. Cr)": "Serum Creatinine",
|
| 108 |
+
"Blood Urea Nitrogen": "BUN",
|
| 109 |
+
"Urea": "BUN",
|
| 110 |
+
"Blood Urea": "BUN",
|
| 111 |
+
"S. Urea": "BUN",
|
| 112 |
+
"Uric Acid Serum": "Uric Acid",
|
| 113 |
+
"S. Uric Acid": "Uric Acid",
|
| 114 |
+
# ── Lipids ─────────────────────────────────────────────────────────────
|
| 115 |
+
"Cholesterol": "Total Cholesterol",
|
| 116 |
+
"Total Cholesterol (TC)": "Total Cholesterol",
|
| 117 |
+
"HDL": "HDL Cholesterol",
|
| 118 |
+
"HDL-C": "HDL Cholesterol",
|
| 119 |
+
"LDL": "LDL Cholesterol",
|
| 120 |
+
"LDL-C": "LDL Cholesterol",
|
| 121 |
+
"TGL": "Triglycerides",
|
| 122 |
+
"TG": "Triglycerides",
|
| 123 |
+
"Trig": "Triglycerides",
|
| 124 |
+
"VLDL Cholesterol": "VLDL",
|
| 125 |
+
# ── Iron studies ───────────────────────────────────────────────────────
|
| 126 |
+
"S. Iron": "Serum Iron",
|
| 127 |
+
"Iron": "Serum Iron",
|
| 128 |
+
"Iron Serum": "Serum Iron",
|
| 129 |
+
"Ferritin": "Serum Ferritin",
|
| 130 |
+
"S. Ferritin": "Serum Ferritin",
|
| 131 |
+
"Serum Ferritin (SF)": "Serum Ferritin",
|
| 132 |
+
"Iron Binding Capacity": "TIBC",
|
| 133 |
+
"Total Iron Binding Capacity":"TIBC",
|
| 134 |
+
# ── Inflammation ───────────────────────────────────────────────────────
|
| 135 |
+
"C-Reactive Protein": "CRP",
|
| 136 |
+
"hs CRP": "hs-CRP",
|
| 137 |
+
"High Sensitivity CRP": "hs-CRP",
|
| 138 |
+
"High Sensitivity C-Reactive Protein": "hs-CRP",
|
| 139 |
+
# ── Electrolytes ───────────────────────────────────────────────────────
|
| 140 |
+
"Na": "Sodium",
|
| 141 |
+
"K": "Potassium",
|
| 142 |
+
"Cl": "Chloride",
|
| 143 |
+
"Mg": "Magnesium",
|
| 144 |
+
"Serum Calcium": "Calcium",
|
| 145 |
+
"S. Calcium": "Calcium",
|
| 146 |
+
"Serum Sodium": "Sodium",
|
| 147 |
+
"Serum Potassium": "Potassium",
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
# ── Critical thresholds (override everything — life-threatening values) ────────
|
| 151 |
+
# These are hard safety limits — patient must seek immediate care.
|
| 152 |
+
_CRITICAL_OVERRIDES: dict[str, list[tuple[str, float]]] = {
|
| 153 |
+
"Hemoglobin": [("lt", 7.0)],
|
| 154 |
+
"WBC Count": [("gt", 30000), ("lt", 1500)],
|
| 155 |
+
"Platelet Count": [("lt", 20000)],
|
| 156 |
+
"Fasting Blood Glucose":[("gt", 400)],
|
| 157 |
+
"Random Blood Glucose": [("gt", 400)],
|
| 158 |
+
"HbA1c": [("gt", 10.0)],
|
| 159 |
+
"TSH": [("gt", 10.0), ("lt", 0.1)],
|
| 160 |
+
"Serum Creatinine": [("gt", 8.0)],
|
| 161 |
+
"Potassium": [("gt", 6.5), ("lt", 2.5)],
|
| 162 |
+
"Sodium": [("gt", 155), ("lt", 120)],
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
# ── Deficient thresholds (vitamins + iron — below these need DEFICIENT status) ─
|
| 166 |
+
_DEFICIENT_OVERRIDES: dict[str, float] = {
|
| 167 |
+
"Vitamin D": 20.0, # ng/mL — ICMR deficiency cutoff
|
| 168 |
+
"Vitamin B12": 140.0, # pg/mL — ICMR deficiency cutoff
|
| 169 |
+
"Serum Ferritin": 12.0, # ng/mL — WHO deficiency cutoff
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
# ── Elevated categories (use ELEVATED instead of HIGH for these markers) ───────
|
| 173 |
+
_ELEVATED_CATEGORIES: set[str] = {"Liver", "Lipid", "Other"}
|
| 174 |
+
|
| 175 |
+
# ── Deficient categories (use DEFICIENT instead of LOW for these markers) ──────
|
| 176 |
+
_DEFICIENT_CATEGORIES: set[str] = {"Vitamin", "Blood"}
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
# ── DB loader ─────────────────────────────────────────────────────────────────
|
| 180 |
+
|
| 181 |
+
@lru_cache(maxsize=1)
|
| 182 |
+
def load_icmr_db() -> dict[str, dict]:
|
| 183 |
+
"""Load and cache ICMR ranges JSON. Called once on first use."""
|
| 184 |
+
db_path = Path(__file__).parent.parent.parent / "data" / "icmr_ranges.json"
|
| 185 |
+
with open(db_path, encoding="utf-8") as f:
|
| 186 |
+
return json.load(f)
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
# ── Name resolution ──────────────────────────────────────────────────────────
|
| 190 |
+
|
| 191 |
+
def resolve_test_name(raw_name: str) -> str:
|
| 192 |
+
"""
|
| 193 |
+
Map raw lab report test name → canonical ICMR key.
|
| 194 |
+
Strategy:
|
| 195 |
+
1. Exact match in ALIASES
|
| 196 |
+
2. Case-insensitive match in ALIASES
|
| 197 |
+
3. Return raw_name unchanged (may still match ICMR DB directly)
|
| 198 |
+
"""
|
| 199 |
+
name = raw_name.strip()
|
| 200 |
+
|
| 201 |
+
# 1. Exact match
|
| 202 |
+
if name in ALIASES:
|
| 203 |
+
return ALIASES[name]
|
| 204 |
+
|
| 205 |
+
# 2. Case-insensitive scan
|
| 206 |
+
name_lower = name.lower()
|
| 207 |
+
for alias, canonical in ALIASES.items():
|
| 208 |
+
if alias.lower() == name_lower:
|
| 209 |
+
return canonical
|
| 210 |
+
|
| 211 |
+
# 3. Partial match — for names like "Vitamin D Total" → "Vitamin D"
|
| 212 |
+
for alias, canonical in ALIASES.items():
|
| 213 |
+
if alias.lower() in name_lower or name_lower in alias.lower():
|
| 214 |
+
return canonical
|
| 215 |
+
|
| 216 |
+
return name # unchanged — will be looked up directly in ICMR DB
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
# ── Lab range text parser ────────────────────────────────────────────────────
|
| 220 |
+
|
| 221 |
+
def _parse_lab_range(range_text: str) -> tuple[float | None, float | None]:
|
| 222 |
+
"""
|
| 223 |
+
Parse printed range text from lab report into (low, high).
|
| 224 |
+
|
| 225 |
+
Handles all real-world Indian lab formats:
|
| 226 |
+
"13.0 - 17.0" → (13.0, 17.0)
|
| 227 |
+
"13.0–17.0" → (13.0, 17.0) [en-dash]
|
| 228 |
+
"13.0 to 17.0" → (13.0, 17.0)
|
| 229 |
+
"< 200" → (None, 200.0)
|
| 230 |
+
"<200" → (None, 200.0)
|
| 231 |
+
"> 40" → (40.0, None)
|
| 232 |
+
"Up to 150" → (None, 150.0)
|
| 233 |
+
"Upto 150" → (None, 150.0)
|
| 234 |
+
"0.4 - 4.0" → (0.4, 4.0)
|
| 235 |
+
"""
|
| 236 |
+
if not range_text:
|
| 237 |
+
return None, None
|
| 238 |
+
|
| 239 |
+
t = range_text.strip()
|
| 240 |
+
|
| 241 |
+
# "< X" or "<X"
|
| 242 |
+
m = re.match(r'^[<≤]\s*([\d.]+)', t)
|
| 243 |
+
if m:
|
| 244 |
+
return None, float(m.group(1))
|
| 245 |
+
|
| 246 |
+
# "> X" or ">=X"
|
| 247 |
+
m = re.match(r'^[>≥]\s*([\d.]+)', t)
|
| 248 |
+
if m:
|
| 249 |
+
return float(m.group(1)), None
|
| 250 |
+
|
| 251 |
+
# "Up to X" / "Upto X"
|
| 252 |
+
m = re.match(r'^[Uu]p\s*[Tt]o\s*([\d.]+)', t)
|
| 253 |
+
if m:
|
| 254 |
+
return None, float(m.group(1))
|
| 255 |
+
|
| 256 |
+
# "X - Y" / "X–Y" / "X to Y" / "X ~ Y"
|
| 257 |
+
m = re.match(r'^([\d.]+)\s*[-–~]|[Tt][Oo]\s*([\d.]+)$', t)
|
| 258 |
+
m = re.match(
|
| 259 |
+
r'^([\d.]+)\s*(?:[-–~]|[Tt][Oo])\s*([\d.]+)$',
|
| 260 |
+
t.replace(',', '')
|
| 261 |
+
)
|
| 262 |
+
if m:
|
| 263 |
+
return float(m.group(1)), float(m.group(2))
|
| 264 |
+
|
| 265 |
+
return None, None
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
# ── Main range resolver ──────────────────────────────────────────────────────
|
| 269 |
+
|
| 270 |
+
def get_reference_range(
|
| 271 |
+
test_name: str,
|
| 272 |
+
gender: str = "unknown",
|
| 273 |
+
lab_range_text: str | None = None,
|
| 274 |
+
) -> dict | None:
|
| 275 |
+
"""
|
| 276 |
+
Resolve reference range for a biomarker.
|
| 277 |
+
|
| 278 |
+
Priority:
|
| 279 |
+
1. Parse lab's own printed range (most clinically relevant)
|
| 280 |
+
2. ICMR/AIIMS database lookup
|
| 281 |
+
|
| 282 |
+
Returns dict with keys: low, high, category, source
|
| 283 |
+
Returns None if no range found.
|
| 284 |
+
"""
|
| 285 |
+
canonical = resolve_test_name(test_name)
|
| 286 |
+
gender_key = gender.lower() if gender.lower() in ("male", "female") else "any"
|
| 287 |
+
category = "Other"
|
| 288 |
+
|
| 289 |
+
# ── Priority 1: Lab printed range ───────────────────────────────────────
|
| 290 |
+
if lab_range_text:
|
| 291 |
+
low, high = _parse_lab_range(lab_range_text)
|
| 292 |
+
if low is not None or high is not None:
|
| 293 |
+
# Try to get category from ICMR DB even if we use lab range
|
| 294 |
+
db = load_icmr_db()
|
| 295 |
+
data = db.get(canonical, {})
|
| 296 |
+
cat_entry = data.get("any") or data.get(gender_key)
|
| 297 |
+
if cat_entry:
|
| 298 |
+
category = cat_entry.get("category", "Other")
|
| 299 |
+
return {
|
| 300 |
+
"low": low,
|
| 301 |
+
"high": high,
|
| 302 |
+
"category": category,
|
| 303 |
+
"source": "lab",
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
# ── Priority 2: ICMR DB ─────────────────────────────────────────────────
|
| 307 |
+
db = load_icmr_db()
|
| 308 |
+
data = db.get(canonical, {})
|
| 309 |
+
|
| 310 |
+
if not data:
|
| 311 |
+
return None # Unknown biomarker — can't classify
|
| 312 |
+
|
| 313 |
+
# Gender-specific → any fallback
|
| 314 |
+
entry = data.get(gender_key) or data.get("any")
|
| 315 |
+
if not entry:
|
| 316 |
+
return None
|
| 317 |
+
|
| 318 |
+
return {
|
| 319 |
+
"low": entry.get("low"),
|
| 320 |
+
"high": entry.get("high"),
|
| 321 |
+
"category": entry.get("category", "Other"),
|
| 322 |
+
"source": "icmr",
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
# ── Status classifier ────────────────────────────────────────────────────────
|
| 327 |
+
|
| 328 |
+
def classify_status(
|
| 329 |
+
value: float,
|
| 330 |
+
ref: dict | None,
|
| 331 |
+
test_name: str,
|
| 332 |
+
) -> BiomarkerStatus:
|
| 333 |
+
"""
|
| 334 |
+
Classify a biomarker value into a status.
|
| 335 |
+
100% deterministic — zero LLM.
|
| 336 |
+
|
| 337 |
+
Order of precedence:
|
| 338 |
+
1. Critical overrides (life-threatening)
|
| 339 |
+
2. Deficient overrides (vitamin/iron specific)
|
| 340 |
+
3. Standard range comparison
|
| 341 |
+
"""
|
| 342 |
+
if ref is None:
|
| 343 |
+
return BiomarkerStatus.NORMAL # Can't classify without range
|
| 344 |
+
|
| 345 |
+
canonical = resolve_test_name(test_name)
|
| 346 |
+
low = ref.get("low")
|
| 347 |
+
high = ref.get("high")
|
| 348 |
+
category = ref.get("category", "Other")
|
| 349 |
+
|
| 350 |
+
# ── 1. Critical overrides ────────────────────────────────────────────────
|
| 351 |
+
overrides = _CRITICAL_OVERRIDES.get(canonical, [])
|
| 352 |
+
for op, threshold in overrides:
|
| 353 |
+
if op == "lt" and value < threshold:
|
| 354 |
+
return BiomarkerStatus.CRITICAL
|
| 355 |
+
if op == "gt" and value > threshold:
|
| 356 |
+
return BiomarkerStatus.CRITICAL
|
| 357 |
+
|
| 358 |
+
# ── 2. Deficient overrides (vitamins + ferritin) ─────────────────────────
|
| 359 |
+
deficient_cutoff = _DEFICIENT_OVERRIDES.get(canonical)
|
| 360 |
+
if deficient_cutoff is not None and value < deficient_cutoff:
|
| 361 |
+
return BiomarkerStatus.DEFICIENT
|
| 362 |
+
|
| 363 |
+
# ── 3. Standard range logic ──────────────────────────────────────────────
|
| 364 |
+
below_low = (low is not None and value < low)
|
| 365 |
+
above_high = (high is not None and value > high)
|
| 366 |
+
|
| 367 |
+
if below_low:
|
| 368 |
+
# Vitamins and iron markers → DEFICIENT instead of LOW
|
| 369 |
+
if category in _DEFICIENT_CATEGORIES and canonical not in {
|
| 370 |
+
"Hemoglobin", "RBC Count", "WBC Count", "Platelet Count",
|
| 371 |
+
"Hematocrit", "Neutrophils", "Lymphocytes",
|
| 372 |
+
}:
|
| 373 |
+
return BiomarkerStatus.DEFICIENT
|
| 374 |
+
return BiomarkerStatus.LOW
|
| 375 |
+
|
| 376 |
+
if above_high:
|
| 377 |
+
# Liver + lipid markers → ELEVATED instead of HIGH
|
| 378 |
+
if category in _ELEVATED_CATEGORIES:
|
| 379 |
+
return BiomarkerStatus.ELEVATED
|
| 380 |
+
return BiomarkerStatus.HIGH
|
| 381 |
+
|
| 382 |
+
return BiomarkerStatus.NORMAL
|
| 383 |
+
|
| 384 |
+
|
| 385 |
+
def get_category(test_name: str, gender: str = "any") -> BiomarkerCategory:
|
| 386 |
+
"""
|
| 387 |
+
Convenience: get BiomarkerCategory for a test name.
|
| 388 |
+
Used by pdf_extractor when assembling BiomarkerRaw list.
|
| 389 |
+
"""
|
| 390 |
+
ref = get_reference_range(test_name, gender)
|
| 391 |
+
cat_str = (ref or {}).get("category", "Other")
|
| 392 |
+
try:
|
| 393 |
+
return BiomarkerCategory(cat_str)
|
| 394 |
+
except ValueError:
|
| 395 |
+
return BiomarkerCategory.OTHER
|
app/core/scorer.py
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
LabCard AI — Health Scorer (Deterministic)
|
| 3 |
+
==========================================
|
| 4 |
+
ZERO LLM. 100% math.
|
| 5 |
+
|
| 6 |
+
Computes health score, health grade, preliminary biological age,
|
| 7 |
+
top priority finding, and critical alert from classified biomarkers.
|
| 8 |
+
|
| 9 |
+
Claude (Tier 4 premium) can refine the biological age with deeper reasoning,
|
| 10 |
+
but the baseline here is already scientifically grounded.
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import re
|
| 15 |
+
from dataclasses import dataclass, field
|
| 16 |
+
|
| 17 |
+
from app.models.biomarker import Biomarker, BiomarkerCategory, BiomarkerStatus
|
| 18 |
+
|
| 19 |
+
# ── Status deduction table ────────────────────────────────────────────────────
|
| 20 |
+
|
| 21 |
+
STATUS_DEDUCTIONS: dict[BiomarkerStatus, float] = {
|
| 22 |
+
BiomarkerStatus.NORMAL: 0.0,
|
| 23 |
+
BiomarkerStatus.LOW: 5.0,
|
| 24 |
+
BiomarkerStatus.HIGH: 5.0,
|
| 25 |
+
BiomarkerStatus.DEFICIENT: 10.0,
|
| 26 |
+
BiomarkerStatus.ELEVATED: 10.0,
|
| 27 |
+
BiomarkerStatus.CRITICAL: 25.0,
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
# ── Category weight multipliers ────────────────────────────────────────────────
|
| 31 |
+
# Reflects Indian disease burden: diabetes + anemia + kidney disease are primary risks
|
| 32 |
+
|
| 33 |
+
CATEGORY_WEIGHT: dict[str, float] = {
|
| 34 |
+
"Blood": 1.2, # Anemia is endemic in India
|
| 35 |
+
"Sugar": 1.3, # Diabetes epidemic
|
| 36 |
+
"Lipid": 1.1, # Rising cardiovascular risk
|
| 37 |
+
"Liver": 1.1, # NAFLD increasing
|
| 38 |
+
"Kidney": 1.2, # CKD underdiagnosed
|
| 39 |
+
"Thyroid": 1.0,
|
| 40 |
+
"Vitamin": 0.8, # Deficiencies very common — slightly lower weight
|
| 41 |
+
"Other": 0.9,
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
# ── Priority order for "top finding" selection ────────────────────────────────
|
| 45 |
+
|
| 46 |
+
_STATUS_PRIORITY: dict[BiomarkerStatus, int] = {
|
| 47 |
+
BiomarkerStatus.CRITICAL: 5,
|
| 48 |
+
BiomarkerStatus.DEFICIENT: 4,
|
| 49 |
+
BiomarkerStatus.ELEVATED: 3,
|
| 50 |
+
BiomarkerStatus.LOW: 2,
|
| 51 |
+
BiomarkerStatus.HIGH: 2,
|
| 52 |
+
BiomarkerStatus.NORMAL: 0,
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
# ── Output dataclass ──────────────────────────────────────────────────────────
|
| 57 |
+
|
| 58 |
+
@dataclass
|
| 59 |
+
class HealthMetrics:
|
| 60 |
+
health_score: int
|
| 61 |
+
health_grade: str
|
| 62 |
+
biological_age: int
|
| 63 |
+
chronological_age: int
|
| 64 |
+
bio_age_delta: int # years added/subtracted
|
| 65 |
+
bio_age_factors: list[str] = field(default_factory=list) # reasons
|
| 66 |
+
top_priority: str = ""
|
| 67 |
+
has_critical_alert: bool = False
|
| 68 |
+
critical_alert_text: str = ""
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
# ── Helper: parse age string ──────────────────────────────────────────────────
|
| 72 |
+
|
| 73 |
+
def _parse_age(age_str: str) -> int:
|
| 74 |
+
"""
|
| 75 |
+
Parse patient age from various formats:
|
| 76 |
+
"28 Years" → 28
|
| 77 |
+
"28" → 28
|
| 78 |
+
"28Y" → 28
|
| 79 |
+
"" → 0
|
| 80 |
+
"""
|
| 81 |
+
if not age_str:
|
| 82 |
+
return 0
|
| 83 |
+
m = re.search(r"(\d{1,3})", age_str)
|
| 84 |
+
if m:
|
| 85 |
+
age = int(m.group(1))
|
| 86 |
+
return age if 1 <= age <= 120 else 0
|
| 87 |
+
return 0
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
# ── Helper: fast biomarker lookup ─────────────────────────────────────────────
|
| 91 |
+
|
| 92 |
+
def _find(biomarkers: list[Biomarker], *name_fragments: str) -> Biomarker | None:
|
| 93 |
+
"""Find first biomarker whose name contains ANY of the given fragments (case-insensitive)."""
|
| 94 |
+
for bm in biomarkers:
|
| 95 |
+
name_lower = bm.name.lower()
|
| 96 |
+
if any(frag.lower() in name_lower for frag in name_fragments):
|
| 97 |
+
return bm
|
| 98 |
+
return None
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _find_all(biomarkers: list[Biomarker], *name_fragments: str) -> list[Biomarker]:
|
| 102 |
+
"""Find all biomarkers whose name contains ANY of the given fragments."""
|
| 103 |
+
results = []
|
| 104 |
+
for bm in biomarkers:
|
| 105 |
+
name_lower = bm.name.lower()
|
| 106 |
+
if any(frag.lower() in name_lower for frag in name_fragments):
|
| 107 |
+
results.append(bm)
|
| 108 |
+
return results
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
# ── Score calculation ─────────────────────────────────────────────────────────
|
| 112 |
+
|
| 113 |
+
def _compute_score(biomarkers: list[Biomarker]) -> int:
|
| 114 |
+
"""
|
| 115 |
+
Compute 0–100 health score with category weighting.
|
| 116 |
+
Each abnormal biomarker deducts points weighted by its category's importance.
|
| 117 |
+
"""
|
| 118 |
+
total_deduction = 0.0
|
| 119 |
+
|
| 120 |
+
for bm in biomarkers:
|
| 121 |
+
deduction = STATUS_DEDUCTIONS.get(bm.status, 0.0)
|
| 122 |
+
if deduction == 0.0:
|
| 123 |
+
continue
|
| 124 |
+
weight = CATEGORY_WEIGHT.get(bm.category.value, 1.0)
|
| 125 |
+
total_deduction += deduction * weight
|
| 126 |
+
|
| 127 |
+
raw = 100.0 - total_deduction
|
| 128 |
+
return max(0, min(100, round(raw)))
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def _score_to_grade(score: int) -> str:
|
| 132 |
+
"""Map numeric score → grade string."""
|
| 133 |
+
if score >= 85:
|
| 134 |
+
return "Excellent"
|
| 135 |
+
if score >= 70:
|
| 136 |
+
return "Good"
|
| 137 |
+
if score >= 55:
|
| 138 |
+
return "Fair"
|
| 139 |
+
if score >= 40:
|
| 140 |
+
return "Poor"
|
| 141 |
+
return "Critical"
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
# ── Biological age estimation ─────────────────────────────────────────────────
|
| 145 |
+
|
| 146 |
+
def _estimate_bio_age(
|
| 147 |
+
biomarkers: list[Biomarker],
|
| 148 |
+
chrono_age: int,
|
| 149 |
+
) -> tuple[int, int, list[str]]:
|
| 150 |
+
"""
|
| 151 |
+
Estimate biological age using additive delta rules.
|
| 152 |
+
Returns: (biological_age, delta, factors_list)
|
| 153 |
+
|
| 154 |
+
Claude (Tier 4) can refine this further with deeper pattern analysis.
|
| 155 |
+
This baseline is scientifically grounded in:
|
| 156 |
+
- Levine phenotypic age algorithm (simplified)
|
| 157 |
+
- ICMR population health data for India
|
| 158 |
+
"""
|
| 159 |
+
delta = 0
|
| 160 |
+
factors: list[str] = []
|
| 161 |
+
|
| 162 |
+
# ── Negative factors (add years) ─────────────────────────────────────────
|
| 163 |
+
|
| 164 |
+
# 1. Anemia combo: Hemoglobin low/deficient + Ferritin low
|
| 165 |
+
hb = _find(biomarkers, "hemoglobin", "haemoglobin")
|
| 166 |
+
ferritin = _find(biomarkers, "ferritin")
|
| 167 |
+
if hb and hb.status in (BiomarkerStatus.LOW, BiomarkerStatus.DEFICIENT, BiomarkerStatus.CRITICAL):
|
| 168 |
+
if ferritin and ferritin.status in (BiomarkerStatus.LOW, BiomarkerStatus.DEFICIENT):
|
| 169 |
+
delta += 3
|
| 170 |
+
factors.append("Iron-deficiency anemia (Hemoglobin + Ferritin low) +3 yrs")
|
| 171 |
+
else:
|
| 172 |
+
delta += 2
|
| 173 |
+
factors.append("Low Hemoglobin +2 yrs")
|
| 174 |
+
|
| 175 |
+
# 2. Vitamin D deficiency
|
| 176 |
+
vit_d = _find(biomarkers, "vitamin d", "vit d")
|
| 177 |
+
if vit_d and vit_d.value < 20:
|
| 178 |
+
delta += 2
|
| 179 |
+
factors.append("Severe Vitamin D deficiency (<20 ng/mL) +2 yrs")
|
| 180 |
+
elif vit_d and vit_d.value < 30:
|
| 181 |
+
delta += 1
|
| 182 |
+
factors.append("Vitamin D insufficiency (<30 ng/mL) +1 yr")
|
| 183 |
+
|
| 184 |
+
# 3. Vitamin B12 deficiency
|
| 185 |
+
b12 = _find(biomarkers, "b12", "vitamin b12", "cobalamin")
|
| 186 |
+
if b12 and b12.value < 200:
|
| 187 |
+
delta += 2
|
| 188 |
+
factors.append("Vitamin B12 deficiency (<200 pg/mL) +2 yrs")
|
| 189 |
+
elif b12 and b12.value < 300:
|
| 190 |
+
delta += 1
|
| 191 |
+
factors.append("Low Vitamin B12 (<300 pg/mL) +1 yr")
|
| 192 |
+
|
| 193 |
+
# 4. Glycaemic control
|
| 194 |
+
hba1c = _find(biomarkers, "hba1c", "hemoglobin a1c", "glycated", "glycosylated")
|
| 195 |
+
if hba1c:
|
| 196 |
+
if hba1c.value > 6.4:
|
| 197 |
+
delta += 6
|
| 198 |
+
factors.append(f"Diabetic HbA1c ({hba1c.value}%) +6 yrs")
|
| 199 |
+
elif hba1c.value >= 5.7:
|
| 200 |
+
delta += 3
|
| 201 |
+
factors.append(f"Pre-diabetic HbA1c ({hba1c.value}%) +3 yrs")
|
| 202 |
+
|
| 203 |
+
# 5. Lipid double hit: LDL > 130 AND TG > 150
|
| 204 |
+
ldl = _find(biomarkers, "ldl")
|
| 205 |
+
tg = _find(biomarkers, "triglyceride", "tgl", "tg")
|
| 206 |
+
if (
|
| 207 |
+
ldl and ldl.value > 130
|
| 208 |
+
and tg and tg.value > 150
|
| 209 |
+
):
|
| 210 |
+
delta += 3
|
| 211 |
+
factors.append(f"High LDL ({ldl.value}) + High Triglycerides ({tg.value}) +3 yrs")
|
| 212 |
+
elif ldl and ldl.status in (BiomarkerStatus.HIGH, BiomarkerStatus.ELEVATED):
|
| 213 |
+
delta += 1
|
| 214 |
+
factors.append(f"Elevated LDL ({ldl.value} mg/dL) +1 yr")
|
| 215 |
+
|
| 216 |
+
# 6. Critical markers — +5 per CRITICAL
|
| 217 |
+
critical_bm = [b for b in biomarkers if b.status == BiomarkerStatus.CRITICAL]
|
| 218 |
+
if critical_bm:
|
| 219 |
+
add = len(critical_bm) * 5
|
| 220 |
+
delta += add
|
| 221 |
+
names = ", ".join(b.name for b in critical_bm)
|
| 222 |
+
factors.append(f"Critical values ({names}) +{add} yrs")
|
| 223 |
+
|
| 224 |
+
# 7. Liver stress: BOTH SGPT and SGOT elevated
|
| 225 |
+
sgpt = _find(biomarkers, "sgpt", "alt")
|
| 226 |
+
sgot = _find(biomarkers, "sgot", "ast")
|
| 227 |
+
if (
|
| 228 |
+
sgpt and sgpt.status in (BiomarkerStatus.HIGH, BiomarkerStatus.ELEVATED, BiomarkerStatus.CRITICAL)
|
| 229 |
+
and sgot and sgot.status in (BiomarkerStatus.HIGH, BiomarkerStatus.ELEVATED, BiomarkerStatus.CRITICAL)
|
| 230 |
+
):
|
| 231 |
+
delta += 3
|
| 232 |
+
factors.append(f"Both liver enzymes elevated (SGPT {sgpt.value}, SGOT {sgot.value}) +3 yrs")
|
| 233 |
+
|
| 234 |
+
# 8. Kidney impairment: Creatinine elevated
|
| 235 |
+
creat = _find(biomarkers, "creatinine")
|
| 236 |
+
if creat and creat.status in (BiomarkerStatus.HIGH, BiomarkerStatus.ELEVATED, BiomarkerStatus.CRITICAL):
|
| 237 |
+
delta += 2
|
| 238 |
+
factors.append(f"Elevated Creatinine ({creat.value} mg/dL) +2 yrs")
|
| 239 |
+
|
| 240 |
+
# 9. Iron store depletion: Ferritin critically low
|
| 241 |
+
if ferritin and ferritin.value is not None and ferritin.value < 8:
|
| 242 |
+
delta += 1
|
| 243 |
+
factors.append(f"Very low Ferritin ({ferritin.value} ng/mL) +1 yr")
|
| 244 |
+
|
| 245 |
+
# ── Positive factors (subtract years) ────────────────────────────────────
|
| 246 |
+
|
| 247 |
+
# 10. All CBC markers Normal → blood health bonus
|
| 248 |
+
cbc_markers = _find_all(
|
| 249 |
+
biomarkers, "hemoglobin", "rbc", "wbc", "platelet", "mcv", "mch", "mchc", "hematocrit"
|
| 250 |
+
)
|
| 251 |
+
if cbc_markers and all(b.status == BiomarkerStatus.NORMAL for b in cbc_markers):
|
| 252 |
+
delta -= 1
|
| 253 |
+
factors.append("All CBC markers normal -1 yr")
|
| 254 |
+
|
| 255 |
+
# 11. All lipids Normal
|
| 256 |
+
lipid_markers = _find_all(biomarkers, "cholesterol", "hdl", "ldl", "triglyceride", "vldl")
|
| 257 |
+
if lipid_markers and all(b.status == BiomarkerStatus.NORMAL for b in lipid_markers):
|
| 258 |
+
delta -= 1
|
| 259 |
+
factors.append("All lipid markers normal -1 yr")
|
| 260 |
+
|
| 261 |
+
# 12. Liver enzymes both Normal
|
| 262 |
+
if (
|
| 263 |
+
sgpt and sgpt.status == BiomarkerStatus.NORMAL
|
| 264 |
+
and sgot and sgot.status == BiomarkerStatus.NORMAL
|
| 265 |
+
):
|
| 266 |
+
delta -= 1
|
| 267 |
+
factors.append("Liver enzymes both normal -1 yr")
|
| 268 |
+
|
| 269 |
+
# ── Final bio age ─────────────────────────────────────────────────────────
|
| 270 |
+
if chrono_age > 0:
|
| 271 |
+
bio_age = max(18, chrono_age + delta)
|
| 272 |
+
else:
|
| 273 |
+
# No chronological age — estimate from population baseline (35) + delta
|
| 274 |
+
bio_age = max(18, 35 + delta)
|
| 275 |
+
|
| 276 |
+
return bio_age, delta, factors
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
# ── Top priority finder ───────────────────────────────────────────────────────
|
| 280 |
+
|
| 281 |
+
def _find_top_priority(biomarkers: list[Biomarker], score: int) -> str:
|
| 282 |
+
"""
|
| 283 |
+
Single most important finding — shown prominently in the health card.
|
| 284 |
+
|
| 285 |
+
Priority:
|
| 286 |
+
1. CRITICAL markers → immediate danger
|
| 287 |
+
2. Score < 55 → worst abnormal marker
|
| 288 |
+
3. Normal score → most impactful abnormal (DEFICIENT > LOW, ELEVATED > HIGH)
|
| 289 |
+
4. All normal → affirmative message
|
| 290 |
+
"""
|
| 291 |
+
# 1. Critical
|
| 292 |
+
critical = [b for b in biomarkers if b.status == BiomarkerStatus.CRITICAL]
|
| 293 |
+
if critical:
|
| 294 |
+
bm = critical[0]
|
| 295 |
+
return (
|
| 296 |
+
f"{bm.name} is at a critical level ({bm.value_raw} {bm.unit}) "
|
| 297 |
+
"and needs immediate medical attention."
|
| 298 |
+
)
|
| 299 |
+
|
| 300 |
+
# 2 & 3. Worst abnormal by priority score
|
| 301 |
+
abnormal = [b for b in biomarkers if b.status != BiomarkerStatus.NORMAL]
|
| 302 |
+
if not abnormal:
|
| 303 |
+
return "All biomarkers are within normal range. Keep up the good work!"
|
| 304 |
+
|
| 305 |
+
# Sort by status priority (descending), then by category weight (descending)
|
| 306 |
+
def _sort_key(bm: Biomarker) -> tuple[int, float]:
|
| 307 |
+
cat_w = CATEGORY_WEIGHT.get(bm.category.value, 1.0)
|
| 308 |
+
return (_STATUS_PRIORITY.get(bm.status, 0), cat_w)
|
| 309 |
+
|
| 310 |
+
abnormal.sort(key=_sort_key, reverse=True)
|
| 311 |
+
top = abnormal[0]
|
| 312 |
+
|
| 313 |
+
direction = {
|
| 314 |
+
BiomarkerStatus.LOW: "is low",
|
| 315 |
+
BiomarkerStatus.HIGH: "is high",
|
| 316 |
+
BiomarkerStatus.DEFICIENT: "is deficient",
|
| 317 |
+
BiomarkerStatus.ELEVATED: "is elevated",
|
| 318 |
+
}.get(top.status, "is abnormal")
|
| 319 |
+
|
| 320 |
+
return (
|
| 321 |
+
f"{top.name} {direction} ({top.value_raw} {top.unit}; "
|
| 322 |
+
f"normal: {top.normal_range_text}). Consult your doctor."
|
| 323 |
+
)
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
# ── Critical alert ────────────────────────────────────────────────────────────
|
| 327 |
+
|
| 328 |
+
def _compute_critical_alert(biomarkers: list[Biomarker]) -> tuple[bool, str]:
|
| 329 |
+
"""Return (has_critical, critical_text)."""
|
| 330 |
+
critical = [b for b in biomarkers if b.status == BiomarkerStatus.CRITICAL]
|
| 331 |
+
if not critical:
|
| 332 |
+
return False, ""
|
| 333 |
+
|
| 334 |
+
names = ", ".join(
|
| 335 |
+
f"{b.name} ({b.value_raw} {b.unit})" for b in critical
|
| 336 |
+
)
|
| 337 |
+
text = (
|
| 338 |
+
f"Critical values detected: {names}. "
|
| 339 |
+
"Please seek immediate medical attention and show this report to a doctor."
|
| 340 |
+
)
|
| 341 |
+
return True, text
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
# ── Main entry point ──────────────────────────────────────────────────────────
|
| 345 |
+
|
| 346 |
+
def compute_health_metrics(
|
| 347 |
+
biomarkers: list[Biomarker],
|
| 348 |
+
patient_age_str: str = "",
|
| 349 |
+
gender: str = "unknown",
|
| 350 |
+
) -> HealthMetrics:
|
| 351 |
+
"""
|
| 352 |
+
Single function to compute all health metrics from classified biomarkers.
|
| 353 |
+
Pure — no I/O, no LLM. Fully unit-testable.
|
| 354 |
+
|
| 355 |
+
Args:
|
| 356 |
+
biomarkers: Classified Biomarker list (status already set by range_db)
|
| 357 |
+
patient_age_str: Raw age string from report e.g. "28 Years"
|
| 358 |
+
gender: "male" | "female" | "unknown"
|
| 359 |
+
|
| 360 |
+
Returns:
|
| 361 |
+
HealthMetrics dataclass with all scoring fields populated.
|
| 362 |
+
"""
|
| 363 |
+
if not biomarkers:
|
| 364 |
+
return HealthMetrics(
|
| 365 |
+
health_score=100,
|
| 366 |
+
health_grade="Excellent",
|
| 367 |
+
biological_age=0,
|
| 368 |
+
chronological_age=0,
|
| 369 |
+
bio_age_delta=0,
|
| 370 |
+
top_priority="No biomarkers found in the report.",
|
| 371 |
+
has_critical_alert=False,
|
| 372 |
+
critical_alert_text="",
|
| 373 |
+
)
|
| 374 |
+
|
| 375 |
+
# ── Score ─────────────────────────────────────────────────────────────────
|
| 376 |
+
score = _compute_score(biomarkers)
|
| 377 |
+
grade = _score_to_grade(score)
|
| 378 |
+
|
| 379 |
+
# ── Age ───────────────────────────────────────────────────────────────────
|
| 380 |
+
chrono_age = _parse_age(patient_age_str)
|
| 381 |
+
bio_age, delta, factors = _estimate_bio_age(biomarkers, chrono_age)
|
| 382 |
+
|
| 383 |
+
# ── Priority ──────────────────────────────────────────────────────────────
|
| 384 |
+
top_priority = _find_top_priority(biomarkers, score)
|
| 385 |
+
|
| 386 |
+
# ── Critical alert ────────────────────────────────────────────────────────
|
| 387 |
+
has_crit, crit_text = _compute_critical_alert(biomarkers)
|
| 388 |
+
|
| 389 |
+
return HealthMetrics(
|
| 390 |
+
health_score=score,
|
| 391 |
+
health_grade=grade,
|
| 392 |
+
biological_age=bio_age,
|
| 393 |
+
chronological_age=chrono_age,
|
| 394 |
+
bio_age_delta=delta,
|
| 395 |
+
bio_age_factors=factors,
|
| 396 |
+
top_priority=top_priority,
|
| 397 |
+
has_critical_alert=has_crit,
|
| 398 |
+
critical_alert_text=crit_text,
|
| 399 |
+
)
|
app/core/unit_validator.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
LabCard AI — Analyte unit validation
|
| 3 |
+
Ensures parsed units match expected clinical units per analyte.
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import re
|
| 8 |
+
|
| 9 |
+
# Canonical analyte → allowed units (first entry is default when unit missing)
|
| 10 |
+
KNOWN_UNITS: dict[str, list[str]] = {
|
| 11 |
+
"Sodium": ["mEq/L", "mmol/L"],
|
| 12 |
+
"Potassium": ["mEq/L", "mmol/L"],
|
| 13 |
+
"Hemoglobin": ["g/dL", "g/L"],
|
| 14 |
+
"Platelet Count": ["lakhs/cumm", "10^3/µL", "thousands/µL", "10^3/uL", "cells/cumm"],
|
| 15 |
+
"WBC Count": ["cells/cumm", "10^3/µL", "10^3/uL", "cells/cu.mm"],
|
| 16 |
+
"WBC": ["cells/cumm", "10^3/µL", "10^3/uL"],
|
| 17 |
+
"Serum Creatinine": ["mg/dL", "µmol/L", "umol/L"],
|
| 18 |
+
"Creatinine": ["mg/dL", "µmol/L", "umol/L"],
|
| 19 |
+
"Glucose": ["mg/dL", "mmol/L"],
|
| 20 |
+
"Fasting Blood Glucose": ["mg/dL", "mmol/L"],
|
| 21 |
+
"Random Blood Glucose": ["mg/dL", "mmol/L"],
|
| 22 |
+
"TSH": ["mIU/L", "µIU/mL", "uIU/mL", "mIU/mL"],
|
| 23 |
+
"Vitamin D": ["ng/mL", "nmol/L"],
|
| 24 |
+
"SGOT": ["U/L", "IU/L"],
|
| 25 |
+
"SGPT": ["U/L", "IU/L"],
|
| 26 |
+
"Total Bilirubin": ["mg/dL"],
|
| 27 |
+
"Bilirubin": ["mg/dL"],
|
| 28 |
+
"Total Cholesterol": ["mg/dL"],
|
| 29 |
+
"Cholesterol": ["mg/dL"],
|
| 30 |
+
"Triglycerides": ["mg/dL"],
|
| 31 |
+
"HDL Cholesterol": ["mg/dL"],
|
| 32 |
+
"LDL Cholesterol": ["mg/dL"],
|
| 33 |
+
"HbA1c": ["%", "percent"],
|
| 34 |
+
"Uric Acid": ["mg/dL"],
|
| 35 |
+
"BUN": ["mg/dL"],
|
| 36 |
+
"Calcium": ["mg/dL", "mmol/L"],
|
| 37 |
+
"Iron": ["µg/dL", "ug/dL"],
|
| 38 |
+
"Ferritin": ["ng/mL"],
|
| 39 |
+
"Vitamin B12": ["pg/mL"],
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
# Extra aliases → canonical KNOWN_UNITS key
|
| 43 |
+
_UNIT_ALIASES: dict[str, str] = {
|
| 44 |
+
"Haemoglobin": "Hemoglobin",
|
| 45 |
+
"S. Creatinine": "Serum Creatinine",
|
| 46 |
+
"S Creatinine": "Serum Creatinine",
|
| 47 |
+
"Blood Urea": "BUN",
|
| 48 |
+
"Blood Urea Nitrogen": "BUN",
|
| 49 |
+
"Total Cholesterol (TC)": "Total Cholesterol",
|
| 50 |
+
"FBS": "Fasting Blood Glucose",
|
| 51 |
+
"Fasting Glucose": "Fasting Blood Glucose",
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _normalize_unit(unit: str) -> str:
|
| 56 |
+
u = unit.strip()
|
| 57 |
+
u = u.replace("μ", "µ").replace("uIU", "µIU").replace("umol", "µmol")
|
| 58 |
+
u = re.sub(r"\s+", "", u)
|
| 59 |
+
return u.lower()
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _match_analyte_key(canonical_name: str) -> str | None:
|
| 63 |
+
name = canonical_name.strip()
|
| 64 |
+
if name in KNOWN_UNITS:
|
| 65 |
+
return name
|
| 66 |
+
if name in _UNIT_ALIASES:
|
| 67 |
+
return _UNIT_ALIASES[name]
|
| 68 |
+
|
| 69 |
+
name_lower = name.lower()
|
| 70 |
+
for key in KNOWN_UNITS:
|
| 71 |
+
if key.lower() == name_lower:
|
| 72 |
+
return key
|
| 73 |
+
for alias, key in _UNIT_ALIASES.items():
|
| 74 |
+
if alias.lower() == name_lower:
|
| 75 |
+
return key
|
| 76 |
+
for key in KNOWN_UNITS:
|
| 77 |
+
if key.lower() in name_lower or name_lower in key.lower():
|
| 78 |
+
return key
|
| 79 |
+
return None
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def validate_unit(canonical_name: str, unit: str) -> tuple[str, bool]:
|
| 83 |
+
"""
|
| 84 |
+
Validate/fix unit for a canonical analyte name.
|
| 85 |
+
|
| 86 |
+
Returns:
|
| 87 |
+
(unit_to_use, parsing_error)
|
| 88 |
+
- missing unit → first KNOWN_UNITS entry, no error
|
| 89 |
+
- valid unit → unchanged, no error
|
| 90 |
+
- wrong unit → empty string, parsing_error=True
|
| 91 |
+
"""
|
| 92 |
+
key = _match_analyte_key(canonical_name)
|
| 93 |
+
if not key:
|
| 94 |
+
return unit.strip(), False
|
| 95 |
+
|
| 96 |
+
allowed = KNOWN_UNITS[key]
|
| 97 |
+
raw = unit.strip()
|
| 98 |
+
|
| 99 |
+
if not raw:
|
| 100 |
+
return allowed[0], False
|
| 101 |
+
|
| 102 |
+
normalized = _normalize_unit(raw)
|
| 103 |
+
for candidate in allowed:
|
| 104 |
+
if _normalize_unit(candidate) == normalized:
|
| 105 |
+
return raw, False
|
| 106 |
+
# Partial match: g/dl vs g/dL
|
| 107 |
+
if normalized.replace("/", "") == _normalize_unit(candidate).replace("/", ""):
|
| 108 |
+
return candidate, False
|
| 109 |
+
|
| 110 |
+
return "", True
|
app/db/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# LabCard AI Backend
|
app/db/supabase_client.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
LabCard AI — Supabase Async Client
|
| 3 |
+
====================================
|
| 4 |
+
All DB calls are async and non-blocking.
|
| 5 |
+
Errors are swallowed — Supabase failure NEVER breaks an API response.
|
| 6 |
+
Backend uses the service role key (bypasses RLS for writes).
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import datetime
|
| 11 |
+
import logging
|
| 12 |
+
from typing import Any
|
| 13 |
+
|
| 14 |
+
from app.config import settings
|
| 15 |
+
from app.models.biomarker import BiomarkerStatus
|
| 16 |
+
from app.models.report import LabReport
|
| 17 |
+
|
| 18 |
+
log = logging.getLogger("labcard.supabase")
|
| 19 |
+
|
| 20 |
+
# ── Client singleton ──────────────────────────────────────────────────────────
|
| 21 |
+
# Initialized lazily on first use (avoids startup error if Supabase not configured)
|
| 22 |
+
try:
|
| 23 |
+
from supabase import AsyncClient, acreate_client as _acreate_client
|
| 24 |
+
_SUPABASE_AVAILABLE = True
|
| 25 |
+
except ImportError:
|
| 26 |
+
_SUPABASE_AVAILABLE = False
|
| 27 |
+
log.warning("supabase package not installed — DB persistence disabled")
|
| 28 |
+
|
| 29 |
+
_client: "AsyncClient | None" = None
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
async def get_client() -> "AsyncClient":
|
| 33 |
+
"""
|
| 34 |
+
Return the Supabase async client singleton.
|
| 35 |
+
Creates it on first call (lazy init for faster startup).
|
| 36 |
+
Uses service role key — bypasses RLS for backend writes.
|
| 37 |
+
"""
|
| 38 |
+
global _client
|
| 39 |
+
|
| 40 |
+
if not _SUPABASE_AVAILABLE:
|
| 41 |
+
raise RuntimeError("supabase package not installed")
|
| 42 |
+
|
| 43 |
+
if not settings.supabase_url or not settings.supabase_service_key:
|
| 44 |
+
raise RuntimeError("SUPABASE_URL or SUPABASE_SERVICE_KEY not configured")
|
| 45 |
+
|
| 46 |
+
if _client is None:
|
| 47 |
+
_client = await _acreate_client(
|
| 48 |
+
settings.supabase_url,
|
| 49 |
+
settings.supabase_service_key,
|
| 50 |
+
)
|
| 51 |
+
log.info("Supabase client initialized: %s", settings.supabase_url)
|
| 52 |
+
|
| 53 |
+
return _client
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
# ── JWT user extraction ───────────────────────────────────────────────────────
|
| 57 |
+
|
| 58 |
+
def extract_user_id_from_jwt(token: str) -> str | None:
|
| 59 |
+
"""
|
| 60 |
+
Decode Supabase JWT and extract the user UUID (sub claim).
|
| 61 |
+
Does NOT verify signature here — Supabase validates on table RLS.
|
| 62 |
+
We use it only to tag the row with user_id.
|
| 63 |
+
|
| 64 |
+
Returns None if token is invalid or missing sub claim.
|
| 65 |
+
"""
|
| 66 |
+
try:
|
| 67 |
+
import base64
|
| 68 |
+
import json
|
| 69 |
+
|
| 70 |
+
# JWT structure: header.payload.signature (base64url encoded)
|
| 71 |
+
parts = token.split(".")
|
| 72 |
+
if len(parts) != 3:
|
| 73 |
+
return None
|
| 74 |
+
|
| 75 |
+
# Pad base64url to standard base64
|
| 76 |
+
payload_b64 = parts[1]
|
| 77 |
+
pad = 4 - len(payload_b64) % 4
|
| 78 |
+
if pad != 4:
|
| 79 |
+
payload_b64 += "=" * pad
|
| 80 |
+
payload_bytes = base64.urlsafe_b64decode(payload_b64)
|
| 81 |
+
claims = json.loads(payload_bytes)
|
| 82 |
+
return str(claims.get("sub")) or None
|
| 83 |
+
|
| 84 |
+
except Exception as exc: # noqa: BLE001
|
| 85 |
+
log.debug("JWT decode failed (non-fatal): %s", exc)
|
| 86 |
+
return None
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
# ── Report persistence ────────────────────────────────────────────────────────
|
| 90 |
+
|
| 91 |
+
async def save_report(
|
| 92 |
+
token: str,
|
| 93 |
+
report: LabReport,
|
| 94 |
+
) -> str | None:
|
| 95 |
+
"""
|
| 96 |
+
Save a LabReport to the reports table.
|
| 97 |
+
Extracts user_id from JWT (anonymous if token invalid/absent).
|
| 98 |
+
|
| 99 |
+
Returns the new report UUID, or None on failure.
|
| 100 |
+
NEVER raises — errors are logged and swallowed.
|
| 101 |
+
"""
|
| 102 |
+
try:
|
| 103 |
+
user_id = extract_user_id_from_jwt(token) if token else None
|
| 104 |
+
client = await get_client()
|
| 105 |
+
|
| 106 |
+
abnormal_count = sum(
|
| 107 |
+
1 for b in report.biomarkers
|
| 108 |
+
if b.status != BiomarkerStatus.NORMAL
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
payload: dict[str, Any] = {
|
| 112 |
+
"user_id": user_id,
|
| 113 |
+
"patient_name": report.patient_name,
|
| 114 |
+
"patient_age": report.patient_age,
|
| 115 |
+
"patient_gender": report.patient_gender,
|
| 116 |
+
"lab_name": report.lab_name,
|
| 117 |
+
"report_date": report.report_date,
|
| 118 |
+
"health_score": report.health_score,
|
| 119 |
+
"health_grade": report.health_grade, # already str
|
| 120 |
+
"biological_age": report.biological_age,
|
| 121 |
+
"chronological_age": report.chronological_age,
|
| 122 |
+
"has_critical_alert": report.has_critical_alert,
|
| 123 |
+
"tier_used": report.tier_used.value,
|
| 124 |
+
"biomarker_count": len(report.biomarkers),
|
| 125 |
+
"abnormal_count": abnormal_count,
|
| 126 |
+
"processing_time_ms": report.processing_time_ms,
|
| 127 |
+
# Full JSON — enables future trend analysis without re-parsing
|
| 128 |
+
"report_json": report.model_dump(mode="json"),
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
result = await client.table("reports").insert(payload).execute()
|
| 132 |
+
|
| 133 |
+
if result.data:
|
| 134 |
+
report_id = result.data[0]["id"]
|
| 135 |
+
log.info(
|
| 136 |
+
"Report saved: id=%s user=%s score=%d",
|
| 137 |
+
report_id, user_id or "anon", report.health_score,
|
| 138 |
+
)
|
| 139 |
+
return report_id
|
| 140 |
+
|
| 141 |
+
log.warning("Supabase insert returned no data")
|
| 142 |
+
return None
|
| 143 |
+
|
| 144 |
+
except Exception as exc: # noqa: BLE001
|
| 145 |
+
log.warning("save_report failed (non-fatal): %s", exc)
|
| 146 |
+
return None
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
# ── User tier lookup ──────────────────────────────────────────────────────────
|
| 150 |
+
|
| 151 |
+
async def get_user_tier(user_id: str) -> str:
|
| 152 |
+
"""
|
| 153 |
+
Fetch user tier from user_profiles table.
|
| 154 |
+
Returns "free" on any error (fail-safe default).
|
| 155 |
+
"""
|
| 156 |
+
try:
|
| 157 |
+
client = await get_client()
|
| 158 |
+
result = (
|
| 159 |
+
await client
|
| 160 |
+
.table("user_profiles")
|
| 161 |
+
.select("tier, premium_expires_at")
|
| 162 |
+
.eq("id", user_id)
|
| 163 |
+
.single()
|
| 164 |
+
.execute()
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
if not result.data:
|
| 168 |
+
return "free"
|
| 169 |
+
|
| 170 |
+
tier = result.data.get("tier", "free")
|
| 171 |
+
expires_at = result.data.get("premium_expires_at")
|
| 172 |
+
|
| 173 |
+
# If monthly plan has expired, treat as free
|
| 174 |
+
if tier == "premium" and expires_at:
|
| 175 |
+
try:
|
| 176 |
+
expiry = datetime.datetime.fromisoformat(
|
| 177 |
+
expires_at.replace("Z", "+00:00")
|
| 178 |
+
)
|
| 179 |
+
if datetime.datetime.now(datetime.timezone.utc) > expiry:
|
| 180 |
+
log.info("Premium expired for user %s — treating as free", user_id)
|
| 181 |
+
return "free"
|
| 182 |
+
except ValueError:
|
| 183 |
+
pass # malformed date — keep tier as-is
|
| 184 |
+
|
| 185 |
+
return tier
|
| 186 |
+
|
| 187 |
+
except Exception as exc: # noqa: BLE001
|
| 188 |
+
log.warning("get_user_tier failed (non-fatal, defaulting free): %s", exc)
|
| 189 |
+
return "free"
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
# ── Premium upgrade ───────────────────────────────────────────────────────────
|
| 193 |
+
|
| 194 |
+
async def upgrade_user_to_premium(user_id: str, plan: str) -> bool:
|
| 195 |
+
"""
|
| 196 |
+
Upsert user_profiles row with premium tier.
|
| 197 |
+
Called after Razorpay payment verification succeeds.
|
| 198 |
+
|
| 199 |
+
Returns True on success, False on failure.
|
| 200 |
+
"""
|
| 201 |
+
try:
|
| 202 |
+
expires_at: str | None = None
|
| 203 |
+
if plan == "monthly":
|
| 204 |
+
expiry_dt = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=30)
|
| 205 |
+
expires_at = expiry_dt.isoformat()
|
| 206 |
+
|
| 207 |
+
client = await get_client()
|
| 208 |
+
|
| 209 |
+
await client.table("user_profiles").upsert(
|
| 210 |
+
{
|
| 211 |
+
"id": user_id,
|
| 212 |
+
"tier": "premium",
|
| 213 |
+
"premium_expires_at": expires_at,
|
| 214 |
+
"updated_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
| 215 |
+
},
|
| 216 |
+
on_conflict="id",
|
| 217 |
+
).execute()
|
| 218 |
+
|
| 219 |
+
log.info(
|
| 220 |
+
"User upgraded to premium: user=%s plan=%s expires=%s",
|
| 221 |
+
user_id, plan, expires_at or "never",
|
| 222 |
+
)
|
| 223 |
+
return True
|
| 224 |
+
|
| 225 |
+
except Exception as exc: # noqa: BLE001
|
| 226 |
+
log.error("upgrade_user_to_premium failed: %s", exc)
|
| 227 |
+
return False
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
# ── Payment record ────────────────────────────────────────────────────────────
|
| 231 |
+
|
| 232 |
+
async def record_payment(
|
| 233 |
+
user_id: str | None,
|
| 234 |
+
razorpay_order_id: str,
|
| 235 |
+
razorpay_payment_id: str,
|
| 236 |
+
razorpay_signature: str,
|
| 237 |
+
plan: str,
|
| 238 |
+
amount_paise: int,
|
| 239 |
+
) -> bool:
|
| 240 |
+
"""
|
| 241 |
+
Insert a payment record after successful Razorpay verification.
|
| 242 |
+
Idempotent — uses UNIQUE on razorpay_order_id so duplicate calls are safe.
|
| 243 |
+
"""
|
| 244 |
+
try:
|
| 245 |
+
client = await get_client()
|
| 246 |
+
now = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
| 247 |
+
|
| 248 |
+
await client.table("payments").upsert(
|
| 249 |
+
{
|
| 250 |
+
"user_id": user_id,
|
| 251 |
+
"razorpay_order_id": razorpay_order_id,
|
| 252 |
+
"razorpay_payment_id": razorpay_payment_id,
|
| 253 |
+
"razorpay_signature": razorpay_signature,
|
| 254 |
+
"plan": plan,
|
| 255 |
+
"amount_paise": amount_paise,
|
| 256 |
+
"status": "success",
|
| 257 |
+
"verified_at": now,
|
| 258 |
+
},
|
| 259 |
+
on_conflict="razorpay_order_id",
|
| 260 |
+
).execute()
|
| 261 |
+
|
| 262 |
+
log.info(
|
| 263 |
+
"Payment recorded: order=%s payment=%s plan=%s amount_paise=%d",
|
| 264 |
+
razorpay_order_id, razorpay_payment_id, plan, amount_paise,
|
| 265 |
+
)
|
| 266 |
+
return True
|
| 267 |
+
|
| 268 |
+
except Exception as exc: # noqa: BLE001
|
| 269 |
+
log.error("record_payment failed: %s", exc)
|
| 270 |
+
return False
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
# ── User report history ───────────────────────────────────────────────────────
|
| 274 |
+
|
| 275 |
+
async def get_user_reports(
|
| 276 |
+
user_id: str,
|
| 277 |
+
limit: int = 10,
|
| 278 |
+
) -> list[dict[str, Any]]:
|
| 279 |
+
"""
|
| 280 |
+
Fetch recent reports for a user (for history/trend features).
|
| 281 |
+
Returns list of lightweight summary dicts (not full report JSON).
|
| 282 |
+
Returns [] on any error.
|
| 283 |
+
"""
|
| 284 |
+
try:
|
| 285 |
+
client = await get_client()
|
| 286 |
+
result = (
|
| 287 |
+
await client
|
| 288 |
+
.table("reports")
|
| 289 |
+
.select(
|
| 290 |
+
"id, patient_name, lab_name, report_date, health_score, "
|
| 291 |
+
"health_grade, biological_age, abnormal_count, created_at"
|
| 292 |
+
)
|
| 293 |
+
.eq("user_id", user_id)
|
| 294 |
+
.order("created_at", desc=True)
|
| 295 |
+
.limit(limit)
|
| 296 |
+
.execute()
|
| 297 |
+
)
|
| 298 |
+
return result.data or []
|
| 299 |
+
|
| 300 |
+
except Exception as exc: # noqa: BLE001
|
| 301 |
+
log.warning("get_user_reports failed: %s", exc)
|
| 302 |
+
return []
|
app/main.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
LabCard AI Backend — FastAPI Entry Point
|
| 3 |
+
Deployed on Hugging Face Spaces (Docker SDK, port 7860)
|
| 4 |
+
Frontend on Vercel communicates via REST + JWT
|
| 5 |
+
"""
|
| 6 |
+
from fastapi import FastAPI
|
| 7 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 8 |
+
|
| 9 |
+
from app.config import settings
|
| 10 |
+
from app.api import analyze, chat, health
|
| 11 |
+
|
| 12 |
+
app = FastAPI(
|
| 13 |
+
title="LabCard AI Backend",
|
| 14 |
+
description=(
|
| 15 |
+
"Lab report parsing + AI analysis API for Indian patients. "
|
| 16 |
+
"Tier 1-2: deterministic (zero LLM). "
|
| 17 |
+
"Tier 3: Gemini/Groq free. "
|
| 18 |
+
"Tier 4: Claude premium."
|
| 19 |
+
),
|
| 20 |
+
version="1.0.0",
|
| 21 |
+
docs_url="/docs",
|
| 22 |
+
redoc_url="/redoc",
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
# CORS — allow Vercel frontend only in production
|
| 26 |
+
app.add_middleware(
|
| 27 |
+
CORSMiddleware,
|
| 28 |
+
allow_origins=settings.origins_list,
|
| 29 |
+
allow_credentials=True,
|
| 30 |
+
allow_methods=["GET", "POST", "OPTIONS"],
|
| 31 |
+
allow_headers=["*"],
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
# Routers
|
| 35 |
+
app.include_router(health.router, prefix="/api", tags=["health"])
|
| 36 |
+
app.include_router(analyze.router, prefix="/api", tags=["analyze"])
|
| 37 |
+
app.include_router(chat.router, prefix="/api", tags=["chat"])
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@app.get("/")
|
| 41 |
+
async def root() -> dict[str, str]:
|
| 42 |
+
return {
|
| 43 |
+
"service": "LabCard AI Backend",
|
| 44 |
+
"status": "running",
|
| 45 |
+
"version": "1.0.0",
|
| 46 |
+
"docs": "/docs",
|
| 47 |
+
}
|
app/models/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# LabCard AI Backend
|
app/models/biomarker.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
LabCard AI — Biomarker Pydantic v2 Models
|
| 3 |
+
All deterministic fields — status assigned by Python logic, never LLM.
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
from pydantic import BaseModel, Field, model_validator
|
| 7 |
+
from enum import Enum
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class BiomarkerStatus(str, Enum):
|
| 11 |
+
NORMAL = "Normal"
|
| 12 |
+
LOW = "Low"
|
| 13 |
+
HIGH = "High"
|
| 14 |
+
DEFICIENT = "Deficient"
|
| 15 |
+
ELEVATED = "Elevated"
|
| 16 |
+
CRITICAL = "Critical"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class BiomarkerCategory(str, Enum):
|
| 20 |
+
BLOOD = "Blood"
|
| 21 |
+
THYROID = "Thyroid"
|
| 22 |
+
VITAMIN = "Vitamin"
|
| 23 |
+
LIVER = "Liver"
|
| 24 |
+
KIDNEY = "Kidney"
|
| 25 |
+
SUGAR = "Sugar"
|
| 26 |
+
LIPID = "Lipid"
|
| 27 |
+
OTHER = "Other"
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class Biomarker(BaseModel):
|
| 31 |
+
"""
|
| 32 |
+
Fully deterministic biomarker result.
|
| 33 |
+
status is assigned by Python range comparison — never by LLM.
|
| 34 |
+
explanation / advice / indian_foods are filled in Tier 3/4 AI step.
|
| 35 |
+
"""
|
| 36 |
+
# Core parsed fields
|
| 37 |
+
name: str
|
| 38 |
+
value: float
|
| 39 |
+
value_raw: str # original string from report e.g. "10.2"
|
| 40 |
+
unit: str = ""
|
| 41 |
+
|
| 42 |
+
# Reference range — lab printed (primary) or ICMR fallback
|
| 43 |
+
normal_range_low: float | None = None
|
| 44 |
+
normal_range_high: float | None = None
|
| 45 |
+
normal_range_text: str = "" # e.g. "13.0 - 17.0" or "> 40"
|
| 46 |
+
|
| 47 |
+
# Classification (deterministic Python logic)
|
| 48 |
+
status: BiomarkerStatus = BiomarkerStatus.NORMAL
|
| 49 |
+
category: BiomarkerCategory = BiomarkerCategory.OTHER
|
| 50 |
+
|
| 51 |
+
# Source of reference range
|
| 52 |
+
source: str = "lab" # "lab" | "icmr" | "aiims" | "who"
|
| 53 |
+
|
| 54 |
+
# AI-enriched fields (Tier 3 — Groq/Gemini, empty until AI step runs)
|
| 55 |
+
explanation: str = ""
|
| 56 |
+
explanation_hindi: str = ""
|
| 57 |
+
advice: str = ""
|
| 58 |
+
indian_foods: list[str] = Field(default_factory=list)
|
| 59 |
+
|
| 60 |
+
# Set when unit validation fails — wrong unit stripped, never shown to user
|
| 61 |
+
parsing_error: bool = False
|
| 62 |
+
|
| 63 |
+
@model_validator(mode="after")
|
| 64 |
+
def validate_range_consistency(self) -> "Biomarker":
|
| 65 |
+
"""If both low and high are set, low must be <= high."""
|
| 66 |
+
if (
|
| 67 |
+
self.normal_range_low is not None
|
| 68 |
+
and self.normal_range_high is not None
|
| 69 |
+
and self.normal_range_low > self.normal_range_high
|
| 70 |
+
):
|
| 71 |
+
# Swap silently — some labs print high first
|
| 72 |
+
self.normal_range_low, self.normal_range_high = (
|
| 73 |
+
self.normal_range_high,
|
| 74 |
+
self.normal_range_low,
|
| 75 |
+
)
|
| 76 |
+
return self
|
| 77 |
+
|
| 78 |
+
@property
|
| 79 |
+
def color(self) -> str:
|
| 80 |
+
"""Frontend card color mapping."""
|
| 81 |
+
if self.status == BiomarkerStatus.NORMAL:
|
| 82 |
+
return "green"
|
| 83 |
+
if self.status in (BiomarkerStatus.LOW, BiomarkerStatus.HIGH):
|
| 84 |
+
return "yellow"
|
| 85 |
+
return "red" # Deficient | Elevated | Critical
|
| 86 |
+
|
| 87 |
+
@property
|
| 88 |
+
def is_abnormal(self) -> bool:
|
| 89 |
+
return self.status != BiomarkerStatus.NORMAL
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
class BiomarkerRaw(BaseModel):
|
| 93 |
+
"""
|
| 94 |
+
Intermediate model produced by the regex parser.
|
| 95 |
+
Status is NOT assigned yet — assigned in scorer.py after range lookup.
|
| 96 |
+
Kept separate to make the pipeline stages explicit and testable.
|
| 97 |
+
"""
|
| 98 |
+
name: str
|
| 99 |
+
value_raw: str # raw string before float conversion
|
| 100 |
+
unit: str = ""
|
| 101 |
+
range_raw: str = "" # extracted range string from report
|
| 102 |
+
line_text: str = "" # full original line (for debugging)
|
| 103 |
+
page_num: int = 0 # PDF page number (for debugging)
|
app/models/report.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
LabCard AI — Report + API Request/Response Pydantic v2 Models
|
| 3 |
+
"""
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
from pydantic import BaseModel, Field, model_validator
|
| 6 |
+
from enum import Enum
|
| 7 |
+
from app.models.biomarker import Biomarker
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class HealthGrade(str, Enum):
|
| 11 |
+
EXCELLENT = "Excellent"
|
| 12 |
+
GOOD = "Good"
|
| 13 |
+
FAIR = "Fair"
|
| 14 |
+
POOR = "Poor"
|
| 15 |
+
CRITICAL = "Critical"
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class UserTier(str, Enum):
|
| 19 |
+
FREE = "free"
|
| 20 |
+
PREMIUM = "premium"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
# ── Request models ─────────────────────────────────────────────────────────────
|
| 24 |
+
|
| 25 |
+
class AnalyzeRequest(BaseModel):
|
| 26 |
+
"""For direct text submission (PDF handled as multipart upload in route)."""
|
| 27 |
+
text: str = Field(..., min_length=50, max_length=50_000)
|
| 28 |
+
tier: UserTier = UserTier.FREE
|
| 29 |
+
lang: str = "en"
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class ChatMessage(BaseModel):
|
| 33 |
+
role: str # "user" | "assistant"
|
| 34 |
+
content: str = Field(..., min_length=1)
|
| 35 |
+
|
| 36 |
+
@model_validator(mode="after")
|
| 37 |
+
def validate_role(self) -> "ChatMessage":
|
| 38 |
+
if self.role not in ("user", "assistant"):
|
| 39 |
+
raise ValueError("role must be 'user' or 'assistant'")
|
| 40 |
+
return self
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class ChatRequest(BaseModel):
|
| 44 |
+
messages: list[ChatMessage] = Field(..., min_length=1, max_length=20)
|
| 45 |
+
report_data: str = Field(..., description="JSON-stringified LabReport")
|
| 46 |
+
lang: str = "en"
|
| 47 |
+
tier: UserTier = UserTier.FREE
|
| 48 |
+
|
| 49 |
+
@model_validator(mode="after")
|
| 50 |
+
def trim_history(self) -> "ChatRequest":
|
| 51 |
+
"""Keep only last 10 messages to control token usage."""
|
| 52 |
+
if len(self.messages) > 10:
|
| 53 |
+
self.messages = self.messages[-10:]
|
| 54 |
+
return self
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
# ── Core report model ──────────────────────────────────────────────────────────
|
| 58 |
+
|
| 59 |
+
class LabReport(BaseModel):
|
| 60 |
+
"""
|
| 61 |
+
Complete analysis output.
|
| 62 |
+
|
| 63 |
+
Tier 2 fills: patient info, biomarkers, health_score, health_grade,
|
| 64 |
+
has_critical_alert, critical_alert_text
|
| 65 |
+
Tier 3 fills: summary, summary_hindi, doctor_note, top_priority,
|
| 66 |
+
biomarker.explanation, biomarker.advice, biomarker.indian_foods
|
| 67 |
+
Tier 4 fills: biological_age, chronological_age, bio_age_insight,
|
| 68 |
+
bio_age_protocol (premium only)
|
| 69 |
+
"""
|
| 70 |
+
|
| 71 |
+
# ── Patient info (Tier 2 — deterministic extraction) ─────────────────────
|
| 72 |
+
patient_name: str = "Unknown"
|
| 73 |
+
patient_age: str = ""
|
| 74 |
+
patient_gender: str = "Unknown"
|
| 75 |
+
report_date: str = ""
|
| 76 |
+
lab_name: str = ""
|
| 77 |
+
|
| 78 |
+
# ── Health scoring (Tier 2 — deterministic, never LLM) ───────────────────
|
| 79 |
+
health_score: int = Field(default=100, ge=0, le=100)
|
| 80 |
+
health_grade: HealthGrade = HealthGrade.GOOD
|
| 81 |
+
|
| 82 |
+
# ── Biological age (Tier 4 only — Claude premium) ────────────────────────
|
| 83 |
+
biological_age: int | None = None
|
| 84 |
+
chronological_age: int | None = None
|
| 85 |
+
bio_age_insight: str = ""
|
| 86 |
+
bio_age_protocol: list[str] = Field(default_factory=list)
|
| 87 |
+
|
| 88 |
+
# ── Summaries (Tier 3 — Groq/Gemini) ─────────────────────────────────────
|
| 89 |
+
summary: str = ""
|
| 90 |
+
summary_hindi: str = ""
|
| 91 |
+
doctor_note: str = ""
|
| 92 |
+
top_priority: str = ""
|
| 93 |
+
|
| 94 |
+
# ── Critical alert (Tier 2 — deterministic, never LLM) ───────────────────
|
| 95 |
+
has_critical_alert: bool = False
|
| 96 |
+
critical_alert_text: str = ""
|
| 97 |
+
|
| 98 |
+
# ── Biomarkers (Tier 2 core + Tier 3 explanations) ───────────────────────
|
| 99 |
+
biomarkers: list[Biomarker] = Field(default_factory=list)
|
| 100 |
+
|
| 101 |
+
# ── Metadata ─────────────────────────────────────────────────────────────
|
| 102 |
+
tier_used: UserTier = UserTier.FREE
|
| 103 |
+
processing_time_ms: int | None = None
|
| 104 |
+
ai_provider_used: str = "" # "groq" | "gemini" | "claude" | "none"
|
| 105 |
+
|
| 106 |
+
@model_validator(mode="after")
|
| 107 |
+
def set_grade_from_score(self) -> "LabReport":
|
| 108 |
+
"""Auto-derive grade from score for consistency."""
|
| 109 |
+
s = self.health_score
|
| 110 |
+
if s >= 85:
|
| 111 |
+
self.health_grade = HealthGrade.EXCELLENT
|
| 112 |
+
elif s >= 70:
|
| 113 |
+
self.health_grade = HealthGrade.GOOD
|
| 114 |
+
elif s >= 50:
|
| 115 |
+
self.health_grade = HealthGrade.FAIR
|
| 116 |
+
elif s >= 30:
|
| 117 |
+
self.health_grade = HealthGrade.POOR
|
| 118 |
+
else:
|
| 119 |
+
self.health_grade = HealthGrade.CRITICAL
|
| 120 |
+
return self
|
| 121 |
+
|
| 122 |
+
@model_validator(mode="after")
|
| 123 |
+
def auto_critical_alert(self) -> "LabReport":
|
| 124 |
+
"""
|
| 125 |
+
Auto-set has_critical_alert if any biomarker is Critical.
|
| 126 |
+
scorer.py also sets this explicitly, but this is a safety net.
|
| 127 |
+
"""
|
| 128 |
+
from app.models.biomarker import BiomarkerStatus
|
| 129 |
+
critical_bm = [b for b in self.biomarkers if b.status == BiomarkerStatus.CRITICAL]
|
| 130 |
+
if critical_bm and not self.has_critical_alert:
|
| 131 |
+
self.has_critical_alert = True
|
| 132 |
+
names = ", ".join(b.name for b in critical_bm[:3])
|
| 133 |
+
self.critical_alert_text = (
|
| 134 |
+
f"Critical values detected: {names}. "
|
| 135 |
+
"Please consult a doctor immediately."
|
| 136 |
+
)
|
| 137 |
+
return self
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
# ── Response models ────────────────────────────────────────────────────────────
|
| 141 |
+
|
| 142 |
+
class AnalyzeResponse(LabReport):
|
| 143 |
+
"""
|
| 144 |
+
Identical to LabReport — kept as separate class so the API route
|
| 145 |
+
can add response_model=AnalyzeResponse for OpenAPI docs.
|
| 146 |
+
"""
|
| 147 |
+
pass
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
class ChatResponse(BaseModel):
|
| 151 |
+
reply: str
|
| 152 |
+
lang: str
|
data/icmr_ranges.json
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"Hemoglobin": {
|
| 3 |
+
"male": { "low": 13.0, "high": 17.0, "unit": "g/dL", "category": "Blood" },
|
| 4 |
+
"female": { "low": 12.0, "high": 15.5, "unit": "g/dL", "category": "Blood" }
|
| 5 |
+
},
|
| 6 |
+
"RBC Count": {
|
| 7 |
+
"male": { "low": 4.5, "high": 5.5, "unit": "million/uL", "category": "Blood" },
|
| 8 |
+
"female": { "low": 4.0, "high": 5.0, "unit": "million/uL", "category": "Blood" }
|
| 9 |
+
},
|
| 10 |
+
"WBC Count": {
|
| 11 |
+
"any": { "low": 4000, "high": 11000, "unit": "cells/uL", "category": "Blood" }
|
| 12 |
+
},
|
| 13 |
+
"Platelet Count": {
|
| 14 |
+
"any": { "low": 150000, "high": 400000, "unit": "/uL", "category": "Blood" }
|
| 15 |
+
},
|
| 16 |
+
"MCV": {
|
| 17 |
+
"any": { "low": 80, "high": 100, "unit": "fL", "category": "Blood" }
|
| 18 |
+
},
|
| 19 |
+
"MCH": {
|
| 20 |
+
"any": { "low": 27, "high": 32, "unit": "pg", "category": "Blood" }
|
| 21 |
+
},
|
| 22 |
+
"MCHC": {
|
| 23 |
+
"any": { "low": 31.5, "high": 34.5, "unit": "g/dL", "category": "Blood" }
|
| 24 |
+
},
|
| 25 |
+
"Hematocrit": {
|
| 26 |
+
"male": { "low": 40, "high": 50, "unit": "%", "category": "Blood" },
|
| 27 |
+
"female": { "low": 36, "high": 46, "unit": "%", "category": "Blood" }
|
| 28 |
+
},
|
| 29 |
+
"Neutrophils": {
|
| 30 |
+
"any": { "low": 40, "high": 75, "unit": "%", "category": "Blood" }
|
| 31 |
+
},
|
| 32 |
+
"Lymphocytes": {
|
| 33 |
+
"any": { "low": 20, "high": 45, "unit": "%", "category": "Blood" }
|
| 34 |
+
},
|
| 35 |
+
"Eosinophils": {
|
| 36 |
+
"any": { "low": 1, "high": 6, "unit": "%", "category": "Blood" }
|
| 37 |
+
},
|
| 38 |
+
"Monocytes": {
|
| 39 |
+
"any": { "low": 2, "high": 10, "unit": "%", "category": "Blood" }
|
| 40 |
+
},
|
| 41 |
+
"Basophils": {
|
| 42 |
+
"any": { "low": 0, "high": 1, "unit": "%", "category": "Blood" }
|
| 43 |
+
},
|
| 44 |
+
"Serum Iron": {
|
| 45 |
+
"any": { "low": 60, "high": 170, "unit": "ug/dL", "category": "Blood" }
|
| 46 |
+
},
|
| 47 |
+
"TIBC": {
|
| 48 |
+
"any": { "low": 250, "high": 370, "unit": "ug/dL", "category": "Blood" }
|
| 49 |
+
},
|
| 50 |
+
"Serum Ferritin": {
|
| 51 |
+
"male": { "low": 12, "high": 300, "unit": "ng/mL", "category": "Blood" },
|
| 52 |
+
"female": { "low": 12, "high": 150, "unit": "ng/mL", "category": "Blood" }
|
| 53 |
+
},
|
| 54 |
+
"Transferrin Saturation": {
|
| 55 |
+
"any": { "low": 20, "high": 50, "unit": "%", "category": "Blood" }
|
| 56 |
+
},
|
| 57 |
+
"TSH": {
|
| 58 |
+
"any": { "low": 0.4, "high": 4.0, "unit": "uIU/mL", "category": "Thyroid" }
|
| 59 |
+
},
|
| 60 |
+
"T3": {
|
| 61 |
+
"any": { "low": 60, "high": 200, "unit": "ng/dL", "category": "Thyroid" }
|
| 62 |
+
},
|
| 63 |
+
"T4": {
|
| 64 |
+
"any": { "low": 4.5, "high": 12.5, "unit": "ug/dL", "category": "Thyroid" }
|
| 65 |
+
},
|
| 66 |
+
"Free T3": {
|
| 67 |
+
"any": { "low": 2.3, "high": 4.2, "unit": "pg/mL", "category": "Thyroid" }
|
| 68 |
+
},
|
| 69 |
+
"Free T4": {
|
| 70 |
+
"any": { "low": 0.89, "high": 1.76, "unit": "ng/dL", "category": "Thyroid" }
|
| 71 |
+
},
|
| 72 |
+
"Vitamin D": {
|
| 73 |
+
"any": {
|
| 74 |
+
"low": 30, "high": 100, "unit": "ng/mL", "category": "Vitamin",
|
| 75 |
+
"deficient_threshold": 20,
|
| 76 |
+
"insufficient_threshold": 30
|
| 77 |
+
}
|
| 78 |
+
},
|
| 79 |
+
"Vitamin B12": {
|
| 80 |
+
"any": {
|
| 81 |
+
"low": 200, "high": 900, "unit": "pg/mL", "category": "Vitamin",
|
| 82 |
+
"deficient_threshold": 140
|
| 83 |
+
}
|
| 84 |
+
},
|
| 85 |
+
"Folic Acid": {
|
| 86 |
+
"any": { "low": 3.0, "high": 17.0, "unit": "ng/mL", "category": "Vitamin" }
|
| 87 |
+
},
|
| 88 |
+
"Vitamin C": {
|
| 89 |
+
"any": { "low": 0.6, "high": 2.0, "unit": "mg/dL", "category": "Vitamin" }
|
| 90 |
+
},
|
| 91 |
+
"Fasting Blood Glucose": {
|
| 92 |
+
"any": {
|
| 93 |
+
"low": 70, "high": 100, "unit": "mg/dL", "category": "Sugar",
|
| 94 |
+
"elevated_threshold": 126
|
| 95 |
+
}
|
| 96 |
+
},
|
| 97 |
+
"HbA1c": {
|
| 98 |
+
"any": {
|
| 99 |
+
"low": 4.0, "high": 5.6, "unit": "%", "category": "Sugar",
|
| 100 |
+
"prediabetes_threshold": 6.4,
|
| 101 |
+
"diabetes_threshold": 6.5
|
| 102 |
+
}
|
| 103 |
+
},
|
| 104 |
+
"Post Prandial Glucose": {
|
| 105 |
+
"any": { "low": 70, "high": 140, "unit": "mg/dL", "category": "Sugar" }
|
| 106 |
+
},
|
| 107 |
+
"Random Blood Glucose": {
|
| 108 |
+
"any": { "low": 70, "high": 140, "unit": "mg/dL", "category": "Sugar" }
|
| 109 |
+
},
|
| 110 |
+
"SGPT": {
|
| 111 |
+
"male": { "low": 0, "high": 40, "unit": "U/L", "category": "Liver" },
|
| 112 |
+
"female": { "low": 0, "high": 35, "unit": "U/L", "category": "Liver" }
|
| 113 |
+
},
|
| 114 |
+
"SGOT": {
|
| 115 |
+
"any": { "low": 0, "high": 40, "unit": "U/L", "category": "Liver" }
|
| 116 |
+
},
|
| 117 |
+
"Alkaline Phosphatase": {
|
| 118 |
+
"any": { "low": 44, "high": 147, "unit": "U/L", "category": "Liver" }
|
| 119 |
+
},
|
| 120 |
+
"Total Bilirubin": {
|
| 121 |
+
"any": { "low": 0.2, "high": 1.2, "unit": "mg/dL", "category": "Liver" }
|
| 122 |
+
},
|
| 123 |
+
"Direct Bilirubin": {
|
| 124 |
+
"any": { "low": 0.0, "high": 0.3, "unit": "mg/dL", "category": "Liver" }
|
| 125 |
+
},
|
| 126 |
+
"Indirect Bilirubin": {
|
| 127 |
+
"any": { "low": 0.1, "high": 0.8, "unit": "mg/dL", "category": "Liver" }
|
| 128 |
+
},
|
| 129 |
+
"Albumin": {
|
| 130 |
+
"any": { "low": 3.5, "high": 5.0, "unit": "g/dL", "category": "Liver" }
|
| 131 |
+
},
|
| 132 |
+
"Total Protein": {
|
| 133 |
+
"any": { "low": 6.0, "high": 8.3, "unit": "g/dL", "category": "Liver" }
|
| 134 |
+
},
|
| 135 |
+
"GGT": {
|
| 136 |
+
"male": { "low": 0, "high": 55, "unit": "U/L", "category": "Liver" },
|
| 137 |
+
"female": { "low": 0, "high": 38, "unit": "U/L", "category": "Liver" }
|
| 138 |
+
},
|
| 139 |
+
"Serum Creatinine": {
|
| 140 |
+
"male": { "low": 0.7, "high": 1.3, "unit": "mg/dL", "category": "Kidney" },
|
| 141 |
+
"female": { "low": 0.5, "high": 1.1, "unit": "mg/dL", "category": "Kidney" }
|
| 142 |
+
},
|
| 143 |
+
"BUN": {
|
| 144 |
+
"any": { "low": 7, "high": 20, "unit": "mg/dL", "category": "Kidney" }
|
| 145 |
+
},
|
| 146 |
+
"Uric Acid": {
|
| 147 |
+
"male": { "low": 3.5, "high": 7.2, "unit": "mg/dL", "category": "Kidney" },
|
| 148 |
+
"female": { "low": 2.5, "high": 6.0, "unit": "mg/dL", "category": "Kidney" }
|
| 149 |
+
},
|
| 150 |
+
"eGFR": {
|
| 151 |
+
"any": { "low": 60, "high": 999, "unit": "mL/min/1.73m2", "category": "Kidney" }
|
| 152 |
+
},
|
| 153 |
+
"Total Cholesterol": {
|
| 154 |
+
"any": { "low": 0, "high": 200, "unit": "mg/dL", "category": "Lipid" }
|
| 155 |
+
},
|
| 156 |
+
"HDL Cholesterol": {
|
| 157 |
+
"male": { "low": 40, "high": 999, "unit": "mg/dL", "category": "Lipid" },
|
| 158 |
+
"female": { "low": 50, "high": 999, "unit": "mg/dL", "category": "Lipid" }
|
| 159 |
+
},
|
| 160 |
+
"LDL Cholesterol": {
|
| 161 |
+
"any": { "low": 0, "high": 100, "unit": "mg/dL", "category": "Lipid" }
|
| 162 |
+
},
|
| 163 |
+
"Triglycerides": {
|
| 164 |
+
"any": { "low": 0, "high": 150, "unit": "mg/dL", "category": "Lipid" }
|
| 165 |
+
},
|
| 166 |
+
"VLDL": {
|
| 167 |
+
"any": { "low": 0, "high": 30, "unit": "mg/dL", "category": "Lipid" }
|
| 168 |
+
},
|
| 169 |
+
"Non-HDL Cholesterol": {
|
| 170 |
+
"any": { "low": 0, "high": 130, "unit": "mg/dL", "category": "Lipid" }
|
| 171 |
+
},
|
| 172 |
+
"CRP": {
|
| 173 |
+
"any": { "low": 0, "high": 5.0, "unit": "mg/L", "category": "Other" }
|
| 174 |
+
},
|
| 175 |
+
"hs-CRP": {
|
| 176 |
+
"any": { "low": 0, "high": 3.0, "unit": "mg/L", "category": "Other" }
|
| 177 |
+
},
|
| 178 |
+
"ESR": {
|
| 179 |
+
"male": { "low": 0, "high": 15, "unit": "mm/hr", "category": "Other" },
|
| 180 |
+
"female": { "low": 0, "high": 20, "unit": "mm/hr", "category": "Other" }
|
| 181 |
+
},
|
| 182 |
+
"Calcium": {
|
| 183 |
+
"any": { "low": 8.5, "high": 10.5, "unit": "mg/dL", "category": "Other" }
|
| 184 |
+
},
|
| 185 |
+
"Phosphorus": {
|
| 186 |
+
"any": { "low": 2.5, "high": 4.5, "unit": "mg/dL", "category": "Other" }
|
| 187 |
+
},
|
| 188 |
+
"Sodium": {
|
| 189 |
+
"any": { "low": 136, "high": 145, "unit": "mEq/L", "category": "Other" }
|
| 190 |
+
},
|
| 191 |
+
"Potassium": {
|
| 192 |
+
"any": { "low": 3.5, "high": 5.0, "unit": "mEq/L", "category": "Other" }
|
| 193 |
+
},
|
| 194 |
+
"Chloride": {
|
| 195 |
+
"any": { "low": 98, "high": 107, "unit": "mEq/L", "category": "Other" }
|
| 196 |
+
},
|
| 197 |
+
"Magnesium": {
|
| 198 |
+
"any": { "low": 1.7, "high": 2.2, "unit": "mg/dL", "category": "Other" }
|
| 199 |
+
},
|
| 200 |
+
"Zinc": {
|
| 201 |
+
"any": { "low": 70, "high": 120, "unit": "ug/dL", "category": "Other" }
|
| 202 |
+
},
|
| 203 |
+
"Iron Binding Capacity": {
|
| 204 |
+
"any": { "low": 250, "high": 370, "unit": "ug/dL", "category": "Blood" }
|
| 205 |
+
},
|
| 206 |
+
"Testosterone": {
|
| 207 |
+
"male": { "low": 300, "high": 1000, "unit": "ng/dL", "category": "Other" },
|
| 208 |
+
"female": { "low": 15, "high": 70, "unit": "ng/dL", "category": "Other" }
|
| 209 |
+
},
|
| 210 |
+
"Prolactin": {
|
| 211 |
+
"male": { "low": 2, "high": 18, "unit": "ng/mL", "category": "Other" },
|
| 212 |
+
"female": { "low": 2, "high": 29, "unit": "ng/mL", "category": "Other" }
|
| 213 |
+
}
|
| 214 |
+
}
|
requirements.txt
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# LabCard AI Backend — Python 3.11
|
| 2 |
+
# Pinned versions tested on HF Spaces Docker SDK
|
| 3 |
+
|
| 4 |
+
# Core API
|
| 5 |
+
fastapi==0.111.0
|
| 6 |
+
uvicorn[standard]==0.30.1
|
| 7 |
+
python-multipart==0.0.9
|
| 8 |
+
|
| 9 |
+
# Validation
|
| 10 |
+
pydantic==2.7.4
|
| 11 |
+
pydantic-settings==2.3.4
|
| 12 |
+
|
| 13 |
+
# PDF Parsing — dual engine
|
| 14 |
+
pdfplumber==0.11.0
|
| 15 |
+
PyMuPDF==1.24.5
|
| 16 |
+
pillow==10.3.0
|
| 17 |
+
|
| 18 |
+
# AI Providers
|
| 19 |
+
anthropic==0.30.0
|
| 20 |
+
groq==0.9.0
|
| 21 |
+
# Gemini 2.5 Flash → called via httpx REST (no SDK needed, lighter)
|
| 22 |
+
|
| 23 |
+
# HTTP client
|
| 24 |
+
httpx==0.27.0
|
| 25 |
+
|
| 26 |
+
# Database
|
| 27 |
+
supabase==2.5.1
|
| 28 |
+
|
| 29 |
+
# Cache
|
| 30 |
+
upstash-redis==1.1.0
|
| 31 |
+
|
| 32 |
+
# Auth
|
| 33 |
+
python-jose[cryptography]==3.3.0
|
| 34 |
+
|
| 35 |
+
# Testing
|
| 36 |
+
pytest==8.2.2
|
| 37 |
+
pytest-asyncio==0.23.7
|
supabase_schema.sql
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- ─────────────────────────────────────────────────────────────────────────────
|
| 2 |
+
-- LabCard AI — Supabase Schema
|
| 3 |
+
-- Run this in Supabase SQL Editor: https://supabase.com/dashboard/project/_/sql
|
| 4 |
+
-- ─────────────────────────────────────────────────────────────────────────────
|
| 5 |
+
|
| 6 |
+
-- ── User Profiles ─────────────────────────────────────────────────────────────
|
| 7 |
+
-- Extends auth.users — created automatically on first report / payment
|
| 8 |
+
|
| 9 |
+
CREATE TABLE IF NOT EXISTS public.user_profiles (
|
| 10 |
+
id UUID REFERENCES auth.users(id) ON DELETE CASCADE PRIMARY KEY,
|
| 11 |
+
tier TEXT NOT NULL DEFAULT 'free'
|
| 12 |
+
CHECK (tier IN ('free', 'premium')),
|
| 13 |
+
reports_analyzed INTEGER NOT NULL DEFAULT 0,
|
| 14 |
+
premium_expires_at TIMESTAMPTZ,
|
| 15 |
+
created_at TIMESTAMPTZ DEFAULT NOW(),
|
| 16 |
+
updated_at TIMESTAMPTZ DEFAULT NOW()
|
| 17 |
+
);
|
| 18 |
+
|
| 19 |
+
-- Auto-update updated_at on any change
|
| 20 |
+
CREATE OR REPLACE FUNCTION update_updated_at()
|
| 21 |
+
RETURNS TRIGGER AS $$
|
| 22 |
+
BEGIN
|
| 23 |
+
NEW.updated_at = NOW();
|
| 24 |
+
RETURN NEW;
|
| 25 |
+
END;
|
| 26 |
+
$$ LANGUAGE plpgsql;
|
| 27 |
+
|
| 28 |
+
CREATE TRIGGER user_profiles_updated_at
|
| 29 |
+
BEFORE UPDATE ON public.user_profiles
|
| 30 |
+
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
| 31 |
+
|
| 32 |
+
-- ── Reports ───────────────────────────────────────────────────────────────────
|
| 33 |
+
-- Full LabReport JSON stored in report_json JSONB for future trend analysis.
|
| 34 |
+
-- Summary columns (score, grade, etc.) allow fast SQL aggregates without
|
| 35 |
+
-- parsing the full JSON every time.
|
| 36 |
+
|
| 37 |
+
CREATE TABLE IF NOT EXISTS public.reports (
|
| 38 |
+
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
| 39 |
+
user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
| 40 |
+
-- Extracted patient fields (denormalized for fast queries)
|
| 41 |
+
patient_name TEXT,
|
| 42 |
+
patient_age TEXT,
|
| 43 |
+
patient_gender TEXT,
|
| 44 |
+
lab_name TEXT,
|
| 45 |
+
report_date TEXT,
|
| 46 |
+
-- Health metrics (all deterministic — not from AI)
|
| 47 |
+
health_score INTEGER CHECK (health_score >= 0 AND health_score <= 100),
|
| 48 |
+
health_grade TEXT,
|
| 49 |
+
biological_age INTEGER,
|
| 50 |
+
chronological_age INTEGER,
|
| 51 |
+
has_critical_alert BOOLEAN DEFAULT FALSE,
|
| 52 |
+
-- Processing metadata
|
| 53 |
+
tier_used TEXT DEFAULT 'free',
|
| 54 |
+
biomarker_count INTEGER,
|
| 55 |
+
abnormal_count INTEGER,
|
| 56 |
+
processing_time_ms INTEGER,
|
| 57 |
+
-- Full report blob (enables trend queries + future re-analysis)
|
| 58 |
+
report_json JSONB NOT NULL,
|
| 59 |
+
created_at TIMESTAMPTZ DEFAULT NOW()
|
| 60 |
+
);
|
| 61 |
+
|
| 62 |
+
-- Indexes for common access patterns
|
| 63 |
+
CREATE INDEX IF NOT EXISTS idx_reports_user_id
|
| 64 |
+
ON public.reports(user_id);
|
| 65 |
+
|
| 66 |
+
CREATE INDEX IF NOT EXISTS idx_reports_created_at
|
| 67 |
+
ON public.reports(created_at DESC);
|
| 68 |
+
|
| 69 |
+
CREATE INDEX IF NOT EXISTS idx_reports_health_score
|
| 70 |
+
ON public.reports(health_score);
|
| 71 |
+
|
| 72 |
+
-- GIN index on JSONB for fast biomarker trend queries
|
| 73 |
+
CREATE INDEX IF NOT EXISTS idx_reports_json
|
| 74 |
+
ON public.reports USING GIN (report_json);
|
| 75 |
+
|
| 76 |
+
-- ── Payments ──────────────────────────────────────────────────────────────────
|
| 77 |
+
-- Audit trail for all Razorpay transactions.
|
| 78 |
+
-- razorpay_order_id is UNIQUE — idempotent upsert is safe.
|
| 79 |
+
|
| 80 |
+
CREATE TABLE IF NOT EXISTS public.payments (
|
| 81 |
+
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
| 82 |
+
user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
| 83 |
+
razorpay_order_id TEXT UNIQUE NOT NULL,
|
| 84 |
+
razorpay_payment_id TEXT UNIQUE,
|
| 85 |
+
razorpay_signature TEXT,
|
| 86 |
+
plan TEXT NOT NULL
|
| 87 |
+
CHECK (plan IN ('per_report', 'monthly')),
|
| 88 |
+
amount_paise INTEGER NOT NULL,
|
| 89 |
+
status TEXT NOT NULL DEFAULT 'pending'
|
| 90 |
+
CHECK (status IN ('pending', 'success', 'failed')),
|
| 91 |
+
verified_at TIMESTAMPTZ,
|
| 92 |
+
created_at TIMESTAMPTZ DEFAULT NOW()
|
| 93 |
+
);
|
| 94 |
+
|
| 95 |
+
CREATE INDEX IF NOT EXISTS idx_payments_user_id
|
| 96 |
+
ON public.payments(user_id);
|
| 97 |
+
|
| 98 |
+
-- ── Row Level Security ────────────────────────────────────────────────────────
|
| 99 |
+
-- Users can only read their own data.
|
| 100 |
+
-- Backend uses service role key — bypasses RLS for writes.
|
| 101 |
+
|
| 102 |
+
ALTER TABLE public.user_profiles ENABLE ROW LEVEL SECURITY;
|
| 103 |
+
ALTER TABLE public.reports ENABLE ROW LEVEL SECURITY;
|
| 104 |
+
ALTER TABLE public.payments ENABLE ROW LEVEL SECURITY;
|
| 105 |
+
|
| 106 |
+
-- user_profiles: users see and update only their own row
|
| 107 |
+
CREATE POLICY "Users see own profile"
|
| 108 |
+
ON public.user_profiles FOR ALL
|
| 109 |
+
USING (auth.uid() = id);
|
| 110 |
+
|
| 111 |
+
-- reports: users can read their own reports
|
| 112 |
+
CREATE POLICY "Users see own reports"
|
| 113 |
+
ON public.reports FOR SELECT
|
| 114 |
+
USING (auth.uid() = user_id);
|
| 115 |
+
|
| 116 |
+
-- reports: backend service role inserts (WITH CHECK true allows service key)
|
| 117 |
+
CREATE POLICY "Service role inserts reports"
|
| 118 |
+
ON public.reports FOR INSERT
|
| 119 |
+
WITH CHECK (true);
|
| 120 |
+
|
| 121 |
+
-- payments: users can only see their own payment records
|
| 122 |
+
CREATE POLICY "Users see own payments"
|
| 123 |
+
ON public.payments FOR SELECT
|
| 124 |
+
USING (auth.uid() = user_id);
|
| 125 |
+
|
| 126 |
+
-- payments: backend service role inserts
|
| 127 |
+
CREATE POLICY "Service role inserts payments"
|
| 128 |
+
ON public.payments FOR INSERT
|
| 129 |
+
WITH CHECK (true);
|
| 130 |
+
|
| 131 |
+
-- ── Helper: increment reports_analyzed counter ────────────────────────────────
|
| 132 |
+
-- Called via trigger when a new report is inserted
|
| 133 |
+
|
| 134 |
+
CREATE OR REPLACE FUNCTION increment_reports_analyzed()
|
| 135 |
+
RETURNS TRIGGER AS $$
|
| 136 |
+
BEGIN
|
| 137 |
+
IF NEW.user_id IS NOT NULL THEN
|
| 138 |
+
INSERT INTO public.user_profiles (id, reports_analyzed)
|
| 139 |
+
VALUES (NEW.user_id, 1)
|
| 140 |
+
ON CONFLICT (id) DO UPDATE
|
| 141 |
+
SET reports_analyzed = user_profiles.reports_analyzed + 1,
|
| 142 |
+
updated_at = NOW();
|
| 143 |
+
END IF;
|
| 144 |
+
RETURN NEW;
|
| 145 |
+
END;
|
| 146 |
+
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
| 147 |
+
|
| 148 |
+
CREATE TRIGGER on_report_inserted
|
| 149 |
+
AFTER INSERT ON public.reports
|
| 150 |
+
FOR EACH ROW EXECUTE FUNCTION increment_reports_analyzed();
|
| 151 |
+
|
| 152 |
+
-- ── Trend analysis view (bonus — useful for future dashboard) ─────────────────
|
| 153 |
+
-- Returns latest health score per user for trend charts
|
| 154 |
+
|
| 155 |
+
CREATE OR REPLACE VIEW public.user_health_trends AS
|
| 156 |
+
SELECT
|
| 157 |
+
r.user_id,
|
| 158 |
+
r.id AS report_id,
|
| 159 |
+
r.created_at,
|
| 160 |
+
r.health_score,
|
| 161 |
+
r.health_grade,
|
| 162 |
+
r.biological_age,
|
| 163 |
+
r.chronological_age,
|
| 164 |
+
r.abnormal_count,
|
| 165 |
+
r.lab_name
|
| 166 |
+
FROM public.reports r
|
| 167 |
+
WHERE r.user_id IS NOT NULL
|
| 168 |
+
ORDER BY r.user_id, r.created_at DESC;
|
| 169 |
+
|
| 170 |
+
-- ── Verify setup ──────────────────────────────────────────────────────────────
|
| 171 |
+
SELECT
|
| 172 |
+
schemaname,
|
| 173 |
+
tablename,
|
| 174 |
+
tableowner
|
| 175 |
+
FROM pg_tables
|
| 176 |
+
WHERE schemaname = 'public'
|
| 177 |
+
AND tablename IN ('user_profiles', 'reports', 'payments');
|
tests/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# LabCard AI Backend
|
tests/test_parser.py
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
LabCard AI — Biomarker Parser Tests
|
| 3 |
+
Covers all 4 real-world Indian lab report format variants.
|
| 4 |
+
Run with: pytest tests/test_parser.py -v
|
| 5 |
+
"""
|
| 6 |
+
import sys
|
| 7 |
+
import os
|
| 8 |
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
| 9 |
+
|
| 10 |
+
import pytest
|
| 11 |
+
from app.core.biomarker_parser import (
|
| 12 |
+
clean_test_name,
|
| 13 |
+
parse_biomarkers,
|
| 14 |
+
parse_range,
|
| 15 |
+
parse_value,
|
| 16 |
+
)
|
| 17 |
+
from app.models.biomarker import BiomarkerRaw
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
# ── parse_value tests ─────────────────────────────────────────────────────────
|
| 21 |
+
|
| 22 |
+
class TestParseValue:
|
| 23 |
+
def test_standard_float(self):
|
| 24 |
+
assert parse_value("10.2") == 10.2
|
| 25 |
+
|
| 26 |
+
def test_integer(self):
|
| 27 |
+
assert parse_value("9800") == 9800.0
|
| 28 |
+
|
| 29 |
+
def test_indian_comma(self):
|
| 30 |
+
assert parse_value("10,200") == 10200.0
|
| 31 |
+
|
| 32 |
+
def test_large_indian_comma(self):
|
| 33 |
+
assert parse_value("1,85,000") == 185000.0
|
| 34 |
+
|
| 35 |
+
def test_less_than(self):
|
| 36 |
+
# "<0.1" → half of 0.1
|
| 37 |
+
result = parse_value("<0.1")
|
| 38 |
+
assert result == pytest.approx(0.05, abs=0.001)
|
| 39 |
+
|
| 40 |
+
def test_greater_than(self):
|
| 41 |
+
# ">100" → 101
|
| 42 |
+
assert parse_value(">100") == 101.0
|
| 43 |
+
|
| 44 |
+
def test_with_trailing_flag(self):
|
| 45 |
+
# Some labs print "10.2 L" (L = Low)
|
| 46 |
+
assert parse_value("10.2 L") == 10.2
|
| 47 |
+
assert parse_value("185000 H") == 185000.0
|
| 48 |
+
|
| 49 |
+
def test_invalid(self):
|
| 50 |
+
assert parse_value("N/A") is None
|
| 51 |
+
assert parse_value("") is None
|
| 52 |
+
assert parse_value("abc") is None
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# ── parse_range tests ─────────────────────────────────────────────────────────
|
| 56 |
+
|
| 57 |
+
class TestParseRange:
|
| 58 |
+
def test_hyphen_range(self):
|
| 59 |
+
assert parse_range("13.0 - 17.0") == (13.0, 17.0)
|
| 60 |
+
|
| 61 |
+
def test_endash_range(self):
|
| 62 |
+
assert parse_range("13.0 – 17.0") == (13.0, 17.0)
|
| 63 |
+
|
| 64 |
+
def test_no_spaces(self):
|
| 65 |
+
assert parse_range("13.0-17.0") == (13.0, 17.0)
|
| 66 |
+
|
| 67 |
+
def test_large_numbers(self):
|
| 68 |
+
assert parse_range("150000-400000") == (150000.0, 400000.0)
|
| 69 |
+
|
| 70 |
+
def test_less_than(self):
|
| 71 |
+
low, high = parse_range("< 200")
|
| 72 |
+
assert low is None
|
| 73 |
+
assert high == 200.0
|
| 74 |
+
|
| 75 |
+
def test_less_than_no_space(self):
|
| 76 |
+
low, high = parse_range("<200")
|
| 77 |
+
assert low is None
|
| 78 |
+
assert high == 200.0
|
| 79 |
+
|
| 80 |
+
def test_greater_than(self):
|
| 81 |
+
low, high = parse_range("> 40")
|
| 82 |
+
assert low == 40.0
|
| 83 |
+
assert high is None
|
| 84 |
+
|
| 85 |
+
def test_decimal_small(self):
|
| 86 |
+
assert parse_range("0.4 - 4.0") == (0.4, 4.0)
|
| 87 |
+
|
| 88 |
+
def test_inverted_range(self):
|
| 89 |
+
# Some labs print high first — parser should swap
|
| 90 |
+
assert parse_range("17.0 - 13.0") == (13.0, 17.0)
|
| 91 |
+
|
| 92 |
+
def test_empty(self):
|
| 93 |
+
assert parse_range("") == (None, None)
|
| 94 |
+
|
| 95 |
+
def test_unparseable(self):
|
| 96 |
+
assert parse_range("Normal") == (None, None)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
# ── clean_test_name tests ─────────────────────────────────────────────────────
|
| 100 |
+
|
| 101 |
+
class TestCleanTestName:
|
| 102 |
+
def test_all_caps(self):
|
| 103 |
+
result = clean_test_name("HEMOGLOBIN")
|
| 104 |
+
assert result == "Hemoglobin"
|
| 105 |
+
|
| 106 |
+
def test_strips_automated(self):
|
| 107 |
+
result = clean_test_name("Hemoglobin (Automated)")
|
| 108 |
+
assert result == "Hemoglobin"
|
| 109 |
+
|
| 110 |
+
def test_strips_serum(self):
|
| 111 |
+
result = clean_test_name("Creatinine (Serum)")
|
| 112 |
+
assert result == "Creatinine"
|
| 113 |
+
|
| 114 |
+
def test_strips_quantitative(self):
|
| 115 |
+
result = clean_test_name("Vitamin B12 (Quantitative)")
|
| 116 |
+
assert result == "Vitamin B12"
|
| 117 |
+
|
| 118 |
+
def test_preserves_meaningful_paren(self):
|
| 119 |
+
# (25-OH) is meaningful — keep it
|
| 120 |
+
result = clean_test_name("Vitamin D (25-OH)")
|
| 121 |
+
# Should not strip (25-OH) — it's not in the suffix list
|
| 122 |
+
assert "25-Oh" in result or "25-oh" in result.lower() or "Vitamin D" in result
|
| 123 |
+
|
| 124 |
+
def test_max_length(self):
|
| 125 |
+
long_name = "A" * 60
|
| 126 |
+
result = clean_test_name(long_name)
|
| 127 |
+
assert len(result) <= 50
|
| 128 |
+
|
| 129 |
+
def test_collapse_spaces(self):
|
| 130 |
+
result = clean_test_name("Hemoglobin Count")
|
| 131 |
+
assert " " not in result
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
# ── parse_biomarkers tests — 4 real-world formats ────────────────────────────
|
| 135 |
+
|
| 136 |
+
class TestParseBiomarkers:
|
| 137 |
+
"""Test the main parser with all 4 Indian lab report formats."""
|
| 138 |
+
|
| 139 |
+
# Format A — Thyrocare colon-separated with brackets
|
| 140 |
+
FORMAT_A = """
|
| 141 |
+
COMPLETE BLOOD COUNT (CBC)
|
| 142 |
+
Hemoglobin : 10.2 g/dL [13.0 - 17.0]
|
| 143 |
+
WBC Count : 9800 cells/uL [4000 - 11000]
|
| 144 |
+
Platelet Count : 185000 /uL [150000 - 400000]
|
| 145 |
+
MCV : 68 fL [80 - 100]
|
| 146 |
+
"""
|
| 147 |
+
|
| 148 |
+
# Format B — Dr. Lal tabular, no colon
|
| 149 |
+
FORMAT_B = """
|
| 150 |
+
TEST RESULT UNIT REFERENCE RANGE
|
| 151 |
+
HEMOGLOBIN (Hb) 10.2 g/dL 13.0 - 17.0 L
|
| 152 |
+
WBC COUNT 9800 cells/uL 4000 - 11000
|
| 153 |
+
PLATELET COUNT 185000 /uL 150000 - 400000
|
| 154 |
+
MCV 68 fL 80 - 100 L
|
| 155 |
+
"""
|
| 156 |
+
|
| 157 |
+
# Format C — Apollo with Ref: prefix
|
| 158 |
+
FORMAT_C = """
|
| 159 |
+
Haemoglobin 10.2 g/dL Ref: 13.0-17.0
|
| 160 |
+
WBC 9800 cells/uL Ref: 4000-11000
|
| 161 |
+
Platelet 185000 /uL Ref: 150000-400000
|
| 162 |
+
"""
|
| 163 |
+
|
| 164 |
+
# Format D — inline abbreviated
|
| 165 |
+
FORMAT_D = """
|
| 166 |
+
Hb: 10.2 g/dL (N: 13.0-17.0)
|
| 167 |
+
WBC: 9800 cells/uL (N: 4000-11000)
|
| 168 |
+
PLT: 185000 /uL (N: 150000-400000)
|
| 169 |
+
"""
|
| 170 |
+
|
| 171 |
+
def _get_parsed(self, text: str) -> dict[str, BiomarkerRaw]:
|
| 172 |
+
"""Helper — parse text and return dict by lowercase name."""
|
| 173 |
+
results = parse_biomarkers(text)
|
| 174 |
+
return {r.name.lower(): r for r in results}
|
| 175 |
+
|
| 176 |
+
def test_format_a_hemoglobin(self):
|
| 177 |
+
parsed = self._get_parsed(self.FORMAT_A)
|
| 178 |
+
# Find hemoglobin (may be titled)
|
| 179 |
+
hb = next(
|
| 180 |
+
(v for k, v in parsed.items() if "hemoglobin" in k or "hb" == k),
|
| 181 |
+
None,
|
| 182 |
+
)
|
| 183 |
+
assert hb is not None, f"Hemoglobin not found. Got: {list(parsed.keys())}"
|
| 184 |
+
assert parse_value(hb.value_raw) == pytest.approx(10.2)
|
| 185 |
+
assert "13" in hb.range_raw and "17" in hb.range_raw
|
| 186 |
+
|
| 187 |
+
def test_format_a_wbc(self):
|
| 188 |
+
parsed = self._get_parsed(self.FORMAT_A)
|
| 189 |
+
wbc = next((v for k, v in parsed.items() if "wbc" in k or "white" in k), None)
|
| 190 |
+
assert wbc is not None, f"WBC not found. Got: {list(parsed.keys())}"
|
| 191 |
+
val = parse_value(wbc.value_raw)
|
| 192 |
+
assert val == pytest.approx(9800.0)
|
| 193 |
+
|
| 194 |
+
def test_format_a_platelet(self):
|
| 195 |
+
parsed = self._get_parsed(self.FORMAT_A)
|
| 196 |
+
plt = next((v for k, v in parsed.items() if "platelet" in k or "plt" in k), None)
|
| 197 |
+
assert plt is not None, f"Platelet not found. Got: {list(parsed.keys())}"
|
| 198 |
+
val = parse_value(plt.value_raw)
|
| 199 |
+
assert val == pytest.approx(185000.0)
|
| 200 |
+
|
| 201 |
+
def test_format_b_hemoglobin(self):
|
| 202 |
+
parsed = self._get_parsed(self.FORMAT_B)
|
| 203 |
+
hb = next((v for k, v in parsed.items() if "hemoglobin" in k or "hb" in k), None)
|
| 204 |
+
assert hb is not None, f"Hemoglobin not found in Format B. Got: {list(parsed.keys())}"
|
| 205 |
+
assert parse_value(hb.value_raw) == pytest.approx(10.2)
|
| 206 |
+
|
| 207 |
+
def test_format_c_haemoglobin(self):
|
| 208 |
+
parsed = self._get_parsed(self.FORMAT_C)
|
| 209 |
+
hb = next((v for k, v in parsed.items() if "haemoglobin" in k or "hemoglobin" in k), None)
|
| 210 |
+
assert hb is not None, f"Haemoglobin not found in Format C. Got: {list(parsed.keys())}"
|
| 211 |
+
|
| 212 |
+
def test_deduplication(self):
|
| 213 |
+
"""Same test on two lines — keep the one with a range."""
|
| 214 |
+
text = """
|
| 215 |
+
Hemoglobin : 10.2 g/dL
|
| 216 |
+
Hemoglobin : 10.2 g/dL [13.0 - 17.0]
|
| 217 |
+
"""
|
| 218 |
+
results = parse_biomarkers(text)
|
| 219 |
+
hb_results = [r for r in results if "hemoglobin" in r.name.lower()]
|
| 220 |
+
assert len(hb_results) == 1, "Deduplication failed — got duplicates"
|
| 221 |
+
assert hb_results[0].range_raw != "", "Should keep entry with range"
|
| 222 |
+
|
| 223 |
+
def test_skips_header_lines(self):
|
| 224 |
+
text = """
|
| 225 |
+
TEST NAME RESULT UNIT REFERENCE RANGE
|
| 226 |
+
Hemoglobin : 10.2 g/dL [13.0 - 17.0]
|
| 227 |
+
PARAMETER VALUE NORMAL
|
| 228 |
+
"""
|
| 229 |
+
results = parse_biomarkers(text)
|
| 230 |
+
names = [r.name.lower() for r in results]
|
| 231 |
+
assert not any("test name" in n or "parameter" in n for n in names)
|
| 232 |
+
assert any("hemoglobin" in n for n in names)
|
| 233 |
+
|
| 234 |
+
def test_skips_empty_lines(self):
|
| 235 |
+
text = "\n\n\n\nHemoglobin : 10.2 g/dL [13.0 - 17.0]\n\n"
|
| 236 |
+
results = parse_biomarkers(text)
|
| 237 |
+
assert len(results) >= 1
|
| 238 |
+
|
| 239 |
+
def test_empty_text(self):
|
| 240 |
+
assert parse_biomarkers("") == []
|
| 241 |
+
assert parse_biomarkers(" ") == []
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
# ── Integration: Demo report from frontend ────────────────────────────────────
|
| 245 |
+
|
| 246 |
+
class TestDemoReport:
|
| 247 |
+
"""
|
| 248 |
+
Parse the actual demo-report.txt from the frontend project.
|
| 249 |
+
Validates end-to-end that all major biomarkers are extracted.
|
| 250 |
+
"""
|
| 251 |
+
|
| 252 |
+
DEMO_REPORT = """
|
| 253 |
+
THYROCARE TECHNOLOGIES LIMITED
|
| 254 |
+
Test Report
|
| 255 |
+
|
| 256 |
+
Patient Name: Rahul Sharma
|
| 257 |
+
Age/Gender: 28 Years / Male
|
| 258 |
+
Sample Collected: 15 May 2025
|
| 259 |
+
Report Date: 16 May 2025
|
| 260 |
+
Lab No: TH9823451
|
| 261 |
+
|
| 262 |
+
AAROGYAM 1.3 (FULL BODY CHECKUP)
|
| 263 |
+
|
| 264 |
+
COMPLETE BLOOD COUNT (CBC)
|
| 265 |
+
Hemoglobin : 10.2 g/dL [13.0 - 17.0]
|
| 266 |
+
RBC Count : 4.1 million/uL [4.5 - 5.5]
|
| 267 |
+
WBC Count : 9800 cells/uL [4000 - 11000]
|
| 268 |
+
Platelet Count : 185000 /uL [150000 - 400000]
|
| 269 |
+
MCV : 68 fL [80 - 100]
|
| 270 |
+
MCH : 22 pg [27 - 32]
|
| 271 |
+
MCHC : 29 g/dL [31.5 - 34.5]
|
| 272 |
+
Hematocrit (PCV) : 32 % [40 - 50]
|
| 273 |
+
|
| 274 |
+
IRON STUDIES
|
| 275 |
+
Serum Iron : 42 ug/dL [60 - 170]
|
| 276 |
+
TIBC : 420 ug/dL [250 - 370]
|
| 277 |
+
Serum Ferritin : 8 ng/mL [12 - 300]
|
| 278 |
+
Transferrin Saturation: 10 % [20 - 50]
|
| 279 |
+
|
| 280 |
+
THYROID PROFILE
|
| 281 |
+
TSH : 2.8 uIU/mL [0.4 - 4.0]
|
| 282 |
+
T3 (Triiodothyronine): 98 ng/dL [60 - 200]
|
| 283 |
+
T4 (Thyroxine) : 7.2 ug/dL [4.5 - 12.5]
|
| 284 |
+
|
| 285 |
+
VITAMINS
|
| 286 |
+
Vitamin D (25-OH) : 14.2 ng/mL [30 - 100]
|
| 287 |
+
Vitamin B12 : 185 pg/mL [200 - 900]
|
| 288 |
+
Folic Acid : 5.8 ng/mL [3.0 - 17.0]
|
| 289 |
+
|
| 290 |
+
DIABETES
|
| 291 |
+
Fasting Blood Glucose: 88 mg/dL [70 - 100]
|
| 292 |
+
HbA1c : 5.2 % [4.0 - 5.6]
|
| 293 |
+
|
| 294 |
+
LIVER FUNCTION TEST
|
| 295 |
+
SGPT (ALT) : 32 U/L [0 - 40]
|
| 296 |
+
SGOT (AST) : 28 U/L [0 - 40]
|
| 297 |
+
Alkaline Phosphatase : 78 U/L [44 - 147]
|
| 298 |
+
Bilirubin Total : 0.8 mg/dL [0.2 - 1.2]
|
| 299 |
+
Albumin : 4.1 g/dL [3.5 - 5.0]
|
| 300 |
+
|
| 301 |
+
KIDNEY FUNCTION TEST
|
| 302 |
+
Serum Creatinine : 0.9 mg/dL [0.7 - 1.3]
|
| 303 |
+
Blood Urea Nitrogen : 14 mg/dL [7 - 20]
|
| 304 |
+
Uric Acid : 5.8 mg/dL [3.5 - 7.2]
|
| 305 |
+
|
| 306 |
+
LIPID PROFILE
|
| 307 |
+
Total Cholesterol : 198 mg/dL [< 200]
|
| 308 |
+
HDL Cholesterol : 38 mg/dL [> 40]
|
| 309 |
+
LDL Cholesterol : 128 mg/dL [< 100]
|
| 310 |
+
Triglycerides : 185 mg/dL [< 150]
|
| 311 |
+
VLDL : 37 mg/dL [< 30]
|
| 312 |
+
"""
|
| 313 |
+
|
| 314 |
+
def test_extracts_at_least_20_biomarkers(self):
|
| 315 |
+
results = parse_biomarkers(self.DEMO_REPORT)
|
| 316 |
+
assert len(results) >= 20, (
|
| 317 |
+
f"Expected ≥20 biomarkers, got {len(results)}: "
|
| 318 |
+
f"{[r.name for r in results]}"
|
| 319 |
+
)
|
| 320 |
+
|
| 321 |
+
def test_key_biomarkers_found(self):
|
| 322 |
+
results = parse_biomarkers(self.DEMO_REPORT)
|
| 323 |
+
names_lower = [r.name.lower() for r in results]
|
| 324 |
+
|
| 325 |
+
required = [
|
| 326 |
+
"hemoglobin", "wbc", "platelet", "tsh", "vitamin d",
|
| 327 |
+
"vitamin b12", "sgpt", "creatinine", "cholesterol",
|
| 328 |
+
]
|
| 329 |
+
for req in required:
|
| 330 |
+
found = any(req in n for n in names_lower)
|
| 331 |
+
assert found, (
|
| 332 |
+
f"Required biomarker '{req}' not found. "
|
| 333 |
+
f"Got: {[r.name for r in results]}"
|
| 334 |
+
)
|
| 335 |
+
|
| 336 |
+
def test_hemoglobin_value_correct(self):
|
| 337 |
+
results = parse_biomarkers(self.DEMO_REPORT)
|
| 338 |
+
hb = next((r for r in results if "hemoglobin" in r.name.lower()), None)
|
| 339 |
+
assert hb is not None
|
| 340 |
+
assert parse_value(hb.value_raw) == pytest.approx(10.2)
|
| 341 |
+
low, high = parse_range(hb.range_raw)
|
| 342 |
+
assert low == pytest.approx(13.0)
|
| 343 |
+
assert high == pytest.approx(17.0)
|
| 344 |
+
|
| 345 |
+
def test_vitamin_d_range_parsed(self):
|
| 346 |
+
results = parse_biomarkers(self.DEMO_REPORT)
|
| 347 |
+
vd = next((r for r in results if "vitamin d" in r.name.lower()), None)
|
| 348 |
+
assert vd is not None, "Vitamin D not found"
|
| 349 |
+
low, high = parse_range(vd.range_raw)
|
| 350 |
+
assert low == pytest.approx(30.0)
|
| 351 |
+
assert high == pytest.approx(100.0)
|
| 352 |
+
|
| 353 |
+
def test_lipid_less_than_range(self):
|
| 354 |
+
results = parse_biomarkers(self.DEMO_REPORT)
|
| 355 |
+
chol = next((r for r in results if "cholesterol" in r.name.lower() and "total" in r.name.lower()), None)
|
| 356 |
+
if chol:
|
| 357 |
+
low, high = parse_range(chol.range_raw)
|
| 358 |
+
assert high == pytest.approx(200.0)
|
| 359 |
+
assert low is None
|
| 360 |
+
|
| 361 |
+
def test_no_header_lines_in_results(self):
|
| 362 |
+
results = parse_biomarkers(self.DEMO_REPORT)
|
| 363 |
+
names = [r.name.lower() for r in results]
|
| 364 |
+
bad = ["test name", "parameter", "result", "reference", "thyrocare", "patient"]
|
| 365 |
+
for b in bad:
|
| 366 |
+
assert not any(b == n.strip() for n in names), (
|
| 367 |
+
f"Header/metadata '{b}' appeared in biomarker results"
|
| 368 |
+
)
|
| 369 |
+
|
| 370 |
+
def test_no_duplicates(self):
|
| 371 |
+
results = parse_biomarkers(self.DEMO_REPORT)
|
| 372 |
+
names_lower = [r.name.lower() for r in results]
|
| 373 |
+
duplicates = [n for n in names_lower if names_lower.count(n) > 1]
|
| 374 |
+
assert not duplicates, f"Duplicate biomarkers found: {duplicates}"
|
tests/test_scorer.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
LabCard AI — Health Scorer Tests
|
| 3 |
+
Tests verify:
|
| 4 |
+
- Demo report: health_score ≈ 52 (Fair)
|
| 5 |
+
- Demo report: biological_age > chronological_age (28 yrs actual → ~35–38 bio)
|
| 6 |
+
- Demo report: has_critical_alert = False
|
| 7 |
+
- Demo report: top_priority mentions iron/hemoglobin
|
| 8 |
+
- Edge cases: empty list, all normal, single critical
|
| 9 |
+
Run with: pytest tests/test_scorer.py -v
|
| 10 |
+
"""
|
| 11 |
+
import sys
|
| 12 |
+
import os
|
| 13 |
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
| 14 |
+
|
| 15 |
+
import pytest
|
| 16 |
+
from app.core.scorer import (
|
| 17 |
+
HealthMetrics,
|
| 18 |
+
_compute_score,
|
| 19 |
+
_estimate_bio_age,
|
| 20 |
+
_find_top_priority,
|
| 21 |
+
_parse_age,
|
| 22 |
+
_score_to_grade,
|
| 23 |
+
compute_health_metrics,
|
| 24 |
+
)
|
| 25 |
+
from app.models.biomarker import Biomarker, BiomarkerCategory, BiomarkerStatus
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# ── Biomarker factory helpers ─────────────────────────────────────────────────
|
| 29 |
+
|
| 30 |
+
def make_bm(
|
| 31 |
+
name: str,
|
| 32 |
+
value: float,
|
| 33 |
+
status: BiomarkerStatus,
|
| 34 |
+
category: BiomarkerCategory = BiomarkerCategory.BLOOD,
|
| 35 |
+
unit: str = "g/dL",
|
| 36 |
+
normal_range_text: str = "13.0 - 17.0",
|
| 37 |
+
) -> Biomarker:
|
| 38 |
+
"""Create a Biomarker for testing."""
|
| 39 |
+
return Biomarker(
|
| 40 |
+
name=name,
|
| 41 |
+
value=value,
|
| 42 |
+
value_raw=str(value),
|
| 43 |
+
unit=unit,
|
| 44 |
+
normal_range_text=normal_range_text,
|
| 45 |
+
status=status,
|
| 46 |
+
category=category,
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
# ── Demo report biomarkers (mirrors public/demo-report.txt) ──────────────────
|
| 51 |
+
|
| 52 |
+
def build_demo_biomarkers() -> list[Biomarker]:
|
| 53 |
+
"""
|
| 54 |
+
Build the full Rahul Sharma demo report biomarker set.
|
| 55 |
+
Status matches what range_db.classify_status() would return.
|
| 56 |
+
"""
|
| 57 |
+
return [
|
| 58 |
+
# CBC — several abnormal (anemia pattern)
|
| 59 |
+
make_bm("Hemoglobin", 10.2, BiomarkerStatus.LOW, BiomarkerCategory.BLOOD, "g/dL", "13.0 - 17.0"),
|
| 60 |
+
make_bm("RBC Count", 4.1, BiomarkerStatus.LOW, BiomarkerCategory.BLOOD, "M/uL", "4.5 - 5.5"),
|
| 61 |
+
make_bm("WBC Count", 9800, BiomarkerStatus.NORMAL, BiomarkerCategory.BLOOD, "cells/uL","4000 - 11000"),
|
| 62 |
+
make_bm("Platelet Count",185000, BiomarkerStatus.NORMAL, BiomarkerCategory.BLOOD, "/uL", "150000 - 400000"),
|
| 63 |
+
make_bm("MCV", 68, BiomarkerStatus.LOW, BiomarkerCategory.BLOOD, "fL", "80 - 100"),
|
| 64 |
+
make_bm("MCH", 22, BiomarkerStatus.LOW, BiomarkerCategory.BLOOD, "pg", "27 - 32"),
|
| 65 |
+
make_bm("MCHC", 29, BiomarkerStatus.LOW, BiomarkerCategory.BLOOD, "g/dL", "31.5 - 34.5"),
|
| 66 |
+
make_bm("Hematocrit", 32, BiomarkerStatus.LOW, BiomarkerCategory.BLOOD, "%", "40 - 50"),
|
| 67 |
+
# Iron studies — deficient
|
| 68 |
+
make_bm("Serum Iron", 42, BiomarkerStatus.LOW, BiomarkerCategory.BLOOD, "ug/dL", "60 - 170"),
|
| 69 |
+
make_bm("TIBC", 420, BiomarkerStatus.ELEVATED, BiomarkerCategory.BLOOD, "ug/dL", "250 - 370"),
|
| 70 |
+
make_bm("Serum Ferritin", 8, BiomarkerStatus.DEFICIENT, BiomarkerCategory.BLOOD, "ng/mL", "12 - 300"),
|
| 71 |
+
make_bm("Transferrin Saturation", 10, BiomarkerStatus.LOW, BiomarkerCategory.BLOOD, "%", "20 - 50"),
|
| 72 |
+
# Thyroid — normal
|
| 73 |
+
make_bm("TSH", 2.8, BiomarkerStatus.NORMAL, BiomarkerCategory.THYROID, "uIU/mL", "0.4 - 4.0"),
|
| 74 |
+
make_bm("T3", 98, BiomarkerStatus.NORMAL, BiomarkerCategory.THYROID, "ng/dL", "60 - 200"),
|
| 75 |
+
make_bm("T4", 7.2, BiomarkerStatus.NORMAL, BiomarkerCategory.THYROID, "ug/dL", "4.5 - 12.5"),
|
| 76 |
+
# Vitamins — deficient
|
| 77 |
+
make_bm("Vitamin D", 14.2, BiomarkerStatus.DEFICIENT, BiomarkerCategory.VITAMIN,"ng/mL", "30 - 100"),
|
| 78 |
+
make_bm("Vitamin B12", 185, BiomarkerStatus.DEFICIENT, BiomarkerCategory.VITAMIN,"pg/mL", "200 - 900"),
|
| 79 |
+
make_bm("Folic Acid", 5.8, BiomarkerStatus.NORMAL, BiomarkerCategory.VITAMIN, "ng/mL", "3.0 - 17.0"),
|
| 80 |
+
# Diabetes — normal
|
| 81 |
+
make_bm("Fasting Blood Glucose", 88, BiomarkerStatus.NORMAL, BiomarkerCategory.SUGAR,"mg/dL", "70 - 100"),
|
| 82 |
+
make_bm("HbA1c", 5.2, BiomarkerStatus.NORMAL, BiomarkerCategory.SUGAR, "%", "4.0 - 5.6"),
|
| 83 |
+
# Liver — normal
|
| 84 |
+
make_bm("SGPT", 32, BiomarkerStatus.NORMAL, BiomarkerCategory.LIVER, "U/L", "0 - 40"),
|
| 85 |
+
make_bm("SGOT", 28, BiomarkerStatus.NORMAL, BiomarkerCategory.LIVER, "U/L", "0 - 40"),
|
| 86 |
+
make_bm("Alkaline Phosphatase", 78, BiomarkerStatus.NORMAL, BiomarkerCategory.LIVER,"U/L", "44 - 147"),
|
| 87 |
+
make_bm("Total Bilirubin", 0.8, BiomarkerStatus.NORMAL, BiomarkerCategory.LIVER, "mg/dL", "0.2 - 1.2"),
|
| 88 |
+
make_bm("Albumin", 4.1, BiomarkerStatus.NORMAL, BiomarkerCategory.LIVER, "g/dL", "3.5 - 5.0"),
|
| 89 |
+
# Kidney — normal
|
| 90 |
+
make_bm("Serum Creatinine",0.9, BiomarkerStatus.NORMAL, BiomarkerCategory.KIDNEY, "mg/dL", "0.7 - 1.3"),
|
| 91 |
+
make_bm("BUN", 14, BiomarkerStatus.NORMAL, BiomarkerCategory.KIDNEY, "mg/dL", "7 - 20"),
|
| 92 |
+
make_bm("Uric Acid", 5.8, BiomarkerStatus.NORMAL, BiomarkerCategory.KIDNEY, "mg/dL", "3.5 - 7.2"),
|
| 93 |
+
# Lipids — slightly abnormal
|
| 94 |
+
make_bm("Total Cholesterol",198, BiomarkerStatus.NORMAL, BiomarkerCategory.LIPID, "mg/dL", "< 200"),
|
| 95 |
+
make_bm("HDL Cholesterol", 38, BiomarkerStatus.LOW, BiomarkerCategory.LIPID, "mg/dL", "> 40"),
|
| 96 |
+
make_bm("LDL Cholesterol", 128, BiomarkerStatus.ELEVATED, BiomarkerCategory.LIPID, "mg/dL", "< 100"),
|
| 97 |
+
make_bm("Triglycerides", 185, BiomarkerStatus.ELEVATED, BiomarkerCategory.LIPID, "mg/dL", "< 150"),
|
| 98 |
+
make_bm("VLDL", 37, BiomarkerStatus.ELEVATED, BiomarkerCategory.LIPID, "mg/dL", "< 30"),
|
| 99 |
+
]
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
# ── _parse_age tests ──────────────────────────────────────────────────────────
|
| 103 |
+
|
| 104 |
+
class TestParseAge:
|
| 105 |
+
def test_years_format(self):
|
| 106 |
+
assert _parse_age("28 Years") == 28
|
| 107 |
+
|
| 108 |
+
def test_digits_only(self):
|
| 109 |
+
assert _parse_age("35") == 35
|
| 110 |
+
|
| 111 |
+
def test_yrs_format(self):
|
| 112 |
+
assert _parse_age("22 Yrs") == 22
|
| 113 |
+
|
| 114 |
+
def test_empty(self):
|
| 115 |
+
assert _parse_age("") == 0
|
| 116 |
+
|
| 117 |
+
def test_out_of_range(self):
|
| 118 |
+
assert _parse_age("200") == 0
|
| 119 |
+
|
| 120 |
+
def test_with_gender(self):
|
| 121 |
+
# "28 Years / Male" — should extract 28
|
| 122 |
+
assert _parse_age("28 Years / Male") == 28
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
# ── _score_to_grade tests ─────────────────────────────────────────────────────
|
| 126 |
+
|
| 127 |
+
class TestScoreToGrade:
|
| 128 |
+
def test_excellent(self):
|
| 129 |
+
assert _score_to_grade(90) == "Excellent"
|
| 130 |
+
assert _score_to_grade(85) == "Excellent"
|
| 131 |
+
|
| 132 |
+
def test_good(self):
|
| 133 |
+
assert _score_to_grade(75) == "Good"
|
| 134 |
+
assert _score_to_grade(70) == "Good"
|
| 135 |
+
|
| 136 |
+
def test_fair(self):
|
| 137 |
+
assert _score_to_grade(60) == "Fair"
|
| 138 |
+
assert _score_to_grade(55) == "Fair"
|
| 139 |
+
|
| 140 |
+
def test_poor(self):
|
| 141 |
+
assert _score_to_grade(45) == "Poor"
|
| 142 |
+
assert _score_to_grade(40) == "Poor"
|
| 143 |
+
|
| 144 |
+
def test_critical(self):
|
| 145 |
+
assert _score_to_grade(39) == "Critical"
|
| 146 |
+
assert _score_to_grade(0) == "Critical"
|
| 147 |
+
|
| 148 |
+
def test_boundaries(self):
|
| 149 |
+
assert _score_to_grade(84) == "Good" # just below Excellent
|
| 150 |
+
assert _score_to_grade(69) == "Fair" # just below Good
|
| 151 |
+
assert _score_to_grade(54) == "Poor" # just below Fair
|
| 152 |
+
assert _score_to_grade(39) == "Critical" # just below Poor
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
# ── Demo report integration tests ────────────────────────────────────────────
|
| 156 |
+
|
| 157 |
+
class TestDemoReport:
|
| 158 |
+
"""
|
| 159 |
+
Rahul Sharma (28M) — iron-deficiency anemia + vitamin deficiencies.
|
| 160 |
+
Expected: Fair score, biological age > chronological, no critical alert.
|
| 161 |
+
"""
|
| 162 |
+
|
| 163 |
+
@pytest.fixture(scope="class")
|
| 164 |
+
def metrics(self) -> HealthMetrics:
|
| 165 |
+
biomarkers = build_demo_biomarkers()
|
| 166 |
+
return compute_health_metrics(biomarkers, "28 Years", "male")
|
| 167 |
+
|
| 168 |
+
def test_health_score_is_abnormal(self, metrics: HealthMetrics):
|
| 169 |
+
"""
|
| 170 |
+
Score must be below 70 given the severity of this report.
|
| 171 |
+
Demo has 16 abnormal markers (8 CBC, 4 iron, 2 vitamin, 3 lipid).
|
| 172 |
+
Actual score will be low (0-45) — this is clinically correct for
|
| 173 |
+
multi-system iron deficiency anemia + vitamin deficiencies + dyslipidemia.
|
| 174 |
+
"""
|
| 175 |
+
assert metrics.health_score <= 55, (
|
| 176 |
+
f"Expected score ≤55 (significant abnormalities), got {metrics.health_score}"
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
def test_health_grade_is_not_excellent_or_good(self, metrics: HealthMetrics):
|
| 180 |
+
"""Grade should not be Excellent or Good with 16 abnormal markers."""
|
| 181 |
+
assert metrics.health_grade not in ("Excellent", "Good"), (
|
| 182 |
+
f"Expected Poor/Fair/Critical, got {metrics.health_grade}"
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
def test_biological_age_older_than_chronological(self, metrics: HealthMetrics):
|
| 186 |
+
"""Bio age must be higher than actual age given iron + vitamin deficiencies."""
|
| 187 |
+
assert metrics.biological_age > metrics.chronological_age, (
|
| 188 |
+
f"Bio age {metrics.biological_age} should exceed chrono age {metrics.chronological_age}"
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
def test_biological_age_in_plausible_range(self, metrics: HealthMetrics):
|
| 192 |
+
"""Bio age should be 28 + some delta — roughly 32–42 for this report."""
|
| 193 |
+
assert 30 <= metrics.biological_age <= 45, (
|
| 194 |
+
f"Bio age {metrics.biological_age} outside plausible range 30–45"
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
def test_chronological_age_parsed_correctly(self, metrics: HealthMetrics):
|
| 198 |
+
assert metrics.chronological_age == 28
|
| 199 |
+
|
| 200 |
+
def test_no_critical_alert(self, metrics: HealthMetrics):
|
| 201 |
+
"""Demo report has no life-threatening values."""
|
| 202 |
+
assert metrics.has_critical_alert is False
|
| 203 |
+
assert metrics.critical_alert_text == ""
|
| 204 |
+
|
| 205 |
+
def test_top_priority_mentions_iron_or_hemoglobin(self, metrics: HealthMetrics):
|
| 206 |
+
"""Top finding should reference the dominant issue — iron deficiency / anemia."""
|
| 207 |
+
priority_lower = metrics.top_priority.lower()
|
| 208 |
+
has_iron_ref = any(
|
| 209 |
+
kw in priority_lower
|
| 210 |
+
for kw in ["hemoglobin", "ferritin", "iron", "anemia", "hb"]
|
| 211 |
+
)
|
| 212 |
+
assert has_iron_ref, (
|
| 213 |
+
f"Top priority should mention iron/hemoglobin but got: {metrics.top_priority!r}"
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
def test_bio_age_factors_present(self, metrics: HealthMetrics):
|
| 217 |
+
"""Factors list should be non-empty and explain the delta."""
|
| 218 |
+
assert len(metrics.bio_age_factors) > 0
|
| 219 |
+
|
| 220 |
+
def test_bio_age_delta_positive(self, metrics: HealthMetrics):
|
| 221 |
+
"""This patient has deficiencies — delta must be positive."""
|
| 222 |
+
assert metrics.bio_age_delta > 0
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
# ── Edge case tests ───────────────────────────────────────────────────────────
|
| 226 |
+
|
| 227 |
+
class TestEdgeCases:
|
| 228 |
+
def test_empty_biomarkers(self):
|
| 229 |
+
m = compute_health_metrics([], "25", "male")
|
| 230 |
+
assert m.health_score == 100
|
| 231 |
+
assert m.health_grade == "Excellent"
|
| 232 |
+
assert m.has_critical_alert is False
|
| 233 |
+
|
| 234 |
+
def test_all_normal(self):
|
| 235 |
+
biomarkers = [
|
| 236 |
+
make_bm("Hemoglobin", 14.5, BiomarkerStatus.NORMAL),
|
| 237 |
+
make_bm("WBC Count", 7000, BiomarkerStatus.NORMAL, BiomarkerCategory.BLOOD, "cells/uL"),
|
| 238 |
+
make_bm("TSH", 2.5, BiomarkerStatus.NORMAL, BiomarkerCategory.THYROID, "uIU/mL"),
|
| 239 |
+
]
|
| 240 |
+
m = compute_health_metrics(biomarkers, "30", "male")
|
| 241 |
+
assert m.health_score == 100
|
| 242 |
+
assert m.health_grade == "Excellent"
|
| 243 |
+
assert "normal" in m.top_priority.lower()
|
| 244 |
+
assert not m.has_critical_alert
|
| 245 |
+
|
| 246 |
+
def test_single_critical_triggers_alert(self):
|
| 247 |
+
biomarkers = [
|
| 248 |
+
make_bm("Hemoglobin", 5.5, BiomarkerStatus.CRITICAL),
|
| 249 |
+
]
|
| 250 |
+
m = compute_health_metrics(biomarkers, "30", "male")
|
| 251 |
+
assert m.has_critical_alert is True
|
| 252 |
+
assert "hemoglobin" in m.critical_alert_text.lower()
|
| 253 |
+
assert "critical" in m.top_priority.lower()
|
| 254 |
+
|
| 255 |
+
def test_critical_adds_25_to_deduction(self):
|
| 256 |
+
only_critical = [make_bm("Hemoglobin", 5.5, BiomarkerStatus.CRITICAL)]
|
| 257 |
+
only_low = [make_bm("Hemoglobin", 11.0, BiomarkerStatus.LOW)]
|
| 258 |
+
score_critical = _compute_score(only_critical)
|
| 259 |
+
score_low = _compute_score(only_low)
|
| 260 |
+
assert score_critical < score_low, "Critical should score lower than Low"
|
| 261 |
+
|
| 262 |
+
def test_no_age_gives_zero_chrono(self):
|
| 263 |
+
m = compute_health_metrics([], "", "male")
|
| 264 |
+
assert m.chronological_age == 0
|
| 265 |
+
|
| 266 |
+
def test_vitamin_deficiency_lowers_score(self):
|
| 267 |
+
with_vitamin = [make_bm("Vitamin D", 14.2, BiomarkerStatus.DEFICIENT, BiomarkerCategory.VITAMIN)]
|
| 268 |
+
all_normal = [make_bm("Hemoglobin", 14.5, BiomarkerStatus.NORMAL)]
|
| 269 |
+
assert _compute_score(with_vitamin) < _compute_score(all_normal)
|
| 270 |
+
|
| 271 |
+
def test_sugar_category_higher_weight(self):
|
| 272 |
+
"""
|
| 273 |
+
With multiple markers, sugar category accumulates more deduction than blood.
|
| 274 |
+
Single marker rounds to same score due to integer rounding (93.5→94 == 94.0→94).
|
| 275 |
+
Use 3 markers to make the weight difference visible.
|
| 276 |
+
"""
|
| 277 |
+
sugar_bms = [make_bm(f"Sugar{i}", 7.0, BiomarkerStatus.HIGH, BiomarkerCategory.SUGAR, "%") for i in range(3)]
|
| 278 |
+
blood_bms = [make_bm(f"Blood{i}", 22.0, BiomarkerStatus.LOW, BiomarkerCategory.BLOOD, "pg") for i in range(3)]
|
| 279 |
+
# 3 markers: Sugar = 3×5×1.3=19.5 → score 81; Blood = 3×5×1.2=18 → score 82
|
| 280 |
+
assert _compute_score(sugar_bms) <= _compute_score(blood_bms)
|
| 281 |
+
|
| 282 |
+
def test_multiple_critical_amplifies_bio_age(self):
|
| 283 |
+
bm_list = [
|
| 284 |
+
make_bm("Hemoglobin", 5.0, BiomarkerStatus.CRITICAL),
|
| 285 |
+
make_bm("Platelet Count", 10000, BiomarkerStatus.CRITICAL, BiomarkerCategory.BLOOD, "/uL"),
|
| 286 |
+
]
|
| 287 |
+
bio_age, delta, _ = _estimate_bio_age(bm_list, 30)
|
| 288 |
+
assert delta >= 10, f"2 critical markers should add ≥10 years, got {delta}"
|
| 289 |
+
|
| 290 |
+
def test_all_lipids_normal_gives_bonus(self):
|
| 291 |
+
bm_list = [
|
| 292 |
+
make_bm("Total Cholesterol", 175, BiomarkerStatus.NORMAL, BiomarkerCategory.LIPID, "mg/dL"),
|
| 293 |
+
make_bm("HDL Cholesterol", 55, BiomarkerStatus.NORMAL, BiomarkerCategory.LIPID, "mg/dL"),
|
| 294 |
+
make_bm("LDL Cholesterol", 85, BiomarkerStatus.NORMAL, BiomarkerCategory.LIPID, "mg/dL"),
|
| 295 |
+
make_bm("Triglycerides", 90, BiomarkerStatus.NORMAL, BiomarkerCategory.LIPID, "mg/dL"),
|
| 296 |
+
make_bm("VLDL", 18, BiomarkerStatus.NORMAL, BiomarkerCategory.LIPID, "mg/dL"),
|
| 297 |
+
]
|
| 298 |
+
_, delta, factors = _estimate_bio_age(bm_list, 30)
|
| 299 |
+
assert delta <= 0, f"All normal lipids should give ≤0 delta, got {delta}"
|
| 300 |
+
assert any("lipid" in f.lower() for f in factors)
|
| 301 |
+
|
| 302 |
+
def test_prediabetic_hba1c_adds_3_years(self):
|
| 303 |
+
bm_list = [make_bm("HbA1c", 6.0, BiomarkerStatus.HIGH, BiomarkerCategory.SUGAR, "%")]
|
| 304 |
+
_, delta, factors = _estimate_bio_age(bm_list, 30)
|
| 305 |
+
assert delta >= 3, f"Pre-diabetic HbA1c should add ≥3 years, got {delta}"
|
| 306 |
+
assert any("pre-diabet" in f.lower() or "prediabet" in f.lower() for f in factors)
|
| 307 |
+
|
| 308 |
+
def test_diabetic_hba1c_adds_6_years(self):
|
| 309 |
+
bm_list = [make_bm("HbA1c", 7.5, BiomarkerStatus.ELEVATED, BiomarkerCategory.SUGAR, "%")]
|
| 310 |
+
_, delta, factors = _estimate_bio_age(bm_list, 30)
|
| 311 |
+
assert delta >= 6, f"Diabetic HbA1c should add ≥6 years, got {delta}"
|
tests/test_unit_noise.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for unit validation and noise filtering."""
|
| 2 |
+
import sys
|
| 3 |
+
import os
|
| 4 |
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
| 5 |
+
|
| 6 |
+
from app.core.biomarker_parser import (
|
| 7 |
+
filter_report_noise,
|
| 8 |
+
parse_biomarkers,
|
| 9 |
+
resolve_and_classify,
|
| 10 |
+
)
|
| 11 |
+
from app.core.unit_validator import validate_unit
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class TestNoiseFilter:
|
| 15 |
+
def test_blocks_textbook_footer(self):
|
| 16 |
+
text = """
|
| 17 |
+
Hemoglobin : 12.5 g/dL [13.0 - 17.0]
|
| 18 |
+
molecular diagnosis fifth edition chapter 3 page 12
|
| 19 |
+
Serum Creatinine : 0.54 mg/dL [0.7 - 1.3]
|
| 20 |
+
"""
|
| 21 |
+
filtered = filter_report_noise(text)
|
| 22 |
+
assert "edition" not in filtered.lower()
|
| 23 |
+
assert "chapter" not in filtered.lower()
|
| 24 |
+
|
| 25 |
+
def test_no_textbook_biomarker(self):
|
| 26 |
+
text = """
|
| 27 |
+
molecular diagnosis fifth edition chapter : 5.0 U/L [1 - 10]
|
| 28 |
+
Hemoglobin : 12.5 g/dL [13.0 - 17.0]
|
| 29 |
+
"""
|
| 30 |
+
results = parse_biomarkers(text)
|
| 31 |
+
names = [r.name.lower() for r in results]
|
| 32 |
+
assert not any("edition" in n or "chapter" in n for n in names)
|
| 33 |
+
assert any("hemoglobin" in n for n in names)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class TestUnitValidation:
|
| 37 |
+
def test_sodium_rejects_wrong_unit(self):
|
| 38 |
+
unit, err = validate_unit("Sodium", "U/L")
|
| 39 |
+
assert err is True
|
| 40 |
+
assert unit == ""
|
| 41 |
+
|
| 42 |
+
def test_sodium_accepts_meq(self):
|
| 43 |
+
unit, err = validate_unit("Sodium", "mEq/L")
|
| 44 |
+
assert err is False
|
| 45 |
+
assert unit == "mEq/L"
|
| 46 |
+
|
| 47 |
+
def test_missing_unit_filled_from_dict(self):
|
| 48 |
+
unit, err = validate_unit("Hemoglobin", "")
|
| 49 |
+
assert err is False
|
| 50 |
+
assert unit == "g/dL"
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class TestDedup:
|
| 54 |
+
def test_creatinine_not_duplicated(self):
|
| 55 |
+
text = """
|
| 56 |
+
Serum Creatinine : 0.54 mg/dL [0.7 - 1.3]
|
| 57 |
+
Creatinine : 0.54 mg/dL [0.7 - 1.3]
|
| 58 |
+
"""
|
| 59 |
+
raw = parse_biomarkers(text)
|
| 60 |
+
classified = resolve_and_classify(raw, gender="male")
|
| 61 |
+
creatinine = [b for b in classified if "creatinine" in b.name.lower()]
|
| 62 |
+
assert len(creatinine) == 1
|
venv/Lib/site-packages/pymupdf/mupdfcpp64.dll
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:9f86ab1801c0c3df64889bf202222e6dba5522a2303148ae58d2888d35a7b189
|
| 3 |
+
size 23856128
|
venv/Scripts/python.exe
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:21bb438c0d4a6f1f164b9a646f6ee000340185e5871180aec06db8d3f07c0082
|
| 3 |
+
size 274712
|
venv/Scripts/uvicorn.exe
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:761b1bd9c49caf8f1e2c73261c887e5e38528c6e78dcf7654d2fbe44f2aaccce
|
| 3 |
+
size 108429
|