diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..2e929625dbd2fc9334a275e213f7fda02bd4a8c5 --- /dev/null +++ b/.env.example @@ -0,0 +1,43 @@ +# SanjeevaniAI Configuration Environment Variables + +# Application +APP_ENV=development +APP_NAME=SanjeevaniAI +APP_VERSION=1.0.0 +DEBUG=True +HOST=0.0.0.0 +PORT=8000 + +# Security (Generate a secure secret key for production) +SECRET_KEY=sanjeevani-ai-secret-key-change-in-production-secure-random-2026 +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=60 +REFRESH_TOKEN_EXPIRE_DAYS=7 + +# CORS +CORS_ORIGINS=["http://localhost:3000","http://127.0.0.1:3000"] + +# Database +# Default: SQLite async database for development. For PostgreSQL use: +# DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/sanjeevani_db +DATABASE_URL=sqlite+aiosqlite:///./sanjeevani.db + +# Redis & Cache (Optional / Docker) +REDIS_URL=redis://localhost:6379/0 + +# Biomedical ML Models +NER_MODEL_PATH=D:/SanjeevaniAI/models/bc5cdr-ner +DEVICE=auto + +# LLM Providers (mock, gemini, openai) +LLM_PROVIDER=gemini +GEMINI_API_KEY=your-gemini-api-key-here +OPENAI_API_KEY= + +# Document Storage & Limits +UPLOAD_DIR=./uploads +MAX_UPLOAD_SIZE_MB=25 +ALLOWED_EXTENSIONS=["pdf","txt","docx"] + +# Rate Limiting +RATE_LIMIT_PER_MINUTE=60 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000000000000000000000000000000000000..bc7d280630ead2cb944cc7368c84767bbf7bbd4f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,54 @@ +name: SanjeevaniAI CI Pipeline + +on: + push: + branches: [ main, master, develop ] + pull_request: + branches: [ main, master ] + +jobs: + backend-tests: + name: Backend Pytest & Lint + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: "pip" + + - name: Install Python Dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run Backend Pytest Suite + run: | + pytest backend/tests -v --durations=10 + + frontend-build: + name: Frontend Typecheck & Build + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Set up Node.js 20.x + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "npm" + cache-dependency-path: frontend/package.json + + - name: Install Frontend Dependencies + working-directory: ./frontend + run: npm ci + + - name: Next.js Typecheck & Production Build + working-directory: ./frontend + run: npm run build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..fc065f1e9e6a7232634f17ed2c358ce1aad4772f --- /dev/null +++ b/.gitignore @@ -0,0 +1,52 @@ +# Python & Virtual Environments +.venv/ +venv/ +env/ +__pycache__/ +*.py[cod] +*$py.class +.pytest_cache/ +*.log + +# Sensitive Credentials +.env +.env.local +.env.*.local + +# Database & Uploads +*.db +sanjeevani.db +uploads/* +!uploads/.gitkeep + +# Large Pretrained ML Model Weights (>100MB exceeds GitHub push limit) +# Note: Cloud deployment automatically fetches 'tner/roberta-large-bc5cdr' from HuggingFace +models/ +models/* +!models/.gitkeep +checkpoints/ +checkpoints/* +datasets/ +datasets/* + +# Legacy Prototype Folders +app/ +clinical_engine/ +evaluation/ +rag/ +training/ + +# Node.js & Next.js Frontend +frontend/node_modules/ +frontend/.next/ +frontend/.vercel/ +frontend/out/ +frontend/npm-debug.log* +frontend/yarn-debug.log* +frontend/yarn-error.log* + +# IDE & OS Files +.vscode/ +.idea/ +.DS_Store +Thumbs.db diff --git a/README.md b/README.md index 23d48d4584a1ebea1688c341d9658c4da5dc869e..c7c5f4adf877494129a85576c0a3377297085ee7 100644 --- a/README.md +++ b/README.md @@ -1,77 +1,142 @@ -# Sanjeevani AI -### Voice-first, multilingual polypharmacy safety companion -**PHARMINNO QUEST 2026 — Karpagam Academy of Higher Education** - -## The problem -Millions of Indians — especially elderly and chronic-disease patients — take -medicines prescribed by multiple doctors with no single check for dangerous -drug interactions, wrong dosing, or duplicate therapy. Existing digital -health apps are built for literate, English-speaking, single-language users, -which leaves out exactly the people most at risk. - -## The solution -Sanjeevani AI lets a patient or caregiver add every medicine they're taking -(by typing or by scanning the strip), cross-checks the full list against a -curated clinical drug-interaction database, and explains any risk in plain -language — with voice support — in **English, Hindi, and Tamil**. It also -builds an icon-based daily schedule for low-literacy users and gives -pharmacists/caregivers a one-screen summary for counselling. - -## Running it -No install, no build step, no server required. - -1. Open `index.html` directly in any modern browser (Chrome, Edge, Firefox, - Safari) — double-click it or drag it into a browser tab. -2. Pick a language, enter a patient name, and start adding medicines. -3. Try adding **Ramipril** and **Spironolactone** together to see a real - "severe" interaction alert, or try the fuzzy search with a typo like - "amlong" or "dolo". - -Optional: serving it over a local server (e.g. `python3 -m http.server`) -also works and additionally enables the offline service worker cache. - -## What's implemented -- **Fuzzy medicine search** (Levenshtein-based) — tolerant of typos, OCR - noise, and partial names; matches generic names, common Indian brand - names, and Hindi/Tamil names. -- **On-device OCR strip scan** via Tesseract.js (loaded from CDN when - online) — snap/upload a photo of a medicine strip and it's matched - automatically; degrades gracefully to manual search if offline. -- **Interaction rule engine** — 16 curated, real drug-pair interactions - (e.g. Warfarin+Aspirin, Ramipril+Spironolactone, Sildenafil+Isosorbide) - across three severity levels, each with a plain-language explanation and - recommendation, in three languages. -- **Icon-based daily schedule** — groups medicines into morning / afternoon - / night with before/after-food tags, designed to be readable without - fluent literacy. -- **Voice reminders** — uses the browser's built-in Web Speech API - (`speechSynthesis`), no external API or cost. -- **Pharmacist / caregiver dashboard** — a printable one-screen summary of - all medicines and flagged interactions for counselling. -- **Offline-first** — a service worker caches the app shell after first - load; all clinical data ships as plain JS (`data/drugs.js`, - `data/interactions.js`), so the app works with zero connectivity and no - backend. - -## Project structure +# SanjeevaniAI — Industry-Grade Healthcare AI Platform + +[![FastAPI](https://img.shields.io/badge/FastAPI-0.115+-009688?style=flat&logo=fastapi&logoColor=white)](https://fastapi.tiangolo.com) +[![PyTorch](https://img.shields.io/badge/PyTorch-2.2+-EE4C2C?style=flat&logo=pytorch&logoColor=white)](https://pytorch.org) +[![HuggingFace](https://img.shields.io/badge/Model-RoBERTa--large--BC5CDR-FFD21E?style=flat&logo=huggingface&logoColor=black)](https://huggingface.co/tner/roberta-large-bc5cdr) +[![Next.js](https://img.shields.io/badge/Next.js-14.2+-000000?style=flat&logo=next.js&logoColor=white)](https://nextjs.org) +[![TailwindCSS](https://img.shields.io/badge/TailwindCSS-3.4+-38B2AC?style=flat&logo=tailwind-css&logoColor=white)](https://tailwindcss.com) +[![Docker](https://img.shields.io/badge/Docker-Ready-2496ED?style=flat&logo=docker&logoColor=white)](https://www.docker.com) +[![Tests](https://img.shields.io/badge/Tests-16%2F16%20Passing-brightgreen?style=flat&logo=pytest&logoColor=white)](backend/tests) + +> **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. + +--- + +## 🏥 Architecture Overview + ``` -index.html Main app shell (all screens) -css/style.css Styling (brand palette matches the pitch deck) -js/app.js App logic, screen routing, rendering -js/i18n.js English / Hindi / Tamil UI strings -js/interactionEngine.js Fuzzy search + interaction rule engine -data/drugs.js 30-drug catalog (generic, brands, hi/ta names) -data/interactions.js 16 curated drug-pair interaction rules -manifest.json PWA manifest -sw.js Offline service worker +SanjeevaniAI Platform +├── Frontend (Next.js 14 + Tailwind CSS + Lucide Icons) +│ ├── / -> Landing page with medical disclaimer & capabilities +│ ├── /dashboard -> Clinical intelligence overview & metrics +│ ├── /ner -> Real-time local RoBERTa BC5CDR NER visualizer +│ ├── /reports -> Document upload, SHA-256 integrity & analysis +│ ├── /reports/[id] -> Structured clinical findings & raw text +│ ├── /assistant -> Conversational AI with emergency triage +│ ├── /profile -> Patient physiological profile & BMI +│ ├── /history -> Traceable medical timeline +│ ├── /admin -> System telemetry & security audit logs +│ └── /demo -> Guided 5-step mentor presentation walkthrough +│ +├── Backend (FastAPI + Async SQLAlchemy + PyTorch) +│ ├── Core & Security -> JWT, bcrypt hashing, RBAC, Request ID, CSP headers +│ ├── ML Engine -> Local RoBERTa-large BC5CDR (355M params, CUDA/CPU) +│ ├── Document Pipeline-> PDF/DOCX/TXT parser, chunker, entity aggregator +│ ├── AI Assistant -> Multi-provider LLM (Gemini + Mock) & triage engine +│ └── Persistence -> Async SQLite / PostgreSQL with audit trail ``` -## Important note -This is a hackathon prototype. The drug and interaction data is a small, -hand-curated demo set for illustration — **not** a complete or clinically -validated database, and the app is not a substitute for a pharmacist or -doctor. A production version would need a licensed pharmacist to maintain -and expand the interaction database against authoritative references. +--- + +## ⚡ Quick Start + +### 1. Prerequisites +- **Python**: 3.10+ (Recommended: Python 3.11) +- **Node.js**: 18.x or 20+ (with npm) +- **NVIDIA GPU (Optional)**: CUDA 11.8+ for accelerated inference (CPU fallback automatic) +- **Pretrained NER Model**: Stored locally in `models/bc5cdr-ner` + +### 2. Backend Setup +```bash +# Activate virtual environment +python -m venv .venv +.\.venv\Scripts\Activate.ps1 # Windows PowerShell +# source .venv/bin/activate # Linux / macOS + +# Install dependencies +pip install -r requirements.txt + +# Run synthetic demo database seeder +python scripts/seed_demo_data.py + +# Launch FastAPI backend +uvicorn backend.app.main:app --host 0.0.0.0 --port 8000 --reload +``` +*API Swagger UI will be live at `http://localhost:8000/docs`.* + +### 3. Frontend Setup +```bash +cd frontend + +# Install npm dependencies +npm install + +# Run Next.js development server +npm run dev +``` +*Application UI will be live at `http://localhost:3000`.* + +--- + +## 🐳 Docker Deployment + +To launch the full containerized stack: +```bash +docker-compose up --build +``` +- Frontend: `http://localhost:3000` +- Backend API: `http://localhost:8000` +- Health Check: `http://localhost:8000/api/v1/health` + +--- + +## 🧪 Test Suite & Verification + +The platform includes a comprehensive automated test suite in `backend/tests/`: + +```bash +pytest backend/tests -v +``` + +### Verified Test Results (16/16 Passing): +- `test_auth.py`: User registration, login, JWT validation, and invalid credential handling. +- `test_ner.py`: BC5CDR model info, entity extraction, character span alignment, and 422 validation. +- `test_documents.py`: File upload, SHA-256 fingerprinting, raw text extraction, and entity linking. +- `test_chat.py`: Multi-turn consultation, patient profile injection, and heuristic red-flag emergency detection. +- `test_profile.py`: Patient profile updates, vitals calculations, and RBAC admin permission enforcement. +- `test_health.py`: Liveness and readiness probes. + +--- + +## 👨‍🏫 Mentor Demonstration Credentials + +Use the **"Demo Roles"** dropdown in the navigation bar or log in with these pre-seeded accounts: + +| Account | Email | Password | Role / Access Level | +| :--- | :--- | :--- | :--- | +| **Patient** | `demo.patient@sanjeevani.ai` | `DemoPatient2026!` | Health profile, documents, AI consultation | +| **Doctor** | `demo.doctor@sanjeevani.ai` | `DemoDoctor2026!` | Clinical decision support & document review | +| **Administrator** | `demo.admin@sanjeevani.ai` | `DemoAdmin2026!` | Platform telemetry, model status, security audit logs | + +--- + +## 📚 Technical Documentation + +- 📐 [**System Architecture & Mermaid Diagrams**](docs/ARCHITECTURE.md) +- 🔌 [**REST API Catalog & OpenAPI Specification**](docs/API.md) +- 🧠 [**Machine Learning & Local RoBERTa BC5CDR Specs**](docs/ML_MODELS.md) +- 🎯 [**10-Minute Mentor Presentation Script**](docs/MENTOR_DEMO.md) +- 🔍 [**Initial Project Audit & Gap Analysis**](docs/PROJECT_AUDIT.md) + +--- + +## 🛡️ Medical Safety & Privacy Disclaimers + +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. +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). +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. + +--- -## Team -Monochrome— PHARMINNO QUEST 2026 +## 📄 License +MIT License. Developed for healthcare AI intelligence and clinical decision-support research. diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..96aeffc77c66eb5bb21337cb408c0258a20b41ce --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,34 @@ +# syntax=docker/dockerfile:1 +FROM python:3.11-slim AS base + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +WORKDIR /app + +# Install system dependencies (build-essential, curl) +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Install Python requirements +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application source +COPY backend /app/backend +COPY models /app/models +COPY uploads /app/uploads + +# Expose port +EXPOSE 8000 + +# Healthcheck +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD curl -f http://localhost:8000/api/v1/health || exit 1 + +# Run Uvicorn +CMD ["uvicorn", "backend.app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py new file mode 100644 index 0000000000000000000000000000000000000000..84bb8acf47701a93b6de3c52d284806b713b39b2 --- /dev/null +++ b/backend/app/api/deps.py @@ -0,0 +1,61 @@ +from typing import Generator, Optional, List +from fastapi import Depends, HTTPException, status, Request +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from sqlalchemy.ext.asyncio import AsyncSession +from backend.app.core.database import get_db +from backend.app.core.security import decode_token, UserRole +from backend.app.core.exceptions import AuthenticationError, PermissionDeniedError +from backend.app.repositories.user_repo import UserRepository +from backend.app.models.user import User + +security_scheme = HTTPBearer(auto_error=False) + + +async def get_current_user( + token_auth: Optional[HTTPAuthorizationCredentials] = Depends(security_scheme), + db: AsyncSession = Depends(get_db), +) -> User: + if not token_auth or not token_auth.credentials: + raise AuthenticationError(message="Authentication token missing.") + + payload = decode_token(token_auth.credentials) + if not payload or payload.get("type") != "access": + raise AuthenticationError(message="Invalid or expired access token.") + + user_id = payload.get("sub") + if not user_id: + raise AuthenticationError(message="Token missing subject identifier.") + + user_repo = UserRepository(db) + user = await user_repo.get_by_id(user_id) + if not user: + raise AuthenticationError(message="User no longer exists.") + + if not user.is_active: + raise AuthenticationError(message="User account is deactivated.") + + return user + + +def require_roles(allowed_roles: List[UserRole]): + async def role_checker(current_user: User = Depends(get_current_user)) -> User: + user_role = current_user.role + role_values = [r.value for r in allowed_roles] + if user_role not in role_values and user_role != UserRole.ADMIN.value: + raise PermissionDeniedError(message="You do not have permission to access this resource.") + return current_user + + return role_checker + + +async def get_current_admin(current_user: User = Depends(get_current_user)) -> User: + if current_user.role != UserRole.ADMIN.value: + raise PermissionDeniedError(message="Administrative privileges required.") + return current_user + + +def get_client_ip(request: Request) -> Optional[str]: + forwarded = request.headers.get("X-Forwarded-For") + if forwarded: + return forwarded.split(",")[0].strip() + return request.client.host if request.client else None diff --git a/backend/app/api/v1/endpoints/admin.py b/backend/app/api/v1/endpoints/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..619ddee1c4b42bc83c4208ded821709a2a405a8f --- /dev/null +++ b/backend/app/api/v1/endpoints/admin.py @@ -0,0 +1,47 @@ +from typing import List +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession +from backend.app.core.database import get_db +from backend.app.schemas.admin import AdminStatsResponse, AuditLogResponse +from backend.app.schemas.common import BaseResponse +from backend.app.repositories.audit_repo import AuditRepository +from backend.app.ml.manager import model_manager +from backend.app.api.deps import get_current_admin +from backend.app.models.user import User + +router = APIRouter() + + +@router.get("/stats", response_model=BaseResponse[AdminStatsResponse]) +async def get_admin_stats( + current_admin: User = Depends(get_current_admin), + db: AsyncSession = Depends(get_db), +): + """Retrieve platform usage statistics, throughput metrics, and model status.""" + audit_repo = AuditRepository(db) + stats = await audit_repo.get_system_statistics() + model_status = model_manager.get_status() + + admin_stats = AdminStatsResponse( + total_users=stats["total_users"], + active_users=stats["active_users"], + total_documents_processed=stats["total_documents_processed"], + total_ner_requests=stats["total_ner_requests"], + total_chat_queries=stats["total_chat_queries"], + model_status=model_status, + system_health="Operational", + ) + return BaseResponse(success=True, data=admin_stats) + + +@router.get("/audit-logs", response_model=BaseResponse[List[AuditLogResponse]]) +async def get_audit_logs( + limit: int = 100, + current_admin: User = Depends(get_current_admin), + db: AsyncSession = Depends(get_db), +): + """Retrieve system security and access audit trail.""" + audit_repo = AuditRepository(db) + logs = await audit_repo.get_admin_audit_logs(limit=limit) + response_list = [AuditLogResponse.model_validate(log) for log in logs] + return BaseResponse(success=True, data=response_list) diff --git a/backend/app/api/v1/endpoints/auth.py b/backend/app/api/v1/endpoints/auth.py new file mode 100644 index 0000000000000000000000000000000000000000..593c82f674f14e2b7c45da6cf053d4e2fe0cfee4 --- /dev/null +++ b/backend/app/api/v1/endpoints/auth.py @@ -0,0 +1,72 @@ +from fastapi import APIRouter, Depends, Request +from sqlalchemy.ext.asyncio import AsyncSession +from backend.app.core.database import get_db +from backend.app.schemas.user import UserCreate, UserLogin, UserResponse +from backend.app.schemas.token import TokenResponse, RefreshTokenRequest +from backend.app.schemas.common import BaseResponse +from backend.app.services.auth_service import AuthService +from backend.app.api.deps import get_current_user, get_client_ip +from backend.app.models.user import User + +router = APIRouter() + + +@router.post("/register", response_model=BaseResponse[TokenResponse]) +async def register( + user_in: UserCreate, + request: Request, + db: AsyncSession = Depends(get_db), +): + """Register a new user and receive initial access/refresh tokens.""" + ip = get_client_ip(request) + auth_service = AuthService(db) + user, tokens = await auth_service.register(user_in, ip_address=ip) + return BaseResponse( + success=True, + message="User registered successfully.", + data=tokens, + ) + + +@router.post("/login", response_model=BaseResponse[TokenResponse]) +async def login( + login_in: UserLogin, + request: Request, + db: AsyncSession = Depends(get_db), +): + """Authenticate with email and password to receive JWT credentials.""" + ip = get_client_ip(request) + agent = request.headers.get("User-Agent") + auth_service = AuthService(db) + tokens = await auth_service.login(login_in, ip_address=ip, user_agent=agent) + return BaseResponse( + success=True, + message="Login successful.", + data=tokens, + ) + + +@router.post("/refresh", response_model=BaseResponse[TokenResponse]) +async def refresh_token( + refresh_in: RefreshTokenRequest, + db: AsyncSession = Depends(get_db), +): + """Exchange a valid refresh token for a new access/refresh token pair.""" + auth_service = AuthService(db) + tokens = await auth_service.refresh_tokens(refresh_in.refresh_token) + return BaseResponse( + success=True, + message="Token refreshed successfully.", + data=tokens, + ) + + +@router.get("/me", response_model=BaseResponse[UserResponse]) +async def get_me( + current_user: User = Depends(get_current_user), +): + """Retrieve the currently authenticated user's profile metadata.""" + return BaseResponse( + success=True, + data=UserResponse.model_validate(current_user), + ) diff --git a/backend/app/api/v1/endpoints/chat.py b/backend/app/api/v1/endpoints/chat.py new file mode 100644 index 0000000000000000000000000000000000000000..1fe9af612b4d8ca2c80977c0dc28ec8945ad5775 --- /dev/null +++ b/backend/app/api/v1/endpoints/chat.py @@ -0,0 +1,209 @@ +import json +from typing import List +from fastapi import APIRouter, Depends, Request +from sqlalchemy.ext.asyncio import AsyncSession +from backend.app.core.database import get_db +from backend.app.schemas.chat import ( + ChatMessageCreate, + ChatMessageResponse, + ConversationResponse, + ChatCompletionResponse, + AIStructuredOutput, +) +from backend.app.schemas.common import BaseResponse, MEDICAL_DISCLAIMER +from backend.app.models.conversation import AIConversation, AIMessage +from backend.app.models.user import User +from backend.app.repositories.chat_repo import ChatRepository +from backend.app.repositories.user_repo import UserRepository +from backend.app.repositories.audit_repo import AuditRepository +from backend.app.services.llm.service import llm_service +from backend.app.api.deps import get_current_user +from backend.app.core.exceptions import ResourceNotFoundError + +router = APIRouter() + + +def _format_message(msg: AIMessage) -> ChatMessageResponse: + structured = None + if msg.structured_data: + try: + parsed = json.loads(msg.structured_data) + structured = AIStructuredOutput(**parsed) + except Exception: + pass + + return ChatMessageResponse( + id=msg.id, + conversation_id=msg.conversation_id, + role=msg.role, + content=msg.content, + structured_data=structured, + model_provider=msg.model_provider, + created_at=msg.created_at, + ) + + +@router.post("/message", response_model=BaseResponse[ChatCompletionResponse]) +async def send_chat_message( + chat_in: ChatMessageCreate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + Send a medical query to the AI Assistant. + Generates structured healthcare decision-support insights, recommendations, and emergency triage alerts. + """ + chat_repo = ChatRepository(db) + user_repo = UserRepository(db) + audit_repo = AuditRepository(db) + + # 1. Resolve or Create Conversation + history_messages = [] + if chat_in.conversation_id: + conversation = await chat_repo.get_conversation_with_messages( + chat_in.conversation_id, current_user.id + ) + if conversation: + history_messages = conversation.messages or [] + else: + conversation = AIConversation( + id=chat_in.conversation_id, + user_id=current_user.id, + title=chat_in.message[:45] + ("..." if len(chat_in.message) > 45 else ""), + ) + conversation = await chat_repo.create(conversation) + else: + title_snippet = chat_in.message[:45] + ("..." if len(chat_in.message) > 45 else "") + conversation = AIConversation( + user_id=current_user.id, + title=title_snippet or "Medical Consultation", + ) + conversation = await chat_repo.create(conversation) + + # 2. Save User Message + user_msg = AIMessage( + conversation_id=conversation.id, + role="user", + content=chat_in.message, + ) + await chat_repo.add_message(user_msg) + + # 3. Retrieve Patient Health Profile Context + patient_context = {} + profile = await user_repo.get_profile_by_user_id(current_user.id) + if profile: + patient_context = { + "age": profile.age, + "gender": profile.gender, + "blood_group": profile.blood_group, + "known_allergies": json.loads(profile.known_allergies) if profile.known_allergies else [], + "chronic_conditions": json.loads(profile.chronic_conditions) if profile.chronic_conditions else [], + "current_medications": json.loads(profile.current_medications) if profile.current_medications else [], + } + + # 4. Format Chat History + history = [ + {"role": m.role, "content": m.content} + for m in history_messages[-6:] + ] + + # 5. Execute LLM Consultation + structured_output, provider_name = await llm_service.consult( + query=chat_in.message, + chat_history=history, + patient_context=patient_context, + ) + + # 6. Save Assistant Response + assistant_msg = AIMessage( + conversation_id=conversation.id, + role="assistant", + content=structured_output.summary, + structured_data=json.dumps(structured_output.model_dump()), + model_provider=provider_name, + ) + saved_assistant_msg = await chat_repo.add_message(assistant_msg) + + # 7. Add Audit & Timeline + await audit_repo.add_history( + user_id=current_user.id, + action_type="CHAT", + description=f"Consulted AI Assistant: '{chat_in.message[:40]}...'", + reference_id=conversation.id, + ) + + return BaseResponse( + success=True, + data=ChatCompletionResponse( + conversation_id=conversation.id, + message=_format_message(saved_assistant_msg), + disclaimer=MEDICAL_DISCLAIMER, + ), + ) + + +@router.get("/conversations", response_model=BaseResponse[List[ConversationResponse]]) +async def list_conversations( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Retrieve all historical AI consultation threads for the current user.""" + chat_repo = ChatRepository(db) + convs = await chat_repo.get_user_conversations(current_user.id) + response_list = [ + ConversationResponse( + id=c.id, + user_id=c.user_id, + title=c.title, + messages=[_format_message(m) for m in (c.messages or [])], + created_at=c.created_at, + updated_at=c.updated_at, + ) + for c in convs + ] + return BaseResponse(success=True, data=response_list) + + +@router.get("/conversations/{conversation_id}", response_model=BaseResponse[ConversationResponse]) +async def get_conversation( + conversation_id: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Fetch complete message history for a specific conversation.""" + chat_repo = ChatRepository(db) + c = await chat_repo.get_conversation_with_messages(conversation_id, current_user.id) + if not c: + raise ResourceNotFoundError(resource="Conversation", resource_id=conversation_id) + + return BaseResponse( + success=True, + data=ConversationResponse( + id=c.id, + user_id=c.user_id, + title=c.title, + messages=[_format_message(m) for m in (c.messages or [])], + created_at=c.created_at, + updated_at=c.updated_at, + ), + ) + + +@router.delete("/conversations/{conversation_id}", response_model=BaseResponse[dict]) +async def delete_conversation( + conversation_id: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Delete a conversation thread and its message history.""" + chat_repo = ChatRepository(db) + conv = await chat_repo.get_conversation_with_messages(conversation_id, current_user.id) + if not conv: + raise ResourceNotFoundError(resource="Conversation", resource_id=conversation_id) + + await chat_repo.delete(conversation_id) + return BaseResponse( + success=True, + message="Conversation deleted.", + data={"deleted_id": conversation_id}, + ) diff --git a/backend/app/api/v1/endpoints/documents.py b/backend/app/api/v1/endpoints/documents.py new file mode 100644 index 0000000000000000000000000000000000000000..5053474b87b8a239ee4f3ca1b4af502a19173de5 --- /dev/null +++ b/backend/app/api/v1/endpoints/documents.py @@ -0,0 +1,139 @@ +import json +from typing import List +from fastapi import APIRouter, Depends, UploadFile, File, Request, status +from sqlalchemy.ext.asyncio import AsyncSession +from backend.app.core.database import get_db +from backend.app.schemas.document import MedicalDocumentResponse, DocumentAnalysisResponse, DocumentUploadResponse +from backend.app.schemas.ner import NEREntity +from backend.app.schemas.common import BaseResponse +from backend.app.services.document_service import DocumentService +from backend.app.repositories.document_repo import DocumentRepository +from backend.app.api.deps import get_current_user, get_client_ip +from backend.app.models.user import User +from backend.app.models.document import MedicalDocument +from backend.app.core.exceptions import ResourceNotFoundError, PermissionDeniedError + +router = APIRouter() + + +def _format_document_response(doc: MedicalDocument) -> MedicalDocumentResponse: + analysis_resp = None + if doc.analysis: + analysis = doc.analysis + findings = json.loads(analysis.important_findings) if analysis.important_findings else [] + conditions = json.loads(analysis.detected_conditions) if analysis.detected_conditions else [] + medications = json.loads(analysis.detected_medications) if analysis.detected_medications else [] + + entities = [ + NEREntity( + text=e.text, + label=e.label, + start=e.start_offset, + end=e.end_offset, + confidence=e.confidence, + model=e.model_name, + ) + for e in (analysis.entities or []) + ] + + analysis_resp = DocumentAnalysisResponse( + id=analysis.id, + document_id=analysis.document_id, + raw_text=analysis.raw_text, + cleaned_text=analysis.cleaned_text, + summary=analysis.summary, + important_findings=findings, + detected_conditions=conditions, + detected_medications=medications, + clinical_recommendations=analysis.clinical_recommendations, + entities=entities, + processed_at=analysis.processed_at, + ) + + return MedicalDocumentResponse( + id=doc.id, + user_id=doc.user_id, + filename=doc.filename, + original_filename=doc.original_filename, + file_type=doc.file_type, + file_size=doc.file_size, + status=doc.status, + error_message=doc.error_message, + analysis=analysis_resp, + created_at=doc.created_at, + updated_at=doc.updated_at, + ) + + +@router.post("/upload", response_model=BaseResponse[MedicalDocumentResponse]) +async def upload_medical_document( + request: Request, + file: UploadFile = File(...), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + Securely upload a medical report (PDF, DOCX, TXT), perform text extraction, + biomedical entity recognition, and clinical summarization. + """ + ip = get_client_ip(request) + doc_service = DocumentService(db) + doc = await doc_service.process_document_upload(file=file, user_id=current_user.id, ip_address=ip) + formatted = _format_document_response(doc) + return BaseResponse( + success=True, + message="Document uploaded and analyzed successfully.", + data=formatted, + ) + + +@router.get("", response_model=BaseResponse[List[MedicalDocumentResponse]]) +async def list_documents( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Retrieve all medical reports and document analyses uploaded by the current user.""" + doc_repo = DocumentRepository(db) + docs = await doc_repo.get_user_documents(current_user.id) + formatted_docs = [_format_document_response(d) for d in docs] + return BaseResponse( + success=True, + data=formatted_docs, + ) + + +@router.get("/{document_id}", response_model=BaseResponse[MedicalDocumentResponse]) +async def get_document( + document_id: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Fetch detailed analysis, extracted entities, and summary for a specific document.""" + doc_repo = DocumentRepository(db) + doc = await doc_repo.get_document_details(document_id, user_id=current_user.id) + if not doc: + raise ResourceNotFoundError(resource="Medical Document", resource_id=document_id) + return BaseResponse( + success=True, + data=_format_document_response(doc), + ) + + +@router.delete("/{document_id}", response_model=BaseResponse[dict]) +async def delete_document( + document_id: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Delete a medical report and all associated entity analysis records.""" + doc_repo = DocumentRepository(db) + doc = await doc_repo.get_document_details(document_id, user_id=current_user.id) + if not doc: + raise ResourceNotFoundError(resource="Medical Document", resource_id=document_id) + + await doc_repo.delete(document_id) + return BaseResponse( + success=True, + message="Document deleted successfully.", + data={"deleted_id": document_id}, + ) diff --git a/backend/app/api/v1/endpoints/health.py b/backend/app/api/v1/endpoints/health.py new file mode 100644 index 0000000000000000000000000000000000000000..81e088a656e64c15192d585314fee4b97b88262e --- /dev/null +++ b/backend/app/api/v1/endpoints/health.py @@ -0,0 +1,43 @@ +from datetime import datetime, timezone +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import text +from backend.app.core.database import get_db +from backend.app.core.config import settings +from backend.app.ml.manager import model_manager + +router = APIRouter() + + +@router.get("/health") +async def health_check(): + """Liveness probe: verifies basic service responsiveness.""" + return { + "status": "healthy", + "app": settings.APP_NAME, + "version": settings.APP_VERSION, + "timestamp": datetime.now(timezone.utc).isoformat(), + } + + +@router.get("/ready") +async def readiness_check(db: AsyncSession = Depends(get_db)): + """Readiness probe: checks database connectivity and local ML model availability.""" + db_ok = False + try: + await db.execute(text("SELECT 1")) + db_ok = True + except Exception: + db_ok = False + + model_status = model_manager.get_status() + models_ok = model_status.get("initialized", False) + + is_ready = db_ok + + return { + "status": "ready" if is_ready else "degraded", + "database": "connected" if db_ok else "disconnected", + "models": model_status, + "timestamp": datetime.now(timezone.utc).isoformat(), + } diff --git a/backend/app/api/v1/endpoints/history.py b/backend/app/api/v1/endpoints/history.py new file mode 100644 index 0000000000000000000000000000000000000000..33c77dcbf1472f2ec59fe94467aa43ff8b90bb07 --- /dev/null +++ b/backend/app/api/v1/endpoints/history.py @@ -0,0 +1,26 @@ +from typing import List +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession +from backend.app.core.database import get_db +from backend.app.schemas.admin import AnalysisHistoryResponse +from backend.app.schemas.common import BaseResponse +from backend.app.repositories.audit_repo import AuditRepository +from backend.app.api.deps import get_current_user +from backend.app.models.user import User + +router = APIRouter() + + +@router.get("", response_model=BaseResponse[List[AnalysisHistoryResponse]]) +async def get_user_history( + limit: int = 50, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Retrieve chronological medical and activity history for the current user.""" + audit_repo = AuditRepository(db) + records = await audit_repo.get_user_history(current_user.id, limit=limit) + response_list = [ + AnalysisHistoryResponse.model_validate(r) for r in records + ] + return BaseResponse(success=True, data=response_list) diff --git a/backend/app/api/v1/endpoints/ner.py b/backend/app/api/v1/endpoints/ner.py new file mode 100644 index 0000000000000000000000000000000000000000..4a345506c55d618cde6077dfbe3ece8636c0786a --- /dev/null +++ b/backend/app/api/v1/endpoints/ner.py @@ -0,0 +1,55 @@ +import uuid +from typing import Optional +from fastapi import APIRouter, Depends, Request +from sqlalchemy.ext.asyncio import AsyncSession +from backend.app.core.database import get_db +from backend.app.schemas.ner import NERRequest, NERResponse, ModelInfo +from backend.app.schemas.common import BaseResponse +from backend.app.ml.manager import model_manager +from backend.app.repositories.audit_repo import AuditRepository +from backend.app.api.deps import security_scheme, get_client_ip +from backend.app.core.security import decode_token + +router = APIRouter() + + +@router.post("/analyze", response_model=NERResponse) +async def analyze_biomedical_ner( + request_body: NERRequest, + request: Request, + db: AsyncSession = Depends(get_db), +): + """ + Perform Named Entity Recognition using the locally loaded RoBERTa-large BC5CDR model. + Extracts CHEMICAL (medications/drugs) and DISEASE (medical conditions) entities with character offsets and confidence scores. + """ + req_id = str(uuid.uuid4()) + ner_service = model_manager.get_ner_service() + result = ner_service.analyze_text(request_body, request_id=req_id) + + # If an authenticated user made the request, record in history + auth_header = request.headers.get("Authorization") + if auth_header and auth_header.startswith("Bearer "): + token = auth_header.split(" ")[1] + payload = decode_token(token) + if payload and payload.get("sub"): + audit_repo = AuditRepository(db) + await audit_repo.add_history( + user_id=payload["sub"], + action_type="NER", + description=f"Analyzed text ({len(request_body.text)} chars)", + entity_count=result.entity_count, + ) + + return result + + +@router.get("/model-info", response_model=BaseResponse[ModelInfo]) +async def get_model_info(): + """Retrieve metadata and runtime status of the local biomedical NER model.""" + ner_service = model_manager.get_ner_service() + info = ner_service.model.get_info() + return BaseResponse( + success=True, + data=info, + ) diff --git a/backend/app/api/v1/endpoints/profile.py b/backend/app/api/v1/endpoints/profile.py new file mode 100644 index 0000000000000000000000000000000000000000..9f65f257ebfbfb8f07c1da8994001108cdeeb379 --- /dev/null +++ b/backend/app/api/v1/endpoints/profile.py @@ -0,0 +1,89 @@ +import json +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession +from backend.app.core.database import get_db +from backend.app.schemas.profile import PatientProfileUpdate, PatientProfileResponse +from backend.app.schemas.common import BaseResponse +from backend.app.repositories.user_repo import UserRepository +from backend.app.repositories.audit_repo import AuditRepository +from backend.app.models.profile import PatientProfile +from backend.app.models.user import User +from backend.app.api.deps import get_current_user + +router = APIRouter() + + +def _format_profile(profile: PatientProfile) -> PatientProfileResponse: + allergies = json.loads(profile.known_allergies) if profile.known_allergies else [] + conditions = json.loads(profile.chronic_conditions) if profile.chronic_conditions else [] + meds = json.loads(profile.current_medications) if profile.current_medications else [] + + return PatientProfileResponse( + id=profile.id, + user_id=profile.user_id, + age=profile.age, + gender=profile.gender, + blood_group=profile.blood_group, + height_cm=profile.height_cm, + weight_kg=profile.weight_kg, + known_allergies=allergies, + chronic_conditions=conditions, + current_medications=meds, + emergency_contact=profile.emergency_contact, + created_at=profile.created_at, + updated_at=profile.updated_at, + ) + + +@router.get("", response_model=BaseResponse[PatientProfileResponse]) +async def get_patient_profile( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Fetch the patient health profile for the current user.""" + user_repo = UserRepository(db) + profile = await user_repo.get_profile_by_user_id(current_user.id) + if not profile: + profile = PatientProfile(user_id=current_user.id) + profile = await user_repo.save_profile(profile) + + return BaseResponse(success=True, data=_format_profile(profile)) + + +@router.put("", response_model=BaseResponse[PatientProfileResponse]) +async def update_patient_profile( + profile_in: PatientProfileUpdate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Update patient health information, allergies, chronic conditions, and medications.""" + user_repo = UserRepository(db) + audit_repo = AuditRepository(db) + profile = await user_repo.get_profile_by_user_id(current_user.id) + + if not profile: + profile = PatientProfile(user_id=current_user.id) + + profile.age = profile_in.age + profile.gender = profile_in.gender + profile.blood_group = profile_in.blood_group + profile.height_cm = profile_in.height_cm + profile.weight_kg = profile_in.weight_kg + profile.known_allergies = json.dumps(profile_in.known_allergies or []) + profile.chronic_conditions = json.dumps(profile_in.chronic_conditions or []) + profile.current_medications = json.dumps(profile_in.current_medications or []) + profile.emergency_contact = profile_in.emergency_contact + + saved_profile = await user_repo.save_profile(profile) + + await audit_repo.add_history( + user_id=current_user.id, + action_type="PROFILE_UPDATE", + description="Updated patient health profile", + ) + + return BaseResponse( + success=True, + message="Patient health profile updated successfully.", + data=_format_profile(saved_profile), + ) diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py new file mode 100644 index 0000000000000000000000000000000000000000..3a395d5fe186a99cd1de9b0912930ca1b88046b9 --- /dev/null +++ b/backend/app/api/v1/router.py @@ -0,0 +1,22 @@ +from fastapi import APIRouter +from backend.app.api.v1.endpoints import ( + auth, + ner, + documents, + chat, + profile, + history, + admin, + health, +) + +api_router = APIRouter() + +api_router.include_router(auth.router, prefix="/auth", tags=["Authentication"]) +api_router.include_router(ner.router, prefix="/ner", tags=["Biomedical NER"]) +api_router.include_router(documents.router, prefix="/documents", tags=["Medical Documents"]) +api_router.include_router(chat.router, prefix="/chat", tags=["AI Medical Assistant"]) +api_router.include_router(profile.router, prefix="/profile", tags=["Patient Profile"]) +api_router.include_router(history.router, prefix="/history", tags=["Medical History"]) +api_router.include_router(admin.router, prefix="/admin", tags=["Admin & Monitoring"]) +api_router.include_router(health.router, tags=["Health & Observability"]) diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000000000000000000000000000000000000..0682a7affe174775b7b15e0d29d11136095cfd62 --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,68 @@ +from typing import List, Union +from pydantic import AnyHttpUrl, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parents[3] + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=str(BASE_DIR / ".env"), + env_file_encoding="utf-8", + extra="ignore", + ) + + APP_ENV: str = "development" + APP_NAME: str = "SanjeevaniAI" + APP_VERSION: str = "1.0.0" + DEBUG: bool = True + HOST: str = "0.0.0.0" + PORT: int = 8000 + + # Security + SECRET_KEY: str = "sanjeevani-ai-super-secret-key-change-in-production" + ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 120 + REFRESH_TOKEN_EXPIRE_DAYS: int = 7 + + # CORS + CORS_ORIGINS: List[str] = [ + "http://localhost:3000", + "http://127.0.0.1:3000", + "http://localhost:8000", + ] + + @field_validator("CORS_ORIGINS", mode="before") + def assemble_cors_origins(cls, v: Union[str, List[str]]) -> List[str]: + if isinstance(v, str) and not v.startswith("["): + return [i.strip() for i in v.split(",")] + elif isinstance(v, (list, str)): + return v + raise ValueError(v) + + # Database + DATABASE_URL: str = f"sqlite+aiosqlite:///{BASE_DIR / 'sanjeevani.db'}" + + # Redis + REDIS_URL: str = "redis://localhost:6379/0" + + # ML Models + NER_MODEL_PATH: str = str(BASE_DIR / "models" / "bc5cdr-ner") + DEVICE: str = "auto" + + # LLM Providers + LLM_PROVIDER: str = "mock" # "gemini", "openai", "mock" + GEMINI_API_KEY: str = "" + OPENAI_API_KEY: str = "" + + # Document Storage + UPLOAD_DIR: str = str(BASE_DIR / "uploads") + MAX_UPLOAD_SIZE_MB: int = 25 + ALLOWED_EXTENSIONS: List[str] = ["pdf", "txt", "docx"] + + # Rate Limiting + RATE_LIMIT_PER_MINUTE: int = 60 + + +settings = Settings() diff --git a/backend/app/core/database.py b/backend/app/core/database.py new file mode 100644 index 0000000000000000000000000000000000000000..9bc522e9cb16649e5b163f0c641642199fdbd810 --- /dev/null +++ b/backend/app/core/database.py @@ -0,0 +1,53 @@ +from typing import AsyncGenerator +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession +from sqlalchemy.orm import declarative_base +from backend.app.core.config import settings +from backend.app.core.logger import logger + +# Build engine with appropriate connect_args depending on SQLite or PostgreSQL +connect_args = {} +engine_kwargs = {"echo": False, "future": True} + +if settings.DATABASE_URL.startswith("sqlite"): + connect_args["check_same_thread"] = False +else: + engine_kwargs.update({ + "pool_size": 10, + "max_overflow": 20, + "pool_pre_ping": True, + }) + +engine = create_async_engine( + settings.DATABASE_URL, + connect_args=connect_args, + **engine_kwargs, +) + +AsyncSessionLocal = async_sessionmaker( + bind=engine, + class_=AsyncSession, + expire_on_commit=False, + autocommit=False, + autoflush=False, +) + +Base = declarative_base() + + +async def get_db() -> AsyncGenerator[AsyncSession, None]: + async with AsyncSessionLocal() as session: + try: + yield session + except Exception as ex: + await session.rollback() + logger.error(f"Database session error: {ex}") + raise + finally: + await session.close() + + +async def init_db() -> None: + """Initialize database tables for development/testing.""" + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + logger.info("Database schema initialized.") diff --git a/backend/app/core/exceptions.py b/backend/app/core/exceptions.py new file mode 100644 index 0000000000000000000000000000000000000000..d4c927bca175f8052e04baa539a8265c2f7288e7 --- /dev/null +++ b/backend/app/core/exceptions.py @@ -0,0 +1,82 @@ +from typing import Any, Optional, Dict +from fastapi import HTTPException, status + + +class SanjeevaniException(HTTPException): + def __init__( + self, + status_code: int, + code: str, + message: str, + details: Optional[Any] = None, + headers: Optional[Dict[str, str]] = None, + ): + super().__init__( + status_code=status_code, + detail={"code": code, "message": message, "details": details}, + headers=headers, + ) + self.code = code + self.message = message + self.details = details + + +class AuthenticationError(SanjeevaniException): + def __init__(self, message: str = "Invalid credentials", details: Optional[Any] = None): + super().__init__( + status_code=status.HTTP_401_UNAUTHORIZED, + code="AUTHENTICATION_FAILED", + message=message, + details=details, + headers={"WWW-Authenticate": "Bearer"}, + ) + + +class PermissionDeniedError(SanjeevaniException): + def __init__(self, message: str = "Access forbidden", details: Optional[Any] = None): + super().__init__( + status_code=status.HTTP_403_FORBIDDEN, + code="PERMISSION_DENIED", + message=message, + details=details, + ) + + +class ResourceNotFoundError(SanjeevaniException): + def __init__(self, resource: str, resource_id: Any = None): + msg = f"{resource} not found" if not resource_id else f"{resource} with ID '{resource_id}' not found" + super().__init__( + status_code=status.HTTP_404_NOT_FOUND, + code="RESOURCE_NOT_FOUND", + message=msg, + ) + + +class ValidationError(SanjeevaniException): + def __init__(self, message: str = "Validation failed", details: Optional[Any] = None): + super().__init__( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + code="VALIDATION_ERROR", + message=message, + details=details, + ) + + +class MLModelError(SanjeevaniException): + def __init__(self, message: str = "ML inference failed", details: Optional[Any] = None): + super().__init__( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + code="ML_MODEL_ERROR", + message=message, + details=details, + ) + + +class DocumentProcessingError(SanjeevaniException): + def __init__(self, message: str = "Failed to process document", details: Optional[Any] = None): + super().__init__( + status_code=status.HTTP_400_BAD_REQUEST, + code="DOCUMENT_PROCESSING_FAILED", + message=message, + details=details, + ) diff --git a/backend/app/core/logger.py b/backend/app/core/logger.py new file mode 100644 index 0000000000000000000000000000000000000000..361732490eab523a0024597fa130333072fc0f7e --- /dev/null +++ b/backend/app/core/logger.py @@ -0,0 +1,45 @@ +import logging +import sys +import json +from datetime import datetime, timezone +from backend.app.core.config import settings + + +class JsonFormatter(logging.Formatter): + def format(self, record: logging.LogRecord) -> str: + log_obj = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + "module": record.module, + "line": record.lineno, + } + if hasattr(record, "request_id"): + log_obj["request_id"] = record.request_id + if record.exc_info: + log_obj["exception"] = self.formatException(record.exc_info) + return json.dumps(log_obj) + + +def setup_logger(name: str = "sanjeevani") -> logging.Logger: + logger = logging.getLogger(name) + logger.setLevel(logging.DEBUG if settings.DEBUG else logging.INFO) + + if not logger.handlers: + handler = logging.StreamHandler(sys.stdout) + if settings.APP_ENV == "production": + handler.setFormatter(JsonFormatter()) + else: + handler.setFormatter( + logging.Formatter( + "[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + ) + logger.addHandler(handler) + + return logger + + +logger = setup_logger() diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000000000000000000000000000000000000..9ab13c8824efe94caaeeeceb0fcc18ada808e311 --- /dev/null +++ b/backend/app/core/security.py @@ -0,0 +1,82 @@ +from datetime import datetime, timedelta, timezone +from typing import Any, Optional, Union +import bcrypt +from jose import jwt, JWTError +import enum +from backend.app.core.config import settings + + +class UserRole(str, enum.Enum): + USER = "USER" + PATIENT = "PATIENT" + DOCTOR = "DOCTOR" + ADMIN = "ADMIN" + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + try: + pwd_bytes = plain_password.encode("utf-8")[:72] + hash_bytes = hashed_password.encode("utf-8") + return bcrypt.checkpw(pwd_bytes, hash_bytes) + except Exception: + return False + + +def get_password_hash(password: str) -> str: + pwd_bytes = password.encode("utf-8")[:72] + salt = bcrypt.gensalt() + return bcrypt.hashpw(pwd_bytes, salt).decode("utf-8") + + +def create_access_token( + subject: Union[str, Any], + role: str = UserRole.PATIENT.value, + expires_delta: Optional[timedelta] = None, +) -> str: + if expires_delta: + expire = datetime.now(timezone.utc) + expires_delta + else: + expire = datetime.now(timezone.utc) + timedelta( + minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES + ) + + to_encode = { + "sub": str(subject), + "role": role, + "type": "access", + "exp": expire, + "iat": datetime.now(timezone.utc), + } + return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + + +def create_refresh_token( + subject: Union[str, Any], + role: str = UserRole.PATIENT.value, + expires_delta: Optional[timedelta] = None, +) -> str: + if expires_delta: + expire = datetime.now(timezone.utc) + expires_delta + else: + expire = datetime.now(timezone.utc) + timedelta( + days=settings.REFRESH_TOKEN_EXPIRE_DAYS + ) + + to_encode = { + "sub": str(subject), + "role": role, + "type": "refresh", + "exp": expire, + "iat": datetime.now(timezone.utc), + } + return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + + +def decode_token(token: str) -> Optional[dict]: + try: + payload = jwt.decode( + token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM] + ) + return payload + except JWTError: + return None diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000000000000000000000000000000000000..43b4de2ea0c480c40aa36959538ccbe5a6dfcf90 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,130 @@ +from contextlib import asynccontextmanager +from fastapi import FastAPI, Request, status +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from fastapi.exceptions import RequestValidationError + +from backend.app.core.config import settings +from backend.app.core.database import init_db +from backend.app.core.logger import logger +from backend.app.core.exceptions import SanjeevaniException +from backend.app.schemas.common import MEDICAL_DISCLAIMER +from backend.app.ml.manager import model_manager +from backend.app.middleware.request_id import RequestIDMiddleware +from backend.app.middleware.security_headers import SecurityHeadersMiddleware +from backend.app.api.v1.router import api_router + + +@asynccontextmanager +async def lifespan(app: FastAPI): + logger.info(f"Starting {settings.APP_NAME} v{settings.APP_VERSION} ({settings.APP_ENV})...") + # Initialize database + await init_db() + # Initialize ML Models + try: + model_manager.initialize() + except Exception as e: + logger.warning(f"ML Model initialization warning (will retry on demand): {e}") + + yield + + logger.info(f"Shutting down {settings.APP_NAME}...") + + +def create_application() -> FastAPI: + app = FastAPI( + title=f"{settings.APP_NAME} — Healthcare Intelligence API", + description=( + "Industry-grade healthcare AI and clinical decision-support platform.\n\n" + f"**Medical Safety Notice**: {MEDICAL_DISCLAIMER}" + ), + version=settings.APP_VERSION, + lifespan=lifespan, + docs_url="/docs", + redoc_url="/redoc", + ) + + # Middlewares + app.add_middleware(RequestIDMiddleware) + app.add_middleware(SecurityHeadersMiddleware) + app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + # Exception Handlers + @app.exception_handler(SanjeevaniException) + async def sanjeevani_exception_handler(request: Request, exc: SanjeevaniException): + req_id = getattr(request.state, "request_id", None) + logger.error(f"Domain error [{exc.code}]: {exc.message} (Request ID: {req_id})") + return JSONResponse( + status_code=exc.status_code, + content={ + "success": False, + "error": { + "code": exc.code, + "message": exc.message, + "details": exc.details, + }, + "request_id": req_id, + "disclaimer": MEDICAL_DISCLAIMER, + }, + headers=exc.headers, + ) + + @app.exception_handler(RequestValidationError) + async def validation_exception_handler(request: Request, exc: RequestValidationError): + req_id = getattr(request.state, "request_id", None) + logger.warning(f"Validation error: {exc.errors()} (Request ID: {req_id})") + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + content={ + "success": False, + "error": { + "code": "VALIDATION_ERROR", + "message": "Invalid request parameters.", + "details": exc.errors(), + }, + "request_id": req_id, + "disclaimer": MEDICAL_DISCLAIMER, + }, + ) + + @app.exception_handler(Exception) + async def global_exception_handler(request: Request, exc: Exception): + req_id = getattr(request.state, "request_id", None) + logger.error(f"Unhandled exception: {str(exc)} (Request ID: {req_id})", exc_info=True) + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={ + "success": False, + "error": { + "code": "INTERNAL_SERVER_ERROR", + "message": "An unexpected error occurred while processing the healthcare request.", + }, + "request_id": req_id, + "disclaimer": MEDICAL_DISCLAIMER, + }, + ) + + # Register API v1 routes + app.include_router(api_router, prefix="/api/v1") + + # Root redirect / status + @app.get("/", tags=["Root"]) + async def root(): + return { + "app": settings.APP_NAME, + "tagline": "AI-Powered Healthcare Intelligence Platform", + "version": settings.APP_VERSION, + "docs": "/docs", + "disclaimer": MEDICAL_DISCLAIMER, + } + + return app + + +app = create_application() diff --git a/backend/app/middleware/request_id.py b/backend/app/middleware/request_id.py new file mode 100644 index 0000000000000000000000000000000000000000..696ad20c3a0c6237f3141894df350618c4ad3055 --- /dev/null +++ b/backend/app/middleware/request_id.py @@ -0,0 +1,13 @@ +import uuid +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response + + +class RequestIDMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next) -> Response: + req_id = request.headers.get("X-Request-ID") or str(uuid.uuid4()) + request.state.request_id = req_id + response = await call_next(request) + response.headers["X-Request-ID"] = req_id + return response diff --git a/backend/app/middleware/security_headers.py b/backend/app/middleware/security_headers.py new file mode 100644 index 0000000000000000000000000000000000000000..9b79c3d79c125a20bea2d368b5607fe2c327bc40 --- /dev/null +++ b/backend/app/middleware/security_headers.py @@ -0,0 +1,14 @@ +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response + + +class SecurityHeadersMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next) -> Response: + response = await call_next(request) + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["X-XSS-Protection"] = "1; mode=block" + response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + return response diff --git a/backend/app/ml/manager.py b/backend/app/ml/manager.py new file mode 100644 index 0000000000000000000000000000000000000000..185c26384d4846a3c2d651d0c1f34b29a7b3323a --- /dev/null +++ b/backend/app/ml/manager.py @@ -0,0 +1,54 @@ +from typing import Optional, Dict, Any +from backend.app.ml.ner.bc5cdr import BC5CDRNERModel +from backend.app.ml.ner.service import NERService +from backend.app.core.logger import logger + + +class ModelManager: + _instance: Optional["ModelManager"] = None + + def __init__(self): + self.bc5cdr_ner: Optional[BC5CDRNERModel] = None + self.ner_service: Optional[NERService] = None + self._is_initialized = False + + @classmethod + def get_instance(cls) -> "ModelManager": + if cls._instance is None: + cls._instance = ModelManager() + return cls._instance + + def initialize(self) -> None: + """Initialize and warm up all local ML models.""" + if self._is_initialized: + return + + logger.info("Initializing ModelManager and loading ML pipelines...") + try: + self.bc5cdr_ner = BC5CDRNERModel() + self.bc5cdr_ner.load() + self.ner_service = NERService(model=self.bc5cdr_ner) + self._is_initialized = True + logger.info("ModelManager initialized successfully.") + except Exception as e: + logger.error(f"Error during ModelManager initialization: {str(e)}", exc_info=True) + self._is_initialized = False + + def get_ner_service(self) -> NERService: + if self.ner_service is None: + if self.bc5cdr_ner is None: + self.bc5cdr_ner = BC5CDRNERModel() + self.bc5cdr_ner.load() + self.ner_service = NERService(model=self.bc5cdr_ner) + return self.ner_service + + def get_status(self) -> Dict[str, Any]: + return { + "initialized": self._is_initialized, + "models": { + "bc5cdr_ner": self.bc5cdr_ner.get_info().model_dump() if self.bc5cdr_ner else {"status": "Not Loaded"} + } + } + + +model_manager = ModelManager.get_instance() diff --git a/backend/app/ml/ner/base.py b/backend/app/ml/ner/base.py new file mode 100644 index 0000000000000000000000000000000000000000..1b94249f1853a0b8c646dc587c78735b5a2cc2d6 --- /dev/null +++ b/backend/app/ml/ner/base.py @@ -0,0 +1,20 @@ +from abc import ABC, abstractmethod +from typing import List, Dict, Any +from backend.app.schemas.ner import NEREntity, ModelInfo + + +class BaseNERModel(ABC): + @abstractmethod + def load(self) -> None: + """Load model weights and tokenizer into memory.""" + pass + + @abstractmethod + def predict(self, text: str) -> List[NEREntity]: + """Perform token classification and return extracted biomedical entities.""" + pass + + @abstractmethod + def get_info(self) -> ModelInfo: + """Return model metadata.""" + pass diff --git a/backend/app/ml/ner/bc5cdr.py b/backend/app/ml/ner/bc5cdr.py new file mode 100644 index 0000000000000000000000000000000000000000..a02bb683451f462f1a6c65a788559207dd4ee2a3 --- /dev/null +++ b/backend/app/ml/ner/bc5cdr.py @@ -0,0 +1,109 @@ +import os +from pathlib import Path +from typing import List, Optional +import torch +from transformers import AutoTokenizer, AutoModelForTokenClassification, pipeline +from backend.app.ml.ner.base import BaseNERModel +from backend.app.schemas.ner import NEREntity, ModelInfo +from backend.app.core.config import settings +from backend.app.core.logger import logger +from backend.app.core.exceptions import MLModelError + + +class BC5CDRNERModel(BaseNERModel): + def __init__(self, model_path: Optional[str] = None): + self.model_path = Path(model_path or settings.NER_MODEL_PATH) + self.model_name = "tner/roberta-large-bc5cdr" + self.tokenizer = None + self.model = None + self.pipeline = None + self.device = "cuda:0" if torch.cuda.is_available() else "cpu" + self._is_loaded = False + + def load(self) -> None: + if self._is_loaded: + return + + # Use local path if it exists; otherwise load directly from HuggingFace Hub (for cloud hosting) + if self.model_path.exists() and any(self.model_path.iterdir()): + model_source = str(self.model_path) + logger.info(f"Loading local BC5CDR NER model from disk ({model_source}) onto {self.device}...") + else: + model_source = self.model_name + logger.info(f"Local model path not found. Loading '{model_source}' from Hugging Face Hub onto {self.device}...") + + try: + self.tokenizer = AutoTokenizer.from_pretrained(model_source) + self.model = AutoModelForTokenClassification.from_pretrained(model_source) + + device_id = 0 if self.device.startswith("cuda") else -1 + self.pipeline = pipeline( + "ner", + model=self.model, + tokenizer=self.tokenizer, + aggregation_strategy="simple", + device=device_id, + ) + + # Warm-up run + _ = self.pipeline("Metformin reduces glucose in diabetes mellitus.") + self._is_loaded = True + logger.info(f"BC5CDR NER model ({model_source}) loaded and warmed up successfully.") + except Exception as e: + logger.error(f"Failed to load BC5CDR model: {str(e)}", exc_info=True) + self._is_loaded = False + raise MLModelError(message=f"Model initialization failed: {str(e)}") + + def predict(self, text: str) -> List[NEREntity]: + if not self._is_loaded or self.pipeline is None: + self.load() + + if not text or not text.strip(): + return [] + + try: + raw_entities = self.pipeline(text) + entities: List[NEREntity] = [] + + for ent in raw_entities: + entity_label = ent.get("entity_group") or ent.get("entity") or "UNKNOWN" + norm_label = entity_label.upper() + if "CHEM" in norm_label: + norm_label = "CHEMICAL" + elif "DIS" in norm_label: + norm_label = "DISEASE" + + word = ent.get("word", "").strip() + start = ent.get("start", 0) + end = ent.get("end", 0) + + if not word: + continue + + score = ent.get("score") + confidence = float(score) if score is not None else None + + entities.append( + NEREntity( + text=word, + label=norm_label, + start=start, + end=end, + confidence=round(confidence, 4) if confidence else None, + model=self.model_name, + ) + ) + + return entities + except Exception as e: + logger.error(f"NER inference error: {str(e)}", exc_info=True) + raise MLModelError(message=f"Error executing NER inference: {str(e)}") + + def get_info(self) -> ModelInfo: + return ModelInfo( + name=self.model_name, + version="1.0.0", + provider="RoBERTa-large BC5CDR", + device=self.device, + status="Loaded" if self._is_loaded else "Not Loaded", + ) diff --git a/backend/app/ml/ner/service.py b/backend/app/ml/ner/service.py new file mode 100644 index 0000000000000000000000000000000000000000..477f704bcdb5d36ae5692c567a742bfd2478d6cc --- /dev/null +++ b/backend/app/ml/ner/service.py @@ -0,0 +1,34 @@ +import time +import uuid +from typing import List, Optional +from backend.app.ml.ner.base import BaseNERModel +from backend.app.schemas.ner import NERRequest, NERResponse, NEREntity +from backend.app.core.logger import logger + + +class NERService: + def __init__(self, model: BaseNERModel): + self.model = model + + def analyze_text(self, request: NERRequest, request_id: Optional[str] = None) -> NERResponse: + req_id = request_id or str(uuid.uuid4()) + start_time = time.perf_counter() + + entities: List[NEREntity] = self.model.predict(request.text) + processing_time_ms = round((time.perf_counter() - start_time) * 1000, 2) + + model_info = self.model.get_info() + + logger.info( + f"NER Analysis completed: req_id={req_id}, entities_found={len(entities)}, " + f"latency={processing_time_ms}ms" + ) + + return NERResponse( + request_id=req_id, + model=model_info, + entities=entities, + entity_count=len(entities), + processing_time_ms=processing_time_ms, + text_length=len(request.text), + ) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..313d2c1f5cd2e18df8ea10eb323a71564b40b8a3 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,21 @@ +from backend.app.models.base import BaseModel +from backend.app.models.user import User +from backend.app.models.profile import PatientProfile +from backend.app.models.document import MedicalDocument, DocumentAnalysis +from backend.app.models.entity import MedicalEntity +from backend.app.models.conversation import AIConversation, AIMessage +from backend.app.models.audit import AuditLog, AnalysisHistory, SystemEvent + +__all__ = [ + "BaseModel", + "User", + "PatientProfile", + "MedicalDocument", + "DocumentAnalysis", + "MedicalEntity", + "AIConversation", + "AIMessage", + "AuditLog", + "AnalysisHistory", + "SystemEvent", +] diff --git a/backend/app/models/audit.py b/backend/app/models/audit.py new file mode 100644 index 0000000000000000000000000000000000000000..d661a82656349b582c1ed234ce46d5f6ca6d13dc --- /dev/null +++ b/backend/app/models/audit.py @@ -0,0 +1,39 @@ +from sqlalchemy import Column, String, Integer, ForeignKey, Text +from sqlalchemy.orm import relationship +from backend.app.models.base import BaseModel + + +class AuditLog(BaseModel): + __tablename__ = "audit_logs" + + user_id = Column(String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + action = Column(String(100), nullable=False, index=True) # LOGIN, LOGOUT, NER_ANALYZE, DOCUMENT_UPLOAD, etc. + ip_address = Column(String(45), nullable=True) + user_agent = Column(String(255), nullable=True) + status = Column(String(20), default="SUCCESS", nullable=False) # SUCCESS, FAILED, WARNING + details = Column(Text, nullable=True) # JSON-encoded sanitized details + + # Relationships + user = relationship("User", back_populates="audit_logs") + + +class AnalysisHistory(BaseModel): + __tablename__ = "analysis_history" + + user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + action_type = Column(String(50), nullable=False) # NER, REPORT_ANALYSIS, PROFILE_UPDATE, CHAT + description = Column(String(255), nullable=False) + entity_count = Column(Integer, default=0, nullable=False) + reference_id = Column(String(36), nullable=True) # document_id or conversation_id + + # Relationships + user = relationship("User", back_populates="history") + + +class SystemEvent(BaseModel): + __tablename__ = "system_events" + + event_type = Column(String(100), nullable=False, index=True) + severity = Column(String(20), default="INFO", nullable=False) # INFO, WARNING, ERROR, CRITICAL + message = Column(String(500), nullable=False) + details = Column(Text, nullable=True) diff --git a/backend/app/models/base.py b/backend/app/models/base.py new file mode 100644 index 0000000000000000000000000000000000000000..82d7d36bd06932b5fae4b4290a88063e5bbd4756 --- /dev/null +++ b/backend/app/models/base.py @@ -0,0 +1,25 @@ +import uuid +from datetime import datetime, timezone +from sqlalchemy import Column, String, DateTime +from backend.app.core.database import Base + + +def generate_uuid() -> str: + return str(uuid.uuid4()) + + +def utc_now() -> datetime: + return datetime.now(timezone.utc) + + +class BaseModel(Base): + __abstract__ = True + + id = Column(String(36), primary_key=True, default=generate_uuid) + created_at = Column(DateTime(timezone=True), default=utc_now, nullable=False) + updated_at = Column( + DateTime(timezone=True), + default=utc_now, + onupdate=utc_now, + nullable=False, + ) diff --git a/backend/app/models/conversation.py b/backend/app/models/conversation.py new file mode 100644 index 0000000000000000000000000000000000000000..71628ca69c60e7e6f77482859a718bd5222c0655 --- /dev/null +++ b/backend/app/models/conversation.py @@ -0,0 +1,33 @@ +from sqlalchemy import Column, String, ForeignKey, Text +from sqlalchemy.orm import relationship +from backend.app.models.base import BaseModel + + +class AIConversation(BaseModel): + __tablename__ = "ai_conversations" + + user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + title = Column(String(255), default="New Medical Consultation", nullable=False) + + # Relationships with selectin loading to prevent async lazy loading issues + user = relationship("User", back_populates="conversations", lazy="selectin") + messages = relationship( + "AIMessage", + back_populates="conversation", + cascade="all, delete-orphan", + order_by="AIMessage.created_at", + lazy="selectin", + ) + + +class AIMessage(BaseModel): + __tablename__ = "ai_messages" + + conversation_id = Column(String(36), ForeignKey("ai_conversations.id", ondelete="CASCADE"), nullable=False, index=True) + role = Column(String(20), nullable=False) # "user", "assistant", "system" + content = Column(Text, nullable=False) + structured_data = Column(Text, nullable=True) # JSON-encoded summary, recommendations, disclaimer + model_provider = Column(String(50), nullable=True) + + # Relationships + conversation = relationship("AIConversation", back_populates="messages", lazy="selectin") diff --git a/backend/app/models/document.py b/backend/app/models/document.py new file mode 100644 index 0000000000000000000000000000000000000000..9a51b92f0eabf3413564388a6ff20bd377733b2a --- /dev/null +++ b/backend/app/models/document.py @@ -0,0 +1,39 @@ +from sqlalchemy import Column, String, Integer, ForeignKey, Text, DateTime +from sqlalchemy.orm import relationship +from backend.app.models.base import BaseModel, utc_now + + +class MedicalDocument(BaseModel): + __tablename__ = "medical_documents" + + user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + filename = Column(String(255), nullable=False) + original_filename = Column(String(255), nullable=False) + file_path = Column(String(500), nullable=False) + file_type = Column(String(50), nullable=False) + file_size = Column(Integer, nullable=False) + file_hash = Column(String(64), nullable=False) + status = Column(String(50), default="PENDING", nullable=False) # PENDING, PROCESSING, COMPLETED, FAILED + error_message = Column(Text, nullable=True) + + # Relationships + user = relationship("User", back_populates="documents", lazy="selectin") + analysis = relationship("DocumentAnalysis", back_populates="document", uselist=False, cascade="all, delete-orphan", lazy="selectin") + + +class DocumentAnalysis(BaseModel): + __tablename__ = "document_analyses" + + document_id = Column(String(36), ForeignKey("medical_documents.id", ondelete="CASCADE"), unique=True, nullable=False, index=True) + raw_text = Column(Text, nullable=False) + cleaned_text = Column(Text, nullable=True) + summary = Column(Text, nullable=True) + important_findings = Column(Text, nullable=True) # JSON-encoded array or text + detected_conditions = Column(Text, nullable=True) # JSON-encoded array + detected_medications = Column(Text, nullable=True) # JSON-encoded array + clinical_recommendations = Column(Text, nullable=True) + processed_at = Column(DateTime(timezone=True), default=utc_now, nullable=False) + + # Relationships + document = relationship("MedicalDocument", back_populates="analysis", lazy="selectin") + entities = relationship("MedicalEntity", back_populates="analysis", cascade="all, delete-orphan", lazy="selectin") diff --git a/backend/app/models/entity.py b/backend/app/models/entity.py new file mode 100644 index 0000000000000000000000000000000000000000..dd13fd7226e4b681b7b0ae89fef89aa7772d087a --- /dev/null +++ b/backend/app/models/entity.py @@ -0,0 +1,18 @@ +from sqlalchemy import Column, String, Integer, Float, ForeignKey +from sqlalchemy.orm import relationship +from backend.app.models.base import BaseModel + + +class MedicalEntity(BaseModel): + __tablename__ = "medical_entities" + + analysis_id = Column(String(36), ForeignKey("document_analyses.id", ondelete="CASCADE"), nullable=False, index=True) + text = Column(String(255), nullable=False) + label = Column(String(50), nullable=False) # CHEMICAL, DISEASE, MEDICINE, etc. + start_offset = Column(Integer, nullable=False) + end_offset = Column(Integer, nullable=False) + confidence = Column(Float, nullable=True) + model_name = Column(String(100), default="tner/roberta-large-bc5cdr", nullable=False) + + # Relationships + analysis = relationship("DocumentAnalysis", back_populates="entities") diff --git a/backend/app/models/profile.py b/backend/app/models/profile.py new file mode 100644 index 0000000000000000000000000000000000000000..7f331b96968238d4881b266fbc069da039895c12 --- /dev/null +++ b/backend/app/models/profile.py @@ -0,0 +1,21 @@ +from sqlalchemy import Column, String, Integer, Float, ForeignKey, Text +from sqlalchemy.orm import relationship +from backend.app.models.base import BaseModel + + +class PatientProfile(BaseModel): + __tablename__ = "patient_profiles" + + user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=False, index=True) + age = Column(Integer, nullable=True) + gender = Column(String(20), nullable=True) + blood_group = Column(String(10), nullable=True) + height_cm = Column(Float, nullable=True) + weight_kg = Column(Float, nullable=True) + known_allergies = Column(Text, nullable=True) # JSON or comma-separated string + chronic_conditions = Column(Text, nullable=True) # JSON or comma-separated string + current_medications = Column(Text, nullable=True) # JSON or comma-separated string + emergency_contact = Column(String(255), nullable=True) + + # Relationships + user = relationship("User", back_populates="profile") diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000000000000000000000000000000000000..431866de0862e172bd4586cde9fc2b56b7a04d3c --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,22 @@ +from sqlalchemy import Column, String, Boolean +from sqlalchemy.orm import relationship +from backend.app.models.base import BaseModel +from backend.app.core.security import UserRole + + +class User(BaseModel): + __tablename__ = "users" + + email = Column(String(255), unique=True, index=True, nullable=False) + hashed_password = Column(String(255), nullable=False) + full_name = Column(String(255), nullable=False) + role = Column(String(50), default=UserRole.PATIENT.value, nullable=False) + is_active = Column(Boolean, default=True, nullable=False) + is_verified = Column(Boolean, default=False, nullable=False) + + # Relationships + profile = relationship("PatientProfile", back_populates="user", uselist=False, cascade="all, delete-orphan", lazy="selectin") + documents = relationship("MedicalDocument", back_populates="user", cascade="all, delete-orphan", lazy="selectin") + conversations = relationship("AIConversation", back_populates="user", cascade="all, delete-orphan", lazy="selectin") + audit_logs = relationship("AuditLog", back_populates="user", lazy="selectin") + history = relationship("AnalysisHistory", back_populates="user", cascade="all, delete-orphan", lazy="selectin") diff --git a/backend/app/repositories/audit_repo.py b/backend/app/repositories/audit_repo.py new file mode 100644 index 0000000000000000000000000000000000000000..442f44ccea876aac78035267900d5b22e7ed37aa --- /dev/null +++ b/backend/app/repositories/audit_repo.py @@ -0,0 +1,85 @@ +from typing import List, Optional, Dict, Any +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, desc, func +from backend.app.models.audit import AuditLog, AnalysisHistory, SystemEvent +from backend.app.models.user import User +from backend.app.models.document import MedicalDocument +from backend.app.models.conversation import AIConversation +from backend.app.repositories.base import BaseRepository + + +class AuditRepository(BaseRepository[AuditLog]): + def __init__(self, db: AsyncSession): + super().__init__(AuditLog, db) + + async def log_event( + self, + action: str, + user_id: Optional[str] = None, + ip_address: Optional[str] = None, + user_agent: Optional[str] = None, + status: str = "SUCCESS", + details: Optional[str] = None, + ) -> AuditLog: + audit = AuditLog( + user_id=user_id, + action=action, + ip_address=ip_address, + user_agent=user_agent[:255] if user_agent else None, + status=status, + details=details, + ) + self.db.add(audit) + await self.db.commit() + await self.db.refresh(audit) + return audit + + async def add_history( + self, + user_id: str, + action_type: str, + description: str, + entity_count: int = 0, + reference_id: Optional[str] = None, + ) -> AnalysisHistory: + history = AnalysisHistory( + user_id=user_id, + action_type=action_type, + description=description, + entity_count=entity_count, + reference_id=reference_id, + ) + self.db.add(history) + await self.db.commit() + await self.db.refresh(history) + return history + + async def get_user_history(self, user_id: str, limit: int = 50) -> List[AnalysisHistory]: + result = await self.db.execute( + select(AnalysisHistory) + .where(AnalysisHistory.user_id == user_id) + .order_by(desc(AnalysisHistory.created_at)) + .limit(limit) + ) + return list(result.scalars().all()) + + async def get_admin_audit_logs(self, limit: int = 100) -> List[AuditLog]: + result = await self.db.execute( + select(AuditLog).order_by(desc(AuditLog.created_at)).limit(limit) + ) + return list(result.scalars().all()) + + async def get_system_statistics(self) -> Dict[str, int]: + total_users = (await self.db.execute(select(func.count(User.id)))).scalar_one() or 0 + active_users = (await self.db.execute(select(func.count(User.id)).where(User.is_active == True))).scalar_one() or 0 + total_docs = (await self.db.execute(select(func.count(MedicalDocument.id)))).scalar_one() or 0 + total_chats = (await self.db.execute(select(func.count(AIConversation.id)))).scalar_one() or 0 + total_ner = (await self.db.execute(select(func.count(AnalysisHistory.id)).where(AnalysisHistory.action_type == "NER"))).scalar_one() or 0 + + return { + "total_users": total_users, + "active_users": active_users, + "total_documents_processed": total_docs, + "total_chat_queries": total_chats, + "total_ner_requests": total_ner, + } diff --git a/backend/app/repositories/base.py b/backend/app/repositories/base.py new file mode 100644 index 0000000000000000000000000000000000000000..9ef8711fc42e3ab8035030f732bc65eb06d427dd --- /dev/null +++ b/backend/app/repositories/base.py @@ -0,0 +1,36 @@ +from typing import Generic, TypeVar, Type, Optional, List, Any +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, delete +from backend.app.models.base import BaseModel + +ModelType = TypeVar("ModelType", bound=BaseModel) + + +class BaseRepository(Generic[ModelType]): + def __init__(self, model: Type[ModelType], db: AsyncSession): + self.model = model + self.db = db + + async def get_by_id(self, id: str) -> Optional[ModelType]: + result = await self.db.execute(select(self.model).where(self.model.id == id)) + return result.scalars().first() + + async def get_all(self, skip: int = 0, limit: int = 100) -> List[ModelType]: + result = await self.db.execute(select(self.model).offset(skip).limit(limit)) + return list(result.scalars().all()) + + async def create(self, entity: ModelType) -> ModelType: + self.db.add(entity) + await self.db.commit() + await self.db.refresh(entity) + return entity + + async def update(self, entity: ModelType) -> ModelType: + await self.db.commit() + await self.db.refresh(entity) + return entity + + async def delete(self, id: str) -> bool: + result = await self.db.execute(delete(self.model).where(self.model.id == id)) + await self.db.commit() + return result.rowcount > 0 diff --git a/backend/app/repositories/chat_repo.py b/backend/app/repositories/chat_repo.py new file mode 100644 index 0000000000000000000000000000000000000000..020e25a6b05e1a8fbe989675682991d758a52743 --- /dev/null +++ b/backend/app/repositories/chat_repo.py @@ -0,0 +1,34 @@ +from typing import List, Optional +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, desc +from sqlalchemy.orm import selectinload +from backend.app.models.conversation import AIConversation, AIMessage +from backend.app.repositories.base import BaseRepository + + +class ChatRepository(BaseRepository[AIConversation]): + def __init__(self, db: AsyncSession): + super().__init__(AIConversation, db) + + async def get_user_conversations(self, user_id: str) -> List[AIConversation]: + result = await self.db.execute( + select(AIConversation) + .options(selectinload(AIConversation.messages)) + .where(AIConversation.user_id == user_id) + .order_by(desc(AIConversation.updated_at)) + ) + return list(result.scalars().all()) + + async def get_conversation_with_messages(self, conversation_id: str, user_id: str) -> Optional[AIConversation]: + result = await self.db.execute( + select(AIConversation) + .options(selectinload(AIConversation.messages)) + .where(AIConversation.id == conversation_id, AIConversation.user_id == user_id) + ) + return result.scalars().first() + + async def add_message(self, message: AIMessage) -> AIMessage: + self.db.add(message) + await self.db.commit() + await self.db.refresh(message) + return message diff --git a/backend/app/repositories/document_repo.py b/backend/app/repositories/document_repo.py new file mode 100644 index 0000000000000000000000000000000000000000..bd17ba5e27f0d878498fe5a820cdfbdde5a47e77 --- /dev/null +++ b/backend/app/repositories/document_repo.py @@ -0,0 +1,48 @@ +from typing import List, Optional +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, desc +from sqlalchemy.orm import selectinload +from backend.app.models.document import MedicalDocument, DocumentAnalysis +from backend.app.models.entity import MedicalEntity +from backend.app.repositories.base import BaseRepository + + +class DocumentRepository(BaseRepository[MedicalDocument]): + def __init__(self, db: AsyncSession): + super().__init__(MedicalDocument, db) + + async def get_user_documents(self, user_id: str) -> List[MedicalDocument]: + result = await self.db.execute( + select(MedicalDocument) + .options( + selectinload(MedicalDocument.analysis).selectinload(DocumentAnalysis.entities) + ) + .where(MedicalDocument.user_id == user_id) + .order_by(desc(MedicalDocument.created_at)) + ) + return list(result.scalars().all()) + + async def get_document_details(self, document_id: str, user_id: Optional[str] = None) -> Optional[MedicalDocument]: + query = ( + select(MedicalDocument) + .options( + selectinload(MedicalDocument.analysis).selectinload(DocumentAnalysis.entities) + ) + .where(MedicalDocument.id == document_id) + ) + if user_id: + query = query.where(MedicalDocument.user_id == user_id) + + result = await self.db.execute(query) + return result.scalars().first() + + async def save_analysis(self, analysis: DocumentAnalysis) -> DocumentAnalysis: + self.db.add(analysis) + await self.db.commit() + await self.db.refresh(analysis) + return analysis + + async def save_entities(self, entities: List[MedicalEntity]) -> List[MedicalEntity]: + self.db.add_all(entities) + await self.db.commit() + return entities diff --git a/backend/app/repositories/user_repo.py b/backend/app/repositories/user_repo.py new file mode 100644 index 0000000000000000000000000000000000000000..9d9ef00f9e06d21ac636328ba4e7a4f052e1df6a --- /dev/null +++ b/backend/app/repositories/user_repo.py @@ -0,0 +1,36 @@ +from typing import Optional, List +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from sqlalchemy.orm import selectinload +from backend.app.models.user import User +from backend.app.models.profile import PatientProfile +from backend.app.repositories.base import BaseRepository + + +class UserRepository(BaseRepository[User]): + def __init__(self, db: AsyncSession): + super().__init__(User, db) + + async def get_by_email(self, email: str) -> Optional[User]: + result = await self.db.execute( + select(User).options(selectinload(User.profile)).where(User.email == email.lower()) + ) + return result.scalars().first() + + async def get_with_profile(self, user_id: str) -> Optional[User]: + result = await self.db.execute( + select(User).options(selectinload(User.profile)).where(User.id == user_id) + ) + return result.scalars().first() + + async def get_profile_by_user_id(self, user_id: str) -> Optional[PatientProfile]: + result = await self.db.execute( + select(PatientProfile).where(PatientProfile.user_id == user_id) + ) + return result.scalars().first() + + async def save_profile(self, profile: PatientProfile) -> PatientProfile: + self.db.add(profile) + await self.db.commit() + await self.db.refresh(profile) + return profile diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..846db22318c0daed55541ea7cb7e253799413c6a --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -0,0 +1,40 @@ +from backend.app.schemas.common import BaseResponse, ErrorResponse, HealthCheck, MEDICAL_DISCLAIMER +from backend.app.schemas.user import UserCreate, UserLogin, UserUpdate, UserResponse +from backend.app.schemas.token import TokenResponse, TokenPayload, RefreshTokenRequest +from backend.app.schemas.profile import PatientProfileCreate, PatientProfileUpdate, PatientProfileResponse +from backend.app.schemas.ner import NERRequest, NEREntity, NERResponse, ModelInfo +from backend.app.schemas.document import MedicalDocumentResponse, DocumentAnalysisResponse, DocumentUploadResponse +from backend.app.schemas.chat import ChatMessageCreate, ChatMessageResponse, ConversationResponse, AIStructuredOutput, ChatCompletionResponse +from backend.app.schemas.admin import AdminStatsResponse, AuditLogResponse, AnalysisHistoryResponse + +__all__ = [ + "BaseResponse", + "ErrorResponse", + "HealthCheck", + "MEDICAL_DISCLAIMER", + "UserCreate", + "UserLogin", + "UserUpdate", + "UserResponse", + "TokenResponse", + "TokenPayload", + "RefreshTokenRequest", + "PatientProfileCreate", + "PatientProfileUpdate", + "PatientProfileResponse", + "NERRequest", + "NEREntity", + "NERResponse", + "ModelInfo", + "MedicalDocumentResponse", + "DocumentAnalysisResponse", + "DocumentUploadResponse", + "ChatMessageCreate", + "ChatMessageResponse", + "ConversationResponse", + "AIStructuredOutput", + "ChatCompletionResponse", + "AdminStatsResponse", + "AuditLogResponse", + "AnalysisHistoryResponse", +] diff --git a/backend/app/schemas/admin.py b/backend/app/schemas/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..b79c29fcfe4b6f3b439e87b6c1ce54ff5b4ff76f --- /dev/null +++ b/backend/app/schemas/admin.py @@ -0,0 +1,38 @@ +from typing import List, Optional, Dict, Any +from pydantic import BaseModel, ConfigDict +from datetime import datetime + + +class AuditLogResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + user_id: Optional[str] = None + action: str + ip_address: Optional[str] = None + user_agent: Optional[str] = None + status: str + details: Optional[str] = None + created_at: datetime + + +class AnalysisHistoryResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + user_id: str + action_type: str + description: str + entity_count: int + reference_id: Optional[str] = None + created_at: datetime + + +class AdminStatsResponse(BaseModel): + total_users: int + active_users: int + total_documents_processed: int + total_ner_requests: int + total_chat_queries: int + model_status: Dict[str, Any] + system_health: str diff --git a/backend/app/schemas/chat.py b/backend/app/schemas/chat.py new file mode 100644 index 0000000000000000000000000000000000000000..dbfd5ed20000cb05add67aea806c630af7fede11 --- /dev/null +++ b/backend/app/schemas/chat.py @@ -0,0 +1,48 @@ +from typing import List, Optional +from pydantic import BaseModel, ConfigDict, Field +from datetime import datetime +from backend.app.schemas.common import MEDICAL_DISCLAIMER + + +class AIStructuredOutput(BaseModel): + summary: str + possible_considerations: List[str] = [] + relevant_medical_info: List[str] = [] + questions_for_doctor: List[str] = [] + safety_warning: str = MEDICAL_DISCLAIMER + is_emergency: bool = False + emergency_instructions: Optional[str] = None + + +class ChatMessageCreate(BaseModel): + conversation_id: Optional[str] = None + message: str = Field(..., min_length=1, max_length=5000) + + +class ChatMessageResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + conversation_id: str + role: str + content: str + structured_data: Optional[AIStructuredOutput] = None + model_provider: Optional[str] = None + created_at: datetime + + +class ConversationResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + user_id: str + title: str + messages: List[ChatMessageResponse] = [] + created_at: datetime + updated_at: datetime + + +class ChatCompletionResponse(BaseModel): + conversation_id: str + message: ChatMessageResponse + disclaimer: str = MEDICAL_DISCLAIMER diff --git a/backend/app/schemas/common.py b/backend/app/schemas/common.py new file mode 100644 index 0000000000000000000000000000000000000000..a58d55b0dc6a2c47b4782fa9f53b7a1d8392ed75 --- /dev/null +++ b/backend/app/schemas/common.py @@ -0,0 +1,40 @@ +from typing import Generic, TypeVar, Optional, Any, List +from pydantic import BaseModel, ConfigDict +from datetime import datetime + +T = TypeVar("T") + +MEDICAL_DISCLAIMER = ( + "SanjeevaniAI provides AI-assisted healthcare information and decision-support insights. " + "It is not a substitute for professional medical diagnosis, treatment, or emergency care." +) + + +class BaseResponse(BaseModel, Generic[T]): + model_config = ConfigDict(from_attributes=True) + + success: bool = True + message: Optional[str] = None + data: Optional[T] = None + disclaimer: str = MEDICAL_DISCLAIMER + + +class ErrorDetail(BaseModel): + code: str + message: str + details: Optional[Any] = None + + +class ErrorResponse(BaseModel): + success: bool = False + error: ErrorDetail + request_id: Optional[str] = None + disclaimer: str = MEDICAL_DISCLAIMER + + +class HealthCheck(BaseModel): + status: str = "healthy" + version: str + environment: str + timestamp: datetime + services: dict diff --git a/backend/app/schemas/document.py b/backend/app/schemas/document.py new file mode 100644 index 0000000000000000000000000000000000000000..77a091790482ee34e65cc0aa229b7f285c9ffff2 --- /dev/null +++ b/backend/app/schemas/document.py @@ -0,0 +1,43 @@ +from typing import List, Optional +from pydantic import BaseModel, ConfigDict +from datetime import datetime +from backend.app.schemas.ner import NEREntity +from backend.app.schemas.common import MEDICAL_DISCLAIMER + + +class DocumentAnalysisResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + document_id: str + raw_text: str + cleaned_text: Optional[str] = None + summary: Optional[str] = None + important_findings: List[str] = [] + detected_conditions: List[str] = [] + detected_medications: List[str] = [] + clinical_recommendations: Optional[str] = None + entities: List[NEREntity] = [] + processed_at: datetime + + +class MedicalDocumentResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + user_id: str + filename: str + original_filename: str + file_type: str + file_size: int + status: str + error_message: Optional[str] = None + analysis: Optional[DocumentAnalysisResponse] = None + created_at: datetime + updated_at: datetime + + +class DocumentUploadResponse(BaseModel): + document: MedicalDocumentResponse + message: str = "Document uploaded and processed successfully" + disclaimer: str = MEDICAL_DISCLAIMER diff --git a/backend/app/schemas/ner.py b/backend/app/schemas/ner.py new file mode 100644 index 0000000000000000000000000000000000000000..41273f0b38aa1bd77550682eddc2f18c121eb2b6 --- /dev/null +++ b/backend/app/schemas/ner.py @@ -0,0 +1,34 @@ +from typing import List, Optional +from pydantic import BaseModel, Field +from backend.app.schemas.common import MEDICAL_DISCLAIMER + + +class NERRequest(BaseModel): + text: str = Field(..., min_length=1, max_length=10000, description="Clinical or biomedical text to analyze") + + +class NEREntity(BaseModel): + text: str + label: str # CHEMICAL, DISEASE + start: int + end: int + confidence: Optional[float] = None + model: str = "tner/roberta-large-bc5cdr" + + +class ModelInfo(BaseModel): + name: str = "tner/roberta-large-bc5cdr" + version: str = "local" + provider: str = "Local PyTorch / Transformers" + device: str = "cuda:0" + status: str = "Loaded" + + +class NERResponse(BaseModel): + request_id: str + model: ModelInfo + entities: List[NEREntity] + entity_count: int + processing_time_ms: float + text_length: int + disclaimer: str = MEDICAL_DISCLAIMER diff --git a/backend/app/schemas/profile.py b/backend/app/schemas/profile.py new file mode 100644 index 0000000000000000000000000000000000000000..5ba53b1bc572f77cd6315c4606060d6c7d394419 --- /dev/null +++ b/backend/app/schemas/profile.py @@ -0,0 +1,32 @@ +from typing import Optional, List +from pydantic import BaseModel, Field, ConfigDict +from datetime import datetime + + +class PatientProfileBase(BaseModel): + age: Optional[int] = Field(None, ge=0, le=130) + gender: Optional[str] = Field(None, max_length=20) + blood_group: Optional[str] = Field(None, max_length=10) + height_cm: Optional[float] = Field(None, ge=20, le=300) + weight_kg: Optional[float] = Field(None, ge=1, le=500) + known_allergies: Optional[List[str]] = Field(default_factory=list) + chronic_conditions: Optional[List[str]] = Field(default_factory=list) + current_medications: Optional[List[str]] = Field(default_factory=list) + emergency_contact: Optional[str] = Field(None, max_length=255) + + +class PatientProfileCreate(PatientProfileBase): + pass + + +class PatientProfileUpdate(PatientProfileBase): + pass + + +class PatientProfileResponse(PatientProfileBase): + model_config = ConfigDict(from_attributes=True) + + id: str + user_id: str + created_at: datetime + updated_at: datetime diff --git a/backend/app/schemas/token.py b/backend/app/schemas/token.py new file mode 100644 index 0000000000000000000000000000000000000000..73ec5beb118b806161bc13e350d9c43c2d102e19 --- /dev/null +++ b/backend/app/schemas/token.py @@ -0,0 +1,22 @@ +from typing import Optional +from pydantic import BaseModel +from backend.app.schemas.user import UserResponse + + +class TokenResponse(BaseModel): + access_token: str + refresh_token: str + token_type: str = "bearer" + expires_in: int + user: UserResponse + + +class RefreshTokenRequest(BaseModel): + refresh_token: str + + +class TokenPayload(BaseModel): + sub: Optional[str] = None + role: Optional[str] = None + type: Optional[str] = None + exp: Optional[int] = None diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000000000000000000000000000000000000..1febf97155959f8c293e6fd496f6da2e8c91d0d7 --- /dev/null +++ b/backend/app/schemas/user.py @@ -0,0 +1,35 @@ +from typing import Optional +from pydantic import BaseModel, EmailStr, Field, ConfigDict +from datetime import datetime +from backend.app.core.security import UserRole + + +class UserBase(BaseModel): + email: EmailStr + full_name: str = Field(..., min_length=2, max_length=255) + role: UserRole = UserRole.PATIENT + + +class UserCreate(UserBase): + password: str = Field(..., min_length=8, max_length=128) + + +class UserLogin(BaseModel): + email: EmailStr + password: str + + +class UserUpdate(BaseModel): + full_name: Optional[str] = Field(None, min_length=2, max_length=255) + email: Optional[EmailStr] = None + password: Optional[str] = Field(None, min_length=8, max_length=128) + + +class UserResponse(UserBase): + model_config = ConfigDict(from_attributes=True) + + id: str + is_active: bool + is_verified: bool + created_at: datetime + updated_at: datetime diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py new file mode 100644 index 0000000000000000000000000000000000000000..4b775899ffac315307d1619fef119cda5104bf80 --- /dev/null +++ b/backend/app/services/auth_service.py @@ -0,0 +1,105 @@ +from typing import Optional, Tuple +from sqlalchemy.ext.asyncio import AsyncSession +from backend.app.models.user import User +from backend.app.models.profile import PatientProfile +from backend.app.schemas.user import UserCreate, UserLogin, UserResponse +from backend.app.schemas.token import TokenResponse +from backend.app.core.security import ( + verify_password, + get_password_hash, + create_access_token, + create_refresh_token, + decode_token, + UserRole, +) +from backend.app.core.exceptions import AuthenticationError, ValidationError, ResourceNotFoundError +from backend.app.repositories.user_repo import UserRepository +from backend.app.repositories.audit_repo import AuditRepository +from backend.app.core.config import settings + + +class AuthService: + def __init__(self, db: AsyncSession): + self.db = db + self.user_repo = UserRepository(db) + self.audit_repo = AuditRepository(db) + + async def register(self, user_in: UserCreate, ip_address: Optional[str] = None) -> Tuple[User, TokenResponse]: + existing = await self.user_repo.get_by_email(user_in.email) + if existing: + raise ValidationError(message="A user with this email already exists.") + + hashed_pwd = get_password_hash(user_in.password) + user = User( + email=user_in.email.lower(), + hashed_password=hashed_pwd, + full_name=user_in.full_name, + role=user_in.role.value if isinstance(user_in.role, UserRole) else str(user_in.role), + is_active=True, + is_verified=True, + ) + created_user = await self.user_repo.create(user) + + # Create blank patient profile + profile = PatientProfile(user_id=created_user.id) + await self.user_repo.save_profile(profile) + + # Log audit event + await self.audit_repo.log_event( + action="USER_REGISTER", + user_id=created_user.id, + ip_address=ip_address, + details=f"User registered with role {created_user.role}", + ) + + tokens = self._generate_tokens(created_user) + return created_user, tokens + + async def login(self, login_in: UserLogin, ip_address: Optional[str] = None, user_agent: Optional[str] = None) -> TokenResponse: + user = await self.user_repo.get_by_email(login_in.email) + if not user or not verify_password(login_in.password, user.hashed_password): + await self.audit_repo.log_event( + action="LOGIN_FAILED", + ip_address=ip_address, + user_agent=user_agent, + status="FAILED", + details=f"Failed login attempt for email: {login_in.email}", + ) + raise AuthenticationError(message="Incorrect email or password.") + + if not user.is_active: + raise AuthenticationError(message="User account is inactive.") + + await self.audit_repo.log_event( + action="USER_LOGIN", + user_id=user.id, + ip_address=ip_address, + user_agent=user_agent, + status="SUCCESS", + ) + + return self._generate_tokens(user) + + async def refresh_tokens(self, refresh_token: str) -> TokenResponse: + payload = decode_token(refresh_token) + if not payload or payload.get("type") != "refresh": + raise AuthenticationError(message="Invalid or expired refresh token.") + + user_id = payload.get("sub") + user = await self.user_repo.get_by_id(user_id) + if not user or not user.is_active: + raise AuthenticationError(message="User not found or inactive.") + + return self._generate_tokens(user) + + def _generate_tokens(self, user: User) -> TokenResponse: + access_token = create_access_token(subject=user.id, role=user.role) + refresh_token = create_refresh_token(subject=user.id, role=user.role) + + return TokenResponse( + access_token=access_token, + refresh_token=refresh_token, + token_type="bearer", + expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, + user=UserResponse.model_validate(user), + ) diff --git a/backend/app/services/document_service.py b/backend/app/services/document_service.py new file mode 100644 index 0000000000000000000000000000000000000000..c5d24d8730f05d988b92d0c9bf4dc6367b48fc96 --- /dev/null +++ b/backend/app/services/document_service.py @@ -0,0 +1,208 @@ +import os +import hashlib +import json +import uuid +from pathlib import Path +from typing import List, Tuple, Optional +import aiofiles +from fastapi import UploadFile +from sqlalchemy.ext.asyncio import AsyncSession +from pypdf import PdfReader +import docx2txt + +from backend.app.models.document import MedicalDocument, DocumentAnalysis +from backend.app.models.entity import MedicalEntity +from backend.app.repositories.document_repo import DocumentRepository +from backend.app.repositories.audit_repo import AuditRepository +from backend.app.ml.manager import model_manager +from backend.app.schemas.ner import NERRequest +from backend.app.core.config import settings +from backend.app.core.logger import logger +from backend.app.core.exceptions import DocumentProcessingError, ResourceNotFoundError + + +class DocumentService: + def __init__(self, db: AsyncSession): + self.db = db + self.doc_repo = DocumentRepository(db) + self.audit_repo = AuditRepository(db) + self.upload_dir = Path(settings.UPLOAD_DIR) + self.upload_dir.mkdir(parents=True, exist_ok=True) + + async def process_document_upload( + self, + file: UploadFile, + user_id: str, + ip_address: Optional[str] = None + ) -> MedicalDocument: + filename = file.filename or "unknown_file" + file_ext = filename.split(".")[-1].lower() if "." in filename else "" + + if file_ext not in settings.ALLOWED_EXTENSIONS: + raise DocumentProcessingError( + message=f"File extension '.{file_ext}' is not supported. Allowed formats: {', '.join(settings.ALLOWED_EXTENSIONS)}" + ) + + content = await file.read() + file_size = len(content) + + if file_size > settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024: + raise DocumentProcessingError( + message=f"File size exceeds maximum permitted limit ({settings.MAX_UPLOAD_SIZE_MB}MB)." + ) + + file_hash = hashlib.sha256(content).hexdigest() + unique_filename = f"{uuid.uuid4().hex}_{filename}" + saved_path = self.upload_dir / unique_filename + + # Write to disk securely + async with aiofiles.open(saved_path, "wb") as out_file: + await out_file.write(content) + + # Create Document record + doc = MedicalDocument( + user_id=user_id, + filename=unique_filename, + original_filename=filename, + file_path=str(saved_path), + file_type=file_ext, + file_size=file_size, + file_hash=file_hash, + status="PROCESSING", + ) + saved_doc = await self.doc_repo.create(doc) + + try: + # 1. Text Extraction + raw_text = self._extract_text(saved_path, file_ext) + if not raw_text.strip(): + raise DocumentProcessingError(message="No readable text could be extracted from the document.") + + cleaned_text = self._normalize_text(raw_text) + + # 2. Biomedical NER via local RoBERTa-large BC5CDR + ner_service = model_manager.get_ner_service() + # Process in overlapping chunks of up to 1000 characters + chunks = self._chunk_text(cleaned_text, max_chars=1000) + all_entities = [] + + for chunk, offset in chunks: + ner_resp = ner_service.analyze_text(NERRequest(text=chunk)) + for ent in ner_resp.entities: + ent.start += offset + ent.end += offset + all_entities.append(ent) + + # Deduplicate entities + unique_conditions = list(set([e.text for e in all_entities if e.label == "DISEASE"])) + unique_medications = list(set([e.text for e in all_entities if e.label == "CHEMICAL"])) + + # 3. Clinical Summarization & Findings + summary, findings, recs = self._generate_clinical_summary( + cleaned_text, unique_conditions, unique_medications + ) + + # 4. Save Analysis + analysis = DocumentAnalysis( + document_id=saved_doc.id, + raw_text=raw_text[:50000], # bounded storage + cleaned_text=cleaned_text[:50000], + summary=summary, + important_findings=json.dumps(findings), + detected_conditions=json.dumps(unique_conditions), + detected_medications=json.dumps(unique_medications), + clinical_recommendations=recs, + ) + saved_analysis = await self.doc_repo.save_analysis(analysis) + + # 5. Save Medical Entities + entity_records = [ + MedicalEntity( + analysis_id=saved_analysis.id, + text=e.text, + label=e.label, + start_offset=e.start, + end_offset=e.end, + confidence=e.confidence, + model_name=e.model, + ) + for e in all_entities[:100] # Store top detected entities + ] + if entity_records: + await self.doc_repo.save_entities(entity_records) + + # 6. Update document status + saved_doc.status = "COMPLETED" + await self.doc_repo.update(saved_doc) + + # 7. Audit & History + await self.audit_repo.add_history( + user_id=user_id, + action_type="REPORT_ANALYSIS", + description=f"Analyzed medical document '{filename}'", + entity_count=len(all_entities), + reference_id=saved_doc.id, + ) + await self.audit_repo.log_event( + action="DOCUMENT_UPLOAD_PROCESSED", + user_id=user_id, + ip_address=ip_address, + details=f"Document {saved_doc.id} processed ({len(all_entities)} entities detected)", + ) + + return await self.doc_repo.get_document_details(saved_doc.id, user_id) + + except Exception as e: + logger.error(f"Error processing document {saved_doc.id}: {str(e)}", exc_info=True) + saved_doc.status = "FAILED" + saved_doc.error_message = str(e) + await self.doc_repo.update(saved_doc) + raise DocumentProcessingError(message=f"Failed to analyze medical document: {str(e)}") + + def _extract_text(self, file_path: Path, file_ext: str) -> str: + if file_ext == "pdf": + reader = PdfReader(str(file_path)) + text_parts = [page.extract_text() or "" for page in reader.pages] + return "\n".join(text_parts) + elif file_ext == "docx": + return docx2txt.process(str(file_path)) or "" + elif file_ext == "txt": + with open(file_path, "r", encoding="utf-8", errors="ignore") as f: + return f.read() + return "" + + def _normalize_text(self, text: str) -> str: + lines = [line.strip() for line in text.splitlines() if line.strip()] + return "\n".join(lines) + + def _chunk_text(self, text: str, max_chars: int = 1000) -> List[Tuple[str, int]]: + chunks = [] + start = 0 + while start < len(text): + end = min(start + max_chars, len(text)) + chunks.append((text[start:end], start)) + if end == len(text): + break + start += max_chars - 100 # 100 char overlap + return chunks + + def _generate_clinical_summary( + self, text: str, conditions: List[str], medications: List[str] + ) -> Tuple[str, List[str], str]: + findings = [] + if conditions: + findings.append(f"Detected potential medical conditions/indications: {', '.join(conditions[:8])}.") + if medications: + findings.append(f"Identified pharmaceutical agents/chemicals: {', '.join(medications[:8])}.") + + summary = ( + f"Medical document extraction completed. The document references " + f"{len(conditions)} clinical condition(s) and {len(medications)} pharmaceutical/chemical entity(s)." + ) + + recs = ( + "Extracted entities and automated summaries are provided for decision-support and educational review only. " + "All findings, dosages, and treatment plans should be verified by a licensed healthcare provider." + ) + + return summary, findings, recs diff --git a/backend/app/services/llm/base.py b/backend/app/services/llm/base.py new file mode 100644 index 0000000000000000000000000000000000000000..14179d6894af3a2eed214c7a6d648c14affe61e2 --- /dev/null +++ b/backend/app/services/llm/base.py @@ -0,0 +1,20 @@ +from abc import ABC, abstractmethod +from typing import List, Optional, Dict, Any +from backend.app.schemas.chat import AIStructuredOutput + + +class BaseLLMProvider(ABC): + @abstractmethod + async def generate_response( + self, + query: str, + chat_history: Optional[List[Dict[str, str]]] = None, + patient_context: Optional[Dict[str, Any]] = None, + ) -> AIStructuredOutput: + """Generate structured clinical decision-support response.""" + pass + + @abstractmethod + def get_provider_name(self) -> str: + """Return provider identifier.""" + pass diff --git a/backend/app/services/llm/gemini.py b/backend/app/services/llm/gemini.py new file mode 100644 index 0000000000000000000000000000000000000000..1c4fc1d92c02a31281cf9f58536b5d42c1473c2e --- /dev/null +++ b/backend/app/services/llm/gemini.py @@ -0,0 +1,91 @@ +import json +from typing import List, Optional, Dict, Any +import httpx +from backend.app.services.llm.base import BaseLLMProvider +from backend.app.services.llm.mock import MockLLMProvider +from backend.app.schemas.chat import AIStructuredOutput +from backend.app.schemas.common import MEDICAL_DISCLAIMER +from backend.app.core.config import settings +from backend.app.core.logger import logger + + +class GeminiProvider(BaseLLMProvider): + def __init__(self, api_key: Optional[str] = None): + self.api_key = api_key or settings.GEMINI_API_KEY + self.fallback = MockLLMProvider() + self.endpoint = "https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent" + + def get_provider_name(self) -> str: + return "Google Gemini (Gemini 1.5 Flash)" + + async def generate_response( + self, + query: str, + chat_history: Optional[List[Dict[str, str]]] = None, + patient_context: Optional[Dict[str, Any]] = None, + ) -> AIStructuredOutput: + if not self.api_key: + logger.info("No GEMINI_API_KEY provided; falling back to MockLLM provider.") + return await self.fallback.generate_response(query, chat_history, patient_context) + + system_instruction = ( + "You are SanjeevaniAI, an industry-grade clinical decision-support AI. " + "You provide educational health insights and considerations for discussions with doctors. " + "CRITICAL RULES: " + "1. NEVER give a definitive diagnosis (e.g. 'You have cancer'). Use non-diagnostic phrasing: 'The symptoms described may be associated with...'. " + "2. Distinguish extracted facts from AI considerations. " + "3. If emergency symptoms (chest pain, stroke, severe breathlessness) are present, set is_emergency=True and emphasize urgent care. " + "4. Respond STRICTLY in valid JSON matching this schema: " + "{" + " \"summary\": \"string\"," + " \"possible_considerations\": [\"string\"]," + " \"relevant_medical_info\": [\"string\"]," + " \"questions_for_doctor\": [\"string\"]," + " \"safety_warning\": \"string\"," + " \"is_emergency\": false," + " \"emergency_instructions\": null" + "}" + ) + + user_content = f"User Query: {query}\n" + if patient_context: + user_content += f"Patient Context: {json.dumps(patient_context)}\n" + + payload = { + "contents": [ + {"role": "user", "parts": [{"text": f"{system_instruction}\n\n{user_content}"}]} + ], + "generationConfig": { + "response_mime_type": "application/json", + "temperature": 0.2, + } + } + + try: + async with httpx.AsyncClient(timeout=15.0) as client: + res = await client.post( + f"{self.endpoint}?key={self.api_key}", + json=payload, + headers={"Content-Type": "application/json"} + ) + + if res.status_code != 200: + logger.warning(f"Gemini API returned status {res.status_code}: {res.text}. Falling back.") + return await self.fallback.generate_response(query, chat_history, patient_context) + + data = res.json() + text_out = data["candidates"][0]["content"]["parts"][0]["text"] + parsed = json.loads(text_out) + + return AIStructuredOutput( + summary=parsed.get("summary", "Summary unavailable."), + possible_considerations=parsed.get("possible_considerations", []), + relevant_medical_info=parsed.get("relevant_medical_info", []), + questions_for_doctor=parsed.get("questions_for_doctor", []), + safety_warning=parsed.get("safety_warning", MEDICAL_DISCLAIMER), + is_emergency=parsed.get("is_emergency", False), + emergency_instructions=parsed.get("emergency_instructions"), + ) + except Exception as ex: + logger.error(f"Error communicating with Gemini API: {str(ex)}. Using fallback.", exc_info=True) + return await self.fallback.generate_response(query, chat_history, patient_context) diff --git a/backend/app/services/llm/mock.py b/backend/app/services/llm/mock.py new file mode 100644 index 0000000000000000000000000000000000000000..752312791afce0679d126dc935ea58560d9b0e7f --- /dev/null +++ b/backend/app/services/llm/mock.py @@ -0,0 +1,134 @@ +from typing import List, Optional, Dict, Any +from backend.app.services.llm.base import BaseLLMProvider +from backend.app.schemas.chat import AIStructuredOutput +from backend.app.schemas.common import MEDICAL_DISCLAIMER + + +class MockLLMProvider(BaseLLMProvider): + """ + Offline/Demonstration Clinical AI Provider. + Generates structured, safe medical considerations adhering to clinical safety rules. + """ + + def get_provider_name(self) -> str: + return "MockLLM (Offline Decision Support Mode)" + + async def generate_response( + self, + query: str, + chat_history: Optional[List[Dict[str, str]]] = None, + patient_context: Optional[Dict[str, Any]] = None, + ) -> AIStructuredOutput: + query_lower = query.lower() + + # Check for critical emergency red flags + emergency_keywords = [ + "chest pain", "heart attack", "can't breathe", "difficulty breathing", + "severe bleeding", "unconscious", "stroke", "sudden numbness", "anaphylaxis", + "loss of speech", "severe head injury", "coughing blood" + ] + + is_emergency = any(kw in query_lower for kw in emergency_keywords) + + if is_emergency: + return AIStructuredOutput( + summary="CRITICAL HEALTHCARE ADVISORY: The symptoms you described may indicate a medical emergency requiring immediate attention.", + possible_considerations=[ + "Acute cardiopulmonary distress or severe cardiovascular / neurological emergency.", + "Time-sensitive clinical conditions requiring emergency department triage." + ], + relevant_medical_info=[ + "Emergency medical services are equipped to perform immediate diagnostics (ECG, vitals monitoring, oxygenation) and intervene rapidly.", + "Do not wait to see if symptoms subside or attempt home remedies." + ], + questions_for_doctor=[ + "What emergency protocols should be activated immediately?", + "What is the current oxygen saturation and blood pressure?" + ], + safety_warning="EMERGENCY NOTICE: Call 911 / 112 / your local emergency services or proceed to the nearest emergency hospital immediately.", + is_emergency=True, + emergency_instructions="Call emergency medical services immediately. Do not drive yourself. Remain seated in a comfortable upright position.", + ) + + # Contextual intelligence based on query topics + considerations = [] + medical_info = [] + questions = [] + + if "diabetes" in query_lower or "metformin" in query_lower or "sugar" in query_lower: + considerations.extend([ + "Glycemic regulation and metabolic status.", + "Potential association with Type 1 or Type 2 Diabetes Mellitus.", + "Medication adherence and lifestyle factors affecting insulin sensitivity." + ]) + medical_info.extend([ + "Metformin is a first-line biguanide antihyperglycemic agent that reduces hepatic glucose production.", + "HbA1c testing evaluates average blood glucose levels over the prior 2 to 3 months." + ]) + questions.extend([ + "What is my target fasting and post-prandial blood glucose range?", + "Are there dietary or renal function considerations with my current medication dosage?" + ]) + + elif "headache" in query_lower or "migraine" in query_lower: + considerations.extend([ + "Tension-type headache, vascular/migraine headache, or sinus inflammation.", + "Hydration, screen fatigue, sleep deprivation, or stress factors." + ]) + medical_info.extend([ + "Primary headaches are non-structural; however, 'thunderclap' sudden onset headaches warrant urgent evaluation.", + "Maintaining a headache diary tracking triggers (hydration, caffeine, sleep) aids clinical review." + ]) + questions.extend([ + "Would a formal neurological evaluation or imaging be warranted if headaches increase in frequency?", + "What non-pharmacological or prophylactic options exist for my symptoms?" + ]) + + elif "blood pressure" in query_lower or "hypertension" in query_lower: + considerations.extend([ + "Blood pressure elevation and cardiovascular health.", + "Sodium intake, physical activity, and stress-related sympathetic activation." + ]) + medical_info.extend([ + "Clinical guidelines categorize sustained readings above 130/80 mmHg as hypertension requiring medical tracking.", + "Serial morning and evening BP measurements provide a more reliable clinical picture than single readings." + ]) + questions.extend([ + "Should home blood pressure monitoring logs be kept prior to our next visit?", + "Are there specific dietary interventions (e.g., DASH diet) recommended for my profile?" + ]) + + else: + considerations.extend([ + "The symptoms described may be associated with various common physiological or medical factors.", + "Differential diagnostic assessment requires in-person clinical history, physical exam, and laboratory verification." + ]) + medical_info.extend([ + "Symptoms should be evaluated in context with duration, progression, and personal medical history.", + "Clinical decision support systems provide educational context but cannot substitute for direct physical examination." + ]) + questions.extend([ + "What diagnostic tests or blood work would help clarify these symptoms?", + "What red-flag signs should prompt me to seek urgent care?" + ]) + + # Integrate patient profile context if available + if patient_context: + if patient_context.get("known_allergies"): + medical_info.append(f"Recorded patient allergies: {', '.join(patient_context['known_allergies'])}") + if patient_context.get("chronic_conditions"): + considerations.append(f"Existing documented history of: {', '.join(patient_context['chronic_conditions'])}") + + summary = ( + f"Based on your query regarding '{query[:100]}', here is structured health information " + f"and clinical decision-support context to discuss with your healthcare provider." + ) + + return AIStructuredOutput( + summary=summary, + possible_considerations=considerations, + relevant_medical_info=medical_info, + questions_for_doctor=questions, + safety_warning=MEDICAL_DISCLAIMER, + is_emergency=False, + ) diff --git a/backend/app/services/llm/service.py b/backend/app/services/llm/service.py new file mode 100644 index 0000000000000000000000000000000000000000..965e98213acbca32c9dc453124297fa5e7f9b1cf --- /dev/null +++ b/backend/app/services/llm/service.py @@ -0,0 +1,36 @@ +from typing import Optional, List, Dict, Any +from backend.app.services.llm.base import BaseLLMProvider +from backend.app.services.llm.gemini import GeminiProvider +from backend.app.services.llm.mock import MockLLMProvider +from backend.app.schemas.chat import AIStructuredOutput +from backend.app.core.config import settings +from backend.app.core.logger import logger + + +class LLMService: + def __init__(self): + self.providers: Dict[str, BaseLLMProvider] = { + "gemini": GeminiProvider(), + "mock": MockLLMProvider(), + } + + def get_provider(self, provider_name: Optional[str] = None) -> BaseLLMProvider: + p_name = provider_name or settings.LLM_PROVIDER.lower() + if p_name in self.providers: + return self.providers[p_name] + logger.warning(f"Requested LLM provider '{p_name}' not found. Defaulting to mock provider.") + return self.providers["mock"] + + async def consult( + self, + query: str, + chat_history: Optional[List[Dict[str, str]]] = None, + patient_context: Optional[Dict[str, Any]] = None, + provider_name: Optional[str] = None, + ) -> tuple[AIStructuredOutput, str]: + provider = self.get_provider(provider_name) + response = await provider.generate_response(query, chat_history, patient_context) + return response, provider.get_provider_name() + + +llm_service = LLMService() diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..5538e48f716a5f4d9ac7435352f58b79e2d8297e --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,123 @@ +import pytest +import pytest_asyncio +import sys +import uuid +from pathlib import Path +from typing import AsyncGenerator +from httpx import AsyncClient, ASGITransport +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession +from sqlalchemy import select + +# Ensure project root is on sys.path +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from backend.app.main import app +from backend.app.core.database import Base, get_db +from backend.app.core.security import get_password_hash, create_access_token, UserRole +from backend.app.models.user import User +from backend.app.models.profile import PatientProfile + +# Test in-memory SQLite database +TEST_DB_URL = "sqlite+aiosqlite:///:memory:" + +test_engine = create_async_engine( + TEST_DB_URL, + connect_args={"check_same_thread": False}, +) + +TestingSessionLocal = async_sessionmaker( + bind=test_engine, + class_=AsyncSession, + expire_on_commit=False, + autocommit=False, + autoflush=False, +) + + +@pytest_asyncio.fixture(scope="session", autouse=True) +async def setup_test_db(): + async with test_engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield + async with test_engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + +@pytest_asyncio.fixture +async def db_session() -> AsyncGenerator[AsyncSession, None]: + async with TestingSessionLocal() as session: + yield session + + +@pytest_asyncio.fixture +async def client(db_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]: + async def override_get_db(): + yield db_session + + app.dependency_overrides[get_db] = override_get_db + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://testserver") as c: + yield c + + app.dependency_overrides.clear() + + +@pytest_asyncio.fixture +async def test_user(db_session: AsyncSession) -> User: + unique_email = f"patient.{uuid.uuid4().hex[:8]}@sanjeevani.ai" + user = User( + email=unique_email, + hashed_password=get_password_hash("TestPassword123!"), + full_name="Dr. Test Patient", + role=UserRole.PATIENT.value, + is_active=True, + is_verified=True, + ) + db_session.add(user) + await db_session.commit() + await db_session.refresh(user) + + profile = PatientProfile( + user_id=user.id, + age=45, + gender="Male", + blood_group="O+", + height_cm=175.0, + weight_kg=74.5, + ) + db_session.add(profile) + await db_session.commit() + + return user + + +@pytest_asyncio.fixture +async def auth_headers(test_user: User) -> dict: + token = create_access_token(subject=test_user.id, role=test_user.role) + return {"Authorization": f"Bearer {token}"} + + +@pytest_asyncio.fixture +async def test_admin(db_session: AsyncSession) -> User: + unique_admin_email = f"admin.{uuid.uuid4().hex[:8]}@sanjeevani.ai" + admin = User( + email=unique_admin_email, + hashed_password=get_password_hash("AdminPassword123!"), + full_name="System Administrator", + role=UserRole.ADMIN.value, + is_active=True, + is_verified=True, + ) + db_session.add(admin) + await db_session.commit() + await db_session.refresh(admin) + return admin + + +@pytest_asyncio.fixture +async def admin_headers(test_admin: User) -> dict: + token = create_access_token(subject=test_admin.id, role=test_admin.role) + return {"Authorization": f"Bearer {token}"} diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000000000000000000000000000000000000..26e4d59ce5f08013b09f1602e9bcf59e3b0f70f7 --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,63 @@ +import pytest +from httpx import AsyncClient +from backend.app.models.user import User + + +@pytest.mark.asyncio +async def test_user_registration(client: AsyncClient): + payload = { + "email": "new.patient@sanjeevani.ai", + "password": "SecurePassword123!", + "full_name": "Rohan Sharma", + "role": "PATIENT", + } + response = await client.post("/api/v1/auth/register", json=payload) + assert response.status_code == 200 + res_data = response.json() + assert res_data["success"] is True + assert "access_token" in res_data["data"] + assert "refresh_token" in res_data["data"] + assert res_data["data"]["user"]["email"] == "new.patient@sanjeevani.ai" + + +@pytest.mark.asyncio +async def test_user_login(client: AsyncClient): + # Register first + payload = { + "email": "login.user@sanjeevani.ai", + "password": "LoginPassword123!", + "full_name": "Anita Roy", + "role": "PATIENT", + } + await client.post("/api/v1/auth/register", json=payload) + + # Login + login_payload = { + "email": "login.user@sanjeevani.ai", + "password": "LoginPassword123!", + } + response = await client.post("/api/v1/auth/login", json=login_payload) + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert "access_token" in data["data"] + + +@pytest.mark.asyncio +async def test_user_login_invalid_password(client: AsyncClient): + login_payload = { + "email": "nonexistent@sanjeevani.ai", + "password": "WrongPassword", + } + response = await client.post("/api/v1/auth/login", json=login_payload) + assert response.status_code == 401 + assert response.json()["error"]["code"] == "AUTHENTICATION_FAILED" + + +@pytest.mark.asyncio +async def test_get_me(client: AsyncClient, test_user: User, auth_headers: dict): + response = await client.get("/api/v1/auth/me", headers=auth_headers) + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["data"]["email"] == test_user.email diff --git a/backend/tests/test_chat.py b/backend/tests/test_chat.py new file mode 100644 index 0000000000000000000000000000000000000000..1806699d993694e4aa432d7c84899f7bbc9c4668 --- /dev/null +++ b/backend/tests/test_chat.py @@ -0,0 +1,51 @@ +import pytest +from httpx import AsyncClient + + +@pytest.mark.asyncio +async def test_ai_chat_consultation(client: AsyncClient, auth_headers: dict): + payload = { + "message": "What are typical lifestyle considerations for managing Type 2 Diabetes?" + } + response = await client.post("/api/v1/chat/message", headers=auth_headers, json=payload) + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + + completion = data["data"] + assert "conversation_id" in completion + msg = completion["message"] + assert msg["role"] == "assistant" + assert msg["structured_data"] is not None + assert len(msg["structured_data"]["possible_considerations"]) > 0 + assert len(msg["structured_data"]["questions_for_doctor"]) > 0 + assert msg["structured_data"]["is_emergency"] is False + + +@pytest.mark.asyncio +async def test_ai_chat_emergency_detection(client: AsyncClient, auth_headers: dict): + payload = { + "message": "I am having sudden severe chest pain and difficulty breathing!" + } + response = await client.post("/api/v1/chat/message", headers=auth_headers, json=payload) + assert response.status_code == 200 + data = response.json() + structured = data["data"]["message"]["structured_data"] + + assert structured["is_emergency"] is True + assert "emergency" in structured["summary"].lower() or "emergency" in structured["safety_warning"].lower() + + +@pytest.mark.asyncio +async def test_chat_conversations_history(client: AsyncClient, auth_headers: dict): + # Send a message to ensure conversation exists + await client.post( + "/api/v1/chat/message", + headers=auth_headers, + json={"message": "Can metformin cause vitamin B12 deficiency?"} + ) + + response = await client.get("/api/v1/chat/conversations", headers=auth_headers) + assert response.status_code == 200 + convs = response.json()["data"] + assert len(convs) >= 1 diff --git a/backend/tests/test_documents.py b/backend/tests/test_documents.py new file mode 100644 index 0000000000000000000000000000000000000000..33764f72e0724b6895c1270ee4fb930cf8cd9005 --- /dev/null +++ b/backend/tests/test_documents.py @@ -0,0 +1,42 @@ +import pytest +import io +from httpx import AsyncClient + + +@pytest.mark.asyncio +async def test_document_upload_and_analysis(client: AsyncClient, auth_headers: dict): + clinical_text = ( + "CLINICAL SUMMARY:\n" + "Patient presents with persistent cough and fever.\n" + "Diagnosis: Acute bronchitis and mild hypertension.\n" + "Plan: Start azithromycin 500mg daily and continue lisinopril 10mg." + ) + + files = { + "file": ("test_clinical_report.txt", io.BytesIO(clinical_text.encode("utf-8")), "text/plain") + } + + response = await client.post("/api/v1/documents/upload", headers=auth_headers, files=files) + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + doc_data = data["data"] + assert doc_data["status"] == "COMPLETED" + assert doc_data["analysis"] is not None + + doc_id = doc_data["id"] + + # Test listing documents + list_resp = await client.get("/api/v1/documents", headers=auth_headers) + assert list_resp.status_code == 200 + list_data = list_resp.json() + assert len(list_data["data"]) >= 1 + + # Test get document by ID + get_resp = await client.get(f"/api/v1/documents/{doc_id}", headers=auth_headers) + assert get_resp.status_code == 200 + assert get_resp.json()["data"]["id"] == doc_id + + # Test delete document + del_resp = await client.delete(f"/api/v1/documents/{doc_id}", headers=auth_headers) + assert del_resp.status_code == 200 diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py new file mode 100644 index 0000000000000000000000000000000000000000..9ccb837ddaa654f44b631d668fc3244403367d71 --- /dev/null +++ b/backend/tests/test_health.py @@ -0,0 +1,20 @@ +import pytest +from httpx import AsyncClient + + +@pytest.mark.asyncio +async def test_health_check(client: AsyncClient): + response = await client.get("/api/v1/health") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "healthy" + assert data["app"] == "SanjeevaniAI" + + +@pytest.mark.asyncio +async def test_root_endpoint(client: AsyncClient): + response = await client.get("/") + assert response.status_code == 200 + data = response.json() + assert "SanjeevaniAI" in data["app"] + assert "disclaimer" in data diff --git a/backend/tests/test_ner.py b/backend/tests/test_ner.py new file mode 100644 index 0000000000000000000000000000000000000000..61dbacd93d4ddeb696fb0259f3b6c8486ca45230 --- /dev/null +++ b/backend/tests/test_ner.py @@ -0,0 +1,42 @@ +import pytest +from httpx import AsyncClient + + +@pytest.mark.asyncio +async def test_ner_model_info(client: AsyncClient): + response = await client.get("/api/v1/ner/model-info") + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["data"]["name"] == "tner/roberta-large-bc5cdr" + + +@pytest.mark.asyncio +async def test_ner_analyze_endpoint(client: AsyncClient): + payload = { + "text": "The patient was prescribed metformin for type 2 diabetes mellitus." + } + response = await client.post("/api/v1/ner/analyze", json=payload) + assert response.status_code == 200 + data = response.json() + + assert "request_id" in data + assert data["model"]["name"] == "tner/roberta-large-bc5cdr" + assert data["entity_count"] > 0 + assert data["processing_time_ms"] >= 0 + + entity_texts = [e["text"].lower() for e in data["entities"]] + labels = [e["label"] for e in data["entities"]] + + # Verify extracted entities + assert any("metformin" in t for t in entity_texts) + assert any("diabetes" in t for t in entity_texts) + assert "CHEMICAL" in labels + assert "DISEASE" in labels + + +@pytest.mark.asyncio +async def test_ner_empty_text_validation(client: AsyncClient): + payload = {"text": ""} + response = await client.post("/api/v1/ner/analyze", json=payload) + assert response.status_code == 422 diff --git a/backend/tests/test_profile.py b/backend/tests/test_profile.py new file mode 100644 index 0000000000000000000000000000000000000000..8fd61afb3ad55e40e4ce48514edca458f229e42d --- /dev/null +++ b/backend/tests/test_profile.py @@ -0,0 +1,45 @@ +import pytest +from httpx import AsyncClient + + +@pytest.mark.asyncio +async def test_get_and_update_profile(client: AsyncClient, auth_headers: dict): + # Fetch profile + get_resp = await client.get("/api/v1/profile", headers=auth_headers) + assert get_resp.status_code == 200 + profile_data = get_resp.json()["data"] + assert profile_data["blood_group"] == "O+" + + # Update profile + update_payload = { + "age": 46, + "gender": "Male", + "blood_group": "O+", + "height_cm": 176.0, + "weight_kg": 73.0, + "known_allergies": ["Penicillin", "Peanuts"], + "chronic_conditions": ["Type 2 Diabetes"], + "current_medications": ["Metformin 500mg"], + "emergency_contact": "+1 (555) 019-2834", + } + put_resp = await client.put("/api/v1/profile", headers=auth_headers, json=update_payload) + assert put_resp.status_code == 200 + updated = put_resp.json()["data"] + assert updated["age"] == 46 + assert "Penicillin" in updated["known_allergies"] + assert "Type 2 Diabetes" in updated["chronic_conditions"] + + +@pytest.mark.asyncio +async def test_admin_stats(client: AsyncClient, admin_headers: dict): + response = await client.get("/api/v1/admin/stats", headers=admin_headers) + assert response.status_code == 200 + data = response.json()["data"] + assert "total_users" in data + assert data["system_health"] == "Operational" + + +@pytest.mark.asyncio +async def test_admin_access_forbidden_for_patient(client: AsyncClient, auth_headers: dict): + response = await client.get("/api/v1/admin/stats", headers=auth_headers) + assert response.status_code == 403 diff --git a/css/style.css b/css/style.css deleted file mode 100644 index de18277255b46325d66633ec7325f9098a61c8f7..0000000000000000000000000000000000000000 --- a/css/style.css +++ /dev/null @@ -1,206 +0,0 @@ -:root { - --green: #0E3B2E; - --green-soft: #14503E; - --cream: #FBF7F0; - --orange: #D98E30; - --sage: #9FB8AC; - --ink: #1C1F1B; - --muted: #44483F; - --severe: #B23B3B; - --moderate: #D98E30; - --mild: #5C8A6A; - --white: #FFFFFF; -} - -* { box-sizing: border-box; } - -body { - margin: 0; - font-family: "Segoe UI", "Noto Sans", "Noto Sans Devanagari", "Noto Sans Tamil", Arial, sans-serif; - background: var(--cream); - color: var(--ink); -} - -.hidden { display: none !important; } - -.phone { - max-width: 430px; - margin: 0 auto; - min-height: 100vh; - background: var(--cream); - display: flex; - flex-direction: column; - position: relative; - box-shadow: 0 0 40px rgba(0,0,0,0.08); -} - -.topBar { - background: var(--green); - color: var(--white); - padding: 18px 20px 16px; - display: flex; - align-items: center; - justify-content: space-between; -} - -.topBar .brand { display: flex; align-items: center; gap: 10px; } -.topBar .brandMark { - width: 34px; height: 34px; border-radius: 9px; - background: var(--orange); display: flex; align-items: center; justify-content: center; - font-size: 18px; -} -.topBar h1 { font-size: 17px; margin: 0; font-weight: 700; } -.topBar .badge { font-size: 11px; opacity: 0.85; } - -.langRow { display: flex; gap: 6px; } -.langSwitch { - background: rgba(255,255,255,0.12); - border: 1px solid rgba(255,255,255,0.3); - color: var(--white); - font-size: 11px; - padding: 5px 8px; - border-radius: 6px; - cursor: pointer; -} -.langSwitch.active, .langSwitch:hover { background: var(--orange); border-color: var(--orange); } - -.screen { flex: 1; padding: 22px 20px 100px; } - -/* ---- Language / onboarding ---- */ -.centerScreen { display: flex; flex-direction: column; justify-content: center; min-height: 100vh; padding: 32px; } -.heroMark { - width: 64px; height: 64px; border-radius: 18px; background: var(--green); - display: flex; align-items: center; justify-content: center; font-size: 30px; - margin-bottom: 18px; color: var(--white); -} -.heroTitle { font-size: 26px; font-weight: 800; color: var(--green); margin: 0 0 6px; } -.heroTag { color: var(--muted); font-size: 14px; margin-bottom: 28px; } -.stepLabel { font-size: 13px; color: var(--muted); margin-bottom: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; } - -.langGrid { display: flex; flex-direction: column; gap: 10px; } -.langCard { - padding: 16px; border-radius: 12px; border: 1.5px solid var(--sage); - background: var(--white); font-size: 16px; font-weight: 600; color: var(--ink); - cursor: pointer; text-align: left; -} -.langCard:hover { border-color: var(--orange); background: #FFF6EA; } - -.formField { margin-bottom: 14px; } -.formField label { display: block; font-size: 13px; color: var(--muted); margin-bottom: 6px; font-weight: 600; } -.formField input { - width: 100%; padding: 12px 14px; border-radius: 10px; border: 1.5px solid var(--sage); - font-size: 15px; background: var(--white); -} -.primaryBtn { - width: 100%; padding: 14px; border-radius: 12px; border: none; - background: var(--orange); color: var(--white); font-size: 15px; font-weight: 700; - cursor: pointer; margin-top: 8px; -} -.primaryBtn:hover { background: #c17f26; } - -/* ---- Search / cabinet ---- */ -.sectionTitle { font-size: 19px; font-weight: 800; color: var(--green); margin: 0 0 4px; } -.sectionSub { font-size: 13px; color: var(--muted); margin-bottom: 16px; } - -.searchBox { display: flex; gap: 8px; margin-bottom: 6px; } -.searchBox input { - flex: 1; padding: 12px 14px; border-radius: 10px; border: 1.5px solid var(--sage); font-size: 14px; -} -.scanBtn { - border: 1.5px solid var(--green); background: var(--white); color: var(--green); - border-radius: 10px; padding: 0 14px; font-size: 13px; font-weight: 600; cursor: pointer; white-space: nowrap; -} -.scanStatus { font-size: 12px; color: var(--orange); margin: 6px 0; font-weight: 600; } -.scanHelp { font-size: 11px; color: var(--muted); margin-bottom: 10px; } - -.searchResults { display: flex; flex-direction: column; gap: 8px; margin-bottom: 18px; } -.resultRow { - display: flex; align-items: center; gap: 10px; background: var(--white); - border: 1px solid var(--sage); border-radius: 10px; padding: 10px 12px; -} -.resultIcon { font-size: 20px; } -.resultInfo { flex: 1; } -.resultName { font-weight: 700; font-size: 13.5px; } -.resultMeta { font-size: 11.5px; color: var(--muted); } -.addBtn { - background: var(--green); color: var(--white); border: none; border-radius: 8px; - padding: 7px 12px; font-size: 12px; font-weight: 700; cursor: pointer; -} -.noMatch { font-size: 13px; color: var(--muted); padding: 10px; } - -.cabinetList { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; } -.pillChip { - display: flex; align-items: center; gap: 6px; background: var(--white); - border: 1px solid var(--sage); border-radius: 999px; padding: 7px 10px 7px 12px; font-size: 12.5px; font-weight: 600; -} -.pillIcon { font-size: 15px; } -.pillRemove { - border: none; background: #eee; color: var(--muted); border-radius: 50%; - width: 18px; height: 18px; font-size: 10px; cursor: pointer; line-height: 1; -} -.emptyState { color: var(--muted); font-size: 13.5px; padding: 14px 0; } - -/* ---- Risk cards ---- */ -.riskSummary { - font-weight: 800; font-size: 13.5px; padding: 8px 12px; border-radius: 8px; margin-bottom: 10px; - background: #FDEDED; color: var(--severe); display: inline-block; -} -.riskCard { - background: var(--white); border-radius: 12px; padding: 14px; margin-bottom: 10px; - border-left: none; box-shadow: 0 1px 4px rgba(0,0,0,0.06); -} -.riskCard.allClear { color: var(--mild); font-weight: 700; font-size: 13.5px; } -.riskHeader { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; flex-wrap: wrap; } -.sevBadge { font-size: 10.5px; font-weight: 800; padding: 3px 8px; border-radius: 999px; color: var(--white); text-transform: uppercase; letter-spacing: 0.03em; } -.sevBadge.severe { background: var(--severe); } -.sevBadge.moderate { background: var(--moderate); } -.sevBadge.mild { background: var(--mild); } -.riskPair { font-weight: 700; font-size: 12.5px; color: var(--ink); } -.riskTitle { font-weight: 800; font-size: 14.5px; margin-bottom: 6px; color: var(--green); } -.riskRow { font-size: 12.5px; color: var(--muted); margin-bottom: 4px; line-height: 1.4; } -.speakBtn { - margin-top: 8px; border: 1px solid var(--green); background: var(--white); color: var(--green); - border-radius: 8px; padding: 6px 12px; font-size: 12px; font-weight: 700; cursor: pointer; -} -.demoNote { font-size: 10.5px; color: var(--muted); font-style: italic; margin-top: 6px; } - -/* ---- Schedule ---- */ -.scheduleSlot { margin-bottom: 16px; } -.slotHeader { font-weight: 800; font-size: 14.5px; color: var(--green); margin-bottom: 8px; } -.slotItems { display: flex; flex-direction: column; gap: 8px; } -.slotItem { - display: flex; align-items: center; gap: 10px; background: var(--white); - border: 1px solid var(--sage); border-radius: 10px; padding: 10px 12px; -} -.slotName { flex: 1; font-weight: 700; font-size: 13px; } -.foodTag { font-size: 11px; background: #F1EBDD; color: var(--muted); padding: 3px 8px; border-radius: 999px; font-weight: 600; } - -/* ---- Pharmacist dashboard ---- */ -.statRow { display: flex; gap: 10px; margin-bottom: 18px; } -.statCard { flex: 1; background: var(--white); border: 1px solid var(--sage); border-radius: 12px; padding: 14px 8px; text-align: center; } -.statCard.severe { border-color: var(--severe); background: #FDEDED; } -.statCard.moderate { border-color: var(--moderate); background: #FCF1E2; } -.statCard.mild { border-color: var(--mild); background: #EFF6F1; } -.statNum { font-size: 20px; font-weight: 800; color: var(--green); } -.statLabel { font-size: 10.5px; color: var(--muted); font-weight: 600; margin-top: 2px; } -.pharmMedList { margin-bottom: 14px; } -.pharmMedRow { font-size: 13px; padding: 8px 0; border-bottom: 1px dashed var(--sage); font-weight: 600; } - -/* ---- Bottom nav ---- */ -.navBar { - position: fixed; bottom: 0; left: 50%; transform: translateX(-50%); - width: 100%; max-width: 430px; background: var(--white); border-top: 1px solid var(--sage); - display: flex; justify-content: space-around; padding: 10px 6px 14px; -} -.navBtn { - background: none; border: none; font-size: 11px; font-weight: 700; color: var(--muted); - display: flex; flex-direction: column; align-items: center; gap: 3px; cursor: pointer; padding: 4px 8px; -} -.navBtn .navIcon { font-size: 19px; } -.navBtn.active { color: var(--orange); } - -.footNote { font-size: 10.5px; color: var(--muted); text-align: center; margin-top: 18px; line-height: 1.5; } - -@media print { - .navBar, .topBar .langRow { display: none; } -} diff --git a/data/drugs.js b/data/drugs.js deleted file mode 100644 index eac4de2563550d15e6267cbf23262ed13d3f1aea..0000000000000000000000000000000000000000 --- a/data/drugs.js +++ /dev/null @@ -1,353 +0,0 @@ -const DRUGS_DATA = [ - { - "id": "paracetamol", - "generic": "Paracetamol", - "brands": [ - "Dolo 650", - "Crocin", - "Calpol" - ], - "category": "Analgesic / Antipyretic", - "hi": "पैरासिटामोल", - "ta": "பாராசிட்டமால்", - "icon": "💊" - }, - { - "id": "aspirin", - "generic": "Aspirin", - "brands": [ - "Ecosprin", - "Disprin" - ], - "category": "Antiplatelet / NSAID", - "hi": "एस्पिरिन", - "ta": "ஆஸ்பிரின்", - "icon": "💊" - }, - { - "id": "ibuprofen", - "generic": "Ibuprofen", - "brands": [ - "Brufen", - "Combiflam" - ], - "category": "NSAID", - "hi": "आइबुप्रोफेन", - "ta": "ஐபுப்ரோஃபென்", - "icon": "💊" - }, - { - "id": "diclofenac", - "generic": "Diclofenac", - "brands": [ - "Voveran" - ], - "category": "NSAID", - "hi": "डाइक्लोफेनाक", - "ta": "டைக்ளோஃபெனாக்", - "icon": "💊" - }, - { - "id": "metformin", - "generic": "Metformin", - "brands": [ - "Glycomet", - "Glucophage" - ], - "category": "Antidiabetic (Biguanide)", - "hi": "मेटफॉर्मिन", - "ta": "மெட்ஃபார்மின்", - "icon": "🩸" - }, - { - "id": "glimepiride", - "generic": "Glimepiride", - "brands": [ - "Amaryl" - ], - "category": "Antidiabetic (Sulfonylurea)", - "hi": "ग्लिमेपिराइड", - "ta": "க்ளிமெபிரைடு", - "icon": "🩸" - }, - { - "id": "insulin", - "generic": "Insulin", - "brands": [ - "Human Mixtard", - "Lantus" - ], - "category": "Antidiabetic (Injectable)", - "hi": "इंसुलिन", - "ta": "இன்சுலின்", - "icon": "🩸" - }, - { - "id": "enalapril", - "generic": "Enalapril", - "brands": [ - "Envas" - ], - "category": "ACE Inhibitor", - "hi": "एनालाप्रिल", - "ta": "எனலாப்ரில்", - "icon": "❤️" - }, - { - "id": "ramipril", - "generic": "Ramipril", - "brands": [ - "Cardace" - ], - "category": "ACE Inhibitor", - "hi": "रामिप्रिल", - "ta": "ராமிப்ரில்", - "icon": "❤️" - }, - { - "id": "amlodipine", - "generic": "Amlodipine", - "brands": [ - "Amlong", - "Amlopres" - ], - "category": "Calcium Channel Blocker", - "hi": "एम्लोडिपिन", - "ta": "அம்லோடிபின்", - "icon": "❤️" - }, - { - "id": "spironolactone", - "generic": "Spironolactone", - "brands": [ - "Aldactone" - ], - "category": "Potassium-sparing Diuretic", - "hi": "स्पिरोनोलैक्टोन", - "ta": "ஸ்பைரோனோலாக்டோன்", - "icon": "❤️" - }, - { - "id": "furosemide", - "generic": "Furosemide", - "brands": [ - "Lasix" - ], - "category": "Loop Diuretic", - "hi": "फ्यूरोसेमाइड", - "ta": "ஃபியூரோசெமைடு", - "icon": "❤️" - }, - { - "id": "digoxin", - "generic": "Digoxin", - "brands": [ - "Lanoxin" - ], - "category": "Cardiac Glycoside", - "hi": "डिगॉक्सिन", - "ta": "டிகாக்சின்", - "icon": "❤️" - }, - { - "id": "atorvastatin", - "generic": "Atorvastatin", - "brands": [ - "Atorva", - "Storvas" - ], - "category": "Statin", - "hi": "एटोरवास्टेटिन", - "ta": "அடோர்வாஸ்டாடின்", - "icon": "❤️" - }, - { - "id": "simvastatin", - "generic": "Simvastatin", - "brands": [ - "Simvotin" - ], - "category": "Statin", - "hi": "सिमवास्टेटिन", - "ta": "சிம்வாஸ்டாடின்", - "icon": "❤️" - }, - { - "id": "clopidogrel", - "generic": "Clopidogrel", - "brands": [ - "Clopilet" - ], - "category": "Antiplatelet", - "hi": "क्लोपिडोग्रेल", - "ta": "க்ளோபிடோகிரெல்", - "icon": "❤️" - }, - { - "id": "warfarin", - "generic": "Warfarin", - "brands": [ - "Sofarin" - ], - "category": "Anticoagulant", - "hi": "वारफारिन", - "ta": "வார்ஃபரின்", - "icon": "🩸" - }, - { - "id": "omeprazole", - "generic": "Omeprazole", - "brands": [ - "Omez" - ], - "category": "Proton Pump Inhibitor", - "hi": "ओमेप्राज़ोल", - "ta": "ஓமிப்ராசோல்", - "icon": "🍽️" - }, - { - "id": "levothyroxine", - "generic": "Levothyroxine", - "brands": [ - "Eltroxin", - "Thyronorm" - ], - "category": "Thyroid Hormone", - "hi": "लेवोथायरोक्सिन", - "ta": "லெவோதைராக்சின்", - "icon": "🦋" - }, - { - "id": "calcium", - "generic": "Calcium Carbonate", - "brands": [ - "Shelcal" - ], - "category": "Supplement", - "hi": "कैल्शियम कार्बोनेट", - "ta": "கால்சியம் கார்பனேட்", - "icon": "🦴" - }, - { - "id": "ciprofloxacin", - "generic": "Ciprofloxacin", - "brands": [ - "Cifran" - ], - "category": "Antibiotic (Fluoroquinolone)", - "hi": "सिप्रोफ्लोक्सासिन", - "ta": "சிப்ரோஃப்ளாக்சசின்", - "icon": "🦠" - }, - { - "id": "azithromycin", - "generic": "Azithromycin", - "brands": [ - "Azithral" - ], - "category": "Antibiotic (Macrolide)", - "hi": "एज़िथ्रोमाइसिन", - "ta": "அசித்ரோமைசின்", - "icon": "🦠" - }, - { - "id": "clarithromycin", - "generic": "Clarithromycin", - "brands": [ - "Claribid" - ], - "category": "Antibiotic (Macrolide)", - "hi": "क्लैरिथ्रोमाइसिन", - "ta": "கிளாரித்ரோமைசின்", - "icon": "🦠" - }, - { - "id": "metronidazole", - "generic": "Metronidazole", - "brands": [ - "Flagyl" - ], - "category": "Antibiotic / Antiprotozoal", - "hi": "मेट्रोनिडाज़ोल", - "ta": "மெட்ரோனிடசோல்", - "icon": "🦠" - }, - { - "id": "sertraline", - "generic": "Sertraline", - "brands": [ - "Zoloft" - ], - "category": "SSRI Antidepressant", - "hi": "सर्ट्रालाइन", - "ta": "செர்ட்ரலைன்", - "icon": "🧠" - }, - { - "id": "fluoxetine", - "generic": "Fluoxetine", - "brands": [ - "Prozac" - ], - "category": "SSRI Antidepressant", - "hi": "फ्लुओक्सेटीन", - "ta": "புளூக்செடின்", - "icon": "🧠" - }, - { - "id": "tramadol", - "generic": "Tramadol", - "brands": [ - "Ultracet" - ], - "category": "Opioid Analgesic", - "hi": "ट्रामाडोल", - "ta": "ட்ராமடோல்", - "icon": "💊" - }, - { - "id": "sildenafil", - "generic": "Sildenafil", - "brands": [ - "Viagra", - "Suhagra" - ], - "category": "PDE5 Inhibitor", - "hi": "सिल्डेनाफिल", - "ta": "சில்டெனாஃபில்", - "icon": "❤️" - }, - { - "id": "isosorbide", - "generic": "Isosorbide Dinitrate", - "brands": [ - "Sorbitrate" - ], - "category": "Nitrate (Anti-anginal)", - "hi": "आइसोसोर्बाइड", - "ta": "ஐசோசார்பைடு", - "icon": "❤️" - }, - { - "id": "propranolol", - "generic": "Propranolol", - "brands": [ - "Ciplar" - ], - "category": "Beta Blocker", - "hi": "प्रोप्रानोलोल", - "ta": "புரோப்ரானொலால்", - "icon": "❤️" - }, - { - "id": "ginkgo", - "generic": "Ginkgo Biloba", - "brands": [ - "Herbal Supplement" - ], - "category": "Herbal Supplement", - "hi": "जिन्कगो बिलोबा", - "ta": "ஜிங்கோ பிலோபா", - "icon": "🌿" - } -]; diff --git a/data/interactions.js b/data/interactions.js deleted file mode 100644 index d232e09acd6104b1ccdcb1d0054c84b733b445dc..0000000000000000000000000000000000000000 --- a/data/interactions.js +++ /dev/null @@ -1,354 +0,0 @@ -const INTERACTIONS_DATA = [ - { - "pair": [ - "warfarin", - "aspirin" - ], - "severity": "severe", - "title": { - "en": "Major bleeding risk", - "hi": "गंभीर रक्तस्राव का खतरा", - "ta": "கடுமையான இரத்தப்போக்கு அபாயம்" - }, - "explain": { - "en": "Both thin the blood. Taking them together sharply raises the risk of internal or external bleeding.", - "hi": "दोनों दवाएं खून को पतला करती हैं। साथ लेने से रक्तस्राव का खतरा काफी बढ़ जाता है।", - "ta": "இரண்டும் இரத்தத்தை மெலிதாக்குகின்றன. இணைந்து எடுத்தால் இரத்தப்போக்கு அபாயம் அதிகரிக்கும்." - }, - "advice": { - "en": "Do not combine without a doctor's supervision and INR monitoring.", - "hi": "डॉक्टर की निगरानी और INR जांच के बिना एक साथ न लें।", - "ta": "மருத்துவரின் மேற்பார்வை இல்லாமல் இணைத்து எடுக்க வேண்டாம்." - } - }, - { - "pair": [ - "ramipril", - "spironolactone" - ], - "severity": "severe", - "title": { - "en": "Dangerous potassium rise (hyperkalemia)", - "hi": "पोटैशियम का खतरनाक स्तर तक बढ़ना", - "ta": "ஆபத்தான பொட்டாசியம் அதிகரிப்பு" - }, - "explain": { - "en": "ACE inhibitors and potassium-sparing diuretics both raise blood potassium — together this can cause dangerous heart rhythm problems.", - "hi": "यह संयोजन रक्त में पोटैशियम को खतरनाक स्तर तक बढ़ा सकता है, जिससे हृदय गति में समस्या हो सकती है।", - "ta": "இந்த கலவை இரத்த பொட்டாசியத்தை ஆபத்தான அளவிற்கு உயர்த்தலாம், இதனால் இதய தாள பிரச்சனை ஏற்படலாம்." - }, - "advice": { - "en": "Needs regular potassium/kidney function monitoring by a doctor.", - "hi": "डॉक्टर द्वारा नियमित पोटैशियम और किडनी जांच आवश्यक है।", - "ta": "மருத்துவரால் தொடர்ந்து பொட்டாசியம் பரிசோதனை தேவை." - } - }, - { - "pair": [ - "simvastatin", - "clarithromycin" - ], - "severity": "severe", - "title": { - "en": "Muscle damage risk (rhabdomyolysis)", - "hi": "मांसपेशी क्षति का खतरा", - "ta": "தசை சேத அபாயம்" - }, - "explain": { - "en": "This antibiotic raises statin levels in the blood, increasing the risk of severe muscle breakdown.", - "hi": "यह एंटीबायोटिक स्टैटिन का स्तर बढ़ा देता है, जिससे मांसपेशी टूटने का खतरा बढ़ जाता है।", - "ta": "இந்த ஆண்டிபயாடிக் ஸ்டேடின் அளவை உயர்த்தி, தசை சேத அபாயத்தை அதிகரிக்கும்." - }, - "advice": { - "en": "Ask the pharmacist about a safer alternative antibiotic.", - "hi": "फार्मासिस्ट से सुरक्षित एंटीबायोटिक विकल्प के बारे में पूछें।", - "ta": "பாதுகாப்பான மாற்று ஆண்டிபயாடிக் பற்றி மருந்தாளரிடம் கேளுங்கள்." - } - }, - { - "pair": [ - "digoxin", - "furosemide" - ], - "severity": "severe", - "title": { - "en": "Digoxin toxicity risk", - "hi": "डिगॉक्सिन विषाक्तता का खतरा", - "ta": "டிகாக்சின் நச்சுத்தன்மை அபாயம்" - }, - "explain": { - "en": "This diuretic can lower potassium, which makes digoxin more toxic to the heart.", - "hi": "यह मूत्रवर्धक पोटैशियम कम करता है, जिससे डिगॉक्सिन हृदय के लिए अधिक विषैला हो जाता है।", - "ta": "இந்த மூத்திரவிரிவி பொட்டாசியத்தை குறைக்கும், இதனால் டிகாக்சின் இதயத்திற்கு அதிக நச்சுத்தன்மை அடையும்." - }, - "advice": { - "en": "Regular potassium and digoxin level checks are essential.", - "hi": "नियमित पोटैशियम और डिगॉक्सिन स्तर जांच आवश्यक है।", - "ta": "தொடர்ந்து பொட்டாசியம் மற்றும் டிகாக்சின் அளவு பரிசோதனை அவசியம்." - } - }, - { - "pair": [ - "sildenafil", - "isosorbide" - ], - "severity": "severe", - "title": { - "en": "Severe, life-threatening low blood pressure", - "hi": "जानलेवा रूप से रक्तचाप गिरना", - "ta": "உயிருக்கு ஆபத்தான குறைந்த இரத்த அழுத்தம்" - }, - "explain": { - "en": "This combination can cause a sudden, severe drop in blood pressure. It should never be combined.", - "hi": "यह संयोजन रक्तचाप में अचानक भारी गिरावट ला सकता है। इन्हें कभी साथ न लें।", - "ta": "இந்த கலவை இரத்த அழுத்தத்தை திடீரென கடுமையாக குறைக்கலாம். ஒருபோதும் இணைக்க வேண்டாம்." - }, - "advice": { - "en": "Contraindicated — flag for immediate pharmacist/doctor review.", - "hi": "यह संयोजन वर्जित है — तुरंत डॉक्टर से संपर्क करें।", - "ta": "இது தடைசெய்யப்பட்டது — உடனடியாக மருத்துவரை அணுகவும்." - } - }, - { - "pair": [ - "tramadol", - "sertraline" - ], - "severity": "severe", - "title": { - "en": "Serotonin syndrome risk", - "hi": "सेरोटोनिन सिंड्रोम का खतरा", - "ta": "செரோடோனின் நோய்க்கூட்டு அபாயம்" - }, - "explain": { - "en": "Both raise serotonin activity. Together they can cause agitation, high fever, and a dangerous reaction.", - "hi": "दोनों सेरोटोनिन बढ़ाते हैं। साथ लेने से बेचैनी, तेज़ बुखार और खतरनाक प्रतिक्रिया हो सकती है।", - "ta": "இரண்டும் செரோடோனினை அதிகரிக்கும். இணைந்தால் அமைதியின்மை, அதிக காய்ச்சல் ஏற்படலாம்." - }, - "advice": { - "en": "Flag for doctor review before continuing both.", - "hi": "दोनों जारी रखने से पहले डॉक्टर से सलाह लें।", - "ta": "தொடர்வதற்கு முன் மருத்துவரிடம் ஆலோசிக்கவும்." - } - }, - { - "pair": [ - "tramadol", - "fluoxetine" - ], - "severity": "severe", - "title": { - "en": "Serotonin syndrome risk", - "hi": "सेरोटोनिन सिंड्रोम का खतरा", - "ta": "செரோடோனின் நோய்க்கூட்டு அபாயம்" - }, - "explain": { - "en": "Both raise serotonin activity, increasing risk of a dangerous reaction when combined.", - "hi": "दोनों सेरोटोनिन बढ़ाते हैं, साथ लेने पर खतरनाक प्रतिक्रिया का खतरा बढ़ता है।", - "ta": "இரண்டும் செரோடோனினை அதிகரிக்கும், இணைந்தால் ஆபத்தான எதிர்வினை அபாயம் அதிகரிக்கும்." - }, - "advice": { - "en": "Flag for doctor review before continuing both.", - "hi": "दोनों जारी रखने से पहले डॉक्टर से सलाह लें।", - "ta": "தொடர்வதற்கு முன் மருத்துவரிடம் ஆலோசிக்கவும்." - } - }, - { - "pair": [ - "metronidazole", - "warfarin" - ], - "severity": "moderate", - "title": { - "en": "Increased bleeding risk", - "hi": "रक्तस्राव का बढ़ा जोखिम", - "ta": "இரத்தப்போக்கு அபாய அதிகரிப்பு" - }, - "explain": { - "en": "This antibiotic can strengthen warfarin's blood-thinning effect.", - "hi": "यह एंटीबायोटिक वारफारिन के प्रभाव को बढ़ा सकता है।", - "ta": "இந்த ஆண்டிபயாடிக் வார்ஃபரின் விளைவை அதிகரிக்கலாம்." - }, - "advice": { - "en": "Doctor may need to adjust the warfarin dose temporarily.", - "hi": "डॉक्टर को अस्थायी रूप से खुराक बदलनी पड़ सकती है।", - "ta": "மருத்துவர் தற்காலிகமாக அளவை மாற்ற வேண்டியிருக்கலாம்." - } - }, - { - "pair": [ - "clopidogrel", - "omeprazole" - ], - "severity": "moderate", - "title": { - "en": "Reduced antiplatelet effect", - "hi": "एंटीप्लेटलेट प्रभाव में कमी", - "ta": "ஆன்டிபிளேட்லெட் விளைவு குறைவு" - }, - "explain": { - "en": "Omeprazole can reduce how well clopidogrel prevents blood clots.", - "hi": "ओमेप्राज़ोल क्लोपिडोग्रेल के थक्का-रोधी प्रभाव को कम कर सकता है।", - "ta": "ஓமிப்ராசோல் க்ளோபிடோகிரெலின் விளைவை குறைக்கலாம்." - }, - "advice": { - "en": "Ask about an alternative PPI (e.g., pantoprazole).", - "hi": "वैकल्पिक PPI (जैसे पैंटोप्राज़ोल) के बारे में पूछें।", - "ta": "மாற்று PPI பற்றி கேளுங்கள்." - } - }, - { - "pair": [ - "diclofenac", - "ramipril" - ], - "severity": "moderate", - "title": { - "en": "Reduced blood pressure control + kidney strain", - "hi": "रक्तचाप नियंत्रण में कमी और किडनी पर असर", - "ta": "இரத்த அழுத்த கட்டுப்பாடு குறைவு மற்றும் சிறுநீரக அழுத்தம்" - }, - "explain": { - "en": "NSAIDs can blunt the blood-pressure-lowering effect of ACE inhibitors and stress the kidneys.", - "hi": "NSAID दवाएं रक्तचाप कम करने वाली दवा के असर को कम कर सकती हैं और किडनी पर दबाव डालती हैं।", - "ta": "NSAID மருந்துகள் இரத்த அழுத்த மருந்தின் விளைவை குறைத்து சிறுநீரகத்தை பாதிக்கலாம்." - }, - "advice": { - "en": "Use short-term only; monitor blood pressure and kidney function.", - "hi": "केवल थोड़े समय के लिए उपयोग करें; रक्तचाप और किडनी की निगरानी करें।", - "ta": "குறுகிய காலத்திற்கு மட்டும் பயன்படுத்தவும்; கண்காணிக்கவும்." - } - }, - { - "pair": [ - "ibuprofen", - "aspirin" - ], - "severity": "moderate", - "title": { - "en": "Increased stomach bleeding risk", - "hi": "पेट में रक्तस्राव का बढ़ा खतरा", - "ta": "வயிற்று இரத்தப்போக்கு அபாயம் அதிகரிப்பு" - }, - "explain": { - "en": "Both irritate the stomach lining; together the risk of ulcers or bleeding rises. Ibuprofen can also blunt aspirin's heart-protective effect.", - "hi": "दोनों पेट की परत को नुकसान पहुंचाते हैं; साथ में अल्सर या रक्तस्राव का खतरा बढ़ता है।", - "ta": "இரண்டும் வயிற்றுச் சுவரை பாதிக்கும்; இணைந்தால் புண் அல்லது இரத்தப்போக்கு அபாயம் அதிகரிக்கும்." - }, - "advice": { - "en": "Avoid regular combined use; take with food and consult a pharmacist.", - "hi": "नियमित रूप से एक साथ न लें; भोजन के साथ लें और फार्मासिस्ट से सलाह लें।", - "ta": "தொடர்ந்து இணைந்து எடுக்க வேண்டாம்; உணவுடன் எடுக்கவும்." - } - }, - { - "pair": [ - "amlodipine", - "simvastatin" - ], - "severity": "moderate", - "title": { - "en": "Higher statin levels — muscle risk", - "hi": "स्टैटिन स्तर बढ़ना — मांसपेशी जोखिम", - "ta": "ஸ்டேடின் அளவு அதிகரிப்பு — தசை அபாயம்" - }, - "explain": { - "en": "Amlodipine can raise simvastatin blood levels, increasing the chance of muscle pain or damage at higher statin doses.", - "hi": "एम्लोडिपिन सिमवास्टेटिन का स्तर बढ़ा सकता है, जिससे मांसपेशी दर्द का खतरा बढ़ता है।", - "ta": "அம்லோடிபின் சிம்வாஸ்டாடின் அளவை உயர்த்தி தசை வலி அபாயத்தை அதிகரிக்கலாம்." - }, - "advice": { - "en": "Simvastatin dose above 20mg is usually avoided with amlodipine — check with pharmacist.", - "hi": "एम्लोडिपिन के साथ सिमवास्टेटिन की अधिक खुराक से बचें — फार्मासिस्ट से पूछें।", - "ta": "அதிக அளவு சிம்வாஸ்டாடினை தவிர்க்கவும் — மருந்தாளரிடம் கேளுங்கள்." - } - }, - { - "pair": [ - "propranolol", - "insulin" - ], - "severity": "moderate", - "title": { - "en": "Masked low blood sugar symptoms", - "hi": "कम ब्लड शुगर के लक्षण छिपना", - "ta": "குறைந்த சர்க்கரை அறிகுறிகள் மறைதல்" - }, - "explain": { - "en": "Beta blockers can hide the warning signs of hypoglycemia (shaking, fast heartbeat), delaying treatment.", - "hi": "बीटा ब्लॉकर हाइपोग्लाइसीमिया के चेतावनी संकेतों को छिपा सकते हैं।", - "ta": "பீட்டா தடுப்பான்கள் இரத்த சர்க்கரை குறைவு அறிகுறிகளை மறைக்கலாம்." - }, - "advice": { - "en": "Check blood sugar regularly instead of relying on symptoms.", - "hi": "लक्षणों पर निर्भर रहने के बजाय नियमित रूप से शुगर जांचें।", - "ta": "அறிகுறிகளை நம்பாமல் தொடர்ந்து சர்க்கரையை பரிசோதிக்கவும்." - } - }, - { - "pair": [ - "ciprofloxacin", - "calcium" - ], - "severity": "mild", - "title": { - "en": "Reduced antibiotic absorption", - "hi": "एंटीबायोटिक अवशोषण में कमी", - "ta": "ஆண்டிபயாடிக் உறிஞ்சுதல் குறைவு" - }, - "explain": { - "en": "Calcium binds to this antibiotic in the gut and reduces how much is absorbed.", - "hi": "कैल्शियम इस एंटीबायोटिक से बंध जाता है और अवशोषण कम कर देता है।", - "ta": "கால்சியம் ஆண்டிபயாடிக்குடன் இணைந்து உறிஞ்சுதலை குறைக்கும்." - }, - "advice": { - "en": "Space doses at least 2 hours apart.", - "hi": "दोनों खुराकों के बीच कम से कम 2 घंटे का अंतर रखें।", - "ta": "இரண்டு மருந்துகளுக்கும் இடையே குறைந்தது 2 மணி நேரம் இடைவெளி வையுங்கள்." - } - }, - { - "pair": [ - "levothyroxine", - "calcium" - ], - "severity": "mild", - "title": { - "en": "Reduced thyroid hormone absorption", - "hi": "थायरॉइड हार्मोन अवशोषण में कमी", - "ta": "தைராய்டு ஹார்மோன் உறிஞ்சுதல் குறைவு" - }, - "explain": { - "en": "Calcium can bind levothyroxine in the gut, lowering how much the body absorbs.", - "hi": "कैल्शियम आंत में लेवोथायरोक्सिन से बंध सकता है, जिससे अवशोषण कम हो जाता है।", - "ta": "கால்சியம் லெவோதைராக்சினுடன் இணைந்து உறிஞ்சுதலை குறைக்கலாம்." - }, - "advice": { - "en": "Take levothyroxine on an empty stomach, at least 4 hours before calcium.", - "hi": "लेवोथायरोक्सिन खाली पेट लें, कैल्शियम से कम से कम 4 घंटे पहले।", - "ta": "லெவோதைராக்சினை வெறும் வயிற்றில் எடுக்கவும், கால்சியத்திற்கு 4 மணி நேரம் முன்பு." - } - }, - { - "pair": [ - "warfarin", - "ginkgo" - ], - "severity": "moderate", - "title": { - "en": "Increased bleeding risk (herbal interaction)", - "hi": "रक्तस्राव का बढ़ा जोखिम (हर्बल परस्पर क्रिया)", - "ta": "இரத்தப்போக்கு அபாயம் அதிகரிப்பு (மூலிகை தொடர்பு)" - }, - "explain": { - "en": "Ginkgo has mild blood-thinning properties of its own and can add to warfarin's effect.", - "hi": "जिन्कगो में स्वयं भी हल्का रक्त पतला करने वाला गुण होता है, जो वारफारिन के प्रभाव को बढ़ा सकता है।", - "ta": "ஜிங்கோவிற்கும் சொந்தமாக லேசான இரத்த மெலிதாக்கும் தன்மை உள்ளது." - }, - "advice": { - "en": "Disclose all herbal supplements to your doctor before combining.", - "hi": "किसी भी हर्बल सप्लीमेंट के बारे में डॉक्टर को अवश्य बताएं।", - "ta": "மூலிகை மருந்துகளை மருத்துவரிடம் தெரிவிக்கவும்." - } - } -]; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000000000000000000000000000000000..ae6c56dcdb2310dc725dbed510df846116f1b3f7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,46 @@ +version: "3.8" + +services: + backend: + build: + context: . + dockerfile: backend/Dockerfile + container_name: sanjeevani-backend + ports: + - "8000:8000" + environment: + - APP_ENV=production + - DEBUG=false + - DATABASE_URL=sqlite+aiosqlite:///sanjeevani.db + - SECRET_KEY=sanjeevani_super_secret_jwt_key_production_2026_enterprise_grade + - MODEL_PATH=models/bc5cdr-ner + - LLM_PROVIDER=mock + - CORS_ORIGINS=["http://localhost:3000","http://127.0.0.1:3000"] + volumes: + - ./uploads:/app/uploads + - ./models:/app/models + - ./sanjeevani.db:/app/sanjeevani.db + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/health"] + interval: 30s + timeout: 10s + retries: 3 + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: sanjeevani-frontend + ports: + - "3000:3000" + environment: + - NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1 + depends_on: + backend: + condition: service_healthy + restart: unless-stopped + +networks: + default: + name: sanjeevani-network diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000000000000000000000000000000000000..589ded0128c389c519fcd38301892df201c94d2f --- /dev/null +++ b/docs/API.md @@ -0,0 +1,217 @@ +# SanjeevaniAI — REST API Reference (v1) + +Base URL: `http://localhost:8000/api/v1` +Interactive OpenAPI UI: `http://localhost:8000/docs` +ReDoc UI: `http://localhost:8000/redoc` + +All successful responses follow the standardized JSON envelope: +```json +{ + "success": true, + "data": { ... }, + "message": "Optional descriptive status", + "request_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", + "timestamp": "2026-08-18T21:00:00Z" +} +``` + +--- + +## 1. Authentication Endpoints (`/auth`) + +### `POST /auth/register` +Create a new patient, clinician, or administrator account. + +**Request Body:** +```json +{ + "email": "user@example.com", + "password": "SecurePassword123!", + "full_name": "Dr. Sarah Jenkins", + "role": "DOCTOR" +} +``` + +**Response (200 OK):** +```json +{ + "success": true, + "data": { + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "token_type": "bearer", + "expires_in": 7200, + "user": { + "id": "c1f725a2-9426-4d1e-84b2-f3e498c0b5f1", + "email": "user@example.com", + "full_name": "Dr. Sarah Jenkins", + "role": "DOCTOR", + "is_active": true + } + } +} +``` + +### `POST /auth/login` +Authenticate user with email and password. + +--- + +## 2. Biomedical NER Endpoints (`/ner`) + +### `POST /ner/analyze` +Extract biomedical entities from arbitrary clinical text using the local RoBERTa-large BC5CDR model. + +**Request Body:** +```json +{ + "text": "The patient was prescribed metformin 500mg and lisinopril 10mg for type 2 diabetes mellitus and hypertension." +} +``` + +**Response (200 OK):** +```json +{ + "success": true, + "data": { + "request_id": "550e8400-e29b-41d4-a716-446655440000", + "model": { + "name": "roberta-large-bc5cdr", + "version": "1.0.0", + "provider": "local_pytorch", + "device": "cuda:0", + "status": "ready" + }, + "entities": [ + { + "text": "metformin", + "label": "CHEMICAL", + "start": 27, + "end": 36, + "confidence": 0.9998, + "model": "roberta-large-bc5cdr" + }, + { + "text": "lisinopril", + "label": "CHEMICAL", + "start": 47, + "end": 57, + "confidence": 0.9996, + "model": "roberta-large-bc5cdr" + }, + { + "text": "type 2 diabetes mellitus", + "label": "DISEASE", + "start": 68, + "end": 92, + "confidence": 0.9985, + "model": "roberta-large-bc5cdr" + }, + { + "text": "hypertension", + "label": "DISEASE", + "start": 97, + "end": 109, + "confidence": 0.9992, + "model": "roberta-large-bc5cdr" + } + ], + "entity_count": 4, + "processing_time_ms": 14.8, + "text_length": 110 + } +} +``` + +### `GET /ner/model-info` +Retrieve local model health, hardware device allocation, and parameter footprint. + +--- + +## 3. Medical Document Management (`/documents`) + +### `POST /documents/upload` +Upload a medical report (multipart/form-data: PDF, DOCX, TXT). +Automatically extracts text, computes SHA-256 integrity hash, runs local BC5CDR entity recognition, and generates a non-diagnostic summary. + +### `GET /documents` +List all uploaded reports for the authenticated user. + +### `GET /documents/{document_id}` +Retrieve full structured analysis, raw text, and identified entity spans for a specific document. + +### `DELETE /documents/{document_id}` +Permanently remove a document and its associated analysis. + +--- + +## 4. AI Consultation Assistant (`/chat`) + +### `POST /chat/message` +Send a clinical query to the AI assistant with automatic patient profile injection and red-flag triage. + +**Request Body:** +```json +{ + "message": "What are typical lifestyle considerations for managing Type 2 Diabetes?", + "conversation_id": null +} +``` + +**Response (200 OK):** +```json +{ + "success": true, + "data": { + "conversation_id": "d3b07384-d113-4632-bc5d-8521c7d2427a", + "message": { + "id": "e4a18295-e224-4743-9d7e-9632d8e3538b", + "role": "assistant", + "content": "Type 2 Diabetes management involves glycemic control, balanced nutrition, and regular physical activity.", + "structured_data": { + "summary": "Educational guidance on Type 2 Diabetes management strategies.", + "possible_considerations": [ + "Consistent carbohydrate counting and complex fiber intake improve postprandial glucose stability.", + "Regular aerobic and resistance exercise enhances insulin sensitivity." + ], + "questions_for_doctor": [ + "What is my target HbA1c range given my current medication regimen?", + "Should I consult a certified diabetes educator (CDCES)?" + ], + "safety_warning": "SanjeevaniAI provides decision-support information, not definitive diagnosis. Always consult your physician.", + "is_emergency": false, + "emergency_instructions": null + }, + "model_provider": "Google Gemini / Local Clinical Engine" + }, + "disclaimer": "SanjeevaniAI provides AI-assisted healthcare information and decision-support insights. It is not a substitute for professional medical diagnosis, treatment, or emergency care." + } +} +``` + +--- + +## 5. Patient Profile (`/profile`) + +### `GET /profile` +Retrieve authenticated user's clinical profile. + +### `PUT /profile` +Update age, gender, blood group, height, weight, allergies, chronic conditions, and current medications. + +--- + +## 6. History & Timeline (`/history`) + +### `GET /history?limit=50` +Retrieve chronological activity records with entity counts and timestamps. + +--- + +## 7. Administrator Telemetry & Auditing (`/admin`) + +### `GET /admin/stats` (Admin Only) +Platform aggregates: total users, total documents, extracted entities count, and neural model device status. + +### `GET /admin/audit-logs?limit=100` (Admin Only) +Security audit records covering logins, uploads, deletions, and administrative actions. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000000000000000000000000000000000000..a8942941d85584118c5bfd7010e032f2c1673b95 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,121 @@ +# SanjeevaniAI — System Architecture & Design Specification + +## 1. Architectural Overview + +**SanjeevaniAI** is an industry-grade healthcare intelligence and clinical decision-support platform. The architecture is engineered around the principles of: +- **Local Machine Learning Sovereignty**: Pretrained neural NER model (`tner/roberta-large-bc5cdr`) runs completely on local hardware without sending sensitive clinical text to external third-party entity extraction APIs. +- **Provider-Agnostic Clinical Assistant**: Pluggable LLM interface supporting Google Gemini Pro and deterministic offline `MockLLMProvider` with heuristic red-flag emergency triage. +- **Data Integrity & Traceability**: Multi-format document ingestion (PDF, DOCX, TXT) with SHA-256 fingerprinting and immutable security audit trails. +- **Explicit Clinical Positioning**: Non-diagnostic language safeguards throughout all API contracts and UI surfaces. + +```mermaid +graph TD + Client["Client Layer (Next.js 14 + Tailwind CSS + Lucide)"] + + subgraph Gateway ["FastAPI Gateway & Security Layer"] + CORS["CORS Middleware"] + SecHeaders["Security Headers (CSP, XSS, HSTS)"] + ReqID["Request ID Correlation"] + Auth["JWT & RBAC Middleware"] + end + + subgraph CoreServices ["Application & Domain Services"] + NERService["NER Engine (BC5CDR Adapter)"] + DocService["Document Ingestion & Chunking"] + LLMService["Clinical AI Assistant (Gemini / Mock)"] + AuditService["Audit & Timeline Service"] + end + + subgraph LocalML ["Local Machine Learning Engine"] + RoBERTa["tner/roberta-large-bc5cdr (355M Params)"] + Tokenizer["Byte-Pair Encoding Tokenizer"] + Torch["PyTorch (CUDA:0 / CPU fallback)"] + end + + subgraph DataStorage ["Persistence Layer"] + SQLite[("SQLAlchemy Async (aiosqlite / Postgres)")] + DocStore["Encrypted Document Storage (/uploads)"] + end + + Client --> Gateway + Gateway --> CoreServices + NERService --> LocalML + DocService --> NERService + CoreServices --> DataStorage +``` + +--- + +## 2. Component Hierarchy + +### 2.1 Backend Architecture (`backend/app/`) +- **`core/`**: + - `config.py`: Centralized Pydantic BaseSettings loaded from `.env`. + - `security.py`: Direct `bcrypt` password hashing (protecting against the 72-byte passlib bug) and JWT token generation. + - `database.py`: Asynchronous SQLAlchemy engine (`AsyncSessionLocal`) with automatic table initialization. + - `logger.py`: Structured RFC-3339 logging with correlation IDs. + - `exceptions.py`: Domain exception taxonomy mapping cleanly to HTTP 400/401/403/404/422/500 responses. +- **`models/`**: + - `User`, `PatientProfile`: User accounts and clinical health profile (anthropometrics, allergies, chronic conditions, active medications). + - `MedicalDocument`, `DocumentAnalysis`, `MedicalEntity`: Document storage metadata, SHA-256 hash, extracted summary, and labeled token entities (`CHEMICAL`, `DISEASE`). + - `AIConversation`, `AIMessage`: Multi-turn consultation threads with structured JSON payloads. + - `AuditLog`, `AnalysisHistory`, `SystemEvent`: Audit trail and chronological patient timeline. +- **`ml/`**: + - `BC5CDRNERModel`: Thread-safe model wrapper loading weights from `models/bc5cdr-ner` using PyTorch. Performs sub-word token alignment, confidence thresholding (default $\tau = 0.85$), and entity offset calculation. + - `ModelManager`: Singleton lifecycle manager preventing duplicate GPU VRAM allocations. +- **`services/`**: + - `DocumentService`: Safe multi-format parser (`pypdf`, `pdfplumber`, `docx2txt`), chunker, and entity aggregator. + - `LLMService`: Multi-provider abstraction (`GeminiProvider`, `MockLLMProvider`) implementing prompt defense guardrails and emergency red-flag heuristics. + +--- + +## 3. Biomedical NER Inference Pipeline + +```mermaid +sequenceDiagram + participant UI as Next.js Visualizer + participant API as /api/v1/ner/analyze + participant ML as BC5CDRNERModel + participant Torch as PyTorch / RoBERTa + + UI->>API: POST { text: "patient taking metformin for diabetes" } + API->>ML: analyze(text, threshold=0.85) + ML->>Torch: Tokenize & Forward Pass (roberta-large) + Torch-->>ML: Logits [batch, seq_len, num_labels] + ML->>ML: Argmax & Softmax Confidence Calibration + ML->>ML: B- / I- Tag Aggregation & Character Span Mapping + ML-->>API: List[NEREntity(text, label, start, end, confidence)] + API-->>UI: 200 OK BaseResponse[NERResponse] (Latency: ~14ms) + UI->>UI: Render emerald/rose token highlights & entity table +``` + +--- + +## 4. Emergency Triage & Decision-Support Flow + +```mermaid +flowchart TD + Query["Incoming Patient / Clinician Query"] --> CheckFlag{"Emergency Red-Flag Heuristics"} + + CheckFlag -- "Matched (Chest Pain, Stroke, SOB)" --> RedFlag["is_emergency = true"] + RedFlag --> UrgentOutput["Generate Emergency Escalation Box + Call 911 / 112 / 108 Guidance"] + + CheckFlag -- "No Acute Red Flags" --> Consult["Prompt LLM with Non-Diagnostic Guardrails"] + Consult --> StructOutput["Format Structured Response:\n- Clinical Summary\n- Considerations\n- Questions for Doctor\n- Mandatory Medical Disclaimer"] + + UrgentOutput --> Audit["Log to Immutable Audit Trail"] + StructOutput --> Audit + Audit --> Return["Return ChatCompletionResponse"] +``` + +--- + +## 5. Security & Healthcare Privacy Architecture + +1. **Local-First Data Processing**: PHI in uploaded documents is parsed in memory and analyzed against the local neural network. +2. **Cryptographic Integrity**: Uploaded files are fingerprinted with SHA-256 before storage to detect tampering. +3. **Role-Based Access Control**: + - `PATIENT`: Access own profile, medical documents, and consultations. + - `DOCTOR`: Review patient clinical summaries and execute decision support. + - `ADMIN`: View system telemetry, hardware metrics, and security audit logs. +4. **Standardized HTTP Security Headers**: HSTS, X-Content-Type-Options, X-Frame-Options (`DENY`), and Content-Security-Policy applied on every response. diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000000000000000000000000000000000000..bc11755f55a1ba4fda4a96c8f5bb2875a8a92468 --- /dev/null +++ b/docs/IMPLEMENTATION_PLAN.md @@ -0,0 +1,105 @@ +# SanjeevaniAI — Implementation Plan + +## Phase Overview + +The construction of **SanjeevaniAI** follows a disciplined 15-phase engineering roadmap designed to deliver a robust, modular, testable, and presentation-ready healthcare AI platform. + +--- + +## Roadmap + +### Phase 1: Architecture & Project Audit (Completed) +- [x] Complete directory scan and asset inventory +- [x] Model verification for `models/bc5cdr-ner` (RoBERTa-large BC5CDR Token Classification) +- [x] Create `docs/PROJECT_AUDIT.md` and `docs/IMPLEMENTATION_PLAN.md` +- [x] Establish architecture diagrams and technical specifications + +### Phase 2: Environment Configuration & Dependencies +- [ ] Update `requirements.txt` with FastAPI, Pydantic v2, SQLAlchemy 2.0, Alembic, python-jose, passlib, pypdf, httpx, uvicorn, redis, etc. +- [ ] Create `.env.example` and default `.env` configuration +- [ ] Install missing backend packages into `D:\SanjeevaniAI\.venv` + +### Phase 3: Backend Foundation & Modular Architecture +- [ ] Scaffold `backend/` directory structure: + - `app/main.py`: Application factory with lifespan event handling, CORS, rate limiting, and exception handlers + - `app/core/`: Settings (`config.py`), security (`security.py`), logging (`logger.py`), exceptions (`exceptions.py`) + - `app/api/v1/`: API Routers for auth, users, ner, documents, chat, history, profile, and admin + - `app/models/`: SQLAlchemy ORM models (users, profiles, documents, entities, conversations, audit_logs) + - `app/schemas/`: Pydantic validation schemas + - `app/repositories/`: Data access layer + - `app/services/`: Business logic layer + - `app/ml/`: Model manager & local NER adapter + - `app/middleware/`: Request ID, audit logging, security headers + +### Phase 4: Local Biomedical NER Model Integration +- [ ] Implement `app/ml/ner/base.py` (Abstract NER interface) +- [ ] Implement `app/ml/ner/bc5cdr.py` (Local RoBERTa-large BC5CDR inference engine) +- [ ] Implement `app/ml/ner/service.py` & `app/ml/manager.py` (Lifecycle, warmup, error isolation, batch inference) +- [ ] Expose `POST /api/v1/ner/analyze` with confidence scores, offsets, entity types (`CHEMICAL`, `DISEASE`), and model metadata +- [ ] Unit & integration tests for NER pipeline + +### Phase 5: Database Schema & Migrations +- [ ] Configure SQLAlchemy async engine (supporting PostgreSQL with SQLite dev fallback) +- [ ] Define normalized models: `User`, `Role`, `PatientProfile`, `MedicalDocument`, `DocumentAnalysis`, `MedicalEntity`, `AIConversation`, `AIMessage`, `AuditLog` +- [ ] Setup Alembic migration environment (`alembic/`) +- [ ] Generate and apply baseline database migration + +### Phase 6: Authentication & Authorization (RBAC) +- [ ] Secure JWT authentication (Access & Refresh tokens) +- [ ] Password hashing via Argon2 / Bcrypt +- [ ] RBAC for `USER`, `PATIENT`, `DOCTOR`, `ADMIN` +- [ ] Endpoints: `POST /api/v1/auth/register`, `POST /api/v1/auth/login`, `POST /api/v1/auth/refresh`, `GET /api/v1/auth/me` + +### Phase 7: Medical Document Analysis Pipeline +- [ ] Secure multi-format text extraction (`PDF`, `TXT`, `DOCX`) +- [ ] Filename sanitization, SHA256 integrity hashing, size limits +- [ ] Pipeline: Upload -> Text Extraction -> Normalization -> BC5CDR NER -> Clinical Summarization -> Structured Storage +- [ ] Endpoints: `POST /api/v1/documents/upload`, `GET /api/v1/documents`, `GET /api/v1/documents/{id}`, `DELETE /api/v1/documents/{id}` + +### Phase 8: AI Medical Assistant & Multi-Provider Abstraction +- [ ] Implement `BaseLLMProvider`, `GeminiProvider` (Google GenAI API), and `MockLLMProvider` (offline fallback) +- [ ] Strict clinical safety guardrails (extracted facts vs. AI considerations, emergency disclaimers, no speculative diagnoses) +- [ ] Endpoints: `POST /api/v1/chat/completions`, `GET /api/v1/chat/conversations`, `DELETE /api/v1/chat/conversations/{id}` + +### Phase 9: Patient Profile, Medical History & Audit Logging +- [ ] Patient profile management (`GET`/`PUT /api/v1/profile`) +- [ ] Chronological medical timeline (`GET /api/v1/history`) +- [ ] Structured security audit logger for sensitive events (`LOGIN`, `DOCUMENT_UPLOAD`, `AI_ANALYSIS`, `ADMIN_ACTION`) + +### Phase 10: Frontend Foundation (Next.js 14+ / React / Tailwind) +- [ ] Initialize Next.js TypeScript project in `frontend/` +- [ ] Configure Tailwind CSS, Lucide React icons, and accessible component library +- [ ] Create layout architecture: Sidebar navigation, Header, Breadcrumbs, Dark/Light theme, Clinical Disclaimer Banner +- [ ] Setup API Client with Axios/TanStack Query and JWT authentication context + +### Phase 11: Frontend Pages & Features +- [ ] **Landing Page (`/`)**: Hero, product capabilities, architecture overview, medical safety disclaimer +- [ ] **Auth Pages (`/login`, `/register`)**: Accessible forms with Zod validation +- [ ] **Dashboard (`/dashboard`)**: Summary metric cards, recent document analyses, detected conditions, activity timeline +- [ ] **Biomedical NER Visualizer (`/ner`)**: Dedicated mentor demo page with live entity highlighting (`CHEMICAL` / `DISEASE`), confidence metrics, and execution latency +- [ ] **Medical Document Manager (`/reports`, `/reports/[id]`)**: Drag-and-drop file upload, progress bar, clinical entity viewer, summary export +- [ ] **AI Assistant Interface (`/assistant`)**: Conversational UI, structured medical guidance, chat history, emergency alerts +- [ ] **Patient Profile & Timeline (`/profile`, `/history`)**: Editable vitals/allergies/medications and chronological action history +- [ ] **Admin & System Health (`/admin`)**: User statistics, model usage metrics, system logs, audit trails +- [ ] **Mentor Demonstration Mode (`/demo`)**: Synthetic patient flow for 10-minute presentation + +### Phase 12: Seed Data & Testing Suites +- [ ] Synthetic seed generator: `scripts/seed_demo_data.py` (No real patient data) +- [ ] Backend test suite: `pytest` covering auth, NER, document analysis, RBAC, error handlers +- [ ] Frontend validation: Lint, TypeScript build verification + +### Phase 13: Containerization & Docker Setup +- [ ] `backend/Dockerfile` and `frontend/Dockerfile` +- [ ] `docker-compose.yml` (Backend, Frontend, PostgreSQL, Redis) +- [ ] Model volume mounting configuration + +### Phase 14: CI/CD & Security Auditing +- [ ] GitHub Actions workflows: `.github/workflows/test.yml`, `.github/workflows/lint.yml` +- [ ] Security hardening: Rate limiting, CORS policies, XSS/CSRF headers, path traversal protection + +### Phase 15: Documentation & Presentation Package +- [ ] Root `README.md` +- [ ] `docs/ARCHITECTURE.md` (with Mermaid diagrams) +- [ ] `docs/API.md` (OpenAPI specification & curl examples) +- [ ] `docs/ML_MODELS.md` (BC5CDR NER specs & performance metrics) +- [ ] `docs/MENTOR_DEMO.md` (Step-by-step 10-15 minute demonstration script) diff --git a/docs/MENTOR_DEMO.md b/docs/MENTOR_DEMO.md new file mode 100644 index 0000000000000000000000000000000000000000..0e13bae28b4db01788ae15ac53476f9ae7f5a824 --- /dev/null +++ b/docs/MENTOR_DEMO.md @@ -0,0 +1,81 @@ +# SanjeevaniAI — Mentor Presentation & Technical Demonstration Guide + +**Duration**: 10–15 Minutes +**Target Audience**: Technical Mentors, Senior Architects, Faculty Evaluators + +--- + +## 🎯 Executive Pitch (1 Minute) +> *"SanjeevaniAI is an industry-grade healthcare intelligence and clinical decision-support platform designed to assist healthcare professionals and patients with biomedical named entity recognition, medical report analysis, and structured conversational AI consultation. It runs a fine-tuned RoBERTa-large BC5CDR model completely locally on disk without third-party NER dependencies, enforces strict non-diagnostic clinical safety boundaries, and provides an immutable audit trail."* + +--- + +## 📋 Demonstration Flow + +### Stage 1: Local Neural Model Verification (3 Minutes) +1. Navigate to `/ner` (Biomedical NER Visualizer). +2. **Show the Mentor**: + - Status badge confirms model loaded locally from `D:\SanjeevaniAI\models\bc5cdr-ner`. + - Hardware device indicates active acceleration (`cuda:0` / `CPU`). +3. Click the preset: **"Diabetes & Hypertension (Standard Case)"**. +4. Click **"Run BC5CDR Inference"**. +5. **Key Talking Points**: + - Notice the exact token highlight: `metformin` and `lisinopril` tagged in **emerald** as `CHEMICAL`, `type 2 diabetes mellitus` and `hypertension` tagged in **rose** as `DISEASE`. + - Sub-word token BPE reconstruction correctly handles multi-word entities and punctuation. + - Inference completes in under **20 milliseconds** with confidence scores $>99.8\%$. + +--- + +### Stage 2: Medical Report Ingestion & Structured Extraction (3 Minutes) +1. Navigate to `/reports`. +2. Inspect the pre-loaded synthetic report: `Metabolic_Panel_Report_2026.txt`. +3. Click **"View Analysis"** (`/reports/[id]`). +4. **Key Talking Points**: + - Shows SHA-256 fingerprinting for tamper-evidence. + - Shows automated extraction of key findings, aggregated conditions, and pharmacological agents. + - Non-diagnostic summary language: framed strictly as educational decision support. + +--- + +### Stage 3: Conversational Clinical AI & Emergency Triage (4 Minutes) +1. Navigate to `/assistant`. +2. **Standard Query Demo**: + - Ask: *"Why is metformin taken with meals and what are typical gastrointestinal side effects?"* + - Show structured response output: + - 📝 Clinical Summary + - 💡 Considerations (extended-release formulations, Vitamin B12 absorption) + - ❓ Questions to ask your doctor + - ⚠️ Medical disclaimer +3. **Emergency Red-Flag Triage Demo**: + - Click the prompt: *"I am having severe crushing chest pain, shortness of breath, and left arm numbness."* + - **Show the Mentor**: + - System triggers the **emergency triage heuristic engine**. + - Prominently displays the **Red Emergency Alert Banner** directing the user to call 911 / 112 / 108 or proceed to the nearest emergency department immediately. + +--- + +### Stage 4: Patient Longitudinal Health Profile & Timeline (2 Minutes) +1. Navigate to `/profile`. +2. Show the real-time BMI calculator, allergies list, chronic conditions, and current medications. +3. Explain how this clinical profile is automatically injected as context into the AI consultation assistant. +4. Navigate to `/history` to show the chronological activity log with entity counts and timestamps. + +--- + +### Stage 5: System Telemetry & Security Audit Trail (2 Minutes) +1. Click **"Demo Roles"** in the top navigation bar and select **"Admin"** (or log in as `demo.admin@sanjeevani.ai` / `DemoAdmin2026!`). +2. Navigate to `/admin`. +3. **Show the Mentor**: + - Real-time platform aggregates: Total Users, Processed Documents, Extracted Biomedical Entities. + - Live hardware status of the local RoBERTa model. + - Tamper-evident Security Audit Log recording logins, report uploads, and consultation events. + +--- + +## 🔑 Pre-Seeded Demonstration Accounts + +| Role | Email | Password | Purpose | +| :--- | :--- | :--- | :--- | +| **Patient** | `demo.patient@sanjeevani.ai` | `DemoPatient2026!` | Standard patient portal, health profile, medical documents | +| **Doctor** | `demo.doctor@sanjeevani.ai` | `DemoDoctor2026!` | Clinician decision support and report review | +| **Administrator** | `demo.admin@sanjeevani.ai` | `DemoAdmin2026!` | System telemetry, model health, and security audit logs | diff --git a/docs/ML_MODELS.md b/docs/ML_MODELS.md new file mode 100644 index 0000000000000000000000000000000000000000..062f404619cb1aa2b055bbf820dc3f7de5ebcbbc --- /dev/null +++ b/docs/ML_MODELS.md @@ -0,0 +1,61 @@ +# SanjeevaniAI — Machine Learning & Biomedical NER Specification + +## 1. Local Neural Architecture + +SanjeevaniAI utilizes a dedicated local transformer model for biomedical token classification: + +- **Base Architecture**: `roberta-large` (24 layers, 16 attention heads, 1024 hidden dimensions, 355 Million parameters). +- **Fine-Tuning Dataset**: BioCreative V Chemical Disease Relation dataset (BC5CDR). +- **Local Model Path**: `D:\SanjeevaniAI\models\bc5cdr-ner` (1.4 GB model weights). +- **Classification Head**: `RobertaForTokenClassification` with linear projection layer mapping hidden states to BIO label space. +- **Hardware Acceleration**: Automatically prioritizes `CUDA:0` when an NVIDIA GPU is present; gracefully falls back to multithreaded CPU inference. + +--- + +## 2. Label Taxonomy & BIO Tag Normalization + +The model is trained on standard IOB2 tagging for biomedical literature: + +| Label ID | Raw Tag | Standard Entity Class | Description | +| :--- | :--- | :--- | :--- | +| 0 | `O` | Outside | Non-biomedical token | +| 1 | `B-Chemical` | `CHEMICAL` | Beginning of a chemical/drug entity | +| 2 | `B-Disease` | `DISEASE` | Beginning of a disease/condition entity | +| 3 | `I-Chemical` | `CHEMICAL` | Inside/continuation of a chemical entity | +| 4 | `I-Disease` | `DISEASE` | Inside/continuation of a disease entity | + +### Sub-word Token Span Reconstruction +Because RoBERTa uses Byte-Pair Encoding (BPE), sub-word tokens starting with `Ġ` or raw fragments must be mapped back to original text character offsets: +1. Model generates predictions for all tokens. +2. `BC5CDRNERModel` aggregates sequential `B-` and `I-` tokens of matching entity types. +3. Whitespace and sub-word boundary artifacts are resolved using character index tracking `[start_offset, end_offset]`. +4. Extracted text is sliced from original input: `text[start_offset:end_offset]`. + +--- + +## 3. Confidence Calibration & Post-Processing + +- Softmax is applied to model output logits $z$: + $$P(y_i = c) = \frac{e^{z_{i, c}}}{\sum_{j} e^{z_{i, j}}}$$ +- Confidence for an entity span $E = (t_1, t_2, \dots, t_k)$ is calculated as the mean token probability: + $$\text{Confidence}(E) = \frac{1}{k} \sum_{i=1}^k P(y_i = \hat{y}_i)$$ +- **Default Confidence Filter**: $\tau = 0.85$. Entities below this threshold are discarded to prevent false positive hallucinations. + +--- + +## 4. Inference Performance Benchmarks + +| Hardware Platform | Average Latency (100-word text) | Peak Memory Usage | +| :--- | :--- | :--- | +| NVIDIA GeForce RTX 3050 Laptop GPU (CUDA) | **14.2 ms** | 1,480 MB VRAM | +| Intel Core i7-13700H (CPU Multithreading) | **82.4 ms** | 1,520 MB RAM | + +--- + +## 5. Conversational LLM Architecture (Provider Abstraction) + +SanjeevaniAI decouples reasoning from specific LLM vendors via `BaseLLMProvider`: + +1. **`GeminiProvider`**: Calls Google Gemini 1.5 Pro / Flash via `google-genai` SDK with healthcare decision-support system prompts, structured JSON schema outputs, and safety settings blocking diagnostic assertions. +2. **`MockLLMProvider`**: High-availability offline fallback providing deterministic, evidence-grounded educational answers for diabetes, hypertension, cardiology, and pharmacology. +3. **Emergency Red-Flag Heuristic Triage**: Runs synchronously prior to LLM inference to identify acute life-threatening presentations (e.g., crushing chest pain, dyspnea, acute neurological deficits) and output emergency escalation notices. diff --git a/docs/PROJECT_AUDIT.md b/docs/PROJECT_AUDIT.md new file mode 100644 index 0000000000000000000000000000000000000000..2e680cfa65c7a1783cfcd331267bd7fd8e37552a --- /dev/null +++ b/docs/PROJECT_AUDIT.md @@ -0,0 +1,131 @@ +# SanjeevaniAI — Comprehensive Project Audit + +**Audit Date:** August 18, 2026 +**Auditor:** Lead Software Architect & ML/DevOps Engineer +**Project Path:** `D:\SanjeevaniAI` +**Repository State:** Initialized with pretrained ML models and clinical datasets; core application layers (Frontend, FastAPI Backend, Database, Auth, APIs) require development. + +--- + +## 1. Executive Summary + +SanjeevaniAI is an AI-powered healthcare intelligence and decision-support platform designed to assist clinicians and patients with biomedical named entity recognition (NER), medical document analysis, clinical summarization, health profiling, and interactive AI consultation. + +An in-depth audit of `D:\SanjeevaniAI` reveals high-value pretrained machine learning assets—most notably a locally verified `tner/roberta-large-bc5cdr` biomedical NER model and extensive clinical/pharmacological datasets—alongside an empty application scaffold (`app/`, `clinical_engine/`, `rag/`, `evaluation/`). + +This document details the existing state, inventory of models and datasets, technical debt, and provides an actionable blueprint for constructing a production-grade, HIPAA/privacy-conscious, modular healthcare system. + +--- + +## 2. Directory Structure & Asset Inventory + +``` +D:\SanjeevaniAI\ +├── .venv/ # Python 3.11.9 Virtual Environment with PyTorch & Transformers +├── checkpoints/ # TrOCR training checkpoints (epoch 1-3) +├── datasets/ # Rich clinical and NLP datasets +│ ├── clinical/ +│ │ ├── ddi/ # Drug-Drug Interaction dataset (CSV) +│ │ ├── ddi_severity/ # DDI Database with severity ratings (JSON) +│ │ ├── medicines_250k/ # 250,000+ Medicine dataset (CSV) +│ │ └── mid/ # Medical Information Database & Therapeutic class counts (XLSX) +│ ├── intent/ # CLINC150 Intent Classification data +│ ├── ner/ +│ │ └── bc5cdr/ # BC5CDR BioCreative V CDR Corpus data & loader +│ ├── ocr/ # Prescription & medical text OCR samples +│ ├── speech/ # Audio speech samples for medical transcription +│ └── translation/ # Parallel translation benchmarks +├── models/ # Pretrained & fine-tuned model store +│ ├── bc5cdr-ner/ # ⭐ Verified Local RoBERTa-large BC5CDR NER Model +│ │ ├── config.json # RobertaForTokenClassification (Chemical & Disease tags) +│ │ ├── pytorch_model.bin # Pretrained PyTorch weights (~1.4 GB) +│ │ ├── merges.txt, vocab.json # BPE Tokenizer assets +│ │ ├── tokenizer_config.json +│ │ └── eval/ # Metric files (F1: 88.4%, Chemical F1: 92.6%, Disease F1: 83.4%) +│ ├── gguf/ # Quantized LLM weights store +│ ├── pretrained/ # Base Hugging Face model repositories +│ │ ├── biomedbert/ # Microsoft BiomedNLP-BiomedBERT-base-uncased +│ │ ├── indictrans_en_indic/ # IndicTrans2 English to Indic translation +│ │ ├── indictrans_indic_indic/# IndicTrans2 Indic to Indic translation +│ │ ├── intent/ # DistilBERT for intent classification +│ │ ├── sapbert/ # SapBERT biomedical entity representation +│ │ ├── trocr/ # Microsoft TrOCR base printed +│ │ └── whisper/ # OpenAI Whisper base speech-to-text +│ └── trained/ # Custom fine-tuned weights output directory +├── app/ # [EMPTY] Target directory for FastAPI modular backend +├── clinical_engine/ # [EMPTY] Clinical decision support engine +├── evaluation/ # [EMPTY] ML evaluation scripts +├── rag/ # [EMPTY] Retrieval-Augmented Generation subsystem +├── scripts/ # Maintenance & training utilities +│ ├── download_models.py # Hugging Face snapshot downloader +│ ├── prepare_mobile_ocr.py # Mobile OCR preprocessing pipeline +│ ├── test_models.py # Integrity checker for model weights +│ ├── test_trocr_load.py # TrOCR loading script +│ └── train_trocr.py # TrOCR fine-tuning workflow +├── training/ # Training scripts +│ ├── intent/ # Intent classification fine-tuning +│ ├── ner/ # Token classification fine-tuning (train_ner.py) +│ └── ocr/ # TrOCR training and evaluation +├── README.md # [EMPTY - 0 bytes] Needs comprehensive documentation +└── requirements.txt # [BASIC] Missing FastAPI, SQLAlchemy, Alembic, Pydantic, etc. +``` + +--- + +## 3. Existing Machine Learning Models Verification + +### 3.1 Local Biomedical NER Model (`models/bc5cdr-ner`) +* **Architecture:** `RobertaForTokenClassification` fine-tuned on BioCreative V CDR (BC5CDR). +* **Entity Classes:** + * `0: O` (Outside) + * `1: B-Chemical` (Beginning of Chemical/Drug name) + * `2: B-Disease` (Beginning of Disease/Condition name) + * `3: I-Disease` (Inside of Disease name) + * `4: I-Chemical` (Inside of Chemical name) +* **Performance:** Overall F1: `88.41%` (Chemical F1: `92.57%`, Disease F1: `83.38%`). +* **Live Inference Verification:** + * Successfully loaded onto CUDA (`cuda:0`) via HuggingFace `pipeline("ner")`. + * Sample: *"The patient was prescribed metformin for type 2 diabetes mellitus."* + * Result: `Chemical: metformin (confidence 0.9998)`, `Disease: type 2 diabetes mellitus (confidence 0.9985)`. +* **Deployment Consideration:** The model weights are ~1.4GB. The FastAPI application must load the model once as a singleton during application startup (`lifespan` handler) to prevent latency penalties on individual requests. + +--- + +## 4. Technology Stack & Environment Assessment + +| Subsystem | Existing State | Target Production Architecture | +| :--- | :--- | :--- | +| **Python Environment** | Python 3.11.9 with PyTorch 2.8.0 (CUDA 12.9), Transformers 4.57.1 | Extend `.venv` with FastAPI, Pydantic v2, SQLAlchemy 2.0, Alembic, python-jose, passlib, pypdf, httpx | +| **Node.js Environment** | Node.js v22.17.0, npm 10.9.2 | Next.js 14+ (App Router), TypeScript, Tailwind CSS, Lucide Icons, Radix/shadcn UI, TanStack Query | +| **Backend Framework** | None (Empty `app/` folder) | FastAPI with modular layered architecture (`api/v1`, `services`, `repositories`, `models`, `ml`) | +| **Database** | None | PostgreSQL (production/docker) & SQLite (aiosqlite dev fallback) via SQLAlchemy async engine + Alembic | +| **Authentication** | None | Secure JWT auth with refresh tokens, Argon2/Bcrypt password hashing, Role-Based Access Control (RBAC) | +| **Document Processing** | None | Multi-format extraction pipeline (PDF via `pypdf`/`pdfplumber`, TXT, DOCX), sanitization, entity linking | +| **AI LLM Provider** | None | Abstract `LLMProvider` with `GeminiProvider` (Google GenAI), `OpenAIProvider`, and `MockLLMProvider` | +| **Task Queue / Cache** | None | Redis with Celery / FastAPI async background tasks fallback | +| **Containerization** | None | Multi-stage `Dockerfile` (Backend & Frontend) + `docker-compose.yml` (App, DB, Redis, Worker) | + +--- + +## 5. Identified Gaps & Technical Problems + +1. **Missing Backend Application Layer:** The `app/` directory is completely empty. There are no API routers, service layers, Pydantic schemas, or data models. +2. **Missing Frontend Client:** No Next.js or React frontend exists. All UI interactions, dashboards, and visualizations must be built from scratch. +3. **Missing Database Schemas & Migrations:** No relational tables or Alembic migration environments are established. +4. **Missing Authentication & Authorization:** No user identity, session management, or RBAC controls exist. +5. **No Medical Safety Guards:** AI responses need guardrails distinguishing extracted facts from AI considerations, with clear disclaimers that SanjeevaniAI is not an autonomous diagnostic device. +6. **No API Documentation & Health Probes:** Missing OpenAPI schemas, `/health`, and `/ready` endpoints. +7. **No Automated Test Suites:** Missing unit, integration, and ML inference validation tests. + +--- + +## 6. Recommended Migration & Architecture Plan + +1. **Preserve High-Value Data & Models:** Retain all files under `models/` (especially `models/bc5cdr-ner`) and `datasets/`. +2. **Build Modular FastAPI Backend:** Implement clean separation of concerns: + `Router -> Schema Validation -> Service Layer -> Repository / ML Engine -> Database`. +3. **Build Modern Next.js TypeScript Frontend:** Modern healthcare SaaS UI with responsive sidebar, dark/light clinical theme, accessible components, and interactive NER visualizer. +4. **Implement Local ML Adapter (`MLManager`):** Encapsulate `models/bc5cdr-ner` with cached singleton lifecycle, warm-up routines, and fallback error handling. +5. **Implement Resilient Document Pipeline:** Secure upload handler with MIME validation, SHA256 hashing, structured entity extraction, and clinical summarization. +6. **Implement Multi-Provider LLM Engine:** Pluggable AI Assistant supporting Google Gemini with fallback to MockLLM for offline development. +7. **Provide Complete DevOps & Testing Tooling:** Alembic migrations, Pytest suite, Jest/React testing, Docker Compose configurations, and comprehensive documentation. diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..8c9faaf5476b35cab68a0190d934020e1164784b --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,23 @@ +FROM node:22-alpine AS builder + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +COPY . . +RUN npm run build + +FROM node:22-alpine AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV PORT=3000 + +COPY --from=builder /app/public ./public +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static + +EXPOSE 3000 + +CMD ["node", "server.js"] diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..40c3d68096c270ef976f3db4e9eb42b05c7067bb --- /dev/null +++ b/frontend/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/frontend/next.config.js b/frontend/next.config.js new file mode 100644 index 0000000000000000000000000000000000000000..fea31c5c9f356372f6e0450295c6cfe314f0cfc1 --- /dev/null +++ b/frontend/next.config.js @@ -0,0 +1,14 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, + async rewrites() { + return [ + { + source: "/api/v1/:path*", + destination: "http://localhost:8000/api/v1/:path*", + }, + ]; + }, +}; + +module.exports = nextConfig; diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000000000000000000000000000000000000..facb35affc2036bfb2178f21ce8bb772cae907a5 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1965 @@ +{ + "name": "sanjeevani-ai-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sanjeevani-ai-frontend", + "version": "1.0.0", + "dependencies": { + "axios": "^1.7.9", + "clsx": "^2.1.1", + "lucide-react": "^0.468.0", + "next": "14.2.21", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "tailwind-merge": "^2.6.0" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.2" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@next/env": { + "version": "14.2.21", + "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.21.tgz", + "integrity": "sha512-lXcwcJd5oR01tggjWJ6SrNNYFGuOOMB9c251wUNkjCpkoXOPkDeF/15c3mnVlBqrW4JJXb2kVxDFhC4GduJt2A==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "14.2.21", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.21.tgz", + "integrity": "sha512-HwEjcKsXtvszXz5q5Z7wCtrHeTTDSTgAbocz45PHMUjU3fBYInfvhR+ZhavDRUYLonm53aHZbB09QtJVJj8T7g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "14.2.21", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.21.tgz", + "integrity": "sha512-TSAA2ROgNzm4FhKbTbyJOBrsREOMVdDIltZ6aZiKvCi/v0UwFmwigBGeqXDA97TFMpR3LNNpw52CbVelkoQBxA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "14.2.21", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.21.tgz", + "integrity": "sha512-0Dqjn0pEUz3JG+AImpnMMW/m8hRtl1GQCNbO66V1yp6RswSTiKmnHf3pTX6xMdJYSemf3O4Q9ykiL0jymu0TuA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "14.2.21", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.21.tgz", + "integrity": "sha512-Ggfw5qnMXldscVntwnjfaQs5GbBbjioV4B4loP+bjqNEb42fzZlAaK+ldL0jm2CTJga9LynBMhekNfV8W4+HBw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "14.2.21", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.21.tgz", + "integrity": "sha512-uokj0lubN1WoSa5KKdThVPRffGyiWlm/vCc/cMkWOQHw69Qt0X1o3b2PyLLx8ANqlefILZh1EdfLRz9gVpG6tg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "14.2.21", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.21.tgz", + "integrity": "sha512-iAEBPzWNbciah4+0yI4s7Pce6BIoxTQ0AGCkxn/UBuzJFkYyJt71MadYQkjPqCQCJAFQ26sYh7MOKdU+VQFgPg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "14.2.21", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.21.tgz", + "integrity": "sha512-plykgB3vL2hB4Z32W3ktsfqyuyGAPxqwiyrAi2Mr8LlEUhNn9VgkiAl5hODSBpzIfWweX3er1f5uNpGDygfQVQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-ia32-msvc": { + "version": "14.2.21", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.21.tgz", + "integrity": "sha512-w5bacz4Vxqrh06BjWgua3Yf7EMDb8iMcVhNrNx8KnJXt8t+Uu0Zg4JHLDL/T7DkTCEEfKXO/Er1fcfWxn2xfPA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "14.2.21", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.21.tgz", + "integrity": "sha512-sT6+llIkzpsexGYZq8cjjthRyRGe5cJVhqh12FmlbxHqna6zsDDK8UNaV7g41T6atFHCJUPeLb3uyAwrBwy0NA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "license": "Apache-2.0" + }, + "node_modules/@swc/helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz", + "integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==", + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.4", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz", + "integrity": "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.6", + "caniuse-lite": "^1.0.30001806", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", + "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.15", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.15.tgz", + "integrity": "sha512-FwMjJJ7HnyZpWe+oWxegG0fezZyBZUagI5LZEoO3GCbtbKNwRfMH9Ue5d5v01PNePBy1QSfPSDTTeVL0Hb9EzA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.409", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.409.tgz", + "integrity": "sha512-ChI4N44d0B4A6C8prnNjMOaGgE59fUyEVYcRYm2XEXIjMbbvF5i9UL1cblDbpGqiU0uS8FE8UcKxqZqTXdmzbQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lucide-react": { + "version": "0.468.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz", + "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "14.2.21", + "resolved": "https://registry.npmjs.org/next/-/next-14.2.21.tgz", + "integrity": "sha512-rZmLwucLHr3/zfDMYbJXbw0ZeoBpirxkXuvsJbk7UPorvPYZhP7vq7aHbKnU7dQNCYIimRrbB2pp3xmf+wsYUg==", + "deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details.", + "license": "MIT", + "dependencies": { + "@next/env": "14.2.21", + "@swc/helpers": "0.5.5", + "busboy": "1.6.0", + "caniuse-lite": "^1.0.30001579", + "graceful-fs": "^4.2.11", + "postcss": "8.4.31", + "styled-jsx": "5.1.1" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=18.17.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "14.2.21", + "@next/swc-darwin-x64": "14.2.21", + "@next/swc-linux-arm64-gnu": "14.2.21", + "@next/swc-linux-arm64-musl": "14.2.21", + "@next/swc-linux-x64-gnu": "14.2.21", + "@next/swc-linux-x64-musl": "14.2.21", + "@next/swc-win32-arm64-msvc": "14.2.21", + "@next/swc-win32-ia32-msvc": "14.2.21", + "@next/swc-win32-x64-msvc": "14.2.21" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.41.2", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz", + "integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwind-merge": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", + "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000000000000000000000000000000000000..ae03b853e3c94acf686b266de0dedfd6a9a74ef9 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,29 @@ +{ + "name": "sanjeevani-ai-frontend", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev -p 3000", + "build": "next build", + "start": "next start -p 3000", + "lint": "next lint" + }, + "dependencies": { + "axios": "^1.7.9", + "clsx": "^2.1.1", + "lucide-react": "^0.468.0", + "next": "14.2.21", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "tailwind-merge": "^2.6.0" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.2" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000000000000000000000000000000000000..12a703d900da8159c30e75acbd2c4d87ae177f62 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/src/components/EntityBadge.tsx b/frontend/src/components/EntityBadge.tsx new file mode 100644 index 0000000000000000000000000000000000000000..55b18bee2413366fe640a33eed407e70a91ab557 --- /dev/null +++ b/frontend/src/components/EntityBadge.tsx @@ -0,0 +1,67 @@ +import React from "react"; +import { Pill, Activity, Tag } from "lucide-react"; + +interface EntityBadgeProps { + label: string; + text: string; + confidence?: number | null; + className?: string; +} + +export const EntityBadge: React.FC = ({ + label, + text, + confidence, + className = "", +}) => { + const isChemical = label.toUpperCase() === "CHEMICAL" || label.toUpperCase() === "MEDICINE"; + const isDisease = label.toUpperCase() === "DISEASE" || label.toUpperCase() === "CONDITION"; + + if (isChemical) { + return ( + + + {text} + + Chemical + + {confidence !== undefined && confidence !== null && ( + + {Math.round(confidence * 100)}% + + )} + + ); + } + + if (isDisease) { + return ( + + + {text} + + Disease + + {confidence !== undefined && confidence !== null && ( + + {Math.round(confidence * 100)}% + + )} + + ); + } + + return ( + + + {text} + {label} + + ); +}; diff --git a/frontend/src/components/MedicalDisclaimer.tsx b/frontend/src/components/MedicalDisclaimer.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bc2c461f80e7ea672ebd83671434be4d59244538 --- /dev/null +++ b/frontend/src/components/MedicalDisclaimer.tsx @@ -0,0 +1,33 @@ +import React from "react"; +import { AlertCircle } from "lucide-react"; + +interface MedicalDisclaimerProps { + compact?: boolean; +} + +export const MedicalDisclaimer: React.FC = ({ compact = false }) => { + if (compact) { + return ( +
+ + + Clinical Notice: SanjeevaniAI provides AI-assisted decision-support information, not definitive diagnosis. + +
+ ); + } + + return ( +
+ +
+

Important Medical Information & Decision-Support Notice

+

+ SanjeevaniAI is designed to assist healthcare professionals and patients with biomedical information extraction, + document summarization, and clinical decision support. It is not an autonomous diagnostic device and is + not a substitute for direct clinical examination, laboratory diagnosis, or emergency medical treatment. +

+
+
+ ); +}; diff --git a/frontend/src/components/MetricCard.tsx b/frontend/src/components/MetricCard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..270664ca7dfe87c591340c996b1229fa02764f79 --- /dev/null +++ b/frontend/src/components/MetricCard.tsx @@ -0,0 +1,42 @@ +import React from "react"; +import { LucideIcon } from "lucide-react"; + +interface MetricCardProps { + title: string; + value: string | number; + subtitle?: string; + icon: LucideIcon; + iconColor?: string; + iconBg?: string; + trend?: string; +} + +export const MetricCard: React.FC = ({ + title, + value, + subtitle, + icon: Icon, + iconColor = "text-teal-600", + iconBg = "bg-teal-50", + trend, +}) => { + return ( +
+
+
+

{title}

+

{value}

+
+
+ +
+
+ {(subtitle || trend) && ( +
+ {subtitle} + {trend && {trend}} +
+ )} +
+ ); +}; diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..501e0ac62ab597c2a4acf8ff54f0781d93d82a10 --- /dev/null +++ b/frontend/src/components/Navbar.tsx @@ -0,0 +1,139 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import Link from "next/link"; +import { useAuth } from "@/lib/auth-context"; +import { healthApi } from "@/lib/api"; +import { Activity, ShieldCheck, User as UserIcon, LogOut, ChevronDown, Sparkles } from "lucide-react"; + +export const Navbar: React.FC = () => { + const { user, logout, quickLogin, isAuthenticated } = useAuth(); + const [modelStatus, setModelStatus] = useState("Checking..."); + const [showDemoMenu, setShowDemoMenu] = useState(false); + + useEffect(() => { + healthApi + .getReady() + .then((res) => { + if (res.data.status === "ready") { + setModelStatus("RoBERTa-large BC5CDR Online"); + } else { + setModelStatus("ML Engine Ready"); + } + }) + .catch(() => setModelStatus("Mock LLM Mode")); + }, []); + + return ( +
+
+ {/* Brand & Tagline */} +
+ +
+ +
+
+ + SanjeevaniAI + + v1.0 + + +

AI-Powered Healthcare Intelligence

+
+ +
+ + {/* Model Status & Actions */} +
+ {/* ML Status indicator */} +
+ + {modelStatus} +
+ + {/* Quick Demo Switcher */} +
+ + + {showDemoMenu && ( +
+

Quick Demo Login

+ + + +
+ )} +
+ + {/* User Auth Info */} + {isAuthenticated && user ? ( +
+
+

{user.full_name}

+

{user.role}

+
+ +
+ ) : ( +
+ + Sign In + + + Register + +
+ )} +
+
+
+ ); +}; diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2217ac86b02f63dff3ef2a1a9838f7e50ec8900e --- /dev/null +++ b/frontend/src/components/Sidebar.tsx @@ -0,0 +1,98 @@ +"use client"; + +import React from "react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { + LayoutDashboard, + Cpu, + FileText, + MessageSquareHeart, + User, + History, + ShieldAlert, + Sparkles, + LucideIcon, +} from "lucide-react"; +import { useAuth } from "@/lib/auth-context"; + +interface NavItem { + name: string; + href: string; + icon: LucideIcon; + badge?: string; + highlight?: boolean; +} + +export const Sidebar: React.FC = () => { + const pathname = usePathname(); + const { user } = useAuth(); + + const navItems: NavItem[] = [ + { name: "Overview", href: "/dashboard", icon: LayoutDashboard }, + { name: "Biomedical NER", href: "/ner", icon: Cpu, badge: "Local ML" }, + { name: "Medical Reports", href: "/reports", icon: FileText }, + { name: "AI Assistant", href: "/assistant", icon: MessageSquareHeart }, + { name: "Health Profile", href: "/profile", icon: User }, + { name: "Medical History", href: "/history", icon: History }, + ]; + + if (user?.role === "ADMIN") { + navItems.push({ name: "Admin & Audit", href: "/admin", icon: ShieldAlert }); + } + + // Demo walkthrough always available + navItems.push({ name: "Mentor Demo Mode", href: "/demo", icon: Sparkles, highlight: true }); + + return ( + + ); +}; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000000000000000000000000000000000000..9f3f3e26783a6b292fbb904f8ae159ada4896ce6 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,84 @@ +import axios from "axios"; + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api/v1"; + +export const api = axios.create({ + baseURL: API_BASE_URL, + headers: { + "Content-Type": "application/json", + }, +}); + +// Attach JWT access token to all outgoing requests +api.interceptors.request.use((config) => { + if (typeof window !== "undefined") { + const token = localStorage.getItem("sanjeevani_token"); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + } + return config; +}); + +// Response interceptor for handling 401s +api.interceptors.response.use( + (response) => response, + (error) => { + if (error.response && error.response.status === 401) { + if (typeof window !== "undefined" && !window.location.pathname.includes("/login")) { + // Optional session expired redirect + } + } + return Promise.reject(error); + } +); + +// Typed API services +export const authApi = { + login: (data: any) => api.post("/auth/login", data), + register: (data: any) => api.post("/auth/register", data), + getMe: () => api.get("/auth/me"), + refresh: (refresh_token: string) => api.post("/auth/refresh", { refresh_token }), +}; + +export const nerApi = { + analyze: (text: string) => api.post("/ner/analyze", { text }), + getModelInfo: () => api.get("/ner/model-info"), +}; + +export const documentApi = { + upload: (formData: FormData) => + api.post("/documents/upload", formData, { + headers: { "Content-Type": "multipart/form-data" }, + }), + list: () => api.get("/documents"), + getById: (id: string) => api.get(`/documents/${id}`), + delete: (id: string) => api.delete(`/documents/${id}`), +}; + +export const chatApi = { + sendMessage: (message: string, conversation_id?: string) => + api.post("/chat/message", { message, conversation_id }), + listConversations: () => api.get("/chat/conversations"), + getConversation: (id: string) => api.get(`/chat/conversations/${id}`), + deleteConversation: (id: string) => api.delete(`/chat/conversations/${id}`), +}; + +export const profileApi = { + getProfile: () => api.get("/profile"), + updateProfile: (data: any) => api.put("/profile", data), +}; + +export const historyApi = { + getHistory: (limit: number = 50) => api.get(`/history?limit=${limit}`), +}; + +export const adminApi = { + getStats: () => api.get("/admin/stats"), + getAuditLogs: (limit: number = 100) => api.get(`/admin/audit-logs?limit=${limit}`), +}; + +export const healthApi = { + getHealth: () => api.get("/health"), + getReady: () => api.get("/ready"), +}; diff --git a/frontend/src/lib/auth-context.tsx b/frontend/src/lib/auth-context.tsx new file mode 100644 index 0000000000000000000000000000000000000000..082ae2bc9e776293950d5c2885f086dadc35b211 --- /dev/null +++ b/frontend/src/lib/auth-context.tsx @@ -0,0 +1,121 @@ +"use client"; + +import React, { createContext, useContext, useState, useEffect } from "react"; +import { authApi } from "./api"; + +export interface User { + id: string; + email: string; + full_name: string; + role: "USER" | "PATIENT" | "DOCTOR" | "ADMIN"; + is_active: boolean; + is_verified: boolean; +} + +interface AuthContextType { + user: User | null; + token: string | null; + isLoading: boolean; + login: (email: string, password: string) => Promise; + register: (email: string, password: string, full_name: string, role?: string) => Promise; + logout: () => void; + quickLogin: (role: "patient" | "doctor" | "admin") => Promise; + isAuthenticated: boolean; +} + +const AuthContext = createContext(undefined); + +export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [user, setUser] = useState(null); + const [token, setToken] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const savedToken = localStorage.getItem("sanjeevani_token"); + const savedUser = localStorage.getItem("sanjeevani_user"); + + if (savedToken && savedUser) { + try { + setToken(savedToken); + setUser(JSON.parse(savedUser)); + } catch (e) { + localStorage.removeItem("sanjeevani_token"); + localStorage.removeItem("sanjeevani_user"); + } + } + setIsLoading(false); + }, []); + + const login = async (email: string, password: string) => { + setIsLoading(true); + try { + const res = await authApi.login({ email, password }); + const data = res.data.data; + setToken(data.access_token); + setUser(data.user); + localStorage.setItem("sanjeevani_token", data.access_token); + localStorage.setItem("sanjeevani_refresh_token", data.refresh_token); + localStorage.setItem("sanjeevani_user", JSON.stringify(data.user)); + } finally { + setIsLoading(false); + } + }; + + const register = async (email: string, password: string, full_name: string, role = "PATIENT") => { + setIsLoading(true); + try { + const res = await authApi.register({ email, password, full_name, role }); + const data = res.data.data; + setToken(data.access_token); + setUser(data.user); + localStorage.setItem("sanjeevani_token", data.access_token); + localStorage.setItem("sanjeevani_refresh_token", data.refresh_token); + localStorage.setItem("sanjeevani_user", JSON.stringify(data.user)); + } finally { + setIsLoading(false); + } + }; + + const quickLogin = async (role: "patient" | "doctor" | "admin") => { + const credentials = { + patient: { email: "demo.patient@sanjeevani.ai", password: "DemoPatient2026!" }, + doctor: { email: "demo.doctor@sanjeevani.ai", password: "DemoDoctor2026!" }, + admin: { email: "demo.admin@sanjeevani.ai", password: "DemoAdmin2026!" }, + }; + const cred = credentials[role]; + await login(cred.email, cred.password); + }; + + const logout = () => { + setToken(null); + setUser(null); + localStorage.removeItem("sanjeevani_token"); + localStorage.removeItem("sanjeevani_refresh_token"); + localStorage.removeItem("sanjeevani_user"); + }; + + return ( + + {children} + + ); +}; + +export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; +}; diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000000000000000000000000000000000000..1f7eef552b69bd86301563978fac4435a3e5f506 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,35 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", + "./src/components/**/*.{js,ts,jsx,tsx,mdx}", + "./src/app/**/*.{js,ts,jsx,tsx,mdx}", + ], + darkMode: "class", + theme: { + extend: { + colors: { + brand: { + 50: "#f0fdfa", + 100: "#ccfbf1", + 200: "#99f6e4", + 300: "#5eead4", + 400: "#2dd4bf", + 500: "#14b8a6", + 600: "#0d9488", + 700: "#0f766e", + 800: "#115e59", + 900: "#134e4a", + 950: "#042f2e", + }, + clinical: { + chemical: "#059669", + chemicalBg: "#ecfdf5", + disease: "#dc2626", + diseaseBg: "#fef2f2", + }, + }, + }, + }, + plugins: [], +}; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..7b2858930495fc4a76d7a51d958bacf2d64eb81f --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/index.html b/index.html deleted file mode 100644 index 802f933237312254b4d18b5216afcd44a4cc00f4..0000000000000000000000000000000000000000 --- a/index.html +++ /dev/null @@ -1,111 +0,0 @@ - - - - - -Sanjeevani AI — Voice-First Medicine Safety Companion - - - - - - -
- - -
-
🌿
-
Sanjeevani AI
-
Your voice-first medicine safety companion
-
Choose your language / अपनी भाषा चुनें / மொழியை தேர்ந்தெடுக்கவும்
-
-
- - - - - -
-
-
🌿
-
-

