Sivaneshakumar commited on
Commit
151ec26
·
1 Parent(s): ee1ce77
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env.example +43 -0
  2. .github/workflows/ci.yml +54 -0
  3. .gitignore +52 -0
  4. README.md +138 -73
  5. backend/Dockerfile +34 -0
  6. backend/app/api/deps.py +61 -0
  7. backend/app/api/v1/endpoints/admin.py +47 -0
  8. backend/app/api/v1/endpoints/auth.py +72 -0
  9. backend/app/api/v1/endpoints/chat.py +209 -0
  10. backend/app/api/v1/endpoints/documents.py +139 -0
  11. backend/app/api/v1/endpoints/health.py +43 -0
  12. backend/app/api/v1/endpoints/history.py +26 -0
  13. backend/app/api/v1/endpoints/ner.py +55 -0
  14. backend/app/api/v1/endpoints/profile.py +89 -0
  15. backend/app/api/v1/router.py +22 -0
  16. backend/app/core/config.py +68 -0
  17. backend/app/core/database.py +53 -0
  18. backend/app/core/exceptions.py +82 -0
  19. backend/app/core/logger.py +45 -0
  20. backend/app/core/security.py +82 -0
  21. backend/app/main.py +130 -0
  22. backend/app/middleware/request_id.py +13 -0
  23. backend/app/middleware/security_headers.py +14 -0
  24. backend/app/ml/manager.py +54 -0
  25. backend/app/ml/ner/base.py +20 -0
  26. backend/app/ml/ner/bc5cdr.py +109 -0
  27. backend/app/ml/ner/service.py +34 -0
  28. backend/app/models/__init__.py +21 -0
  29. backend/app/models/audit.py +39 -0
  30. backend/app/models/base.py +25 -0
  31. backend/app/models/conversation.py +33 -0
  32. backend/app/models/document.py +39 -0
  33. backend/app/models/entity.py +18 -0
  34. backend/app/models/profile.py +21 -0
  35. backend/app/models/user.py +22 -0
  36. backend/app/repositories/audit_repo.py +85 -0
  37. backend/app/repositories/base.py +36 -0
  38. backend/app/repositories/chat_repo.py +34 -0
  39. backend/app/repositories/document_repo.py +48 -0
  40. backend/app/repositories/user_repo.py +36 -0
  41. backend/app/schemas/__init__.py +40 -0
  42. backend/app/schemas/admin.py +38 -0
  43. backend/app/schemas/chat.py +48 -0
  44. backend/app/schemas/common.py +40 -0
  45. backend/app/schemas/document.py +43 -0
  46. backend/app/schemas/ner.py +34 -0
  47. backend/app/schemas/profile.py +32 -0
  48. backend/app/schemas/token.py +22 -0
  49. backend/app/schemas/user.py +35 -0
  50. 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
- # Sanjeevani AI
2
- ### Voice-first, multilingual polypharmacy safety companion
3
- **PHARMINNO QUEST 2026 — Karpagam Academy of Higher Education**
4
-
5
- ## The problem
6
- Millions of Indians — especially elderly and chronic-disease patients — take
7
- medicines prescribed by multiple doctors with no single check for dangerous
8
- drug interactions, wrong dosing, or duplicate therapy. Existing digital
9
- health apps are built for literate, English-speaking, single-language users,
10
- which leaves out exactly the people most at risk.
11
-
12
- ## The solution
13
- Sanjeevani AI lets a patient or caregiver add every medicine they're taking
14
- (by typing or by scanning the strip), cross-checks the full list against a
15
- curated clinical drug-interaction database, and explains any risk in plain
16
- language — with voice support — in **English, Hindi, and Tamil**. It also
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
- index.html Main app shell (all screens)
59
- css/style.css Styling (brand palette matches the pitch deck)
60
- js/app.js App logic, screen routing, rendering
61
- js/i18n.js English / Hindi / Tamil UI strings
62
- js/interactionEngine.js Fuzzy search + interaction rule engine
63
- data/drugs.js 30-drug catalog (generic, brands, hi/ta names)
64
- data/interactions.js 16 curated drug-pair interaction rules
65
- manifest.json PWA manifest
66
- sw.js Offline service worker
 
 
 
 
 
 
 
 
 
 
67
  ```
68
 
69
- ## Important note
70
- This is a hackathon prototype. The drug and interaction data is a small,
71
- hand-curated demo set for illustration — **not** a complete or clinically
72
- validated database, and the app is not a substitute for a pharmacist or
73
- doctor. A production version would need a licensed pharmacist to maintain
74
- and expand the interaction database against authoritative references.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
 
76
- ## Team
77
- Monochrome— PHARMINNO QUEST 2026
 
1
+ # SanjeevaniAI — Industry-Grade Healthcare AI Platform
2
+
3
+ [![FastAPI](https://img.shields.io/badge/FastAPI-0.115+-009688?style=flat&logo=fastapi&logoColor=white)](https://fastapi.tiangolo.com)
4
+ [![PyTorch](https://img.shields.io/badge/PyTorch-2.2+-EE4C2C?style=flat&logo=pytorch&logoColor=white)](https://pytorch.org)
5
+ [![HuggingFace](https://img.shields.io/badge/Model-RoBERTa--large--BC5CDR-FFD21E?style=flat&logo=huggingface&logoColor=black)](https://huggingface.co/tner/roberta-large-bc5cdr)
6
+ [![Next.js](https://img.shields.io/badge/Next.js-14.2+-000000?style=flat&logo=next.js&logoColor=white)](https://nextjs.org)
7
+ [![TailwindCSS](https://img.shields.io/badge/TailwindCSS-3.4+-38B2AC?style=flat&logo=tailwind-css&logoColor=white)](https://tailwindcss.com)
8
+ [![Docker](https://img.shields.io/badge/Docker-Ready-2496ED?style=flat&logo=docker&logoColor=white)](https://www.docker.com)
9
+ [![Tests](https://img.shields.io/badge/Tests-16%2F16%20Passing-brightgreen?style=flat&logo=pytest&logoColor=white)](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
+ )