Spaces:
Sleeping
Sleeping
Commit ·
151ec26
1
Parent(s): ee1ce77
V1
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .env.example +43 -0
- .github/workflows/ci.yml +54 -0
- .gitignore +52 -0
- README.md +138 -73
- backend/Dockerfile +34 -0
- backend/app/api/deps.py +61 -0
- backend/app/api/v1/endpoints/admin.py +47 -0
- backend/app/api/v1/endpoints/auth.py +72 -0
- backend/app/api/v1/endpoints/chat.py +209 -0
- backend/app/api/v1/endpoints/documents.py +139 -0
- backend/app/api/v1/endpoints/health.py +43 -0
- backend/app/api/v1/endpoints/history.py +26 -0
- backend/app/api/v1/endpoints/ner.py +55 -0
- backend/app/api/v1/endpoints/profile.py +89 -0
- backend/app/api/v1/router.py +22 -0
- backend/app/core/config.py +68 -0
- backend/app/core/database.py +53 -0
- backend/app/core/exceptions.py +82 -0
- backend/app/core/logger.py +45 -0
- backend/app/core/security.py +82 -0
- backend/app/main.py +130 -0
- backend/app/middleware/request_id.py +13 -0
- backend/app/middleware/security_headers.py +14 -0
- backend/app/ml/manager.py +54 -0
- backend/app/ml/ner/base.py +20 -0
- backend/app/ml/ner/bc5cdr.py +109 -0
- backend/app/ml/ner/service.py +34 -0
- backend/app/models/__init__.py +21 -0
- backend/app/models/audit.py +39 -0
- backend/app/models/base.py +25 -0
- backend/app/models/conversation.py +33 -0
- backend/app/models/document.py +39 -0
- backend/app/models/entity.py +18 -0
- backend/app/models/profile.py +21 -0
- backend/app/models/user.py +22 -0
- backend/app/repositories/audit_repo.py +85 -0
- backend/app/repositories/base.py +36 -0
- backend/app/repositories/chat_repo.py +34 -0
- backend/app/repositories/document_repo.py +48 -0
- backend/app/repositories/user_repo.py +36 -0
- backend/app/schemas/__init__.py +40 -0
- backend/app/schemas/admin.py +38 -0
- backend/app/schemas/chat.py +48 -0
- backend/app/schemas/common.py +40 -0
- backend/app/schemas/document.py +43 -0
- backend/app/schemas/ner.py +34 -0
- backend/app/schemas/profile.py +32 -0
- backend/app/schemas/token.py +22 -0
- backend/app/schemas/user.py +35 -0
- backend/app/services/auth_service.py +105 -0
.env.example
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SanjeevaniAI Configuration Environment Variables
|
| 2 |
+
|
| 3 |
+
# Application
|
| 4 |
+
APP_ENV=development
|
| 5 |
+
APP_NAME=SanjeevaniAI
|
| 6 |
+
APP_VERSION=1.0.0
|
| 7 |
+
DEBUG=True
|
| 8 |
+
HOST=0.0.0.0
|
| 9 |
+
PORT=8000
|
| 10 |
+
|
| 11 |
+
# Security (Generate a secure secret key for production)
|
| 12 |
+
SECRET_KEY=sanjeevani-ai-secret-key-change-in-production-secure-random-2026
|
| 13 |
+
ALGORITHM=HS256
|
| 14 |
+
ACCESS_TOKEN_EXPIRE_MINUTES=60
|
| 15 |
+
REFRESH_TOKEN_EXPIRE_DAYS=7
|
| 16 |
+
|
| 17 |
+
# CORS
|
| 18 |
+
CORS_ORIGINS=["http://localhost:3000","http://127.0.0.1:3000"]
|
| 19 |
+
|
| 20 |
+
# Database
|
| 21 |
+
# Default: SQLite async database for development. For PostgreSQL use:
|
| 22 |
+
# DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/sanjeevani_db
|
| 23 |
+
DATABASE_URL=sqlite+aiosqlite:///./sanjeevani.db
|
| 24 |
+
|
| 25 |
+
# Redis & Cache (Optional / Docker)
|
| 26 |
+
REDIS_URL=redis://localhost:6379/0
|
| 27 |
+
|
| 28 |
+
# Biomedical ML Models
|
| 29 |
+
NER_MODEL_PATH=D:/SanjeevaniAI/models/bc5cdr-ner
|
| 30 |
+
DEVICE=auto
|
| 31 |
+
|
| 32 |
+
# LLM Providers (mock, gemini, openai)
|
| 33 |
+
LLM_PROVIDER=gemini
|
| 34 |
+
GEMINI_API_KEY=your-gemini-api-key-here
|
| 35 |
+
OPENAI_API_KEY=
|
| 36 |
+
|
| 37 |
+
# Document Storage & Limits
|
| 38 |
+
UPLOAD_DIR=./uploads
|
| 39 |
+
MAX_UPLOAD_SIZE_MB=25
|
| 40 |
+
ALLOWED_EXTENSIONS=["pdf","txt","docx"]
|
| 41 |
+
|
| 42 |
+
# Rate Limiting
|
| 43 |
+
RATE_LIMIT_PER_MINUTE=60
|
.github/workflows/ci.yml
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: SanjeevaniAI CI Pipeline
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches: [ main, master, develop ]
|
| 6 |
+
pull_request:
|
| 7 |
+
branches: [ main, master ]
|
| 8 |
+
|
| 9 |
+
jobs:
|
| 10 |
+
backend-tests:
|
| 11 |
+
name: Backend Pytest & Lint
|
| 12 |
+
runs-on: ubuntu-latest
|
| 13 |
+
|
| 14 |
+
steps:
|
| 15 |
+
- name: Checkout Code
|
| 16 |
+
uses: actions/checkout@v4
|
| 17 |
+
|
| 18 |
+
- name: Set up Python 3.11
|
| 19 |
+
uses: actions/setup-python@v5
|
| 20 |
+
with:
|
| 21 |
+
python-version: "3.11"
|
| 22 |
+
cache: "pip"
|
| 23 |
+
|
| 24 |
+
- name: Install Python Dependencies
|
| 25 |
+
run: |
|
| 26 |
+
python -m pip install --upgrade pip
|
| 27 |
+
pip install -r requirements.txt
|
| 28 |
+
|
| 29 |
+
- name: Run Backend Pytest Suite
|
| 30 |
+
run: |
|
| 31 |
+
pytest backend/tests -v --durations=10
|
| 32 |
+
|
| 33 |
+
frontend-build:
|
| 34 |
+
name: Frontend Typecheck & Build
|
| 35 |
+
runs-on: ubuntu-latest
|
| 36 |
+
|
| 37 |
+
steps:
|
| 38 |
+
- name: Checkout Code
|
| 39 |
+
uses: actions/checkout@v4
|
| 40 |
+
|
| 41 |
+
- name: Set up Node.js 20.x
|
| 42 |
+
uses: actions/setup-node@v4
|
| 43 |
+
with:
|
| 44 |
+
node-version: 20
|
| 45 |
+
cache: "npm"
|
| 46 |
+
cache-dependency-path: frontend/package.json
|
| 47 |
+
|
| 48 |
+
- name: Install Frontend Dependencies
|
| 49 |
+
working-directory: ./frontend
|
| 50 |
+
run: npm ci
|
| 51 |
+
|
| 52 |
+
- name: Next.js Typecheck & Production Build
|
| 53 |
+
working-directory: ./frontend
|
| 54 |
+
run: npm run build
|
.gitignore
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python & Virtual Environments
|
| 2 |
+
.venv/
|
| 3 |
+
venv/
|
| 4 |
+
env/
|
| 5 |
+
__pycache__/
|
| 6 |
+
*.py[cod]
|
| 7 |
+
*$py.class
|
| 8 |
+
.pytest_cache/
|
| 9 |
+
*.log
|
| 10 |
+
|
| 11 |
+
# Sensitive Credentials
|
| 12 |
+
.env
|
| 13 |
+
.env.local
|
| 14 |
+
.env.*.local
|
| 15 |
+
|
| 16 |
+
# Database & Uploads
|
| 17 |
+
*.db
|
| 18 |
+
sanjeevani.db
|
| 19 |
+
uploads/*
|
| 20 |
+
!uploads/.gitkeep
|
| 21 |
+
|
| 22 |
+
# Large Pretrained ML Model Weights (>100MB exceeds GitHub push limit)
|
| 23 |
+
# Note: Cloud deployment automatically fetches 'tner/roberta-large-bc5cdr' from HuggingFace
|
| 24 |
+
models/
|
| 25 |
+
models/*
|
| 26 |
+
!models/.gitkeep
|
| 27 |
+
checkpoints/
|
| 28 |
+
checkpoints/*
|
| 29 |
+
datasets/
|
| 30 |
+
datasets/*
|
| 31 |
+
|
| 32 |
+
# Legacy Prototype Folders
|
| 33 |
+
app/
|
| 34 |
+
clinical_engine/
|
| 35 |
+
evaluation/
|
| 36 |
+
rag/
|
| 37 |
+
training/
|
| 38 |
+
|
| 39 |
+
# Node.js & Next.js Frontend
|
| 40 |
+
frontend/node_modules/
|
| 41 |
+
frontend/.next/
|
| 42 |
+
frontend/.vercel/
|
| 43 |
+
frontend/out/
|
| 44 |
+
frontend/npm-debug.log*
|
| 45 |
+
frontend/yarn-debug.log*
|
| 46 |
+
frontend/yarn-error.log*
|
| 47 |
+
|
| 48 |
+
# IDE & OS Files
|
| 49 |
+
.vscode/
|
| 50 |
+
.idea/
|
| 51 |
+
.DS_Store
|
| 52 |
+
Thumbs.db
|
README.md
CHANGED
|
@@ -1,77 +1,142 @@
|
|
| 1 |
-
#
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
builds an icon-based daily schedule for low-literacy users and gives
|
| 18 |
-
pharmacists/caregivers a one-screen summary for counselling.
|
| 19 |
-
|
| 20 |
-
## Running it
|
| 21 |
-
No install, no build step, no server required.
|
| 22 |
-
|
| 23 |
-
1. Open `index.html` directly in any modern browser (Chrome, Edge, Firefox,
|
| 24 |
-
Safari) — double-click it or drag it into a browser tab.
|
| 25 |
-
2. Pick a language, enter a patient name, and start adding medicines.
|
| 26 |
-
3. Try adding **Ramipril** and **Spironolactone** together to see a real
|
| 27 |
-
"severe" interaction alert, or try the fuzzy search with a typo like
|
| 28 |
-
"amlong" or "dolo".
|
| 29 |
-
|
| 30 |
-
Optional: serving it over a local server (e.g. `python3 -m http.server`)
|
| 31 |
-
also works and additionally enables the offline service worker cache.
|
| 32 |
-
|
| 33 |
-
## What's implemented
|
| 34 |
-
- **Fuzzy medicine search** (Levenshtein-based) — tolerant of typos, OCR
|
| 35 |
-
noise, and partial names; matches generic names, common Indian brand
|
| 36 |
-
names, and Hindi/Tamil names.
|
| 37 |
-
- **On-device OCR strip scan** via Tesseract.js (loaded from CDN when
|
| 38 |
-
online) — snap/upload a photo of a medicine strip and it's matched
|
| 39 |
-
automatically; degrades gracefully to manual search if offline.
|
| 40 |
-
- **Interaction rule engine** — 16 curated, real drug-pair interactions
|
| 41 |
-
(e.g. Warfarin+Aspirin, Ramipril+Spironolactone, Sildenafil+Isosorbide)
|
| 42 |
-
across three severity levels, each with a plain-language explanation and
|
| 43 |
-
recommendation, in three languages.
|
| 44 |
-
- **Icon-based daily schedule** — groups medicines into morning / afternoon
|
| 45 |
-
/ night with before/after-food tags, designed to be readable without
|
| 46 |
-
fluent literacy.
|
| 47 |
-
- **Voice reminders** — uses the browser's built-in Web Speech API
|
| 48 |
-
(`speechSynthesis`), no external API or cost.
|
| 49 |
-
- **Pharmacist / caregiver dashboard** — a printable one-screen summary of
|
| 50 |
-
all medicines and flagged interactions for counselling.
|
| 51 |
-
- **Offline-first** — a service worker caches the app shell after first
|
| 52 |
-
load; all clinical data ships as plain JS (`data/drugs.js`,
|
| 53 |
-
`data/interactions.js`), so the app works with zero connectivity and no
|
| 54 |
-
backend.
|
| 55 |
-
|
| 56 |
-
## Project structure
|
| 57 |
```
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
```
|
| 68 |
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
|
| 76 |
-
##
|
| 77 |
-
|
|
|
|
| 1 |
+
# SanjeevaniAI — Industry-Grade Healthcare AI Platform
|
| 2 |
+
|
| 3 |
+
[](https://fastapi.tiangolo.com)
|
| 4 |
+
[](https://pytorch.org)
|
| 5 |
+
[](https://huggingface.co/tner/roberta-large-bc5cdr)
|
| 6 |
+
[](https://nextjs.org)
|
| 7 |
+
[](https://tailwindcss.com)
|
| 8 |
+
[](https://www.docker.com)
|
| 9 |
+
[](backend/tests)
|
| 10 |
+
|
| 11 |
+
> **Clinical Notice & Positioning**: SanjeevaniAI provides **AI-assisted healthcare information and clinical decision-support insights**. It is **not an autonomous diagnostic system** and is not a substitute for direct clinical examination, laboratory diagnosis, or emergency medical care.
|
| 12 |
+
|
| 13 |
+
---
|
| 14 |
+
|
| 15 |
+
## 🏥 Architecture Overview
|
| 16 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
```
|
| 18 |
+
SanjeevaniAI Platform
|
| 19 |
+
├── Frontend (Next.js 14 + Tailwind CSS + Lucide Icons)
|
| 20 |
+
│ ├── / -> Landing page with medical disclaimer & capabilities
|
| 21 |
+
│ ├── /dashboard -> Clinical intelligence overview & metrics
|
| 22 |
+
│ ├── /ner -> Real-time local RoBERTa BC5CDR NER visualizer
|
| 23 |
+
│ ├── /reports -> Document upload, SHA-256 integrity & analysis
|
| 24 |
+
│ ├── /reports/[id] -> Structured clinical findings & raw text
|
| 25 |
+
│ ├── /assistant -> Conversational AI with emergency triage
|
| 26 |
+
│ ├── /profile -> Patient physiological profile & BMI
|
| 27 |
+
│ ├── /history -> Traceable medical timeline
|
| 28 |
+
│ ├── /admin -> System telemetry & security audit logs
|
| 29 |
+
│ └── /demo -> Guided 5-step mentor presentation walkthrough
|
| 30 |
+
│
|
| 31 |
+
├── Backend (FastAPI + Async SQLAlchemy + PyTorch)
|
| 32 |
+
│ ├── Core & Security -> JWT, bcrypt hashing, RBAC, Request ID, CSP headers
|
| 33 |
+
│ ├── ML Engine -> Local RoBERTa-large BC5CDR (355M params, CUDA/CPU)
|
| 34 |
+
│ ├── Document Pipeline-> PDF/DOCX/TXT parser, chunker, entity aggregator
|
| 35 |
+
│ ├── AI Assistant -> Multi-provider LLM (Gemini + Mock) & triage engine
|
| 36 |
+
│ └── Persistence -> Async SQLite / PostgreSQL with audit trail
|
| 37 |
```
|
| 38 |
|
| 39 |
+
---
|
| 40 |
+
|
| 41 |
+
## ⚡ Quick Start
|
| 42 |
+
|
| 43 |
+
### 1. Prerequisites
|
| 44 |
+
- **Python**: 3.10+ (Recommended: Python 3.11)
|
| 45 |
+
- **Node.js**: 18.x or 20+ (with npm)
|
| 46 |
+
- **NVIDIA GPU (Optional)**: CUDA 11.8+ for accelerated inference (CPU fallback automatic)
|
| 47 |
+
- **Pretrained NER Model**: Stored locally in `models/bc5cdr-ner`
|
| 48 |
+
|
| 49 |
+
### 2. Backend Setup
|
| 50 |
+
```bash
|
| 51 |
+
# Activate virtual environment
|
| 52 |
+
python -m venv .venv
|
| 53 |
+
.\.venv\Scripts\Activate.ps1 # Windows PowerShell
|
| 54 |
+
# source .venv/bin/activate # Linux / macOS
|
| 55 |
+
|
| 56 |
+
# Install dependencies
|
| 57 |
+
pip install -r requirements.txt
|
| 58 |
+
|
| 59 |
+
# Run synthetic demo database seeder
|
| 60 |
+
python scripts/seed_demo_data.py
|
| 61 |
+
|
| 62 |
+
# Launch FastAPI backend
|
| 63 |
+
uvicorn backend.app.main:app --host 0.0.0.0 --port 8000 --reload
|
| 64 |
+
```
|
| 65 |
+
*API Swagger UI will be live at `http://localhost:8000/docs`.*
|
| 66 |
+
|
| 67 |
+
### 3. Frontend Setup
|
| 68 |
+
```bash
|
| 69 |
+
cd frontend
|
| 70 |
+
|
| 71 |
+
# Install npm dependencies
|
| 72 |
+
npm install
|
| 73 |
+
|
| 74 |
+
# Run Next.js development server
|
| 75 |
+
npm run dev
|
| 76 |
+
```
|
| 77 |
+
*Application UI will be live at `http://localhost:3000`.*
|
| 78 |
+
|
| 79 |
+
---
|
| 80 |
+
|
| 81 |
+
## 🐳 Docker Deployment
|
| 82 |
+
|
| 83 |
+
To launch the full containerized stack:
|
| 84 |
+
```bash
|
| 85 |
+
docker-compose up --build
|
| 86 |
+
```
|
| 87 |
+
- Frontend: `http://localhost:3000`
|
| 88 |
+
- Backend API: `http://localhost:8000`
|
| 89 |
+
- Health Check: `http://localhost:8000/api/v1/health`
|
| 90 |
+
|
| 91 |
+
---
|
| 92 |
+
|
| 93 |
+
## 🧪 Test Suite & Verification
|
| 94 |
+
|
| 95 |
+
The platform includes a comprehensive automated test suite in `backend/tests/`:
|
| 96 |
+
|
| 97 |
+
```bash
|
| 98 |
+
pytest backend/tests -v
|
| 99 |
+
```
|
| 100 |
+
|
| 101 |
+
### Verified Test Results (16/16 Passing):
|
| 102 |
+
- `test_auth.py`: User registration, login, JWT validation, and invalid credential handling.
|
| 103 |
+
- `test_ner.py`: BC5CDR model info, entity extraction, character span alignment, and 422 validation.
|
| 104 |
+
- `test_documents.py`: File upload, SHA-256 fingerprinting, raw text extraction, and entity linking.
|
| 105 |
+
- `test_chat.py`: Multi-turn consultation, patient profile injection, and heuristic red-flag emergency detection.
|
| 106 |
+
- `test_profile.py`: Patient profile updates, vitals calculations, and RBAC admin permission enforcement.
|
| 107 |
+
- `test_health.py`: Liveness and readiness probes.
|
| 108 |
+
|
| 109 |
+
---
|
| 110 |
+
|
| 111 |
+
## 👨🏫 Mentor Demonstration Credentials
|
| 112 |
+
|
| 113 |
+
Use the **"Demo Roles"** dropdown in the navigation bar or log in with these pre-seeded accounts:
|
| 114 |
+
|
| 115 |
+
| Account | Email | Password | Role / Access Level |
|
| 116 |
+
| :--- | :--- | :--- | :--- |
|
| 117 |
+
| **Patient** | `demo.patient@sanjeevani.ai` | `DemoPatient2026!` | Health profile, documents, AI consultation |
|
| 118 |
+
| **Doctor** | `demo.doctor@sanjeevani.ai` | `DemoDoctor2026!` | Clinical decision support & document review |
|
| 119 |
+
| **Administrator** | `demo.admin@sanjeevani.ai` | `DemoAdmin2026!` | Platform telemetry, model status, security audit logs |
|
| 120 |
+
|
| 121 |
+
---
|
| 122 |
+
|
| 123 |
+
## 📚 Technical Documentation
|
| 124 |
+
|
| 125 |
+
- 📐 [**System Architecture & Mermaid Diagrams**](docs/ARCHITECTURE.md)
|
| 126 |
+
- 🔌 [**REST API Catalog & OpenAPI Specification**](docs/API.md)
|
| 127 |
+
- 🧠 [**Machine Learning & Local RoBERTa BC5CDR Specs**](docs/ML_MODELS.md)
|
| 128 |
+
- 🎯 [**10-Minute Mentor Presentation Script**](docs/MENTOR_DEMO.md)
|
| 129 |
+
- 🔍 [**Initial Project Audit & Gap Analysis**](docs/PROJECT_AUDIT.md)
|
| 130 |
+
|
| 131 |
+
---
|
| 132 |
+
|
| 133 |
+
## 🛡️ Medical Safety & Privacy Disclaimers
|
| 134 |
+
|
| 135 |
+
1. **AI-Assisted Decision Support**: SanjeevaniAI generates educational insights to assist healthcare providers and patients. It does not issue binding clinical diagnoses or prescribe treatment plans.
|
| 136 |
+
2. **Emergency Triage Protocol**: If severe acute symptoms (e.g. crushing chest pain, difficulty breathing, stroke symptoms) are entered, the platform immediately presents an emergency escalation alert advising immediate contact with emergency medical services (911 / 112 / 108).
|
| 137 |
+
3. **Data Security**: Uploaded files are fingerprinted with SHA-256, sensitive credentials hashed with direct bcrypt, and all administrative events logged in an immutable audit table.
|
| 138 |
+
|
| 139 |
+
---
|
| 140 |
|
| 141 |
+
## 📄 License
|
| 142 |
+
MIT License. Developed for healthcare AI intelligence and clinical decision-support research.
|
backend/Dockerfile
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# syntax=docker/dockerfile:1
|
| 2 |
+
FROM python:3.11-slim AS base
|
| 3 |
+
|
| 4 |
+
ENV PYTHONUNBUFFERED=1 \
|
| 5 |
+
PYTHONDONTWRITEBYTECODE=1 \
|
| 6 |
+
PIP_NO_CACHE_DIR=1 \
|
| 7 |
+
PIP_DISABLE_PIP_VERSION_CHECK=1
|
| 8 |
+
|
| 9 |
+
WORKDIR /app
|
| 10 |
+
|
| 11 |
+
# Install system dependencies (build-essential, curl)
|
| 12 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 13 |
+
build-essential \
|
| 14 |
+
curl \
|
| 15 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 16 |
+
|
| 17 |
+
# Install Python requirements
|
| 18 |
+
COPY requirements.txt .
|
| 19 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 20 |
+
|
| 21 |
+
# Copy application source
|
| 22 |
+
COPY backend /app/backend
|
| 23 |
+
COPY models /app/models
|
| 24 |
+
COPY uploads /app/uploads
|
| 25 |
+
|
| 26 |
+
# Expose port
|
| 27 |
+
EXPOSE 8000
|
| 28 |
+
|
| 29 |
+
# Healthcheck
|
| 30 |
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
| 31 |
+
CMD curl -f http://localhost:8000/api/v1/health || exit 1
|
| 32 |
+
|
| 33 |
+
# Run Uvicorn
|
| 34 |
+
CMD ["uvicorn", "backend.app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
backend/app/api/deps.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Generator, Optional, List
|
| 2 |
+
from fastapi import Depends, HTTPException, status, Request
|
| 3 |
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 4 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 5 |
+
from backend.app.core.database import get_db
|
| 6 |
+
from backend.app.core.security import decode_token, UserRole
|
| 7 |
+
from backend.app.core.exceptions import AuthenticationError, PermissionDeniedError
|
| 8 |
+
from backend.app.repositories.user_repo import UserRepository
|
| 9 |
+
from backend.app.models.user import User
|
| 10 |
+
|
| 11 |
+
security_scheme = HTTPBearer(auto_error=False)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
async def get_current_user(
|
| 15 |
+
token_auth: Optional[HTTPAuthorizationCredentials] = Depends(security_scheme),
|
| 16 |
+
db: AsyncSession = Depends(get_db),
|
| 17 |
+
) -> User:
|
| 18 |
+
if not token_auth or not token_auth.credentials:
|
| 19 |
+
raise AuthenticationError(message="Authentication token missing.")
|
| 20 |
+
|
| 21 |
+
payload = decode_token(token_auth.credentials)
|
| 22 |
+
if not payload or payload.get("type") != "access":
|
| 23 |
+
raise AuthenticationError(message="Invalid or expired access token.")
|
| 24 |
+
|
| 25 |
+
user_id = payload.get("sub")
|
| 26 |
+
if not user_id:
|
| 27 |
+
raise AuthenticationError(message="Token missing subject identifier.")
|
| 28 |
+
|
| 29 |
+
user_repo = UserRepository(db)
|
| 30 |
+
user = await user_repo.get_by_id(user_id)
|
| 31 |
+
if not user:
|
| 32 |
+
raise AuthenticationError(message="User no longer exists.")
|
| 33 |
+
|
| 34 |
+
if not user.is_active:
|
| 35 |
+
raise AuthenticationError(message="User account is deactivated.")
|
| 36 |
+
|
| 37 |
+
return user
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def require_roles(allowed_roles: List[UserRole]):
|
| 41 |
+
async def role_checker(current_user: User = Depends(get_current_user)) -> User:
|
| 42 |
+
user_role = current_user.role
|
| 43 |
+
role_values = [r.value for r in allowed_roles]
|
| 44 |
+
if user_role not in role_values and user_role != UserRole.ADMIN.value:
|
| 45 |
+
raise PermissionDeniedError(message="You do not have permission to access this resource.")
|
| 46 |
+
return current_user
|
| 47 |
+
|
| 48 |
+
return role_checker
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
async def get_current_admin(current_user: User = Depends(get_current_user)) -> User:
|
| 52 |
+
if current_user.role != UserRole.ADMIN.value:
|
| 53 |
+
raise PermissionDeniedError(message="Administrative privileges required.")
|
| 54 |
+
return current_user
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def get_client_ip(request: Request) -> Optional[str]:
|
| 58 |
+
forwarded = request.headers.get("X-Forwarded-For")
|
| 59 |
+
if forwarded:
|
| 60 |
+
return forwarded.split(",")[0].strip()
|
| 61 |
+
return request.client.host if request.client else None
|
backend/app/api/v1/endpoints/admin.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List
|
| 2 |
+
from fastapi import APIRouter, Depends
|
| 3 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 4 |
+
from backend.app.core.database import get_db
|
| 5 |
+
from backend.app.schemas.admin import AdminStatsResponse, AuditLogResponse
|
| 6 |
+
from backend.app.schemas.common import BaseResponse
|
| 7 |
+
from backend.app.repositories.audit_repo import AuditRepository
|
| 8 |
+
from backend.app.ml.manager import model_manager
|
| 9 |
+
from backend.app.api.deps import get_current_admin
|
| 10 |
+
from backend.app.models.user import User
|
| 11 |
+
|
| 12 |
+
router = APIRouter()
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@router.get("/stats", response_model=BaseResponse[AdminStatsResponse])
|
| 16 |
+
async def get_admin_stats(
|
| 17 |
+
current_admin: User = Depends(get_current_admin),
|
| 18 |
+
db: AsyncSession = Depends(get_db),
|
| 19 |
+
):
|
| 20 |
+
"""Retrieve platform usage statistics, throughput metrics, and model status."""
|
| 21 |
+
audit_repo = AuditRepository(db)
|
| 22 |
+
stats = await audit_repo.get_system_statistics()
|
| 23 |
+
model_status = model_manager.get_status()
|
| 24 |
+
|
| 25 |
+
admin_stats = AdminStatsResponse(
|
| 26 |
+
total_users=stats["total_users"],
|
| 27 |
+
active_users=stats["active_users"],
|
| 28 |
+
total_documents_processed=stats["total_documents_processed"],
|
| 29 |
+
total_ner_requests=stats["total_ner_requests"],
|
| 30 |
+
total_chat_queries=stats["total_chat_queries"],
|
| 31 |
+
model_status=model_status,
|
| 32 |
+
system_health="Operational",
|
| 33 |
+
)
|
| 34 |
+
return BaseResponse(success=True, data=admin_stats)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@router.get("/audit-logs", response_model=BaseResponse[List[AuditLogResponse]])
|
| 38 |
+
async def get_audit_logs(
|
| 39 |
+
limit: int = 100,
|
| 40 |
+
current_admin: User = Depends(get_current_admin),
|
| 41 |
+
db: AsyncSession = Depends(get_db),
|
| 42 |
+
):
|
| 43 |
+
"""Retrieve system security and access audit trail."""
|
| 44 |
+
audit_repo = AuditRepository(db)
|
| 45 |
+
logs = await audit_repo.get_admin_audit_logs(limit=limit)
|
| 46 |
+
response_list = [AuditLogResponse.model_validate(log) for log in logs]
|
| 47 |
+
return BaseResponse(success=True, data=response_list)
|
backend/app/api/v1/endpoints/auth.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, Request
|
| 2 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 3 |
+
from backend.app.core.database import get_db
|
| 4 |
+
from backend.app.schemas.user import UserCreate, UserLogin, UserResponse
|
| 5 |
+
from backend.app.schemas.token import TokenResponse, RefreshTokenRequest
|
| 6 |
+
from backend.app.schemas.common import BaseResponse
|
| 7 |
+
from backend.app.services.auth_service import AuthService
|
| 8 |
+
from backend.app.api.deps import get_current_user, get_client_ip
|
| 9 |
+
from backend.app.models.user import User
|
| 10 |
+
|
| 11 |
+
router = APIRouter()
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@router.post("/register", response_model=BaseResponse[TokenResponse])
|
| 15 |
+
async def register(
|
| 16 |
+
user_in: UserCreate,
|
| 17 |
+
request: Request,
|
| 18 |
+
db: AsyncSession = Depends(get_db),
|
| 19 |
+
):
|
| 20 |
+
"""Register a new user and receive initial access/refresh tokens."""
|
| 21 |
+
ip = get_client_ip(request)
|
| 22 |
+
auth_service = AuthService(db)
|
| 23 |
+
user, tokens = await auth_service.register(user_in, ip_address=ip)
|
| 24 |
+
return BaseResponse(
|
| 25 |
+
success=True,
|
| 26 |
+
message="User registered successfully.",
|
| 27 |
+
data=tokens,
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@router.post("/login", response_model=BaseResponse[TokenResponse])
|
| 32 |
+
async def login(
|
| 33 |
+
login_in: UserLogin,
|
| 34 |
+
request: Request,
|
| 35 |
+
db: AsyncSession = Depends(get_db),
|
| 36 |
+
):
|
| 37 |
+
"""Authenticate with email and password to receive JWT credentials."""
|
| 38 |
+
ip = get_client_ip(request)
|
| 39 |
+
agent = request.headers.get("User-Agent")
|
| 40 |
+
auth_service = AuthService(db)
|
| 41 |
+
tokens = await auth_service.login(login_in, ip_address=ip, user_agent=agent)
|
| 42 |
+
return BaseResponse(
|
| 43 |
+
success=True,
|
| 44 |
+
message="Login successful.",
|
| 45 |
+
data=tokens,
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@router.post("/refresh", response_model=BaseResponse[TokenResponse])
|
| 50 |
+
async def refresh_token(
|
| 51 |
+
refresh_in: RefreshTokenRequest,
|
| 52 |
+
db: AsyncSession = Depends(get_db),
|
| 53 |
+
):
|
| 54 |
+
"""Exchange a valid refresh token for a new access/refresh token pair."""
|
| 55 |
+
auth_service = AuthService(db)
|
| 56 |
+
tokens = await auth_service.refresh_tokens(refresh_in.refresh_token)
|
| 57 |
+
return BaseResponse(
|
| 58 |
+
success=True,
|
| 59 |
+
message="Token refreshed successfully.",
|
| 60 |
+
data=tokens,
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@router.get("/me", response_model=BaseResponse[UserResponse])
|
| 65 |
+
async def get_me(
|
| 66 |
+
current_user: User = Depends(get_current_user),
|
| 67 |
+
):
|
| 68 |
+
"""Retrieve the currently authenticated user's profile metadata."""
|
| 69 |
+
return BaseResponse(
|
| 70 |
+
success=True,
|
| 71 |
+
data=UserResponse.model_validate(current_user),
|
| 72 |
+
)
|
backend/app/api/v1/endpoints/chat.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from typing import List
|
| 3 |
+
from fastapi import APIRouter, Depends, Request
|
| 4 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 5 |
+
from backend.app.core.database import get_db
|
| 6 |
+
from backend.app.schemas.chat import (
|
| 7 |
+
ChatMessageCreate,
|
| 8 |
+
ChatMessageResponse,
|
| 9 |
+
ConversationResponse,
|
| 10 |
+
ChatCompletionResponse,
|
| 11 |
+
AIStructuredOutput,
|
| 12 |
+
)
|
| 13 |
+
from backend.app.schemas.common import BaseResponse, MEDICAL_DISCLAIMER
|
| 14 |
+
from backend.app.models.conversation import AIConversation, AIMessage
|
| 15 |
+
from backend.app.models.user import User
|
| 16 |
+
from backend.app.repositories.chat_repo import ChatRepository
|
| 17 |
+
from backend.app.repositories.user_repo import UserRepository
|
| 18 |
+
from backend.app.repositories.audit_repo import AuditRepository
|
| 19 |
+
from backend.app.services.llm.service import llm_service
|
| 20 |
+
from backend.app.api.deps import get_current_user
|
| 21 |
+
from backend.app.core.exceptions import ResourceNotFoundError
|
| 22 |
+
|
| 23 |
+
router = APIRouter()
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _format_message(msg: AIMessage) -> ChatMessageResponse:
|
| 27 |
+
structured = None
|
| 28 |
+
if msg.structured_data:
|
| 29 |
+
try:
|
| 30 |
+
parsed = json.loads(msg.structured_data)
|
| 31 |
+
structured = AIStructuredOutput(**parsed)
|
| 32 |
+
except Exception:
|
| 33 |
+
pass
|
| 34 |
+
|
| 35 |
+
return ChatMessageResponse(
|
| 36 |
+
id=msg.id,
|
| 37 |
+
conversation_id=msg.conversation_id,
|
| 38 |
+
role=msg.role,
|
| 39 |
+
content=msg.content,
|
| 40 |
+
structured_data=structured,
|
| 41 |
+
model_provider=msg.model_provider,
|
| 42 |
+
created_at=msg.created_at,
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@router.post("/message", response_model=BaseResponse[ChatCompletionResponse])
|
| 47 |
+
async def send_chat_message(
|
| 48 |
+
chat_in: ChatMessageCreate,
|
| 49 |
+
current_user: User = Depends(get_current_user),
|
| 50 |
+
db: AsyncSession = Depends(get_db),
|
| 51 |
+
):
|
| 52 |
+
"""
|
| 53 |
+
Send a medical query to the AI Assistant.
|
| 54 |
+
Generates structured healthcare decision-support insights, recommendations, and emergency triage alerts.
|
| 55 |
+
"""
|
| 56 |
+
chat_repo = ChatRepository(db)
|
| 57 |
+
user_repo = UserRepository(db)
|
| 58 |
+
audit_repo = AuditRepository(db)
|
| 59 |
+
|
| 60 |
+
# 1. Resolve or Create Conversation
|
| 61 |
+
history_messages = []
|
| 62 |
+
if chat_in.conversation_id:
|
| 63 |
+
conversation = await chat_repo.get_conversation_with_messages(
|
| 64 |
+
chat_in.conversation_id, current_user.id
|
| 65 |
+
)
|
| 66 |
+
if conversation:
|
| 67 |
+
history_messages = conversation.messages or []
|
| 68 |
+
else:
|
| 69 |
+
conversation = AIConversation(
|
| 70 |
+
id=chat_in.conversation_id,
|
| 71 |
+
user_id=current_user.id,
|
| 72 |
+
title=chat_in.message[:45] + ("..." if len(chat_in.message) > 45 else ""),
|
| 73 |
+
)
|
| 74 |
+
conversation = await chat_repo.create(conversation)
|
| 75 |
+
else:
|
| 76 |
+
title_snippet = chat_in.message[:45] + ("..." if len(chat_in.message) > 45 else "")
|
| 77 |
+
conversation = AIConversation(
|
| 78 |
+
user_id=current_user.id,
|
| 79 |
+
title=title_snippet or "Medical Consultation",
|
| 80 |
+
)
|
| 81 |
+
conversation = await chat_repo.create(conversation)
|
| 82 |
+
|
| 83 |
+
# 2. Save User Message
|
| 84 |
+
user_msg = AIMessage(
|
| 85 |
+
conversation_id=conversation.id,
|
| 86 |
+
role="user",
|
| 87 |
+
content=chat_in.message,
|
| 88 |
+
)
|
| 89 |
+
await chat_repo.add_message(user_msg)
|
| 90 |
+
|
| 91 |
+
# 3. Retrieve Patient Health Profile Context
|
| 92 |
+
patient_context = {}
|
| 93 |
+
profile = await user_repo.get_profile_by_user_id(current_user.id)
|
| 94 |
+
if profile:
|
| 95 |
+
patient_context = {
|
| 96 |
+
"age": profile.age,
|
| 97 |
+
"gender": profile.gender,
|
| 98 |
+
"blood_group": profile.blood_group,
|
| 99 |
+
"known_allergies": json.loads(profile.known_allergies) if profile.known_allergies else [],
|
| 100 |
+
"chronic_conditions": json.loads(profile.chronic_conditions) if profile.chronic_conditions else [],
|
| 101 |
+
"current_medications": json.loads(profile.current_medications) if profile.current_medications else [],
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
# 4. Format Chat History
|
| 105 |
+
history = [
|
| 106 |
+
{"role": m.role, "content": m.content}
|
| 107 |
+
for m in history_messages[-6:]
|
| 108 |
+
]
|
| 109 |
+
|
| 110 |
+
# 5. Execute LLM Consultation
|
| 111 |
+
structured_output, provider_name = await llm_service.consult(
|
| 112 |
+
query=chat_in.message,
|
| 113 |
+
chat_history=history,
|
| 114 |
+
patient_context=patient_context,
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
# 6. Save Assistant Response
|
| 118 |
+
assistant_msg = AIMessage(
|
| 119 |
+
conversation_id=conversation.id,
|
| 120 |
+
role="assistant",
|
| 121 |
+
content=structured_output.summary,
|
| 122 |
+
structured_data=json.dumps(structured_output.model_dump()),
|
| 123 |
+
model_provider=provider_name,
|
| 124 |
+
)
|
| 125 |
+
saved_assistant_msg = await chat_repo.add_message(assistant_msg)
|
| 126 |
+
|
| 127 |
+
# 7. Add Audit & Timeline
|
| 128 |
+
await audit_repo.add_history(
|
| 129 |
+
user_id=current_user.id,
|
| 130 |
+
action_type="CHAT",
|
| 131 |
+
description=f"Consulted AI Assistant: '{chat_in.message[:40]}...'",
|
| 132 |
+
reference_id=conversation.id,
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
return BaseResponse(
|
| 136 |
+
success=True,
|
| 137 |
+
data=ChatCompletionResponse(
|
| 138 |
+
conversation_id=conversation.id,
|
| 139 |
+
message=_format_message(saved_assistant_msg),
|
| 140 |
+
disclaimer=MEDICAL_DISCLAIMER,
|
| 141 |
+
),
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
@router.get("/conversations", response_model=BaseResponse[List[ConversationResponse]])
|
| 146 |
+
async def list_conversations(
|
| 147 |
+
current_user: User = Depends(get_current_user),
|
| 148 |
+
db: AsyncSession = Depends(get_db),
|
| 149 |
+
):
|
| 150 |
+
"""Retrieve all historical AI consultation threads for the current user."""
|
| 151 |
+
chat_repo = ChatRepository(db)
|
| 152 |
+
convs = await chat_repo.get_user_conversations(current_user.id)
|
| 153 |
+
response_list = [
|
| 154 |
+
ConversationResponse(
|
| 155 |
+
id=c.id,
|
| 156 |
+
user_id=c.user_id,
|
| 157 |
+
title=c.title,
|
| 158 |
+
messages=[_format_message(m) for m in (c.messages or [])],
|
| 159 |
+
created_at=c.created_at,
|
| 160 |
+
updated_at=c.updated_at,
|
| 161 |
+
)
|
| 162 |
+
for c in convs
|
| 163 |
+
]
|
| 164 |
+
return BaseResponse(success=True, data=response_list)
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
@router.get("/conversations/{conversation_id}", response_model=BaseResponse[ConversationResponse])
|
| 168 |
+
async def get_conversation(
|
| 169 |
+
conversation_id: str,
|
| 170 |
+
current_user: User = Depends(get_current_user),
|
| 171 |
+
db: AsyncSession = Depends(get_db),
|
| 172 |
+
):
|
| 173 |
+
"""Fetch complete message history for a specific conversation."""
|
| 174 |
+
chat_repo = ChatRepository(db)
|
| 175 |
+
c = await chat_repo.get_conversation_with_messages(conversation_id, current_user.id)
|
| 176 |
+
if not c:
|
| 177 |
+
raise ResourceNotFoundError(resource="Conversation", resource_id=conversation_id)
|
| 178 |
+
|
| 179 |
+
return BaseResponse(
|
| 180 |
+
success=True,
|
| 181 |
+
data=ConversationResponse(
|
| 182 |
+
id=c.id,
|
| 183 |
+
user_id=c.user_id,
|
| 184 |
+
title=c.title,
|
| 185 |
+
messages=[_format_message(m) for m in (c.messages or [])],
|
| 186 |
+
created_at=c.created_at,
|
| 187 |
+
updated_at=c.updated_at,
|
| 188 |
+
),
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
@router.delete("/conversations/{conversation_id}", response_model=BaseResponse[dict])
|
| 193 |
+
async def delete_conversation(
|
| 194 |
+
conversation_id: str,
|
| 195 |
+
current_user: User = Depends(get_current_user),
|
| 196 |
+
db: AsyncSession = Depends(get_db),
|
| 197 |
+
):
|
| 198 |
+
"""Delete a conversation thread and its message history."""
|
| 199 |
+
chat_repo = ChatRepository(db)
|
| 200 |
+
conv = await chat_repo.get_conversation_with_messages(conversation_id, current_user.id)
|
| 201 |
+
if not conv:
|
| 202 |
+
raise ResourceNotFoundError(resource="Conversation", resource_id=conversation_id)
|
| 203 |
+
|
| 204 |
+
await chat_repo.delete(conversation_id)
|
| 205 |
+
return BaseResponse(
|
| 206 |
+
success=True,
|
| 207 |
+
message="Conversation deleted.",
|
| 208 |
+
data={"deleted_id": conversation_id},
|
| 209 |
+
)
|
backend/app/api/v1/endpoints/documents.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from typing import List
|
| 3 |
+
from fastapi import APIRouter, Depends, UploadFile, File, Request, status
|
| 4 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 5 |
+
from backend.app.core.database import get_db
|
| 6 |
+
from backend.app.schemas.document import MedicalDocumentResponse, DocumentAnalysisResponse, DocumentUploadResponse
|
| 7 |
+
from backend.app.schemas.ner import NEREntity
|
| 8 |
+
from backend.app.schemas.common import BaseResponse
|
| 9 |
+
from backend.app.services.document_service import DocumentService
|
| 10 |
+
from backend.app.repositories.document_repo import DocumentRepository
|
| 11 |
+
from backend.app.api.deps import get_current_user, get_client_ip
|
| 12 |
+
from backend.app.models.user import User
|
| 13 |
+
from backend.app.models.document import MedicalDocument
|
| 14 |
+
from backend.app.core.exceptions import ResourceNotFoundError, PermissionDeniedError
|
| 15 |
+
|
| 16 |
+
router = APIRouter()
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _format_document_response(doc: MedicalDocument) -> MedicalDocumentResponse:
|
| 20 |
+
analysis_resp = None
|
| 21 |
+
if doc.analysis:
|
| 22 |
+
analysis = doc.analysis
|
| 23 |
+
findings = json.loads(analysis.important_findings) if analysis.important_findings else []
|
| 24 |
+
conditions = json.loads(analysis.detected_conditions) if analysis.detected_conditions else []
|
| 25 |
+
medications = json.loads(analysis.detected_medications) if analysis.detected_medications else []
|
| 26 |
+
|
| 27 |
+
entities = [
|
| 28 |
+
NEREntity(
|
| 29 |
+
text=e.text,
|
| 30 |
+
label=e.label,
|
| 31 |
+
start=e.start_offset,
|
| 32 |
+
end=e.end_offset,
|
| 33 |
+
confidence=e.confidence,
|
| 34 |
+
model=e.model_name,
|
| 35 |
+
)
|
| 36 |
+
for e in (analysis.entities or [])
|
| 37 |
+
]
|
| 38 |
+
|
| 39 |
+
analysis_resp = DocumentAnalysisResponse(
|
| 40 |
+
id=analysis.id,
|
| 41 |
+
document_id=analysis.document_id,
|
| 42 |
+
raw_text=analysis.raw_text,
|
| 43 |
+
cleaned_text=analysis.cleaned_text,
|
| 44 |
+
summary=analysis.summary,
|
| 45 |
+
important_findings=findings,
|
| 46 |
+
detected_conditions=conditions,
|
| 47 |
+
detected_medications=medications,
|
| 48 |
+
clinical_recommendations=analysis.clinical_recommendations,
|
| 49 |
+
entities=entities,
|
| 50 |
+
processed_at=analysis.processed_at,
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
return MedicalDocumentResponse(
|
| 54 |
+
id=doc.id,
|
| 55 |
+
user_id=doc.user_id,
|
| 56 |
+
filename=doc.filename,
|
| 57 |
+
original_filename=doc.original_filename,
|
| 58 |
+
file_type=doc.file_type,
|
| 59 |
+
file_size=doc.file_size,
|
| 60 |
+
status=doc.status,
|
| 61 |
+
error_message=doc.error_message,
|
| 62 |
+
analysis=analysis_resp,
|
| 63 |
+
created_at=doc.created_at,
|
| 64 |
+
updated_at=doc.updated_at,
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@router.post("/upload", response_model=BaseResponse[MedicalDocumentResponse])
|
| 69 |
+
async def upload_medical_document(
|
| 70 |
+
request: Request,
|
| 71 |
+
file: UploadFile = File(...),
|
| 72 |
+
current_user: User = Depends(get_current_user),
|
| 73 |
+
db: AsyncSession = Depends(get_db),
|
| 74 |
+
):
|
| 75 |
+
"""
|
| 76 |
+
Securely upload a medical report (PDF, DOCX, TXT), perform text extraction,
|
| 77 |
+
biomedical entity recognition, and clinical summarization.
|
| 78 |
+
"""
|
| 79 |
+
ip = get_client_ip(request)
|
| 80 |
+
doc_service = DocumentService(db)
|
| 81 |
+
doc = await doc_service.process_document_upload(file=file, user_id=current_user.id, ip_address=ip)
|
| 82 |
+
formatted = _format_document_response(doc)
|
| 83 |
+
return BaseResponse(
|
| 84 |
+
success=True,
|
| 85 |
+
message="Document uploaded and analyzed successfully.",
|
| 86 |
+
data=formatted,
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
@router.get("", response_model=BaseResponse[List[MedicalDocumentResponse]])
|
| 91 |
+
async def list_documents(
|
| 92 |
+
current_user: User = Depends(get_current_user),
|
| 93 |
+
db: AsyncSession = Depends(get_db),
|
| 94 |
+
):
|
| 95 |
+
"""Retrieve all medical reports and document analyses uploaded by the current user."""
|
| 96 |
+
doc_repo = DocumentRepository(db)
|
| 97 |
+
docs = await doc_repo.get_user_documents(current_user.id)
|
| 98 |
+
formatted_docs = [_format_document_response(d) for d in docs]
|
| 99 |
+
return BaseResponse(
|
| 100 |
+
success=True,
|
| 101 |
+
data=formatted_docs,
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
@router.get("/{document_id}", response_model=BaseResponse[MedicalDocumentResponse])
|
| 106 |
+
async def get_document(
|
| 107 |
+
document_id: str,
|
| 108 |
+
current_user: User = Depends(get_current_user),
|
| 109 |
+
db: AsyncSession = Depends(get_db),
|
| 110 |
+
):
|
| 111 |
+
"""Fetch detailed analysis, extracted entities, and summary for a specific document."""
|
| 112 |
+
doc_repo = DocumentRepository(db)
|
| 113 |
+
doc = await doc_repo.get_document_details(document_id, user_id=current_user.id)
|
| 114 |
+
if not doc:
|
| 115 |
+
raise ResourceNotFoundError(resource="Medical Document", resource_id=document_id)
|
| 116 |
+
return BaseResponse(
|
| 117 |
+
success=True,
|
| 118 |
+
data=_format_document_response(doc),
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
@router.delete("/{document_id}", response_model=BaseResponse[dict])
|
| 123 |
+
async def delete_document(
|
| 124 |
+
document_id: str,
|
| 125 |
+
current_user: User = Depends(get_current_user),
|
| 126 |
+
db: AsyncSession = Depends(get_db),
|
| 127 |
+
):
|
| 128 |
+
"""Delete a medical report and all associated entity analysis records."""
|
| 129 |
+
doc_repo = DocumentRepository(db)
|
| 130 |
+
doc = await doc_repo.get_document_details(document_id, user_id=current_user.id)
|
| 131 |
+
if not doc:
|
| 132 |
+
raise ResourceNotFoundError(resource="Medical Document", resource_id=document_id)
|
| 133 |
+
|
| 134 |
+
await doc_repo.delete(document_id)
|
| 135 |
+
return BaseResponse(
|
| 136 |
+
success=True,
|
| 137 |
+
message="Document deleted successfully.",
|
| 138 |
+
data={"deleted_id": document_id},
|
| 139 |
+
)
|
backend/app/api/v1/endpoints/health.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime, timezone
|
| 2 |
+
from fastapi import APIRouter, Depends
|
| 3 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 4 |
+
from sqlalchemy import text
|
| 5 |
+
from backend.app.core.database import get_db
|
| 6 |
+
from backend.app.core.config import settings
|
| 7 |
+
from backend.app.ml.manager import model_manager
|
| 8 |
+
|
| 9 |
+
router = APIRouter()
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@router.get("/health")
|
| 13 |
+
async def health_check():
|
| 14 |
+
"""Liveness probe: verifies basic service responsiveness."""
|
| 15 |
+
return {
|
| 16 |
+
"status": "healthy",
|
| 17 |
+
"app": settings.APP_NAME,
|
| 18 |
+
"version": settings.APP_VERSION,
|
| 19 |
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@router.get("/ready")
|
| 24 |
+
async def readiness_check(db: AsyncSession = Depends(get_db)):
|
| 25 |
+
"""Readiness probe: checks database connectivity and local ML model availability."""
|
| 26 |
+
db_ok = False
|
| 27 |
+
try:
|
| 28 |
+
await db.execute(text("SELECT 1"))
|
| 29 |
+
db_ok = True
|
| 30 |
+
except Exception:
|
| 31 |
+
db_ok = False
|
| 32 |
+
|
| 33 |
+
model_status = model_manager.get_status()
|
| 34 |
+
models_ok = model_status.get("initialized", False)
|
| 35 |
+
|
| 36 |
+
is_ready = db_ok
|
| 37 |
+
|
| 38 |
+
return {
|
| 39 |
+
"status": "ready" if is_ready else "degraded",
|
| 40 |
+
"database": "connected" if db_ok else "disconnected",
|
| 41 |
+
"models": model_status,
|
| 42 |
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
| 43 |
+
}
|
backend/app/api/v1/endpoints/history.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List
|
| 2 |
+
from fastapi import APIRouter, Depends
|
| 3 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 4 |
+
from backend.app.core.database import get_db
|
| 5 |
+
from backend.app.schemas.admin import AnalysisHistoryResponse
|
| 6 |
+
from backend.app.schemas.common import BaseResponse
|
| 7 |
+
from backend.app.repositories.audit_repo import AuditRepository
|
| 8 |
+
from backend.app.api.deps import get_current_user
|
| 9 |
+
from backend.app.models.user import User
|
| 10 |
+
|
| 11 |
+
router = APIRouter()
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@router.get("", response_model=BaseResponse[List[AnalysisHistoryResponse]])
|
| 15 |
+
async def get_user_history(
|
| 16 |
+
limit: int = 50,
|
| 17 |
+
current_user: User = Depends(get_current_user),
|
| 18 |
+
db: AsyncSession = Depends(get_db),
|
| 19 |
+
):
|
| 20 |
+
"""Retrieve chronological medical and activity history for the current user."""
|
| 21 |
+
audit_repo = AuditRepository(db)
|
| 22 |
+
records = await audit_repo.get_user_history(current_user.id, limit=limit)
|
| 23 |
+
response_list = [
|
| 24 |
+
AnalysisHistoryResponse.model_validate(r) for r in records
|
| 25 |
+
]
|
| 26 |
+
return BaseResponse(success=True, data=response_list)
|
backend/app/api/v1/endpoints/ner.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
+
from typing import Optional
|
| 3 |
+
from fastapi import APIRouter, Depends, Request
|
| 4 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 5 |
+
from backend.app.core.database import get_db
|
| 6 |
+
from backend.app.schemas.ner import NERRequest, NERResponse, ModelInfo
|
| 7 |
+
from backend.app.schemas.common import BaseResponse
|
| 8 |
+
from backend.app.ml.manager import model_manager
|
| 9 |
+
from backend.app.repositories.audit_repo import AuditRepository
|
| 10 |
+
from backend.app.api.deps import security_scheme, get_client_ip
|
| 11 |
+
from backend.app.core.security import decode_token
|
| 12 |
+
|
| 13 |
+
router = APIRouter()
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@router.post("/analyze", response_model=NERResponse)
|
| 17 |
+
async def analyze_biomedical_ner(
|
| 18 |
+
request_body: NERRequest,
|
| 19 |
+
request: Request,
|
| 20 |
+
db: AsyncSession = Depends(get_db),
|
| 21 |
+
):
|
| 22 |
+
"""
|
| 23 |
+
Perform Named Entity Recognition using the locally loaded RoBERTa-large BC5CDR model.
|
| 24 |
+
Extracts CHEMICAL (medications/drugs) and DISEASE (medical conditions) entities with character offsets and confidence scores.
|
| 25 |
+
"""
|
| 26 |
+
req_id = str(uuid.uuid4())
|
| 27 |
+
ner_service = model_manager.get_ner_service()
|
| 28 |
+
result = ner_service.analyze_text(request_body, request_id=req_id)
|
| 29 |
+
|
| 30 |
+
# If an authenticated user made the request, record in history
|
| 31 |
+
auth_header = request.headers.get("Authorization")
|
| 32 |
+
if auth_header and auth_header.startswith("Bearer "):
|
| 33 |
+
token = auth_header.split(" ")[1]
|
| 34 |
+
payload = decode_token(token)
|
| 35 |
+
if payload and payload.get("sub"):
|
| 36 |
+
audit_repo = AuditRepository(db)
|
| 37 |
+
await audit_repo.add_history(
|
| 38 |
+
user_id=payload["sub"],
|
| 39 |
+
action_type="NER",
|
| 40 |
+
description=f"Analyzed text ({len(request_body.text)} chars)",
|
| 41 |
+
entity_count=result.entity_count,
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
return result
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@router.get("/model-info", response_model=BaseResponse[ModelInfo])
|
| 48 |
+
async def get_model_info():
|
| 49 |
+
"""Retrieve metadata and runtime status of the local biomedical NER model."""
|
| 50 |
+
ner_service = model_manager.get_ner_service()
|
| 51 |
+
info = ner_service.model.get_info()
|
| 52 |
+
return BaseResponse(
|
| 53 |
+
success=True,
|
| 54 |
+
data=info,
|
| 55 |
+
)
|
backend/app/api/v1/endpoints/profile.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from fastapi import APIRouter, Depends
|
| 3 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 4 |
+
from backend.app.core.database import get_db
|
| 5 |
+
from backend.app.schemas.profile import PatientProfileUpdate, PatientProfileResponse
|
| 6 |
+
from backend.app.schemas.common import BaseResponse
|
| 7 |
+
from backend.app.repositories.user_repo import UserRepository
|
| 8 |
+
from backend.app.repositories.audit_repo import AuditRepository
|
| 9 |
+
from backend.app.models.profile import PatientProfile
|
| 10 |
+
from backend.app.models.user import User
|
| 11 |
+
from backend.app.api.deps import get_current_user
|
| 12 |
+
|
| 13 |
+
router = APIRouter()
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _format_profile(profile: PatientProfile) -> PatientProfileResponse:
|
| 17 |
+
allergies = json.loads(profile.known_allergies) if profile.known_allergies else []
|
| 18 |
+
conditions = json.loads(profile.chronic_conditions) if profile.chronic_conditions else []
|
| 19 |
+
meds = json.loads(profile.current_medications) if profile.current_medications else []
|
| 20 |
+
|
| 21 |
+
return PatientProfileResponse(
|
| 22 |
+
id=profile.id,
|
| 23 |
+
user_id=profile.user_id,
|
| 24 |
+
age=profile.age,
|
| 25 |
+
gender=profile.gender,
|
| 26 |
+
blood_group=profile.blood_group,
|
| 27 |
+
height_cm=profile.height_cm,
|
| 28 |
+
weight_kg=profile.weight_kg,
|
| 29 |
+
known_allergies=allergies,
|
| 30 |
+
chronic_conditions=conditions,
|
| 31 |
+
current_medications=meds,
|
| 32 |
+
emergency_contact=profile.emergency_contact,
|
| 33 |
+
created_at=profile.created_at,
|
| 34 |
+
updated_at=profile.updated_at,
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@router.get("", response_model=BaseResponse[PatientProfileResponse])
|
| 39 |
+
async def get_patient_profile(
|
| 40 |
+
current_user: User = Depends(get_current_user),
|
| 41 |
+
db: AsyncSession = Depends(get_db),
|
| 42 |
+
):
|
| 43 |
+
"""Fetch the patient health profile for the current user."""
|
| 44 |
+
user_repo = UserRepository(db)
|
| 45 |
+
profile = await user_repo.get_profile_by_user_id(current_user.id)
|
| 46 |
+
if not profile:
|
| 47 |
+
profile = PatientProfile(user_id=current_user.id)
|
| 48 |
+
profile = await user_repo.save_profile(profile)
|
| 49 |
+
|
| 50 |
+
return BaseResponse(success=True, data=_format_profile(profile))
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@router.put("", response_model=BaseResponse[PatientProfileResponse])
|
| 54 |
+
async def update_patient_profile(
|
| 55 |
+
profile_in: PatientProfileUpdate,
|
| 56 |
+
current_user: User = Depends(get_current_user),
|
| 57 |
+
db: AsyncSession = Depends(get_db),
|
| 58 |
+
):
|
| 59 |
+
"""Update patient health information, allergies, chronic conditions, and medications."""
|
| 60 |
+
user_repo = UserRepository(db)
|
| 61 |
+
audit_repo = AuditRepository(db)
|
| 62 |
+
profile = await user_repo.get_profile_by_user_id(current_user.id)
|
| 63 |
+
|
| 64 |
+
if not profile:
|
| 65 |
+
profile = PatientProfile(user_id=current_user.id)
|
| 66 |
+
|
| 67 |
+
profile.age = profile_in.age
|
| 68 |
+
profile.gender = profile_in.gender
|
| 69 |
+
profile.blood_group = profile_in.blood_group
|
| 70 |
+
profile.height_cm = profile_in.height_cm
|
| 71 |
+
profile.weight_kg = profile_in.weight_kg
|
| 72 |
+
profile.known_allergies = json.dumps(profile_in.known_allergies or [])
|
| 73 |
+
profile.chronic_conditions = json.dumps(profile_in.chronic_conditions or [])
|
| 74 |
+
profile.current_medications = json.dumps(profile_in.current_medications or [])
|
| 75 |
+
profile.emergency_contact = profile_in.emergency_contact
|
| 76 |
+
|
| 77 |
+
saved_profile = await user_repo.save_profile(profile)
|
| 78 |
+
|
| 79 |
+
await audit_repo.add_history(
|
| 80 |
+
user_id=current_user.id,
|
| 81 |
+
action_type="PROFILE_UPDATE",
|
| 82 |
+
description="Updated patient health profile",
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
return BaseResponse(
|
| 86 |
+
success=True,
|
| 87 |
+
message="Patient health profile updated successfully.",
|
| 88 |
+
data=_format_profile(saved_profile),
|
| 89 |
+
)
|
backend/app/api/v1/router.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter
|
| 2 |
+
from backend.app.api.v1.endpoints import (
|
| 3 |
+
auth,
|
| 4 |
+
ner,
|
| 5 |
+
documents,
|
| 6 |
+
chat,
|
| 7 |
+
profile,
|
| 8 |
+
history,
|
| 9 |
+
admin,
|
| 10 |
+
health,
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
api_router = APIRouter()
|
| 14 |
+
|
| 15 |
+
api_router.include_router(auth.router, prefix="/auth", tags=["Authentication"])
|
| 16 |
+
api_router.include_router(ner.router, prefix="/ner", tags=["Biomedical NER"])
|
| 17 |
+
api_router.include_router(documents.router, prefix="/documents", tags=["Medical Documents"])
|
| 18 |
+
api_router.include_router(chat.router, prefix="/chat", tags=["AI Medical Assistant"])
|
| 19 |
+
api_router.include_router(profile.router, prefix="/profile", tags=["Patient Profile"])
|
| 20 |
+
api_router.include_router(history.router, prefix="/history", tags=["Medical History"])
|
| 21 |
+
api_router.include_router(admin.router, prefix="/admin", tags=["Admin & Monitoring"])
|
| 22 |
+
api_router.include_router(health.router, tags=["Health & Observability"])
|
backend/app/core/config.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List, Union
|
| 2 |
+
from pydantic import AnyHttpUrl, field_validator
|
| 3 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
BASE_DIR = Path(__file__).resolve().parents[3]
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class Settings(BaseSettings):
|
| 10 |
+
model_config = SettingsConfigDict(
|
| 11 |
+
env_file=str(BASE_DIR / ".env"),
|
| 12 |
+
env_file_encoding="utf-8",
|
| 13 |
+
extra="ignore",
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
APP_ENV: str = "development"
|
| 17 |
+
APP_NAME: str = "SanjeevaniAI"
|
| 18 |
+
APP_VERSION: str = "1.0.0"
|
| 19 |
+
DEBUG: bool = True
|
| 20 |
+
HOST: str = "0.0.0.0"
|
| 21 |
+
PORT: int = 8000
|
| 22 |
+
|
| 23 |
+
# Security
|
| 24 |
+
SECRET_KEY: str = "sanjeevani-ai-super-secret-key-change-in-production"
|
| 25 |
+
ALGORITHM: str = "HS256"
|
| 26 |
+
ACCESS_TOKEN_EXPIRE_MINUTES: int = 120
|
| 27 |
+
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
| 28 |
+
|
| 29 |
+
# CORS
|
| 30 |
+
CORS_ORIGINS: List[str] = [
|
| 31 |
+
"http://localhost:3000",
|
| 32 |
+
"http://127.0.0.1:3000",
|
| 33 |
+
"http://localhost:8000",
|
| 34 |
+
]
|
| 35 |
+
|
| 36 |
+
@field_validator("CORS_ORIGINS", mode="before")
|
| 37 |
+
def assemble_cors_origins(cls, v: Union[str, List[str]]) -> List[str]:
|
| 38 |
+
if isinstance(v, str) and not v.startswith("["):
|
| 39 |
+
return [i.strip() for i in v.split(",")]
|
| 40 |
+
elif isinstance(v, (list, str)):
|
| 41 |
+
return v
|
| 42 |
+
raise ValueError(v)
|
| 43 |
+
|
| 44 |
+
# Database
|
| 45 |
+
DATABASE_URL: str = f"sqlite+aiosqlite:///{BASE_DIR / 'sanjeevani.db'}"
|
| 46 |
+
|
| 47 |
+
# Redis
|
| 48 |
+
REDIS_URL: str = "redis://localhost:6379/0"
|
| 49 |
+
|
| 50 |
+
# ML Models
|
| 51 |
+
NER_MODEL_PATH: str = str(BASE_DIR / "models" / "bc5cdr-ner")
|
| 52 |
+
DEVICE: str = "auto"
|
| 53 |
+
|
| 54 |
+
# LLM Providers
|
| 55 |
+
LLM_PROVIDER: str = "mock" # "gemini", "openai", "mock"
|
| 56 |
+
GEMINI_API_KEY: str = ""
|
| 57 |
+
OPENAI_API_KEY: str = ""
|
| 58 |
+
|
| 59 |
+
# Document Storage
|
| 60 |
+
UPLOAD_DIR: str = str(BASE_DIR / "uploads")
|
| 61 |
+
MAX_UPLOAD_SIZE_MB: int = 25
|
| 62 |
+
ALLOWED_EXTENSIONS: List[str] = ["pdf", "txt", "docx"]
|
| 63 |
+
|
| 64 |
+
# Rate Limiting
|
| 65 |
+
RATE_LIMIT_PER_MINUTE: int = 60
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
settings = Settings()
|
backend/app/core/database.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import AsyncGenerator
|
| 2 |
+
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
|
| 3 |
+
from sqlalchemy.orm import declarative_base
|
| 4 |
+
from backend.app.core.config import settings
|
| 5 |
+
from backend.app.core.logger import logger
|
| 6 |
+
|
| 7 |
+
# Build engine with appropriate connect_args depending on SQLite or PostgreSQL
|
| 8 |
+
connect_args = {}
|
| 9 |
+
engine_kwargs = {"echo": False, "future": True}
|
| 10 |
+
|
| 11 |
+
if settings.DATABASE_URL.startswith("sqlite"):
|
| 12 |
+
connect_args["check_same_thread"] = False
|
| 13 |
+
else:
|
| 14 |
+
engine_kwargs.update({
|
| 15 |
+
"pool_size": 10,
|
| 16 |
+
"max_overflow": 20,
|
| 17 |
+
"pool_pre_ping": True,
|
| 18 |
+
})
|
| 19 |
+
|
| 20 |
+
engine = create_async_engine(
|
| 21 |
+
settings.DATABASE_URL,
|
| 22 |
+
connect_args=connect_args,
|
| 23 |
+
**engine_kwargs,
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
AsyncSessionLocal = async_sessionmaker(
|
| 27 |
+
bind=engine,
|
| 28 |
+
class_=AsyncSession,
|
| 29 |
+
expire_on_commit=False,
|
| 30 |
+
autocommit=False,
|
| 31 |
+
autoflush=False,
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
Base = declarative_base()
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
| 38 |
+
async with AsyncSessionLocal() as session:
|
| 39 |
+
try:
|
| 40 |
+
yield session
|
| 41 |
+
except Exception as ex:
|
| 42 |
+
await session.rollback()
|
| 43 |
+
logger.error(f"Database session error: {ex}")
|
| 44 |
+
raise
|
| 45 |
+
finally:
|
| 46 |
+
await session.close()
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
async def init_db() -> None:
|
| 50 |
+
"""Initialize database tables for development/testing."""
|
| 51 |
+
async with engine.begin() as conn:
|
| 52 |
+
await conn.run_sync(Base.metadata.create_all)
|
| 53 |
+
logger.info("Database schema initialized.")
|
backend/app/core/exceptions.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any, Optional, Dict
|
| 2 |
+
from fastapi import HTTPException, status
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class SanjeevaniException(HTTPException):
|
| 6 |
+
def __init__(
|
| 7 |
+
self,
|
| 8 |
+
status_code: int,
|
| 9 |
+
code: str,
|
| 10 |
+
message: str,
|
| 11 |
+
details: Optional[Any] = None,
|
| 12 |
+
headers: Optional[Dict[str, str]] = None,
|
| 13 |
+
):
|
| 14 |
+
super().__init__(
|
| 15 |
+
status_code=status_code,
|
| 16 |
+
detail={"code": code, "message": message, "details": details},
|
| 17 |
+
headers=headers,
|
| 18 |
+
)
|
| 19 |
+
self.code = code
|
| 20 |
+
self.message = message
|
| 21 |
+
self.details = details
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class AuthenticationError(SanjeevaniException):
|
| 25 |
+
def __init__(self, message: str = "Invalid credentials", details: Optional[Any] = None):
|
| 26 |
+
super().__init__(
|
| 27 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 28 |
+
code="AUTHENTICATION_FAILED",
|
| 29 |
+
message=message,
|
| 30 |
+
details=details,
|
| 31 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class PermissionDeniedError(SanjeevaniException):
|
| 36 |
+
def __init__(self, message: str = "Access forbidden", details: Optional[Any] = None):
|
| 37 |
+
super().__init__(
|
| 38 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 39 |
+
code="PERMISSION_DENIED",
|
| 40 |
+
message=message,
|
| 41 |
+
details=details,
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class ResourceNotFoundError(SanjeevaniException):
|
| 46 |
+
def __init__(self, resource: str, resource_id: Any = None):
|
| 47 |
+
msg = f"{resource} not found" if not resource_id else f"{resource} with ID '{resource_id}' not found"
|
| 48 |
+
super().__init__(
|
| 49 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 50 |
+
code="RESOURCE_NOT_FOUND",
|
| 51 |
+
message=msg,
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class ValidationError(SanjeevaniException):
|
| 56 |
+
def __init__(self, message: str = "Validation failed", details: Optional[Any] = None):
|
| 57 |
+
super().__init__(
|
| 58 |
+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
| 59 |
+
code="VALIDATION_ERROR",
|
| 60 |
+
message=message,
|
| 61 |
+
details=details,
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
class MLModelError(SanjeevaniException):
|
| 66 |
+
def __init__(self, message: str = "ML inference failed", details: Optional[Any] = None):
|
| 67 |
+
super().__init__(
|
| 68 |
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 69 |
+
code="ML_MODEL_ERROR",
|
| 70 |
+
message=message,
|
| 71 |
+
details=details,
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class DocumentProcessingError(SanjeevaniException):
|
| 76 |
+
def __init__(self, message: str = "Failed to process document", details: Optional[Any] = None):
|
| 77 |
+
super().__init__(
|
| 78 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 79 |
+
code="DOCUMENT_PROCESSING_FAILED",
|
| 80 |
+
message=message,
|
| 81 |
+
details=details,
|
| 82 |
+
)
|
backend/app/core/logger.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import sys
|
| 3 |
+
import json
|
| 4 |
+
from datetime import datetime, timezone
|
| 5 |
+
from backend.app.core.config import settings
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class JsonFormatter(logging.Formatter):
|
| 9 |
+
def format(self, record: logging.LogRecord) -> str:
|
| 10 |
+
log_obj = {
|
| 11 |
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
| 12 |
+
"level": record.levelname,
|
| 13 |
+
"logger": record.name,
|
| 14 |
+
"message": record.getMessage(),
|
| 15 |
+
"module": record.module,
|
| 16 |
+
"line": record.lineno,
|
| 17 |
+
}
|
| 18 |
+
if hasattr(record, "request_id"):
|
| 19 |
+
log_obj["request_id"] = record.request_id
|
| 20 |
+
if record.exc_info:
|
| 21 |
+
log_obj["exception"] = self.formatException(record.exc_info)
|
| 22 |
+
return json.dumps(log_obj)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def setup_logger(name: str = "sanjeevani") -> logging.Logger:
|
| 26 |
+
logger = logging.getLogger(name)
|
| 27 |
+
logger.setLevel(logging.DEBUG if settings.DEBUG else logging.INFO)
|
| 28 |
+
|
| 29 |
+
if not logger.handlers:
|
| 30 |
+
handler = logging.StreamHandler(sys.stdout)
|
| 31 |
+
if settings.APP_ENV == "production":
|
| 32 |
+
handler.setFormatter(JsonFormatter())
|
| 33 |
+
else:
|
| 34 |
+
handler.setFormatter(
|
| 35 |
+
logging.Formatter(
|
| 36 |
+
"[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s",
|
| 37 |
+
datefmt="%Y-%m-%d %H:%M:%S",
|
| 38 |
+
)
|
| 39 |
+
)
|
| 40 |
+
logger.addHandler(handler)
|
| 41 |
+
|
| 42 |
+
return logger
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
logger = setup_logger()
|
backend/app/core/security.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime, timedelta, timezone
|
| 2 |
+
from typing import Any, Optional, Union
|
| 3 |
+
import bcrypt
|
| 4 |
+
from jose import jwt, JWTError
|
| 5 |
+
import enum
|
| 6 |
+
from backend.app.core.config import settings
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class UserRole(str, enum.Enum):
|
| 10 |
+
USER = "USER"
|
| 11 |
+
PATIENT = "PATIENT"
|
| 12 |
+
DOCTOR = "DOCTOR"
|
| 13 |
+
ADMIN = "ADMIN"
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
| 17 |
+
try:
|
| 18 |
+
pwd_bytes = plain_password.encode("utf-8")[:72]
|
| 19 |
+
hash_bytes = hashed_password.encode("utf-8")
|
| 20 |
+
return bcrypt.checkpw(pwd_bytes, hash_bytes)
|
| 21 |
+
except Exception:
|
| 22 |
+
return False
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def get_password_hash(password: str) -> str:
|
| 26 |
+
pwd_bytes = password.encode("utf-8")[:72]
|
| 27 |
+
salt = bcrypt.gensalt()
|
| 28 |
+
return bcrypt.hashpw(pwd_bytes, salt).decode("utf-8")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def create_access_token(
|
| 32 |
+
subject: Union[str, Any],
|
| 33 |
+
role: str = UserRole.PATIENT.value,
|
| 34 |
+
expires_delta: Optional[timedelta] = None,
|
| 35 |
+
) -> str:
|
| 36 |
+
if expires_delta:
|
| 37 |
+
expire = datetime.now(timezone.utc) + expires_delta
|
| 38 |
+
else:
|
| 39 |
+
expire = datetime.now(timezone.utc) + timedelta(
|
| 40 |
+
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
to_encode = {
|
| 44 |
+
"sub": str(subject),
|
| 45 |
+
"role": role,
|
| 46 |
+
"type": "access",
|
| 47 |
+
"exp": expire,
|
| 48 |
+
"iat": datetime.now(timezone.utc),
|
| 49 |
+
}
|
| 50 |
+
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def create_refresh_token(
|
| 54 |
+
subject: Union[str, Any],
|
| 55 |
+
role: str = UserRole.PATIENT.value,
|
| 56 |
+
expires_delta: Optional[timedelta] = None,
|
| 57 |
+
) -> str:
|
| 58 |
+
if expires_delta:
|
| 59 |
+
expire = datetime.now(timezone.utc) + expires_delta
|
| 60 |
+
else:
|
| 61 |
+
expire = datetime.now(timezone.utc) + timedelta(
|
| 62 |
+
days=settings.REFRESH_TOKEN_EXPIRE_DAYS
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
to_encode = {
|
| 66 |
+
"sub": str(subject),
|
| 67 |
+
"role": role,
|
| 68 |
+
"type": "refresh",
|
| 69 |
+
"exp": expire,
|
| 70 |
+
"iat": datetime.now(timezone.utc),
|
| 71 |
+
}
|
| 72 |
+
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def decode_token(token: str) -> Optional[dict]:
|
| 76 |
+
try:
|
| 77 |
+
payload = jwt.decode(
|
| 78 |
+
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
|
| 79 |
+
)
|
| 80 |
+
return payload
|
| 81 |
+
except JWTError:
|
| 82 |
+
return None
|
backend/app/main.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from contextlib import asynccontextmanager
|
| 2 |
+
from fastapi import FastAPI, Request, status
|
| 3 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 4 |
+
from fastapi.responses import JSONResponse
|
| 5 |
+
from fastapi.exceptions import RequestValidationError
|
| 6 |
+
|
| 7 |
+
from backend.app.core.config import settings
|
| 8 |
+
from backend.app.core.database import init_db
|
| 9 |
+
from backend.app.core.logger import logger
|
| 10 |
+
from backend.app.core.exceptions import SanjeevaniException
|
| 11 |
+
from backend.app.schemas.common import MEDICAL_DISCLAIMER
|
| 12 |
+
from backend.app.ml.manager import model_manager
|
| 13 |
+
from backend.app.middleware.request_id import RequestIDMiddleware
|
| 14 |
+
from backend.app.middleware.security_headers import SecurityHeadersMiddleware
|
| 15 |
+
from backend.app.api.v1.router import api_router
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@asynccontextmanager
|
| 19 |
+
async def lifespan(app: FastAPI):
|
| 20 |
+
logger.info(f"Starting {settings.APP_NAME} v{settings.APP_VERSION} ({settings.APP_ENV})...")
|
| 21 |
+
# Initialize database
|
| 22 |
+
await init_db()
|
| 23 |
+
# Initialize ML Models
|
| 24 |
+
try:
|
| 25 |
+
model_manager.initialize()
|
| 26 |
+
except Exception as e:
|
| 27 |
+
logger.warning(f"ML Model initialization warning (will retry on demand): {e}")
|
| 28 |
+
|
| 29 |
+
yield
|
| 30 |
+
|
| 31 |
+
logger.info(f"Shutting down {settings.APP_NAME}...")
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def create_application() -> FastAPI:
|
| 35 |
+
app = FastAPI(
|
| 36 |
+
title=f"{settings.APP_NAME} — Healthcare Intelligence API",
|
| 37 |
+
description=(
|
| 38 |
+
"Industry-grade healthcare AI and clinical decision-support platform.\n\n"
|
| 39 |
+
f"**Medical Safety Notice**: {MEDICAL_DISCLAIMER}"
|
| 40 |
+
),
|
| 41 |
+
version=settings.APP_VERSION,
|
| 42 |
+
lifespan=lifespan,
|
| 43 |
+
docs_url="/docs",
|
| 44 |
+
redoc_url="/redoc",
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
# Middlewares
|
| 48 |
+
app.add_middleware(RequestIDMiddleware)
|
| 49 |
+
app.add_middleware(SecurityHeadersMiddleware)
|
| 50 |
+
app.add_middleware(
|
| 51 |
+
CORSMiddleware,
|
| 52 |
+
allow_origins=settings.CORS_ORIGINS,
|
| 53 |
+
allow_credentials=True,
|
| 54 |
+
allow_methods=["*"],
|
| 55 |
+
allow_headers=["*"],
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
# Exception Handlers
|
| 59 |
+
@app.exception_handler(SanjeevaniException)
|
| 60 |
+
async def sanjeevani_exception_handler(request: Request, exc: SanjeevaniException):
|
| 61 |
+
req_id = getattr(request.state, "request_id", None)
|
| 62 |
+
logger.error(f"Domain error [{exc.code}]: {exc.message} (Request ID: {req_id})")
|
| 63 |
+
return JSONResponse(
|
| 64 |
+
status_code=exc.status_code,
|
| 65 |
+
content={
|
| 66 |
+
"success": False,
|
| 67 |
+
"error": {
|
| 68 |
+
"code": exc.code,
|
| 69 |
+
"message": exc.message,
|
| 70 |
+
"details": exc.details,
|
| 71 |
+
},
|
| 72 |
+
"request_id": req_id,
|
| 73 |
+
"disclaimer": MEDICAL_DISCLAIMER,
|
| 74 |
+
},
|
| 75 |
+
headers=exc.headers,
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
@app.exception_handler(RequestValidationError)
|
| 79 |
+
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
| 80 |
+
req_id = getattr(request.state, "request_id", None)
|
| 81 |
+
logger.warning(f"Validation error: {exc.errors()} (Request ID: {req_id})")
|
| 82 |
+
return JSONResponse(
|
| 83 |
+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
| 84 |
+
content={
|
| 85 |
+
"success": False,
|
| 86 |
+
"error": {
|
| 87 |
+
"code": "VALIDATION_ERROR",
|
| 88 |
+
"message": "Invalid request parameters.",
|
| 89 |
+
"details": exc.errors(),
|
| 90 |
+
},
|
| 91 |
+
"request_id": req_id,
|
| 92 |
+
"disclaimer": MEDICAL_DISCLAIMER,
|
| 93 |
+
},
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
@app.exception_handler(Exception)
|
| 97 |
+
async def global_exception_handler(request: Request, exc: Exception):
|
| 98 |
+
req_id = getattr(request.state, "request_id", None)
|
| 99 |
+
logger.error(f"Unhandled exception: {str(exc)} (Request ID: {req_id})", exc_info=True)
|
| 100 |
+
return JSONResponse(
|
| 101 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 102 |
+
content={
|
| 103 |
+
"success": False,
|
| 104 |
+
"error": {
|
| 105 |
+
"code": "INTERNAL_SERVER_ERROR",
|
| 106 |
+
"message": "An unexpected error occurred while processing the healthcare request.",
|
| 107 |
+
},
|
| 108 |
+
"request_id": req_id,
|
| 109 |
+
"disclaimer": MEDICAL_DISCLAIMER,
|
| 110 |
+
},
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
# Register API v1 routes
|
| 114 |
+
app.include_router(api_router, prefix="/api/v1")
|
| 115 |
+
|
| 116 |
+
# Root redirect / status
|
| 117 |
+
@app.get("/", tags=["Root"])
|
| 118 |
+
async def root():
|
| 119 |
+
return {
|
| 120 |
+
"app": settings.APP_NAME,
|
| 121 |
+
"tagline": "AI-Powered Healthcare Intelligence Platform",
|
| 122 |
+
"version": settings.APP_VERSION,
|
| 123 |
+
"docs": "/docs",
|
| 124 |
+
"disclaimer": MEDICAL_DISCLAIMER,
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
return app
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
app = create_application()
|
backend/app/middleware/request_id.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
+
from starlette.middleware.base import BaseHTTPMiddleware
|
| 3 |
+
from starlette.requests import Request
|
| 4 |
+
from starlette.responses import Response
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class RequestIDMiddleware(BaseHTTPMiddleware):
|
| 8 |
+
async def dispatch(self, request: Request, call_next) -> Response:
|
| 9 |
+
req_id = request.headers.get("X-Request-ID") or str(uuid.uuid4())
|
| 10 |
+
request.state.request_id = req_id
|
| 11 |
+
response = await call_next(request)
|
| 12 |
+
response.headers["X-Request-ID"] = req_id
|
| 13 |
+
return response
|
backend/app/middleware/security_headers.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from starlette.middleware.base import BaseHTTPMiddleware
|
| 2 |
+
from starlette.requests import Request
|
| 3 |
+
from starlette.responses import Response
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
| 7 |
+
async def dispatch(self, request: Request, call_next) -> Response:
|
| 8 |
+
response = await call_next(request)
|
| 9 |
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
| 10 |
+
response.headers["X-Frame-Options"] = "DENY"
|
| 11 |
+
response.headers["X-XSS-Protection"] = "1; mode=block"
|
| 12 |
+
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
| 13 |
+
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
| 14 |
+
return response
|
backend/app/ml/manager.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Optional, Dict, Any
|
| 2 |
+
from backend.app.ml.ner.bc5cdr import BC5CDRNERModel
|
| 3 |
+
from backend.app.ml.ner.service import NERService
|
| 4 |
+
from backend.app.core.logger import logger
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class ModelManager:
|
| 8 |
+
_instance: Optional["ModelManager"] = None
|
| 9 |
+
|
| 10 |
+
def __init__(self):
|
| 11 |
+
self.bc5cdr_ner: Optional[BC5CDRNERModel] = None
|
| 12 |
+
self.ner_service: Optional[NERService] = None
|
| 13 |
+
self._is_initialized = False
|
| 14 |
+
|
| 15 |
+
@classmethod
|
| 16 |
+
def get_instance(cls) -> "ModelManager":
|
| 17 |
+
if cls._instance is None:
|
| 18 |
+
cls._instance = ModelManager()
|
| 19 |
+
return cls._instance
|
| 20 |
+
|
| 21 |
+
def initialize(self) -> None:
|
| 22 |
+
"""Initialize and warm up all local ML models."""
|
| 23 |
+
if self._is_initialized:
|
| 24 |
+
return
|
| 25 |
+
|
| 26 |
+
logger.info("Initializing ModelManager and loading ML pipelines...")
|
| 27 |
+
try:
|
| 28 |
+
self.bc5cdr_ner = BC5CDRNERModel()
|
| 29 |
+
self.bc5cdr_ner.load()
|
| 30 |
+
self.ner_service = NERService(model=self.bc5cdr_ner)
|
| 31 |
+
self._is_initialized = True
|
| 32 |
+
logger.info("ModelManager initialized successfully.")
|
| 33 |
+
except Exception as e:
|
| 34 |
+
logger.error(f"Error during ModelManager initialization: {str(e)}", exc_info=True)
|
| 35 |
+
self._is_initialized = False
|
| 36 |
+
|
| 37 |
+
def get_ner_service(self) -> NERService:
|
| 38 |
+
if self.ner_service is None:
|
| 39 |
+
if self.bc5cdr_ner is None:
|
| 40 |
+
self.bc5cdr_ner = BC5CDRNERModel()
|
| 41 |
+
self.bc5cdr_ner.load()
|
| 42 |
+
self.ner_service = NERService(model=self.bc5cdr_ner)
|
| 43 |
+
return self.ner_service
|
| 44 |
+
|
| 45 |
+
def get_status(self) -> Dict[str, Any]:
|
| 46 |
+
return {
|
| 47 |
+
"initialized": self._is_initialized,
|
| 48 |
+
"models": {
|
| 49 |
+
"bc5cdr_ner": self.bc5cdr_ner.get_info().model_dump() if self.bc5cdr_ner else {"status": "Not Loaded"}
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
model_manager = ModelManager.get_instance()
|
backend/app/ml/ner/base.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from abc import ABC, abstractmethod
|
| 2 |
+
from typing import List, Dict, Any
|
| 3 |
+
from backend.app.schemas.ner import NEREntity, ModelInfo
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class BaseNERModel(ABC):
|
| 7 |
+
@abstractmethod
|
| 8 |
+
def load(self) -> None:
|
| 9 |
+
"""Load model weights and tokenizer into memory."""
|
| 10 |
+
pass
|
| 11 |
+
|
| 12 |
+
@abstractmethod
|
| 13 |
+
def predict(self, text: str) -> List[NEREntity]:
|
| 14 |
+
"""Perform token classification and return extracted biomedical entities."""
|
| 15 |
+
pass
|
| 16 |
+
|
| 17 |
+
@abstractmethod
|
| 18 |
+
def get_info(self) -> ModelInfo:
|
| 19 |
+
"""Return model metadata."""
|
| 20 |
+
pass
|
backend/app/ml/ner/bc5cdr.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
from typing import List, Optional
|
| 4 |
+
import torch
|
| 5 |
+
from transformers import AutoTokenizer, AutoModelForTokenClassification, pipeline
|
| 6 |
+
from backend.app.ml.ner.base import BaseNERModel
|
| 7 |
+
from backend.app.schemas.ner import NEREntity, ModelInfo
|
| 8 |
+
from backend.app.core.config import settings
|
| 9 |
+
from backend.app.core.logger import logger
|
| 10 |
+
from backend.app.core.exceptions import MLModelError
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class BC5CDRNERModel(BaseNERModel):
|
| 14 |
+
def __init__(self, model_path: Optional[str] = None):
|
| 15 |
+
self.model_path = Path(model_path or settings.NER_MODEL_PATH)
|
| 16 |
+
self.model_name = "tner/roberta-large-bc5cdr"
|
| 17 |
+
self.tokenizer = None
|
| 18 |
+
self.model = None
|
| 19 |
+
self.pipeline = None
|
| 20 |
+
self.device = "cuda:0" if torch.cuda.is_available() else "cpu"
|
| 21 |
+
self._is_loaded = False
|
| 22 |
+
|
| 23 |
+
def load(self) -> None:
|
| 24 |
+
if self._is_loaded:
|
| 25 |
+
return
|
| 26 |
+
|
| 27 |
+
# Use local path if it exists; otherwise load directly from HuggingFace Hub (for cloud hosting)
|
| 28 |
+
if self.model_path.exists() and any(self.model_path.iterdir()):
|
| 29 |
+
model_source = str(self.model_path)
|
| 30 |
+
logger.info(f"Loading local BC5CDR NER model from disk ({model_source}) onto {self.device}...")
|
| 31 |
+
else:
|
| 32 |
+
model_source = self.model_name
|
| 33 |
+
logger.info(f"Local model path not found. Loading '{model_source}' from Hugging Face Hub onto {self.device}...")
|
| 34 |
+
|
| 35 |
+
try:
|
| 36 |
+
self.tokenizer = AutoTokenizer.from_pretrained(model_source)
|
| 37 |
+
self.model = AutoModelForTokenClassification.from_pretrained(model_source)
|
| 38 |
+
|
| 39 |
+
device_id = 0 if self.device.startswith("cuda") else -1
|
| 40 |
+
self.pipeline = pipeline(
|
| 41 |
+
"ner",
|
| 42 |
+
model=self.model,
|
| 43 |
+
tokenizer=self.tokenizer,
|
| 44 |
+
aggregation_strategy="simple",
|
| 45 |
+
device=device_id,
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
# Warm-up run
|
| 49 |
+
_ = self.pipeline("Metformin reduces glucose in diabetes mellitus.")
|
| 50 |
+
self._is_loaded = True
|
| 51 |
+
logger.info(f"BC5CDR NER model ({model_source}) loaded and warmed up successfully.")
|
| 52 |
+
except Exception as e:
|
| 53 |
+
logger.error(f"Failed to load BC5CDR model: {str(e)}", exc_info=True)
|
| 54 |
+
self._is_loaded = False
|
| 55 |
+
raise MLModelError(message=f"Model initialization failed: {str(e)}")
|
| 56 |
+
|
| 57 |
+
def predict(self, text: str) -> List[NEREntity]:
|
| 58 |
+
if not self._is_loaded or self.pipeline is None:
|
| 59 |
+
self.load()
|
| 60 |
+
|
| 61 |
+
if not text or not text.strip():
|
| 62 |
+
return []
|
| 63 |
+
|
| 64 |
+
try:
|
| 65 |
+
raw_entities = self.pipeline(text)
|
| 66 |
+
entities: List[NEREntity] = []
|
| 67 |
+
|
| 68 |
+
for ent in raw_entities:
|
| 69 |
+
entity_label = ent.get("entity_group") or ent.get("entity") or "UNKNOWN"
|
| 70 |
+
norm_label = entity_label.upper()
|
| 71 |
+
if "CHEM" in norm_label:
|
| 72 |
+
norm_label = "CHEMICAL"
|
| 73 |
+
elif "DIS" in norm_label:
|
| 74 |
+
norm_label = "DISEASE"
|
| 75 |
+
|
| 76 |
+
word = ent.get("word", "").strip()
|
| 77 |
+
start = ent.get("start", 0)
|
| 78 |
+
end = ent.get("end", 0)
|
| 79 |
+
|
| 80 |
+
if not word:
|
| 81 |
+
continue
|
| 82 |
+
|
| 83 |
+
score = ent.get("score")
|
| 84 |
+
confidence = float(score) if score is not None else None
|
| 85 |
+
|
| 86 |
+
entities.append(
|
| 87 |
+
NEREntity(
|
| 88 |
+
text=word,
|
| 89 |
+
label=norm_label,
|
| 90 |
+
start=start,
|
| 91 |
+
end=end,
|
| 92 |
+
confidence=round(confidence, 4) if confidence else None,
|
| 93 |
+
model=self.model_name,
|
| 94 |
+
)
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
return entities
|
| 98 |
+
except Exception as e:
|
| 99 |
+
logger.error(f"NER inference error: {str(e)}", exc_info=True)
|
| 100 |
+
raise MLModelError(message=f"Error executing NER inference: {str(e)}")
|
| 101 |
+
|
| 102 |
+
def get_info(self) -> ModelInfo:
|
| 103 |
+
return ModelInfo(
|
| 104 |
+
name=self.model_name,
|
| 105 |
+
version="1.0.0",
|
| 106 |
+
provider="RoBERTa-large BC5CDR",
|
| 107 |
+
device=self.device,
|
| 108 |
+
status="Loaded" if self._is_loaded else "Not Loaded",
|
| 109 |
+
)
|
backend/app/ml/ner/service.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
import uuid
|
| 3 |
+
from typing import List, Optional
|
| 4 |
+
from backend.app.ml.ner.base import BaseNERModel
|
| 5 |
+
from backend.app.schemas.ner import NERRequest, NERResponse, NEREntity
|
| 6 |
+
from backend.app.core.logger import logger
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class NERService:
|
| 10 |
+
def __init__(self, model: BaseNERModel):
|
| 11 |
+
self.model = model
|
| 12 |
+
|
| 13 |
+
def analyze_text(self, request: NERRequest, request_id: Optional[str] = None) -> NERResponse:
|
| 14 |
+
req_id = request_id or str(uuid.uuid4())
|
| 15 |
+
start_time = time.perf_counter()
|
| 16 |
+
|
| 17 |
+
entities: List[NEREntity] = self.model.predict(request.text)
|
| 18 |
+
processing_time_ms = round((time.perf_counter() - start_time) * 1000, 2)
|
| 19 |
+
|
| 20 |
+
model_info = self.model.get_info()
|
| 21 |
+
|
| 22 |
+
logger.info(
|
| 23 |
+
f"NER Analysis completed: req_id={req_id}, entities_found={len(entities)}, "
|
| 24 |
+
f"latency={processing_time_ms}ms"
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
return NERResponse(
|
| 28 |
+
request_id=req_id,
|
| 29 |
+
model=model_info,
|
| 30 |
+
entities=entities,
|
| 31 |
+
entity_count=len(entities),
|
| 32 |
+
processing_time_ms=processing_time_ms,
|
| 33 |
+
text_length=len(request.text),
|
| 34 |
+
)
|
backend/app/models/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from backend.app.models.base import BaseModel
|
| 2 |
+
from backend.app.models.user import User
|
| 3 |
+
from backend.app.models.profile import PatientProfile
|
| 4 |
+
from backend.app.models.document import MedicalDocument, DocumentAnalysis
|
| 5 |
+
from backend.app.models.entity import MedicalEntity
|
| 6 |
+
from backend.app.models.conversation import AIConversation, AIMessage
|
| 7 |
+
from backend.app.models.audit import AuditLog, AnalysisHistory, SystemEvent
|
| 8 |
+
|
| 9 |
+
__all__ = [
|
| 10 |
+
"BaseModel",
|
| 11 |
+
"User",
|
| 12 |
+
"PatientProfile",
|
| 13 |
+
"MedicalDocument",
|
| 14 |
+
"DocumentAnalysis",
|
| 15 |
+
"MedicalEntity",
|
| 16 |
+
"AIConversation",
|
| 17 |
+
"AIMessage",
|
| 18 |
+
"AuditLog",
|
| 19 |
+
"AnalysisHistory",
|
| 20 |
+
"SystemEvent",
|
| 21 |
+
]
|
backend/app/models/audit.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import Column, String, Integer, ForeignKey, Text
|
| 2 |
+
from sqlalchemy.orm import relationship
|
| 3 |
+
from backend.app.models.base import BaseModel
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class AuditLog(BaseModel):
|
| 7 |
+
__tablename__ = "audit_logs"
|
| 8 |
+
|
| 9 |
+
user_id = Column(String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
| 10 |
+
action = Column(String(100), nullable=False, index=True) # LOGIN, LOGOUT, NER_ANALYZE, DOCUMENT_UPLOAD, etc.
|
| 11 |
+
ip_address = Column(String(45), nullable=True)
|
| 12 |
+
user_agent = Column(String(255), nullable=True)
|
| 13 |
+
status = Column(String(20), default="SUCCESS", nullable=False) # SUCCESS, FAILED, WARNING
|
| 14 |
+
details = Column(Text, nullable=True) # JSON-encoded sanitized details
|
| 15 |
+
|
| 16 |
+
# Relationships
|
| 17 |
+
user = relationship("User", back_populates="audit_logs")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class AnalysisHistory(BaseModel):
|
| 21 |
+
__tablename__ = "analysis_history"
|
| 22 |
+
|
| 23 |
+
user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
| 24 |
+
action_type = Column(String(50), nullable=False) # NER, REPORT_ANALYSIS, PROFILE_UPDATE, CHAT
|
| 25 |
+
description = Column(String(255), nullable=False)
|
| 26 |
+
entity_count = Column(Integer, default=0, nullable=False)
|
| 27 |
+
reference_id = Column(String(36), nullable=True) # document_id or conversation_id
|
| 28 |
+
|
| 29 |
+
# Relationships
|
| 30 |
+
user = relationship("User", back_populates="history")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class SystemEvent(BaseModel):
|
| 34 |
+
__tablename__ = "system_events"
|
| 35 |
+
|
| 36 |
+
event_type = Column(String(100), nullable=False, index=True)
|
| 37 |
+
severity = Column(String(20), default="INFO", nullable=False) # INFO, WARNING, ERROR, CRITICAL
|
| 38 |
+
message = Column(String(500), nullable=False)
|
| 39 |
+
details = Column(Text, nullable=True)
|
backend/app/models/base.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
+
from datetime import datetime, timezone
|
| 3 |
+
from sqlalchemy import Column, String, DateTime
|
| 4 |
+
from backend.app.core.database import Base
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def generate_uuid() -> str:
|
| 8 |
+
return str(uuid.uuid4())
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def utc_now() -> datetime:
|
| 12 |
+
return datetime.now(timezone.utc)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class BaseModel(Base):
|
| 16 |
+
__abstract__ = True
|
| 17 |
+
|
| 18 |
+
id = Column(String(36), primary_key=True, default=generate_uuid)
|
| 19 |
+
created_at = Column(DateTime(timezone=True), default=utc_now, nullable=False)
|
| 20 |
+
updated_at = Column(
|
| 21 |
+
DateTime(timezone=True),
|
| 22 |
+
default=utc_now,
|
| 23 |
+
onupdate=utc_now,
|
| 24 |
+
nullable=False,
|
| 25 |
+
)
|
backend/app/models/conversation.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import Column, String, ForeignKey, Text
|
| 2 |
+
from sqlalchemy.orm import relationship
|
| 3 |
+
from backend.app.models.base import BaseModel
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class AIConversation(BaseModel):
|
| 7 |
+
__tablename__ = "ai_conversations"
|
| 8 |
+
|
| 9 |
+
user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
| 10 |
+
title = Column(String(255), default="New Medical Consultation", nullable=False)
|
| 11 |
+
|
| 12 |
+
# Relationships with selectin loading to prevent async lazy loading issues
|
| 13 |
+
user = relationship("User", back_populates="conversations", lazy="selectin")
|
| 14 |
+
messages = relationship(
|
| 15 |
+
"AIMessage",
|
| 16 |
+
back_populates="conversation",
|
| 17 |
+
cascade="all, delete-orphan",
|
| 18 |
+
order_by="AIMessage.created_at",
|
| 19 |
+
lazy="selectin",
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class AIMessage(BaseModel):
|
| 24 |
+
__tablename__ = "ai_messages"
|
| 25 |
+
|
| 26 |
+
conversation_id = Column(String(36), ForeignKey("ai_conversations.id", ondelete="CASCADE"), nullable=False, index=True)
|
| 27 |
+
role = Column(String(20), nullable=False) # "user", "assistant", "system"
|
| 28 |
+
content = Column(Text, nullable=False)
|
| 29 |
+
structured_data = Column(Text, nullable=True) # JSON-encoded summary, recommendations, disclaimer
|
| 30 |
+
model_provider = Column(String(50), nullable=True)
|
| 31 |
+
|
| 32 |
+
# Relationships
|
| 33 |
+
conversation = relationship("AIConversation", back_populates="messages", lazy="selectin")
|
backend/app/models/document.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import Column, String, Integer, ForeignKey, Text, DateTime
|
| 2 |
+
from sqlalchemy.orm import relationship
|
| 3 |
+
from backend.app.models.base import BaseModel, utc_now
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class MedicalDocument(BaseModel):
|
| 7 |
+
__tablename__ = "medical_documents"
|
| 8 |
+
|
| 9 |
+
user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
| 10 |
+
filename = Column(String(255), nullable=False)
|
| 11 |
+
original_filename = Column(String(255), nullable=False)
|
| 12 |
+
file_path = Column(String(500), nullable=False)
|
| 13 |
+
file_type = Column(String(50), nullable=False)
|
| 14 |
+
file_size = Column(Integer, nullable=False)
|
| 15 |
+
file_hash = Column(String(64), nullable=False)
|
| 16 |
+
status = Column(String(50), default="PENDING", nullable=False) # PENDING, PROCESSING, COMPLETED, FAILED
|
| 17 |
+
error_message = Column(Text, nullable=True)
|
| 18 |
+
|
| 19 |
+
# Relationships
|
| 20 |
+
user = relationship("User", back_populates="documents", lazy="selectin")
|
| 21 |
+
analysis = relationship("DocumentAnalysis", back_populates="document", uselist=False, cascade="all, delete-orphan", lazy="selectin")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class DocumentAnalysis(BaseModel):
|
| 25 |
+
__tablename__ = "document_analyses"
|
| 26 |
+
|
| 27 |
+
document_id = Column(String(36), ForeignKey("medical_documents.id", ondelete="CASCADE"), unique=True, nullable=False, index=True)
|
| 28 |
+
raw_text = Column(Text, nullable=False)
|
| 29 |
+
cleaned_text = Column(Text, nullable=True)
|
| 30 |
+
summary = Column(Text, nullable=True)
|
| 31 |
+
important_findings = Column(Text, nullable=True) # JSON-encoded array or text
|
| 32 |
+
detected_conditions = Column(Text, nullable=True) # JSON-encoded array
|
| 33 |
+
detected_medications = Column(Text, nullable=True) # JSON-encoded array
|
| 34 |
+
clinical_recommendations = Column(Text, nullable=True)
|
| 35 |
+
processed_at = Column(DateTime(timezone=True), default=utc_now, nullable=False)
|
| 36 |
+
|
| 37 |
+
# Relationships
|
| 38 |
+
document = relationship("MedicalDocument", back_populates="analysis", lazy="selectin")
|
| 39 |
+
entities = relationship("MedicalEntity", back_populates="analysis", cascade="all, delete-orphan", lazy="selectin")
|
backend/app/models/entity.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import Column, String, Integer, Float, ForeignKey
|
| 2 |
+
from sqlalchemy.orm import relationship
|
| 3 |
+
from backend.app.models.base import BaseModel
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class MedicalEntity(BaseModel):
|
| 7 |
+
__tablename__ = "medical_entities"
|
| 8 |
+
|
| 9 |
+
analysis_id = Column(String(36), ForeignKey("document_analyses.id", ondelete="CASCADE"), nullable=False, index=True)
|
| 10 |
+
text = Column(String(255), nullable=False)
|
| 11 |
+
label = Column(String(50), nullable=False) # CHEMICAL, DISEASE, MEDICINE, etc.
|
| 12 |
+
start_offset = Column(Integer, nullable=False)
|
| 13 |
+
end_offset = Column(Integer, nullable=False)
|
| 14 |
+
confidence = Column(Float, nullable=True)
|
| 15 |
+
model_name = Column(String(100), default="tner/roberta-large-bc5cdr", nullable=False)
|
| 16 |
+
|
| 17 |
+
# Relationships
|
| 18 |
+
analysis = relationship("DocumentAnalysis", back_populates="entities")
|
backend/app/models/profile.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import Column, String, Integer, Float, ForeignKey, Text
|
| 2 |
+
from sqlalchemy.orm import relationship
|
| 3 |
+
from backend.app.models.base import BaseModel
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class PatientProfile(BaseModel):
|
| 7 |
+
__tablename__ = "patient_profiles"
|
| 8 |
+
|
| 9 |
+
user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=False, index=True)
|
| 10 |
+
age = Column(Integer, nullable=True)
|
| 11 |
+
gender = Column(String(20), nullable=True)
|
| 12 |
+
blood_group = Column(String(10), nullable=True)
|
| 13 |
+
height_cm = Column(Float, nullable=True)
|
| 14 |
+
weight_kg = Column(Float, nullable=True)
|
| 15 |
+
known_allergies = Column(Text, nullable=True) # JSON or comma-separated string
|
| 16 |
+
chronic_conditions = Column(Text, nullable=True) # JSON or comma-separated string
|
| 17 |
+
current_medications = Column(Text, nullable=True) # JSON or comma-separated string
|
| 18 |
+
emergency_contact = Column(String(255), nullable=True)
|
| 19 |
+
|
| 20 |
+
# Relationships
|
| 21 |
+
user = relationship("User", back_populates="profile")
|
backend/app/models/user.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import Column, String, Boolean
|
| 2 |
+
from sqlalchemy.orm import relationship
|
| 3 |
+
from backend.app.models.base import BaseModel
|
| 4 |
+
from backend.app.core.security import UserRole
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class User(BaseModel):
|
| 8 |
+
__tablename__ = "users"
|
| 9 |
+
|
| 10 |
+
email = Column(String(255), unique=True, index=True, nullable=False)
|
| 11 |
+
hashed_password = Column(String(255), nullable=False)
|
| 12 |
+
full_name = Column(String(255), nullable=False)
|
| 13 |
+
role = Column(String(50), default=UserRole.PATIENT.value, nullable=False)
|
| 14 |
+
is_active = Column(Boolean, default=True, nullable=False)
|
| 15 |
+
is_verified = Column(Boolean, default=False, nullable=False)
|
| 16 |
+
|
| 17 |
+
# Relationships
|
| 18 |
+
profile = relationship("PatientProfile", back_populates="user", uselist=False, cascade="all, delete-orphan", lazy="selectin")
|
| 19 |
+
documents = relationship("MedicalDocument", back_populates="user", cascade="all, delete-orphan", lazy="selectin")
|
| 20 |
+
conversations = relationship("AIConversation", back_populates="user", cascade="all, delete-orphan", lazy="selectin")
|
| 21 |
+
audit_logs = relationship("AuditLog", back_populates="user", lazy="selectin")
|
| 22 |
+
history = relationship("AnalysisHistory", back_populates="user", cascade="all, delete-orphan", lazy="selectin")
|
backend/app/repositories/audit_repo.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List, Optional, Dict, Any
|
| 2 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 3 |
+
from sqlalchemy import select, desc, func
|
| 4 |
+
from backend.app.models.audit import AuditLog, AnalysisHistory, SystemEvent
|
| 5 |
+
from backend.app.models.user import User
|
| 6 |
+
from backend.app.models.document import MedicalDocument
|
| 7 |
+
from backend.app.models.conversation import AIConversation
|
| 8 |
+
from backend.app.repositories.base import BaseRepository
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class AuditRepository(BaseRepository[AuditLog]):
|
| 12 |
+
def __init__(self, db: AsyncSession):
|
| 13 |
+
super().__init__(AuditLog, db)
|
| 14 |
+
|
| 15 |
+
async def log_event(
|
| 16 |
+
self,
|
| 17 |
+
action: str,
|
| 18 |
+
user_id: Optional[str] = None,
|
| 19 |
+
ip_address: Optional[str] = None,
|
| 20 |
+
user_agent: Optional[str] = None,
|
| 21 |
+
status: str = "SUCCESS",
|
| 22 |
+
details: Optional[str] = None,
|
| 23 |
+
) -> AuditLog:
|
| 24 |
+
audit = AuditLog(
|
| 25 |
+
user_id=user_id,
|
| 26 |
+
action=action,
|
| 27 |
+
ip_address=ip_address,
|
| 28 |
+
user_agent=user_agent[:255] if user_agent else None,
|
| 29 |
+
status=status,
|
| 30 |
+
details=details,
|
| 31 |
+
)
|
| 32 |
+
self.db.add(audit)
|
| 33 |
+
await self.db.commit()
|
| 34 |
+
await self.db.refresh(audit)
|
| 35 |
+
return audit
|
| 36 |
+
|
| 37 |
+
async def add_history(
|
| 38 |
+
self,
|
| 39 |
+
user_id: str,
|
| 40 |
+
action_type: str,
|
| 41 |
+
description: str,
|
| 42 |
+
entity_count: int = 0,
|
| 43 |
+
reference_id: Optional[str] = None,
|
| 44 |
+
) -> AnalysisHistory:
|
| 45 |
+
history = AnalysisHistory(
|
| 46 |
+
user_id=user_id,
|
| 47 |
+
action_type=action_type,
|
| 48 |
+
description=description,
|
| 49 |
+
entity_count=entity_count,
|
| 50 |
+
reference_id=reference_id,
|
| 51 |
+
)
|
| 52 |
+
self.db.add(history)
|
| 53 |
+
await self.db.commit()
|
| 54 |
+
await self.db.refresh(history)
|
| 55 |
+
return history
|
| 56 |
+
|
| 57 |
+
async def get_user_history(self, user_id: str, limit: int = 50) -> List[AnalysisHistory]:
|
| 58 |
+
result = await self.db.execute(
|
| 59 |
+
select(AnalysisHistory)
|
| 60 |
+
.where(AnalysisHistory.user_id == user_id)
|
| 61 |
+
.order_by(desc(AnalysisHistory.created_at))
|
| 62 |
+
.limit(limit)
|
| 63 |
+
)
|
| 64 |
+
return list(result.scalars().all())
|
| 65 |
+
|
| 66 |
+
async def get_admin_audit_logs(self, limit: int = 100) -> List[AuditLog]:
|
| 67 |
+
result = await self.db.execute(
|
| 68 |
+
select(AuditLog).order_by(desc(AuditLog.created_at)).limit(limit)
|
| 69 |
+
)
|
| 70 |
+
return list(result.scalars().all())
|
| 71 |
+
|
| 72 |
+
async def get_system_statistics(self) -> Dict[str, int]:
|
| 73 |
+
total_users = (await self.db.execute(select(func.count(User.id)))).scalar_one() or 0
|
| 74 |
+
active_users = (await self.db.execute(select(func.count(User.id)).where(User.is_active == True))).scalar_one() or 0
|
| 75 |
+
total_docs = (await self.db.execute(select(func.count(MedicalDocument.id)))).scalar_one() or 0
|
| 76 |
+
total_chats = (await self.db.execute(select(func.count(AIConversation.id)))).scalar_one() or 0
|
| 77 |
+
total_ner = (await self.db.execute(select(func.count(AnalysisHistory.id)).where(AnalysisHistory.action_type == "NER"))).scalar_one() or 0
|
| 78 |
+
|
| 79 |
+
return {
|
| 80 |
+
"total_users": total_users,
|
| 81 |
+
"active_users": active_users,
|
| 82 |
+
"total_documents_processed": total_docs,
|
| 83 |
+
"total_chat_queries": total_chats,
|
| 84 |
+
"total_ner_requests": total_ner,
|
| 85 |
+
}
|
backend/app/repositories/base.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Generic, TypeVar, Type, Optional, List, Any
|
| 2 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 3 |
+
from sqlalchemy import select, delete
|
| 4 |
+
from backend.app.models.base import BaseModel
|
| 5 |
+
|
| 6 |
+
ModelType = TypeVar("ModelType", bound=BaseModel)
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class BaseRepository(Generic[ModelType]):
|
| 10 |
+
def __init__(self, model: Type[ModelType], db: AsyncSession):
|
| 11 |
+
self.model = model
|
| 12 |
+
self.db = db
|
| 13 |
+
|
| 14 |
+
async def get_by_id(self, id: str) -> Optional[ModelType]:
|
| 15 |
+
result = await self.db.execute(select(self.model).where(self.model.id == id))
|
| 16 |
+
return result.scalars().first()
|
| 17 |
+
|
| 18 |
+
async def get_all(self, skip: int = 0, limit: int = 100) -> List[ModelType]:
|
| 19 |
+
result = await self.db.execute(select(self.model).offset(skip).limit(limit))
|
| 20 |
+
return list(result.scalars().all())
|
| 21 |
+
|
| 22 |
+
async def create(self, entity: ModelType) -> ModelType:
|
| 23 |
+
self.db.add(entity)
|
| 24 |
+
await self.db.commit()
|
| 25 |
+
await self.db.refresh(entity)
|
| 26 |
+
return entity
|
| 27 |
+
|
| 28 |
+
async def update(self, entity: ModelType) -> ModelType:
|
| 29 |
+
await self.db.commit()
|
| 30 |
+
await self.db.refresh(entity)
|
| 31 |
+
return entity
|
| 32 |
+
|
| 33 |
+
async def delete(self, id: str) -> bool:
|
| 34 |
+
result = await self.db.execute(delete(self.model).where(self.model.id == id))
|
| 35 |
+
await self.db.commit()
|
| 36 |
+
return result.rowcount > 0
|
backend/app/repositories/chat_repo.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List, Optional
|
| 2 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 3 |
+
from sqlalchemy import select, desc
|
| 4 |
+
from sqlalchemy.orm import selectinload
|
| 5 |
+
from backend.app.models.conversation import AIConversation, AIMessage
|
| 6 |
+
from backend.app.repositories.base import BaseRepository
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class ChatRepository(BaseRepository[AIConversation]):
|
| 10 |
+
def __init__(self, db: AsyncSession):
|
| 11 |
+
super().__init__(AIConversation, db)
|
| 12 |
+
|
| 13 |
+
async def get_user_conversations(self, user_id: str) -> List[AIConversation]:
|
| 14 |
+
result = await self.db.execute(
|
| 15 |
+
select(AIConversation)
|
| 16 |
+
.options(selectinload(AIConversation.messages))
|
| 17 |
+
.where(AIConversation.user_id == user_id)
|
| 18 |
+
.order_by(desc(AIConversation.updated_at))
|
| 19 |
+
)
|
| 20 |
+
return list(result.scalars().all())
|
| 21 |
+
|
| 22 |
+
async def get_conversation_with_messages(self, conversation_id: str, user_id: str) -> Optional[AIConversation]:
|
| 23 |
+
result = await self.db.execute(
|
| 24 |
+
select(AIConversation)
|
| 25 |
+
.options(selectinload(AIConversation.messages))
|
| 26 |
+
.where(AIConversation.id == conversation_id, AIConversation.user_id == user_id)
|
| 27 |
+
)
|
| 28 |
+
return result.scalars().first()
|
| 29 |
+
|
| 30 |
+
async def add_message(self, message: AIMessage) -> AIMessage:
|
| 31 |
+
self.db.add(message)
|
| 32 |
+
await self.db.commit()
|
| 33 |
+
await self.db.refresh(message)
|
| 34 |
+
return message
|
backend/app/repositories/document_repo.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List, Optional
|
| 2 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 3 |
+
from sqlalchemy import select, desc
|
| 4 |
+
from sqlalchemy.orm import selectinload
|
| 5 |
+
from backend.app.models.document import MedicalDocument, DocumentAnalysis
|
| 6 |
+
from backend.app.models.entity import MedicalEntity
|
| 7 |
+
from backend.app.repositories.base import BaseRepository
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class DocumentRepository(BaseRepository[MedicalDocument]):
|
| 11 |
+
def __init__(self, db: AsyncSession):
|
| 12 |
+
super().__init__(MedicalDocument, db)
|
| 13 |
+
|
| 14 |
+
async def get_user_documents(self, user_id: str) -> List[MedicalDocument]:
|
| 15 |
+
result = await self.db.execute(
|
| 16 |
+
select(MedicalDocument)
|
| 17 |
+
.options(
|
| 18 |
+
selectinload(MedicalDocument.analysis).selectinload(DocumentAnalysis.entities)
|
| 19 |
+
)
|
| 20 |
+
.where(MedicalDocument.user_id == user_id)
|
| 21 |
+
.order_by(desc(MedicalDocument.created_at))
|
| 22 |
+
)
|
| 23 |
+
return list(result.scalars().all())
|
| 24 |
+
|
| 25 |
+
async def get_document_details(self, document_id: str, user_id: Optional[str] = None) -> Optional[MedicalDocument]:
|
| 26 |
+
query = (
|
| 27 |
+
select(MedicalDocument)
|
| 28 |
+
.options(
|
| 29 |
+
selectinload(MedicalDocument.analysis).selectinload(DocumentAnalysis.entities)
|
| 30 |
+
)
|
| 31 |
+
.where(MedicalDocument.id == document_id)
|
| 32 |
+
)
|
| 33 |
+
if user_id:
|
| 34 |
+
query = query.where(MedicalDocument.user_id == user_id)
|
| 35 |
+
|
| 36 |
+
result = await self.db.execute(query)
|
| 37 |
+
return result.scalars().first()
|
| 38 |
+
|
| 39 |
+
async def save_analysis(self, analysis: DocumentAnalysis) -> DocumentAnalysis:
|
| 40 |
+
self.db.add(analysis)
|
| 41 |
+
await self.db.commit()
|
| 42 |
+
await self.db.refresh(analysis)
|
| 43 |
+
return analysis
|
| 44 |
+
|
| 45 |
+
async def save_entities(self, entities: List[MedicalEntity]) -> List[MedicalEntity]:
|
| 46 |
+
self.db.add_all(entities)
|
| 47 |
+
await self.db.commit()
|
| 48 |
+
return entities
|
backend/app/repositories/user_repo.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Optional, List
|
| 2 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 3 |
+
from sqlalchemy import select
|
| 4 |
+
from sqlalchemy.orm import selectinload
|
| 5 |
+
from backend.app.models.user import User
|
| 6 |
+
from backend.app.models.profile import PatientProfile
|
| 7 |
+
from backend.app.repositories.base import BaseRepository
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class UserRepository(BaseRepository[User]):
|
| 11 |
+
def __init__(self, db: AsyncSession):
|
| 12 |
+
super().__init__(User, db)
|
| 13 |
+
|
| 14 |
+
async def get_by_email(self, email: str) -> Optional[User]:
|
| 15 |
+
result = await self.db.execute(
|
| 16 |
+
select(User).options(selectinload(User.profile)).where(User.email == email.lower())
|
| 17 |
+
)
|
| 18 |
+
return result.scalars().first()
|
| 19 |
+
|
| 20 |
+
async def get_with_profile(self, user_id: str) -> Optional[User]:
|
| 21 |
+
result = await self.db.execute(
|
| 22 |
+
select(User).options(selectinload(User.profile)).where(User.id == user_id)
|
| 23 |
+
)
|
| 24 |
+
return result.scalars().first()
|
| 25 |
+
|
| 26 |
+
async def get_profile_by_user_id(self, user_id: str) -> Optional[PatientProfile]:
|
| 27 |
+
result = await self.db.execute(
|
| 28 |
+
select(PatientProfile).where(PatientProfile.user_id == user_id)
|
| 29 |
+
)
|
| 30 |
+
return result.scalars().first()
|
| 31 |
+
|
| 32 |
+
async def save_profile(self, profile: PatientProfile) -> PatientProfile:
|
| 33 |
+
self.db.add(profile)
|
| 34 |
+
await self.db.commit()
|
| 35 |
+
await self.db.refresh(profile)
|
| 36 |
+
return profile
|
backend/app/schemas/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from backend.app.schemas.common import BaseResponse, ErrorResponse, HealthCheck, MEDICAL_DISCLAIMER
|
| 2 |
+
from backend.app.schemas.user import UserCreate, UserLogin, UserUpdate, UserResponse
|
| 3 |
+
from backend.app.schemas.token import TokenResponse, TokenPayload, RefreshTokenRequest
|
| 4 |
+
from backend.app.schemas.profile import PatientProfileCreate, PatientProfileUpdate, PatientProfileResponse
|
| 5 |
+
from backend.app.schemas.ner import NERRequest, NEREntity, NERResponse, ModelInfo
|
| 6 |
+
from backend.app.schemas.document import MedicalDocumentResponse, DocumentAnalysisResponse, DocumentUploadResponse
|
| 7 |
+
from backend.app.schemas.chat import ChatMessageCreate, ChatMessageResponse, ConversationResponse, AIStructuredOutput, ChatCompletionResponse
|
| 8 |
+
from backend.app.schemas.admin import AdminStatsResponse, AuditLogResponse, AnalysisHistoryResponse
|
| 9 |
+
|
| 10 |
+
__all__ = [
|
| 11 |
+
"BaseResponse",
|
| 12 |
+
"ErrorResponse",
|
| 13 |
+
"HealthCheck",
|
| 14 |
+
"MEDICAL_DISCLAIMER",
|
| 15 |
+
"UserCreate",
|
| 16 |
+
"UserLogin",
|
| 17 |
+
"UserUpdate",
|
| 18 |
+
"UserResponse",
|
| 19 |
+
"TokenResponse",
|
| 20 |
+
"TokenPayload",
|
| 21 |
+
"RefreshTokenRequest",
|
| 22 |
+
"PatientProfileCreate",
|
| 23 |
+
"PatientProfileUpdate",
|
| 24 |
+
"PatientProfileResponse",
|
| 25 |
+
"NERRequest",
|
| 26 |
+
"NEREntity",
|
| 27 |
+
"NERResponse",
|
| 28 |
+
"ModelInfo",
|
| 29 |
+
"MedicalDocumentResponse",
|
| 30 |
+
"DocumentAnalysisResponse",
|
| 31 |
+
"DocumentUploadResponse",
|
| 32 |
+
"ChatMessageCreate",
|
| 33 |
+
"ChatMessageResponse",
|
| 34 |
+
"ConversationResponse",
|
| 35 |
+
"AIStructuredOutput",
|
| 36 |
+
"ChatCompletionResponse",
|
| 37 |
+
"AdminStatsResponse",
|
| 38 |
+
"AuditLogResponse",
|
| 39 |
+
"AnalysisHistoryResponse",
|
| 40 |
+
]
|
backend/app/schemas/admin.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List, Optional, Dict, Any
|
| 2 |
+
from pydantic import BaseModel, ConfigDict
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class AuditLogResponse(BaseModel):
|
| 7 |
+
model_config = ConfigDict(from_attributes=True)
|
| 8 |
+
|
| 9 |
+
id: str
|
| 10 |
+
user_id: Optional[str] = None
|
| 11 |
+
action: str
|
| 12 |
+
ip_address: Optional[str] = None
|
| 13 |
+
user_agent: Optional[str] = None
|
| 14 |
+
status: str
|
| 15 |
+
details: Optional[str] = None
|
| 16 |
+
created_at: datetime
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class AnalysisHistoryResponse(BaseModel):
|
| 20 |
+
model_config = ConfigDict(from_attributes=True)
|
| 21 |
+
|
| 22 |
+
id: str
|
| 23 |
+
user_id: str
|
| 24 |
+
action_type: str
|
| 25 |
+
description: str
|
| 26 |
+
entity_count: int
|
| 27 |
+
reference_id: Optional[str] = None
|
| 28 |
+
created_at: datetime
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class AdminStatsResponse(BaseModel):
|
| 32 |
+
total_users: int
|
| 33 |
+
active_users: int
|
| 34 |
+
total_documents_processed: int
|
| 35 |
+
total_ner_requests: int
|
| 36 |
+
total_chat_queries: int
|
| 37 |
+
model_status: Dict[str, Any]
|
| 38 |
+
system_health: str
|
backend/app/schemas/chat.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List, Optional
|
| 2 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from backend.app.schemas.common import MEDICAL_DISCLAIMER
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class AIStructuredOutput(BaseModel):
|
| 8 |
+
summary: str
|
| 9 |
+
possible_considerations: List[str] = []
|
| 10 |
+
relevant_medical_info: List[str] = []
|
| 11 |
+
questions_for_doctor: List[str] = []
|
| 12 |
+
safety_warning: str = MEDICAL_DISCLAIMER
|
| 13 |
+
is_emergency: bool = False
|
| 14 |
+
emergency_instructions: Optional[str] = None
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class ChatMessageCreate(BaseModel):
|
| 18 |
+
conversation_id: Optional[str] = None
|
| 19 |
+
message: str = Field(..., min_length=1, max_length=5000)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class ChatMessageResponse(BaseModel):
|
| 23 |
+
model_config = ConfigDict(from_attributes=True)
|
| 24 |
+
|
| 25 |
+
id: str
|
| 26 |
+
conversation_id: str
|
| 27 |
+
role: str
|
| 28 |
+
content: str
|
| 29 |
+
structured_data: Optional[AIStructuredOutput] = None
|
| 30 |
+
model_provider: Optional[str] = None
|
| 31 |
+
created_at: datetime
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class ConversationResponse(BaseModel):
|
| 35 |
+
model_config = ConfigDict(from_attributes=True)
|
| 36 |
+
|
| 37 |
+
id: str
|
| 38 |
+
user_id: str
|
| 39 |
+
title: str
|
| 40 |
+
messages: List[ChatMessageResponse] = []
|
| 41 |
+
created_at: datetime
|
| 42 |
+
updated_at: datetime
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class ChatCompletionResponse(BaseModel):
|
| 46 |
+
conversation_id: str
|
| 47 |
+
message: ChatMessageResponse
|
| 48 |
+
disclaimer: str = MEDICAL_DISCLAIMER
|
backend/app/schemas/common.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Generic, TypeVar, Optional, Any, List
|
| 2 |
+
from pydantic import BaseModel, ConfigDict
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
|
| 5 |
+
T = TypeVar("T")
|
| 6 |
+
|
| 7 |
+
MEDICAL_DISCLAIMER = (
|
| 8 |
+
"SanjeevaniAI provides AI-assisted healthcare information and decision-support insights. "
|
| 9 |
+
"It is not a substitute for professional medical diagnosis, treatment, or emergency care."
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class BaseResponse(BaseModel, Generic[T]):
|
| 14 |
+
model_config = ConfigDict(from_attributes=True)
|
| 15 |
+
|
| 16 |
+
success: bool = True
|
| 17 |
+
message: Optional[str] = None
|
| 18 |
+
data: Optional[T] = None
|
| 19 |
+
disclaimer: str = MEDICAL_DISCLAIMER
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class ErrorDetail(BaseModel):
|
| 23 |
+
code: str
|
| 24 |
+
message: str
|
| 25 |
+
details: Optional[Any] = None
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class ErrorResponse(BaseModel):
|
| 29 |
+
success: bool = False
|
| 30 |
+
error: ErrorDetail
|
| 31 |
+
request_id: Optional[str] = None
|
| 32 |
+
disclaimer: str = MEDICAL_DISCLAIMER
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class HealthCheck(BaseModel):
|
| 36 |
+
status: str = "healthy"
|
| 37 |
+
version: str
|
| 38 |
+
environment: str
|
| 39 |
+
timestamp: datetime
|
| 40 |
+
services: dict
|
backend/app/schemas/document.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List, Optional
|
| 2 |
+
from pydantic import BaseModel, ConfigDict
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from backend.app.schemas.ner import NEREntity
|
| 5 |
+
from backend.app.schemas.common import MEDICAL_DISCLAIMER
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class DocumentAnalysisResponse(BaseModel):
|
| 9 |
+
model_config = ConfigDict(from_attributes=True)
|
| 10 |
+
|
| 11 |
+
id: str
|
| 12 |
+
document_id: str
|
| 13 |
+
raw_text: str
|
| 14 |
+
cleaned_text: Optional[str] = None
|
| 15 |
+
summary: Optional[str] = None
|
| 16 |
+
important_findings: List[str] = []
|
| 17 |
+
detected_conditions: List[str] = []
|
| 18 |
+
detected_medications: List[str] = []
|
| 19 |
+
clinical_recommendations: Optional[str] = None
|
| 20 |
+
entities: List[NEREntity] = []
|
| 21 |
+
processed_at: datetime
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class MedicalDocumentResponse(BaseModel):
|
| 25 |
+
model_config = ConfigDict(from_attributes=True)
|
| 26 |
+
|
| 27 |
+
id: str
|
| 28 |
+
user_id: str
|
| 29 |
+
filename: str
|
| 30 |
+
original_filename: str
|
| 31 |
+
file_type: str
|
| 32 |
+
file_size: int
|
| 33 |
+
status: str
|
| 34 |
+
error_message: Optional[str] = None
|
| 35 |
+
analysis: Optional[DocumentAnalysisResponse] = None
|
| 36 |
+
created_at: datetime
|
| 37 |
+
updated_at: datetime
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class DocumentUploadResponse(BaseModel):
|
| 41 |
+
document: MedicalDocumentResponse
|
| 42 |
+
message: str = "Document uploaded and processed successfully"
|
| 43 |
+
disclaimer: str = MEDICAL_DISCLAIMER
|
backend/app/schemas/ner.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List, Optional
|
| 2 |
+
from pydantic import BaseModel, Field
|
| 3 |
+
from backend.app.schemas.common import MEDICAL_DISCLAIMER
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class NERRequest(BaseModel):
|
| 7 |
+
text: str = Field(..., min_length=1, max_length=10000, description="Clinical or biomedical text to analyze")
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class NEREntity(BaseModel):
|
| 11 |
+
text: str
|
| 12 |
+
label: str # CHEMICAL, DISEASE
|
| 13 |
+
start: int
|
| 14 |
+
end: int
|
| 15 |
+
confidence: Optional[float] = None
|
| 16 |
+
model: str = "tner/roberta-large-bc5cdr"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class ModelInfo(BaseModel):
|
| 20 |
+
name: str = "tner/roberta-large-bc5cdr"
|
| 21 |
+
version: str = "local"
|
| 22 |
+
provider: str = "Local PyTorch / Transformers"
|
| 23 |
+
device: str = "cuda:0"
|
| 24 |
+
status: str = "Loaded"
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class NERResponse(BaseModel):
|
| 28 |
+
request_id: str
|
| 29 |
+
model: ModelInfo
|
| 30 |
+
entities: List[NEREntity]
|
| 31 |
+
entity_count: int
|
| 32 |
+
processing_time_ms: float
|
| 33 |
+
text_length: int
|
| 34 |
+
disclaimer: str = MEDICAL_DISCLAIMER
|
backend/app/schemas/profile.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Optional, List
|
| 2 |
+
from pydantic import BaseModel, Field, ConfigDict
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class PatientProfileBase(BaseModel):
|
| 7 |
+
age: Optional[int] = Field(None, ge=0, le=130)
|
| 8 |
+
gender: Optional[str] = Field(None, max_length=20)
|
| 9 |
+
blood_group: Optional[str] = Field(None, max_length=10)
|
| 10 |
+
height_cm: Optional[float] = Field(None, ge=20, le=300)
|
| 11 |
+
weight_kg: Optional[float] = Field(None, ge=1, le=500)
|
| 12 |
+
known_allergies: Optional[List[str]] = Field(default_factory=list)
|
| 13 |
+
chronic_conditions: Optional[List[str]] = Field(default_factory=list)
|
| 14 |
+
current_medications: Optional[List[str]] = Field(default_factory=list)
|
| 15 |
+
emergency_contact: Optional[str] = Field(None, max_length=255)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class PatientProfileCreate(PatientProfileBase):
|
| 19 |
+
pass
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class PatientProfileUpdate(PatientProfileBase):
|
| 23 |
+
pass
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class PatientProfileResponse(PatientProfileBase):
|
| 27 |
+
model_config = ConfigDict(from_attributes=True)
|
| 28 |
+
|
| 29 |
+
id: str
|
| 30 |
+
user_id: str
|
| 31 |
+
created_at: datetime
|
| 32 |
+
updated_at: datetime
|
backend/app/schemas/token.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Optional
|
| 2 |
+
from pydantic import BaseModel
|
| 3 |
+
from backend.app.schemas.user import UserResponse
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class TokenResponse(BaseModel):
|
| 7 |
+
access_token: str
|
| 8 |
+
refresh_token: str
|
| 9 |
+
token_type: str = "bearer"
|
| 10 |
+
expires_in: int
|
| 11 |
+
user: UserResponse
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class RefreshTokenRequest(BaseModel):
|
| 15 |
+
refresh_token: str
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class TokenPayload(BaseModel):
|
| 19 |
+
sub: Optional[str] = None
|
| 20 |
+
role: Optional[str] = None
|
| 21 |
+
type: Optional[str] = None
|
| 22 |
+
exp: Optional[int] = None
|
backend/app/schemas/user.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Optional
|
| 2 |
+
from pydantic import BaseModel, EmailStr, Field, ConfigDict
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from backend.app.core.security import UserRole
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class UserBase(BaseModel):
|
| 8 |
+
email: EmailStr
|
| 9 |
+
full_name: str = Field(..., min_length=2, max_length=255)
|
| 10 |
+
role: UserRole = UserRole.PATIENT
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class UserCreate(UserBase):
|
| 14 |
+
password: str = Field(..., min_length=8, max_length=128)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class UserLogin(BaseModel):
|
| 18 |
+
email: EmailStr
|
| 19 |
+
password: str
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class UserUpdate(BaseModel):
|
| 23 |
+
full_name: Optional[str] = Field(None, min_length=2, max_length=255)
|
| 24 |
+
email: Optional[EmailStr] = None
|
| 25 |
+
password: Optional[str] = Field(None, min_length=8, max_length=128)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class UserResponse(UserBase):
|
| 29 |
+
model_config = ConfigDict(from_attributes=True)
|
| 30 |
+
|
| 31 |
+
id: str
|
| 32 |
+
is_active: bool
|
| 33 |
+
is_verified: bool
|
| 34 |
+
created_at: datetime
|
| 35 |
+
updated_at: datetime
|
backend/app/services/auth_service.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Optional, Tuple
|
| 2 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 3 |
+
from backend.app.models.user import User
|
| 4 |
+
from backend.app.models.profile import PatientProfile
|
| 5 |
+
from backend.app.schemas.user import UserCreate, UserLogin, UserResponse
|
| 6 |
+
from backend.app.schemas.token import TokenResponse
|
| 7 |
+
from backend.app.core.security import (
|
| 8 |
+
verify_password,
|
| 9 |
+
get_password_hash,
|
| 10 |
+
create_access_token,
|
| 11 |
+
create_refresh_token,
|
| 12 |
+
decode_token,
|
| 13 |
+
UserRole,
|
| 14 |
+
)
|
| 15 |
+
from backend.app.core.exceptions import AuthenticationError, ValidationError, ResourceNotFoundError
|
| 16 |
+
from backend.app.repositories.user_repo import UserRepository
|
| 17 |
+
from backend.app.repositories.audit_repo import AuditRepository
|
| 18 |
+
from backend.app.core.config import settings
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class AuthService:
|
| 22 |
+
def __init__(self, db: AsyncSession):
|
| 23 |
+
self.db = db
|
| 24 |
+
self.user_repo = UserRepository(db)
|
| 25 |
+
self.audit_repo = AuditRepository(db)
|
| 26 |
+
|
| 27 |
+
async def register(self, user_in: UserCreate, ip_address: Optional[str] = None) -> Tuple[User, TokenResponse]:
|
| 28 |
+
existing = await self.user_repo.get_by_email(user_in.email)
|
| 29 |
+
if existing:
|
| 30 |
+
raise ValidationError(message="A user with this email already exists.")
|
| 31 |
+
|
| 32 |
+
hashed_pwd = get_password_hash(user_in.password)
|
| 33 |
+
user = User(
|
| 34 |
+
email=user_in.email.lower(),
|
| 35 |
+
hashed_password=hashed_pwd,
|
| 36 |
+
full_name=user_in.full_name,
|
| 37 |
+
role=user_in.role.value if isinstance(user_in.role, UserRole) else str(user_in.role),
|
| 38 |
+
is_active=True,
|
| 39 |
+
is_verified=True,
|
| 40 |
+
)
|
| 41 |
+
created_user = await self.user_repo.create(user)
|
| 42 |
+
|
| 43 |
+
# Create blank patient profile
|
| 44 |
+
profile = PatientProfile(user_id=created_user.id)
|
| 45 |
+
await self.user_repo.save_profile(profile)
|
| 46 |
+
|
| 47 |
+
# Log audit event
|
| 48 |
+
await self.audit_repo.log_event(
|
| 49 |
+
action="USER_REGISTER",
|
| 50 |
+
user_id=created_user.id,
|
| 51 |
+
ip_address=ip_address,
|
| 52 |
+
details=f"User registered with role {created_user.role}",
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
tokens = self._generate_tokens(created_user)
|
| 56 |
+
return created_user, tokens
|
| 57 |
+
|
| 58 |
+
async def login(self, login_in: UserLogin, ip_address: Optional[str] = None, user_agent: Optional[str] = None) -> TokenResponse:
|
| 59 |
+
user = await self.user_repo.get_by_email(login_in.email)
|
| 60 |
+
if not user or not verify_password(login_in.password, user.hashed_password):
|
| 61 |
+
await self.audit_repo.log_event(
|
| 62 |
+
action="LOGIN_FAILED",
|
| 63 |
+
ip_address=ip_address,
|
| 64 |
+
user_agent=user_agent,
|
| 65 |
+
status="FAILED",
|
| 66 |
+
details=f"Failed login attempt for email: {login_in.email}",
|
| 67 |
+
)
|
| 68 |
+
raise AuthenticationError(message="Incorrect email or password.")
|
| 69 |
+
|
| 70 |
+
if not user.is_active:
|
| 71 |
+
raise AuthenticationError(message="User account is inactive.")
|
| 72 |
+
|
| 73 |
+
await self.audit_repo.log_event(
|
| 74 |
+
action="USER_LOGIN",
|
| 75 |
+
user_id=user.id,
|
| 76 |
+
ip_address=ip_address,
|
| 77 |
+
user_agent=user_agent,
|
| 78 |
+
status="SUCCESS",
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
return self._generate_tokens(user)
|
| 82 |
+
|
| 83 |
+
async def refresh_tokens(self, refresh_token: str) -> TokenResponse:
|
| 84 |
+
payload = decode_token(refresh_token)
|
| 85 |
+
if not payload or payload.get("type") != "refresh":
|
| 86 |
+
raise AuthenticationError(message="Invalid or expired refresh token.")
|
| 87 |
+
|
| 88 |
+
user_id = payload.get("sub")
|
| 89 |
+
user = await self.user_repo.get_by_id(user_id)
|
| 90 |
+
if not user or not user.is_active:
|
| 91 |
+
raise AuthenticationError(message="User not found or inactive.")
|
| 92 |
+
|
| 93 |
+
return self._generate_tokens(user)
|
| 94 |
+
|
| 95 |
+
def _generate_tokens(self, user: User) -> TokenResponse:
|
| 96 |
+
access_token = create_access_token(subject=user.id, role=user.role)
|
| 97 |
+
refresh_token = create_refresh_token(subject=user.id, role=user.role)
|
| 98 |
+
|
| 99 |
+
return TokenResponse(
|
| 100 |
+
access_token=access_token,
|
| 101 |
+
refresh_token=refresh_token,
|
| 102 |
+
token_type="bearer",
|
| 103 |
+
expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
|
| 104 |
+
user=UserResponse.model_validate(user),
|
| 105 |
+
)
|