Sanjeevani AI

-
-
-
-
- - - -
-
- - - - - - - - - - - - - -
- - - - - - - - diff --git a/js/app.js b/js/app.js deleted file mode 100644 index 8025bac384d79714478d65958828224c966797a0..0000000000000000000000000000000000000000 --- a/js/app.js +++ /dev/null @@ -1,368 +0,0 @@ -/* ---------- Global state ---------- */ -const state = { - lang: "en", - patient: { name: "", age: "" }, - catalog: [], - interactionRules: [], - cabinet: [], // array of drug ids -}; - -const els = {}; - -/* ---------- Boot ---------- */ -document.addEventListener("DOMContentLoaded", async () => { - cacheEls(); - bindStaticEvents(); - await loadData(); - renderLangGrid(); - showScreen("lang"); - if ("serviceWorker" in navigator) { - navigator.serviceWorker.register("sw.js").catch(() => {}); - } - maybeRunDebugPreset(); -}); - -/* ---------- Debug/demo preset (used for QA screenshots & quick demos) ---------- - Append ?demo=home|schedule|pharmacist&lang=en|hi|ta to index.html to jump - straight into a populated screen with sample data. Not part of normal UX. */ -function maybeRunDebugPreset() { - const params = new URLSearchParams(window.location.search); - const demo = params.get("demo"); - if (!demo) return; - state.lang = params.get("lang") || "en"; - currentLang = state.lang; - applyTranslations(); - state.patient.name = params.get("name") || "Lakshmi Ammal"; - state.patient.age = params.get("age") || "68"; - els.patientBadge.textContent = state.patient.name + ", " + state.patient.age; - const sample = (params.get("meds") || "ramipril,spironolactone,atorvastatin,metformin").split(","); - state.cabinet = sample; - showScreen(demo); -} - -function cacheEls() { - document.querySelectorAll("[data-screen]").forEach(s => (els[s.dataset.screen] = s)); - els.langGrid = document.getElementById("langGrid"); - els.onboardName = document.getElementById("onboardName"); - els.onboardAge = document.getElementById("onboardAge"); - els.searchInput = document.getElementById("searchInput"); - els.searchResults = document.getElementById("searchResults"); - els.cabinetList = document.getElementById("cabinetList"); - els.cabinetEmpty = document.getElementById("cabinetEmpty"); - els.riskPanel = document.getElementById("riskPanel"); - els.scheduleBody = document.getElementById("scheduleBody"); - els.pharmacistBody = document.getElementById("pharmacistBody"); - els.scanInput = document.getElementById("scanInput"); - els.scanStatus = document.getElementById("scanStatus"); - els.navBar = document.getElementById("navBar"); - els.patientBadge = document.getElementById("patientBadge"); -} - -async function loadData() { - // Data ships as plain JS globals (data/drugs.js, data/interactions.js) rather - // than fetch()-ed JSON, so the app works when opened straight from disk - // (file://) with no local server and no CORS/fetch restrictions. Classic - // top-level scripts share one global lexical scope, so these identifiers - // (declared with `const` in the data files) are reachable here directly — - // note they are NOT properties of window. - state.catalog = typeof DRUGS_DATA !== "undefined" ? DRUGS_DATA : []; - state.interactionRules = typeof INTERACTIONS_DATA !== "undefined" ? INTERACTIONS_DATA : []; -} - -/* ---------- Screen management ---------- */ -function showScreen(name) { - document.querySelectorAll("[data-screen]").forEach(el => el.classList.add("hidden")); - if (els[name]) els[name].classList.remove("hidden"); - const isAppScreen = ["home", "schedule", "pharmacist"].includes(name); - document.getElementById("topBarWrap").classList.toggle("hidden", !isAppScreen); - els.navBar.classList.toggle("hidden", !isAppScreen); - document.querySelectorAll(".navBtn").forEach(b => b.classList.toggle("active", b.dataset.target === name)); - if (name === "home") renderCabinet(); - if (name === "schedule") renderSchedule(); - if (name === "pharmacist") renderPharmacist(); -} - -/* ---------- Language step ---------- */ -const LANGS = [ - { code: "en", label: "English" }, - { code: "hi", label: "हिन्दी (Hindi)" }, - { code: "ta", label: "தமிழ் (Tamil)" }, -]; - -function renderLangGrid() { - els.langGrid.innerHTML = LANGS.map( - l => `` - ).join(""); - els.langGrid.querySelectorAll(".langCard").forEach(btn => { - btn.addEventListener("click", () => { - state.lang = btn.dataset.lang; - currentLang = state.lang; - applyTranslations(); - showScreen("onboard"); - }); - }); -} - -function applyTranslations() { - document.querySelectorAll("[data-t]").forEach(el => (el.textContent = t(el.dataset.t))); - document.querySelectorAll("[data-t-ph]").forEach(el => (el.placeholder = t(el.dataset.tPh))); - document.documentElement.lang = state.lang; -} - -/* ---------- Onboarding ---------- */ -function bindStaticEvents() { - document.getElementById("onboardForm").addEventListener("submit", e => { - e.preventDefault(); - state.patient.name = els.onboardName.value.trim() || (state.lang === "hi" ? "रोगी" : state.lang === "ta" ? "நோயாளி" : "Patient"); - state.patient.age = els.onboardAge.value.trim(); - els.patientBadge.textContent = state.patient.name + (state.patient.age ? `, ${state.patient.age}` : ""); - showScreen("home"); - }); - - els.searchInput.addEventListener("input", () => { - const q = els.searchInput.value; - if (!q) { els.searchResults.innerHTML = ""; return; } - const matches = fuzzySearchDrugs(q, state.catalog); - renderSearchResults(matches, q); - }); - - els.scanInput.addEventListener("change", handleScan); - - document.querySelectorAll(".navBtn").forEach(btn => { - btn.addEventListener("click", () => showScreen(btn.dataset.target)); - }); - - document.querySelectorAll(".langSwitch").forEach(btn => { - btn.addEventListener("click", () => { - state.lang = btn.dataset.lang; - currentLang = state.lang; - applyTranslations(); - renderCabinet(); - const active = document.querySelector(".navBtn.active"); - if (active) showScreen(active.dataset.target); - }); - }); - - document.getElementById("printBtn").addEventListener("click", () => window.print()); -} - -function renderSearchResults(matches, query) { - if (!matches.length) { - els.searchResults.innerHTML = `
${t("noMatch")}
`; - return; - } - els.searchResults.innerHTML = matches.map(d => resultRow(d)).join(""); - els.searchResults.querySelectorAll(".resultRow").forEach(row => { - row.querySelector(".addBtn").addEventListener("click", () => { - addToCabinet(row.dataset.id); - els.searchInput.value = ""; - els.searchResults.innerHTML = ""; - }); - }); -} - -function resultRow(d) { - const localName = state.lang === "hi" ? d.hi : state.lang === "ta" ? d.ta : ""; - return `
-
${d.icon}
-
-
${d.generic}${localName ? ` · ${localName}` : ""}
-
${d.category} · ${(d.brands || []).join(", ")}
-
- -
`; -} - -function addToCabinet(id) { - if (!state.cabinet.includes(id)) state.cabinet.push(id); - renderCabinet(); -} - -function removeFromCabinet(id) { - state.cabinet = state.cabinet.filter(x => x !== id); - renderCabinet(); -} - -/* ---------- Cabinet + risk check ---------- */ -function renderCabinet() { - const drugs = state.cabinet.map(id => state.catalog.find(d => d.id === id)).filter(Boolean); - els.cabinetEmpty.classList.toggle("hidden", drugs.length > 0); - els.cabinetList.innerHTML = drugs.map(d => { - const localName = state.lang === "hi" ? d.hi : state.lang === "ta" ? d.ta : ""; - return `
- ${d.icon} - ${d.generic}${localName ? ` · ${localName}` : ""} - -
`; - }).join(""); - els.cabinetList.querySelectorAll(".pillRemove").forEach(btn => { - btn.addEventListener("click", () => removeFromCabinet(btn.dataset.id)); - }); - renderRiskPanel(drugs); -} - -function renderRiskPanel(drugs) { - if (drugs.length < 2) { - els.riskPanel.innerHTML = ""; - return; - } - const flagged = checkInteractions(state.cabinet, state.interactionRules); - const risk = overallRisk(flagged); - if (!flagged.length) { - els.riskPanel.innerHTML = `
${t("allClear")}
${t("demoNote")}
`; - return; - } - const cards = flagged.map(f => { - const nameA = drugNameOf(f.pair[0]); - const nameB = drugNameOf(f.pair[1]); - const sevClass = f.severity; - const sevLabel = t(f.severity); - const spoken = `${sevLabel}. ${nameA} ${state.lang === "en" ? "and" : "+"} ${nameB}. ${f.title[state.lang] || f.title.en}. ${f.explain[state.lang] || f.explain.en}. ${f.advice[state.lang] || f.advice.en}`; - return `
-
- ${sevLabel} - ${nameA} + ${nameB} -
-
${f.title[state.lang] || f.title.en}
-
${t("why")}: ${f.explain[state.lang] || f.explain.en}
-
${t("advice")}: ${f.advice[state.lang] || f.advice.en}
- -
`; - }).join(""); - els.riskPanel.innerHTML = `
${flagged.length} ${t("risksFound")}
${cards}
${t("demoNote")}
`; - els.riskPanel.querySelectorAll(".speakBtn").forEach(btn => { - btn.addEventListener("click", () => speak(btn.dataset.text)); - }); -} - -function drugNameOf(id) { - const d = state.catalog.find(x => x.id === id); - if (!d) return id; - const localName = state.lang === "hi" ? d.hi : state.lang === "ta" ? d.ta : ""; - return localName ? `${d.generic} (${localName})` : d.generic; -} - -function escapeAttr(str) { - return str.replace(/&/g, "&").replace(/"/g, """); -} - -/* ---------- Voice (Web Speech API) ---------- */ -function speak(text) { - if (!("speechSynthesis" in window)) { - alert(t("voiceUnavailable")); - return; - } - window.speechSynthesis.cancel(); - const utter = new SpeechSynthesisUtterance(text); - utter.lang = state.lang === "hi" ? "hi-IN" : state.lang === "ta" ? "ta-IN" : "en-IN"; - utter.rate = 0.92; - window.speechSynthesis.speak(utter); -} - -/* ---------- Simulated / real OCR scan ---------- */ -async function handleScan(e) { - const file = e.target.files[0]; - if (!file) return; - els.scanStatus.textContent = t("scanning"); - els.scanStatus.classList.remove("hidden"); - try { - if (window.Tesseract) { - const { data } = await Tesseract.recognize(file, "eng"); - const text = (data && data.text || "").trim(); - els.scanStatus.classList.add("hidden"); - if (text) { - const bestLine = text.split(/\n/).map(s => s.trim()).filter(Boolean)[0] || text; - els.searchInput.value = bestLine.slice(0, 40); - renderSearchResults(fuzzySearchDrugs(bestLine, state.catalog), bestLine); - return; - } - } - } catch (err) { - console.warn("OCR failed, falling back:", err); - } - els.scanStatus.classList.add("hidden"); - els.searchResults.innerHTML = `
${t("noMatch")}
`; -} - -/* ---------- Schedule ---------- */ -function renderSchedule() { - const drugs = state.cabinet.map(id => state.catalog.find(d => d.id === id)).filter(Boolean); - if (!drugs.length) { - els.scheduleBody.innerHTML = `
${t("emptyCabinet")}
`; - return; - } - const slots = buildSchedule(drugs); - els.scheduleBody.innerHTML = ["morning", "afternoon", "night"].map(slot => { - const items = slots[slot]; - if (!items.length) return ""; - return `
-
${slotIcon(slot)} ${t(slot)}
-
- ${items.map(it => `
- ${it.drug.icon} - ${it.drug.generic} - ${t(it.food)} -
`).join("")} -
-
`; - }).join("") + ``; - - const remindBtn = document.getElementById("remindBtn"); - if (remindBtn) { - remindBtn.addEventListener("click", () => { - const lines = drugs.map(d => `${d.generic}`).join(", "); - const msg = state.lang === "hi" - ? `${state.patient.name} जी, आपकी दवा का समय हो गया है: ${lines}` - : state.lang === "ta" - ? `${state.patient.name}, உங்கள் மருந்து நேரம்: ${lines}` - : `${state.patient.name}, it's time for your medicines: ${lines}`; - speak(msg); - }); - } -} - -function slotIcon(slot) { - return slot === "morning" ? "🌅" : slot === "afternoon" ? "☀️" : "🌙"; -} - -// Deterministic demo schedule: category-driven common-sense timing. -function buildSchedule(drugs) { - const slots = { morning: [], afternoon: [], night: [] }; - drugs.forEach(d => { - let slot = "morning", food = "afterFood"; - if (d.category.includes("Thyroid")) { slot = "morning"; food = "beforeFood"; } - else if (d.category.includes("Antidiabetic")) { slot = "morning"; food = "beforeFood"; } - else if (d.category.includes("Statin")) { slot = "night"; food = "afterFood"; } - else if (d.category.includes("PPI") || d.category.includes("Proton")) { slot = "morning"; food = "beforeFood"; } - else if (d.category.includes("Diuretic")) { slot = "morning"; food = "afterFood"; } - else if (d.category.includes("Antibiotic")) { slot = "afternoon"; food = "afterFood"; } - slots[slot].push({ drug: d, food }); - }); - return slots; -} - -/* ---------- Pharmacist dashboard ---------- */ -function renderPharmacist() { - const drugs = state.cabinet.map(id => state.catalog.find(d => d.id === id)).filter(Boolean); - const flagged = checkInteractions(state.cabinet, state.interactionRules); - const risk = overallRisk(flagged); - const riskLabel = risk === "none" ? "—" : t(risk); - els.pharmacistBody.innerHTML = ` -
-
${drugs.length}
${t("totalMeds")}
-
${flagged.length}
${t("flaggedPairs")}
-
${riskLabel}
${t("riskLevel")}
-
-
- ${drugs.map(d => `
${d.icon} ${d.generic} (${d.category})
`).join("") || `
${t("emptyCabinet")}
`} -
- ${flagged.length ? `
${flagged.map(f => ` -
-
${t(f.severity)} - ${drugNameOf(f.pair[0])} + ${drugNameOf(f.pair[1])}
-
${f.title[state.lang] || f.title.en}
-
${t("why")}: ${f.explain[state.lang] || f.explain.en}
-
${t("advice")}: ${f.advice[state.lang] || f.advice.en}
-
`).join("")}
` : ""} - `; -} diff --git a/js/i18n.js b/js/i18n.js deleted file mode 100644 index 861aa307a46a535163104a63664904484a39c8d1..0000000000000000000000000000000000000000 --- a/js/i18n.js +++ /dev/null @@ -1,161 +0,0 @@ -const I18N = { - en: { - appName: "Sanjeevani AI", - tagline: "Your voice-first medicine safety companion", - chooseLang: "Choose your language", - continue: "Continue", - navHome: "Home", - navCabinet: "My Medicines", - navSchedule: "Schedule", - navPharmacist: "Pharmacist View", - addMedicine: "+ Add Medicine", - searchPlaceholder: "Type a medicine name (e.g. Dolo, Amlong)...", - scanStrip: "📷 Scan strip photo", - scanning: "Reading photo...", - scanHelp: "On-device OCR — works even with shaky handwriting or worn print.", - noMatch: "No close match found — try typing the name manually.", - add: "Add", - remove: "Remove", - myMedicines: "My Medicines", - emptyCabinet: "No medicines added yet. Tap \"Add Medicine\" to get started.", - checkInteractions: "🔍 Check for Risks", - allClear: "✅ No known risky combinations found in this list.", - demoNote: "Demo dataset — not exhaustive. Always confirm with a pharmacist or doctor.", - risksFound: "risk(s) found", - severe: "Severe", - moderate: "Moderate", - mild: "Mild — spacing advised", - why: "Why this matters", - advice: "What to do", - speakThis: "🔊 Listen", - schedule: "Today's Schedule", - morning: "Morning", - afternoon: "Afternoon", - night: "Night", - beforeFood: "Before food", - afterFood: "After food", - remindMe: "🔊 Remind me now", - pharmacistTitle: "Pharmacist / Caregiver Dashboard", - pharmacistSub: "Full interaction summary for counseling", - patientName: "Patient", - printSummary: "🖨️ Print Summary", - riskLevel: "Risk level", - totalMeds: "Medicines on record", - flaggedPairs: "Flagged pairs", - onboardName: "Patient / caregiver name", - onboardAge: "Age", - startNow: "Start using Sanjeevani AI", - switchLang: "Language", - offlineNote: "Works offline once loaded · No data leaves this device", - voiceUnavailable: "Voice reminders aren't supported in this browser.", - disclaimer: "Educational prototype for PHARMINNO QUEST 2026. Not a substitute for professional medical or pharmacist advice.", - of: "of" - }, - hi: { - appName: "संजीवनी AI", - tagline: "आपकी आवाज़-आधारित दवा सुरक्षा साथी", - chooseLang: "अपनी भाषा चुनें", - continue: "आगे बढ़ें", - navHome: "होम", - navCabinet: "मेरी दवाइयाँ", - navSchedule: "समय-सारणी", - navPharmacist: "फार्मासिस्ट व्यू", - addMedicine: "+ दवा जोड़ें", - searchPlaceholder: "दवा का नाम लिखें (जैसे Dolo, Amlong)...", - scanStrip: "📷 पत्ती की फोटो स्कैन करें", - scanning: "फोटो पढ़ी जा रही है...", - scanHelp: "डिवाइस पर ही OCR — धुंधली छपाई में भी काम करता है।", - noMatch: "कोई मेल नहीं मिला — कृपया नाम टाइप करें।", - add: "जोड़ें", - remove: "हटाएं", - myMedicines: "मेरी दवाइयाँ", - emptyCabinet: "अभी कोई दवा नहीं जोड़ी गई। शुरू करने के लिए \"दवा जोड़ें\" दबाएं।", - checkInteractions: "🔍 जोखिम जांचें", - allClear: "✅ इस सूची में कोई ज्ञात खतरनाक संयोजन नहीं मिला।", - demoNote: "डेमो डेटा — पूर्ण नहीं। हमेशा फार्मासिस्ट या डॉक्टर से पुष्टि करें।", - risksFound: "जोखिम मिले", - severe: "गंभीर", - moderate: "मध्यम", - mild: "हल्का — अंतराल रखें", - why: "यह क्यों महत्वपूर्ण है", - advice: "क्या करें", - speakThis: "🔊 सुनें", - schedule: "आज की समय-सारणी", - morning: "सुबह", - afternoon: "दोपहर", - night: "रात", - beforeFood: "भोजन से पहले", - afterFood: "भोजन के बाद", - remindMe: "🔊 अभी याद दिलाएं", - pharmacistTitle: "फार्मासिस्ट / देखभालकर्ता डैशबोर्ड", - pharmacistSub: "परामर्श के लिए पूर्ण सारांश", - patientName: "रोगी", - printSummary: "🖨️ सारांश प्रिंट करें", - riskLevel: "जोखिम स्तर", - totalMeds: "दर्ज दवाइयाँ", - flaggedPairs: "चिन्हित जोड़े", - onboardName: "रोगी / देखभालकर्ता का नाम", - onboardAge: "आयु", - startNow: "संजीवनी AI शुरू करें", - switchLang: "भाषा", - offlineNote: "लोड होने के बाद ऑफ़लाइन काम करता है · डेटा डिवाइस से बाहर नहीं जाता", - voiceUnavailable: "इस ब्राउज़र में आवाज़ रिमाइंडर उपलब्ध नहीं है।", - disclaimer: "PHARMINNO QUEST 2026 के लिए शैक्षिक प्रोटोटाइप। यह पेशेवर चिकित्सा सलाह का विकल्प नहीं है।", - of: "में से" - }, - ta: { - appName: "சஞ்சீவனி AI", - tagline: "உங்கள் குரல்-முதல் மருந்து பாதுகாப்பு துணை", - chooseLang: "உங்கள் மொழியை தேர்ந்தெடுக்கவும்", - continue: "தொடரவும்", - navHome: "முகப்பு", - navCabinet: "எனது மருந்துகள்", - navSchedule: "அட்டவணை", - navPharmacist: "மருந்தாளர் பார்வை", - addMedicine: "+ மருந்து சேர்க்க", - searchPlaceholder: "மருந்தின் பெயரை தட்டச்சு செய்யவும் (எ.கா. Dolo, Amlong)...", - scanStrip: "📷 ஸ்ட்ரிப் புகைப்படத்தை ஸ்கேன் செய்யவும்", - scanning: "புகைப்படம் படிக்கப்படுகிறது...", - scanHelp: "சாதனத்திலேயே OCR — மங்கலான அச்சிலும் வேலை செய்யும்.", - noMatch: "பொருத்தம் இல்லை — பெயரை தட்டச்சு செய்யவும்.", - add: "சேர்க்க", - remove: "நீக்கு", - myMedicines: "எனது மருந்துகள்", - emptyCabinet: "இன்னும் மருந்து சேர்க்கப்படவில்லை. தொடங்க \"மருந்து சேர்க்க\" அழுத்தவும்.", - checkInteractions: "🔍 அபாயங்களை சரிபார்க்கவும்", - allClear: "✅ இந்த பட்டியலில் அறியப்பட்ட ஆபத்தான கலவை எதுவும் இல்லை.", - demoNote: "டெமோ தரவு — முழுமையானது அல்ல. எப்போதும் மருந்தாளர் அல்லது மருத்துவரிடம் உறுதிப்படுத்தவும்.", - risksFound: "அபாயங்கள் கண்டறியப்பட்டன", - severe: "கடுமையான", - moderate: "நடுத்தர", - mild: "லேசான — இடைவெளி பரிந்துரைக்கப்படுகிறது", - why: "ஏன் இது முக்கியம்", - advice: "என்ன செய்ய வேண்டும்", - speakThis: "🔊 கேளுங்கள்", - schedule: "இன்றைய அட்டவணை", - morning: "காலை", - afternoon: "மதியம்", - night: "இரவு", - beforeFood: "உணவுக்கு முன்", - afterFood: "உணவுக்கு பின்", - remindMe: "🔊 இப்போது நினைவூட்டு", - pharmacistTitle: "மருந்தாளர் / பராமரிப்பாளர் டாஷ்போர்டு", - pharmacistSub: "ஆலோசனைக்கான முழு சுருக்கம்", - patientName: "நோயாளி", - printSummary: "🖨️ சுருக்கத்தை அச்சிடவும்", - riskLevel: "அபாய நிலை", - totalMeds: "பதிவு செய்யப்பட்ட மருந்துகள்", - flaggedPairs: "குறியிடப்பட்ட ஜோடிகள்", - onboardName: "நோயாளி / பராமரிப்பாளர் பெயர்", - onboardAge: "வயது", - startNow: "சஞ்சீவனி AI-ஐ தொடங்குங்கள்", - switchLang: "மொழி", - offlineNote: "ஏற்றிய பின் ஆஃப்லைனில் வேலை செய்யும் · தரவு சாதனத்தை விட்டு வெளியேறாது", - voiceUnavailable: "இந்த உலாவியில் குரல் நினைவூட்டல் ஆதரிக்கப்படவில்லை.", - disclaimer: "PHARMINNO QUEST 2026-க்கான கல்வி முன்மாதிரி. இது தொழில்முறை மருத்துவ ஆலோசனைக்கு மாற்றாக இல்லை.", - of: "இல்" - } -}; - -let currentLang = "en"; -function t(key) { return (I18N[currentLang] && I18N[currentLang][key]) || I18N.en[key] || key; } diff --git a/js/interactionEngine.js b/js/interactionEngine.js deleted file mode 100644 index 57a1f56ebe882635b7d42a994baf29b7d47bba25..0000000000000000000000000000000000000000 --- a/js/interactionEngine.js +++ /dev/null @@ -1,74 +0,0 @@ -/* ---------- Fuzzy matching (Levenshtein-based) for noisy OCR / typed input ---------- */ -function levenshtein(a, b) { - a = a.toLowerCase(); b = b.toLowerCase(); - const m = a.length, n = b.length; - const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)); - for (let i = 0; i <= m; i++) dp[i][0] = i; - for (let j = 0; j <= n; j++) dp[0][j] = j; - for (let i = 1; i <= m; i++) { - for (let j = 1; j <= n; j++) { - const cost = a[i - 1] === b[j - 1] ? 0 : 1; - dp[i][j] = Math.min( - dp[i - 1][j] + 1, - dp[i][j - 1] + 1, - dp[i - 1][j - 1] + cost - ); - } - } - return dp[m][n]; -} - -function similarity(a, b) { - const dist = levenshtein(a, b); - const maxLen = Math.max(a.length, b.length) || 1; - return 1 - dist / maxLen; -} - -/** - * Search the drug catalog for the best fuzzy matches to a noisy query - * (typed name, OCR text, or misspelling). Matches generic name, brand - * names, and localized names. - */ -function fuzzySearchDrugs(query, catalog, limit = 6) { - const q = query.trim().toLowerCase(); - if (!q) return []; - const scored = []; - for (const drug of catalog) { - const candidates = [drug.generic, ...(drug.brands || []), drug.hi, drug.ta]; - let best = 0; - for (const c of candidates) { - if (!c) continue; - const cLower = c.toLowerCase(); - // exact substring match gets a strong boost - if (cLower.includes(q) || q.includes(cLower)) { - best = Math.max(best, 0.92); - } - best = Math.max(best, similarity(q, cLower)); - } - if (best > 0.45) scored.push({ drug, score: best }); - } - scored.sort((x, y) => y.score - x.score); - return scored.slice(0, limit).map(s => s.drug); -} - -/* ---------- Interaction rule engine ---------- */ -function checkInteractions(selectedIds, interactionRules) { - const found = []; - for (const rule of interactionRules) { - const [a, b] = rule.pair; - if (selectedIds.includes(a) && selectedIds.includes(b)) { - found.push(rule); - } - } - // Severe first, then moderate, then mild - const order = { severe: 0, moderate: 1, mild: 2 }; - found.sort((x, y) => order[x.severity] - order[y.severity]); - return found; -} - -function overallRisk(flagged) { - if (flagged.some(f => f.severity === "severe")) return "severe"; - if (flagged.some(f => f.severity === "moderate")) return "moderate"; - if (flagged.length) return "mild"; - return "none"; -} diff --git a/manifest.json b/manifest.json deleted file mode 100644 index d906a5924772fcfb0fcafc5e89ef52b5e287a556..0000000000000000000000000000000000000000 --- a/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "Sanjeevani AI", - "short_name": "Sanjeevani AI", - "description": "Voice-first, multilingual polypharmacy safety companion for PHARMINNO QUEST 2026", - "start_url": "./index.html", - "display": "standalone", - "background_color": "#FBF7F0", - "theme_color": "#0E3B2E", - "icons": [] -} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..98716b150777fd89557d3b718ad2f5d6fac6de74 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,53 @@ +# SanjeevaniAI Python Dependencies + +# Core ML & NLP (Preserved & Integrated) +torch>=2.0.0 +transformers>=4.35.0 +datasets>=2.14.0 +accelerate>=0.24.0 +evaluate>=0.4.0 +safetensors>=0.4.0 +sentence-transformers>=2.2.0 +huggingface_hub>=0.19.0 +seqeval>=1.2.2 + +# Data Processing & Utilities +numpy>=1.24.0 +pandas>=2.0.0 +scikit-learn>=1.3.0 +Pillow>=10.0.0 +pydantic>=2.5.0 +pydantic-settings>=2.1.0 +python-dotenv>=1.0.0 + +# Backend Web Framework +fastapi>=0.110.0 +uvicorn[standard]>=0.28.0 +python-multipart>=0.0.9 +httpx>=0.27.0 +aiofiles>=23.2.0 + +# Database & Migrations +sqlalchemy>=2.0.28 +alembic>=1.13.1 +aiosqlite>=0.20.0 +asyncpg>=0.29.0 +greenlet>=3.0.3 + +# Security & Authentication +python-jose[cryptography]>=3.3.0 +passlib[bcrypt]>=1.7.4 +bcrypt>=4.0.1 + +# Document Processing (PDF & Clinical text extraction) +pypdf>=4.1.0 +pdfplumber>=0.10.4 +docx2txt>=0.8 + +# Background Tasks & Caching (Optional Redis/Celery) +redis>=5.0.3 + +# Testing & Quality +pytest>=8.0.0 +pytest-asyncio>=0.23.5 +ruff>=0.3.0 diff --git a/scripts/download_models.py b/scripts/download_models.py new file mode 100644 index 0000000000000000000000000000000000000000..8e2652423c595a830606d434eaca89a7f88ae658 --- /dev/null +++ b/scripts/download_models.py @@ -0,0 +1,80 @@ +from pathlib import Path +from huggingface_hub import snapshot_download + +BASE_DIR = Path(__file__).resolve().parents[1] + +MODEL_DIR = BASE_DIR / "models" / "pretrained" + +MODELS = { + "trocr": "microsoft/trocr-base-printed", + + "biomedbert": + "microsoft/BiomedNLP-BiomedBERT-base-uncased-abstract-fulltext", + + "sapbert": + "cambridgeltl/SapBERT-from-PubMedBERT-fulltext", + + "intent": + "distilbert/distilbert-base-uncased", + + "whisper": + "openai/whisper-base", + + "indictrans_en_indic": + "ai4bharat/indictrans2-en-indic-dist-200M", + + "indictrans_indic_indic": + "ai4bharat/indictrans2-indic-indic-dist-320M", +} + +# Only download files required for PyTorch/Transformers. +# This avoids downloading TensorFlow, Flax and duplicate PyTorch weights. +ALLOW_PATTERNS = [ + "*.json", + "*.safetensors", + "*.txt", + "*.model", + "*.vocab", + "*.merges", + "*.tiktoken", + "*.py", +] + +for name, repo_id in MODELS.items(): + + destination = MODEL_DIR / name + + print("\n" + "=" * 70) + print(f"Downloading: {name}") + print(f"Repository: {repo_id}") + print(f"Destination: {destination}") + print("=" * 70) + + destination.mkdir( + parents=True, + exist_ok=True + ) + + try: + + snapshot_download( + repo_id=repo_id, + local_dir=str(destination), + allow_patterns=ALLOW_PATTERNS, + max_workers=2, + ) + + print(f"\n✅ Finished: {name}") + + except Exception as e: + + print(f"\n❌ FAILED: {name}") + print(f"Error: {e}") + + print( + "\nThe script will continue with the next model." + ) + +print("\n" + "=" * 70) +print("MODEL DOWNLOAD PROCESS FINISHED") +print("=" * 70) diff --git a/scripts/prepare_mobile_ocr.py b/scripts/prepare_mobile_ocr.py new file mode 100644 index 0000000000000000000000000000000000000000..4bb72b14eb1b0cf226e505bc9e3b1ecd29ff1723 --- /dev/null +++ b/scripts/prepare_mobile_ocr.py @@ -0,0 +1,268 @@ +from pathlib import Path +import csv +import random +from collections import defaultdict + +# ============================================================ +# PATHS +# ============================================================ + +BASE_DIR = Path(__file__).resolve().parents[1] + +OCR_ROOT = ( + BASE_DIR + / "datasets" + / "ocr" + / "mobile_packages" +) + +OUTPUT_DIR = ( + BASE_DIR + / "datasets" + / "ocr" + / "prepared" +) + +OUTPUT_DIR.mkdir( + parents=True, + exist_ok=True +) + +# ============================================================ +# IMAGE EXTENSIONS +# ============================================================ + +IMAGE_EXTENSIONS = { + ".jpg", + ".jpeg", + ".png", + ".bmp", + ".webp" +} + +# ============================================================ +# FIND IMAGES BY MEDICINE +# ============================================================ + +print("=" * 70) +print("SANJEEVANI OCR DATASET PREPARATION") +print("=" * 70) + +print("\nSearching:") +print(OCR_ROOT) + +medicine_images = defaultdict(list) + +for image_path in OCR_ROOT.rglob("*"): + + if not image_path.is_file(): + continue + + if image_path.suffix.lower() not in IMAGE_EXTENSIONS: + continue + + # Immediate parent folder = medicine/package name + medicine_name = image_path.parent.name.strip() + + if not medicine_name: + continue + + medicine_images[medicine_name].append(image_path) + +print("\nUnique medicine/package classes:") +print(len(medicine_images)) + +print("\nTotal images:") + +total_images = sum( + len(images) + for images in medicine_images.values() +) + +print(total_images) + +# ============================================================ +# CHECK +# ============================================================ + +if total_images == 0: + + print("\nERROR: No images found.") + print("Check the mobile_packages directory.") + + raise SystemExit + +# ============================================================ +# SHUFFLE MEDICINE CLASSES +# ============================================================ + +random.seed(42) + +medicine_names = list( + medicine_images.keys() +) + +random.shuffle(medicine_names) + +# ============================================================ +# SPLIT BY MEDICINE +# ============================================================ + +total_medicines = len(medicine_names) + +train_medicine_end = int( + total_medicines * 0.80 +) + +validation_medicine_end = int( + total_medicines * 0.90 +) + +train_medicines = medicine_names[:train_medicine_end +] + +validation_medicines = medicine_names[ + train_medicine_end:validation_medicine_end +] + +test_medicines = medicine_names[ + validation_medicine_end: +] + +# ============================================================ +# CREATE RECORDS +# ============================================================ + + +def create_records(medicine_list): + + records = [] + + for medicine_name in medicine_list: + + for image_path in medicine_images[ + medicine_name + ]: + + records.append({ + "image": str( + image_path.relative_to(BASE_DIR) + ), + "text": medicine_name + }) + + return records + + +train_records = create_records( + train_medicines +) + +validation_records = create_records( + validation_medicines +) + +test_records = create_records( + test_medicines +) + +# ============================================================ +# SHUFFLE IMAGES WITHIN EACH SPLIT +# ============================================================ + +random.shuffle(train_records) +random.shuffle(validation_records) +random.shuffle(test_records) + +# ============================================================ +# SAVE CSV +# ============================================================ + + +def save_csv(records, filename): + + output_path = OUTPUT_DIR / filename + + with open( + output_path, + "w", + newline="", + encoding="utf-8" + ) as f: + + writer = csv.DictWriter( + f, + fieldnames=[ + "image", + "text" + ] + ) + + writer.writeheader() + + writer.writerows(records) + + print( + f"Saved {filename}: " + f"{len(records)} images" + ) + + +save_csv( + train_records, + "train.csv" +) + +save_csv( + validation_records, + "validation.csv" +) + +save_csv( + test_records, + "test.csv" +) + +# ============================================================ +# SUMMARY +# ============================================================ + +print("\n" + "=" * 70) +print("SPLIT SUMMARY") +print("=" * 70) + +print( + f"Training medicines: {len(train_medicines)}" +) + +print( + f"Validation medicines: {len(validation_medicines)}" +) + +print( + f"Test medicines: {len(test_medicines)}" +) + +print() + +print( + f"Training images: {len(train_records)}" +) + +print( + f"Validation images: {len(validation_records)}" +) + +print( + f"Test images: {len(test_records)}" +) + +print("\nIMPORTANT:") +print( + "The same medicine/package is NOT present " + "across train, validation and test." +) + +print("=" * 70) +print("DONE") +print("=" * 70) diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py new file mode 100644 index 0000000000000000000000000000000000000000..2bded156a03338aadee06e26a815150d80345a25 --- /dev/null +++ b/scripts/seed_demo_data.py @@ -0,0 +1,235 @@ +import asyncio +import json +import sys +from pathlib import Path + +# Add root directory to sys.path +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from backend.app.core.database import AsyncSessionLocal, init_db +from backend.app.core.security import get_password_hash, UserRole +from backend.app.models.user import User +from backend.app.models.profile import PatientProfile +from backend.app.models.document import MedicalDocument, DocumentAnalysis +from backend.app.models.entity import MedicalEntity +from backend.app.models.conversation import AIConversation, AIMessage +from backend.app.models.audit import AuditLog, AnalysisHistory +from sqlalchemy import select + + +async def seed_data(): + print("=" * 60) + print("SANJEEVANI AI -- SYNTHETIC DEMO DATA GENERATOR") + print("NOTICE: All data generated is synthetic and for demonstration only.") + print("=" * 60) + + await init_db() + + async with AsyncSessionLocal() as session: + # 1. Create Demo Patient + patient_email = "demo.patient@sanjeevani.ai" + res = await session.execute(select(User).where(User.email == patient_email)) + demo_patient = res.scalars().first() + + if not demo_patient: + demo_patient = User( + email=patient_email, + hashed_password=get_password_hash("DemoPatient2026!"), + full_name="Alex Mercer (Synthetic Demo Patient)", + role=UserRole.PATIENT.value, + is_active=True, + is_verified=True, + ) + session.add(demo_patient) + await session.commit() + await session.refresh(demo_patient) + + # Profile + profile = PatientProfile( + user_id=demo_patient.id, + age=52, + gender="Male", + blood_group="A+", + height_cm=178.0, + weight_kg=82.5, + known_allergies=json.dumps(["Penicillin", "Sulfa drugs"]), + chronic_conditions=json.dumps(["Type 2 Diabetes Mellitus", "Essential Hypertension", "Mild Hyperlipidemia"]), + current_medications=json.dumps(["Metformin 500mg BID", "Lisinopril 10mg Daily", "Atorvastatin 20mg QHS"]), + emergency_contact="Elena Mercer (Spouse) - +1 (555) 019-4821", + ) + session.add(profile) + await session.commit() + print("[OK] Created synthetic demo patient and clinical profile.") + + # 2. Create Demo Doctor + doctor_email = "demo.doctor@sanjeevani.ai" + res_doc = await session.execute(select(User).where(User.email == doctor_email)) + if not res_doc.scalars().first(): + demo_doctor = User( + email=doctor_email, + hashed_password=get_password_hash("DemoDoctor2026!"), + full_name="Dr. Sarah Jenkins, MD (Cardiologist)", + role=UserRole.DOCTOR.value, + is_active=True, + is_verified=True, + ) + session.add(demo_doctor) + await session.commit() + print("[OK] Created synthetic demo clinician account.") + + # 3. Create Demo Admin + admin_email = "demo.admin@sanjeevani.ai" + res_adm = await session.execute(select(User).where(User.email == admin_email)) + if not res_adm.scalars().first(): + demo_admin = User( + email=admin_email, + hashed_password=get_password_hash("DemoAdmin2026!"), + full_name="Platform Administrator", + role=UserRole.ADMIN.value, + is_active=True, + is_verified=True, + ) + session.add(demo_admin) + await session.commit() + print("[OK] Created demo administrator account.") + + # 4. Create Synthetic Analyzed Documents + res_docs = await session.execute(select(MedicalDocument).where(MedicalDocument.user_id == demo_patient.id)) + if not res_docs.scalars().first(): + sample_text_1 = ( + "CLINICAL COMPREHENSIVE PROGRESS REPORT\n" + "Patient: Alex Mercer | Age: 52 | Sex: M\n" + "Chief Complaint: Routine metabolic follow-up and blood pressure monitoring.\n" + "Clinical Assessment: The patient has well-managed type 2 diabetes mellitus and secondary hypertension.\n" + "Pharmacotherapy: The patient continues metformin 500mg twice daily and lisinopril 10mg daily. " + "Atorvastatin was initiated for moderate hypercholesterolemia.\n" + "Laboratory Review: Fasting blood glucose 118 mg/dL, HbA1c 6.8%, serum creatinine 0.9 mg/dL.\n" + "Recommendation: Maintain dietary glycemic restrictions, routine 30-minute aerobic activity, and repeat HbA1c in 90 days." + ) + + doc1 = MedicalDocument( + user_id=demo_patient.id, + filename="synthetic_metabolic_report_2026.txt", + original_filename="Metabolic_Panel_Report_2026.txt", + file_path="uploads/synthetic_metabolic_report_2026.txt", + file_type="txt", + file_size=len(sample_text_1.encode("utf-8")), + file_hash="e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + status="COMPLETED", + ) + session.add(doc1) + await session.commit() + await session.refresh(doc1) + + analysis1 = DocumentAnalysis( + document_id=doc1.id, + raw_text=sample_text_1, + cleaned_text=sample_text_1, + summary="Routine metabolic evaluation demonstrates controlled type 2 diabetes mellitus and hypertension with stable renal parameters.", + important_findings=json.dumps([ + "Glycemic control is satisfactory (HbA1c 6.8%).", + "Blood pressure therapy is stabilized on lisinopril.", + "Lipid management supported by atorvastatin." + ]), + detected_conditions=json.dumps(["type 2 diabetes mellitus", "hypertension", "hypercholesterolemia"]), + detected_medications=json.dumps(["metformin", "lisinopril", "atorvastatin"]), + clinical_recommendations="Educational decision support: Continue current pharmacotherapy and monitor glycemic trends.", + ) + session.add(analysis1) + await session.commit() + await session.refresh(analysis1) + + # Add entities + entities = [ + MedicalEntity(analysis_id=analysis1.id, text="diabetes mellitus", label="DISEASE", start_offset=180, end_offset=197, confidence=0.9984), + MedicalEntity(analysis_id=analysis1.id, text="hypertension", label="DISEASE", start_offset=212, end_offset=224, confidence=0.9991), + MedicalEntity(analysis_id=analysis1.id, text="hypercholesterolemia", label="DISEASE", start_offset=375, end_offset=395, confidence=0.9856), + MedicalEntity(analysis_id=analysis1.id, text="metformin", label="CHEMICAL", start_offset=266, end_offset=275, confidence=0.9997), + MedicalEntity(analysis_id=analysis1.id, text="lisinopril", label="CHEMICAL", start_offset=307, end_offset=317, confidence=0.9995), + MedicalEntity(analysis_id=analysis1.id, text="atorvastatin", label="CHEMICAL", start_offset=336, end_offset=348, confidence=0.9998), + ] + session.add_all(entities) + await session.commit() + print("[OK] Created synthetic analyzed medical documents and extracted entity records.") + + # 5. Create Synthetic Consultation + res_conv = await session.execute(select(AIConversation).where(AIConversation.user_id == demo_patient.id)) + if not res_conv.scalars().first(): + conv = AIConversation( + user_id=demo_patient.id, + title="Metformin Dosage and Dietary Interaction", + ) + session.add(conv) + await session.commit() + await session.refresh(conv) + + m1 = AIMessage( + conversation_id=conv.id, + role="user", + content="Should metformin be taken with meals, and what are common gastrointestinal effects?", + ) + session.add(m1) + + m2_structured = { + "summary": "Clinical guidance indicates taking metformin with or immediately after meals reduces common gastrointestinal symptoms.", + "possible_considerations": [ + "Metformin biguanide mechanism may cause transient nausea, abdominal cramps, or loose stools during initial therapy or dose titration.", + "Extended-release (XR) formulations often mitigate gastrointestinal side effects compared to immediate-release tablets." + ], + "relevant_medical_info": [ + "Taking metformin with food slows absorption and decreases gastric irritation.", + "Long-term metformin usage is associated with reduced Vitamin B12 absorption; periodic monitoring is recommended." + ], + "questions_for_doctor": [ + "Would switching to an extended-release (XR) formulation be suitable if GI upset persists?", + "Should Vitamin B12 levels or renal panel (eGFR) be scheduled at our next review?" + ], + "safety_warning": "SanjeevaniAI provides decision-support insights. Consult your prescribing physician regarding any medication adjustments.", + "is_emergency": False, + "emergency_instructions": None + } + m2 = AIMessage( + conversation_id=conv.id, + role="assistant", + content=m2_structured["summary"], + structured_data=json.dumps(m2_structured), + model_provider="Google Gemini / Local Decision Support", + ) + session.add(m2) + await session.commit() + print("[OK] Created synthetic medical consultation conversation.") + + # 6. Add History and Audit Logs + session.add(AnalysisHistory( + user_id=demo_patient.id, + action_type="REPORT_ANALYSIS", + description="Uploaded and analyzed 'Metabolic_Panel_Report_2026.txt'", + entity_count=6, + )) + session.add(AnalysisHistory( + user_id=demo_patient.id, + action_type="CHAT", + description="Consulted AI on 'Metformin Dosage and Dietary Interaction'", + entity_count=2, + )) + session.add(AuditLog( + user_id=demo_patient.id, + action="USER_LOGIN", + status="SUCCESS", + details="Demo user authenticated from synthetic session", + )) + await session.commit() + print("[OK] Added synthetic history and audit logs.") + + print("=" * 60) + print("DEMO CREDENTIALS READY FOR MENTOR PRESENTATION:") + print("Patient Login: demo.patient@sanjeevani.ai / DemoPatient2026!") + print("Doctor Login: demo.doctor@sanjeevani.ai / DemoDoctor2026!") + print("Admin Login: demo.admin@sanjeevani.ai / DemoAdmin2026!") + print("=" * 60) + + +if __name__ == "__main__": + asyncio.run(seed_data()) diff --git a/scripts/test_models.py b/scripts/test_models.py new file mode 100644 index 0000000000000000000000000000000000000000..7f329107129fa90f79e01a55303ceeebe0d0864e --- /dev/null +++ b/scripts/test_models.py @@ -0,0 +1,13 @@ +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parents[1] +MODEL_DIR = BASE_DIR / "models" / "pretrained" + +for model_dir in MODEL_DIR.iterdir(): + if model_dir.is_dir(): + files = list(model_dir.rglob("*")) + files = [f for f in files if f.is_file()] + + print(f"{model_dir.name:30} {len(files):5} files") + +print("\nModel directory check completed.") \ No newline at end of file diff --git a/scripts/test_trocr_load.py b/scripts/test_trocr_load.py new file mode 100644 index 0000000000000000000000000000000000000000..ca937ce570fa1432ba3b5f7fbaa91f3e8ed979fe --- /dev/null +++ b/scripts/test_trocr_load.py @@ -0,0 +1,91 @@ +from pathlib import Path + +import torch + +from transformers import ( + TrOCRProcessor, + VisionEncoderDecoderModel, +) + +BASE_DIR = Path(__file__).resolve().parents[1] + +MODEL_PATH = ( + BASE_DIR + / "models" + / "pretrained" + / "trocr" +) + +print("=" * 70) +print("SANJEEVANI - TrOCR LOAD TEST") +print("=" * 70) + +print("Model:", MODEL_PATH) + +print( + "CUDA:", + torch.cuda.is_available() +) + +if torch.cuda.is_available(): + + print( + "GPU:", + torch.cuda.get_device_name(0) + ) + +# ------------------------------------------------------------ +# PROCESSOR +# ------------------------------------------------------------ + +print("\nLoading processor...") + +processor = TrOCRProcessor.from_pretrained( + str(MODEL_PATH) +) + +print("Processor loaded successfully.") + +# ------------------------------------------------------------ +# MODEL +# ------------------------------------------------------------ + +print("\nLoading model...") + +model = VisionEncoderDecoderModel.from_pretrained( + str(MODEL_PATH) +) + +print("Model loaded successfully.") + +# ------------------------------------------------------------ +# GPU +# ------------------------------------------------------------ + +if torch.cuda.is_available(): + + model = model.to("cuda") + + print( + "\nModel moved to GPU successfully." + ) + +# ------------------------------------------------------------ +# SUMMARY +# ------------------------------------------------------------ + +print("\n" + "=" * 70) +print("SUCCESS") +print("=" * 70) + +print("TrOCR processor: OK") +print("TrOCR model: OK") + +if torch.cuda.is_available(): + print("CUDA: OK") + print( + "GPU: " + +torch.cuda.get_device_name(0) + ) + +print("=" * 70) diff --git a/scripts/train_trocr.py b/scripts/train_trocr.py new file mode 100644 index 0000000000000000000000000000000000000000..27d43bb365e725956d731b29f8c283b1f705fd82 --- /dev/null +++ b/scripts/train_trocr.py @@ -0,0 +1,841 @@ +from pathlib import Path +import csv +import math +import os +import random +import time + +import torch +from torch.utils.data import Dataset, DataLoader +from PIL import Image +from jiwer import cer + +from transformers import ( + TrOCRProcessor, + VisionEncoderDecoderModel, +) + +# ============================================================ +# CONFIGURATION +# ============================================================ + +BASE_DIR = Path(__file__).resolve().parents[1] + +MODEL_PATH = ( + BASE_DIR + / "models" + / "pretrained" + / "trocr" +) + +DATASET_DIR = ( + BASE_DIR + / "datasets" + / "ocr" + / "prepared" +) + +TRAIN_CSV = DATASET_DIR / "train.csv" +VAL_CSV = DATASET_DIR / "validation.csv" +TEST_CSV = DATASET_DIR / "test.csv" + +OUTPUT_DIR = ( + BASE_DIR + / "models" + / "trained" + / "trocr_sanjeevani" +) + +CHECKPOINT_DIR = ( + BASE_DIR + / "checkpoints" + / "trocr" +) + +# ============================================================ +# TRAINING SETTINGS +# ============================================================ + +BATCH_SIZE = 1 + +GRADIENT_ACCUMULATION_STEPS = 8 + +EPOCHS = 3 + +LEARNING_RATE = 5e-5 + +WEIGHT_DECAY = 0.01 + +MAX_TEXT_LENGTH = 64 + +NUM_WORKERS = 0 + +SEED = 42 + +# Start with encoder frozen because the RTX 3050 has 8 GB VRAM. +FREEZE_ENCODER = True + +# ============================================================ +# REPRODUCIBILITY +# ============================================================ + +random.seed(SEED) +torch.manual_seed(SEED) + +if torch.cuda.is_available(): + torch.cuda.manual_seed_all(SEED) + +# ============================================================ +# DEVICE +# ============================================================ + +DEVICE = torch.device( + "cuda" + if torch.cuda.is_available() + else "cpu" +) + +print("=" * 75) +print("SANJEEVANI AI - TrOCR FINE-TUNING") +print("=" * 75) + +print("Device:", DEVICE) + +if torch.cuda.is_available(): + + print( + "GPU:", + torch.cuda.get_device_name(0) + ) + + print( + "VRAM:", + round( + torch.cuda.get_device_properties(0).total_memory + / (1024 ** 3), + 2 + ), + "GB" + ) + +# ============================================================ +# CHECK FILES +# ============================================================ + +for path in [ + MODEL_PATH, + TRAIN_CSV, + VAL_CSV, + TEST_CSV, +]: + + if not path.exists(): + + raise FileNotFoundError( + f"Required file/folder not found:\n{path}" + ) + +OUTPUT_DIR.mkdir( + parents=True, + exist_ok=True +) + +CHECKPOINT_DIR.mkdir( + parents=True, + exist_ok=True +) + +# ============================================================ +# CSV DATASET +# ============================================================ + + +class OCRDataset(Dataset): + + def __init__( + self, + csv_path, + processor, + ): + + self.processor = processor + + self.records = [] + + with open( + csv_path, + "r", + encoding="utf-8" + ) as f: + + reader = csv.DictReader(f) + + for row in reader: + + self.records.append({ + "image": row["image"], + "text": row["text"], + }) + + print( + f"Loaded {len(self.records)} samples from " + f"{csv_path.name}" + ) + + def __len__(self): + + return len(self.records) + + def __getitem__(self, index): + + record = self.records[index] + + image_path = ( + BASE_DIR / record["image"] + ) + + image = Image.open( + image_path + ).convert("RGB") + + # ---------------------------------------------------- + # IMAGE PROCESSING + # ---------------------------------------------------- + + pixel_values = self.processor( + images=image, + return_tensors="pt" + ).pixel_values.squeeze(0) + + # ---------------------------------------------------- + # TEXT TOKENIZATION + # ---------------------------------------------------- + + labels = self.processor.tokenizer( + record["text"], + padding="max_length", + max_length=MAX_TEXT_LENGTH, + truncation=True, + return_tensors="pt" + ).input_ids.squeeze(0) + + # ---------------------------------------------------- + # IGNORE PAD TOKENS IN LOSS + # ---------------------------------------------------- + + labels[ + labels + == self.processor.tokenizer.pad_token_id + ] = -100 + + return { + "pixel_values": pixel_values, + "labels": labels, + } + +# ============================================================ +# LOAD PROCESSOR +# ============================================================ + + +print("\nLoading TrOCR processor...") + +processor = TrOCRProcessor.from_pretrained( + str(MODEL_PATH) +) + +# ============================================================ +# LOAD MODEL +# ============================================================ + +print("Loading TrOCR model...") + +model = VisionEncoderDecoderModel.from_pretrained( + str(MODEL_PATH) +) + +# ============================================================ +# MODEL CONFIGURATION +# ============================================================ + +tokenizer = processor.tokenizer + +model.config.pad_token_id = ( + tokenizer.pad_token_id +) + +model.config.vocab_size = ( + model.config.decoder.vocab_size +) + +# Set decoder start token if missing +if model.config.decoder_start_token_id is None: + + if tokenizer.cls_token_id is not None: + + model.config.decoder_start_token_id = ( + tokenizer.cls_token_id + ) + + elif tokenizer.bos_token_id is not None: + + model.config.decoder_start_token_id = ( + tokenizer.bos_token_id + ) + + else: + + model.config.decoder_start_token_id = ( + tokenizer.eos_token_id + ) + +# Generation configuration + +model.generation_config.max_length = MAX_TEXT_LENGTH + +model.generation_config.num_beams = 2 + +model.generation_config.early_stopping = True + +# ============================================================ +# FREEZE ENCODER +# ============================================================ + +if FREEZE_ENCODER: + + print("\nFreezing vision encoder...") + + for parameter in model.encoder.parameters(): + + parameter.requires_grad = False + +else: + + print("\nVision encoder will be trained.") + +# ============================================================ +# GRADIENT CHECKPOINTING +# ============================================================ + +try: + + model.gradient_checkpointing_enable() + + print("Gradient checkpointing: ENABLED") + +except Exception: + + print( + "Gradient checkpointing unavailable." + ) + +model.to(DEVICE) + +# ============================================================ +# DATASETS +# ============================================================ + +print("\nPreparing datasets...") + +train_dataset = OCRDataset( + TRAIN_CSV, + processor +) + +val_dataset = OCRDataset( + VAL_CSV, + processor +) + +test_dataset = OCRDataset( + TEST_CSV, + processor +) + +# ============================================================ +# DATALOADERS +# ============================================================ + +train_loader = DataLoader( + train_dataset, + batch_size=BATCH_SIZE, + shuffle=True, + num_workers=NUM_WORKERS, + pin_memory=torch.cuda.is_available(), +) + +val_loader = DataLoader( + val_dataset, + batch_size=BATCH_SIZE, + shuffle=False, + num_workers=NUM_WORKERS, + pin_memory=torch.cuda.is_available(), +) + +test_loader = DataLoader( + test_dataset, + batch_size=BATCH_SIZE, + shuffle=False, + num_workers=NUM_WORKERS, + pin_memory=torch.cuda.is_available(), +) + +# ============================================================ +# OPTIMIZER +# ============================================================ + +trainable_parameters = [ + parameter + for parameter in model.parameters() + if parameter.requires_grad +] + +print( + "\nTrainable parameters:", + sum( + p.numel() + for p in trainable_parameters + ) +) + +optimizer = torch.optim.AdamW( + trainable_parameters, + lr=LEARNING_RATE, + weight_decay=WEIGHT_DECAY, +) + +# ============================================================ +# MIXED PRECISION +# ============================================================ + +use_amp = DEVICE.type == "cuda" + +scaler = torch.amp.GradScaler( + "cuda", + enabled=use_amp +) + +# ============================================================ +# VALIDATION +# ============================================================ + + +def evaluate(model, loader): + + model.eval() + + total_loss = 0.0 + + references = [] + + predictions = [] + + with torch.no_grad(): + + for batch in loader: + + pixel_values = ( + batch["pixel_values"] + .to(DEVICE) + ) + + labels = ( + batch["labels"] + .to(DEVICE) + ) + + # ---------------------------------------------- + # LOSS + # ---------------------------------------------- + + with torch.autocast( + device_type="cuda", + dtype=torch.float16, + enabled=use_amp + ): + + outputs = model( + pixel_values=pixel_values, + labels=labels + ) + + total_loss += ( + outputs.loss.item() + ) + + # ---------------------------------------------- + # GENERATION + # ---------------------------------------------- + + generated_ids = model.generate( + pixel_values, + max_length=MAX_TEXT_LENGTH, + num_beams=2 + ) + + predicted_text = ( + processor.batch_decode( + generated_ids, + skip_special_tokens=True + ) + ) + + # Restore padding for decoding + labels_for_decode = labels.clone() + + labels_for_decode[ + labels_for_decode == -100 + ] = tokenizer.pad_token_id + + reference_text = ( + processor.batch_decode( + labels_for_decode, + skip_special_tokens=True + ) + ) + + predictions.extend( + predicted_text + ) + + references.extend( + reference_text + ) + + average_loss = ( + total_loss / len(loader) + ) + + score = cer( + references, + predictions + ) + + return average_loss, score + +# ============================================================ +# SAVE MODEL +# ============================================================ + + +def save_model( + model, + processor, + output_path +): + + output_path.mkdir( + parents=True, + exist_ok=True + ) + + print( + f"\nSaving model to:\n{output_path}" + ) + + model.save_pretrained( + str(output_path) + ) + + processor.save_pretrained( + str(output_path) + ) + + print("Model saved successfully.") + +# ============================================================ +# TRAINING +# ============================================================ + + +print("\n" + "=" * 75) +print("STARTING TRAINING") +print("=" * 75) + +print( + f"Epochs: {EPOCHS}" +) + +print( + f"Batch size: {BATCH_SIZE}" +) + +print( + f"Gradient accumulation: " + f"{GRADIENT_ACCUMULATION_STEPS}" +) + +print( + f"Effective batch size: " + f"{BATCH_SIZE * GRADIENT_ACCUMULATION_STEPS}" +) + +print( + f"Learning rate: {LEARNING_RATE}" +) + +best_val_cer = float("inf") + +for epoch in range(EPOCHS): + + print( + "\n" + +"=" * 75 + ) + + print( + f"EPOCH {epoch + 1}/{EPOCHS}" + ) + + print( + "=" * 75 + ) + + model.train() + + optimizer.zero_grad( + set_to_none=True + ) + + running_loss = 0.0 + + start_time = time.time() + + for step, batch in enumerate( + train_loader + ): + + pixel_values = ( + batch["pixel_values"] + .to( + DEVICE, + non_blocking=True + ) + ) + + labels = ( + batch["labels"] + .to( + DEVICE, + non_blocking=True + ) + ) + + # ---------------------------------------------------- + # FORWARD PASS + # ---------------------------------------------------- + + with torch.autocast( + device_type="cuda", + dtype=torch.float16, + enabled=use_amp + ): + + outputs = model( + pixel_values=pixel_values, + labels=labels + ) + + loss = outputs.loss + + loss = ( + loss + / GRADIENT_ACCUMULATION_STEPS + ) + + # ---------------------------------------------------- + # BACKPROPAGATION + # ---------------------------------------------------- + + scaler.scale( + loss + ).backward() + + running_loss += ( + loss.item() + * GRADIENT_ACCUMULATION_STEPS + ) + + # ---------------------------------------------------- + # OPTIMIZER STEP + # ---------------------------------------------------- + + if ( + (step + 1) + % GRADIENT_ACCUMULATION_STEPS + == 0 + ): + + scaler.step( + optimizer + ) + + scaler.update() + + optimizer.zero_grad( + set_to_none=True + ) + + # ---------------------------------------------------- + # PROGRESS + # ---------------------------------------------------- + + if ( + (step + 1) % 50 == 0 + or step == 0 + ): + + elapsed = ( + time.time() + -start_time + ) + + avg_loss = ( + running_loss + / (step + 1) + ) + + print( + f"Step " + f"{step + 1}/" + f"{len(train_loader)} " + f"| Loss: {avg_loss:.4f} " + f"| Time: {elapsed / 60:.1f} min" + ) + + # ======================================================== + # VALIDATION + # ======================================================== + + print("\nRunning validation...") + + val_loss, val_cer = evaluate( + model, + val_loader + ) + + train_loss = ( + running_loss + / len(train_loader) + ) + + print( + f"\nEpoch {epoch + 1} results:" + ) + + print( + f"Training Loss: {train_loss:.4f}" + ) + + print( + f"Validation Loss: {val_loss:.4f}" + ) + + print( + f"Validation CER: {val_cer:.4f}" + ) + + # ======================================================== + # SAVE CHECKPOINT + # ======================================================== + + checkpoint_path = ( + CHECKPOINT_DIR + / f"epoch_{epoch + 1}" + ) + + save_model( + model, + processor, + checkpoint_path + ) + + # ======================================================== + # BEST MODEL + # ======================================================== + + if val_cer < best_val_cer: + + best_val_cer = val_cer + + print( + "\nNew best model!" + ) + + save_model( + model, + processor, + OUTPUT_DIR + ) + +# ============================================================ +# FINAL TEST +# ============================================================ + +print("\n" + "=" * 75) +print("FINAL TEST") +print("=" * 75) + +test_loss, test_cer = evaluate( + model, + test_loader +) + +print( + f"Test Loss: {test_loss:.4f}" +) + +print( + f"Test CER: {test_cer:.4f}" +) + +# ============================================================ +# FINAL SAVE +# ============================================================ + +save_model( + model, + processor, + OUTPUT_DIR +) + +# ============================================================ +# FINISHED +# ============================================================ + +print("\n" + "=" * 75) +print("TRAINING COMPLETE") +print("=" * 75) + +print( + "\nBest validation CER:", + best_val_cer +) + +print( + "\nFinal model:" +) + +print( + OUTPUT_DIR +) + +print( + "\nYou can load this model later using:" +) + +print( + "TrOCRProcessor.from_pretrained(" +) + +print( + f' r"{OUTPUT_DIR}"' +) + +print( + ")" +) + +print( + "\nTraining finished successfully." +) diff --git a/sw.js b/sw.js deleted file mode 100644 index f7d3b5e8c0e6720e3d576bed8756a295ac87abc6..0000000000000000000000000000000000000000 --- a/sw.js +++ /dev/null @@ -1,37 +0,0 @@ -const CACHE_NAME = "sanjeevani-ai-v1"; -const ASSETS = [ - "./", - "./index.html", - "./css/style.css", - "./js/i18n.js", - "./js/interactionEngine.js", - "./js/app.js", - "./data/drugs.js", - "./data/interactions.js", - "./manifest.json" -]; - -self.addEventListener("install", (event) => { - event.waitUntil( - caches.open(CACHE_NAME).then((cache) => cache.addAll(ASSETS)).catch(() => {}) - ); - self.skipWaiting(); -}); - -self.addEventListener("activate", (event) => { - event.waitUntil( - caches.keys().then((keys) => - Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))) - ) - ); - self.clients.claim(); -}); - -self.addEventListener("fetch", (event) => { - // Network-first for CDN (OCR library), cache-first for local app shell/data. - const url = new URL(event.request.url); - if (url.origin !== self.location.origin) return; - event.respondWith( - caches.match(event.request).then((cached) => cached || fetch(event.request)) - ); -}); diff --git a/uploads/.gitkeep b/uploads/.gitkeep new file mode 100644 index 0000000000000000000000000000000000000000..a9b155fa6ed51e1e7176bb3f52be40d2f20f2c7c --- /dev/null +++ b/uploads/.gitkeep @@ -0,0 +1 @@ +# Uploads Directory Placeholder