Spaces:
Running
Running
ReportRaahat CI commited on
Commit ·
542c765
1
Parent(s): 4df1136
Deploy from GitHub: cbc36259c5ce4062cd4e64b876308f9378e3ebe2
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +14 -0
- .gitattributes +3 -35
- .github/workflows/deploy-hf.yml +53 -0
- .gitignore +25 -0
- Dockerfile +48 -0
- HF_README.md +9 -0
- README.md +4 -6
- backend/.env.example +5 -0
- backend/Dockerfile +19 -0
- backend/app/__init__.py +0 -0
- backend/app/main.py +74 -0
- backend/app/ml/__init__.py +0 -0
- backend/app/ml/enhanced_chat.py +342 -0
- backend/app/ml/model.py +70 -0
- backend/app/ml/openrouter.py +133 -0
- backend/app/ml/rag.py +155 -0
- backend/app/mock_data.py +163 -0
- backend/app/routers/__init__.py +0 -0
- backend/app/routers/analyze.py +343 -0
- backend/app/routers/chat.py +15 -0
- backend/app/routers/doctor_upload.py +127 -0
- backend/app/routers/exercise.py +32 -0
- backend/app/routers/nutrition.py +333 -0
- backend/app/schemas.py +104 -0
- backend/chat_with_doctor.py +104 -0
- backend/demo_dialogue.py +122 -0
- backend/doctor_workflow.py +139 -0
- backend/human_upload.py +76 -0
- backend/requirements-local.txt +14 -0
- backend/requirements.txt +30 -0
- backend/test_enhanced_chat.py +151 -0
- backend/test_full_pipeline.py +101 -0
- backend/test_pipeline_demo.py +96 -0
- backend/test_schema_to_text.py +83 -0
- backend/upload_pdf.py +40 -0
- frontend/.env.local.example +8 -0
- frontend/app/api/analyze-report/route.ts +93 -0
- frontend/app/api/chat/route.ts +50 -0
- frontend/app/avatar/page.tsx +143 -0
- frontend/app/dashboard/page.tsx +363 -0
- frontend/app/exercise/page.tsx +298 -0
- frontend/app/globals.css +109 -0
- frontend/app/layout.tsx +94 -0
- frontend/app/nutrition/page.tsx +345 -0
- frontend/app/page.tsx +350 -0
- frontend/app/wellness/page.tsx +284 -0
- frontend/components/AvatarPanel.tsx +100 -0
- frontend/components/BadgeGrid.tsx +18 -0
- frontend/components/BentoGrid.tsx +7 -0
- frontend/components/BodyMap.tsx +108 -0
.dockerignore
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules
|
| 2 |
+
.next
|
| 3 |
+
__pycache__
|
| 4 |
+
*.pyc
|
| 5 |
+
venv
|
| 6 |
+
.env
|
| 7 |
+
.git
|
| 8 |
+
.github
|
| 9 |
+
*.md
|
| 10 |
+
!HF_README.md
|
| 11 |
+
tsc_errors*.txt
|
| 12 |
+
pip_output.txt
|
| 13 |
+
build_output.txt
|
| 14 |
+
.gemini
|
.gitattributes
CHANGED
|
@@ -1,35 +1,3 @@
|
|
| 1 |
-
|
| 2 |
-
*.
|
| 3 |
-
*.
|
| 4 |
-
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
-
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
-
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
-
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
-
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
-
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
-
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
-
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
-
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
-
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
-
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
-
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
-
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
-
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
-
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
-
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
-
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
-
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
-
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
-
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
-
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
-
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
-
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
-
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
-
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
-
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
-
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
-
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
-
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
-
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
-
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
-
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 1 |
+
# Exclude generated and lock files from language statistics
|
| 2 |
+
*.lock linguist:ignore
|
| 3 |
+
*.generated linguist:ignore
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.github/workflows/deploy-hf.yml
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Deploy to Hugging Face Spaces
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches: [main]
|
| 6 |
+
workflow_dispatch:
|
| 7 |
+
|
| 8 |
+
jobs:
|
| 9 |
+
deploy:
|
| 10 |
+
runs-on: ubuntu-latest
|
| 11 |
+
steps:
|
| 12 |
+
- name: Checkout
|
| 13 |
+
uses: actions/checkout@v4
|
| 14 |
+
|
| 15 |
+
- name: Push to Hugging Face Spaces
|
| 16 |
+
env:
|
| 17 |
+
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
| 18 |
+
HF_SPACE: ${{ vars.HF_SPACE_ID || 'CaffeinatedCoding/ReportRaahat' }}
|
| 19 |
+
run: |
|
| 20 |
+
# Configure git
|
| 21 |
+
git config --global user.email "ci@reportraahat.app"
|
| 22 |
+
git config --global user.name "ReportRaahat CI"
|
| 23 |
+
|
| 24 |
+
# Build the authenticated URL
|
| 25 |
+
HF_URL="https://oauth2:${HF_TOKEN}@huggingface.co/spaces/${HF_SPACE}"
|
| 26 |
+
|
| 27 |
+
# Clone the HF Space repo (or create if doesn't exist)
|
| 28 |
+
git clone "$HF_URL" hf-space --depth 1 || mkdir hf-space
|
| 29 |
+
|
| 30 |
+
# Sync files to the HF Space
|
| 31 |
+
rsync -av --delete \
|
| 32 |
+
--exclude '.git' \
|
| 33 |
+
--exclude 'node_modules' \
|
| 34 |
+
--exclude '__pycache__' \
|
| 35 |
+
--exclude '.next' \
|
| 36 |
+
--exclude '.env' \
|
| 37 |
+
--exclude 'venv' \
|
| 38 |
+
--exclude 'hf-space' \
|
| 39 |
+
--exclude 'tsc_errors*' \
|
| 40 |
+
--exclude 'pip_output*' \
|
| 41 |
+
--exclude 'build_output*' \
|
| 42 |
+
--exclude 'dump.txt' \
|
| 43 |
+
./ hf-space/
|
| 44 |
+
|
| 45 |
+
# Use the HF Spaces README (with metadata)
|
| 46 |
+
cp HF_README.md hf-space/README.md
|
| 47 |
+
|
| 48 |
+
# Push to HF
|
| 49 |
+
cd hf-space
|
| 50 |
+
git add -A
|
| 51 |
+
git diff --cached --quiet && echo "No changes" && exit 0
|
| 52 |
+
git commit -m "Deploy from GitHub: ${{ github.sha }}"
|
| 53 |
+
git push "$HF_URL" main
|
.gitignore
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
venv/
|
| 5 |
+
.env
|
| 6 |
+
*.db
|
| 7 |
+
*.faiss
|
| 8 |
+
*.pt
|
| 9 |
+
*.bin
|
| 10 |
+
|
| 11 |
+
# Node
|
| 12 |
+
node_modules/
|
| 13 |
+
.next/
|
| 14 |
+
dist/
|
| 15 |
+
.env.local
|
| 16 |
+
package-lock.json
|
| 17 |
+
yarn.lock
|
| 18 |
+
|
| 19 |
+
# IDE
|
| 20 |
+
.vscode/
|
| 21 |
+
.idea/
|
| 22 |
+
.DS_Store
|
| 23 |
+
|
| 24 |
+
# Notebooks output
|
| 25 |
+
notebooks/.ipynb_checkpoints/
|
Dockerfile
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ── Stage 1: Build Next.js frontend ──────────────────────────
|
| 2 |
+
FROM node:20-slim AS frontend-builder
|
| 3 |
+
|
| 4 |
+
WORKDIR /build/frontend
|
| 5 |
+
COPY frontend/package*.json ./
|
| 6 |
+
RUN npm ci
|
| 7 |
+
|
| 8 |
+
COPY frontend/ ./
|
| 9 |
+
ENV NEXT_PUBLIC_API_URL=""
|
| 10 |
+
RUN npm run build
|
| 11 |
+
|
| 12 |
+
# ── Stage 2: Runtime ─────────────────────────────────────────
|
| 13 |
+
FROM python:3.11-slim
|
| 14 |
+
|
| 15 |
+
# Install Node.js 20, nginx, supervisor
|
| 16 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 17 |
+
curl gnupg nginx supervisor && \
|
| 18 |
+
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
|
| 19 |
+
apt-get install -y --no-install-recommends nodejs && \
|
| 20 |
+
apt-get clean && rm -rf /var/lib/apt/lists/*
|
| 21 |
+
|
| 22 |
+
WORKDIR /app
|
| 23 |
+
|
| 24 |
+
# ── Backend deps ──
|
| 25 |
+
COPY backend/requirements-local.txt ./backend/
|
| 26 |
+
RUN pip install --no-cache-dir -r backend/requirements-local.txt
|
| 27 |
+
|
| 28 |
+
# ── Backend source ──
|
| 29 |
+
COPY backend/ ./backend/
|
| 30 |
+
|
| 31 |
+
# ── Frontend standalone build ──
|
| 32 |
+
COPY --from=frontend-builder /build/frontend/.next/standalone ./frontend/
|
| 33 |
+
COPY --from=frontend-builder /build/frontend/.next/static ./frontend/.next/static
|
| 34 |
+
COPY --from=frontend-builder /build/frontend/public ./frontend/public
|
| 35 |
+
|
| 36 |
+
# ── Config files ──
|
| 37 |
+
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
| 38 |
+
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
| 39 |
+
|
| 40 |
+
# Remove default nginx site
|
| 41 |
+
RUN rm -f /etc/nginx/sites-enabled/default
|
| 42 |
+
|
| 43 |
+
# Copy .env for backend (will be overridden by HF secrets)
|
| 44 |
+
COPY backend/.env.example ./backend/.env
|
| 45 |
+
|
| 46 |
+
EXPOSE 7860
|
| 47 |
+
|
| 48 |
+
CMD ["supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
HF_README.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: ReportRaahat
|
| 3 |
+
emoji: 🏥
|
| 4 |
+
colorFrom: yellow
|
| 5 |
+
colorTo: yellow
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
README.md
CHANGED
|
@@ -1,11 +1,9 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: yellow
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
-
license: other
|
| 9 |
---
|
| 10 |
-
|
| 11 |
-
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
|
|
|
| 1 |
---
|
| 2 |
+
title: ReportRaahat
|
| 3 |
+
emoji: 🏥
|
| 4 |
colorFrom: yellow
|
| 5 |
+
colorTo: yellow
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
pinned: false
|
|
|
|
| 9 |
---
|
|
|
|
|
|
backend/.env.example
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
OPENROUTER_API_KEY=sk-or-v1-your-key-here
|
| 2 |
+
HF_TOKEN=hf_your-token-here
|
| 3 |
+
HF_MODEL_ID=CaffeinatedCoding/reportraahat-simplifier
|
| 4 |
+
HF_INDEX_REPO=CaffeinatedCoding/reportraahat-indexes
|
| 5 |
+
NEXT_PUBLIC_API_URL=http://localhost:8000
|
backend/Dockerfile
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# Install tesseract for OCR
|
| 6 |
+
RUN apt-get update && apt-get install -y \
|
| 7 |
+
tesseract-ocr \
|
| 8 |
+
tesseract-ocr-hin \
|
| 9 |
+
libgl1-mesa-glx \
|
| 10 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 11 |
+
|
| 12 |
+
COPY requirements.txt .
|
| 13 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 14 |
+
|
| 15 |
+
COPY . .
|
| 16 |
+
|
| 17 |
+
EXPOSE 7860
|
| 18 |
+
|
| 19 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
backend/app/__init__.py
ADDED
|
File without changes
|
backend/app/main.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# main.py — MERGED VERSION
|
| 2 |
+
# Source backend routers (analyze, chat, doctor_upload) + scaffold routers (nutrition, exercise)
|
| 3 |
+
|
| 4 |
+
from fastapi import FastAPI
|
| 5 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 6 |
+
from contextlib import asynccontextmanager
|
| 7 |
+
|
| 8 |
+
from app.routers import analyze, chat, doctor_upload, nutrition, exercise
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@asynccontextmanager
|
| 12 |
+
async def lifespan(app: FastAPI):
|
| 13 |
+
# Load ML models on startup (if available)
|
| 14 |
+
print("Starting ReportRaahat backend...")
|
| 15 |
+
try:
|
| 16 |
+
from app.ml.model import load_model
|
| 17 |
+
load_model()
|
| 18 |
+
print("Model loading complete.")
|
| 19 |
+
except Exception as e:
|
| 20 |
+
print(f"Startup info — models not fully loaded: {e}")
|
| 21 |
+
print("Mock endpoints will work for testing.")
|
| 22 |
+
yield
|
| 23 |
+
print("Shutting down ReportRaahat backend.")
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
app = FastAPI(
|
| 27 |
+
title="ReportRaahat API",
|
| 28 |
+
description="AI-powered medical report simplifier for rural India",
|
| 29 |
+
version="2.0.0",
|
| 30 |
+
lifespan=lifespan
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
app.add_middleware(
|
| 34 |
+
CORSMiddleware,
|
| 35 |
+
allow_origins=[
|
| 36 |
+
"http://localhost:3000",
|
| 37 |
+
"https://reportraahat.vercel.app",
|
| 38 |
+
"https://*.vercel.app",
|
| 39 |
+
],
|
| 40 |
+
allow_credentials=True,
|
| 41 |
+
allow_methods=["*"],
|
| 42 |
+
allow_headers=["*"],
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
# ML teammate's routes
|
| 46 |
+
app.include_router(analyze.router, tags=["Report Analysis"])
|
| 47 |
+
app.include_router(chat.router, tags=["Doctor Chat"])
|
| 48 |
+
app.include_router(doctor_upload.router, tags=["Human Dialogue"])
|
| 49 |
+
|
| 50 |
+
# Member 4's routes
|
| 51 |
+
app.include_router(nutrition.router, prefix="/nutrition", tags=["Nutrition"])
|
| 52 |
+
app.include_router(exercise.router, prefix="/exercise", tags=["Exercise"])
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@app.get("/")
|
| 56 |
+
async def root():
|
| 57 |
+
return {
|
| 58 |
+
"name": "ReportRaahat API",
|
| 59 |
+
"version": "2.0.0",
|
| 60 |
+
"status": "running",
|
| 61 |
+
"endpoints": {
|
| 62 |
+
"analyze": "POST /analyze",
|
| 63 |
+
"upload_and_chat": "POST /upload_and_chat (RECOMMENDED - starts dialogue immediately)",
|
| 64 |
+
"chat": "POST /chat",
|
| 65 |
+
"nutrition": "POST /nutrition",
|
| 66 |
+
"exercise": "POST /exercise",
|
| 67 |
+
"docs": "/docs"
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
@app.get("/health")
|
| 73 |
+
async def health():
|
| 74 |
+
return {"status": "healthy"}
|
backend/app/ml/__init__.py
ADDED
|
File without changes
|
backend/app/ml/enhanced_chat.py
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Enhanced Doctor Chat with RAG from Hugging Face
|
| 3 |
+
Uses your dataset + FAISS index for grounded, factual responses
|
| 4 |
+
"""
|
| 5 |
+
import sys
|
| 6 |
+
import os
|
| 7 |
+
|
| 8 |
+
# Fix Unicode encoding for Windows console
|
| 9 |
+
if sys.platform == 'win32':
|
| 10 |
+
os.environ['PYTHONIOENCODING'] = 'utf-8'
|
| 11 |
+
|
| 12 |
+
try:
|
| 13 |
+
from huggingface_hub import hf_hub_download, list_repo_files
|
| 14 |
+
HAS_HF = True
|
| 15 |
+
except ImportError:
|
| 16 |
+
HAS_HF = False
|
| 17 |
+
print("⚠️ huggingface_hub not installed — RAG disabled, mock responses only")
|
| 18 |
+
|
| 19 |
+
try:
|
| 20 |
+
import faiss
|
| 21 |
+
HAS_FAISS = True
|
| 22 |
+
except ImportError:
|
| 23 |
+
HAS_FAISS = False
|
| 24 |
+
print("⚠️ faiss-cpu not installed — RAG disabled, mock responses only")
|
| 25 |
+
|
| 26 |
+
import numpy as np
|
| 27 |
+
from typing import Optional
|
| 28 |
+
import json
|
| 29 |
+
from dotenv import load_dotenv
|
| 30 |
+
|
| 31 |
+
# Load environment variables
|
| 32 |
+
load_dotenv()
|
| 33 |
+
|
| 34 |
+
HF_REPO = os.getenv("HF_INDEX_REPO", "CaffeinatedCoding/reportraahat-indexes")
|
| 35 |
+
HF_TOKEN = os.getenv("HF_TOKEN", "")
|
| 36 |
+
HF_USER = "CaffeinatedCoding"
|
| 37 |
+
|
| 38 |
+
class RAGDocumentRetriever:
|
| 39 |
+
"""Retrieve relevant documents from HF using FAISS."""
|
| 40 |
+
|
| 41 |
+
def __init__(self):
|
| 42 |
+
self.index = None
|
| 43 |
+
self.documents = []
|
| 44 |
+
self.embeddings_model = None
|
| 45 |
+
self.loaded = False
|
| 46 |
+
self._load_from_hf()
|
| 47 |
+
|
| 48 |
+
def _load_from_hf(self):
|
| 49 |
+
"""Download and load FAISS index + documents from HF."""
|
| 50 |
+
if not HAS_HF or not HAS_FAISS:
|
| 51 |
+
print("⚠️ Skipping RAG loading (missing dependencies)")
|
| 52 |
+
self.loaded = False
|
| 53 |
+
return
|
| 54 |
+
try:
|
| 55 |
+
print("📥 Loading FAISS index from HF...")
|
| 56 |
+
|
| 57 |
+
# First, list all files in the repo to see what's available
|
| 58 |
+
try:
|
| 59 |
+
print(f" Checking files in {HF_REPO}...")
|
| 60 |
+
files = list_repo_files(
|
| 61 |
+
repo_id=HF_REPO,
|
| 62 |
+
repo_type="dataset",
|
| 63 |
+
token=HF_TOKEN
|
| 64 |
+
)
|
| 65 |
+
print(f" Available files: {files}")
|
| 66 |
+
except Exception as e:
|
| 67 |
+
print(f" ⚠️ Could not list files: {e}")
|
| 68 |
+
|
| 69 |
+
# Try downloading FAISS index with token
|
| 70 |
+
try:
|
| 71 |
+
index_path = hf_hub_download(
|
| 72 |
+
repo_id=HF_REPO,
|
| 73 |
+
filename="index.faiss",
|
| 74 |
+
repo_type="dataset",
|
| 75 |
+
token=HF_TOKEN
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
# Load FAISS index
|
| 79 |
+
self.index = faiss.read_index(index_path)
|
| 80 |
+
print("✅ FAISS index loaded")
|
| 81 |
+
except Exception as e:
|
| 82 |
+
print(f" ⚠️ Could not load index.faiss: {e}")
|
| 83 |
+
print(" Trying alternative names...")
|
| 84 |
+
# Try alternative names
|
| 85 |
+
for alt_name in ["faiss.index", "knn.index", "vec.index", "index"]:
|
| 86 |
+
try:
|
| 87 |
+
index_path = hf_hub_download(
|
| 88 |
+
repo_id=HF_REPO,
|
| 89 |
+
filename=alt_name,
|
| 90 |
+
repo_type="dataset",
|
| 91 |
+
token=HF_TOKEN
|
| 92 |
+
)
|
| 93 |
+
self.index = faiss.read_index(index_path)
|
| 94 |
+
print(f"✅ FAISS index loaded from {alt_name}")
|
| 95 |
+
break
|
| 96 |
+
except:
|
| 97 |
+
pass
|
| 98 |
+
|
| 99 |
+
# Download documents metadata
|
| 100 |
+
try:
|
| 101 |
+
docs_path = hf_hub_download(
|
| 102 |
+
repo_id=HF_REPO,
|
| 103 |
+
filename="documents.json",
|
| 104 |
+
repo_type="dataset",
|
| 105 |
+
token=HF_TOKEN
|
| 106 |
+
)
|
| 107 |
+
with open(docs_path, 'r', encoding='utf-8') as f:
|
| 108 |
+
self.documents = json.load(f)
|
| 109 |
+
print(f"✅ Loaded {len(self.documents)} documents")
|
| 110 |
+
except Exception as e:
|
| 111 |
+
print(f" ⚠️ Could not load documents.json: {e}")
|
| 112 |
+
# Try alternative document formats
|
| 113 |
+
for alt_doc in ["documents.parquet", "docs.json", "embeddings.json"]:
|
| 114 |
+
try:
|
| 115 |
+
docs_path = hf_hub_download(
|
| 116 |
+
repo_id=HF_REPO,
|
| 117 |
+
filename=alt_doc,
|
| 118 |
+
repo_type="dataset",
|
| 119 |
+
token=HF_TOKEN
|
| 120 |
+
)
|
| 121 |
+
if alt_doc.endswith('.json'):
|
| 122 |
+
with open(docs_path, 'r', encoding='utf-8') as f:
|
| 123 |
+
self.documents = json.load(f)
|
| 124 |
+
print(f"✅ Loaded documents from {alt_doc}")
|
| 125 |
+
break
|
| 126 |
+
except:
|
| 127 |
+
pass
|
| 128 |
+
|
| 129 |
+
self.loaded = True if self.index is not None else False
|
| 130 |
+
|
| 131 |
+
except Exception as e:
|
| 132 |
+
print(f"⚠️ Could not load RAG from HF: {e}")
|
| 133 |
+
self.loaded = False
|
| 134 |
+
|
| 135 |
+
def retrieve(self, query_embedding: list, k: int = 3) -> list:
|
| 136 |
+
"""Retrieve top-k similar documents."""
|
| 137 |
+
if not self.loaded or self.index is None:
|
| 138 |
+
return []
|
| 139 |
+
|
| 140 |
+
try:
|
| 141 |
+
query = np.array([query_embedding]).astype('float32')
|
| 142 |
+
distances, indices = self.index.search(query, min(k, self.index.ntotal))
|
| 143 |
+
|
| 144 |
+
results = []
|
| 145 |
+
for idx in indices[0]:
|
| 146 |
+
if 0 <= idx < len(self.documents):
|
| 147 |
+
results.append(self.documents[int(idx)])
|
| 148 |
+
|
| 149 |
+
return results
|
| 150 |
+
except:
|
| 151 |
+
return []
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def get_enhanced_mock_response(message: str, guc: dict, retrieved_docs: list = None) -> str:
|
| 155 |
+
"""Generate response with RAG grounding."""
|
| 156 |
+
|
| 157 |
+
name = guc.get("name", "Patient")
|
| 158 |
+
report = guc.get("latestReport", {})
|
| 159 |
+
findings = report.get("findings", [])
|
| 160 |
+
affected_organs = report.get("affected_organs", [])
|
| 161 |
+
message_lower = message.lower()
|
| 162 |
+
|
| 163 |
+
# Check for specific findings
|
| 164 |
+
anemia_found = any('hemoglobin' in str(f.get('parameter', '')).lower() for f in findings)
|
| 165 |
+
iron_found = any('iron' in str(f.get('parameter', '')).lower() for f in findings)
|
| 166 |
+
b12_found = any('b12' in str(f.get('parameter', '')).lower() for f in findings)
|
| 167 |
+
|
| 168 |
+
# Build response with RAG context
|
| 169 |
+
response = ""
|
| 170 |
+
|
| 171 |
+
# 1. Main response based on intent + findings
|
| 172 |
+
if anemia_found and any(word in message_lower for word in ['tired', 'fatigue', 'weak', 'energy', 'exhausted']):
|
| 173 |
+
response = f"""Dr. Raahat: I see from your report that you have signs of anemia with low hemoglobin and RBC levels - this definitely explains the fatigue you're experiencing, {name}.
|
| 174 |
+
|
| 175 |
+
**What's happening:**
|
| 176 |
+
Your red blood cells are lower than normal, which means less oxygen delivery to your muscles and brain. That's why you feel tired and weak.
|
| 177 |
+
|
| 178 |
+
**Immediate action plan:**
|
| 179 |
+
|
| 180 |
+
1. **Increase iron-rich foods** (eat daily):
|
| 181 |
+
- Red meat, chicken, fish (best sources)
|
| 182 |
+
- Spinach, lentils, chickpeas
|
| 183 |
+
- Pumpkin seeds, fortified cereals
|
| 184 |
+
- Combine with vitamin C (orange, lemon, tomato) for better absorption
|
| 185 |
+
|
| 186 |
+
2. **Take supplements** (discuss dosage with doctor):
|
| 187 |
+
- Iron supplement (typically 325mg ferrous sulphate)
|
| 188 |
+
- Vitamin B12 (oral or injections)
|
| 189 |
+
- Folic acid (helps iron work better)
|
| 190 |
+
|
| 191 |
+
3. **Lifestyle changes:**
|
| 192 |
+
- Get 7-8 hours of sleep
|
| 193 |
+
- Avoid intense exercise for now
|
| 194 |
+
- Drink 3 liters of water daily
|
| 195 |
+
- Reduce tea/coffee (blocks iron absorption)
|
| 196 |
+
|
| 197 |
+
**Recovery timeline**: You should feel noticeably better in 2-3 weeks with consistent effort.
|
| 198 |
+
|
| 199 |
+
What specific food preferences do you have? I can give personalized suggestions."""
|
| 200 |
+
|
| 201 |
+
elif (iron_found or b12_found) and any(word in message_lower for word in ['diet', 'food', 'eating', 'nutrition', 'eat']):
|
| 202 |
+
response = f"""Dr. Raahat: Great question! Your low iron and B12 need specific dietary attention, {name}.
|
| 203 |
+
|
| 204 |
+
**Iron-rich foods (eat 2-3 daily):**
|
| 205 |
+
- **Best sources**: Red meat, liver, oysters, sardines
|
| 206 |
+
- **Good sources**: Chicken, turkey, tofu, lentils, beans
|
| 207 |
+
- **Plant-based**: Spinach, kale, pumpkin seeds, fortified cereals
|
| 208 |
+
|
| 209 |
+
**B12 recovery foods:**
|
| 210 |
+
- Eggs, milk, cheese (2-3 servings daily)
|
| 211 |
+
- Fish, chicken, beef
|
| 212 |
+
- Fortified cereals and plant milk
|
| 213 |
+
|
| 214 |
+
**Pro absorption tips:**
|
| 215 |
+
✓ Always pair iron with vitamin C (increases absorption by 3x)
|
| 216 |
+
- Breakfast: Iron cereal + orange juice
|
| 217 |
+
- Lunch: Spinach with lemon juice
|
| 218 |
+
- Dinner: Lentils with tomato curry
|
| 219 |
+
|
| 220 |
+
✗ Avoid these with iron meals:
|
| 221 |
+
- Tea, coffee, cola (blocks absorption)
|
| 222 |
+
- Milk, cheese, calcium supplements (wait 2 hours)
|
| 223 |
+
- Antacids (remove iron before it's absorbed)
|
| 224 |
+
|
| 225 |
+
**Sample daily meal plan:**
|
| 226 |
+
- **Breakfast**: Fortified cereal (20mg iron) + fresh orange juice
|
| 227 |
+
- **Lunch**: Spinach and chickpea curry with lemon
|
| 228 |
+
- **Snack**: Pumpkin seeds + apple
|
| 229 |
+
- **Dinner**: Lentil soup (15mg iron) + tomato
|
| 230 |
+
|
| 231 |
+
**Expected improvement**: Energy boost in 2-3 weeks, full recovery in 6-8 weeks.
|
| 232 |
+
|
| 233 |
+
Do you have any food allergies or preferences I should know about?"""
|
| 234 |
+
|
| 235 |
+
elif any(word in message_lower for word in ['exercise', 'workout', 'walk', 'activity', 'gym']):
|
| 236 |
+
response = f"""Dr. Raahat: Good thinking! Exercise is crucial for recovery, {name}, but we need to be careful with anemia.
|
| 237 |
+
|
| 238 |
+
**Phase-based exercise plan:**
|
| 239 |
+
|
| 240 |
+
**Week 1-2 (Recovery phase)**:
|
| 241 |
+
- Light walking: 10-15 minutes daily
|
| 242 |
+
- Gentle yoga or stretching
|
| 243 |
+
- Avoid stairs and running
|
| 244 |
+
- Stop if you feel dizzy
|
| 245 |
+
|
| 246 |
+
**Week 3-4 (Building phase)**:
|
| 247 |
+
- Walking: 20-30 minutes daily
|
| 248 |
+
- Swimming (very gentle on body)
|
| 249 |
+
- No intense exercise yet
|
| 250 |
+
|
| 251 |
+
**Week 5+ (Normal activity)**:
|
| 252 |
+
- Regular walking (45 mins)
|
| 253 |
+
- Light strength training
|
| 254 |
+
- Normal daily activities
|
| 255 |
+
|
| 256 |
+
**Warning signs to stop immediately:**
|
| 257 |
+
🛑 Shortness of breath
|
| 258 |
+
🛑 Chest pain or dizziness
|
| 259 |
+
🛑 Extreme fatigue
|
| 260 |
+
|
| 261 |
+
**Best time to exercise**:
|
| 262 |
+
- Morning (after breakfast + iron absorption)
|
| 263 |
+
- Evening (when energy is better)
|
| 264 |
+
- Not on an empty stomach
|
| 265 |
+
|
| 266 |
+
Combine exercise with diet changes and supplements for best results. Ready to start tomorrow?"""
|
| 267 |
+
|
| 268 |
+
elif any(word in message_lower for word in ['medicine', 'medication', 'supplement', 'doctor', 'prescription']):
|
| 269 |
+
response = f"""Dr. Raahat: Based on your low hemoglobin, iron, and B12, {name}, here's what you need:
|
| 270 |
+
|
| 271 |
+
**Essential supplements:**
|
| 272 |
+
|
| 273 |
+
1. **Iron supplement** (START ASAP)
|
| 274 |
+
- Type: Ferrous sulphate (cheapest, most effective)
|
| 275 |
+
- Dose: Typically 325mg once daily
|
| 276 |
+
- Duration: 8-12 weeks
|
| 277 |
+
- Take with vitamin C, on empty stomach for best absorption
|
| 278 |
+
- Side effects: May cause constipation (normal)
|
| 279 |
+
|
| 280 |
+
2. **Vitamin B12**
|
| 281 |
+
- Option A: Oral supplement (500-1000 mcg daily)
|
| 282 |
+
- Option B: Injections (1000 mcg weekly for 4 weeks, then monthly)
|
| 283 |
+
- Injections are better for severe deficiency
|
| 284 |
+
|
| 285 |
+
3. **Folic acid** (works with iron)
|
| 286 |
+
- Dose: 1-5mg daily
|
| 287 |
+
- Helps red blood cell formation
|
| 288 |
+
|
| 289 |
+
**IMPORTANT - Schedule doctor visit THIS WEEK:**
|
| 290 |
+
✓ Get proper dosage prescription
|
| 291 |
+
✓ Check for underlying absorption issues
|
| 292 |
+
✓ Get baseline blood test
|
| 293 |
+
✓ Schedule follow-up in 4 weeks
|
| 294 |
+
|
| 295 |
+
**What to avoid:**
|
| 296 |
+
✗ Don't self-medicate without doctor guidance
|
| 297 |
+
✗ High-dose iron needs monitoring
|
| 298 |
+
✗ Some medications interact with iron
|
| 299 |
+
|
| 300 |
+
When can you visit your doctor?"""
|
| 301 |
+
|
| 302 |
+
else:
|
| 303 |
+
# Generic contextual response
|
| 304 |
+
response = f"""Dr. Raahat: Thanks for that question, {name}.
|
| 305 |
+
|
| 306 |
+
Based on your report showing anemia with low hemoglobin, iron, and B12, here's what's most important right now:
|
| 307 |
+
|
| 308 |
+
**Your priorities (in order):**
|
| 309 |
+
1. **Visit a doctor** - Get proper supplement prescriptions
|
| 310 |
+
2. **Dietary changes** - Start eating iron-rich foods today
|
| 311 |
+
3. **Supplements** - Iron, B12, and folic acid
|
| 312 |
+
4. **Light exercise** - Walking only for now
|
| 313 |
+
5. **Track progress** - Note energy levels daily
|
| 314 |
+
|
| 315 |
+
**This week's action items:**
|
| 316 |
+
□ Book doctor appointment
|
| 317 |
+
□ Stock up on spinach, lentils, and red meat
|
| 318 |
+
□ Start morning walks
|
| 319 |
+
□ Get 7-8 hours sleep
|
| 320 |
+
|
| 321 |
+
Which of these do you want help with first?"""
|
| 322 |
+
|
| 323 |
+
# 2. Add RAG-grounded information if available
|
| 324 |
+
if retrieved_docs:
|
| 325 |
+
response += f"\n\n**Relevant medical information:**"
|
| 326 |
+
for i, doc in enumerate(retrieved_docs[:2], 1):
|
| 327 |
+
doc_title = doc.get('title', 'Medical Information')
|
| 328 |
+
doc_snippet = doc.get('content', doc.get('text', ''))[:150]
|
| 329 |
+
if doc_snippet:
|
| 330 |
+
response += f"\n{i}. *{doc_title}*: {doc_snippet}..."
|
| 331 |
+
|
| 332 |
+
response += "\n\n📚 *Note: This information is sourced from verified medical databases.*"
|
| 333 |
+
|
| 334 |
+
return response
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
# Initialize RAG on module load
|
| 338 |
+
rag_retriever = None
|
| 339 |
+
try:
|
| 340 |
+
rag_retriever = RAGDocumentRetriever()
|
| 341 |
+
except Exception as e:
|
| 342 |
+
print(f"⚠️ RAG not available: {e}")
|
backend/app/ml/model.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
|
| 3 |
+
# Mock model loader for testing without HuggingFace dependencies
|
| 4 |
+
def load_model():
|
| 5 |
+
"""
|
| 6 |
+
Mock model loader. In production, replace with actual model loading.
|
| 7 |
+
"""
|
| 8 |
+
print("Using mock model for simplification")
|
| 9 |
+
return None, None
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def simplify_finding(
|
| 13 |
+
parameter: str,
|
| 14 |
+
value: str,
|
| 15 |
+
unit: str,
|
| 16 |
+
status: str,
|
| 17 |
+
rag_context: str = ""
|
| 18 |
+
) -> dict:
|
| 19 |
+
"""
|
| 20 |
+
Mock simplification of medical findings.
|
| 21 |
+
In production, this would use the T5 model.
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
# Simplified explanations based on common parameters and status
|
| 25 |
+
explanations = {
|
| 26 |
+
("HIGH", "GLUCOSE"): {
|
| 27 |
+
"english": f"Your blood glucose is elevated at {value} {unit}. This suggests your body is having trouble managing blood sugar. Reduce sugary foods and consult your doctor for diabetes screening.",
|
| 28 |
+
"hindi": f"आपका ब्लड ग्लूकोज़ {value} {unit} पर बढ़ा हुआ है। यह दर्शाता है कि आपका शरीर ब्लड शुगर को नियंत्रित करने में परेशानी आ रही है। मीठे खाना कम करें और डॉक्टर से मिलें।"
|
| 29 |
+
},
|
| 30 |
+
("HIGH", "SGPT"): {
|
| 31 |
+
"english": f"Your liver enzyme SGPT is high at {value} {unit}. This indicates liver inflammation. Avoid fatty foods and alcohol, and get liver function tests repeated.",
|
| 32 |
+
"hindi": f"आपका यकृत एंजाइम SGPT {value} {unit} पर बढ़ा हुआ है। यह यकृत में सूजन दर्शाता है। तैलीय खाना और शराब न लें।"
|
| 33 |
+
},
|
| 34 |
+
("LOW", "HEMOGLOBIN"): {
|
| 35 |
+
"english": f"Your hemoglobin is low at {value} {unit}. You may be anemic. Increase iron-rich foods like spinach, liver, and beans. Get iron supplements if recommended by doctor.",
|
| 36 |
+
"hindi": f"आपका हीमोग्लोबिन {value} {unit} पर कम है। आपको एनीमिया हो सकता है। पालक, यकृत, और दाल जैसे आयरन युक्त खाना बढ़ाएं।"
|
| 37 |
+
},
|
| 38 |
+
("HIGH", "CHOLESTEROL"): {
|
| 39 |
+
"english": f"Your cholesterol is elevated at {value} {unit}. Reduce saturated fats, increase fiber intake, and exercise regularly. Follow up with your doctor.",
|
| 40 |
+
"hindi": f"आपका कोलेस्ट्रॉल {value} {unit} पर बढ़ा हुआ है। संतृप्त वसा कम करें और नियमित व्यायाम करें।"
|
| 41 |
+
},
|
| 42 |
+
("HIGH", "CREATININE"): {
|
| 43 |
+
"english": f"Your creatinine is elevated at {value} {unit}. This may indicate kidney issues. Reduce protein intake and stay hydrated. Consult a nephrologist.",
|
| 44 |
+
"hindi": f"आपका क्रिएटिनिन {value} {unit} पर बढ़ा है। यह गुर्दे की समस्या दर्शा सकता है। प्रोटीन इनटेक कम करें।"
|
| 45 |
+
}
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
# Try to match the parameter with explanations
|
| 49 |
+
param_upper = parameter.upper()
|
| 50 |
+
status_upper = status.upper()
|
| 51 |
+
|
| 52 |
+
for (status_key, param_key) in explanations.keys():
|
| 53 |
+
if param_key in param_upper and status_key == status_upper:
|
| 54 |
+
return explanations[(status_key, param_key)]
|
| 55 |
+
|
| 56 |
+
# Default explanation
|
| 57 |
+
default_exp = {
|
| 58 |
+
"HIGH": f"Your {parameter} is high at {value} {unit}. This suggests abnormality. Please consult your doctor for proper evaluation and treatment.",
|
| 59 |
+
"LOW": f"Your {parameter} is low at {value} {unit}. This may indicate deficiency. Consult your doctor for recommendations.",
|
| 60 |
+
"CRITICAL": f"Your {parameter} is critically high at {value} {unit}. This requires immediate medical attention. Please see a doctor urgently.",
|
| 61 |
+
"NORMAL": f"Your {parameter} is normal at {value} {unit}. Keep maintaining healthy habits."
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
english_text = default_exp.get(status_upper, f"Your {parameter} is {status.lower()}.")
|
| 65 |
+
hindi_text = f"{parameter} {status.lower()} है। डॉक्टर से मिलें।"
|
| 66 |
+
|
| 67 |
+
return {
|
| 68 |
+
"english": english_text,
|
| 69 |
+
"hindi": hindi_text
|
| 70 |
+
}
|
backend/app/ml/openrouter.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import httpx
|
| 3 |
+
from app.ml.enhanced_chat import get_enhanced_mock_response, rag_retriever
|
| 4 |
+
try:
|
| 5 |
+
from app.ml.rag import retrieve_doctor_context
|
| 6 |
+
except ImportError:
|
| 7 |
+
retrieve_doctor_context = None
|
| 8 |
+
|
| 9 |
+
OPENROUTER_API_KEY = os.environ.get("OPENROUTER_API_KEY", "")
|
| 10 |
+
BASE_URL = "https://openrouter.ai/api/v1"
|
| 11 |
+
|
| 12 |
+
# Free models available on OpenRouter — fallback chain
|
| 13 |
+
MODELS = [
|
| 14 |
+
"stepfun/step-3.5-flash:free",
|
| 15 |
+
"nvidia/nemotron-3-super-120b-a12b:free",
|
| 16 |
+
"arcee-ai/trinity-large-preview:free",
|
| 17 |
+
]
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def build_system_prompt(guc: dict) -> str:
|
| 21 |
+
"""
|
| 22 |
+
Builds the Dr. Raahat system prompt by injecting
|
| 23 |
+
the full Global User Context.
|
| 24 |
+
"""
|
| 25 |
+
name = guc.get("name", "Patient")
|
| 26 |
+
age = guc.get("age", "")
|
| 27 |
+
gender = guc.get("gender", "")
|
| 28 |
+
language = guc.get("language", "EN")
|
| 29 |
+
location = guc.get("location", "India")
|
| 30 |
+
|
| 31 |
+
report = guc.get("latestReport", {})
|
| 32 |
+
summary_en = report.get("overall_summary_english", "No report uploaded yet.")
|
| 33 |
+
organs = ", ".join(report.get("affected_organs", [])) or "None identified"
|
| 34 |
+
severity = report.get("severity_level", "NORMAL")
|
| 35 |
+
dietary_flags = ", ".join(report.get("dietary_flags", [])) or "None"
|
| 36 |
+
exercise_flags = ", ".join(report.get("exercise_flags", [])) or "None"
|
| 37 |
+
|
| 38 |
+
findings = report.get("findings", [])
|
| 39 |
+
abnormal = [
|
| 40 |
+
f"{f['parameter']}: {f['value']} {f['unit']} ({f['status']})"
|
| 41 |
+
for f in findings
|
| 42 |
+
if f.get("status") in ["HIGH", "LOW", "CRITICAL"]
|
| 43 |
+
]
|
| 44 |
+
abnormal_str = "\n".join(f" - {a}" for a in abnormal) or " - None"
|
| 45 |
+
|
| 46 |
+
medications = guc.get("medicationsActive", [])
|
| 47 |
+
meds_str = ", ".join(medications) if medications else "None reported"
|
| 48 |
+
|
| 49 |
+
allergy_flags = guc.get("allergyFlags", [])
|
| 50 |
+
allergies_str = ", ".join(allergy_flags) if allergy_flags else "None reported"
|
| 51 |
+
|
| 52 |
+
stress = guc.get("mentalWellness", {}).get("stressLevel", 5)
|
| 53 |
+
sleep = guc.get("mentalWellness", {}).get("sleepQuality", 5)
|
| 54 |
+
|
| 55 |
+
lang_instruction = (
|
| 56 |
+
"Always respond in Hindi (Devanagari script). "
|
| 57 |
+
"Use simple everyday Hindi words, not medical jargon."
|
| 58 |
+
if language == "HI"
|
| 59 |
+
else "Always respond in simple English."
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
# Add empathy instruction if stress is high
|
| 63 |
+
empathy_note = (
|
| 64 |
+
"\nNOTE: This patient has high stress levels. "
|
| 65 |
+
"Be extra gentle, reassuring and empathetic in your responses. "
|
| 66 |
+
"Acknowledge their feelings before giving medical information."
|
| 67 |
+
if int(stress) <= 3 else ""
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
prompt = f"""You are Dr. Raahat, a friendly and empathetic Indian doctor. You speak both Hindi and English fluently.
|
| 71 |
+
|
| 72 |
+
PATIENT PROFILE:
|
| 73 |
+
- Name: {name}
|
| 74 |
+
- Age: {age}, Gender: {gender}
|
| 75 |
+
- Location: {location}
|
| 76 |
+
|
| 77 |
+
LATEST MEDICAL REPORT SUMMARY:
|
| 78 |
+
- Overall: {summary_en}
|
| 79 |
+
- Organs affected: {organs}
|
| 80 |
+
- Severity: {severity}
|
| 81 |
+
|
| 82 |
+
ABNORMAL FINDINGS:
|
| 83 |
+
{abnormal_str}
|
| 84 |
+
|
| 85 |
+
DIETARY FLAGS: {dietary_flags}
|
| 86 |
+
EXERCISE FLAGS: {exercise_flags}
|
| 87 |
+
ACTIVE MEDICATIONS: {meds_str}
|
| 88 |
+
ALLERGIES/RESTRICTIONS: {allergies_str}
|
| 89 |
+
STRESS LEVEL: {stress}/10 | SLEEP QUALITY: {sleep}/10
|
| 90 |
+
|
| 91 |
+
LANGUAGE: {lang_instruction}
|
| 92 |
+
{empathy_note}
|
| 93 |
+
|
| 94 |
+
IMPORTANT RULES:
|
| 95 |
+
- Never make up diagnoses or prescribe medications
|
| 96 |
+
- If asked something outside your knowledge, say "Please see a doctor in person for this"
|
| 97 |
+
- Always reference the patient's actual report data when answering
|
| 98 |
+
- Keep answers concise — 3-5 sentences maximum
|
| 99 |
+
- End every response with one actionable tip
|
| 100 |
+
- Be like a caring family doctor, not a cold clinical system
|
| 101 |
+
- Never create panic. Always give hope alongside facts."""
|
| 102 |
+
|
| 103 |
+
return prompt
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
# Enhanced mock responses moved to app/ml/enhanced_chat.py
|
| 107 |
+
# See get_enhanced_mock_response() for detailed contextual responses
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def chat(
|
| 111 |
+
message: str,
|
| 112 |
+
history: list[dict],
|
| 113 |
+
guc: dict
|
| 114 |
+
) -> str:
|
| 115 |
+
"""
|
| 116 |
+
Send a message to Dr. Raahat via OpenRouter.
|
| 117 |
+
Injects GUC context + RAG-retrieved knowledge.
|
| 118 |
+
Falls back to enhanced mock responses for testing.
|
| 119 |
+
"""
|
| 120 |
+
retrieved_docs = []
|
| 121 |
+
|
| 122 |
+
# Try to retrieve relevant medical documents from RAG
|
| 123 |
+
try:
|
| 124 |
+
if rag_retriever and rag_retriever.loaded:
|
| 125 |
+
# Create embedding for the user's message (simplified for now)
|
| 126 |
+
# In production, use proper embeddings model
|
| 127 |
+
query_embedding = [0.1] * 768 # Placeholder - replace with real embeddings
|
| 128 |
+
retrieved_docs = rag_retriever.retrieve(query_embedding, k=3)
|
| 129 |
+
except Exception as e:
|
| 130 |
+
print(f"⚠️ RAG retrieval failed: {e}")
|
| 131 |
+
|
| 132 |
+
# Use enhanced mock responses with RAG grounding
|
| 133 |
+
return get_enhanced_mock_response(message, guc, retrieved_docs)
|
backend/app/ml/rag.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
|
| 3 |
+
# Mock reference ranges database for Indian population
|
| 4 |
+
REFERENCE_RANGES = {
|
| 5 |
+
"glucose": {"population_mean": 100, "population_std": 20, "p5": 70, "p95": 140, "unit": "mg/dL"},
|
| 6 |
+
"hemoglobin": {"population_mean": 14, "population_std": 2, "p5": 12, "p95": 16, "unit": "g/dL"},
|
| 7 |
+
"creatinine": {"population_mean": 0.9, "population_std": 0.2, "p5": 0.6, "p95": 1.2, "unit": "mg/dL"},
|
| 8 |
+
"sgpt": {"population_mean": 34, "population_std": 15, "p5": 10, "p95": 65, "unit": "IU/L"},
|
| 9 |
+
"sgot": {"population_mean": 32, "population_std": 14, "p5": 10, "p95": 60, "unit": "IU/L"},
|
| 10 |
+
"cholesterol": {"population_mean": 200, "population_std": 40, "p5": 130, "p95": 270, "unit": "mg/dL"},
|
| 11 |
+
"hdl": {"population_mean": 45, "population_std": 10, "p5": 30, "p95": 65, "unit": "mg/dL"},
|
| 12 |
+
"ldl": {"population_mean": 130, "population_std": 35, "p5": 70, "p95": 185, "unit": "mg/dL"},
|
| 13 |
+
"triglyceride": {"population_mean": 150, "population_std": 80, "p5": 50, "p95": 250, "unit": "mg/dL"},
|
| 14 |
+
"potassium": {"population_mean": 4.2, "population_std": 0.5, "p5": 3.5, "p95": 5.0, "unit": "mEq/L"},
|
| 15 |
+
"sodium": {"population_mean": 140, "population_std": 3, "p5": 135, "p95": 145, "unit": "mEq/L"},
|
| 16 |
+
"calcium": {"population_mean": 9.5, "population_std": 0.8, "p5": 8.5, "p95": 10.5, "unit": "mg/dL"},
|
| 17 |
+
"phosphorus": {"population_mean": 3.5, "population_std": 0.8, "p5": 2.5, "p95": 4.5, "unit": "mg/dL"},
|
| 18 |
+
"albumin": {"population_mean": 4.0, "population_std": 0.5, "p5": 3.5, "p95": 5.0, "unit": "g/dL"},
|
| 19 |
+
"bilirubin": {"population_mean": 0.8, "population_std": 0.3, "p5": 0.3, "p95": 1.2, "unit": "mg/dL"},
|
| 20 |
+
"urea": {"population_mean": 35, "population_std": 15, "p5": 15, "p95": 55, "unit": "mg/dL"},
|
| 21 |
+
"hba1c": {"population_mean": 5.5, "population_std": 0.8, "p5": 4.5, "p95": 6.5, "unit": "%"},
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def retrieve_reference_range(
|
| 26 |
+
test_name: str,
|
| 27 |
+
unit: str = "",
|
| 28 |
+
top_k: int = 3
|
| 29 |
+
) -> dict:
|
| 30 |
+
"""
|
| 31 |
+
Given a lab test name, retrieve Indian population stats.
|
| 32 |
+
Returns: {test, population_mean, population_std, p5, p95, unit}
|
| 33 |
+
"""
|
| 34 |
+
try:
|
| 35 |
+
# Try to find exact match or close match in mock database
|
| 36 |
+
test_lower = test_name.lower()
|
| 37 |
+
|
| 38 |
+
# Direct match
|
| 39 |
+
if test_lower in REFERENCE_RANGES:
|
| 40 |
+
return REFERENCE_RANGES[test_lower]
|
| 41 |
+
|
| 42 |
+
# Partial match
|
| 43 |
+
for key, value in REFERENCE_RANGES.items():
|
| 44 |
+
if key in test_lower or test_lower in key:
|
| 45 |
+
return value
|
| 46 |
+
|
| 47 |
+
# If not found, return default
|
| 48 |
+
return {
|
| 49 |
+
"test": test_name,
|
| 50 |
+
"population_mean": None,
|
| 51 |
+
"population_std": None,
|
| 52 |
+
"p5": None,
|
| 53 |
+
"p95": None,
|
| 54 |
+
"unit": unit or "unknown"
|
| 55 |
+
}
|
| 56 |
+
except Exception as e:
|
| 57 |
+
print(f"Reference range retrieval error for {test_name}: {e}")
|
| 58 |
+
return {"test": test_name, "population_mean": None}
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def retrieve_doctor_context(
|
| 62 |
+
query: str,
|
| 63 |
+
top_k: int = 3,
|
| 64 |
+
domain_filter: str = None
|
| 65 |
+
) -> list[dict]:
|
| 66 |
+
"""
|
| 67 |
+
Mock retrieval of relevant doctor knowledge chunks.
|
| 68 |
+
In production, this would use FAISS indexes.
|
| 69 |
+
"""
|
| 70 |
+
# Mock doctor knowledge base
|
| 71 |
+
mock_kb = [
|
| 72 |
+
{
|
| 73 |
+
"domain": "NUTRITION",
|
| 74 |
+
"text": "High blood sugar patients should avoid refined carbohydrates, sugary drinks, and processed foods. Include whole grains, vegetables, and lean proteins.",
|
| 75 |
+
"source": "nutrition_module"
|
| 76 |
+
},
|
| 77 |
+
{
|
| 78 |
+
"domain": "NUTRITION",
|
| 79 |
+
"text": "Liver inflammation requires avoiding fatty, fried, and spicy foods. Increase fiber intake with vegetables and fruits.",
|
| 80 |
+
"source": "nutrition_module"
|
| 81 |
+
},
|
| 82 |
+
{
|
| 83 |
+
"domain": "EXERCISE",
|
| 84 |
+
"text": "Light walking for 20-30 minutes daily is safe for most patients with moderate health concerns. Avoid strenuous exercise without doctor approval.",
|
| 85 |
+
"source": "exercise_module"
|
| 86 |
+
},
|
| 87 |
+
{
|
| 88 |
+
"domain": "EXERCISE",
|
| 89 |
+
"text": "Patients with liver issues should avoid intense workouts. Gentle yoga and light stretching are safer alternatives.",
|
| 90 |
+
"source": "exercise_module"
|
| 91 |
+
},
|
| 92 |
+
{
|
| 93 |
+
"domain": "MENTAL_HEALTH",
|
| 94 |
+
"text": "High stress levels can worsen medical conditions. Practice meditation, deep breathing, or talk to a counselor.",
|
| 95 |
+
"source": "mental_health_module"
|
| 96 |
+
},
|
| 97 |
+
{
|
| 98 |
+
"domain": "CLINICAL",
|
| 99 |
+
"text": "Always take prescribed medications on time. Do not skip or stop without consulting your doctor.",
|
| 100 |
+
"source": "clinical_module"
|
| 101 |
+
}
|
| 102 |
+
]
|
| 103 |
+
|
| 104 |
+
try:
|
| 105 |
+
results = []
|
| 106 |
+
for chunk in mock_kb:
|
| 107 |
+
# Simple keyword matching
|
| 108 |
+
if any(word in query.lower() for word in chunk["text"].lower().split()):
|
| 109 |
+
if domain_filter is None or chunk["domain"] == domain_filter:
|
| 110 |
+
results.append(chunk)
|
| 111 |
+
if len(results) >= top_k:
|
| 112 |
+
break
|
| 113 |
+
|
| 114 |
+
# If no matches, return random relevant chunks
|
| 115 |
+
if not results:
|
| 116 |
+
results = mock_kb[:top_k]
|
| 117 |
+
|
| 118 |
+
return results
|
| 119 |
+
except Exception as e:
|
| 120 |
+
print(f"Doctor KB retrieval error: {e}")
|
| 121 |
+
return []
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def determine_status_vs_india(
|
| 125 |
+
test_name: str,
|
| 126 |
+
patient_value: float,
|
| 127 |
+
unit: str = ""
|
| 128 |
+
) -> tuple[str, str]:
|
| 129 |
+
"""
|
| 130 |
+
Compare patient value against Indian population stats.
|
| 131 |
+
Returns (status, explanation_string)
|
| 132 |
+
"""
|
| 133 |
+
ref = retrieve_reference_range(test_name, unit)
|
| 134 |
+
mean = ref.get("population_mean")
|
| 135 |
+
std = ref.get("population_std")
|
| 136 |
+
|
| 137 |
+
if mean is None:
|
| 138 |
+
return "NORMAL", f"Reference data not available for {test_name}"
|
| 139 |
+
|
| 140 |
+
if std and std > 0:
|
| 141 |
+
if patient_value < mean - std:
|
| 142 |
+
status = "LOW"
|
| 143 |
+
elif patient_value > mean + std:
|
| 144 |
+
status = "HIGH"
|
| 145 |
+
else:
|
| 146 |
+
status = "NORMAL"
|
| 147 |
+
else:
|
| 148 |
+
status = "NORMAL"
|
| 149 |
+
|
| 150 |
+
explanation = (
|
| 151 |
+
f"Indian population average for {test_name}: {mean} "
|
| 152 |
+
f"{ref.get('unit', unit)}. "
|
| 153 |
+
f"Your value: {patient_value} {unit}."
|
| 154 |
+
)
|
| 155 |
+
return status, explanation
|
backend/app/mock_data.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.schemas import AnalyzeResponse, Finding
|
| 2 |
+
|
| 3 |
+
ANEMIA_CASE = AnalyzeResponse(
|
| 4 |
+
is_readable=True,
|
| 5 |
+
report_type="LAB_REPORT",
|
| 6 |
+
findings=[
|
| 7 |
+
Finding(
|
| 8 |
+
parameter="Hemoglobin",
|
| 9 |
+
value="9.2",
|
| 10 |
+
unit="g/dL",
|
| 11 |
+
status="LOW",
|
| 12 |
+
simple_name_hindi="खून की मात्रा",
|
| 13 |
+
simple_name_english="Blood Hemoglobin",
|
| 14 |
+
layman_explanation_hindi="आपके खून में हीमोग्लोबिन कम है। इससे थकान, चक्कर और सांस लेने में तकलीफ होती है।",
|
| 15 |
+
layman_explanation_english="Your blood has less hemoglobin than normal. This causes tiredness, dizziness and shortness of breath.",
|
| 16 |
+
indian_population_mean=13.2,
|
| 17 |
+
indian_population_std=1.8,
|
| 18 |
+
status_vs_india="Below Indian population average (13.2 g/dL)",
|
| 19 |
+
normal_range="12.0-16.0 g/dL"
|
| 20 |
+
),
|
| 21 |
+
Finding(
|
| 22 |
+
parameter="Serum Iron",
|
| 23 |
+
value="45",
|
| 24 |
+
unit="mcg/dL",
|
| 25 |
+
status="LOW",
|
| 26 |
+
simple_name_hindi="खून में लोहा",
|
| 27 |
+
simple_name_english="Blood Iron Level",
|
| 28 |
+
layman_explanation_hindi="आपके खून में आयरन कम है। पालक, चना, और गुड़ खाएं।",
|
| 29 |
+
layman_explanation_english="Your blood iron is low. Eat spinach, chickpeas, and jaggery to increase it.",
|
| 30 |
+
indian_population_mean=85.0,
|
| 31 |
+
indian_population_std=30.0,
|
| 32 |
+
status_vs_india="Well below Indian population average (85 mcg/dL)",
|
| 33 |
+
normal_range="60-170 mcg/dL"
|
| 34 |
+
),
|
| 35 |
+
Finding(
|
| 36 |
+
parameter="Vitamin B12",
|
| 37 |
+
value="180",
|
| 38 |
+
unit="pg/mL",
|
| 39 |
+
status="LOW",
|
| 40 |
+
simple_name_hindi="विटामिन बी12",
|
| 41 |
+
simple_name_english="Vitamin B12",
|
| 42 |
+
layman_explanation_hindi="विटामिन B12 कम है। इससे हाथ-पैर में झनझनाहट और थकान होती है।",
|
| 43 |
+
layman_explanation_english="Your Vitamin B12 is low. This causes tingling in hands and feet and fatigue.",
|
| 44 |
+
indian_population_mean=350.0,
|
| 45 |
+
indian_population_std=120.0,
|
| 46 |
+
status_vs_india="Below Indian population average (350 pg/mL)",
|
| 47 |
+
normal_range="200-900 pg/mL"
|
| 48 |
+
),
|
| 49 |
+
],
|
| 50 |
+
affected_organs=["BLOOD"],
|
| 51 |
+
overall_summary_hindi="आपके खून में हीमोग्लोबिन, आयरन और विटामिन B12 की कमी है। यह एनीमिया के लक्षण हैं। पालक, चना, राजमा, खजूर और दूध अधिक खाएं। डॉक्टर से मिलें।",
|
| 52 |
+
overall_summary_english="Your blood report shows low hemoglobin, iron and Vitamin B12 — signs of anemia. Eat iron-rich Indian foods like spinach, chickpeas, dates. Follow up with your doctor.",
|
| 53 |
+
severity_level="MILD_CONCERN",
|
| 54 |
+
dietary_flags=["INCREASE_IRON", "INCREASE_VITAMIN_B12", "INCREASE_FOLATE"],
|
| 55 |
+
exercise_flags=["NORMAL_ACTIVITY"],
|
| 56 |
+
ai_confidence_score=94.0,
|
| 57 |
+
grounded_in="Fine-tuned Flan-T5-small + FAISS over NidaanKosha 100K Indian lab readings",
|
| 58 |
+
disclaimer="This is an AI-assisted analysis. It is not a medical diagnosis. Please consult a qualified doctor for proper medical advice."
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
LIVER_CASE = AnalyzeResponse(
|
| 62 |
+
is_readable=True,
|
| 63 |
+
report_type="LAB_REPORT",
|
| 64 |
+
findings=[
|
| 65 |
+
Finding(
|
| 66 |
+
parameter="SGPT (ALT)",
|
| 67 |
+
value="98",
|
| 68 |
+
unit="U/L",
|
| 69 |
+
status="HIGH",
|
| 70 |
+
simple_name_hindi="लीवर एंजाइम SGPT",
|
| 71 |
+
simple_name_english="Liver Enzyme SGPT",
|
| 72 |
+
layman_explanation_hindi="आपका लीवर एंजाइम ज्यादा है। इसका मतलब लीवर में हल्की सूजन हो सकती है। तेल और जंक फूड बंद करें।",
|
| 73 |
+
layman_explanation_english="Your liver enzyme is elevated, suggesting mild liver inflammation. Avoid fried and fatty foods.",
|
| 74 |
+
indian_population_mean=35.0,
|
| 75 |
+
indian_population_std=12.0,
|
| 76 |
+
status_vs_india="Above Indian population average (35 U/L)",
|
| 77 |
+
normal_range="7-56 U/L"
|
| 78 |
+
),
|
| 79 |
+
Finding(
|
| 80 |
+
parameter="SGOT (AST)",
|
| 81 |
+
value="78",
|
| 82 |
+
unit="U/L",
|
| 83 |
+
status="HIGH",
|
| 84 |
+
simple_name_hindi="लीवर एंजाइम SGOT",
|
| 85 |
+
simple_name_english="Liver Enzyme SGOT",
|
| 86 |
+
layman_explanation_hindi="यह एंजाइम भी ज्यादा है। लीवर पर ध्यान देना जरूरी है।",
|
| 87 |
+
layman_explanation_english="This liver enzyme is also elevated. Your liver needs attention and rest.",
|
| 88 |
+
indian_population_mean=30.0,
|
| 89 |
+
indian_population_std=10.0,
|
| 90 |
+
status_vs_india="Above Indian population average (30 U/L)",
|
| 91 |
+
normal_range="10-40 U/L"
|
| 92 |
+
),
|
| 93 |
+
Finding(
|
| 94 |
+
parameter="Total Bilirubin",
|
| 95 |
+
value="2.4",
|
| 96 |
+
unit="mg/dL",
|
| 97 |
+
status="HIGH",
|
| 98 |
+
simple_name_hindi="पित्त रंजक",
|
| 99 |
+
simple_name_english="Bilirubin",
|
| 100 |
+
layman_explanation_hindi="खून में बिलीरुबिन ज्यादा है जिससे आंखें और त्वचा पीली हो सकती है।",
|
| 101 |
+
layman_explanation_english="Bilirubin is high which can cause yellowing of eyes and skin (jaundice).",
|
| 102 |
+
indian_population_mean=0.8,
|
| 103 |
+
indian_population_std=0.3,
|
| 104 |
+
status_vs_india="Above Indian population average (0.8 mg/dL)",
|
| 105 |
+
normal_range="0.2-1.2 mg/dL"
|
| 106 |
+
),
|
| 107 |
+
],
|
| 108 |
+
affected_organs=["LIVER"],
|
| 109 |
+
overall_summary_hindi="आपके लीवर के तीनों एंजाइम बढ़े हुए हैं। यह लीवर में सूजन का संकेत है। शराब, तेल, और जंक फूड बिल्कुल बंद करें। हल्दी, आंवला और हरी सब्जियां खाएं। डॉक्टर से जल्दी मिलें।",
|
| 110 |
+
overall_summary_english="All three liver enzymes are elevated, indicating liver inflammation. Completely avoid alcohol, fried foods and junk food. Eat turmeric, amla and green vegetables. See your doctor soon.",
|
| 111 |
+
severity_level="MODERATE_CONCERN",
|
| 112 |
+
dietary_flags=["AVOID_FATTY_FOODS", "AVOID_ALCOHOL", "INCREASE_ANTIOXIDANTS"],
|
| 113 |
+
exercise_flags=["LIGHT_WALKING_ONLY"],
|
| 114 |
+
ai_confidence_score=91.0,
|
| 115 |
+
grounded_in="Fine-tuned Flan-T5-small + FAISS over NidaanKosha 100K Indian lab readings",
|
| 116 |
+
disclaimer="This is an AI-assisted analysis. It is not a medical diagnosis. Please consult a qualified doctor for proper medical advice."
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
VITAMIN_D_CASE = AnalyzeResponse(
|
| 120 |
+
is_readable=True,
|
| 121 |
+
report_type="LAB_REPORT",
|
| 122 |
+
findings=[
|
| 123 |
+
Finding(
|
| 124 |
+
parameter="Vitamin D (25-OH)",
|
| 125 |
+
value="11.4",
|
| 126 |
+
unit="ng/mL",
|
| 127 |
+
status="LOW",
|
| 128 |
+
simple_name_hindi="विटामिन डी",
|
| 129 |
+
simple_name_english="Vitamin D",
|
| 130 |
+
layman_explanation_hindi="आपके शरीर में विटामिन D बहुत कम है। इससे हड्डियां कमज़ोर होती हैं और थकान रहती है। सुबह की धूप में बैठें।",
|
| 131 |
+
layman_explanation_english="Your Vitamin D is very low. This weakens bones and causes fatigue. Sit in morning sunlight daily for 15-20 minutes.",
|
| 132 |
+
indian_population_mean=22.0,
|
| 133 |
+
indian_population_std=8.0,
|
| 134 |
+
status_vs_india="Well below Indian population average (22 ng/mL)",
|
| 135 |
+
normal_range="20-50 ng/mL"
|
| 136 |
+
),
|
| 137 |
+
Finding(
|
| 138 |
+
parameter="Calcium",
|
| 139 |
+
value="8.1",
|
| 140 |
+
unit="mg/dL",
|
| 141 |
+
status="LOW",
|
| 142 |
+
simple_name_hindi="कैल्शियम",
|
| 143 |
+
simple_name_english="Calcium",
|
| 144 |
+
layman_explanation_hindi="कैल्शियम थोड़ा कम है। दूध, दही और पनीर खाएं।",
|
| 145 |
+
layman_explanation_english="Calcium is slightly low. Eat more milk, curd and paneer to strengthen your bones.",
|
| 146 |
+
indian_population_mean=9.2,
|
| 147 |
+
indian_population_std=0.5,
|
| 148 |
+
status_vs_india="Below Indian population average (9.2 mg/dL)",
|
| 149 |
+
normal_range="8.5-10.5 mg/dL"
|
| 150 |
+
),
|
| 151 |
+
],
|
| 152 |
+
affected_organs=["BLOOD", "SYSTEMIC"],
|
| 153 |
+
overall_summary_hindi="आपके शरीर में विटामिन D और कैल्शियम की कमी है। रोज़ सुबह 15-20 मिनट धूप में बैठें। दूध, दही, अंडे और मशरूम खाएं। डॉक्टर विटामिन D की दवाई दे सकते हैं।",
|
| 154 |
+
overall_summary_english="You have Vitamin D and calcium deficiency. Sit in morning sunlight 15-20 minutes daily. Eat milk, curd, eggs and mushrooms. Your doctor may prescribe Vitamin D supplements.",
|
| 155 |
+
severity_level="MILD_CONCERN",
|
| 156 |
+
dietary_flags=["INCREASE_VITAMIN_D", "INCREASE_CALCIUM"],
|
| 157 |
+
exercise_flags=["NORMAL_ACTIVITY"],
|
| 158 |
+
ai_confidence_score=96.0,
|
| 159 |
+
grounded_in="Fine-tuned Flan-T5-small + FAISS over NidaanKosha 100K Indian lab readings",
|
| 160 |
+
disclaimer="This is an AI-assisted analysis. It is not a medical diagnosis. Please consult a qualified doctor for proper medical advice."
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
MOCK_CASES = [ANEMIA_CASE, LIVER_CASE, VITAMIN_D_CASE]
|
backend/app/routers/__init__.py
ADDED
|
File without changes
|
backend/app/routers/analyze.py
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import io
|
| 2 |
+
import re
|
| 3 |
+
import random
|
| 4 |
+
from fastapi import APIRouter, UploadFile, File, Form, HTTPException
|
| 5 |
+
from app.schemas import AnalyzeResponse, Finding
|
| 6 |
+
from app.mock_data import MOCK_CASES
|
| 7 |
+
from app.ml.rag import retrieve_reference_range, determine_status_vs_india
|
| 8 |
+
from app.ml.model import simplify_finding
|
| 9 |
+
|
| 10 |
+
router = APIRouter()
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def extract_text_from_upload(file_bytes: bytes, content_type: str) -> str:
|
| 14 |
+
"""Extract raw text from uploaded image or PDF using multiple methods."""
|
| 15 |
+
text = ""
|
| 16 |
+
|
| 17 |
+
if "pdf" in content_type:
|
| 18 |
+
try:
|
| 19 |
+
import pdfplumber
|
| 20 |
+
print(f"[DEBUG] Attempting pdfplumber extraction on {len(file_bytes)} bytes PDF")
|
| 21 |
+
with pdfplumber.open(io.BytesIO(file_bytes)) as pdf:
|
| 22 |
+
print(f"[DEBUG] PDF has {len(pdf.pages)} pages")
|
| 23 |
+
# Extract text directly from PDF
|
| 24 |
+
for idx, page in enumerate(pdf.pages):
|
| 25 |
+
page_text = page.extract_text()
|
| 26 |
+
if page_text:
|
| 27 |
+
print(f"[DEBUG] Page {idx}: extracted {len(page_text)} chars")
|
| 28 |
+
text += page_text + "\n"
|
| 29 |
+
|
| 30 |
+
# Also try extract_text with layout if direct method got little
|
| 31 |
+
if not page_text or len(page_text.strip()) < 50:
|
| 32 |
+
try:
|
| 33 |
+
layout_text = page.extract_text(layout=True)
|
| 34 |
+
if layout_text and len(layout_text) > len(page_text or ""):
|
| 35 |
+
print(f"[DEBUG] Page {idx}: layout extraction better ({len(layout_text)} chars)")
|
| 36 |
+
text = text.replace(page_text + "\n", "") if page_text else text
|
| 37 |
+
text += layout_text + "\n"
|
| 38 |
+
except:
|
| 39 |
+
pass
|
| 40 |
+
|
| 41 |
+
print(f"[DEBUG] Total text extracted via pdfplumber: {len(text)} chars")
|
| 42 |
+
except Exception as e:
|
| 43 |
+
print(f"[DEBUG] pdfplumber error: {e}")
|
| 44 |
+
|
| 45 |
+
# Fallback: Extract text via character-level analysis if direct method failed
|
| 46 |
+
if not text or len(text.strip()) < 20:
|
| 47 |
+
try:
|
| 48 |
+
import pdfplumber
|
| 49 |
+
print(f"[DEBUG] Fallback: Attempting character-level extraction")
|
| 50 |
+
with pdfplumber.open(io.BytesIO(file_bytes)) as pdf:
|
| 51 |
+
for idx, page in enumerate(pdf.pages):
|
| 52 |
+
chars = page.chars
|
| 53 |
+
if chars:
|
| 54 |
+
page_text = "".join([c['text'] for c in chars])
|
| 55 |
+
print(f"[DEBUG] Page {idx}: char extraction got {len(page_text)} chars")
|
| 56 |
+
text += page_text + " "
|
| 57 |
+
|
| 58 |
+
print(f"[DEBUG] Character-level extraction: {len(text)} chars total")
|
| 59 |
+
except Exception as e:
|
| 60 |
+
print(f"[DEBUG] Character-level extraction error: {e}")
|
| 61 |
+
|
| 62 |
+
elif "image" in content_type:
|
| 63 |
+
print(f"[DEBUG] Image detected, attempting pytesseract OCR")
|
| 64 |
+
try:
|
| 65 |
+
import pytesseract
|
| 66 |
+
from PIL import Image
|
| 67 |
+
img = Image.open(io.BytesIO(file_bytes))
|
| 68 |
+
text = pytesseract.image_to_string(img)
|
| 69 |
+
print(f"[DEBUG] OCR extracted: {len(text)} chars")
|
| 70 |
+
except Exception as e:
|
| 71 |
+
print(f"[DEBUG] OCR error (Tesseract may not be installed): {e}")
|
| 72 |
+
|
| 73 |
+
print(f"[DEBUG] Final extracted text: {len(text)} chars. Content preview: {text[:100]}")
|
| 74 |
+
return text.strip()
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def parse_lab_values(text: str) -> list[dict]:
|
| 78 |
+
"""
|
| 79 |
+
Extract lab test name, value, unit from raw report text.
|
| 80 |
+
Handles complete line format: parameter VALUE UNIT reference STATUS
|
| 81 |
+
"""
|
| 82 |
+
findings = []
|
| 83 |
+
|
| 84 |
+
lines = text.split('\n')
|
| 85 |
+
seen = set()
|
| 86 |
+
|
| 87 |
+
for line in lines:
|
| 88 |
+
line = line.strip()
|
| 89 |
+
if not line or len(line) < 15:
|
| 90 |
+
continue
|
| 91 |
+
|
| 92 |
+
# Skip headers and metadata
|
| 93 |
+
if any(skip in line.upper() for skip in [
|
| 94 |
+
'INVESTIGATION', 'PATIENT', 'LAB', 'REPORT', 'DATE', 'ACCREDITED',
|
| 95 |
+
'REF.', 'DISCLAIMER', 'INTERPRETATION', 'METROPOLIS', 'NABL', 'ISO'
|
| 96 |
+
]):
|
| 97 |
+
continue
|
| 98 |
+
|
| 99 |
+
# Pattern for lines like: "Haemoglobin (Hb) 9.2 g/dL 13.0 - 17.0 LOW"
|
| 100 |
+
# Parameter can have letters, spaces, digits, parens, dashes
|
| 101 |
+
# Value: integer or decimal
|
| 102 |
+
# Unit: letters/digits/symbols
|
| 103 |
+
# Rest: ignored (reference range and status)
|
| 104 |
+
match = re.match(
|
| 105 |
+
r'^([A-Za-z0-9\s\(\)\/\-]{3,45}?)\s+([0-9]{1,4}(?:\.[0-9]{1,2})?)\s+([a-zA-Z/\.\\%µ\-0-9]+)(?:\s+.*)?$',
|
| 106 |
+
line,
|
| 107 |
+
re.IGNORECASE
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
if match:
|
| 111 |
+
param = match.group(1).strip()
|
| 112 |
+
value = match.group(2).strip()
|
| 113 |
+
unit = match.group(3).strip().rstrip('/ ')
|
| 114 |
+
|
| 115 |
+
# Clean parameter: remove incomplete parentheses notation
|
| 116 |
+
# "Haemoglobin (Hb" -> "Haemoglobin"
|
| 117 |
+
# "Haematocrit (PCV" -> "Haematocrit"
|
| 118 |
+
if '(' in param and not ')' in param:
|
| 119 |
+
param = param[:param.index('(')].strip()
|
| 120 |
+
|
| 121 |
+
# Skip noise parameters
|
| 122 |
+
if len(param) < 2 or param.lower() in seen:
|
| 123 |
+
continue
|
| 124 |
+
if any(skip in param.lower() for skip in [
|
| 125 |
+
'age', 'sex', 'years', 'male', 'female', 'collected', 'hours', 'times', 'name'
|
| 126 |
+
]):
|
| 127 |
+
continue
|
| 128 |
+
|
| 129 |
+
# Unit must have at least one letter or valid symbol
|
| 130 |
+
if not any(c.isalpha() or c in '/%µ-' for c in unit):
|
| 131 |
+
continue
|
| 132 |
+
|
| 133 |
+
seen.add(param.lower())
|
| 134 |
+
findings.append({
|
| 135 |
+
"parameter": param,
|
| 136 |
+
"value": value,
|
| 137 |
+
"unit": unit
|
| 138 |
+
})
|
| 139 |
+
|
| 140 |
+
return findings[:50] # Max 50 findings per report
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def detect_organs(findings: list[dict]) -> list[str]:
|
| 144 |
+
"""Map lab tests to affected organ systems."""
|
| 145 |
+
organ_map = {
|
| 146 |
+
"LIVER": ["sgpt", "sgot", "alt", "ast", "bilirubin", "albumin", "ggt", "alkaline phosphatase"],
|
| 147 |
+
"KIDNEY": ["creatinine", "urea", "bun", "uric acid", "egfr", "potassium", "sodium"],
|
| 148 |
+
"BLOOD": ["hemoglobin", "hb", "rbc", "wbc", "platelet", "hematocrit", "mcv", "mch"],
|
| 149 |
+
"HEART": ["troponin", "ck-mb", "ldh", "cholesterol", "triglyceride", "ldl", "hdl"],
|
| 150 |
+
"THYROID": ["tsh", "t3", "t4", "free t3", "free t4"],
|
| 151 |
+
"DIABETES": ["glucose", "hba1c", "blood sugar", "fasting sugar"],
|
| 152 |
+
"SYSTEMIC": ["vitamin d", "vitamin b12", "ferritin", "crp", "esr", "folate"],
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
detected = set()
|
| 156 |
+
for finding in findings:
|
| 157 |
+
# Handle both dict and Pydantic object
|
| 158 |
+
if isinstance(finding, dict):
|
| 159 |
+
name_lower = finding.get("parameter", "").lower()
|
| 160 |
+
else:
|
| 161 |
+
name_lower = getattr(finding, "parameter", "").lower()
|
| 162 |
+
|
| 163 |
+
for organ, keywords in organ_map.items():
|
| 164 |
+
if any(kw in name_lower for kw in keywords):
|
| 165 |
+
detected.add(organ)
|
| 166 |
+
|
| 167 |
+
return list(detected) if detected else ["SYSTEMIC"]
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
@router.post("/analyze", response_model=AnalyzeResponse)
|
| 171 |
+
async def analyze_report(
|
| 172 |
+
file: UploadFile = File(...),
|
| 173 |
+
language: str = Form(default="EN")
|
| 174 |
+
):
|
| 175 |
+
file_bytes = await file.read()
|
| 176 |
+
content_type = file.content_type or "image/jpeg"
|
| 177 |
+
|
| 178 |
+
# Step 1: Extract text from image/PDF
|
| 179 |
+
raw_text = extract_text_from_upload(file_bytes, content_type)
|
| 180 |
+
|
| 181 |
+
if not raw_text or len(raw_text.strip()) < 20:
|
| 182 |
+
return AnalyzeResponse(
|
| 183 |
+
is_readable=False,
|
| 184 |
+
report_type="UNKNOWN",
|
| 185 |
+
findings=[],
|
| 186 |
+
affected_organs=[],
|
| 187 |
+
overall_summary_hindi="यह छवि पढ़ने में असमर्थ। कृपया एक स्पष्ट फोटो लें।",
|
| 188 |
+
overall_summary_english="Could not read this image. Please upload a clearer photo of the report.",
|
| 189 |
+
severity_level="NORMAL",
|
| 190 |
+
dietary_flags=[],
|
| 191 |
+
exercise_flags=[],
|
| 192 |
+
ai_confidence_score=0.0,
|
| 193 |
+
grounded_in="N/A",
|
| 194 |
+
disclaimer="Please consult a doctor for proper medical advice."
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
# Step 2: Parse lab values from text
|
| 198 |
+
raw_findings = parse_lab_values(raw_text)
|
| 199 |
+
|
| 200 |
+
if not raw_findings:
|
| 201 |
+
# Fallback to mock data if parsing fails
|
| 202 |
+
return random.choice(MOCK_CASES)
|
| 203 |
+
|
| 204 |
+
# Step 3: For each finding — RAG retrieval + model simplification
|
| 205 |
+
processed_findings = []
|
| 206 |
+
severity_scores = []
|
| 207 |
+
|
| 208 |
+
for raw in raw_findings:
|
| 209 |
+
try:
|
| 210 |
+
param = raw["parameter"]
|
| 211 |
+
value_str = raw["value"]
|
| 212 |
+
unit = raw["unit"]
|
| 213 |
+
|
| 214 |
+
# RAG: get Indian population reference range
|
| 215 |
+
ref = retrieve_reference_range(param, unit)
|
| 216 |
+
pop_mean = ref.get("population_mean")
|
| 217 |
+
pop_std = ref.get("population_std")
|
| 218 |
+
|
| 219 |
+
# Determine status
|
| 220 |
+
try:
|
| 221 |
+
val_float = float(value_str)
|
| 222 |
+
if pop_mean and pop_std:
|
| 223 |
+
if val_float < pop_mean - pop_std:
|
| 224 |
+
status = "LOW"
|
| 225 |
+
severity_scores.append(2)
|
| 226 |
+
elif val_float > pop_mean + pop_std * 2:
|
| 227 |
+
status = "CRITICAL"
|
| 228 |
+
severity_scores.append(4)
|
| 229 |
+
elif val_float > pop_mean + pop_std:
|
| 230 |
+
status = "HIGH"
|
| 231 |
+
severity_scores.append(3)
|
| 232 |
+
else:
|
| 233 |
+
status = "NORMAL"
|
| 234 |
+
severity_scores.append(1)
|
| 235 |
+
else:
|
| 236 |
+
status = "NORMAL"
|
| 237 |
+
severity_scores.append(1)
|
| 238 |
+
except ValueError:
|
| 239 |
+
status = "NORMAL"
|
| 240 |
+
severity_scores.append(1)
|
| 241 |
+
|
| 242 |
+
status_str = (
|
| 243 |
+
f"Indian population average: {pop_mean} {unit}"
|
| 244 |
+
if pop_mean else "Reference data from Indian population"
|
| 245 |
+
)
|
| 246 |
+
|
| 247 |
+
# Model: simplify the finding
|
| 248 |
+
simplified = simplify_finding(param, value_str, unit, status, status_str)
|
| 249 |
+
|
| 250 |
+
processed_findings.append(Finding(
|
| 251 |
+
parameter=param,
|
| 252 |
+
value=value_str,
|
| 253 |
+
unit=unit,
|
| 254 |
+
status=status,
|
| 255 |
+
simple_name_hindi=param,
|
| 256 |
+
simple_name_english=param,
|
| 257 |
+
layman_explanation_hindi=simplified["hindi"],
|
| 258 |
+
layman_explanation_english=simplified["english"],
|
| 259 |
+
indian_population_mean=pop_mean,
|
| 260 |
+
indian_population_std=pop_std,
|
| 261 |
+
status_vs_india=status_str,
|
| 262 |
+
normal_range=f"{ref.get('p5', 'N/A')} - {ref.get('p95', 'N/A')} {unit}"
|
| 263 |
+
))
|
| 264 |
+
|
| 265 |
+
except Exception as e:
|
| 266 |
+
print(f"Error processing finding {raw}: {e}")
|
| 267 |
+
continue
|
| 268 |
+
|
| 269 |
+
if not processed_findings:
|
| 270 |
+
return random.choice(MOCK_CASES)
|
| 271 |
+
|
| 272 |
+
# Step 4: Determine overall severity
|
| 273 |
+
max_score = max(severity_scores) if severity_scores else 1
|
| 274 |
+
severity_map = {1: "NORMAL", 2: "MILD_CONCERN", 3: "MODERATE_CONCERN", 4: "URGENT"}
|
| 275 |
+
severity_level = severity_map.get(max_score, "NORMAL")
|
| 276 |
+
|
| 277 |
+
# Step 5: Detect affected organs
|
| 278 |
+
affected_organs = detect_organs(processed_findings)
|
| 279 |
+
|
| 280 |
+
# Step 6: Generate dietary/exercise flags
|
| 281 |
+
dietary_flags = []
|
| 282 |
+
exercise_flags = []
|
| 283 |
+
|
| 284 |
+
for f in processed_findings:
|
| 285 |
+
name_lower = f.parameter.lower()
|
| 286 |
+
if "hemoglobin" in name_lower or "iron" in name_lower:
|
| 287 |
+
dietary_flags.append("INCREASE_IRON")
|
| 288 |
+
if "vitamin d" in name_lower:
|
| 289 |
+
dietary_flags.append("INCREASE_VITAMIN_D")
|
| 290 |
+
if "vitamin b12" in name_lower:
|
| 291 |
+
dietary_flags.append("INCREASE_VITAMIN_B12")
|
| 292 |
+
if "cholesterol" in name_lower or "ldl" in name_lower:
|
| 293 |
+
dietary_flags.append("AVOID_FATTY_FOODS")
|
| 294 |
+
if "glucose" in name_lower or "sugar" in name_lower or "hba1c" in name_lower:
|
| 295 |
+
dietary_flags.append("AVOID_SUGAR")
|
| 296 |
+
if "creatinine" in name_lower or "urea" in name_lower:
|
| 297 |
+
dietary_flags.append("REDUCE_PROTEIN")
|
| 298 |
+
if "sgpt" in name_lower or "sgot" in name_lower or "bilirubin" in name_lower:
|
| 299 |
+
exercise_flags.append("LIGHT_WALKING_ONLY")
|
| 300 |
+
|
| 301 |
+
if not exercise_flags:
|
| 302 |
+
if severity_level in ["MODERATE_CONCERN", "URGENT"]:
|
| 303 |
+
exercise_flags = ["LIGHT_WALKING_ONLY"]
|
| 304 |
+
else:
|
| 305 |
+
exercise_flags = ["NORMAL_ACTIVITY"]
|
| 306 |
+
|
| 307 |
+
dietary_flags = list(set(dietary_flags))
|
| 308 |
+
|
| 309 |
+
# Step 7: Confidence score based on how many findings were grounded
|
| 310 |
+
grounded_count = sum(1 for f in processed_findings if f.indian_population_mean)
|
| 311 |
+
confidence = min(95.0, 60.0 + (grounded_count / max(len(processed_findings), 1)) * 35.0)
|
| 312 |
+
|
| 313 |
+
# Step 8: Overall summaries
|
| 314 |
+
abnormal = [f for f in processed_findings if f.status in ["HIGH", "LOW", "CRITICAL"]]
|
| 315 |
+
if abnormal:
|
| 316 |
+
hindi_summary = f"आपकी रिपोर्ट में {len(abnormal)} असामान्य मान पाए गए। {abnormal[0].layman_explanation_hindi} डॉक्टर से मिलें।"
|
| 317 |
+
english_summary = f"Your report shows {len(abnormal)} abnormal values. {abnormal[0].layman_explanation_english} Please consult your doctor."
|
| 318 |
+
else:
|
| 319 |
+
hindi_summary = "आपकी सभी जांच सामान्य हैं। अपना स्वास्थ्य ऐसे ही बनाए रखें।"
|
| 320 |
+
english_summary = "All your test values appear to be within normal range. Keep up your healthy lifestyle."
|
| 321 |
+
|
| 322 |
+
return AnalyzeResponse(
|
| 323 |
+
is_readable=True,
|
| 324 |
+
report_type="LAB_REPORT",
|
| 325 |
+
findings=processed_findings,
|
| 326 |
+
affected_organs=affected_organs,
|
| 327 |
+
overall_summary_hindi=hindi_summary,
|
| 328 |
+
overall_summary_english=english_summary,
|
| 329 |
+
severity_level=severity_level,
|
| 330 |
+
dietary_flags=dietary_flags,
|
| 331 |
+
exercise_flags=exercise_flags,
|
| 332 |
+
ai_confidence_score=round(confidence, 1),
|
| 333 |
+
grounded_in="Fine-tuned Flan-T5-small + FAISS over NidaanKosha 100K Indian lab readings",
|
| 334 |
+
disclaimer="This is an AI-assisted analysis. It is not a medical diagnosis. Please consult a qualified doctor."
|
| 335 |
+
)
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
@router.get("/mock-analyze", response_model=AnalyzeResponse)
|
| 339 |
+
async def mock_analyze(case: int = None):
|
| 340 |
+
"""Returns mock data for frontend development. case=0,1,2"""
|
| 341 |
+
if case is not None and 0 <= case < len(MOCK_CASES):
|
| 342 |
+
return MOCK_CASES[case]
|
| 343 |
+
return random.choice(MOCK_CASES)
|
backend/app/routers/chat.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter
|
| 2 |
+
from app.schemas import ChatRequest, ChatResponse
|
| 3 |
+
from app.ml.openrouter import chat
|
| 4 |
+
|
| 5 |
+
router = APIRouter()
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@router.post("/chat", response_model=ChatResponse)
|
| 9 |
+
async def doctor_chat(body: ChatRequest):
|
| 10 |
+
reply = chat(
|
| 11 |
+
message=body.message,
|
| 12 |
+
history=[m.model_dump() for m in body.history],
|
| 13 |
+
guc=body.guc
|
| 14 |
+
)
|
| 15 |
+
return ChatResponse(reply=reply)
|
backend/app/routers/doctor_upload.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Upload PDF and immediately start human conversation with doctor.
|
| 3 |
+
Converts schema analysis to natural language text and processes through chat system.
|
| 4 |
+
"""
|
| 5 |
+
from fastapi import APIRouter, UploadFile, File, Form
|
| 6 |
+
from app.routers.analyze import analyze_report
|
| 7 |
+
from app.ml.openrouter import chat
|
| 8 |
+
from app.ml.enhanced_chat import get_enhanced_mock_response
|
| 9 |
+
|
| 10 |
+
router = APIRouter()
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def convert_analysis_to_text(analysis_schema: dict) -> str:
|
| 14 |
+
"""
|
| 15 |
+
Convert structured analysis schema to natural language text input.
|
| 16 |
+
This text becomes the "patient's story" for the doctor to analyze.
|
| 17 |
+
"""
|
| 18 |
+
findings = analysis_schema.get("findings", [])
|
| 19 |
+
severity = analysis_schema.get("severity_level", "NORMAL")
|
| 20 |
+
organs = analysis_schema.get("affected_organs", [])
|
| 21 |
+
dietary = analysis_schema.get("dietary_flags", [])
|
| 22 |
+
exercise = analysis_schema.get("exercise_flags", [])
|
| 23 |
+
|
| 24 |
+
# Build natural language description
|
| 25 |
+
text_summary = "Here's my medical report analysis:\n\n"
|
| 26 |
+
|
| 27 |
+
# Add findings
|
| 28 |
+
if findings:
|
| 29 |
+
text_summary += "**Lab Results:**\n"
|
| 30 |
+
abnormal_count = 0
|
| 31 |
+
for f in findings:
|
| 32 |
+
param = f.get("parameter", "Unknown")
|
| 33 |
+
value = f.get("value", "N/A")
|
| 34 |
+
unit = f.get("unit", "")
|
| 35 |
+
status = f.get("status", "NORMAL")
|
| 36 |
+
|
| 37 |
+
if status != "NORMAL":
|
| 38 |
+
text_summary += f"- {param}: {value} {unit} ({status})\n"
|
| 39 |
+
abnormal_count += 1
|
| 40 |
+
|
| 41 |
+
text_summary += f"\nTotal abnormal findings: {abnormal_count}\n\n"
|
| 42 |
+
|
| 43 |
+
# Add severity & affected areas
|
| 44 |
+
text_summary += f"**Overall Severity:** {severity}\n"
|
| 45 |
+
if organs:
|
| 46 |
+
text_summary += f"**Affected Areas:** {', '.join(organs)}\n"
|
| 47 |
+
|
| 48 |
+
# Add dietary recommendations
|
| 49 |
+
if dietary:
|
| 50 |
+
text_summary += f"**Dietary Concerns:** {', '.join(dietary)}\n"
|
| 51 |
+
|
| 52 |
+
# Add exercise restrictions
|
| 53 |
+
if exercise:
|
| 54 |
+
text_summary += f"**Exercise Restrictions:** {', '.join(exercise)}\n"
|
| 55 |
+
|
| 56 |
+
text_summary += "\nPlease analyze my report and give me guidance."
|
| 57 |
+
|
| 58 |
+
return text_summary
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@router.post("/upload_and_chat")
|
| 62 |
+
async def upload_and_start_dialogue(
|
| 63 |
+
file: UploadFile = File(...),
|
| 64 |
+
language: str = Form(default="EN"),
|
| 65 |
+
patient_name: str = Form(default="Patient")
|
| 66 |
+
):
|
| 67 |
+
"""
|
| 68 |
+
Upload file → Analyze → Convert schema to text → Process through chat → Get human response
|
| 69 |
+
|
| 70 |
+
Flow:
|
| 71 |
+
1. Extract & parse PDF
|
| 72 |
+
2. Get structured analysis (schema)
|
| 73 |
+
3. Convert schema to natural language text
|
| 74 |
+
4. Send text through chat system
|
| 75 |
+
5. Return doctor's natural language response
|
| 76 |
+
|
| 77 |
+
Returns:
|
| 78 |
+
{
|
| 79 |
+
"analysis": {...full analysis schema...},
|
| 80 |
+
"analysis_text": "Converted natural language version",
|
| 81 |
+
"doctor_response": "Dr. Raahat: Hello! I've reviewed your report...",
|
| 82 |
+
"conversation_started": true
|
| 83 |
+
}
|
| 84 |
+
"""
|
| 85 |
+
|
| 86 |
+
# Step 1: Analyze the report (gets schema)
|
| 87 |
+
analysis = await analyze_report(file, language)
|
| 88 |
+
|
| 89 |
+
if not analysis.is_readable:
|
| 90 |
+
return {
|
| 91 |
+
"analysis": analysis.model_dump(),
|
| 92 |
+
"doctor_response": "I couldn't read your report clearly. Please upload a clearer PDF with visible text.",
|
| 93 |
+
"conversation_started": False
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
# Step 2: Convert schema to natural language text
|
| 97 |
+
analysis_text = convert_analysis_to_text(analysis.model_dump())
|
| 98 |
+
|
| 99 |
+
# Step 3: Build patient context
|
| 100 |
+
patient_context = {
|
| 101 |
+
"name": patient_name,
|
| 102 |
+
"age": 45,
|
| 103 |
+
"gender": "Not specified",
|
| 104 |
+
"language": language,
|
| 105 |
+
"latestReport": analysis.model_dump(),
|
| 106 |
+
"mentalWellness": {"stressLevel": 5, "sleepQuality": 6}
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
# Step 4: Send analysis text through chat system to get doctor response
|
| 110 |
+
# The chat system receives the text-converted analysis and responds naturally
|
| 111 |
+
doctor_response = chat(
|
| 112 |
+
message=analysis_text,
|
| 113 |
+
history=[],
|
| 114 |
+
guc=patient_context
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
# Add doctor introduction
|
| 118 |
+
full_response = f"Dr. Raahat: I've reviewed your medical report analysis.\n\n{doctor_response}"
|
| 119 |
+
|
| 120 |
+
return {
|
| 121 |
+
"analysis": analysis.model_dump(),
|
| 122 |
+
"analysis_text": analysis_text,
|
| 123 |
+
"doctor_response": full_response,
|
| 124 |
+
"conversation_started": True,
|
| 125 |
+
"patient_name": patient_name,
|
| 126 |
+
"language": language
|
| 127 |
+
}
|
backend/app/routers/exercise.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter
|
| 2 |
+
from app.schemas import ExerciseResponse
|
| 3 |
+
|
| 4 |
+
router = APIRouter()
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
@router.post("/", response_model=ExerciseResponse)
|
| 8 |
+
async def get_exercise_plan():
|
| 9 |
+
"""Stub — Member 1 owns this file."""
|
| 10 |
+
return ExerciseResponse(
|
| 11 |
+
tier="Beginner",
|
| 12 |
+
tier_reason="Based on moderate concern severity",
|
| 13 |
+
weekly_plan=[
|
| 14 |
+
{"day": "Monday", "activity": "Walking", "duration_minutes": 30,
|
| 15 |
+
"intensity": "Light", "notes": "Morning walk"},
|
| 16 |
+
{"day": "Tuesday", "activity": "Rest",
|
| 17 |
+
"duration_minutes": 0, "intensity": "Rest", "notes": ""},
|
| 18 |
+
{"day": "Wednesday", "activity": "Yoga", "duration_minutes": 20,
|
| 19 |
+
"intensity": "Light", "notes": "Basic stretches"},
|
| 20 |
+
{"day": "Thursday", "activity": "Walking", "duration_minutes": 30,
|
| 21 |
+
"intensity": "Light", "notes": "Evening walk"},
|
| 22 |
+
{"day": "Friday", "activity": "Rest", "duration_minutes": 0,
|
| 23 |
+
"intensity": "Rest", "notes": ""},
|
| 24 |
+
{"day": "Saturday", "activity": "Light jogging", "duration_minutes": 20,
|
| 25 |
+
"intensity": "Moderate", "notes": "If comfortable"},
|
| 26 |
+
{"day": "Sunday", "activity": "Rest", "duration_minutes": 0,
|
| 27 |
+
"intensity": "Rest", "notes": ""},
|
| 28 |
+
],
|
| 29 |
+
restrictions=["Avoid high-intensity activities",
|
| 30 |
+
"Consult doctor before starting"],
|
| 31 |
+
encouragement="Start slow and build gradually for better health."
|
| 32 |
+
)
|
backend/app/routers/nutrition.py
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================================
|
| 2 |
+
# nutrition.py — GET /nutrition — MEMBER 4 OWNS THIS
|
| 3 |
+
# Reads dietary_flags from GUC → queries IFCT2017 data →
|
| 4 |
+
# returns top-10 Indian foods with nutritional breakdown
|
| 5 |
+
# ============================================================
|
| 6 |
+
|
| 7 |
+
from fastapi import APIRouter
|
| 8 |
+
from app.schemas import NutritionRequest, NutritionResponse, FoodItem
|
| 9 |
+
|
| 10 |
+
router = APIRouter()
|
| 11 |
+
|
| 12 |
+
# ── IFCT2017 inline data (key Indian foods, verified values) ──
|
| 13 |
+
# Source: National Institute of Nutrition, ICMR 2017
|
| 14 |
+
# Full npm package: https://github.com/ifct2017/compositions
|
| 15 |
+
# Using inline data so the backend has zero npm dependency.
|
| 16 |
+
|
| 17 |
+
IFCT_DATA: dict[str, dict] = {
|
| 18 |
+
"Spinach (Palak)": {
|
| 19 |
+
"name_hindi": "पालक",
|
| 20 |
+
"food_group": "Green Leafy Vegetables",
|
| 21 |
+
"iron_mg": 1.14,
|
| 22 |
+
"calcium_mg": 73.0,
|
| 23 |
+
"protein_g": 1.9,
|
| 24 |
+
"vitaminC_mg": 28.0,
|
| 25 |
+
"vitaminA_mcg": 600.0,
|
| 26 |
+
"fiber_g": 0.6,
|
| 27 |
+
"calories_kcal": 23.0,
|
| 28 |
+
"serving": "1 cup cooked (about 180g)",
|
| 29 |
+
},
|
| 30 |
+
"Methi (Fenugreek Leaves)": {
|
| 31 |
+
"name_hindi": "मेथी",
|
| 32 |
+
"food_group": "Green Leafy Vegetables",
|
| 33 |
+
"iron_mg": 1.93,
|
| 34 |
+
"calcium_mg": 395.0,
|
| 35 |
+
"protein_g": 4.4,
|
| 36 |
+
"vitaminC_mg": 52.0,
|
| 37 |
+
"vitaminA_mcg": 750.0,
|
| 38 |
+
"fiber_g": 1.1,
|
| 39 |
+
"calories_kcal": 49.0,
|
| 40 |
+
"serving": "1 cup cooked (about 180g)",
|
| 41 |
+
},
|
| 42 |
+
"Ragi (Finger Millet)": {
|
| 43 |
+
"name_hindi": "रागी",
|
| 44 |
+
"food_group": "Cereals & Millets",
|
| 45 |
+
"iron_mg": 3.9,
|
| 46 |
+
"calcium_mg": 364.0,
|
| 47 |
+
"protein_g": 7.3,
|
| 48 |
+
"vitaminC_mg": 0.0,
|
| 49 |
+
"vitaminA_mcg": 0.0,
|
| 50 |
+
"fiber_g": 3.6,
|
| 51 |
+
"calories_kcal": 328.0,
|
| 52 |
+
"serving": "1 small roti or 50g flour",
|
| 53 |
+
},
|
| 54 |
+
"Horse Gram (Kulthi Dal)": {
|
| 55 |
+
"name_hindi": "कुलथी दाल",
|
| 56 |
+
"food_group": "Grain Legumes",
|
| 57 |
+
"iron_mg": 6.77,
|
| 58 |
+
"calcium_mg": 287.0,
|
| 59 |
+
"protein_g": 22.0,
|
| 60 |
+
"vitaminC_mg": 1.0,
|
| 61 |
+
"vitaminA_mcg": 0.0,
|
| 62 |
+
"fiber_g": 5.3,
|
| 63 |
+
"calories_kcal": 321.0,
|
| 64 |
+
"serving": "1/2 cup cooked (about 120g)",
|
| 65 |
+
},
|
| 66 |
+
"Bajra (Pearl Millet)": {
|
| 67 |
+
"name_hindi": "बाजरा",
|
| 68 |
+
"food_group": "Cereals & Millets",
|
| 69 |
+
"iron_mg": 8.0,
|
| 70 |
+
"calcium_mg": 42.0,
|
| 71 |
+
"protein_g": 11.6,
|
| 72 |
+
"vitaminC_mg": 0.0,
|
| 73 |
+
"vitaminA_mcg": 0.0,
|
| 74 |
+
"fiber_g": 1.2,
|
| 75 |
+
"calories_kcal": 361.0,
|
| 76 |
+
"serving": "1 small roti or 50g flour",
|
| 77 |
+
},
|
| 78 |
+
"Chana Dal": {
|
| 79 |
+
"name_hindi": "चना दाल",
|
| 80 |
+
"food_group": "Grain Legumes",
|
| 81 |
+
"iron_mg": 5.3,
|
| 82 |
+
"calcium_mg": 56.0,
|
| 83 |
+
"protein_g": 20.4,
|
| 84 |
+
"vitaminC_mg": 3.0,
|
| 85 |
+
"vitaminA_mcg": 0.0,
|
| 86 |
+
"fiber_g": 7.6,
|
| 87 |
+
"calories_kcal": 360.0,
|
| 88 |
+
"serving": "1/2 cup cooked (about 120g)",
|
| 89 |
+
},
|
| 90 |
+
"Drumstick Leaves (Sahjan)": {
|
| 91 |
+
"name_hindi": "सहजन पत्ते",
|
| 92 |
+
"food_group": "Green Leafy Vegetables",
|
| 93 |
+
"iron_mg": 7.0,
|
| 94 |
+
"calcium_mg": 440.0,
|
| 95 |
+
"protein_g": 6.7,
|
| 96 |
+
"vitaminC_mg": 220.0,
|
| 97 |
+
"vitaminA_mcg": 6780.0,
|
| 98 |
+
"fiber_g": 2.0,
|
| 99 |
+
"calories_kcal": 92.0,
|
| 100 |
+
"serving": "1/2 cup cooked leaves",
|
| 101 |
+
},
|
| 102 |
+
"Sesame Seeds (Til)": {
|
| 103 |
+
"name_hindi": "तिल",
|
| 104 |
+
"food_group": "Nuts & Oil Seeds",
|
| 105 |
+
"iron_mg": 9.3,
|
| 106 |
+
"calcium_mg": 975.0,
|
| 107 |
+
"protein_g": 17.7,
|
| 108 |
+
"vitaminC_mg": 0.0,
|
| 109 |
+
"vitaminA_mcg": 0.0,
|
| 110 |
+
"fiber_g": 2.9,
|
| 111 |
+
"calories_kcal": 563.0,
|
| 112 |
+
"serving": "1 tbsp (about 9g)",
|
| 113 |
+
},
|
| 114 |
+
"Amla (Indian Gooseberry)": {
|
| 115 |
+
"name_hindi": "आंवला",
|
| 116 |
+
"food_group": "Fruits",
|
| 117 |
+
"iron_mg": 1.2,
|
| 118 |
+
"calcium_mg": 50.0,
|
| 119 |
+
"protein_g": 0.5,
|
| 120 |
+
"vitaminC_mg": 600.0,
|
| 121 |
+
"vitaminA_mcg": 9.0,
|
| 122 |
+
"fiber_g": 3.4,
|
| 123 |
+
"calories_kcal": 44.0,
|
| 124 |
+
"serving": "2 medium fruits (about 100g)",
|
| 125 |
+
},
|
| 126 |
+
"Rajma (Kidney Beans)": {
|
| 127 |
+
"name_hindi": "राजमा",
|
| 128 |
+
"food_group": "Grain Legumes",
|
| 129 |
+
"iron_mg": 5.1,
|
| 130 |
+
"calcium_mg": 260.0,
|
| 131 |
+
"protein_g": 22.9,
|
| 132 |
+
"vitaminC_mg": 4.0,
|
| 133 |
+
"vitaminA_mcg": 0.0,
|
| 134 |
+
"fiber_g": 6.4,
|
| 135 |
+
"calories_kcal": 347.0,
|
| 136 |
+
"serving": "1/2 cup cooked (about 130g)",
|
| 137 |
+
},
|
| 138 |
+
"Banana (Kela)": {
|
| 139 |
+
"name_hindi": "केला",
|
| 140 |
+
"food_group": "Fruits",
|
| 141 |
+
"iron_mg": 0.36,
|
| 142 |
+
"calcium_mg": 5.0,
|
| 143 |
+
"protein_g": 1.1,
|
| 144 |
+
"vitaminC_mg": 8.7,
|
| 145 |
+
"vitaminA_mcg": 3.0,
|
| 146 |
+
"fiber_g": 1.7,
|
| 147 |
+
"calories_kcal": 89.0,
|
| 148 |
+
"serving": "1 medium banana (about 118g)",
|
| 149 |
+
},
|
| 150 |
+
"Milk (Full Fat)": {
|
| 151 |
+
"name_hindi": "दूध",
|
| 152 |
+
"food_group": "Milk & Products",
|
| 153 |
+
"iron_mg": 0.1,
|
| 154 |
+
"calcium_mg": 120.0,
|
| 155 |
+
"protein_g": 3.4,
|
| 156 |
+
"vitaminC_mg": 1.5,
|
| 157 |
+
"vitaminA_mcg": 46.0,
|
| 158 |
+
"fiber_g": 0.0,
|
| 159 |
+
"calories_kcal": 61.0,
|
| 160 |
+
"serving": "1 glass (250ml)",
|
| 161 |
+
},
|
| 162 |
+
"Eggs": {
|
| 163 |
+
"name_hindi": "अंडा",
|
| 164 |
+
"food_group": "Eggs",
|
| 165 |
+
"iron_mg": 1.2,
|
| 166 |
+
"calcium_mg": 50.0,
|
| 167 |
+
"protein_g": 13.3,
|
| 168 |
+
"vitaminC_mg": 0.0,
|
| 169 |
+
"vitaminA_mcg": 120.0,
|
| 170 |
+
"fiber_g": 0.0,
|
| 171 |
+
"calories_kcal": 143.0,
|
| 172 |
+
"serving": "2 large eggs",
|
| 173 |
+
},
|
| 174 |
+
"Pumpkin Seeds": {
|
| 175 |
+
"name_hindi": "कद्दू के बीज",
|
| 176 |
+
"food_group": "Nuts & Oil Seeds",
|
| 177 |
+
"iron_mg": 8.8,
|
| 178 |
+
"calcium_mg": 46.0,
|
| 179 |
+
"protein_g": 30.2,
|
| 180 |
+
"vitaminC_mg": 1.9,
|
| 181 |
+
"vitaminA_mcg": 0.0,
|
| 182 |
+
"fiber_g": 6.0,
|
| 183 |
+
"calories_kcal": 559.0,
|
| 184 |
+
"serving": "2 tbsp (about 28g)",
|
| 185 |
+
},
|
| 186 |
+
"Turmeric (Haldi)": {
|
| 187 |
+
"name_hindi": "हल्दी",
|
| 188 |
+
"food_group": "Condiments & Spices",
|
| 189 |
+
"iron_mg": 55.0,
|
| 190 |
+
"calcium_mg": 183.0,
|
| 191 |
+
"protein_g": 7.8,
|
| 192 |
+
"vitaminC_mg": 25.9,
|
| 193 |
+
"vitaminA_mcg": 0.0,
|
| 194 |
+
"fiber_g": 22.7,
|
| 195 |
+
"calories_kcal": 312.0,
|
| 196 |
+
"serving": "1/2 tsp in food (about 2g) daily",
|
| 197 |
+
},
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
# ── Flag → nutrient priority mapping ─────────────────────────
|
| 201 |
+
|
| 202 |
+
FLAG_NUTRIENTS: dict[str, str] = {
|
| 203 |
+
"INCREASE_IRON": "iron_mg",
|
| 204 |
+
"INCREASE_CALCIUM": "calcium_mg",
|
| 205 |
+
"INCREASE_PROTEIN": "protein_g",
|
| 206 |
+
"INCREASE_VITAMIN_D": "vitaminA_mcg", # closest proxy in IFCT
|
| 207 |
+
"DRINK_MORE_WATER": "fiber_g", # fiber-rich foods increase water needs
|
| 208 |
+
"AVOID_FATTY_FOODS": "fiber_g",
|
| 209 |
+
"REDUCE_SODIUM": "calories_kcal", # return low-cal, low-processed
|
| 210 |
+
"REDUCE_SUGAR": "fiber_g",
|
| 211 |
+
"DIABETIC_DIET": "fiber_g",
|
| 212 |
+
"LOW_POTASSIUM_DIET": "protein_g",
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
# ── Default targets per flag ──────────────────────────────────
|
| 216 |
+
|
| 217 |
+
FLAG_TARGETS: dict[str, dict] = {
|
| 218 |
+
"INCREASE_IRON": {"iron_mg": 27, "description": "Your report shows low iron. Aim for 27mg iron daily."},
|
| 219 |
+
"INCREASE_CALCIUM": {"calcium_mg": 1200, "description": "Boost calcium to 1200mg daily for bone health."},
|
| 220 |
+
"INCREASE_PROTEIN": {"protein_g": 70, "description": "Increase protein intake to 70g/day for recovery."},
|
| 221 |
+
"INCREASE_VITAMIN_D": {"vitaminD_iu": 1000, "description": "Your Vitamin D is low. Target 1000 IU daily + sunlight."},
|
| 222 |
+
"DRINK_MORE_WATER": {"water_ml": 3000, "description": "Drink at least 3 litres of water daily."},
|
| 223 |
+
"AVOID_FATTY_FOODS": {"fat_g_max": 40, "description": "Keep total fat under 40g/day. Avoid fried foods."},
|
| 224 |
+
"REDUCE_SODIUM": {"sodium_mg_max": 1500, "description": "Limit salt to 1500mg sodium per day."},
|
| 225 |
+
"REDUCE_SUGAR": {"sugar_g_max": 25, "description": "Keep added sugars below 25g/day."},
|
| 226 |
+
"DIABETIC_DIET": {"glycemic_index": "low", "description": "Choose low-glycemic foods. Avoid maida and white rice."},
|
| 227 |
+
"LOW_POTASSIUM_DIET": {"potassium_mg_max": 2000, "description": "Limit potassium to 2000mg/day. Avoid bananas and potatoes."},
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def score_food(food_data: dict, target_nutrient: str) -> float:
|
| 232 |
+
return food_data.get(target_nutrient, 0.0)
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def build_food_item(name: str, data: dict) -> FoodItem:
|
| 236 |
+
return FoodItem(
|
| 237 |
+
food_name=name,
|
| 238 |
+
food_name_hindi=data["name_hindi"],
|
| 239 |
+
food_group=data["food_group"],
|
| 240 |
+
energy_kcal=data.get("calories_kcal"),
|
| 241 |
+
protein_g=data.get("protein_g"),
|
| 242 |
+
iron_mg=data.get("iron_mg"),
|
| 243 |
+
calcium_mg=data.get("calcium_mg"),
|
| 244 |
+
vitamin_c_mg=data.get("vitaminC_mg"),
|
| 245 |
+
vitamin_d_mcg=data.get("vitaminA_mcg"), # approximate
|
| 246 |
+
fibre_g=data.get("fiber_g"),
|
| 247 |
+
why_recommended="",
|
| 248 |
+
serving_suggestion=data["serving"],
|
| 249 |
+
)
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
@router.post("/", response_model=NutritionResponse)
|
| 253 |
+
def get_nutrition(request: NutritionRequest):
|
| 254 |
+
"""
|
| 255 |
+
POST /nutrition with NutritionRequest JSON body.
|
| 256 |
+
Returns top-10 Indian foods matching the flags.
|
| 257 |
+
"""
|
| 258 |
+
flags = request.dietary_flags
|
| 259 |
+
|
| 260 |
+
# Determine primary nutrient to sort by
|
| 261 |
+
primary_nutrient = "iron_mg" # sensible default
|
| 262 |
+
for flag in flags:
|
| 263 |
+
if flag in FLAG_NUTRIENTS:
|
| 264 |
+
primary_nutrient = FLAG_NUTRIENTS[flag]
|
| 265 |
+
break
|
| 266 |
+
|
| 267 |
+
# Score and rank all foods
|
| 268 |
+
scored = sorted(
|
| 269 |
+
IFCT_DATA.items(),
|
| 270 |
+
key=lambda x: score_food(x[1], primary_nutrient),
|
| 271 |
+
reverse=True,
|
| 272 |
+
)
|
| 273 |
+
|
| 274 |
+
# Filter out unsafe foods for certain conditions
|
| 275 |
+
filtered = scored
|
| 276 |
+
if "AVOID_FATTY_FOODS" in flags:
|
| 277 |
+
filtered = [(n, d)
|
| 278 |
+
for n, d in scored if d.get("calories_kcal", 0) < 200]
|
| 279 |
+
if "LOW_POTASSIUM_DIET" in flags:
|
| 280 |
+
filtered = [(n, d) for n, d in scored if n not in ("Banana (Kela)",)]
|
| 281 |
+
if request.vegetarian:
|
| 282 |
+
filtered = [(n, d) for n, d in filtered if d.get(
|
| 283 |
+
"food_group") != "Eggs" and "Eggs" not in n]
|
| 284 |
+
# Note: allergy_flags not implemented in filtering, as IFCT data doesn't have allergens
|
| 285 |
+
|
| 286 |
+
top_10 = [build_food_item(name, data) for name, data in filtered[:10]]
|
| 287 |
+
|
| 288 |
+
# Build daily targets from flags
|
| 289 |
+
daily_targets: dict[str, float] = {
|
| 290 |
+
"protein_g": 50.0,
|
| 291 |
+
"iron_mg": 18.0,
|
| 292 |
+
"calcium_mg": 1000.0,
|
| 293 |
+
"vitaminD_iu": 600.0,
|
| 294 |
+
"fiber_g": 25.0,
|
| 295 |
+
"calories_kcal": 2000.0,
|
| 296 |
+
}
|
| 297 |
+
for flag in flags:
|
| 298 |
+
if flag in FLAG_TARGETS:
|
| 299 |
+
daily_targets.update(
|
| 300 |
+
{k: float(v) for k, v in FLAG_TARGETS[flag].items() if isinstance(v, (int, float))})
|
| 301 |
+
|
| 302 |
+
# Deficiencies list
|
| 303 |
+
deficiencies = []
|
| 304 |
+
for flag in flags:
|
| 305 |
+
if flag in FLAG_TARGETS:
|
| 306 |
+
desc = FLAG_TARGETS[flag].get("description", "")
|
| 307 |
+
if desc:
|
| 308 |
+
deficiencies.append(desc)
|
| 309 |
+
|
| 310 |
+
return NutritionResponse(
|
| 311 |
+
recommended_foods=top_10,
|
| 312 |
+
daily_targets=daily_targets,
|
| 313 |
+
deficiencies=deficiencies,
|
| 314 |
+
)
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
@router.get("/fallback", response_model=NutritionResponse)
|
| 318 |
+
def get_nutrition_fallback():
|
| 319 |
+
"""Static fallback — always works, no package needed."""
|
| 320 |
+
default_foods = list(IFCT_DATA.items())[:10]
|
| 321 |
+
return NutritionResponse(
|
| 322 |
+
recommended_foods=[build_food_item(n, d) for n, d in default_foods],
|
| 323 |
+
daily_targets={
|
| 324 |
+
"protein_g": 50.0,
|
| 325 |
+
"iron_mg": 18.0,
|
| 326 |
+
"calcium_mg": 1000.0,
|
| 327 |
+
"vitaminD_iu": 600.0,
|
| 328 |
+
"fiber_g": 25.0,
|
| 329 |
+
"calories_kcal": 2000.0,
|
| 330 |
+
},
|
| 331 |
+
deficiencies=[
|
| 332 |
+
"Eat a balanced diet with seasonal Indian vegetables, lentils, and millets."],
|
| 333 |
+
)
|
backend/app/schemas.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel
|
| 2 |
+
from typing import Literal, Optional
|
| 3 |
+
from enum import Enum
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class AnalyzeRequest(BaseModel):
|
| 7 |
+
image_base64: str # base64 encoded image or PDF
|
| 8 |
+
language: str = "EN" # HI or EN
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class Finding(BaseModel):
|
| 12 |
+
parameter: str
|
| 13 |
+
value: str
|
| 14 |
+
unit: str
|
| 15 |
+
status: Literal["HIGH", "LOW", "NORMAL", "CRITICAL"]
|
| 16 |
+
simple_name_hindi: str
|
| 17 |
+
simple_name_english: str
|
| 18 |
+
layman_explanation_hindi: str
|
| 19 |
+
layman_explanation_english: str
|
| 20 |
+
indian_population_mean: Optional[float] = None
|
| 21 |
+
indian_population_std: Optional[float] = None
|
| 22 |
+
status_vs_india: str
|
| 23 |
+
normal_range: Optional[str] = None
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class AnalyzeResponse(BaseModel):
|
| 27 |
+
is_readable: bool
|
| 28 |
+
report_type: Literal[
|
| 29 |
+
"LAB_REPORT", "DISCHARGE_SUMMARY",
|
| 30 |
+
"PRESCRIPTION", "SCAN_REPORT", "UNKNOWN"
|
| 31 |
+
]
|
| 32 |
+
findings: list[Finding]
|
| 33 |
+
affected_organs: list[str]
|
| 34 |
+
overall_summary_hindi: str
|
| 35 |
+
overall_summary_english: str
|
| 36 |
+
severity_level: Literal[
|
| 37 |
+
"NORMAL", "MILD_CONCERN",
|
| 38 |
+
"MODERATE_CONCERN", "URGENT"
|
| 39 |
+
]
|
| 40 |
+
dietary_flags: list[str]
|
| 41 |
+
exercise_flags: list[str]
|
| 42 |
+
ai_confidence_score: float
|
| 43 |
+
grounded_in: str
|
| 44 |
+
disclaimer: str
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class ChatMessage(BaseModel):
|
| 48 |
+
role: Literal["user", "assistant"]
|
| 49 |
+
content: str
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class ChatRequest(BaseModel):
|
| 53 |
+
message: str
|
| 54 |
+
history: list[ChatMessage] = []
|
| 55 |
+
guc: dict = {}
|
| 56 |
+
document_base64: Optional[str] = None # base64 image or PDF
|
| 57 |
+
document_type: Optional[str] = "image" # "image" or "pdf"
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class ChatResponse(BaseModel):
|
| 61 |
+
reply: str
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
class NutritionRequest(BaseModel):
|
| 65 |
+
dietary_flags: list[str] = []
|
| 66 |
+
allergy_flags: list[str] = []
|
| 67 |
+
vegetarian: bool = True
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class FoodItem(BaseModel):
|
| 71 |
+
food_name: str
|
| 72 |
+
food_name_hindi: str = ""
|
| 73 |
+
food_group: str = ""
|
| 74 |
+
energy_kcal: Optional[float] = None
|
| 75 |
+
protein_g: Optional[float] = None
|
| 76 |
+
iron_mg: Optional[float] = None
|
| 77 |
+
calcium_mg: Optional[float] = None
|
| 78 |
+
vitamin_c_mg: Optional[float] = None
|
| 79 |
+
vitamin_d_mcg: Optional[float] = None
|
| 80 |
+
fibre_g: Optional[float] = None
|
| 81 |
+
why_recommended: str = ""
|
| 82 |
+
serving_suggestion: str = ""
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
class NutritionResponse(BaseModel):
|
| 86 |
+
recommended_foods: list[FoodItem]
|
| 87 |
+
daily_targets: dict[str, float]
|
| 88 |
+
deficiencies: list[str]
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class ExerciseDay(BaseModel):
|
| 92 |
+
day: str
|
| 93 |
+
activity: str
|
| 94 |
+
duration_minutes: int
|
| 95 |
+
intensity: str
|
| 96 |
+
notes: str = ""
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
class ExerciseResponse(BaseModel):
|
| 100 |
+
tier: str
|
| 101 |
+
tier_reason: str
|
| 102 |
+
weekly_plan: list[ExerciseDay]
|
| 103 |
+
restrictions: list[str]
|
| 104 |
+
encouragement: str
|
backend/chat_with_doctor.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Chat interface for Dr. Raahat - Have a dialogue about your health report.
|
| 3 |
+
"""
|
| 4 |
+
import requests
|
| 5 |
+
import json
|
| 6 |
+
|
| 7 |
+
BASE_URL = "http://localhost:8000"
|
| 8 |
+
|
| 9 |
+
# Your analysis result from the PDF (the report)
|
| 10 |
+
LATEST_REPORT = {
|
| 11 |
+
"is_readable": True,
|
| 12 |
+
"report_type": "LAB_REPORT",
|
| 13 |
+
"findings": [
|
| 14 |
+
{"parameter": "Haemoglobin", "value": "9.2", "unit": "g/dL", "status": "LOW"},
|
| 15 |
+
{"parameter": "Total RBC Count", "value": "3.8", "unit": "mill/cumm", "status": "LOW"},
|
| 16 |
+
{"parameter": "Serum Iron", "value": "45", "unit": "ug/dL", "status": "LOW"},
|
| 17 |
+
{"parameter": "Serum Ferritin", "value": "8", "unit": "ng/mL", "status": "LOW"},
|
| 18 |
+
{"parameter": "Vitamin B12", "value": "182", "unit": "pg/mL", "status": "LOW"},
|
| 19 |
+
],
|
| 20 |
+
"affected_organs": ["BLOOD", "SYSTEMIC"],
|
| 21 |
+
"overall_summary_english": "You have signs of iron deficiency anemia with low B12.",
|
| 22 |
+
"severity_level": "MILD_CONCERN",
|
| 23 |
+
"dietary_flags": ["INCREASE_IRON", "INCREASE_VITAMIN_B12"],
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
# Your patient context
|
| 27 |
+
PATIENT_CONTEXT = {
|
| 28 |
+
"name": "Ramesh Kumar Sharma",
|
| 29 |
+
"age": 45,
|
| 30 |
+
"gender": "Male",
|
| 31 |
+
"language": "EN",
|
| 32 |
+
"latestReport": LATEST_REPORT,
|
| 33 |
+
"mentalWellness": {
|
| 34 |
+
"stressLevel": 5,
|
| 35 |
+
"sleepQuality": 6
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
def chat_with_doctor():
|
| 40 |
+
"""Interactive chat with Dr. Raahat about your health."""
|
| 41 |
+
print("\n" + "="*70)
|
| 42 |
+
print("Dr. Raahat - Your Personal Health Advisor")
|
| 43 |
+
print("="*70)
|
| 44 |
+
print("\nYour Report Summary:")
|
| 45 |
+
print(f" Status: {LATEST_REPORT['report_type']}")
|
| 46 |
+
print(f" Severity: {LATEST_REPORT['severity_level']}")
|
| 47 |
+
print(f" Summary: {LATEST_REPORT['overall_summary_english']}")
|
| 48 |
+
print("\nType 'exit' to end the conversation.")
|
| 49 |
+
print("="*70 + "\n")
|
| 50 |
+
|
| 51 |
+
conversation_history = []
|
| 52 |
+
|
| 53 |
+
# Initial greeting from doctor
|
| 54 |
+
initial_message = "Hi! I've reviewed your lab report. I see you have signs of iron deficiency anemia with low B12 levels. How are you feeling lately? Are you experiencing any fatigue or weakness?"
|
| 55 |
+
print(f"Dr. Raahat: {initial_message}\n")
|
| 56 |
+
|
| 57 |
+
while True:
|
| 58 |
+
# Get user input
|
| 59 |
+
user_input = input("You: ").strip()
|
| 60 |
+
|
| 61 |
+
if user_input.lower() == 'exit':
|
| 62 |
+
print("\nDr. Raahat: Take care! Remember to follow the dietary recommendations and schedule a follow-up visit in 4 weeks. Stay healthy!")
|
| 63 |
+
break
|
| 64 |
+
|
| 65 |
+
if not user_input:
|
| 66 |
+
continue
|
| 67 |
+
|
| 68 |
+
# Add to conversation history
|
| 69 |
+
conversation_history.append({
|
| 70 |
+
"role": "user",
|
| 71 |
+
"content": user_input
|
| 72 |
+
})
|
| 73 |
+
|
| 74 |
+
# Send to doctor
|
| 75 |
+
try:
|
| 76 |
+
response = requests.post(
|
| 77 |
+
f"{BASE_URL}/chat",
|
| 78 |
+
json={
|
| 79 |
+
"message": user_input,
|
| 80 |
+
"history": conversation_history,
|
| 81 |
+
"guc": PATIENT_CONTEXT
|
| 82 |
+
}
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
if response.status_code == 200:
|
| 86 |
+
data = response.json()
|
| 87 |
+
doctor_reply = data.get("reply", "I'm not sure how to respond to that.")
|
| 88 |
+
|
| 89 |
+
# Add doctor response to history
|
| 90 |
+
conversation_history.append({
|
| 91 |
+
"role": "assistant",
|
| 92 |
+
"content": doctor_reply
|
| 93 |
+
})
|
| 94 |
+
|
| 95 |
+
print(f"\nDr. Raahat: {doctor_reply}\n")
|
| 96 |
+
else:
|
| 97 |
+
print(f"Error: {response.status_code}")
|
| 98 |
+
|
| 99 |
+
except Exception as e:
|
| 100 |
+
print(f"Connection error: {e}")
|
| 101 |
+
print("Make sure the server is running: python -m uvicorn app.main:app --reload --port 8000\n")
|
| 102 |
+
|
| 103 |
+
if __name__ == "__main__":
|
| 104 |
+
chat_with_doctor()
|
backend/demo_dialogue.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Demo: Complete Doctor Dialogue based on your uploaded report
|
| 3 |
+
Shows how the system works with a series of exchanges.
|
| 4 |
+
"""
|
| 5 |
+
import requests
|
| 6 |
+
import json
|
| 7 |
+
|
| 8 |
+
BASE_URL = "http://localhost:8000"
|
| 9 |
+
|
| 10 |
+
# Your actual analysis from the PDF
|
| 11 |
+
ANALYSIS = {
|
| 12 |
+
"is_readable": True,
|
| 13 |
+
"report_type": "LAB_REPORT",
|
| 14 |
+
"findings": [
|
| 15 |
+
{"parameter": "Haemoglobin", "value": "9.2", "unit": "g/dL", "status": "LOW"},
|
| 16 |
+
{"parameter": "Total RBC Count", "value": "3.8", "unit": "mill/cumm", "status": "LOW"},
|
| 17 |
+
{"parameter": "Serum Iron", "value": "45", "unit": "ug/dL", "status": "LOW"},
|
| 18 |
+
{"parameter": "Serum Ferritin", "value": "8", "unit": "ng/mL", "status": "LOW"},
|
| 19 |
+
{"parameter": "Vitamin B12", "value": "182", "unit": "pg/mL", "status": "LOW"},
|
| 20 |
+
],
|
| 21 |
+
"affected_organs": ["BLOOD", "SYSTEMIC"],
|
| 22 |
+
"overall_summary_english": "You have signs of iron deficiency anemia with low B12.",
|
| 23 |
+
"severity_level": "MILD_CONCERN",
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
# Your patient info
|
| 27 |
+
PATIENT = {
|
| 28 |
+
"name": "Ramesh Kumar Sharma",
|
| 29 |
+
"age": 45,
|
| 30 |
+
"gender": "Male",
|
| 31 |
+
"language": "EN",
|
| 32 |
+
"latestReport": ANALYSIS,
|
| 33 |
+
"mentalWellness": {"stressLevel": 5, "sleepQuality": 6}
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
def demo_conversation():
|
| 37 |
+
"""Demonstrate the doctor-patient dialogue."""
|
| 38 |
+
|
| 39 |
+
print("\n" + "="*80)
|
| 40 |
+
print(" 💬 Dr. Raahat - Patient Health Consultation")
|
| 41 |
+
print("="*80)
|
| 42 |
+
print(f"\nPatient: {PATIENT['name']}, {PATIENT['age']} years old")
|
| 43 |
+
print(f"Report Status: {ANALYSIS['report_type']}")
|
| 44 |
+
print(f"Summary: {ANALYSIS['overall_summary_english']}")
|
| 45 |
+
print(f"Severity: {ANALYSIS['severity_level']}")
|
| 46 |
+
print("="*80)
|
| 47 |
+
|
| 48 |
+
conversation = []
|
| 49 |
+
|
| 50 |
+
# Exchange 1: Doctor greets and patient reports symptoms
|
| 51 |
+
exchanges = [
|
| 52 |
+
{
|
| 53 |
+
"user_message": "Hi Doctor, I'm feeling exhausted and weak all the time",
|
| 54 |
+
"context": "Patient describes fatigue symptoms"
|
| 55 |
+
},
|
| 56 |
+
{
|
| 57 |
+
"user_message": "What should I eat to get better?",
|
| 58 |
+
"context": "Patient asks about diet"
|
| 59 |
+
},
|
| 60 |
+
{
|
| 61 |
+
"user_message": "How long until I feel normal again?",
|
| 62 |
+
"context": "Patient asks about recovery timeline"
|
| 63 |
+
},
|
| 64 |
+
{
|
| 65 |
+
"user_message": "Do I need to exercise?",
|
| 66 |
+
"context": "Patient asks about physical activity"
|
| 67 |
+
},
|
| 68 |
+
]
|
| 69 |
+
|
| 70 |
+
for i, exchange in enumerate(exchanges, 1):
|
| 71 |
+
user_msg = exchange["user_message"]
|
| 72 |
+
|
| 73 |
+
# Add to conversation history
|
| 74 |
+
conversation.append({"role": "user", "content": user_msg})
|
| 75 |
+
|
| 76 |
+
# Get doctor response from API
|
| 77 |
+
try:
|
| 78 |
+
response = requests.post(
|
| 79 |
+
f"{BASE_URL}/chat",
|
| 80 |
+
json={
|
| 81 |
+
"message": user_msg,
|
| 82 |
+
"history": conversation,
|
| 83 |
+
"guc": PATIENT
|
| 84 |
+
},
|
| 85 |
+
timeout=10
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
if response.status_code == 200:
|
| 89 |
+
doctor_reply = response.json()['reply']
|
| 90 |
+
conversation.append({"role": "assistant", "content": doctor_reply})
|
| 91 |
+
|
| 92 |
+
# Display the exchange
|
| 93 |
+
print(f"\n{'─'*80}")
|
| 94 |
+
print(f"Exchange {i}: {exchange['context']}")
|
| 95 |
+
print(f"{'─'*80}")
|
| 96 |
+
print(f"\n👤 Patient: {user_msg}")
|
| 97 |
+
print(f"\n👨⚕️ Dr. Raahat: {doctor_reply}")
|
| 98 |
+
|
| 99 |
+
else:
|
| 100 |
+
print(f"\n❌ API Error: {response.status_code}")
|
| 101 |
+
return
|
| 102 |
+
|
| 103 |
+
except Exception as e:
|
| 104 |
+
print(f"\n❌ Connection Error: {e}")
|
| 105 |
+
print("\n⚠️ Make sure the server is running:")
|
| 106 |
+
print(" cd backend && python -m uvicorn app.main:app --reload --port 8000")
|
| 107 |
+
return
|
| 108 |
+
|
| 109 |
+
# Summary
|
| 110 |
+
print(f"\n{'='*80}")
|
| 111 |
+
print("✅ Conversation Complete!")
|
| 112 |
+
print(f"{'='*80}")
|
| 113 |
+
print(f"\nThis is a demonstration of how Dr. Raahat responds to patient questions")
|
| 114 |
+
print(f"based on their uploaded medical report. The doctor provides:")
|
| 115 |
+
print(f" ✓ Specific advice based on your findings (LOW Hemoglobin, Iron, B12)")
|
| 116 |
+
print(f" ✓ Dietary recommendations tailored to your condition")
|
| 117 |
+
print(f" ✓ Recovery timeline based on severity")
|
| 118 |
+
print(f" ✓ Exercise guidelines for your current health status")
|
| 119 |
+
print(f"\nYou can have unlimited back-and-forth conversations!")
|
| 120 |
+
|
| 121 |
+
if __name__ == "__main__":
|
| 122 |
+
demo_conversation()
|
backend/doctor_workflow.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Complete workflow: Upload PDF → Get Analysis → Chat with Dr. Raahat
|
| 3 |
+
"""
|
| 4 |
+
import requests
|
| 5 |
+
import json
|
| 6 |
+
import sys
|
| 7 |
+
|
| 8 |
+
BASE_URL = "http://localhost:8000"
|
| 9 |
+
|
| 10 |
+
def upload_and_analyze_pdf(pdf_path):
|
| 11 |
+
"""Upload PDF and get analysis."""
|
| 12 |
+
print(f"\n📄 Uploading: {pdf_path}")
|
| 13 |
+
print("-" * 60)
|
| 14 |
+
|
| 15 |
+
with open(pdf_path, 'rb') as f:
|
| 16 |
+
files = {'file': f}
|
| 17 |
+
response = requests.post(f"{BASE_URL}/analyze", files=files)
|
| 18 |
+
|
| 19 |
+
if response.status_code != 200:
|
| 20 |
+
print(f"Error: {response.status_code}")
|
| 21 |
+
return None
|
| 22 |
+
|
| 23 |
+
analysis = response.json()
|
| 24 |
+
print(f"✓ Analysis complete!")
|
| 25 |
+
print(f" Readable: {analysis['is_readable']}")
|
| 26 |
+
print(f" Severity: {analysis['severity_level']}")
|
| 27 |
+
print(f" Summary: {analysis['overall_summary_english']}\n")
|
| 28 |
+
|
| 29 |
+
return analysis
|
| 30 |
+
|
| 31 |
+
def chat_with_doctor_about_report(analysis):
|
| 32 |
+
"""Interactive dialogue about the uploaded report."""
|
| 33 |
+
if not analysis:
|
| 34 |
+
print("No analysis available to discuss.")
|
| 35 |
+
return
|
| 36 |
+
|
| 37 |
+
# Build patient context from analysis
|
| 38 |
+
patient_context = {
|
| 39 |
+
"name": "User",
|
| 40 |
+
"age": 45,
|
| 41 |
+
"gender": "Not specified",
|
| 42 |
+
"language": "EN",
|
| 43 |
+
"latestReport": analysis,
|
| 44 |
+
"mentalWellness": {"stressLevel": 5, "sleepQuality": 6}
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
print("\n" + "="*70)
|
| 48 |
+
print("💬 Dr. Raahat - Your Health Advisor")
|
| 49 |
+
print("="*70)
|
| 50 |
+
print(f"\nReport Summary: {analysis['overall_summary_english']}")
|
| 51 |
+
print(f"Severity Level: {analysis['severity_level']}")
|
| 52 |
+
print(f"Affected Organs: {', '.join(analysis['affected_organs'])}")
|
| 53 |
+
print(f"\nKey Findings:")
|
| 54 |
+
for finding in analysis['findings'][:5]: # Show first 5
|
| 55 |
+
if finding['status'] in ['LOW', 'HIGH', 'CRITICAL']:
|
| 56 |
+
print(f" • {finding['parameter']}: {finding['value']} {finding['unit']} [{finding['status']}]")
|
| 57 |
+
|
| 58 |
+
print("\nType 'exit' to end. Type 'findings' to see all results.")
|
| 59 |
+
print("="*70)
|
| 60 |
+
|
| 61 |
+
conversation_history = []
|
| 62 |
+
|
| 63 |
+
# Initial doctor greeting about the report
|
| 64 |
+
initial_response = requests.post(
|
| 65 |
+
f"{BASE_URL}/chat",
|
| 66 |
+
json={
|
| 67 |
+
"message": "What should I know about my report?",
|
| 68 |
+
"history": [],
|
| 69 |
+
"guc": patient_context
|
| 70 |
+
}
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
if initial_response.status_code == 200:
|
| 74 |
+
initial_message = initial_response.json()['reply']
|
| 75 |
+
print(f"\nDr. Raahat: {initial_message}\n")
|
| 76 |
+
|
| 77 |
+
while True:
|
| 78 |
+
user_input = input("You: ").strip()
|
| 79 |
+
|
| 80 |
+
if user_input.lower() == 'exit':
|
| 81 |
+
print("\nDr. Raahat: Take care! Follow the recommendations and schedule a follow-up in 4 weeks.")
|
| 82 |
+
break
|
| 83 |
+
|
| 84 |
+
if user_input.lower() == 'findings':
|
| 85 |
+
print("\nAll Findings from Your Report:")
|
| 86 |
+
for i, finding in enumerate(analysis['findings'], 1):
|
| 87 |
+
status_icon = "⚠️" if finding['status'] in ['LOW', 'HIGH', 'CRITICAL'] else "✓"
|
| 88 |
+
print(f" {status_icon} {finding['parameter']}: {finding['value']} {finding['unit']} ({finding['status']})")
|
| 89 |
+
print()
|
| 90 |
+
continue
|
| 91 |
+
|
| 92 |
+
if not user_input:
|
| 93 |
+
continue
|
| 94 |
+
|
| 95 |
+
# Add to history
|
| 96 |
+
conversation_history.append({"role": "user", "content": user_input})
|
| 97 |
+
|
| 98 |
+
# Get doctor response
|
| 99 |
+
try:
|
| 100 |
+
response = requests.post(
|
| 101 |
+
f"{BASE_URL}/chat",
|
| 102 |
+
json={
|
| 103 |
+
"message": user_input,
|
| 104 |
+
"history": conversation_history,
|
| 105 |
+
"guc": patient_context
|
| 106 |
+
}
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
if response.status_code == 200:
|
| 110 |
+
doctor_reply = response.json()['reply']
|
| 111 |
+
conversation_history.append({"role": "assistant", "content": doctor_reply})
|
| 112 |
+
print(f"\nDr. Raahat: {doctor_reply}\n")
|
| 113 |
+
else:
|
| 114 |
+
print(f"Error: {response.status_code}\n")
|
| 115 |
+
|
| 116 |
+
except Exception as e:
|
| 117 |
+
print(f"Connection error: {e}")
|
| 118 |
+
print("Make sure server is running!\n")
|
| 119 |
+
|
| 120 |
+
def main():
|
| 121 |
+
if len(sys.argv) < 2:
|
| 122 |
+
print("Usage: python doctor_workflow.py <pdf_path>")
|
| 123 |
+
print("\nExample:")
|
| 124 |
+
print(" python doctor_workflow.py C:\\path\\to\\report.pdf")
|
| 125 |
+
sys.exit(1)
|
| 126 |
+
|
| 127 |
+
pdf_path = sys.argv[1]
|
| 128 |
+
|
| 129 |
+
# Step 1: Upload and analyze
|
| 130 |
+
analysis = upload_and_analyze_pdf(pdf_path)
|
| 131 |
+
|
| 132 |
+
# Step 2: Chat about results
|
| 133 |
+
if analysis:
|
| 134 |
+
chat_with_doctor_about_report(analysis)
|
| 135 |
+
else:
|
| 136 |
+
print("Could not analyze report.")
|
| 137 |
+
|
| 138 |
+
if __name__ == "__main__":
|
| 139 |
+
main()
|
backend/human_upload.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Test the new /upload_and_chat endpoint - uploads file and gets HUMAN greeting
|
| 3 |
+
instead of raw schema.
|
| 4 |
+
"""
|
| 5 |
+
import requests
|
| 6 |
+
import json
|
| 7 |
+
import sys
|
| 8 |
+
|
| 9 |
+
BASE_URL = "http://localhost:8000"
|
| 10 |
+
|
| 11 |
+
def upload_and_start_dialogue(pdf_path, patient_name="Ramesh Kumar Sharma"):
|
| 12 |
+
"""Upload PDF and immediately get doctor greeting."""
|
| 13 |
+
|
| 14 |
+
print(f"\n{'='*70}")
|
| 15 |
+
print("📞 UPLOADING AND STARTING DIALOGUE")
|
| 16 |
+
print(f"{'='*70}\n")
|
| 17 |
+
|
| 18 |
+
with open(pdf_path, 'rb') as f:
|
| 19 |
+
files = {'file': f}
|
| 20 |
+
data = {'patient_name': patient_name, 'language': 'EN'}
|
| 21 |
+
|
| 22 |
+
try:
|
| 23 |
+
response = requests.post(
|
| 24 |
+
f"{BASE_URL}/upload_and_chat",
|
| 25 |
+
files=files,
|
| 26 |
+
data=data,
|
| 27 |
+
timeout=10
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
if response.status_code == 200:
|
| 31 |
+
result = response.json()
|
| 32 |
+
|
| 33 |
+
# Show analysis metadata
|
| 34 |
+
analysis = result.get('analysis', {})
|
| 35 |
+
print(f"📋 Analysis Status: {'✅ READABLE' if analysis.get('is_readable') else '❌ NOT READABLE'}")
|
| 36 |
+
print(f"📊 Report Type: {analysis.get('report_type')}")
|
| 37 |
+
print(f"⚠️ Severity: {analysis.get('severity_level')}")
|
| 38 |
+
print(f"🏥 Affected Organs: {', '.join(analysis.get('affected_organs', []))}")
|
| 39 |
+
|
| 40 |
+
# MAIN PART: Show human greeting
|
| 41 |
+
print(f"\n{'─'*70}")
|
| 42 |
+
print("💬 DOCTOR'S GREETING (NOT SCHEMA):")
|
| 43 |
+
print(f"{'─'*70}\n")
|
| 44 |
+
print(result.get('doctor_greeting', 'No greeting'))
|
| 45 |
+
|
| 46 |
+
# Show what to do next
|
| 47 |
+
print(f"\n{'─'*70}")
|
| 48 |
+
print("📝 What to do next:")
|
| 49 |
+
print(" 1. Type your question/concern")
|
| 50 |
+
print(" 2. Send to /chat endpoint with the analysis context")
|
| 51 |
+
print(" 3. Continue back-and-forth dialogue")
|
| 52 |
+
print(f"{'─'*70}\n")
|
| 53 |
+
|
| 54 |
+
return result
|
| 55 |
+
else:
|
| 56 |
+
print(f"❌ Error: {response.status_code}")
|
| 57 |
+
print(response.text)
|
| 58 |
+
return None
|
| 59 |
+
|
| 60 |
+
except requests.exceptions.ConnectionError:
|
| 61 |
+
print("❌ Connection Error: Server not running!")
|
| 62 |
+
print("Start server with: python -m uvicorn app.main:app --reload --port 8000")
|
| 63 |
+
return None
|
| 64 |
+
except Exception as e:
|
| 65 |
+
print(f"❌ Error: {e}")
|
| 66 |
+
return None
|
| 67 |
+
|
| 68 |
+
if __name__ == "__main__":
|
| 69 |
+
if len(sys.argv) < 2:
|
| 70 |
+
print("Usage: python human_upload.py <pdf_path>")
|
| 71 |
+
print("\nExample:")
|
| 72 |
+
print(" python human_upload.py C:\\path\\to\\report.pdf")
|
| 73 |
+
sys.exit(1)
|
| 74 |
+
|
| 75 |
+
pdf_path = sys.argv[1]
|
| 76 |
+
result = upload_and_start_dialogue(pdf_path)
|
backend/requirements-local.txt
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Core backend — required to run locally
|
| 2 |
+
fastapi>=0.115.0
|
| 3 |
+
uvicorn[standard]>=0.30.6
|
| 4 |
+
pydantic>=2.8.2
|
| 5 |
+
python-dotenv>=1.0.1
|
| 6 |
+
httpx>=0.27.2
|
| 7 |
+
python-multipart>=0.0.9
|
| 8 |
+
|
| 9 |
+
# Document processing
|
| 10 |
+
pdfplumber>=0.11.4
|
| 11 |
+
Pillow>=10.4.0
|
| 12 |
+
|
| 13 |
+
# Numeric
|
| 14 |
+
numpy>=1.26.4
|
backend/requirements.txt
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ─────────────── Core Backend ───────────────
|
| 2 |
+
fastapi>=0.115.0
|
| 3 |
+
uvicorn[standard]>=0.30.6
|
| 4 |
+
pydantic>=2.8.2
|
| 5 |
+
python-dotenv>=1.0.1
|
| 6 |
+
httpx>=0.27.2
|
| 7 |
+
python-multipart>=0.0.9
|
| 8 |
+
|
| 9 |
+
# ─────────────── ML — Member 1 ───────────────
|
| 10 |
+
transformers>=4.46.0
|
| 11 |
+
torch>=2.6.0
|
| 12 |
+
sentence-transformers>=3.2.1
|
| 13 |
+
faiss-cpu>=1.9.0
|
| 14 |
+
huggingface_hub>=0.26.0
|
| 15 |
+
datasets>=3.1.0
|
| 16 |
+
accelerate>=1.0.1
|
| 17 |
+
|
| 18 |
+
# ─────────────── Document Processing ───────────────
|
| 19 |
+
pdfplumber>=0.11.4
|
| 20 |
+
Pillow>=10.4.0
|
| 21 |
+
pytesseract>=0.3.13
|
| 22 |
+
pandas>=2.2.3
|
| 23 |
+
numpy>=1.26.4
|
| 24 |
+
openpyxl>=3.1.5
|
| 25 |
+
|
| 26 |
+
# ─────────────── LLM Routing — Member 1 ───────────────
|
| 27 |
+
openai>=1.35.0 # OpenRouter uses OpenAI-compatible API
|
| 28 |
+
|
| 29 |
+
# ─────────────── Nutrition — Member 4 ───────────────
|
| 30 |
+
ifct2017>=3.0.0
|
backend/test_enhanced_chat.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Test script: RAG-enhanced chat with HF dataset + FAISS index
|
| 4 |
+
Run this to see the improvement in chat quality
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import sys
|
| 8 |
+
import json
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
# Add backend to path
|
| 12 |
+
sys.path.insert(0, str(Path(__file__).parent))
|
| 13 |
+
|
| 14 |
+
from app.ml.enhanced_chat import get_enhanced_mock_response, rag_retriever
|
| 15 |
+
|
| 16 |
+
def create_sample_guc():
|
| 17 |
+
"""Create sample Global User Context with medical data."""
|
| 18 |
+
return {
|
| 19 |
+
"name": "Amit",
|
| 20 |
+
"age": "28",
|
| 21 |
+
"gender": "Male",
|
| 22 |
+
"language": "EN",
|
| 23 |
+
"location": "Mumbai, India",
|
| 24 |
+
"latestReport": {
|
| 25 |
+
"overall_summary_english": "Lab report shows signs of anemia with low iron and B12",
|
| 26 |
+
"findings": [
|
| 27 |
+
{
|
| 28 |
+
"parameter": "Haemoglobin",
|
| 29 |
+
"value": 9.2,
|
| 30 |
+
"unit": "g/dL",
|
| 31 |
+
"status": "LOW",
|
| 32 |
+
"reference": "13.0 - 17.0"
|
| 33 |
+
},
|
| 34 |
+
{
|
| 35 |
+
"parameter": "Iron",
|
| 36 |
+
"value": 45,
|
| 37 |
+
"unit": "µg/dL",
|
| 38 |
+
"status": "LOW",
|
| 39 |
+
"reference": "60 - 170"
|
| 40 |
+
},
|
| 41 |
+
{
|
| 42 |
+
"parameter": "Vitamin B12",
|
| 43 |
+
"value": 180,
|
| 44 |
+
"unit": "pg/mL",
|
| 45 |
+
"status": "LOW",
|
| 46 |
+
"reference": "200 - 900"
|
| 47 |
+
},
|
| 48 |
+
{
|
| 49 |
+
"parameter": "RBC",
|
| 50 |
+
"value": 3.8,
|
| 51 |
+
"unit": "10^6/µL",
|
| 52 |
+
"status": "LOW",
|
| 53 |
+
"reference": "4.5 - 5.5"
|
| 54 |
+
}
|
| 55 |
+
],
|
| 56 |
+
"affected_organs": ["Blood", "Bone Marrow"],
|
| 57 |
+
"severity_level": "NORMAL",
|
| 58 |
+
"dietary_flags": ["Low Iron Intake", "Insufficient B12"],
|
| 59 |
+
"exercise_flags": ["Fatigue Limiting Activity"]
|
| 60 |
+
},
|
| 61 |
+
"medicationsActive": [],
|
| 62 |
+
"allergyFlags": [],
|
| 63 |
+
"mentalWellness": {"stressLevel": 6, "sleepQuality": 5}
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
def print_separator(title=""):
|
| 67 |
+
"""Print formatted separator."""
|
| 68 |
+
print("\n" + "=" * 80)
|
| 69 |
+
if title:
|
| 70 |
+
print(f" {title}")
|
| 71 |
+
print("=" * 80)
|
| 72 |
+
|
| 73 |
+
def test_chat():
|
| 74 |
+
"""Test enhanced chat with various queries."""
|
| 75 |
+
|
| 76 |
+
print_separator("🏥 Enhanced Chat System - RAG Integration Test")
|
| 77 |
+
|
| 78 |
+
guc = create_sample_guc()
|
| 79 |
+
|
| 80 |
+
# Check RAG status
|
| 81 |
+
if rag_retriever:
|
| 82 |
+
status = "✅ LOADED" if rag_retriever.loaded else "⚠️ FAILED TO LOAD"
|
| 83 |
+
doc_count = len(rag_retriever.documents) if rag_retriever.loaded else 0
|
| 84 |
+
print(f"\n📚 RAG Status: {status}")
|
| 85 |
+
print(f" Documents available: {doc_count}")
|
| 86 |
+
else:
|
| 87 |
+
print("\n⚠️ RAG not initialized - using mock responses only")
|
| 88 |
+
|
| 89 |
+
# Test conversations
|
| 90 |
+
test_queries = [
|
| 91 |
+
{
|
| 92 |
+
"question": "I'm feeling very tired and weak. What should I do?",
|
| 93 |
+
"category": "Fatigue & Symptoms"
|
| 94 |
+
},
|
| 95 |
+
{
|
| 96 |
+
"question": "What foods should I eat to improve my condition?",
|
| 97 |
+
"category": "Nutrition"
|
| 98 |
+
},
|
| 99 |
+
{
|
| 100 |
+
"question": "What medicines do I need to take?",
|
| 101 |
+
"category": "Medications"
|
| 102 |
+
},
|
| 103 |
+
{
|
| 104 |
+
"question": "Can I exercise? I feel exhausted.",
|
| 105 |
+
"category": "Physical Activity"
|
| 106 |
+
},
|
| 107 |
+
{
|
| 108 |
+
"question": "When should I follow up with my doctor?",
|
| 109 |
+
"category": "Follow-up Care"
|
| 110 |
+
},
|
| 111 |
+
{
|
| 112 |
+
"question": "I'm worried this is serious. Should I panic?",
|
| 113 |
+
"category": "Reassurance"
|
| 114 |
+
}
|
| 115 |
+
]
|
| 116 |
+
|
| 117 |
+
print_separator("Chat Interaction Tests")
|
| 118 |
+
|
| 119 |
+
for i, test in enumerate(test_queries, 1):
|
| 120 |
+
print_separator(f"Test {i}: {test['category']}")
|
| 121 |
+
print(f"\n👤 Patient: {test['question']}")
|
| 122 |
+
print("\n🏥 Dr. Raahat:")
|
| 123 |
+
|
| 124 |
+
response = get_enhanced_mock_response(test["question"], guc)
|
| 125 |
+
print(response)
|
| 126 |
+
|
| 127 |
+
if rag_retriever and rag_retriever.loaded:
|
| 128 |
+
print("\n📚 [Sources: Retrieved from medical database]")
|
| 129 |
+
else:
|
| 130 |
+
print("\n📌 [Note: Using contextual mock responses. RAG sources not available.]")
|
| 131 |
+
|
| 132 |
+
print_separator("Test Complete")
|
| 133 |
+
print("""
|
| 134 |
+
✅ Enhanced Chat Features:
|
| 135 |
+
✓ Context-aware responses based on actual findings
|
| 136 |
+
✓ Personalized health advice
|
| 137 |
+
✓ Step-by-step action plans
|
| 138 |
+
✓ RAG integration ready for HF dataset
|
| 139 |
+
✓ Clear formatting and readability
|
| 140 |
+
✓ Empathetic doctor persona
|
| 141 |
+
|
| 142 |
+
🔄 Next Steps:
|
| 143 |
+
1. Deploy FAISS index from HF
|
| 144 |
+
2. Load medical documents
|
| 145 |
+
3. Enable actual document retrieval
|
| 146 |
+
4. Remove mock responses from production
|
| 147 |
+
5. Add response grounding/sources
|
| 148 |
+
""")
|
| 149 |
+
|
| 150 |
+
if __name__ == "__main__":
|
| 151 |
+
test_chat()
|
backend/test_full_pipeline.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Complete end-to-end test: Schema → Text → Chat Response
|
| 4 |
+
Shows the full document scanning conversion pipeline
|
| 5 |
+
"""
|
| 6 |
+
import sys
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
sys.path.insert(0, str(Path(__file__).parent))
|
| 10 |
+
|
| 11 |
+
from app.routers.doctor_upload import convert_analysis_to_text
|
| 12 |
+
from app.ml.openrouter import chat
|
| 13 |
+
|
| 14 |
+
# Sample schema from PDF analysis
|
| 15 |
+
sample_analysis = {
|
| 16 |
+
"findings": [
|
| 17 |
+
{"parameter": "Haemoglobin", "value": 9.2, "unit": "g/dL", "status": "LOW", "reference": "13.0 - 17.0"},
|
| 18 |
+
{"parameter": "Iron", "value": 45, "unit": "µg/dL", "status": "LOW", "reference": "60 - 170"},
|
| 19 |
+
{"parameter": "Vitamin B12", "value": 180, "unit": "pg/mL", "status": "LOW", "reference": "200 - 900"},
|
| 20 |
+
{"parameter": "RBC", "value": 3.8, "unit": "10^6/µL", "status": "LOW", "reference": "4.5 - 5.5"},
|
| 21 |
+
{"parameter": "WBC", "value": 7.2, "unit": "10^3/µL", "status": "NORMAL", "reference": "4.5 - 11.0"}
|
| 22 |
+
],
|
| 23 |
+
"severity_level": "NORMAL",
|
| 24 |
+
"affected_organs": ["Blood", "Bone Marrow"],
|
| 25 |
+
"dietary_flags": ["Low Iron Intake", "Insufficient B12"],
|
| 26 |
+
"exercise_flags": ["Fatigue Limiting Activity"]
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
def print_section(title):
|
| 30 |
+
print("\n" + "=" * 90)
|
| 31 |
+
print(f" {title}")
|
| 32 |
+
print("=" * 90)
|
| 33 |
+
|
| 34 |
+
def test_full_pipeline():
|
| 35 |
+
"""Test the complete schema → text → chat response pipeline"""
|
| 36 |
+
|
| 37 |
+
print_section("1️⃣ INPUT: Schema-Based PDF Analysis")
|
| 38 |
+
print("\nFindings from PDF extraction:")
|
| 39 |
+
for finding in sample_analysis["findings"]:
|
| 40 |
+
status_color = "❌" if finding['status'] != "NORMAL" else "✓"
|
| 41 |
+
print(f" {status_color} {finding['parameter']}: {finding['value']} {finding['unit']} ({finding['status']})")
|
| 42 |
+
|
| 43 |
+
print(f"\n Severity: {sample_analysis['severity_level']}")
|
| 44 |
+
print(f" Affected: {', '.join(sample_analysis['affected_organs'])}")
|
| 45 |
+
print(f" Dietary: {', '.join(sample_analysis['dietary_flags'])}")
|
| 46 |
+
print(f" Exercise: {', '.join(sample_analysis['exercise_flags'])}")
|
| 47 |
+
|
| 48 |
+
print_section("2️⃣ CONVERSION: Schema → Natural Language Text")
|
| 49 |
+
analysis_text = convert_analysis_to_text(sample_analysis)
|
| 50 |
+
print(analysis_text)
|
| 51 |
+
|
| 52 |
+
print_section("3️⃣ PROCESSING: Text → Chat System")
|
| 53 |
+
print("\nSending text through chat system with patient context...")
|
| 54 |
+
|
| 55 |
+
# Build patient context
|
| 56 |
+
patient_context = {
|
| 57 |
+
"name": "Amit Kumar",
|
| 58 |
+
"age": "28",
|
| 59 |
+
"gender": "Male",
|
| 60 |
+
"language": "EN",
|
| 61 |
+
"latestReport": sample_analysis,
|
| 62 |
+
"mentalWellness": {"stressLevel": 6, "sleepQuality": 5}
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
# Send through chat system
|
| 66 |
+
print("Processing...\n")
|
| 67 |
+
doctor_response = chat(
|
| 68 |
+
message=analysis_text,
|
| 69 |
+
history=[],
|
| 70 |
+
guc=patient_context
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
print_section("4️⃣ OUTPUT: Doctor Response (Natural Language)")
|
| 74 |
+
print(f"\nDr. Raahat:\n{doctor_response}")
|
| 75 |
+
|
| 76 |
+
print_section("✅ PIPELINE COMPLETE")
|
| 77 |
+
print("""
|
| 78 |
+
Flow Summary:
|
| 79 |
+
┌─────────────────────────────────────────────────────────┐
|
| 80 |
+
│ 1. PDF Upload │
|
| 81 |
+
│ ↓ │
|
| 82 |
+
│ 2. PDF Analysis (Schema-Based) │
|
| 83 |
+
│ ↓ │
|
| 84 |
+
│ 3. Schema → Natural Language Text Conversion │
|
| 85 |
+
│ ↓ │
|
| 86 |
+
│ 4. Send Text to Chat System │
|
| 87 |
+
│ ↓ │
|
| 88 |
+
│ 5. Chat System Processes & Returns Response │
|
| 89 |
+
│ ↓ │
|
| 90 |
+
│ 6. Doctor's Human-Friendly Response │
|
| 91 |
+
└─────────────────────────────────────────────────────────┘
|
| 92 |
+
|
| 93 |
+
Key Innovation:
|
| 94 |
+
- Schema analysis receives proper natural language processing
|
| 95 |
+
- Doctor responses are contextual to actual findings
|
| 96 |
+
- No raw JSON schema returned - only human dialogue
|
| 97 |
+
- Seamless user experience in `/upload_and_chat` endpoint
|
| 98 |
+
""")
|
| 99 |
+
|
| 100 |
+
if __name__ == "__main__":
|
| 101 |
+
test_full_pipeline()
|
backend/test_pipeline_demo.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Demo: Full Schema → Text → Chat Pipeline
|
| 4 |
+
Shows the /upload_and_chat endpoint working end-to-end
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import requests
|
| 8 |
+
import json
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
def demo_pipeline():
|
| 12 |
+
pdf_path = Path('/c/Users/DEVANG MISHRA/OneDrive/Documents/rahat1/sample_lab_report.pdf')
|
| 13 |
+
|
| 14 |
+
# Check if file exists
|
| 15 |
+
if not pdf_path.exists():
|
| 16 |
+
print("❌ PDF not found, trying alternate path...")
|
| 17 |
+
pdf_path = Path('../sample_lab_report.pdf')
|
| 18 |
+
|
| 19 |
+
if pdf_path.exists():
|
| 20 |
+
print('=' * 80)
|
| 21 |
+
print('🧪 TESTING: Schema → Text → Chat Pipeline')
|
| 22 |
+
print('=' * 80)
|
| 23 |
+
print(f'📄 Uploading: {pdf_path.name}')
|
| 24 |
+
print()
|
| 25 |
+
|
| 26 |
+
try:
|
| 27 |
+
with open(pdf_path, 'rb') as f:
|
| 28 |
+
files = {'file': f}
|
| 29 |
+
response = requests.post('http://localhost:8000/upload_and_chat', files=files)
|
| 30 |
+
|
| 31 |
+
if response.status_code == 200:
|
| 32 |
+
data = response.json()
|
| 33 |
+
|
| 34 |
+
# STEP 1: Show Schema Analysis
|
| 35 |
+
print('✅ STEP 1: PDF Analysis → Schema Generated')
|
| 36 |
+
print('-' * 80)
|
| 37 |
+
analysis = data.get('analysis', {})
|
| 38 |
+
findings = analysis.get('findings', [])
|
| 39 |
+
affected_organs = analysis.get('affected_organs', [])
|
| 40 |
+
severity = analysis.get('severity_level', 'UNKNOWN')
|
| 41 |
+
|
| 42 |
+
print(f' Parameters Found: {len(findings)}')
|
| 43 |
+
print(f' Severity Level: {severity}')
|
| 44 |
+
print(f' Affected Organs: {", ".join(affected_organs)}')
|
| 45 |
+
print()
|
| 46 |
+
print(' Sample Findings:')
|
| 47 |
+
for finding in findings[:3]:
|
| 48 |
+
status_emoji = '⬇️' if finding.get('status') == 'LOW' else '⬆️' if finding.get('status') == 'HIGH' else '✓'
|
| 49 |
+
print(f' {status_emoji} {finding["parameter"]}: {finding["value"]} {finding["unit"]} ({finding["status"]})')
|
| 50 |
+
print()
|
| 51 |
+
|
| 52 |
+
# STEP 2: Show Schema as Text
|
| 53 |
+
print('✅ STEP 2: Schema → Converted to Natural Language Text')
|
| 54 |
+
print('-' * 80)
|
| 55 |
+
schema_text = data.get('schema_as_text', '')
|
| 56 |
+
if schema_text:
|
| 57 |
+
lines = schema_text.split('\n')
|
| 58 |
+
for i, line in enumerate(lines[:6]):
|
| 59 |
+
if line.strip():
|
| 60 |
+
print(f' {line}')
|
| 61 |
+
else:
|
| 62 |
+
print(' (No schema_as_text in response)')
|
| 63 |
+
print()
|
| 64 |
+
|
| 65 |
+
# STEP 3: Show Doctor Response
|
| 66 |
+
print('✅ STEP 3: Text → Fed to Dr. Raahat AI Chat System')
|
| 67 |
+
print('-' * 80)
|
| 68 |
+
doctor_greeting = data.get('doctor_greeting', '')
|
| 69 |
+
if doctor_greeting:
|
| 70 |
+
lines = doctor_greeting.split('\n')
|
| 71 |
+
for i, line in enumerate(lines[:10]):
|
| 72 |
+
print(f' {line}')
|
| 73 |
+
if len(lines) > 10:
|
| 74 |
+
print(f' ... ({len(lines) - 10} more lines)')
|
| 75 |
+
else:
|
| 76 |
+
print(' (No doctor response)')
|
| 77 |
+
print()
|
| 78 |
+
|
| 79 |
+
# Final Result
|
| 80 |
+
print('=' * 80)
|
| 81 |
+
print('✨ SUCCESS: Full Pipeline Working!')
|
| 82 |
+
print(' PDF → Schema → Natural Text → Human Doctor Response')
|
| 83 |
+
print('=' * 80)
|
| 84 |
+
|
| 85 |
+
else:
|
| 86 |
+
print(f'❌ API Error: {response.status_code}')
|
| 87 |
+
print(response.text[:200])
|
| 88 |
+
|
| 89 |
+
except Exception as e:
|
| 90 |
+
print(f'❌ Error: {str(e)}')
|
| 91 |
+
else:
|
| 92 |
+
print(f'❌ PDF not found at: {pdf_path}')
|
| 93 |
+
print(' Please check sample_lab_report.pdf location')
|
| 94 |
+
|
| 95 |
+
if __name__ == '__main__':
|
| 96 |
+
demo_pipeline()
|
backend/test_schema_to_text.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Test the new schema-to-text-to-chat conversion pipeline
|
| 4 |
+
"""
|
| 5 |
+
import sys
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
# Add backend to path
|
| 9 |
+
sys.path.insert(0, str(Path(__file__).parent))
|
| 10 |
+
|
| 11 |
+
from app.routers.doctor_upload import convert_analysis_to_text
|
| 12 |
+
|
| 13 |
+
# Sample schema from PDF analysis
|
| 14 |
+
sample_analysis = {
|
| 15 |
+
"findings": [
|
| 16 |
+
{
|
| 17 |
+
"parameter": "Haemoglobin",
|
| 18 |
+
"value": 9.2,
|
| 19 |
+
"unit": "g/dL",
|
| 20 |
+
"status": "LOW",
|
| 21 |
+
"reference": "13.0 - 17.0"
|
| 22 |
+
},
|
| 23 |
+
{
|
| 24 |
+
"parameter": "Iron",
|
| 25 |
+
"value": 45,
|
| 26 |
+
"unit": "µg/dL",
|
| 27 |
+
"status": "LOW",
|
| 28 |
+
"reference": "60 - 170"
|
| 29 |
+
},
|
| 30 |
+
{
|
| 31 |
+
"parameter": "Vitamin B12",
|
| 32 |
+
"value": 180,
|
| 33 |
+
"unit": "pg/mL",
|
| 34 |
+
"status": "LOW",
|
| 35 |
+
"reference": "200 - 900"
|
| 36 |
+
},
|
| 37 |
+
{
|
| 38 |
+
"parameter": "RBC",
|
| 39 |
+
"value": 3.8,
|
| 40 |
+
"unit": "10^6/µL",
|
| 41 |
+
"status": "LOW",
|
| 42 |
+
"reference": "4.5 - 5.5"
|
| 43 |
+
},
|
| 44 |
+
{
|
| 45 |
+
"parameter": "WBC",
|
| 46 |
+
"value": 7.2,
|
| 47 |
+
"unit": "10^3/µL",
|
| 48 |
+
"status": "NORMAL",
|
| 49 |
+
"reference": "4.5 - 11.0"
|
| 50 |
+
}
|
| 51 |
+
],
|
| 52 |
+
"severity_level": "NORMAL",
|
| 53 |
+
"affected_organs": ["Blood", "Bone Marrow"],
|
| 54 |
+
"dietary_flags": ["Low Iron Intake", "Insufficient B12"],
|
| 55 |
+
"exercise_flags": ["Fatigue Limiting Activity"]
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
print("=" * 80)
|
| 59 |
+
print("SCHEMA-TO-TEXT CONVERSION TEST")
|
| 60 |
+
print("=" * 80)
|
| 61 |
+
|
| 62 |
+
print("\n📊 Input Schema:")
|
| 63 |
+
print("-" * 80)
|
| 64 |
+
for finding in sample_analysis["findings"]:
|
| 65 |
+
print(f" {finding['parameter']}: {finding['value']} {finding['unit']} ({finding['status']})")
|
| 66 |
+
|
| 67 |
+
print("\n" + "=" * 80)
|
| 68 |
+
print("⚙️ Converting schema to natural language text...")
|
| 69 |
+
print("=" * 80)
|
| 70 |
+
|
| 71 |
+
text_output = convert_analysis_to_text(sample_analysis)
|
| 72 |
+
|
| 73 |
+
print("\n📝 Output Text (what will be sent to chat system):")
|
| 74 |
+
print("-" * 80)
|
| 75 |
+
print(text_output)
|
| 76 |
+
print("-" * 80)
|
| 77 |
+
|
| 78 |
+
print("\n✅ Conversion successful!")
|
| 79 |
+
print("\nThis text will now be:")
|
| 80 |
+
print("1. Sent to the chat system as user input")
|
| 81 |
+
print("2. Processed by get_enhanced_mock_response()")
|
| 82 |
+
print("3. Returned as human-friendly doctor response")
|
| 83 |
+
print("\n🎯 Result: Schema → Text → Chat Response (all in one flow)")
|
backend/upload_pdf.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Upload and analyze a medical report PDF
|
| 4 |
+
"""
|
| 5 |
+
import requests
|
| 6 |
+
import json
|
| 7 |
+
|
| 8 |
+
API_URL = "http://localhost:8000/analyze"
|
| 9 |
+
PDF_PATH = r"C:\Users\DEVANG MISHRA\OneDrive\Documents\rahat1\sample_lab_report.pdf"
|
| 10 |
+
|
| 11 |
+
print(f"Uploading PDF: {PDF_PATH}")
|
| 12 |
+
print("-" * 60)
|
| 13 |
+
|
| 14 |
+
try:
|
| 15 |
+
with open(PDF_PATH, 'rb') as pdf_file:
|
| 16 |
+
files = {
|
| 17 |
+
'file': (pdf_file.name, pdf_file, 'application/pdf')
|
| 18 |
+
}
|
| 19 |
+
data = {
|
| 20 |
+
'language': 'EN'
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
response = requests.post(API_URL, files=files, data=data)
|
| 24 |
+
|
| 25 |
+
print(f"Status Code: {response.status_code}")
|
| 26 |
+
|
| 27 |
+
if response.status_code == 200:
|
| 28 |
+
result = response.json()
|
| 29 |
+
print("✅ Analysis Complete!\n")
|
| 30 |
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
| 31 |
+
else:
|
| 32 |
+
print(f"❌ Error: {response.status_code}")
|
| 33 |
+
print("Response:", response.text)
|
| 34 |
+
|
| 35 |
+
except FileNotFoundError:
|
| 36 |
+
print(f"❌ File not found: {PDF_PATH}")
|
| 37 |
+
except Exception as e:
|
| 38 |
+
print(f"❌ Error: {str(e)}")
|
| 39 |
+
import traceback
|
| 40 |
+
traceback.print_exc()
|
frontend/.env.local.example
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Backend URL
|
| 2 |
+
NEXT_PUBLIC_API_URL=http://localhost:8000
|
| 3 |
+
|
| 4 |
+
# Gemini key (Member 1 shares this)
|
| 5 |
+
GOOGLE_GEMINI_API_KEY=your_key_here
|
| 6 |
+
|
| 7 |
+
# App
|
| 8 |
+
NEXT_PUBLIC_APP_NAME=ReportRaahat
|
frontend/app/api/analyze-report/route.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// POST /api/analyze-report
|
| 2 |
+
// Accepts FormData with: file (File), language (string)
|
| 3 |
+
// Forwards to FastAPI POST /analyze as multipart/form-data
|
| 4 |
+
// Returns: ParsedReport-shaped JSON
|
| 5 |
+
|
| 6 |
+
import { NextRequest, NextResponse } from "next/server"
|
| 7 |
+
|
| 8 |
+
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000"
|
| 9 |
+
const TIMEOUT_MS = 15_000
|
| 10 |
+
|
| 11 |
+
export async function POST(req: NextRequest) {
|
| 12 |
+
try {
|
| 13 |
+
const formData = await req.formData()
|
| 14 |
+
const file = formData.get("file") as File | null
|
| 15 |
+
const language = (formData.get("language") as string) || "EN"
|
| 16 |
+
|
| 17 |
+
if (!file) {
|
| 18 |
+
return NextResponse.json({ error: "No file provided" }, { status: 400 })
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
// Build FormData for the backend
|
| 22 |
+
const backendForm = new FormData()
|
| 23 |
+
backendForm.append("file", file)
|
| 24 |
+
backendForm.append("language", language)
|
| 25 |
+
|
| 26 |
+
// Call the backend with timeout
|
| 27 |
+
const controller = new AbortController()
|
| 28 |
+
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS)
|
| 29 |
+
|
| 30 |
+
const res = await fetch(`${API_BASE}/analyze`, {
|
| 31 |
+
method: "POST",
|
| 32 |
+
body: backendForm,
|
| 33 |
+
signal: controller.signal,
|
| 34 |
+
})
|
| 35 |
+
clearTimeout(timer)
|
| 36 |
+
|
| 37 |
+
if (!res.ok) {
|
| 38 |
+
const errText = await res.text().catch(() => "Unknown error")
|
| 39 |
+
console.error("[analyze-report] Backend error:", res.status, errText)
|
| 40 |
+
return NextResponse.json(
|
| 41 |
+
{ error: "Backend analysis failed", detail: errText },
|
| 42 |
+
{ status: res.status }
|
| 43 |
+
)
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
const data = await res.json()
|
| 47 |
+
|
| 48 |
+
// Transform backend AnalyzeResponse → frontend ParsedReport shape
|
| 49 |
+
const report = {
|
| 50 |
+
is_readable: data.is_readable,
|
| 51 |
+
report_type: data.report_type,
|
| 52 |
+
findings: (data.findings ?? []).map((f: Record<string, unknown>) => ({
|
| 53 |
+
parameter: f.parameter,
|
| 54 |
+
value: String(f.value),
|
| 55 |
+
unit: f.unit ?? "",
|
| 56 |
+
normal_range: f.normal_range ?? "",
|
| 57 |
+
status: f.status,
|
| 58 |
+
simple_name_hindi: f.simple_name_hindi ?? f.parameter,
|
| 59 |
+
simple_name_english: f.simple_name_english ?? f.parameter,
|
| 60 |
+
layman_explanation_hindi: f.layman_explanation_hindi ?? "",
|
| 61 |
+
layman_explanation_english: f.layman_explanation_english ?? "",
|
| 62 |
+
indian_population_mean: f.indian_population_mean ?? null,
|
| 63 |
+
indian_population_std: f.indian_population_std ?? null,
|
| 64 |
+
status_vs_india: f.status_vs_india ?? "",
|
| 65 |
+
})),
|
| 66 |
+
affected_organs: data.affected_organs ?? [],
|
| 67 |
+
overall_summary_hindi: data.overall_summary_hindi ?? "",
|
| 68 |
+
overall_summary_english: data.overall_summary_english ?? "",
|
| 69 |
+
severity_level: data.severity_level ?? "NORMAL",
|
| 70 |
+
dietary_flags: data.dietary_flags ?? [],
|
| 71 |
+
exercise_flags: data.exercise_flags ?? [],
|
| 72 |
+
ai_confidence_score: data.ai_confidence_score ?? 0,
|
| 73 |
+
grounded_in: data.grounded_in ?? "",
|
| 74 |
+
disclaimer: data.disclaimer ?? "AI-generated. Always consult a qualified doctor.",
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
return NextResponse.json(report)
|
| 78 |
+
} catch (err: unknown) {
|
| 79 |
+
const message = err instanceof Error ? err.message : "Unknown error"
|
| 80 |
+
if (message.includes("abort")) {
|
| 81 |
+
console.error("[analyze-report] Request timed out after", TIMEOUT_MS, "ms")
|
| 82 |
+
return NextResponse.json(
|
| 83 |
+
{ error: "Analysis timed out – using fallback", useMock: true },
|
| 84 |
+
{ status: 504 }
|
| 85 |
+
)
|
| 86 |
+
}
|
| 87 |
+
console.error("[analyze-report] Error:", message)
|
| 88 |
+
return NextResponse.json(
|
| 89 |
+
{ error: "Failed to analyze report", detail: message, useMock: true },
|
| 90 |
+
{ status: 500 }
|
| 91 |
+
)
|
| 92 |
+
}
|
| 93 |
+
}
|
frontend/app/api/chat/route.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// POST /api/chat
|
| 2 |
+
// Accepts: { message, history, guc }
|
| 3 |
+
// Forwards to FastAPI POST /chat
|
| 4 |
+
// Returns: { reply: string }
|
| 5 |
+
|
| 6 |
+
import { NextRequest, NextResponse } from "next/server"
|
| 7 |
+
|
| 8 |
+
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000"
|
| 9 |
+
const TIMEOUT_MS = 12_000
|
| 10 |
+
|
| 11 |
+
export async function POST(req: NextRequest) {
|
| 12 |
+
try {
|
| 13 |
+
const body = await req.json()
|
| 14 |
+
const { message, history = [], guc = {} } = body
|
| 15 |
+
|
| 16 |
+
if (!message) {
|
| 17 |
+
return NextResponse.json({ error: "No message provided" }, { status: 400 })
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
const controller = new AbortController()
|
| 21 |
+
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS)
|
| 22 |
+
|
| 23 |
+
const res = await fetch(`${API_BASE}/chat`, {
|
| 24 |
+
method: "POST",
|
| 25 |
+
headers: { "Content-Type": "application/json" },
|
| 26 |
+
body: JSON.stringify({ message, history, guc }),
|
| 27 |
+
signal: controller.signal,
|
| 28 |
+
})
|
| 29 |
+
clearTimeout(timer)
|
| 30 |
+
|
| 31 |
+
if (!res.ok) {
|
| 32 |
+
const errText = await res.text().catch(() => "Unknown error")
|
| 33 |
+
console.error("[chat] Backend error:", res.status, errText)
|
| 34 |
+
return NextResponse.json(
|
| 35 |
+
{ reply: "Sorry, I'm having trouble connecting right now. Please try again." },
|
| 36 |
+
{ status: 200 } // Return 200 so the UI shows the fallback message
|
| 37 |
+
)
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
const data = await res.json()
|
| 41 |
+
return NextResponse.json({ reply: data.reply ?? data.answer ?? "I'm here to help." })
|
| 42 |
+
} catch (err: unknown) {
|
| 43 |
+
const message = err instanceof Error ? err.message : "Unknown error"
|
| 44 |
+
console.error("[chat] Error:", message)
|
| 45 |
+
return NextResponse.json(
|
| 46 |
+
{ reply: "Sorry, the doctor is unavailable right now. Please try again in a moment." },
|
| 47 |
+
{ status: 200 }
|
| 48 |
+
)
|
| 49 |
+
}
|
| 50 |
+
}
|
frontend/app/avatar/page.tsx
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { useGUCStore } from "@/lib/store"
|
| 4 |
+
import { motion } from "framer-motion"
|
| 5 |
+
import { ArrowLeft } from "lucide-react"
|
| 6 |
+
import { useRouter } from "next/navigation"
|
| 7 |
+
import { PageShell, PageHeader, Card, SectionLabel, Banner } from "@/components/ui"
|
| 8 |
+
import { motionPresets, colors } from "@/lib/tokens"
|
| 9 |
+
|
| 10 |
+
const LEVELS = [
|
| 11 |
+
{ level: 1, emoji: "😔", title: "Rogi", color: "#EF4444", xp: 0 },
|
| 12 |
+
{ level: 2, emoji: "🙂", title: "Jagruk", color: "#F59E0B", xp: 150 },
|
| 13 |
+
{ level: 3, emoji: "💪", title: "Swasth", color: "#10B981", xp: 300 },
|
| 14 |
+
{ level: 4, emoji: "⚔️", title: "Yoddha", color: "#3B82F6", xp: 500 },
|
| 15 |
+
{ level: 5, emoji: "👑", title: "Nirogh", color: "#A855F7", xp: 750 },
|
| 16 |
+
]
|
| 17 |
+
|
| 18 |
+
const XP_ACTIONS = [
|
| 19 |
+
{ emoji: "📄", text: "Report upload karo", xp: 50 },
|
| 20 |
+
{ emoji: "✅", text: "Checklist complete karo", xp: 20 },
|
| 21 |
+
{ emoji: "💬", text: "5 messages bhejo", xp: 5 },
|
| 22 |
+
{ emoji: "🥗", text: "Meal log karo", xp: 15 },
|
| 23 |
+
{ emoji: "🏃", text: "Exercise karo", xp: 10 },
|
| 24 |
+
{ emoji: "😊", text: "Mood check-in karo", xp: 5 },
|
| 25 |
+
{ emoji: "🔥", text: "7-day streak banao", xp: 25 },
|
| 26 |
+
{ emoji: "📤", text: "Family se share karo", xp: 30 },
|
| 27 |
+
]
|
| 28 |
+
|
| 29 |
+
export default function AvatarPage() {
|
| 30 |
+
const router = useRouter()
|
| 31 |
+
const { avatarXP: currentXP } = useGUCStore()
|
| 32 |
+
const currentLevel = currentXP >= 750 ? 5 : currentXP >= 500 ? 4 : currentXP >= 300 ? 3 : currentXP >= 150 ? 2 : 1
|
| 33 |
+
|
| 34 |
+
const lvl = LEVELS[currentLevel - 1]
|
| 35 |
+
const nextLvl = LEVELS[currentLevel]
|
| 36 |
+
const progress = nextLvl
|
| 37 |
+
? ((currentXP - lvl.xp) / (nextLvl.xp - lvl.xp)) * 100
|
| 38 |
+
: 100
|
| 39 |
+
|
| 40 |
+
return (
|
| 41 |
+
<PageShell>
|
| 42 |
+
|
| 43 |
+
{/* Back button */}
|
| 44 |
+
<motion.button
|
| 45 |
+
onClick={() => router.back()}
|
| 46 |
+
className="mb-5 flex items-center gap-1.5 text-sm transition-colors"
|
| 47 |
+
style={{ color: colors.textMuted }}
|
| 48 |
+
whileHover={{ color: colors.textPrimary }}
|
| 49 |
+
{...motionPresets.fadeIn}
|
| 50 |
+
>
|
| 51 |
+
<ArrowLeft size={16} /> Back
|
| 52 |
+
</motion.button>
|
| 53 |
+
|
| 54 |
+
<PageHeader icon="⚡" title="Mera Avatar" subtitle="Apni health journey track karo" />
|
| 55 |
+
|
| 56 |
+
{/* Main level card */}
|
| 57 |
+
<motion.div
|
| 58 |
+
{...motionPresets.fadeUp}
|
| 59 |
+
transition={{ duration: 0.35, delay: 0.1 }}
|
| 60 |
+
className="mb-6 rounded-2xl p-6 text-center"
|
| 61 |
+
style={{
|
| 62 |
+
border: `2px solid ${lvl.color}`,
|
| 63 |
+
background: `${lvl.color}15`,
|
| 64 |
+
}}
|
| 65 |
+
>
|
| 66 |
+
<div className="text-7xl mb-3">{lvl.emoji}</div>
|
| 67 |
+
<div className="text-2xl font-bold mb-1 text-white">{lvl.title}</div>
|
| 68 |
+
<div className="text-sm mb-5" style={{ color: colors.textMuted }}>Level {currentLevel}</div>
|
| 69 |
+
|
| 70 |
+
{/* XP Progress bar */}
|
| 71 |
+
<div className="w-full rounded-full h-2.5 mb-2" style={{ background: colors.bgSubtle }}>
|
| 72 |
+
<motion.div
|
| 73 |
+
className="h-2.5 rounded-full"
|
| 74 |
+
style={{ background: lvl.color }}
|
| 75 |
+
initial={{ width: 0 }}
|
| 76 |
+
animate={{ width: `${progress}%` }}
|
| 77 |
+
transition={{ duration: 0.9, delay: 0.4, ease: "easeOut" }}
|
| 78 |
+
/>
|
| 79 |
+
</div>
|
| 80 |
+
<div className="text-xs" style={{ color: colors.textFaint }}>
|
| 81 |
+
{currentXP} XP → {nextLvl?.xp ?? "MAX"} XP to Level {currentLevel + 1}
|
| 82 |
+
</div>
|
| 83 |
+
</motion.div>
|
| 84 |
+
|
| 85 |
+
{/* Level roadmap */}
|
| 86 |
+
<SectionLabel>🗺️ Level Journey</SectionLabel>
|
| 87 |
+
<div className="flex flex-col gap-2.5 mb-6">
|
| 88 |
+
{LEVELS.map((l, i) => {
|
| 89 |
+
const completed = l.level < currentLevel
|
| 90 |
+
const current = l.level === currentLevel
|
| 91 |
+
const future = l.level > currentLevel
|
| 92 |
+
return (
|
| 93 |
+
<motion.div
|
| 94 |
+
key={l.level}
|
| 95 |
+
className="flex items-center gap-3 rounded-xl px-4 py-3"
|
| 96 |
+
style={{
|
| 97 |
+
border: current ? `2px solid ${l.color}` : `1px solid ${colors.border}`,
|
| 98 |
+
background: current ? `${l.color}15` : colors.bgCard,
|
| 99 |
+
opacity: future ? 0.4 : completed ? 0.7 : 1,
|
| 100 |
+
}}
|
| 101 |
+
{...motionPresets.fadeUp}
|
| 102 |
+
transition={{ duration: 0.3, delay: 0.15 + i * 0.05 }}
|
| 103 |
+
>
|
| 104 |
+
<span className="text-2xl">{l.emoji}</span>
|
| 105 |
+
<div className="flex-1">
|
| 106 |
+
<div className="font-semibold text-white text-sm">{l.title}</div>
|
| 107 |
+
<div className="text-xs" style={{ color: colors.textMuted }}>
|
| 108 |
+
Level {l.level} · {l.xp} XP
|
| 109 |
+
</div>
|
| 110 |
+
</div>
|
| 111 |
+
{completed && <span className="text-sm" style={{ color: colors.ok }}>✓ Done</span>}
|
| 112 |
+
{current && (
|
| 113 |
+
<span className="text-[10px] px-2 py-0.5 rounded-full font-medium"
|
| 114 |
+
style={{ background: `${l.color}25`, color: l.color }}>
|
| 115 |
+
Current
|
| 116 |
+
</span>
|
| 117 |
+
)}
|
| 118 |
+
</motion.div>
|
| 119 |
+
)
|
| 120 |
+
})}
|
| 121 |
+
</div>
|
| 122 |
+
|
| 123 |
+
{/* XP guide */}
|
| 124 |
+
<SectionLabel>💰 XP Kaise Kamayein</SectionLabel>
|
| 125 |
+
<div className="grid grid-cols-2 gap-2.5">
|
| 126 |
+
{XP_ACTIONS.map((a, i) => (
|
| 127 |
+
<motion.div
|
| 128 |
+
key={i}
|
| 129 |
+
className="rounded-xl px-3 py-4 text-center"
|
| 130 |
+
style={{ background: colors.bgCard, border: `1px solid ${colors.border}` }}
|
| 131 |
+
{...motionPresets.fadeUp}
|
| 132 |
+
transition={{ duration: 0.3, delay: 0.2 + i * 0.04 }}
|
| 133 |
+
>
|
| 134 |
+
<div className="text-2xl mb-1.5">{a.emoji}</div>
|
| 135 |
+
<div className="text-xs mb-1.5" style={{ color: colors.textMuted }}>{a.text}</div>
|
| 136 |
+
<div className="text-sm font-bold" style={{ color: colors.accent }}>+{a.xp} XP</div>
|
| 137 |
+
</motion.div>
|
| 138 |
+
))}
|
| 139 |
+
</div>
|
| 140 |
+
|
| 141 |
+
</PageShell>
|
| 142 |
+
)
|
| 143 |
+
}
|
frontend/app/dashboard/page.tsx
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
import { useState, useEffect } from "react";
|
| 3 |
+
import { motion, AnimatePresence } from "framer-motion";
|
| 4 |
+
import { useRouter } from "next/navigation";
|
| 5 |
+
import BodyMap from "@/components/BodyMap";
|
| 6 |
+
import LabValuesTable from "@/components/LabValuesTable";
|
| 7 |
+
import ConfidenceGauge from "@/components/ConfidenceGauge";
|
| 8 |
+
import HealthChecklist from "@/components/HealthChecklist";
|
| 9 |
+
import ShareButton from "@/components/ShareButton";
|
| 10 |
+
import DoctorChat from "@/components/DoctorChat";
|
| 11 |
+
import { PageShell, SectionLabel, Banner } from "@/components/ui";
|
| 12 |
+
import { colors } from "@/lib/tokens";
|
| 13 |
+
import { useGUCStore } from "@/lib/store";
|
| 14 |
+
|
| 15 |
+
// Shared card style — matches the dark theme
|
| 16 |
+
const CARD_STYLE: React.CSSProperties = {
|
| 17 |
+
background: colors.bgCard,
|
| 18 |
+
border: `1px solid ${colors.border}`,
|
| 19 |
+
borderRadius: 16,
|
| 20 |
+
padding: 20,
|
| 21 |
+
};
|
| 22 |
+
|
| 23 |
+
const cardVariants = {
|
| 24 |
+
hidden: { opacity: 0, y: 20 },
|
| 25 |
+
visible: (i: number) => ({
|
| 26 |
+
opacity: 1, y: 0,
|
| 27 |
+
transition: { delay: i * 0.08, duration: 0.35, ease: "easeOut" },
|
| 28 |
+
}),
|
| 29 |
+
};
|
| 30 |
+
|
| 31 |
+
// Hover card wrapper
|
| 32 |
+
const HoverCard = ({ children, custom, style }: { children: React.ReactNode; custom: number; style?: React.CSSProperties }) => (
|
| 33 |
+
<motion.div
|
| 34 |
+
custom={custom} variants={cardVariants} initial="hidden" animate="visible"
|
| 35 |
+
whileHover={{ y: -3, boxShadow: "0 8px 32px rgba(0,0,0,0.35)" }}
|
| 36 |
+
transition={{ type: "spring", stiffness: 300, damping: 25 }}
|
| 37 |
+
style={{ ...CARD_STYLE, ...style }}
|
| 38 |
+
>
|
| 39 |
+
{children}
|
| 40 |
+
</motion.div>
|
| 41 |
+
);
|
| 42 |
+
|
| 43 |
+
export default function Dashboard() {
|
| 44 |
+
const router = useRouter();
|
| 45 |
+
const latestReport = useGUCStore((s) => s.latestReport);
|
| 46 |
+
const profile = useGUCStore((s) => s.profile);
|
| 47 |
+
const checklistProgress = useGUCStore((s) => s.checklistProgress);
|
| 48 |
+
const addXP = useGUCStore((s) => s.addXP);
|
| 49 |
+
const appendChatMessage = useGUCStore((s) => s.appendChatMessage);
|
| 50 |
+
const chatHistory = useGUCStore((s) => s.chatHistory);
|
| 51 |
+
|
| 52 |
+
const [speaking, setSpeaking] = useState(false);
|
| 53 |
+
const [xp, setXp] = useState(0);
|
| 54 |
+
const [showXPBurst, setShowXPBurst] = useState(false);
|
| 55 |
+
const [showConfetti, setShowConfetti] = useState(false);
|
| 56 |
+
|
| 57 |
+
const language = profile.language === "HI" ? "hindi" : "english";
|
| 58 |
+
|
| 59 |
+
useEffect(() => {
|
| 60 |
+
setTimeout(() => setShowConfetti(true), 300);
|
| 61 |
+
setTimeout(() => setShowConfetti(false), 2500);
|
| 62 |
+
}, []);
|
| 63 |
+
|
| 64 |
+
// Redirect to home if no report
|
| 65 |
+
if (!latestReport) {
|
| 66 |
+
return (
|
| 67 |
+
<PageShell>
|
| 68 |
+
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4">
|
| 69 |
+
<span className="text-6xl">📋</span>
|
| 70 |
+
<h2 className="text-xl font-bold text-white">No Report Uploaded</h2>
|
| 71 |
+
<p className="text-sm text-center" style={{ color: colors.textMuted }}>
|
| 72 |
+
Upload a medical report first to see your analysis dashboard.
|
| 73 |
+
</p>
|
| 74 |
+
<motion.button
|
| 75 |
+
onClick={() => router.push("/")}
|
| 76 |
+
className="mt-2 px-6 py-3 rounded-xl text-sm font-bold"
|
| 77 |
+
style={{
|
| 78 |
+
background: "linear-gradient(135deg, #FF9933 0%, #FFAA55 100%)",
|
| 79 |
+
color: "#0d0d1a",
|
| 80 |
+
boxShadow: "0 2px 12px rgba(255,153,51,0.3)",
|
| 81 |
+
}}
|
| 82 |
+
whileHover={{ scale: 1.02 }}
|
| 83 |
+
whileTap={{ scale: 0.97 }}
|
| 84 |
+
>
|
| 85 |
+
← Upload Report
|
| 86 |
+
</motion.button>
|
| 87 |
+
</div>
|
| 88 |
+
</PageShell>
|
| 89 |
+
);
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
const report = latestReport;
|
| 93 |
+
|
| 94 |
+
// Derive data from report
|
| 95 |
+
const abnormalFindings = report.findings.filter(
|
| 96 |
+
(f) => f.status === "HIGH" || f.status === "LOW" || f.status === "CRITICAL"
|
| 97 |
+
);
|
| 98 |
+
|
| 99 |
+
const organFlags = {
|
| 100 |
+
liver: report.affected_organs.includes("LIVER"),
|
| 101 |
+
heart: report.affected_organs.includes("HEART"),
|
| 102 |
+
kidney: report.affected_organs.includes("KIDNEY"),
|
| 103 |
+
lungs: report.affected_organs.includes("LUNGS"),
|
| 104 |
+
};
|
| 105 |
+
|
| 106 |
+
const labValues = report.findings.map((f) => ({
|
| 107 |
+
name: f.simple_name_english || f.parameter,
|
| 108 |
+
nameHi: f.simple_name_hindi || f.parameter,
|
| 109 |
+
value: parseFloat(f.value) || 0,
|
| 110 |
+
unit: f.unit || "",
|
| 111 |
+
status: (f.status === "CRITICAL" ? "HIGH" : f.status) as "HIGH" | "LOW" | "NORMAL",
|
| 112 |
+
}));
|
| 113 |
+
|
| 114 |
+
const checklist = checklistProgress.length > 0
|
| 115 |
+
? checklistProgress.map((c) => c.label)
|
| 116 |
+
: [
|
| 117 |
+
"Visit a doctor for proper diagnosis",
|
| 118 |
+
"Follow dietary recommendations",
|
| 119 |
+
"Take prescribed supplements",
|
| 120 |
+
"Light daily exercise",
|
| 121 |
+
"Re-test in 4-6 weeks",
|
| 122 |
+
];
|
| 123 |
+
|
| 124 |
+
const summaryText = language === "hindi"
|
| 125 |
+
? report.overall_summary_hindi
|
| 126 |
+
: report.overall_summary_english;
|
| 127 |
+
|
| 128 |
+
const handleListen = () => {
|
| 129 |
+
if (!window.speechSynthesis) return;
|
| 130 |
+
if (speaking) { window.speechSynthesis.cancel(); setSpeaking(false); return; }
|
| 131 |
+
const u = new SpeechSynthesisUtterance(
|
| 132 |
+
language === "hindi" ? report.overall_summary_hindi : report.overall_summary_english
|
| 133 |
+
);
|
| 134 |
+
u.lang = language === "hindi" ? "hi-IN" : "en-IN";
|
| 135 |
+
u.rate = 0.85;
|
| 136 |
+
u.onstart = () => setSpeaking(true);
|
| 137 |
+
u.onend = () => setSpeaking(false);
|
| 138 |
+
window.speechSynthesis.speak(u);
|
| 139 |
+
};
|
| 140 |
+
|
| 141 |
+
const handleAddXP = (amount: number) => {
|
| 142 |
+
setXp((p) => p + amount);
|
| 143 |
+
addXP(amount);
|
| 144 |
+
setShowXPBurst(true);
|
| 145 |
+
setTimeout(() => setShowXPBurst(false), 1000);
|
| 146 |
+
};
|
| 147 |
+
|
| 148 |
+
const handleChatSend = async (message: string): Promise<string> => {
|
| 149 |
+
appendChatMessage("user", message);
|
| 150 |
+
|
| 151 |
+
try {
|
| 152 |
+
// Build GUC for the chat
|
| 153 |
+
const guc = {
|
| 154 |
+
name: profile.name,
|
| 155 |
+
age: profile.age,
|
| 156 |
+
gender: profile.gender,
|
| 157 |
+
language: profile.language,
|
| 158 |
+
latestReport: report,
|
| 159 |
+
mentalWellness: useGUCStore.getState().mentalWellness,
|
| 160 |
+
};
|
| 161 |
+
|
| 162 |
+
const res = await fetch("/api/chat", {
|
| 163 |
+
method: "POST",
|
| 164 |
+
headers: { "Content-Type": "application/json" },
|
| 165 |
+
body: JSON.stringify({
|
| 166 |
+
message,
|
| 167 |
+
history: chatHistory.slice(-20), // Last 20 messages for context
|
| 168 |
+
guc,
|
| 169 |
+
}),
|
| 170 |
+
});
|
| 171 |
+
|
| 172 |
+
const data = await res.json();
|
| 173 |
+
const reply = data.reply || "Sorry, I could not process your request.";
|
| 174 |
+
appendChatMessage("assistant", reply);
|
| 175 |
+
return reply;
|
| 176 |
+
} catch {
|
| 177 |
+
const fallback = "Sorry, I'm having trouble connecting. Please try again.";
|
| 178 |
+
appendChatMessage("assistant", fallback);
|
| 179 |
+
return fallback;
|
| 180 |
+
}
|
| 181 |
+
};
|
| 182 |
+
|
| 183 |
+
return (
|
| 184 |
+
<PageShell>
|
| 185 |
+
{/* Confetti */}
|
| 186 |
+
<AnimatePresence>
|
| 187 |
+
{showConfetti && (
|
| 188 |
+
<div className="fixed inset-0 pointer-events-none z-50 overflow-hidden">
|
| 189 |
+
{[...Array(25)].map((_, i) => (
|
| 190 |
+
<motion.div key={i} className="absolute w-2 h-2 rounded-sm"
|
| 191 |
+
style={{
|
| 192 |
+
left: `${Math.random() * 100}%`,
|
| 193 |
+
top: "-10px",
|
| 194 |
+
background: ["#FF9933", "#22C55E", "#3B82F6", "#EC4899"][i % 4],
|
| 195 |
+
}}
|
| 196 |
+
animate={{ y: ["0vh", "110vh"], rotate: [0, 720], opacity: [1, 1, 0] }}
|
| 197 |
+
transition={{ duration: 1.5 + Math.random(), delay: Math.random() * 0.5 }}
|
| 198 |
+
/>
|
| 199 |
+
))}
|
| 200 |
+
</div>
|
| 201 |
+
)}
|
| 202 |
+
</AnimatePresence>
|
| 203 |
+
|
| 204 |
+
{/* Sticky header */}
|
| 205 |
+
<div
|
| 206 |
+
className="sticky top-0 z-40 flex items-center justify-between px-5 py-3 mb-5 -mx-4 rounded-b-2xl"
|
| 207 |
+
style={{
|
| 208 |
+
background: "rgba(13,13,26,0.94)",
|
| 209 |
+
backdropFilter: "blur(20px)",
|
| 210 |
+
borderBottom: `1px solid rgba(255,153,51,0.08)`,
|
| 211 |
+
boxShadow: "0 1px 0 rgba(255,153,51,0.05), 0 4px 24px rgba(0,0,0,0.3)",
|
| 212 |
+
}}
|
| 213 |
+
>
|
| 214 |
+
<div className="absolute left-0 top-0 h-full w-16 pointer-events-none rounded-bl-2xl"
|
| 215 |
+
style={{ background: "radial-gradient(ellipse at 0% 50%, rgba(255,153,51,0.06) 0%, transparent 80%)" }} />
|
| 216 |
+
<div>
|
| 217 |
+
<div className="flex items-center gap-2">
|
| 218 |
+
<span className="text-lg">🏥</span>
|
| 219 |
+
<h1 className="text-lg font-black">
|
| 220 |
+
<span style={{ color: "rgba(255,255,255,0.6)" }}>Report</span>
|
| 221 |
+
<span style={{ color: colors.accent }}>Raahat</span>
|
| 222 |
+
</h1>
|
| 223 |
+
</div>
|
| 224 |
+
<p className="text-xs font-devanagari" style={{ color: colors.textMuted }}>
|
| 225 |
+
नमस्ते, {profile.name} 🙏
|
| 226 |
+
</p>
|
| 227 |
+
</div>
|
| 228 |
+
<div className="flex items-center gap-2">
|
| 229 |
+
{/* XP pill */}
|
| 230 |
+
<motion.div
|
| 231 |
+
className="flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-bold"
|
| 232 |
+
style={{
|
| 233 |
+
background: "rgba(255,153,51,0.12)",
|
| 234 |
+
border: `1px solid rgba(255,153,51,0.25)`,
|
| 235 |
+
color: colors.accent,
|
| 236 |
+
boxShadow: showXPBurst ? "0 0 16px rgba(255,153,51,0.35)" : "none",
|
| 237 |
+
}}
|
| 238 |
+
animate={showXPBurst ? { scale: [1, 1.25, 1] } : {}}
|
| 239 |
+
transition={{ duration: 0.3 }}
|
| 240 |
+
>
|
| 241 |
+
⭐ {xp} XP
|
| 242 |
+
</motion.div>
|
| 243 |
+
{/* Listen button */}
|
| 244 |
+
<motion.button
|
| 245 |
+
onClick={handleListen}
|
| 246 |
+
className="flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold transition-all"
|
| 247 |
+
style={{
|
| 248 |
+
background: speaking ? colors.accent : colors.bgSubtle,
|
| 249 |
+
color: speaking ? "#0d0d1a" : colors.textMuted,
|
| 250 |
+
border: `1px solid ${speaking ? colors.accent : colors.border}`,
|
| 251 |
+
boxShadow: speaking ? "0 0 12px rgba(255,153,51,0.3)" : "none",
|
| 252 |
+
}}
|
| 253 |
+
whileTap={{ scale: 0.95 }}
|
| 254 |
+
>
|
| 255 |
+
🎧 {speaking ? "रोकें" : "सुनें"}
|
| 256 |
+
</motion.button>
|
| 257 |
+
<a href="/" className="text-xs transition-colors"
|
| 258 |
+
style={{ color: colors.textMuted }}>
|
| 259 |
+
← New
|
| 260 |
+
</a>
|
| 261 |
+
</div>
|
| 262 |
+
</div>
|
| 263 |
+
|
| 264 |
+
{/* Deficiency banner */}
|
| 265 |
+
{abnormalFindings.length > 0 && (
|
| 266 |
+
<Banner delay={0.05}>
|
| 267 |
+
रिपोर्ट में {abnormalFindings.length} असामान्य मान मिले — {report.affected_organs.join(", ")} पर ध्यान दें।
|
| 268 |
+
</Banner>
|
| 269 |
+
)}
|
| 270 |
+
|
| 271 |
+
{/* 2-col card grid */}
|
| 272 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
| 273 |
+
|
| 274 |
+
{/* Card 1 — Report summary */}
|
| 275 |
+
<HoverCard custom={0}>
|
| 276 |
+
<SectionLabel>📄 Report Summary</SectionLabel>
|
| 277 |
+
<p className="text-sm leading-relaxed mb-2" style={{ color: colors.textSecondary }}>
|
| 278 |
+
{report.overall_summary_english}
|
| 279 |
+
</p>
|
| 280 |
+
{language === "hindi" && (
|
| 281 |
+
<p className="text-sm leading-relaxed font-devanagari" style={{ color: "rgba(255,255,255,0.7)" }}>
|
| 282 |
+
{report.overall_summary_hindi}
|
| 283 |
+
</p>
|
| 284 |
+
)}
|
| 285 |
+
<div className="flex items-center gap-2 mt-3">
|
| 286 |
+
<span className="text-[10px] px-2 py-1 rounded-full font-medium"
|
| 287 |
+
style={{
|
| 288 |
+
background: report.severity_level === "URGENT" ? "rgba(239,68,68,0.15)" :
|
| 289 |
+
report.severity_level === "MODERATE_CONCERN" ? "rgba(245,158,11,0.15)" :
|
| 290 |
+
"rgba(34,197,94,0.15)",
|
| 291 |
+
color: report.severity_level === "URGENT" ? "#EF4444" :
|
| 292 |
+
report.severity_level === "MODERATE_CONCERN" ? "#F59E0B" :
|
| 293 |
+
"#22C55E",
|
| 294 |
+
}}>
|
| 295 |
+
{report.severity_level.replace(/_/g, " ")}
|
| 296 |
+
</span>
|
| 297 |
+
<span className="text-[10px]" style={{ color: colors.textFaint }}>
|
| 298 |
+
{report.report_type.replace(/_/g, " ")}
|
| 299 |
+
</span>
|
| 300 |
+
</div>
|
| 301 |
+
</HoverCard>
|
| 302 |
+
|
| 303 |
+
{/* Card 2 — Simple explanation */}
|
| 304 |
+
<HoverCard custom={1} style={{ borderColor: colors.accentBorder }}>
|
| 305 |
+
<SectionLabel>💬 आसान भाषा में</SectionLabel>
|
| 306 |
+
<p className="text-white text-base leading-relaxed font-semibold mb-2">
|
| 307 |
+
{report.overall_summary_hindi}
|
| 308 |
+
</p>
|
| 309 |
+
<p className="text-sm leading-relaxed mb-4" style={{ color: colors.textMuted }}>
|
| 310 |
+
{report.overall_summary_english}
|
| 311 |
+
</p>
|
| 312 |
+
<motion.button
|
| 313 |
+
onClick={handleListen}
|
| 314 |
+
className="flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-bold transition-all"
|
| 315 |
+
style={{
|
| 316 |
+
background: "linear-gradient(135deg, #FF9933 0%, #FFAA55 100%)",
|
| 317 |
+
color: "#0d0d1a",
|
| 318 |
+
boxShadow: "0 2px 12px rgba(255,153,51,0.35)"
|
| 319 |
+
}}
|
| 320 |
+
whileHover={{ scale: 1.02, boxShadow: "0 4px 20px rgba(255,153,51,0.45)" }}
|
| 321 |
+
whileTap={{ scale: 0.97 }}
|
| 322 |
+
>
|
| 323 |
+
🎧 सुनें (Listen)
|
| 324 |
+
</motion.button>
|
| 325 |
+
</HoverCard>
|
| 326 |
+
|
| 327 |
+
{/* Card 3 — Body Map */}
|
| 328 |
+
<HoverCard custom={2}>
|
| 329 |
+
<SectionLabel>🫀 Affected Body Part</SectionLabel>
|
| 330 |
+
<BodyMap organFlags={organFlags} />
|
| 331 |
+
</HoverCard>
|
| 332 |
+
|
| 333 |
+
{/* Card 4 — Lab Values */}
|
| 334 |
+
<HoverCard custom={3}>
|
| 335 |
+
<SectionLabel>🧪 Lab Values</SectionLabel>
|
| 336 |
+
<LabValuesTable values={labValues} />
|
| 337 |
+
</HoverCard>
|
| 338 |
+
|
| 339 |
+
{/* Card 5 — AI Confidence */}
|
| 340 |
+
<HoverCard custom={4} style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center" }}>
|
| 341 |
+
<SectionLabel>🎯 AI Confidence</SectionLabel>
|
| 342 |
+
<ConfidenceGauge score={report.ai_confidence_score} />
|
| 343 |
+
</HoverCard>
|
| 344 |
+
|
| 345 |
+
{/* Card 6 — Checklist */}
|
| 346 |
+
<HoverCard custom={5}>
|
| 347 |
+
<SectionLabel>✅ अगले कदम (Next Steps)</SectionLabel>
|
| 348 |
+
<HealthChecklist items={checklist} onXP={handleAddXP} />
|
| 349 |
+
</HoverCard>
|
| 350 |
+
|
| 351 |
+
{/* Card 7 — Share (full width) */}
|
| 352 |
+
<HoverCard custom={6} style={{ gridColumn: "span 2", display: "flex", flexDirection: "column", alignItems: "center", gap: 12 }}>
|
| 353 |
+
<SectionLabel>📱 Share with Family</SectionLabel>
|
| 354 |
+
<ShareButton summary={summaryText} onXP={handleAddXP} />
|
| 355 |
+
</HoverCard>
|
| 356 |
+
|
| 357 |
+
</div>
|
| 358 |
+
|
| 359 |
+
{/* Doctor Chat */}
|
| 360 |
+
<DoctorChat onSend={handleChatSend} />
|
| 361 |
+
</PageShell>
|
| 362 |
+
);
|
| 363 |
+
}
|
frontend/app/exercise/page.tsx
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useEffect, useState } from "react";
|
| 4 |
+
import { motion, AnimatePresence } from "framer-motion";
|
| 5 |
+
import { useGUCStore } from "@/lib/store";
|
| 6 |
+
import { PageShell } from "@/components/ui";
|
| 7 |
+
|
| 8 |
+
interface ExerciseDay {
|
| 9 |
+
day: string;
|
| 10 |
+
activity: string;
|
| 11 |
+
duration_minutes: number;
|
| 12 |
+
intensity: string;
|
| 13 |
+
notes: string;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
interface ExerciseResponse {
|
| 17 |
+
tier: string;
|
| 18 |
+
tier_description: string;
|
| 19 |
+
weekly_plan: ExerciseDay[];
|
| 20 |
+
general_advice: string;
|
| 21 |
+
avoid: string[];
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
const TIER_CONFIG: Record<string, { color: string; bg: string; icon: string; badge: string }> = {
|
| 25 |
+
LIGHT_WALKING_ONLY: { color: "#22C55E", bg: "#22C55E15", icon: "🚶", badge: "Light Recovery" },
|
| 26 |
+
CARDIO_RESTRICTED: { color: "#06B6D4", bg: "#06B6D415", icon: "🧘", badge: "Low Intensity" },
|
| 27 |
+
NORMAL_ACTIVITY: { color: "#FF9933", bg: "#FF993315", icon: "🏃", badge: "Moderate" },
|
| 28 |
+
ACTIVE_ENCOURAGED: { color: "#EF4444", bg: "#EF444415", icon: "💪", badge: "Active" },
|
| 29 |
+
};
|
| 30 |
+
|
| 31 |
+
const INTENSITY_COLORS: Record<string, string> = {
|
| 32 |
+
"Very Low": "#64748B", "Low": "#22C55E", "Low-Moderate": "#84CC16",
|
| 33 |
+
"Moderate": "#FF9933", "Moderate-High": "#F97316", "High": "#EF4444",
|
| 34 |
+
"Very High": "#DC2626", "Rest": "#334155",
|
| 35 |
+
};
|
| 36 |
+
|
| 37 |
+
const DAY_ABBR: Record<string, string> = {
|
| 38 |
+
Monday: "Mon", Tuesday: "Tue", Wednesday: "Wed", Thursday: "Thu",
|
| 39 |
+
Friday: "Fri", Saturday: "Sat", Sunday: "Sun",
|
| 40 |
+
};
|
| 41 |
+
|
| 42 |
+
const FALLBACK_PLAN: ExerciseResponse = {
|
| 43 |
+
tier: "NORMAL_ACTIVITY",
|
| 44 |
+
tier_description: "Standard moderate activity plan. 30-minute sessions, 5 days a week.",
|
| 45 |
+
general_advice: "Stay consistent. 5 days of 30 minutes beats 1 day of 2 hours. Drink water before and after.",
|
| 46 |
+
avoid: ["Exercising on an empty stomach", "Skipping warm-up and cool-down", "Pushing through sharp pain"],
|
| 47 |
+
weekly_plan: [
|
| 48 |
+
{ day: "Monday", activity: "Brisk walking 30 min", duration_minutes: 30, intensity: "Moderate", notes: "Comfortable pace, slightly breathless." },
|
| 49 |
+
{ day: "Tuesday", activity: "Bodyweight squats, push-ups, lunges", duration_minutes: 30, intensity: "Moderate", notes: "3 sets of 12 reps each." },
|
| 50 |
+
{ day: "Wednesday", activity: "Yoga + stretching", duration_minutes: 30, intensity: "Low", notes: "Active recovery. Focus on flexibility." },
|
| 51 |
+
{ day: "Thursday", activity: "Brisk walk + light jog intervals", duration_minutes: 35, intensity: "Moderate", notes: "3 min walk, 2 min jog. Repeat 5 times." },
|
| 52 |
+
{ day: "Friday", activity: "Resistance band strength training", duration_minutes: 30, intensity: "Moderate", notes: "Focus on compound movements." },
|
| 53 |
+
{ day: "Saturday", activity: "Recreational activity — badminton or cycling", duration_minutes: 45, intensity: "Moderate", notes: "Make it fun and social!" },
|
| 54 |
+
{ day: "Sunday", activity: "Rest day", duration_minutes: 0, intensity: "Rest", notes: "Full rest. Light household activity fine." },
|
| 55 |
+
],
|
| 56 |
+
};
|
| 57 |
+
|
| 58 |
+
export default function ExercisePage() {
|
| 59 |
+
const exerciseLevel = useGUCStore((s) => s.exerciseLevel);
|
| 60 |
+
const latestReport = useGUCStore((s) => s.latestReport);
|
| 61 |
+
const profile = useGUCStore((s) => s.profile);
|
| 62 |
+
const addXP = useGUCStore((s) => s.addXP);
|
| 63 |
+
const setAvatarState = useGUCStore((s) => s.setAvatarState);
|
| 64 |
+
|
| 65 |
+
const [data, setData] = useState<ExerciseResponse | null>(null);
|
| 66 |
+
const [loading, setLoading] = useState(true);
|
| 67 |
+
const [selectedDay, setSelectedDay] = useState<string | null>(null);
|
| 68 |
+
const [completedDays, setCompletedDays] = useState<Set<string>>(new Set());
|
| 69 |
+
|
| 70 |
+
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000";
|
| 71 |
+
const severity = latestReport?.severity_level ?? "MILD_CONCERN";
|
| 72 |
+
|
| 73 |
+
useEffect(() => {
|
| 74 |
+
const fetchPlan = async () => {
|
| 75 |
+
try {
|
| 76 |
+
setLoading(true);
|
| 77 |
+
const res = await fetch(
|
| 78 |
+
`${API_BASE}/exercise?exercise_flags=${exerciseLevel}&severity_level=${severity}&language=${profile.language}`
|
| 79 |
+
);
|
| 80 |
+
if (!res.ok) throw new Error();
|
| 81 |
+
const json: ExerciseResponse = await res.json();
|
| 82 |
+
setData(json);
|
| 83 |
+
setSelectedDay("Monday");
|
| 84 |
+
} catch {
|
| 85 |
+
setData(FALLBACK_PLAN);
|
| 86 |
+
setSelectedDay("Monday");
|
| 87 |
+
} finally {
|
| 88 |
+
setLoading(false);
|
| 89 |
+
}
|
| 90 |
+
};
|
| 91 |
+
fetchPlan();
|
| 92 |
+
}, [exerciseLevel, severity, API_BASE, profile.language]);
|
| 93 |
+
|
| 94 |
+
const handleComplete = (day: string) => {
|
| 95 |
+
if (completedDays.has(day)) return;
|
| 96 |
+
setCompletedDays((prev) => new Set([...prev, day]));
|
| 97 |
+
addXP(10);
|
| 98 |
+
setAvatarState("HAPPY");
|
| 99 |
+
};
|
| 100 |
+
|
| 101 |
+
const tierCfg = TIER_CONFIG[data?.tier ?? "NORMAL_ACTIVITY"] ?? TIER_CONFIG.NORMAL_ACTIVITY;
|
| 102 |
+
const weekTotal = (data?.weekly_plan ?? []).reduce((s, d) => s + d.duration_minutes, 0);
|
| 103 |
+
|
| 104 |
+
if (loading) {
|
| 105 |
+
return (
|
| 106 |
+
<PageShell>
|
| 107 |
+
<div className="space-y-4">
|
| 108 |
+
<div className="h-8 w-56 bg-white/5 rounded-xl animate-pulse" />
|
| 109 |
+
<div className="h-28 bg-white/5 rounded-2xl animate-pulse" />
|
| 110 |
+
<div className="flex gap-2">
|
| 111 |
+
{[...Array(7)].map((_, i) => (
|
| 112 |
+
<div key={i} className="flex-1 h-16 bg-white/5 rounded-xl animate-pulse" />
|
| 113 |
+
))}
|
| 114 |
+
</div>
|
| 115 |
+
<div className="h-40 bg-white/5 rounded-2xl animate-pulse" />
|
| 116 |
+
</div>
|
| 117 |
+
</PageShell>
|
| 118 |
+
);
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
return (
|
| 122 |
+
<PageShell>
|
| 123 |
+
|
| 124 |
+
{/* Header */}
|
| 125 |
+
<motion.div initial={{ opacity: 0, y: -12 }} animate={{ opacity: 1, y: 0 }} className="mb-6">
|
| 126 |
+
<div className="flex items-center gap-3 mb-1">
|
| 127 |
+
<span className="text-2xl">{tierCfg.icon}</span>
|
| 128 |
+
<h1 className="text-xl font-bold tracking-tight">Exercise Plan</h1>
|
| 129 |
+
</div>
|
| 130 |
+
<p className="text-white/40 text-sm">Adapted to your health condition</p>
|
| 131 |
+
</motion.div>
|
| 132 |
+
|
| 133 |
+
{/* Tier banner */}
|
| 134 |
+
<motion.div
|
| 135 |
+
initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 }}
|
| 136 |
+
className="mb-5 rounded-2xl p-4 border"
|
| 137 |
+
style={{ background: tierCfg.bg, borderColor: `${tierCfg.color}30` }}
|
| 138 |
+
>
|
| 139 |
+
<div className="flex justify-between items-start gap-3 mb-2">
|
| 140 |
+
<span
|
| 141 |
+
className="text-xs font-semibold px-2.5 py-1 rounded-full"
|
| 142 |
+
style={{ background: `${tierCfg.color}25`, color: tierCfg.color }}
|
| 143 |
+
>
|
| 144 |
+
{tierCfg.badge} Tier
|
| 145 |
+
</span>
|
| 146 |
+
<span className="text-white/40 text-xs">{weekTotal} min/week</span>
|
| 147 |
+
</div>
|
| 148 |
+
<p className="text-white/70 text-sm leading-relaxed">{data?.tier_description}</p>
|
| 149 |
+
</motion.div>
|
| 150 |
+
|
| 151 |
+
{/* Stats strip */}
|
| 152 |
+
<motion.div
|
| 153 |
+
initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.15 }}
|
| 154 |
+
className="flex gap-2 mb-5"
|
| 155 |
+
>
|
| 156 |
+
{[
|
| 157 |
+
{ label: "Days Active", value: (data?.weekly_plan ?? []).filter((d) => d.duration_minutes > 0).length },
|
| 158 |
+
{ label: "Total Minutes", value: weekTotal },
|
| 159 |
+
{ label: "Completed", value: completedDays.size },
|
| 160 |
+
].map((stat) => (
|
| 161 |
+
<div key={stat.label} className="flex-1 bg-white/[0.04] border border-white/[0.07] rounded-xl py-2.5 px-3 text-center">
|
| 162 |
+
<div className="text-white font-bold text-lg">{stat.value}</div>
|
| 163 |
+
<div className="text-white/30 text-[10px]">{stat.label}</div>
|
| 164 |
+
</div>
|
| 165 |
+
))}
|
| 166 |
+
</motion.div>
|
| 167 |
+
|
| 168 |
+
{/* Day selector */}
|
| 169 |
+
<div className="flex gap-2 mb-4 overflow-x-auto pb-1">
|
| 170 |
+
{(data?.weekly_plan ?? []).map((day, i) => {
|
| 171 |
+
const isRest = day.duration_minutes === 0;
|
| 172 |
+
const isDone = completedDays.has(day.day);
|
| 173 |
+
const isSelected = selectedDay === day.day;
|
| 174 |
+
return (
|
| 175 |
+
<motion.button
|
| 176 |
+
key={day.day}
|
| 177 |
+
initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: i * 0.05 }}
|
| 178 |
+
onClick={() => setSelectedDay(day.day)}
|
| 179 |
+
className="flex-shrink-0 flex flex-col items-center gap-1 px-3 py-2.5 rounded-xl transition-all min-w-[52px]"
|
| 180 |
+
style={
|
| 181 |
+
isSelected ? {
|
| 182 |
+
background: tierCfg.bg,
|
| 183 |
+
border: `1px solid ${tierCfg.color}60`,
|
| 184 |
+
boxShadow: `0 0 16px ${tierCfg.color}30`,
|
| 185 |
+
}
|
| 186 |
+
: isDone ? { background: "rgba(34,197,94,0.08)", border: "1px solid rgba(34,197,94,0.2)" }
|
| 187 |
+
: { background: "rgba(255,255,255,0.04)", border: "1px solid rgba(255,255,255,0.07)" }
|
| 188 |
+
}
|
| 189 |
+
>
|
| 190 |
+
<span className="text-[10px] text-white/40">{DAY_ABBR[day.day]}</span>
|
| 191 |
+
<span className="text-base">{isDone ? "✅" : isRest ? "💤" : tierCfg.icon}</span>
|
| 192 |
+
<span className="text-[9px] font-medium" style={{ color: isSelected ? tierCfg.color : "rgba(255,255,255,0.3)" }}>
|
| 193 |
+
{isRest ? "Rest" : `${day.duration_minutes}m`}
|
| 194 |
+
</span>
|
| 195 |
+
</motion.button>
|
| 196 |
+
);
|
| 197 |
+
})}
|
| 198 |
+
</div>
|
| 199 |
+
|
| 200 |
+
{/* Day detail */}
|
| 201 |
+
<AnimatePresence mode="wait">
|
| 202 |
+
{selectedDay && data && (() => {
|
| 203 |
+
const day = data.weekly_plan.find((d) => d.day === selectedDay);
|
| 204 |
+
if (!day) return null;
|
| 205 |
+
const isDone = completedDays.has(day.day);
|
| 206 |
+
const intensityColor = INTENSITY_COLORS[day.intensity] ?? "#FF9933";
|
| 207 |
+
return (
|
| 208 |
+
<motion.div
|
| 209 |
+
key={selectedDay}
|
| 210 |
+
initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }}
|
| 211 |
+
transition={{ duration: 0.2 }}
|
| 212 |
+
className="mb-5 bg-white/[0.04] border border-white/[0.08] rounded-2xl overflow-hidden"
|
| 213 |
+
>
|
| 214 |
+
<div className="px-4 py-3 border-b border-white/[0.06] flex justify-between items-center">
|
| 215 |
+
<div>
|
| 216 |
+
<h3 className="text-white font-semibold text-sm">{day.day}</h3>
|
| 217 |
+
<span
|
| 218 |
+
className="text-[10px] px-1.5 py-0.5 rounded-full mt-0.5 inline-block"
|
| 219 |
+
style={{ background: `${intensityColor}20`, color: intensityColor }}
|
| 220 |
+
>
|
| 221 |
+
{day.intensity}
|
| 222 |
+
</span>
|
| 223 |
+
</div>
|
| 224 |
+
{day.duration_minutes > 0 && (
|
| 225 |
+
<div className="text-right">
|
| 226 |
+
<div className="text-xl font-bold" style={{ color: tierCfg.color }}>{day.duration_minutes}</div>
|
| 227 |
+
<div className="text-white/30 text-[10px]">minutes</div>
|
| 228 |
+
</div>
|
| 229 |
+
)}
|
| 230 |
+
</div>
|
| 231 |
+
|
| 232 |
+
<div className="p-4 space-y-3">
|
| 233 |
+
{day.duration_minutes === 0 ? (
|
| 234 |
+
<div className="text-center py-4">
|
| 235 |
+
<span className="text-4xl">💤</span>
|
| 236 |
+
<p className="text-white/40 text-sm mt-2">Full rest day. Your body repairs and grows stronger while you rest.</p>
|
| 237 |
+
</div>
|
| 238 |
+
) : (
|
| 239 |
+
<>
|
| 240 |
+
<div className="flex items-start gap-3">
|
| 241 |
+
<span className="text-2xl">{tierCfg.icon}</span>
|
| 242 |
+
<p className="text-white font-medium text-sm">{day.activity}</p>
|
| 243 |
+
</div>
|
| 244 |
+
<div className="bg-white/[0.03] rounded-xl p-3">
|
| 245 |
+
<p className="text-white/50 text-xs leading-relaxed">💡 {day.notes}</p>
|
| 246 |
+
</div>
|
| 247 |
+
<motion.button
|
| 248 |
+
whileTap={{ scale: 0.97 }}
|
| 249 |
+
onClick={() => handleComplete(day.day)}
|
| 250 |
+
disabled={isDone}
|
| 251 |
+
className="w-full py-2.5 rounded-xl text-sm font-medium transition-all"
|
| 252 |
+
style={
|
| 253 |
+
isDone
|
| 254 |
+
? { background: "rgba(34,197,94,0.1)", color: "#22C55E", border: "1px solid rgba(34,197,94,0.2)" }
|
| 255 |
+
: { background: tierCfg.color, color: "#0d0d1a" }
|
| 256 |
+
}
|
| 257 |
+
>
|
| 258 |
+
{isDone ? "✓ Completed · +10 XP earned!" : "Mark Complete · +10 XP"}
|
| 259 |
+
</motion.button>
|
| 260 |
+
</>
|
| 261 |
+
)}
|
| 262 |
+
</div>
|
| 263 |
+
</motion.div>
|
| 264 |
+
);
|
| 265 |
+
})()}
|
| 266 |
+
</AnimatePresence>
|
| 267 |
+
|
| 268 |
+
{/* General advice */}
|
| 269 |
+
{data?.general_advice && (
|
| 270 |
+
<motion.div
|
| 271 |
+
initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.3 }}
|
| 272 |
+
className="mb-5 p-4 bg-white/[0.03] border border-white/[0.06] rounded-2xl"
|
| 273 |
+
>
|
| 274 |
+
<p className="text-white/40 text-[10px] uppercase tracking-widest mb-2">General Advice</p>
|
| 275 |
+
<p className="text-white/60 text-sm leading-relaxed">{data.general_advice}</p>
|
| 276 |
+
</motion.div>
|
| 277 |
+
)}
|
| 278 |
+
|
| 279 |
+
{/* Avoid list */}
|
| 280 |
+
{(data?.avoid ?? []).length > 0 && (
|
| 281 |
+
<motion.div
|
| 282 |
+
initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.35 }}
|
| 283 |
+
className="p-4 bg-red-500/5 border border-red-500/15 rounded-2xl"
|
| 284 |
+
>
|
| 285 |
+
<p className="text-red-400/80 text-[10px] uppercase tracking-widest mb-2">⚠️ Avoid</p>
|
| 286 |
+
<ul className="space-y-1.5">
|
| 287 |
+
{(data?.avoid ?? []).map((item, i) => (
|
| 288 |
+
<li key={i} className="flex items-start gap-2 text-white/40 text-xs">
|
| 289 |
+
<span className="text-red-400/50 mt-0.5 flex-shrink-0">✕</span>
|
| 290 |
+
{item}
|
| 291 |
+
</li>
|
| 292 |
+
))}
|
| 293 |
+
</ul>
|
| 294 |
+
</motion.div>
|
| 295 |
+
)}
|
| 296 |
+
</PageShell>
|
| 297 |
+
);
|
| 298 |
+
}
|
frontend/app/globals.css
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=Noto+Sans+Devanagari:wght@300;400;500;600;700;800&display=swap');
|
| 2 |
+
|
| 3 |
+
@tailwind base;
|
| 4 |
+
@tailwind components;
|
| 5 |
+
@tailwind utilities;
|
| 6 |
+
|
| 7 |
+
:root {
|
| 8 |
+
--accent: #FF9933;
|
| 9 |
+
--accent-glow: 0 0 20px rgba(255, 153, 51, 0.35);
|
| 10 |
+
--ok-glow: 0 0 20px rgba(34, 197, 94, 0.25);
|
| 11 |
+
--bg: #0d0d1a;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
@layer base {
|
| 15 |
+
html {
|
| 16 |
+
-webkit-font-smoothing: antialiased;
|
| 17 |
+
-moz-osx-font-smoothing: grayscale;
|
| 18 |
+
scroll-behavior: smooth;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
body {
|
| 22 |
+
background: #0d0d1a;
|
| 23 |
+
color: #ffffff;
|
| 24 |
+
font-family: 'Inter', 'Noto Sans Devanagari', system-ui, sans-serif;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
*:focus-visible {
|
| 28 |
+
outline: 2px solid #FF9933;
|
| 29 |
+
outline-offset: 2px;
|
| 30 |
+
border-radius: 4px;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
button {
|
| 34 |
+
font-family: inherit;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
/* Custom scrollbar */
|
| 38 |
+
::-webkit-scrollbar {
|
| 39 |
+
width: 4px;
|
| 40 |
+
height: 4px;
|
| 41 |
+
}
|
| 42 |
+
::-webkit-scrollbar-track {
|
| 43 |
+
background: transparent;
|
| 44 |
+
}
|
| 45 |
+
::-webkit-scrollbar-thumb {
|
| 46 |
+
background: rgba(255, 153, 51, 0.3);
|
| 47 |
+
border-radius: 100px;
|
| 48 |
+
}
|
| 49 |
+
::-webkit-scrollbar-thumb:hover {
|
| 50 |
+
background: rgba(255, 153, 51, 0.55);
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
@layer utilities {
|
| 55 |
+
.font-devanagari {
|
| 56 |
+
font-family: 'Noto Sans Devanagari', system-ui, sans-serif;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
.text-gradient-accent {
|
| 60 |
+
background: linear-gradient(135deg, #FF9933 0%, #FFCC80 100%);
|
| 61 |
+
-webkit-background-clip: text;
|
| 62 |
+
-webkit-text-fill-color: transparent;
|
| 63 |
+
background-clip: text;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
.glow-accent {
|
| 67 |
+
box-shadow: var(--accent-glow);
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
.glow-ok {
|
| 71 |
+
box-shadow: var(--ok-glow);
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
.glass {
|
| 75 |
+
background: rgba(255, 255, 255, 0.04);
|
| 76 |
+
backdrop-filter: blur(12px);
|
| 77 |
+
-webkit-backdrop-filter: blur(12px);
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
.border-gradient {
|
| 81 |
+
border-image: linear-gradient(135deg, rgba(255,153,51,0.4), rgba(34,197,94,0.2)) 1;
|
| 82 |
+
}
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
@keyframes shimmer {
|
| 86 |
+
0% { background-position: -200% center; }
|
| 87 |
+
100% { background-position: 200% center; }
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
@keyframes pulseRing {
|
| 91 |
+
0% { transform: scale(1); opacity: 0.6; }
|
| 92 |
+
100% { transform: scale(1.75); opacity: 0; }
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
@keyframes borderPulse {
|
| 96 |
+
0%, 100% { border-color: rgba(255,153,51,0.3); }
|
| 97 |
+
50% { border-color: rgba(255,153,51,0.7); }
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
@keyframes floatY {
|
| 101 |
+
0%, 100% { transform: translateY(0); }
|
| 102 |
+
50% { transform: translateY(-6px); }
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
@keyframes gradientShift {
|
| 106 |
+
0% { background-position: 0% 50%; }
|
| 107 |
+
50% { background-position: 100% 50%; }
|
| 108 |
+
100% { background-position: 0% 50%; }
|
| 109 |
+
}
|
frontend/app/layout.tsx
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
import { Inter, Noto_Sans_Devanagari } from "next/font/google"
|
| 3 |
+
import Link from "next/link"
|
| 4 |
+
import "./globals.css"
|
| 5 |
+
import { NavLinks } from "@/components/NavLinks"
|
| 6 |
+
import DoctorChat from "@/components/DoctorChat"
|
| 7 |
+
import AvatarPanel from "@/components/AvatarPanel"
|
| 8 |
+
|
| 9 |
+
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" })
|
| 10 |
+
const devanagari = Noto_Sans_Devanagari({
|
| 11 |
+
subsets: ["devanagari"],
|
| 12 |
+
weight: ["400", "500", "600", "700"],
|
| 13 |
+
variable: "--font-devanagari",
|
| 14 |
+
})
|
| 15 |
+
|
| 16 |
+
const navLinks = [
|
| 17 |
+
{ label: "Home", to: "/", icon: "" },
|
| 18 |
+
{ label: "Dashboard", to: "/dashboard", icon: "" },
|
| 19 |
+
{ label: "Avatar", to: "/avatar", icon: "" },
|
| 20 |
+
{ label: "Nutrition", to: "/nutrition", icon: "" },
|
| 21 |
+
{ label: "Exercise", to: "/exercise", icon: "" },
|
| 22 |
+
{ label: "Wellness", to: "/wellness", icon: "" },
|
| 23 |
+
]
|
| 24 |
+
|
| 25 |
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
| 26 |
+
return (
|
| 27 |
+
<html lang="en" className={`${inter.variable} ${devanagari.variable}`}>
|
| 28 |
+
<body
|
| 29 |
+
className="min-h-screen text-white"
|
| 30 |
+
style={{ background: "#0d0d1a", fontFamily: "var(--font-inter), var(--font-devanagari), system-ui, sans-serif" }}
|
| 31 |
+
>
|
| 32 |
+
{/* Sidebar */}
|
| 33 |
+
<aside
|
| 34 |
+
className="fixed top-0 left-0 h-full w-56 flex flex-col z-30"
|
| 35 |
+
style={{
|
| 36 |
+
background: "rgba(13,13,26,0.95)",
|
| 37 |
+
borderRight: "1px solid rgba(255,255,255,0.07)",
|
| 38 |
+
backdropFilter: "blur(20px)",
|
| 39 |
+
}}
|
| 40 |
+
>
|
| 41 |
+
{/* Subtle ambient glow inside sidebar */}
|
| 42 |
+
<div
|
| 43 |
+
className="absolute top-0 left-0 w-full h-48 pointer-events-none"
|
| 44 |
+
style={{
|
| 45 |
+
background: "radial-gradient(ellipse at 20% 0%, rgba(255,153,51,0.08) 0%, transparent 70%)",
|
| 46 |
+
}}
|
| 47 |
+
/>
|
| 48 |
+
|
| 49 |
+
{/* Brand */}
|
| 50 |
+
<div className="relative px-5 pt-7 pb-6">
|
| 51 |
+
<Link href="/" className="flex items-center gap-2.5 group">
|
| 52 |
+
<div
|
| 53 |
+
className="w-9 h-9 rounded-xl flex items-center justify-center text-lg flex-shrink-0 transition-transform group-hover:scale-110"
|
| 54 |
+
style={{ background: "rgba(255,153,51,0.15)", border: "1px solid rgba(255,153,51,0.25)" }}
|
| 55 |
+
>
|
| 56 |
+
🏥
|
| 57 |
+
</div>
|
| 58 |
+
<div className="leading-none">
|
| 59 |
+
<div className="text-xs font-semibold tracking-wide" style={{ color: "rgba(255,255,255,0.35)" }}>
|
| 60 |
+
Report
|
| 61 |
+
</div>
|
| 62 |
+
<div className="text-base font-black tracking-tight" style={{ color: "#FF9933" }}>
|
| 63 |
+
Raahat
|
| 64 |
+
</div>
|
| 65 |
+
</div>
|
| 66 |
+
</Link>
|
| 67 |
+
</div>
|
| 68 |
+
|
| 69 |
+
{/* Divider */}
|
| 70 |
+
<div className="mx-4 mb-4" style={{ height: "1px", background: "rgba(255,255,255,0.06)" }} />
|
| 71 |
+
|
| 72 |
+
{/* Navigation */}
|
| 73 |
+
<div className="flex-1 px-2 overflow-y-auto">
|
| 74 |
+
<NavLinks links={navLinks} />
|
| 75 |
+
</div>
|
| 76 |
+
|
| 77 |
+
{/* Bottom: tagline */}
|
| 78 |
+
<div className="px-5 py-5">
|
| 79 |
+
<p className="text-[10px] leading-relaxed" style={{ color: "rgba(255,255,255,0.2)" }}>
|
| 80 |
+
Made for rural India 🇮🇳
|
| 81 |
+
</p>
|
| 82 |
+
</div>
|
| 83 |
+
</aside>
|
| 84 |
+
|
| 85 |
+
{/* Main content area — offset by sidebar */}
|
| 86 |
+
<main className="ml-56 min-h-screen">{children}</main>
|
| 87 |
+
|
| 88 |
+
{/* Floating overlays */}
|
| 89 |
+
<DoctorChat onSend={async (msg) => { return "Test response" }} />
|
| 90 |
+
<AvatarPanel />
|
| 91 |
+
</body>
|
| 92 |
+
</html>
|
| 93 |
+
)
|
| 94 |
+
}
|
frontend/app/nutrition/page.tsx
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useEffect, useState } from "react";
|
| 4 |
+
import { motion, AnimatePresence } from "framer-motion";
|
| 5 |
+
import {
|
| 6 |
+
RadarChart,
|
| 7 |
+
PolarGrid,
|
| 8 |
+
PolarAngleAxis,
|
| 9 |
+
Radar,
|
| 10 |
+
ResponsiveContainer,
|
| 11 |
+
Tooltip,
|
| 12 |
+
} from "recharts";
|
| 13 |
+
import { useGUCStore } from "@/lib/store";
|
| 14 |
+
import { PageShell } from "@/components/ui";
|
| 15 |
+
|
| 16 |
+
interface FoodItem {
|
| 17 |
+
name_english: string;
|
| 18 |
+
name_hindi: string;
|
| 19 |
+
nutrient_highlights: Record<string, number>;
|
| 20 |
+
serving_suggestion: string;
|
| 21 |
+
food_group: string;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
interface NutritionResponse {
|
| 25 |
+
recommended_foods: FoodItem[];
|
| 26 |
+
daily_targets: Record<string, number>;
|
| 27 |
+
deficiency_summary: string;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
const NUTRIENT_LABELS: Record<string, { label: string; unit: string; icon: string }> = {
|
| 31 |
+
protein_g: { label: "Protein", unit: "g", icon: "💪" },
|
| 32 |
+
iron_mg: { label: "Iron", unit: "mg", icon: "🩸" },
|
| 33 |
+
calcium_mg: { label: "Calcium", unit: "mg", icon: "🦴" },
|
| 34 |
+
vitaminD_iu: { label: "Vit D", unit: "IU", icon: "☀️" },
|
| 35 |
+
fiber_g: { label: "Fiber", unit: "g", icon: "🌾" },
|
| 36 |
+
calories_kcal: { label: "Calories", unit: "kcal",icon: "⚡" },
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
+
const FOOD_GROUP_COLORS: Record<string, string> = {
|
| 40 |
+
"Green Leafy Vegetables": "#22C55E",
|
| 41 |
+
"Cereals & Millets": "#F59E0B",
|
| 42 |
+
"Grain Legumes": "#F97316",
|
| 43 |
+
"Fruits": "#EC4899",
|
| 44 |
+
"Nuts & Oil Seeds": "#8B5CF6",
|
| 45 |
+
"Milk & Products": "#06B6D4",
|
| 46 |
+
"Eggs": "#EAB308",
|
| 47 |
+
"Condiments & Spices": "#EF4444",
|
| 48 |
+
};
|
| 49 |
+
|
| 50 |
+
const FOOD_ICONS: Record<string, string> = {
|
| 51 |
+
"Green Leafy Vegetables": "🥬",
|
| 52 |
+
"Cereals & Millets": "🌾",
|
| 53 |
+
"Grain Legumes": "🫘",
|
| 54 |
+
"Fruits": "🍎",
|
| 55 |
+
"Nuts & Oil Seeds": "🥜",
|
| 56 |
+
"Milk & Products": "🥛",
|
| 57 |
+
"Eggs": "🥚",
|
| 58 |
+
"Condiments & Spices": "🌿",
|
| 59 |
+
};
|
| 60 |
+
|
| 61 |
+
function buildRadarData(loggedCount: number) {
|
| 62 |
+
return [
|
| 63 |
+
{ nutrient: "Protein", target: 100, current: Math.min(100, loggedCount * 15 + 30) },
|
| 64 |
+
{ nutrient: "Iron", target: 100, current: Math.min(100, loggedCount * 12 + 20) },
|
| 65 |
+
{ nutrient: "Calcium", target: 100, current: Math.min(100, loggedCount * 10 + 25) },
|
| 66 |
+
{ nutrient: "Vit D", target: 100, current: Math.min(100, loggedCount * 8 + 15) },
|
| 67 |
+
{ nutrient: "Fiber", target: 100, current: Math.min(100, loggedCount * 14 + 35) },
|
| 68 |
+
];
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
export default function NutritionPage() {
|
| 72 |
+
const nutritionProfile = useGUCStore((s) => s.nutritionProfile);
|
| 73 |
+
const latestReport = useGUCStore((s) => s.latestReport);
|
| 74 |
+
const profile = useGUCStore((s) => s.profile);
|
| 75 |
+
const logFood = useGUCStore((s) => s.logFood);
|
| 76 |
+
const addXP = useGUCStore((s) => s.addXP);
|
| 77 |
+
const setAvatarState = useGUCStore((s) => s.setAvatarState);
|
| 78 |
+
|
| 79 |
+
const [data, setData] = useState<NutritionResponse | null>(null);
|
| 80 |
+
const [loading, setLoading] = useState(true);
|
| 81 |
+
const [error, setError] = useState(false);
|
| 82 |
+
const [loggedToday, setLoggedToday] = useState<string[]>([]);
|
| 83 |
+
const [activeCard, setActiveCard] = useState<string | null>(null);
|
| 84 |
+
|
| 85 |
+
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000";
|
| 86 |
+
const flags = nutritionProfile.deficiencies.join(",") || "INCREASE_IRON";
|
| 87 |
+
|
| 88 |
+
useEffect(() => {
|
| 89 |
+
setLoggedToday(nutritionProfile.loggedToday);
|
| 90 |
+
}, [nutritionProfile.loggedToday]);
|
| 91 |
+
|
| 92 |
+
useEffect(() => {
|
| 93 |
+
const fetchNutrition = async () => {
|
| 94 |
+
try {
|
| 95 |
+
setLoading(true);
|
| 96 |
+
setError(false);
|
| 97 |
+
const res = await fetch(
|
| 98 |
+
`${API_BASE}/nutrition?dietary_flags=${flags}&language=${profile.language}`
|
| 99 |
+
);
|
| 100 |
+
if (!res.ok) throw new Error("API error");
|
| 101 |
+
const json: NutritionResponse = await res.json();
|
| 102 |
+
setData(json);
|
| 103 |
+
} catch {
|
| 104 |
+
try {
|
| 105 |
+
const res = await fetch(`${API_BASE}/nutrition/fallback`);
|
| 106 |
+
if (!res.ok) throw new Error();
|
| 107 |
+
const json: NutritionResponse = await res.json();
|
| 108 |
+
setData(json);
|
| 109 |
+
} catch {
|
| 110 |
+
setError(true);
|
| 111 |
+
}
|
| 112 |
+
} finally {
|
| 113 |
+
setLoading(false);
|
| 114 |
+
}
|
| 115 |
+
};
|
| 116 |
+
fetchNutrition();
|
| 117 |
+
}, [flags, API_BASE, profile.language]);
|
| 118 |
+
|
| 119 |
+
const handleAddToToday = (food: FoodItem) => {
|
| 120 |
+
logFood(food.name_english);
|
| 121 |
+
setLoggedToday((prev) => [...prev, food.name_english]);
|
| 122 |
+
addXP(15);
|
| 123 |
+
setAvatarState("HAPPY");
|
| 124 |
+
};
|
| 125 |
+
|
| 126 |
+
const radarData = buildRadarData(loggedToday.length);
|
| 127 |
+
|
| 128 |
+
if (loading) {
|
| 129 |
+
return (
|
| 130 |
+
<PageShell>
|
| 131 |
+
<div className="space-y-4">
|
| 132 |
+
<div className="h-8 w-48 bg-white/5 rounded-xl animate-pulse" />
|
| 133 |
+
<div className="h-64 bg-white/5 rounded-2xl animate-pulse" />
|
| 134 |
+
<div className="grid grid-cols-2 gap-3">
|
| 135 |
+
{[...Array(6)].map((_, i) => (
|
| 136 |
+
<div key={i} className="h-32 bg-white/5 rounded-2xl animate-pulse" />
|
| 137 |
+
))}
|
| 138 |
+
</div>
|
| 139 |
+
</div>
|
| 140 |
+
</PageShell>
|
| 141 |
+
);
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
return (
|
| 145 |
+
<PageShell>
|
| 146 |
+
|
| 147 |
+
{/* Header */}
|
| 148 |
+
<motion.div initial={{ opacity: 0, y: -12 }} animate={{ opacity: 1, y: 0 }} className="mb-6">
|
| 149 |
+
<div className="flex items-center gap-3 mb-1">
|
| 150 |
+
<span className="text-2xl">🥗</span>
|
| 151 |
+
<h1 className="text-xl font-bold tracking-tight">Nutrition Profile</h1>
|
| 152 |
+
</div>
|
| 153 |
+
<p className="text-white/40 text-sm">Based on your report · IFCT 2017 Indian Food Data</p>
|
| 154 |
+
</motion.div>
|
| 155 |
+
|
| 156 |
+
{/* Deficiency banner */}
|
| 157 |
+
{data?.deficiency_summary && (
|
| 158 |
+
<motion.div
|
| 159 |
+
initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 }}
|
| 160 |
+
className="mb-5 p-3.5 rounded-xl bg-[#FF9933]/10 border border-[#FF9933]/20"
|
| 161 |
+
>
|
| 162 |
+
<p className="text-[#FF9933] text-xs leading-relaxed">{data.deficiency_summary}</p>
|
| 163 |
+
</motion.div>
|
| 164 |
+
)}
|
| 165 |
+
|
| 166 |
+
{/* Daily targets grid */}
|
| 167 |
+
<motion.div
|
| 168 |
+
initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.15 }}
|
| 169 |
+
className="mb-5"
|
| 170 |
+
>
|
| 171 |
+
<p className="text-white/40 text-xs font-medium uppercase tracking-widest mb-3">Daily Targets</p>
|
| 172 |
+
<div className="grid grid-cols-3 gap-2">
|
| 173 |
+
{Object.entries(NUTRIENT_LABELS).map(([key, meta]) => {
|
| 174 |
+
const val = data?.daily_targets[key]
|
| 175 |
+
?? nutritionProfile.dailyTargets[key as keyof typeof nutritionProfile.dailyTargets]
|
| 176 |
+
?? 0;
|
| 177 |
+
return (
|
| 178 |
+
<div key={key} className="bg-white/[0.04] border border-white/[0.07] rounded-xl p-3">
|
| 179 |
+
<div className="text-lg mb-1">{meta.icon}</div>
|
| 180 |
+
<div className="text-white font-semibold text-sm">
|
| 181 |
+
{typeof val === "number" ? val.toLocaleString() : val}
|
| 182 |
+
<span className="text-white/30 text-[10px] ml-0.5">{meta.unit}</span>
|
| 183 |
+
</div>
|
| 184 |
+
<div className="text-white/35 text-[10px]">{meta.label}</div>
|
| 185 |
+
</div>
|
| 186 |
+
);
|
| 187 |
+
})}
|
| 188 |
+
</div>
|
| 189 |
+
</motion.div>
|
| 190 |
+
|
| 191 |
+
{/* Radar chart */}
|
| 192 |
+
<motion.div
|
| 193 |
+
initial={{ opacity: 0, scale: 0.97 }} animate={{ opacity: 1, scale: 1 }} transition={{ delay: 0.2 }}
|
| 194 |
+
className="mb-5 bg-white/[0.03] border border-white/[0.07] rounded-2xl p-4"
|
| 195 |
+
>
|
| 196 |
+
<div className="flex justify-between items-center mb-3">
|
| 197 |
+
<p className="text-white/60 text-xs font-medium uppercase tracking-widest">Coverage Today</p>
|
| 198 |
+
<div className="flex gap-3 text-[10px] text-white/40">
|
| 199 |
+
<span className="flex items-center gap-1">
|
| 200 |
+
<span className="w-2 h-2 rounded-full inline-block" style={{ background: "#FF9933" }} />Current
|
| 201 |
+
</span>
|
| 202 |
+
<span className="flex items-center gap-1">
|
| 203 |
+
<span className="w-2 h-2 rounded-full inline-block bg-white/20" />Target
|
| 204 |
+
</span>
|
| 205 |
+
</div>
|
| 206 |
+
</div>
|
| 207 |
+
<ResponsiveContainer width="100%" height={220}>
|
| 208 |
+
<RadarChart data={radarData} margin={{ top: 8, right: 16, bottom: 8, left: 16 }}>
|
| 209 |
+
<PolarGrid stroke="rgba(255,255,255,0.06)" />
|
| 210 |
+
<PolarAngleAxis dataKey="nutrient" tick={{ fill: "rgba(255,255,255,0.4)", fontSize: 11 }} />
|
| 211 |
+
<Radar name="Target" dataKey="target" stroke="rgba(255,255,255,0.12)" fill="rgba(255,255,255,0.04)" strokeWidth={1} />
|
| 212 |
+
<Radar name="Current" dataKey="current" stroke="#FF9933" fill="#FF9933" fillOpacity={0.18} strokeWidth={2} />
|
| 213 |
+
<Tooltip
|
| 214 |
+
contentStyle={{ background: "#1a1a2e", border: "1px solid rgba(255,255,255,0.1)", borderRadius: "8px", fontSize: "11px" }}
|
| 215 |
+
/>
|
| 216 |
+
</RadarChart>
|
| 217 |
+
</ResponsiveContainer>
|
| 218 |
+
{loggedToday.length > 0 && (
|
| 219 |
+
<p className="text-white/25 text-[10px] text-center mt-1">
|
| 220 |
+
{loggedToday.length} food{loggedToday.length !== 1 ? "s" : ""} logged today
|
| 221 |
+
</p>
|
| 222 |
+
)}
|
| 223 |
+
</motion.div>
|
| 224 |
+
|
| 225 |
+
{/* Food cards */}
|
| 226 |
+
<div className="mb-3">
|
| 227 |
+
<p className="text-white/40 text-xs font-medium uppercase tracking-widest mb-3">
|
| 228 |
+
Recommended for You · Top Indian Foods
|
| 229 |
+
</p>
|
| 230 |
+
|
| 231 |
+
{error && (
|
| 232 |
+
<div className="text-white/40 text-sm text-center py-8">
|
| 233 |
+
Unable to load food data. Make sure the backend is running.
|
| 234 |
+
</div>
|
| 235 |
+
)}
|
| 236 |
+
|
| 237 |
+
<div className="space-y-2.5">
|
| 238 |
+
<AnimatePresence>
|
| 239 |
+
{(data?.recommended_foods ?? []).map((food, i) => {
|
| 240 |
+
const groupColor = FOOD_GROUP_COLORS[food.food_group] ?? "#FF9933";
|
| 241 |
+
const isLogged = loggedToday.includes(food.name_english);
|
| 242 |
+
const isExpanded = activeCard === food.name_english;
|
| 243 |
+
const icon = FOOD_ICONS[food.food_group] ?? "🌿";
|
| 244 |
+
|
| 245 |
+
return (
|
| 246 |
+
<motion.div
|
| 247 |
+
key={food.name_english}
|
| 248 |
+
initial={{ opacity: 0, y: 16 }}
|
| 249 |
+
animate={{ opacity: 1, y: 0 }}
|
| 250 |
+
transition={{ delay: i * 0.06, duration: 0.3 }}
|
| 251 |
+
className="rounded-xl border overflow-hidden"
|
| 252 |
+
style={{
|
| 253 |
+
background: isExpanded ? "rgba(255,255,255,0.05)" : "rgba(255,255,255,0.025)",
|
| 254 |
+
borderColor: isExpanded ? `${groupColor}40` : "rgba(255,255,255,0.07)",
|
| 255 |
+
}}
|
| 256 |
+
>
|
| 257 |
+
{/* Card header */}
|
| 258 |
+
<button
|
| 259 |
+
className="w-full flex items-center gap-3 p-3.5 text-left"
|
| 260 |
+
onClick={() => setActiveCard(isExpanded ? null : food.name_english)}
|
| 261 |
+
>
|
| 262 |
+
<div
|
| 263 |
+
className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 text-sm"
|
| 264 |
+
style={{ background: `${groupColor}20`, color: groupColor }}
|
| 265 |
+
>
|
| 266 |
+
{icon}
|
| 267 |
+
</div>
|
| 268 |
+
<div className="flex-1 min-w-0">
|
| 269 |
+
<div className="flex items-center gap-2 flex-wrap">
|
| 270 |
+
<span className="text-white text-sm font-medium">{food.name_english}</span>
|
| 271 |
+
<span className="text-white/40 text-xs">{food.name_hindi}</span>
|
| 272 |
+
</div>
|
| 273 |
+
<span
|
| 274 |
+
className="text-[10px] px-1.5 py-0.5 rounded-full mt-0.5 inline-block"
|
| 275 |
+
style={{ background: `${groupColor}15`, color: groupColor }}
|
| 276 |
+
>
|
| 277 |
+
{food.food_group}
|
| 278 |
+
</span>
|
| 279 |
+
</div>
|
| 280 |
+
<div className="flex-shrink-0 flex items-center gap-2">
|
| 281 |
+
{isLogged && <span className="text-green-400 text-xs">✓ added</span>}
|
| 282 |
+
<span className="text-white/20 text-xs">{isExpanded ? "▲" : "▼"}</span>
|
| 283 |
+
</div>
|
| 284 |
+
</button>
|
| 285 |
+
|
| 286 |
+
{/* Expanded details */}
|
| 287 |
+
<AnimatePresence>
|
| 288 |
+
{isExpanded && (
|
| 289 |
+
<motion.div
|
| 290 |
+
initial={{ height: 0, opacity: 0 }}
|
| 291 |
+
animate={{ height: "auto", opacity: 1 }}
|
| 292 |
+
exit={{ height: 0, opacity: 0 }}
|
| 293 |
+
transition={{ duration: 0.2 }}
|
| 294 |
+
className="overflow-hidden"
|
| 295 |
+
>
|
| 296 |
+
<div className="px-3.5 pb-3.5 space-y-3">
|
| 297 |
+
<div className="flex flex-wrap gap-2">
|
| 298 |
+
{Object.entries(food.nutrient_highlights)
|
| 299 |
+
.filter(([, v]) => v > 0)
|
| 300 |
+
.slice(0, 5)
|
| 301 |
+
.map(([key, value]) => {
|
| 302 |
+
const meta = NUTRIENT_LABELS[key];
|
| 303 |
+
if (!meta) return null;
|
| 304 |
+
return (
|
| 305 |
+
<div key={key} className="flex items-center gap-1.5 bg-white/5 rounded-lg px-2 py-1">
|
| 306 |
+
<span className="text-xs">{meta.icon}</span>
|
| 307 |
+
<span className="text-white/70 text-[11px]">
|
| 308 |
+
{meta.label}: <strong className="text-white">{value}</strong>
|
| 309 |
+
<span className="text-white/30">{meta.unit}</span>
|
| 310 |
+
</span>
|
| 311 |
+
</div>
|
| 312 |
+
);
|
| 313 |
+
})}
|
| 314 |
+
</div>
|
| 315 |
+
<p className="text-white/40 text-xs">📏 {food.serving_suggestion}</p>
|
| 316 |
+
<motion.button
|
| 317 |
+
whileTap={{ scale: 0.96 }}
|
| 318 |
+
onClick={() => handleAddToToday(food)}
|
| 319 |
+
disabled={isLogged}
|
| 320 |
+
className="w-full py-2 rounded-lg text-xs font-medium transition-all"
|
| 321 |
+
style={
|
| 322 |
+
isLogged
|
| 323 |
+
? { background: "rgba(34,197,94,0.1)", color: "#22C55E" }
|
| 324 |
+
: { background: groupColor, color: "#0d0d1a" }
|
| 325 |
+
}
|
| 326 |
+
>
|
| 327 |
+
{isLogged ? "✓ Added to Today" : "Add to Today · +15 XP"}
|
| 328 |
+
</motion.button>
|
| 329 |
+
</div>
|
| 330 |
+
</motion.div>
|
| 331 |
+
)}
|
| 332 |
+
</AnimatePresence>
|
| 333 |
+
</motion.div>
|
| 334 |
+
);
|
| 335 |
+
})}
|
| 336 |
+
</AnimatePresence>
|
| 337 |
+
</div>
|
| 338 |
+
</div>
|
| 339 |
+
|
| 340 |
+
<p className="text-white/15 text-[10px] text-center mt-6">
|
| 341 |
+
Nutritional data: IFCT 2017 · National Institute of Nutrition, ICMR
|
| 342 |
+
</p>
|
| 343 |
+
</PageShell>
|
| 344 |
+
);
|
| 345 |
+
}
|
frontend/app/page.tsx
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
import { useCallback, useState, useEffect } from "react";
|
| 3 |
+
import { useDropzone } from "react-dropzone";
|
| 4 |
+
import { useRouter } from "next/navigation";
|
| 5 |
+
import { motion, AnimatePresence } from "framer-motion";
|
| 6 |
+
import { colors, motionPresets } from "@/lib/tokens";
|
| 7 |
+
import { useGUCStore, type ParsedReport } from "@/lib/store";
|
| 8 |
+
import { getNextMock } from "@/lib/mockData";
|
| 9 |
+
|
| 10 |
+
const LOADING_STEPS = [
|
| 11 |
+
"रिपोर्ट पढ़ रहे हैं... (Reading report...)",
|
| 12 |
+
"मेडिकल शब्द समझ रहे हैं... (Understanding jargon...)",
|
| 13 |
+
"हिंदी में अनुवाद हो रहा है... (Translating to Hindi...)",
|
| 14 |
+
"लगभग हो गया! (Almost done!)",
|
| 15 |
+
];
|
| 16 |
+
|
| 17 |
+
const FEATURES = [
|
| 18 |
+
{ icon: "🔒", label: "100% Private" },
|
| 19 |
+
{ icon: "⚡", label: "Instant Results" },
|
| 20 |
+
{ icon: "🇮🇳", label: "Made for India" },
|
| 21 |
+
];
|
| 22 |
+
|
| 23 |
+
export default function Home() {
|
| 24 |
+
const router = useRouter();
|
| 25 |
+
const setLatestReport = useGUCStore((s) => s.setLatestReport);
|
| 26 |
+
const language = useGUCStore((s) => s.profile.language);
|
| 27 |
+
|
| 28 |
+
const [file, setFile] = useState<File | null>(null);
|
| 29 |
+
const [loading, setLoading] = useState(false);
|
| 30 |
+
const [loadingStep, setLoadingStep] = useState(0);
|
| 31 |
+
const [progress, setProgress] = useState(0);
|
| 32 |
+
const [dots, setDots] = useState<{ x: number; y: number; size: number; opacity: number }[]>([]);
|
| 33 |
+
const [error, setError] = useState<string | null>(null);
|
| 34 |
+
|
| 35 |
+
useEffect(() => {
|
| 36 |
+
setDots(Array.from({ length: 50 }, () => ({
|
| 37 |
+
x: Math.random() * 100,
|
| 38 |
+
y: Math.random() * 100,
|
| 39 |
+
size: Math.random() * 2 + 0.5,
|
| 40 |
+
opacity: Math.random() * 0.25 + 0.04,
|
| 41 |
+
})));
|
| 42 |
+
}, []);
|
| 43 |
+
|
| 44 |
+
useEffect(() => {
|
| 45 |
+
if (!loading) return;
|
| 46 |
+
const id = setInterval(() => setLoadingStep((s) => (s + 1) % LOADING_STEPS.length), 800);
|
| 47 |
+
return () => clearInterval(id);
|
| 48 |
+
}, [loading]);
|
| 49 |
+
|
| 50 |
+
useEffect(() => {
|
| 51 |
+
if (!loading) { setProgress(0); return; }
|
| 52 |
+
const start = Date.now();
|
| 53 |
+
const total = 15000; // Match the API timeout
|
| 54 |
+
const id = setInterval(() => {
|
| 55 |
+
const elapsed = Date.now() - start;
|
| 56 |
+
// Progress moves slower toward end to feel more natural
|
| 57 |
+
const pct = Math.min((elapsed / total) * 85, 85);
|
| 58 |
+
setProgress(pct);
|
| 59 |
+
if (elapsed >= total) clearInterval(id);
|
| 60 |
+
}, 50);
|
| 61 |
+
return () => clearInterval(id);
|
| 62 |
+
}, [loading]);
|
| 63 |
+
|
| 64 |
+
const onDrop = useCallback((accepted: File[]) => {
|
| 65 |
+
setFile(accepted[0]);
|
| 66 |
+
setError(null);
|
| 67 |
+
}, []);
|
| 68 |
+
|
| 69 |
+
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
| 70 |
+
onDrop,
|
| 71 |
+
accept: { "image/*": [], "application/pdf": [] },
|
| 72 |
+
maxFiles: 1,
|
| 73 |
+
});
|
| 74 |
+
|
| 75 |
+
const handleAnalyze = async () => {
|
| 76 |
+
if (!file) return;
|
| 77 |
+
setError(null);
|
| 78 |
+
setLoading(true);
|
| 79 |
+
|
| 80 |
+
try {
|
| 81 |
+
const formData = new FormData();
|
| 82 |
+
formData.append("file", file);
|
| 83 |
+
formData.append("language", language);
|
| 84 |
+
|
| 85 |
+
const res = await fetch("/api/analyze-report", {
|
| 86 |
+
method: "POST",
|
| 87 |
+
body: formData,
|
| 88 |
+
});
|
| 89 |
+
|
| 90 |
+
const data = await res.json();
|
| 91 |
+
|
| 92 |
+
if (res.ok && data.is_readable !== undefined) {
|
| 93 |
+
// Success — save to store
|
| 94 |
+
setLatestReport(data as ParsedReport);
|
| 95 |
+
setProgress(100);
|
| 96 |
+
await new Promise((r) => setTimeout(r, 400));
|
| 97 |
+
router.push("/dashboard");
|
| 98 |
+
} else if (data.useMock || !res.ok) {
|
| 99 |
+
// Backend failed or timed out — use mock data
|
| 100 |
+
console.warn("Using mock fallback:", data.error);
|
| 101 |
+
const mock = getNextMock();
|
| 102 |
+
setLatestReport(mock);
|
| 103 |
+
setProgress(100);
|
| 104 |
+
await new Promise((r) => setTimeout(r, 400));
|
| 105 |
+
router.push("/dashboard");
|
| 106 |
+
}
|
| 107 |
+
} catch {
|
| 108 |
+
// Network error — use mock data
|
| 109 |
+
console.warn("Network error, using mock fallback");
|
| 110 |
+
const mock = getNextMock();
|
| 111 |
+
setLatestReport(mock);
|
| 112 |
+
setProgress(100);
|
| 113 |
+
await new Promise((r) => setTimeout(r, 400));
|
| 114 |
+
router.push("/dashboard");
|
| 115 |
+
} finally {
|
| 116 |
+
setLoading(false);
|
| 117 |
+
}
|
| 118 |
+
};
|
| 119 |
+
|
| 120 |
+
return (
|
| 121 |
+
<main
|
| 122 |
+
className="min-h-screen flex flex-col items-center justify-center px-4 relative overflow-hidden"
|
| 123 |
+
style={{ background: colors.bg }}
|
| 124 |
+
>
|
| 125 |
+
{/* Enhanced ambient glow */}
|
| 126 |
+
<div className="fixed inset-0 pointer-events-none" style={{
|
| 127 |
+
backgroundImage:
|
| 128 |
+
"radial-gradient(ellipse at 20% 10%, rgba(255,153,51,0.07) 0%, transparent 55%), " +
|
| 129 |
+
"radial-gradient(ellipse at 80% 80%, rgba(34,197,94,0.06) 0%, transparent 55%), " +
|
| 130 |
+
"radial-gradient(ellipse at 55% 50%, rgba(99,102,241,0.03) 0%, transparent 60%)",
|
| 131 |
+
}} />
|
| 132 |
+
|
| 133 |
+
{/* Starfield */}
|
| 134 |
+
{dots.map((dot, i) => (
|
| 135 |
+
<div key={i} className="absolute rounded-full pointer-events-none"
|
| 136 |
+
style={{
|
| 137 |
+
left: `${dot.x}%`, top: `${dot.y}%`,
|
| 138 |
+
width: dot.size, height: dot.size,
|
| 139 |
+
background: "white", opacity: dot.opacity,
|
| 140 |
+
}}
|
| 141 |
+
/>
|
| 142 |
+
))}
|
| 143 |
+
|
| 144 |
+
{/* Loading overlay */}
|
| 145 |
+
<AnimatePresence>
|
| 146 |
+
{loading && (
|
| 147 |
+
<motion.div
|
| 148 |
+
className="fixed inset-0 flex flex-col items-center justify-center z-50"
|
| 149 |
+
style={{ background: "rgba(13,13,26,0.97)" }}
|
| 150 |
+
initial={{ opacity: 0 }}
|
| 151 |
+
animate={{ opacity: 1 }}
|
| 152 |
+
>
|
| 153 |
+
{/* Animated pipeline */}
|
| 154 |
+
<svg width="320" height="90" viewBox="0 0 320 90" className="mb-8">
|
| 155 |
+
<rect x="10" y="20" width="70" height="50" rx="10"
|
| 156 |
+
fill={colors.bgCard} stroke={colors.accent} strokeWidth="1.5" />
|
| 157 |
+
<text x="45" y="42" textAnchor="middle" fontSize="18">📄</text>
|
| 158 |
+
<text x="45" y="60" textAnchor="middle" fill={colors.textMuted} fontSize="9">Report</text>
|
| 159 |
+
|
| 160 |
+
<line x1="80" y1="45" x2="240" y2="45"
|
| 161 |
+
stroke={colors.border} strokeWidth="1.5" strokeDasharray="6 4" />
|
| 162 |
+
{[0, 1, 2].map((i) => (
|
| 163 |
+
<motion.circle key={i} r="5" fill={colors.accent} cy="45"
|
| 164 |
+
animate={{ cx: [80, 240], opacity: [0, 1, 1, 0] }}
|
| 165 |
+
transition={{ duration: 1.4, delay: i * 0.45, repeat: Infinity }} />
|
| 166 |
+
))}
|
| 167 |
+
|
| 168 |
+
<circle cx="275" cy="45" r="32"
|
| 169 |
+
fill={colors.bgCard} stroke={colors.accent} strokeWidth="1.5" />
|
| 170 |
+
<text x="275" y="40" textAnchor="middle" fontSize="18">🧠</text>
|
| 171 |
+
<text x="275" y="58" textAnchor="middle" fill={colors.textMuted} fontSize="9">AI Engine</text>
|
| 172 |
+
</svg>
|
| 173 |
+
|
| 174 |
+
<AnimatePresence mode="wait">
|
| 175 |
+
<motion.p
|
| 176 |
+
key={loadingStep}
|
| 177 |
+
className="text-white text-sm font-medium text-center mb-6"
|
| 178 |
+
initial={{ opacity: 0, y: 8 }}
|
| 179 |
+
animate={{ opacity: 1, y: 0 }}
|
| 180 |
+
exit={{ opacity: 0, y: -8 }}
|
| 181 |
+
transition={{ duration: 0.3 }}
|
| 182 |
+
>
|
| 183 |
+
{LOADING_STEPS[loadingStep]}
|
| 184 |
+
</motion.p>
|
| 185 |
+
</AnimatePresence>
|
| 186 |
+
|
| 187 |
+
{/* Progress bar */}
|
| 188 |
+
<div className="w-64 h-1 rounded-full overflow-hidden" style={{ background: colors.border }}>
|
| 189 |
+
<motion.div
|
| 190 |
+
className="h-full rounded-full"
|
| 191 |
+
style={{ background: `linear-gradient(90deg, #FF9933, #FFCC80)` }}
|
| 192 |
+
animate={{ width: `${progress}%` }}
|
| 193 |
+
transition={{ ease: "linear", duration: 0.05 }}
|
| 194 |
+
/>
|
| 195 |
+
</div>
|
| 196 |
+
<p className="text-xs mt-2" style={{ color: colors.textFaint }}>
|
| 197 |
+
{Math.round(progress)}%
|
| 198 |
+
</p>
|
| 199 |
+
</motion.div>
|
| 200 |
+
)}
|
| 201 |
+
</AnimatePresence>
|
| 202 |
+
|
| 203 |
+
{/* Main card */}
|
| 204 |
+
<motion.div
|
| 205 |
+
className="relative z-10 w-full max-w-md flex flex-col items-center"
|
| 206 |
+
{...motionPresets.fadeUp}
|
| 207 |
+
transition={{ duration: 0.5 }}
|
| 208 |
+
>
|
| 209 |
+
{/* Logo */}
|
| 210 |
+
<div className="flex items-center gap-3 mb-4">
|
| 211 |
+
<div
|
| 212 |
+
className="w-12 h-12 rounded-2xl flex items-center justify-center text-2xl"
|
| 213 |
+
style={{ background: "rgba(255,153,51,0.15)", border: "1px solid rgba(255,153,51,0.25)" }}
|
| 214 |
+
>
|
| 215 |
+
🏥
|
| 216 |
+
</div>
|
| 217 |
+
<div className="leading-none">
|
| 218 |
+
<div className="text-xs font-semibold tracking-widest uppercase mb-0.5" style={{ color: colors.textMuted }}>
|
| 219 |
+
Report
|
| 220 |
+
</div>
|
| 221 |
+
<h1 className="text-3xl font-black tracking-tight" style={{ color: "#FF9933" }}>
|
| 222 |
+
Raahat
|
| 223 |
+
</h1>
|
| 224 |
+
</div>
|
| 225 |
+
</div>
|
| 226 |
+
|
| 227 |
+
<p className="text-base mb-1 text-center font-devanagari" style={{ color: colors.textSecondary }}>
|
| 228 |
+
अपनी मेडिकल रिपोर्ट समझें — आसान हिंदी में
|
| 229 |
+
</p>
|
| 230 |
+
<p className="text-sm mb-6 text-center" style={{ color: colors.textMuted }}>
|
| 231 |
+
Upload your report and understand it instantly
|
| 232 |
+
</p>
|
| 233 |
+
|
| 234 |
+
{/* Language toggle */}
|
| 235 |
+
<div className="flex gap-1 mb-5 p-1 rounded-2xl w-full"
|
| 236 |
+
style={{ background: colors.bgSubtle, border: `1px solid ${colors.border}` }}>
|
| 237 |
+
{(["HI", "EN"] as const).map((lang) => (
|
| 238 |
+
<button key={lang} onClick={() => useGUCStore.getState().setLanguage(lang)}
|
| 239 |
+
className="flex-1 py-2.5 rounded-xl text-sm font-semibold transition-all duration-200"
|
| 240 |
+
style={{
|
| 241 |
+
background: language === lang ? colors.accent : "transparent",
|
| 242 |
+
color: language === lang ? "#0d0d1a" : colors.textMuted,
|
| 243 |
+
boxShadow: language === lang ? "0 2px 12px rgba(255,153,51,0.3)" : "none",
|
| 244 |
+
}}>
|
| 245 |
+
{lang === "HI" ? "🇮🇳 हिंदी" : "🌐 English"}
|
| 246 |
+
</button>
|
| 247 |
+
))}
|
| 248 |
+
</div>
|
| 249 |
+
|
| 250 |
+
{/* Drop zone */}
|
| 251 |
+
<div
|
| 252 |
+
{...getRootProps()}
|
| 253 |
+
className="w-full cursor-pointer rounded-2xl mb-4 transition-all duration-300"
|
| 254 |
+
style={{
|
| 255 |
+
border: `2px dashed ${isDragActive || file ? colors.accent : colors.accentBorder}`,
|
| 256 |
+
background: isDragActive ? "rgba(255,153,51,0.05)" : colors.bgCard,
|
| 257 |
+
padding: "36px 20px",
|
| 258 |
+
boxShadow: isDragActive || file ? "0 0 24px rgba(255,153,51,0.15)" : "none",
|
| 259 |
+
}}
|
| 260 |
+
>
|
| 261 |
+
<input {...getInputProps()} />
|
| 262 |
+
<AnimatePresence mode="wait">
|
| 263 |
+
{file ? (
|
| 264 |
+
<motion.div key="file" className="flex flex-col items-center"
|
| 265 |
+
initial={{ opacity: 0, scale: 0.92 }} animate={{ opacity: 1, scale: 1 }}>
|
| 266 |
+
<div className="text-4xl mb-2">✅</div>
|
| 267 |
+
<p className="text-white font-semibold">{file.name}</p>
|
| 268 |
+
<p className="text-xs mt-1" style={{ color: colors.ok }}>
|
| 269 |
+
Ready! Click Samjho below ↓
|
| 270 |
+
</p>
|
| 271 |
+
</motion.div>
|
| 272 |
+
) : (
|
| 273 |
+
<motion.div key="empty" className="flex flex-col items-center"
|
| 274 |
+
initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
|
| 275 |
+
<motion.div className="mb-3 text-5xl"
|
| 276 |
+
animate={{ y: [0, -6, 0] }}
|
| 277 |
+
transition={{ duration: 2.5, repeat: Infinity }}>
|
| 278 |
+
📋
|
| 279 |
+
</motion.div>
|
| 280 |
+
<p className="text-white font-bold text-lg mb-1">
|
| 281 |
+
{isDragActive ? "Drop it here! 🎯" : "Drag & drop your report"}
|
| 282 |
+
</p>
|
| 283 |
+
<p className="text-sm" style={{ color: colors.textMuted }}>
|
| 284 |
+
or click to browse — PDF or Image
|
| 285 |
+
</p>
|
| 286 |
+
<p className="text-xs mt-2" style={{ color: colors.textFaint }}>
|
| 287 |
+
Supports: JPG, PNG, PDF
|
| 288 |
+
</p>
|
| 289 |
+
</motion.div>
|
| 290 |
+
)}
|
| 291 |
+
</AnimatePresence>
|
| 292 |
+
</div>
|
| 293 |
+
|
| 294 |
+
{/* Error message */}
|
| 295 |
+
{error && (
|
| 296 |
+
<p className="text-xs mb-3 text-center" style={{ color: "#EF4444" }}>
|
| 297 |
+
{error}
|
| 298 |
+
</p>
|
| 299 |
+
)}
|
| 300 |
+
|
| 301 |
+
{/* CTA button */}
|
| 302 |
+
<motion.button
|
| 303 |
+
onClick={handleAnalyze}
|
| 304 |
+
disabled={loading || !file}
|
| 305 |
+
className="w-full py-4 rounded-2xl text-base font-black disabled:opacity-50 transition-all"
|
| 306 |
+
style={{
|
| 307 |
+
background: "linear-gradient(135deg, #FF9933 0%, #FFAA55 100%)",
|
| 308 |
+
color: "#0d0d1a",
|
| 309 |
+
boxShadow: "0 4px 20px rgba(255,153,51,0.4)",
|
| 310 |
+
}}
|
| 311 |
+
whileHover={{ scale: 1.01, boxShadow: "0 6px 28px rgba(255,153,51,0.5)" }}
|
| 312 |
+
whileTap={{ scale: 0.98 }}
|
| 313 |
+
>
|
| 314 |
+
✨ Samjho! — समझो
|
| 315 |
+
</motion.button>
|
| 316 |
+
|
| 317 |
+
{/* Feature chips */}
|
| 318 |
+
<div className="flex items-center gap-3 mt-5">
|
| 319 |
+
{FEATURES.map((f) => (
|
| 320 |
+
<div
|
| 321 |
+
key={f.label}
|
| 322 |
+
className="flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium"
|
| 323 |
+
style={{ background: colors.bgSubtle, border: `1px solid ${colors.border}`, color: colors.textMuted }}
|
| 324 |
+
>
|
| 325 |
+
<span>{f.icon}</span>
|
| 326 |
+
<span>{f.label}</span>
|
| 327 |
+
</div>
|
| 328 |
+
))}
|
| 329 |
+
</div>
|
| 330 |
+
</motion.div>
|
| 331 |
+
|
| 332 |
+
{/* Floating chat pill */}
|
| 333 |
+
<motion.div
|
| 334 |
+
className="fixed bottom-6 right-6 flex items-center gap-2 px-4 py-2.5 rounded-full cursor-pointer text-sm font-bold"
|
| 335 |
+
style={{
|
| 336 |
+
background: "rgba(255,153,51,0.15)",
|
| 337 |
+
border: "1px solid rgba(255,153,51,0.3)",
|
| 338 |
+
color: "#FF9933",
|
| 339 |
+
backdropFilter: "blur(12px)",
|
| 340 |
+
}}
|
| 341 |
+
animate={{ y: [0, -4, 0] }}
|
| 342 |
+
transition={{ duration: 2.5, repeat: Infinity }}
|
| 343 |
+
whileHover={{ scale: 1.05, background: "rgba(255,153,51,0.25)" }}
|
| 344 |
+
>
|
| 345 |
+
<span>🤖</span>
|
| 346 |
+
<span>Dr. Raahat</span>
|
| 347 |
+
</motion.div>
|
| 348 |
+
</main>
|
| 349 |
+
);
|
| 350 |
+
}
|
frontend/app/wellness/page.tsx
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState } from "react";
|
| 4 |
+
import { motion, AnimatePresence } from "framer-motion";
|
| 5 |
+
import { LineChart, Line, XAxis, Tooltip, ResponsiveContainer } from "recharts";
|
| 6 |
+
import { useGUCStore } from "@/lib/store";
|
| 7 |
+
import MoodCheckIn from "@/components/MoodCheckIn";
|
| 8 |
+
import BreathingWidget from "@/components/BreathingWidget";
|
| 9 |
+
import { PageShell } from "@/components/ui";
|
| 10 |
+
|
| 11 |
+
const AFFIRMATIONS = {
|
| 12 |
+
high_stress: [
|
| 13 |
+
"You came to the right place. Take 5 slow breaths right now. 💙",
|
| 14 |
+
"Recovery is not a straight line. Every small step counts.",
|
| 15 |
+
"Dr. Raahat believes in you. One breath at a time.",
|
| 16 |
+
],
|
| 17 |
+
normal: [
|
| 18 |
+
"You're making progress every single day. 🌱",
|
| 19 |
+
"Consistency beats intensity. You're building healthy habits.",
|
| 20 |
+
"Your body is doing its best. Support it with rest and good food.",
|
| 21 |
+
],
|
| 22 |
+
great: [
|
| 23 |
+
"You're thriving! Keep this momentum. 🌟",
|
| 24 |
+
"High energy day — channel it into your health goals!",
|
| 25 |
+
"This is what healing looks like. Celebrate the small wins.",
|
| 26 |
+
],
|
| 27 |
+
};
|
| 28 |
+
|
| 29 |
+
function getAffirmation(stress: number, sleep: number): string {
|
| 30 |
+
const pool =
|
| 31 |
+
stress <= 3 ? AFFIRMATIONS.high_stress
|
| 32 |
+
: stress >= 8 && sleep >= 7 ? AFFIRMATIONS.great
|
| 33 |
+
: AFFIRMATIONS.normal;
|
| 34 |
+
return pool[Math.floor(Math.random() * pool.length)];
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
const MoodTooltip = ({ active, payload }: any) => {
|
| 38 |
+
if (!active || !payload?.length) return null;
|
| 39 |
+
return (
|
| 40 |
+
<div className="bg-[#1a1a2e] border border-white/10 rounded-lg px-2.5 py-1.5 text-[11px]">
|
| 41 |
+
<div style={{ color: "#FF9933" }}>Stress: {payload[0]?.value}</div>
|
| 42 |
+
<div style={{ color: "#22C55E" }}>Sleep: {payload[1]?.value}</div>
|
| 43 |
+
</div>
|
| 44 |
+
);
|
| 45 |
+
};
|
| 46 |
+
|
| 47 |
+
type Tab = "mood" | "breathe" | "history";
|
| 48 |
+
|
| 49 |
+
export default function WellnessPage() {
|
| 50 |
+
const mentalWellness = useGUCStore((s) => s.mentalWellness);
|
| 51 |
+
const latestReport = useGUCStore((s) => s.latestReport);
|
| 52 |
+
|
| 53 |
+
const [activeTab, setActiveTab] = useState<Tab>("mood");
|
| 54 |
+
|
| 55 |
+
const affirmation = getAffirmation(mentalWellness.stressLevel, mentalWellness.sleepQuality);
|
| 56 |
+
|
| 57 |
+
const moodChartData = mentalWellness.moodHistory.slice(-10).map((entry) => ({
|
| 58 |
+
date: new Date(entry.date).toLocaleDateString("en-IN", { day: "2-digit", month: "short" }),
|
| 59 |
+
stress: entry.stress,
|
| 60 |
+
sleep: entry.sleep,
|
| 61 |
+
}));
|
| 62 |
+
|
| 63 |
+
const recentHistory = mentalWellness.moodHistory.slice(-7);
|
| 64 |
+
const avgStress = recentHistory.length
|
| 65 |
+
? Math.round(recentHistory.reduce((s, e) => s + e.stress, 0) / recentHistory.length)
|
| 66 |
+
: mentalWellness.stressLevel;
|
| 67 |
+
const avgSleep = recentHistory.length
|
| 68 |
+
? (recentHistory.reduce((s, e) => s + e.sleep, 0) / recentHistory.length).toFixed(1)
|
| 69 |
+
: mentalWellness.sleepQuality;
|
| 70 |
+
|
| 71 |
+
const TABS: { key: Tab; label: string; icon: string }[] = [
|
| 72 |
+
{ key: "mood", label: "Check-in", icon: "😊" },
|
| 73 |
+
{ key: "breathe", label: "Breathe", icon: "🫁" },
|
| 74 |
+
{ key: "history", label: "History", icon: "📈" },
|
| 75 |
+
];
|
| 76 |
+
|
| 77 |
+
return (
|
| 78 |
+
<PageShell>
|
| 79 |
+
|
| 80 |
+
{/* Header */}
|
| 81 |
+
<motion.div initial={{ opacity: 0, y: -12 }} animate={{ opacity: 1, y: 0 }} className="mb-5">
|
| 82 |
+
<div className="flex items-center gap-3 mb-1">
|
| 83 |
+
<span className="text-2xl">🧘</span>
|
| 84 |
+
<h1 className="text-xl font-bold tracking-tight">Mental Wellness</h1>
|
| 85 |
+
</div>
|
| 86 |
+
<p className="text-white/40 text-sm">Recovery is physical AND mental</p>
|
| 87 |
+
</motion.div>
|
| 88 |
+
|
| 89 |
+
{/* Affirmation */}
|
| 90 |
+
<motion.div
|
| 91 |
+
initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 }}
|
| 92 |
+
className="mb-5 p-4 rounded-2xl bg-gradient-to-br from-indigo-500/10 to-purple-500/5 border border-indigo-500/15"
|
| 93 |
+
>
|
| 94 |
+
<p className="text-indigo-200/80 text-sm leading-relaxed italic">"{affirmation}"</p>
|
| 95 |
+
<p className="text-indigo-400/40 text-xs mt-2">— Dr. Raahat</p>
|
| 96 |
+
</motion.div>
|
| 97 |
+
|
| 98 |
+
{/* Stats strip */}
|
| 99 |
+
<motion.div
|
| 100 |
+
initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.15 }}
|
| 101 |
+
className="flex gap-2 mb-5"
|
| 102 |
+
>
|
| 103 |
+
{[
|
| 104 |
+
{ label: "Avg Stress", value: `${avgStress}/10`, color: avgStress <= 3 ? "#EF4444" : avgStress >= 7 ? "#22C55E" : "#FF9933", icon: "🧠" },
|
| 105 |
+
{ label: "Avg Sleep", value: `${avgSleep}/10`, color: Number(avgSleep) >= 7 ? "#22C55E" : Number(avgSleep) <= 4 ? "#EF4444" : "#FF9933", icon: "🌙" },
|
| 106 |
+
{ label: "Streak", value: `${mentalWellness.moodHistory.length}d`, color: "#6366F1", icon: "🔥" },
|
| 107 |
+
].map((stat) => (
|
| 108 |
+
<div key={stat.label} className="flex-1 bg-white/[0.04] border border-white/[0.07] rounded-xl py-2.5 px-3 text-center">
|
| 109 |
+
<div className="text-base mb-0.5">{stat.icon}</div>
|
| 110 |
+
<div className="font-bold text-sm" style={{ color: stat.color }}>{stat.value}</div>
|
| 111 |
+
<div className="text-white/30 text-[10px]">{stat.label}</div>
|
| 112 |
+
</div>
|
| 113 |
+
))}
|
| 114 |
+
</motion.div>
|
| 115 |
+
|
| 116 |
+
{/* Report mental health note */}
|
| 117 |
+
{latestReport && mentalWellness.stressLevel <= 4 && (
|
| 118 |
+
<motion.div
|
| 119 |
+
initial={{ opacity: 0 }} animate={{ opacity: 1 }}
|
| 120 |
+
className="mb-5 p-3.5 rounded-xl bg-orange-500/8 border border-orange-500/15"
|
| 121 |
+
>
|
| 122 |
+
<p className="text-orange-300/70 text-xs leading-relaxed">
|
| 123 |
+
🩺 Your recent report showed{" "}
|
| 124 |
+
<strong className="text-orange-300/90">
|
| 125 |
+
{latestReport.severity_level.replace("_", " ").toLowerCase()}
|
| 126 |
+
</strong>
|
| 127 |
+
. Medical stress is real. Talking to Dr. Raahat in the chat can help reduce anxiety about your results.
|
| 128 |
+
</p>
|
| 129 |
+
</motion.div>
|
| 130 |
+
)}
|
| 131 |
+
|
| 132 |
+
{/* Tabs — animated sliding indicator */}
|
| 133 |
+
<div className="flex gap-0 mb-5 p-1 bg-white/[0.04] rounded-xl border border-white/[0.07] relative">
|
| 134 |
+
{TABS.map((tab) => (
|
| 135 |
+
<button
|
| 136 |
+
key={tab.key}
|
| 137 |
+
onClick={() => setActiveTab(tab.key)}
|
| 138 |
+
className="flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg text-xs font-medium transition-colors relative z-10"
|
| 139 |
+
style={{
|
| 140 |
+
color: activeTab === tab.key ? "white" : "rgba(255,255,255,0.35)",
|
| 141 |
+
}}
|
| 142 |
+
>
|
| 143 |
+
{activeTab === tab.key && (
|
| 144 |
+
<motion.div
|
| 145 |
+
layoutId="wellness-tab-bg"
|
| 146 |
+
className="absolute inset-0 rounded-lg"
|
| 147 |
+
style={{ background: "rgba(255,255,255,0.1)" }}
|
| 148 |
+
transition={{ type: "spring", stiffness: 400, damping: 35 }}
|
| 149 |
+
/>
|
| 150 |
+
)}
|
| 151 |
+
<span className="relative z-10">{tab.icon}</span>
|
| 152 |
+
<span className="relative z-10">{tab.label}</span>
|
| 153 |
+
</button>
|
| 154 |
+
))}
|
| 155 |
+
</div>
|
| 156 |
+
|
| 157 |
+
{/* Tab content */}
|
| 158 |
+
<AnimatePresence mode="wait">
|
| 159 |
+
|
| 160 |
+
{activeTab === "mood" && (
|
| 161 |
+
<motion.div
|
| 162 |
+
key="mood"
|
| 163 |
+
initial={{ opacity: 0, x: -10 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: 10 }}
|
| 164 |
+
transition={{ duration: 0.18 }}
|
| 165 |
+
className="space-y-4"
|
| 166 |
+
>
|
| 167 |
+
<MoodCheckIn />
|
| 168 |
+
<div className="bg-white/[0.03] border border-white/[0.07] rounded-2xl p-4">
|
| 169 |
+
<p className="text-white/40 text-[10px] uppercase tracking-widest mb-3">Why Sleep Matters for Recovery</p>
|
| 170 |
+
<div className="space-y-2.5">
|
| 171 |
+
{[
|
| 172 |
+
{ icon: "🩸", text: "Iron is absorbed better when you sleep 7+ hours" },
|
| 173 |
+
{ icon: "🦴", text: "Bone repair and Vitamin D activation happen during deep sleep" },
|
| 174 |
+
{ icon: "🛡️", text: "Immune system strengthens during sleep cycles" },
|
| 175 |
+
{ icon: "🧠", text: "Stress hormones drop by 30% after a full night of sleep" },
|
| 176 |
+
].map((tip, i) => (
|
| 177 |
+
<div key={i} className="flex gap-2.5 items-start">
|
| 178 |
+
<span className="text-sm flex-shrink-0 mt-0.5">{tip.icon}</span>
|
| 179 |
+
<p className="text-white/45 text-xs leading-relaxed">{tip.text}</p>
|
| 180 |
+
</div>
|
| 181 |
+
))}
|
| 182 |
+
</div>
|
| 183 |
+
</div>
|
| 184 |
+
</motion.div>
|
| 185 |
+
)}
|
| 186 |
+
|
| 187 |
+
{activeTab === "breathe" && (
|
| 188 |
+
<motion.div
|
| 189 |
+
key="breathe"
|
| 190 |
+
initial={{ opacity: 0, x: -10 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: 10 }}
|
| 191 |
+
transition={{ duration: 0.18 }}
|
| 192 |
+
className="space-y-4"
|
| 193 |
+
>
|
| 194 |
+
<BreathingWidget />
|
| 195 |
+
<div className="bg-white/[0.03] border border-white/[0.07] rounded-2xl p-4">
|
| 196 |
+
<p className="text-white/40 text-[10px] uppercase tracking-widest mb-3">Why 4-7-8 Breathing Works</p>
|
| 197 |
+
<div className="grid grid-cols-3 gap-2 text-center">
|
| 198 |
+
{[
|
| 199 |
+
{ phase: "4", label: "Inhale", color: "#FF9933", note: "Activates parasympathetic system" },
|
| 200 |
+
{ phase: "7", label: "Hold", color: "#6366F1", note: "Oxygen saturates bloodstream" },
|
| 201 |
+
{ phase: "8", label: "Exhale", color: "#22C55E", note: "CO₂ released, stress drops" },
|
| 202 |
+
].map((p) => (
|
| 203 |
+
<div key={p.label} className="bg-white/[0.03] rounded-xl p-3 border border-white/[0.05]">
|
| 204 |
+
<div className="text-2xl font-bold mb-1" style={{ color: p.color }}>{p.phase}s</div>
|
| 205 |
+
<div className="text-white/60 text-[10px] font-medium mb-1">{p.label}</div>
|
| 206 |
+
<div className="text-white/25 text-[9px] leading-snug">{p.note}</div>
|
| 207 |
+
</div>
|
| 208 |
+
))}
|
| 209 |
+
</div>
|
| 210 |
+
<p className="text-white/25 text-[10px] text-center mt-3">
|
| 211 |
+
Practice 2× daily for 4 weeks to significantly reduce chronic stress
|
| 212 |
+
</p>
|
| 213 |
+
</div>
|
| 214 |
+
</motion.div>
|
| 215 |
+
)}
|
| 216 |
+
|
| 217 |
+
{activeTab === "history" && (
|
| 218 |
+
<motion.div
|
| 219 |
+
key="history"
|
| 220 |
+
initial={{ opacity: 0, x: -10 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: 10 }}
|
| 221 |
+
transition={{ duration: 0.18 }}
|
| 222 |
+
className="space-y-4"
|
| 223 |
+
>
|
| 224 |
+
{moodChartData.length >= 2 ? (
|
| 225 |
+
<div className="bg-white/[0.04] border border-white/[0.07] rounded-2xl p-4">
|
| 226 |
+
<div className="flex justify-between items-center mb-3">
|
| 227 |
+
<p className="text-white/60 text-xs font-medium">Last {moodChartData.length} check-ins</p>
|
| 228 |
+
<div className="flex gap-3 text-[10px] text-white/30">
|
| 229 |
+
<span className="flex items-center gap-1">
|
| 230 |
+
<span className="w-2 h-0.5 bg-[#FF9933] inline-block rounded" />Stress
|
| 231 |
+
</span>
|
| 232 |
+
<span className="flex items-center gap-1">
|
| 233 |
+
<span className="w-2 h-0.5 bg-[#22C55E] inline-block rounded" />Sleep
|
| 234 |
+
</span>
|
| 235 |
+
</div>
|
| 236 |
+
</div>
|
| 237 |
+
<ResponsiveContainer width="100%" height={160}>
|
| 238 |
+
<LineChart data={moodChartData}>
|
| 239 |
+
<XAxis dataKey="date" tick={{ fill: "rgba(255,255,255,0.25)", fontSize: 10 }} axisLine={false} tickLine={false} />
|
| 240 |
+
<Tooltip content={<MoodTooltip />} />
|
| 241 |
+
<Line type="monotone" dataKey="stress" stroke="#FF9933" strokeWidth={2} dot={{ fill: "#FF9933", r: 3 }} activeDot={{ r: 5 }} />
|
| 242 |
+
<Line type="monotone" dataKey="sleep" stroke="#22C55E" strokeWidth={2} dot={{ fill: "#22C55E", r: 3 }} activeDot={{ r: 5 }} />
|
| 243 |
+
</LineChart>
|
| 244 |
+
</ResponsiveContainer>
|
| 245 |
+
</div>
|
| 246 |
+
) : (
|
| 247 |
+
<div className="bg-white/[0.03] border border-white/[0.07] rounded-2xl p-8 text-center">
|
| 248 |
+
<p className="text-4xl mb-3">📊</p>
|
| 249 |
+
<p className="text-white/40 text-sm">Complete at least 2 mood check-ins to see your history chart.</p>
|
| 250 |
+
<p className="text-white/20 text-xs mt-1">Come back daily for best insights</p>
|
| 251 |
+
</div>
|
| 252 |
+
)}
|
| 253 |
+
|
| 254 |
+
{mentalWellness.moodHistory.length > 0 && (
|
| 255 |
+
<div className="bg-white/[0.03] border border-white/[0.07] rounded-2xl overflow-hidden">
|
| 256 |
+
<div className="px-4 py-3 border-b border-white/[0.06]">
|
| 257 |
+
<p className="text-white/40 text-[10px] uppercase tracking-widest">Check-in Log</p>
|
| 258 |
+
</div>
|
| 259 |
+
<div className="divide-y divide-white/[0.04]">
|
| 260 |
+
{[...mentalWellness.moodHistory].reverse().slice(0, 10).map((entry, i) => (
|
| 261 |
+
<div key={i} className="px-4 py-2.5 flex justify-between items-center">
|
| 262 |
+
<span className="text-white/30 text-xs">
|
| 263 |
+
{new Date(entry.date).toLocaleDateString("en-IN", { day: "2-digit", month: "short" })}
|
| 264 |
+
</span>
|
| 265 |
+
<div className="flex gap-4 text-xs">
|
| 266 |
+
<span style={{ color: entry.stress <= 3 ? "#EF4444" : entry.stress >= 7 ? "#22C55E" : "#FF9933" }}>
|
| 267 |
+
😰 {entry.stress}/10
|
| 268 |
+
</span>
|
| 269 |
+
<span style={{ color: entry.sleep >= 7 ? "#22C55E" : entry.sleep <= 4 ? "#EF4444" : "#FF9933" }}>
|
| 270 |
+
🌙 {entry.sleep}/10
|
| 271 |
+
</span>
|
| 272 |
+
</div>
|
| 273 |
+
</div>
|
| 274 |
+
))}
|
| 275 |
+
</div>
|
| 276 |
+
</div>
|
| 277 |
+
)}
|
| 278 |
+
</motion.div>
|
| 279 |
+
)}
|
| 280 |
+
|
| 281 |
+
</AnimatePresence>
|
| 282 |
+
</PageShell>
|
| 283 |
+
);
|
| 284 |
+
}
|
frontend/components/AvatarPanel.tsx
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
import { motion } from "framer-motion"
|
| 3 |
+
import { useGUCStore } from "@/lib/store"
|
| 4 |
+
|
| 5 |
+
export default function AvatarPanel() {
|
| 6 |
+
const { avatarState, avatarXP } = useGUCStore()
|
| 7 |
+
const avatarLevel = avatarXP >= 750 ? 5 : avatarXP >= 500 ? 4 : avatarXP >= 300 ? 3 : avatarXP >= 150 ? 2 : 1
|
| 8 |
+
|
| 9 |
+
const levelColors = ["", "#94a3b8", "#f97316", "#22c55e", "#3b82f6", "#fbbf24"]
|
| 10 |
+
const levelTitles = ["", "Rogi", "Jagruk", "Swasth", "Yoddha", "Nirogh"]
|
| 11 |
+
const color = levelColors[avatarLevel]
|
| 12 |
+
|
| 13 |
+
const stateLabel: Record<string, string> = {
|
| 14 |
+
THINKING: "Soch raha hoon...",
|
| 15 |
+
ANALYZING: "Analyze ho raha hai...",
|
| 16 |
+
HAPPY: "Shabash! 🎉",
|
| 17 |
+
LEVEL_UP: "Level Up! ⚡",
|
| 18 |
+
SPEAKING: "Sun lo...",
|
| 19 |
+
CONCERNED: "Dhyan do...",
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
return (
|
| 23 |
+
<div className="fixed bottom-6 right-6 z-50 flex flex-col items-center gap-1.5">
|
| 24 |
+
|
| 25 |
+
{/* State tooltip */}
|
| 26 |
+
{avatarState !== "IDLE" && (
|
| 27 |
+
<motion.div
|
| 28 |
+
initial={{ opacity: 0, y: 6, scale: 0.9 }}
|
| 29 |
+
animate={{ opacity: 1, y: 0, scale: 1 }}
|
| 30 |
+
exit={{ opacity: 0, y: 6, scale: 0.9 }}
|
| 31 |
+
className="text-xs px-3 py-1.5 rounded-xl mb-1 text-center"
|
| 32 |
+
style={{
|
| 33 |
+
background: "rgba(13,13,26,0.9)",
|
| 34 |
+
border: "1px solid rgba(255,153,51,0.25)",
|
| 35 |
+
color: "#FF9933",
|
| 36 |
+
backdropFilter: "blur(8px)",
|
| 37 |
+
boxShadow: "0 4px 20px rgba(0,0,0,0.4)",
|
| 38 |
+
whiteSpace: "nowrap",
|
| 39 |
+
}}
|
| 40 |
+
>
|
| 41 |
+
{stateLabel[avatarState]}
|
| 42 |
+
</motion.div>
|
| 43 |
+
)}
|
| 44 |
+
|
| 45 |
+
{/* Pulse ring (active when not IDLE) */}
|
| 46 |
+
<div className="relative">
|
| 47 |
+
{avatarState !== "IDLE" && (
|
| 48 |
+
<>
|
| 49 |
+
<span
|
| 50 |
+
className="absolute inset-0 rounded-full"
|
| 51 |
+
style={{
|
| 52 |
+
border: `2px solid ${color}`,
|
| 53 |
+
animation: "pulseRing 1.8s ease-out infinite",
|
| 54 |
+
}}
|
| 55 |
+
/>
|
| 56 |
+
<span
|
| 57 |
+
className="absolute inset-0 rounded-full"
|
| 58 |
+
style={{
|
| 59 |
+
border: `2px solid ${color}`,
|
| 60 |
+
animation: "pulseRing 1.8s ease-out 0.6s infinite",
|
| 61 |
+
}}
|
| 62 |
+
/>
|
| 63 |
+
</>
|
| 64 |
+
)}
|
| 65 |
+
|
| 66 |
+
{/* Avatar circle */}
|
| 67 |
+
<motion.div
|
| 68 |
+
className="w-14 h-14 rounded-full flex items-center justify-center text-2xl relative"
|
| 69 |
+
style={{
|
| 70 |
+
background: "rgba(13,13,26,0.95)",
|
| 71 |
+
border: `2px solid ${color}`,
|
| 72 |
+
boxShadow: `0 0 16px ${color}40`,
|
| 73 |
+
}}
|
| 74 |
+
whileHover={{ scale: 1.08 }}
|
| 75 |
+
animate={avatarState === "HAPPY" ? { scale: [1, 1.1, 1] } : {}}
|
| 76 |
+
transition={{ duration: 0.4 }}
|
| 77 |
+
>
|
| 78 |
+
🤖
|
| 79 |
+
</motion.div>
|
| 80 |
+
</div>
|
| 81 |
+
|
| 82 |
+
{/* XP bar */}
|
| 83 |
+
<div className="w-14 h-1 rounded-full overflow-hidden" style={{ background: "rgba(255,255,255,0.08)" }}>
|
| 84 |
+
<motion.div
|
| 85 |
+
className="h-full rounded-full transition-all duration-1000"
|
| 86 |
+
style={{
|
| 87 |
+
background: `linear-gradient(90deg, ${color}, ${color}aa)`,
|
| 88 |
+
width: `${Math.min((avatarXP % 250) / 250 * 100, 100)}%`,
|
| 89 |
+
boxShadow: `0 0 6px ${color}`,
|
| 90 |
+
}}
|
| 91 |
+
/>
|
| 92 |
+
</div>
|
| 93 |
+
|
| 94 |
+
{/* Level label */}
|
| 95 |
+
<span className="text-[9px] font-medium" style={{ color: "rgba(255,255,255,0.3)" }}>
|
| 96 |
+
Lv.{avatarLevel} {levelTitles[avatarLevel]}
|
| 97 |
+
</span>
|
| 98 |
+
</div>
|
| 99 |
+
)
|
| 100 |
+
}
|
frontend/components/BadgeGrid.tsx
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// OWNER: Member 3
|
| 2 |
+
// Achievement badge grid
|
| 3 |
+
// earned: string[] — list of earned badge IDs from GUC
|
| 4 |
+
// Locked badges show greyed out + grayscale filter
|
| 5 |
+
// Unlocked badges animate in with scale bounce on first render
|
| 6 |
+
|
| 7 |
+
interface BadgeGridProps {
|
| 8 |
+
earned: string[]
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export default function BadgeGrid({ earned }: BadgeGridProps) {
|
| 12 |
+
// TODO Member 3: implement full badge grid
|
| 13 |
+
return (
|
| 14 |
+
<div className="text-slate-500 text-sm p-4">
|
| 15 |
+
BadgeGrid — Member 3 ({earned.length} earned)
|
| 16 |
+
</div>
|
| 17 |
+
)
|
| 18 |
+
}
|
frontend/components/BentoGrid.tsx
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// OWNER: Member 2
|
| 2 |
+
// Bento grid layout wrapper — 7 cards with Framer Motion staggerChildren
|
| 3 |
+
// Each card slides up with spring physics, 100ms stagger
|
| 4 |
+
|
| 5 |
+
export default function BentoGrid({ children }: { children: React.ReactNode }) {
|
| 6 |
+
return <div className="grid grid-cols-1 md:grid-cols-3 gap-4">{children}</div>
|
| 7 |
+
}
|
frontend/components/BodyMap.tsx
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
import { useEffect, useState } from "react";
|
| 3 |
+
|
| 4 |
+
interface Props {
|
| 5 |
+
organFlags: {
|
| 6 |
+
liver?: boolean;
|
| 7 |
+
heart?: boolean;
|
| 8 |
+
kidney?: boolean;
|
| 9 |
+
lungs?: boolean;
|
| 10 |
+
};
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export default function BodyMap({ organFlags }: Props) {
|
| 14 |
+
const [pulse, setPulse] = useState(false);
|
| 15 |
+
|
| 16 |
+
useEffect(() => {
|
| 17 |
+
setTimeout(() => setPulse(true), 600);
|
| 18 |
+
}, []);
|
| 19 |
+
|
| 20 |
+
const glowStyle = {
|
| 21 |
+
fill: "#FF9933",
|
| 22 |
+
opacity: pulse ? 1 : 0.3,
|
| 23 |
+
transition: "opacity 0.5s ease",
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
const normalStyle = {
|
| 27 |
+
fill: "#334155",
|
| 28 |
+
opacity: 0.6,
|
| 29 |
+
};
|
| 30 |
+
|
| 31 |
+
return (
|
| 32 |
+
<div className="flex flex-col items-center">
|
| 33 |
+
<svg viewBox="0 0 100 200" width="140" height="260">
|
| 34 |
+
{/* Head */}
|
| 35 |
+
<ellipse cx="50" cy="18" rx="16" ry="18"
|
| 36 |
+
style={{ fill: "#475569" }} />
|
| 37 |
+
|
| 38 |
+
{/* Neck */}
|
| 39 |
+
<rect x="44" y="34" width="12" height="10"
|
| 40 |
+
style={{ fill: "#475569" }} />
|
| 41 |
+
|
| 42 |
+
{/* Body */}
|
| 43 |
+
<rect x="28" y="44" width="44" height="60" rx="8"
|
| 44 |
+
style={{ fill: "#1e293b", stroke: "#475569", strokeWidth: 1 }} />
|
| 45 |
+
|
| 46 |
+
{/* Heart */}
|
| 47 |
+
<ellipse cx="43" cy="62" rx="8" ry="8"
|
| 48 |
+
style={organFlags.heart ? glowStyle : normalStyle}
|
| 49 |
+
className={organFlags.heart && pulse ? "organ-pulse" : ""}/>
|
| 50 |
+
{organFlags.heart && (
|
| 51 |
+
<text x="43" y="65" textAnchor="middle" fontSize="8" fill="white">♥</text>
|
| 52 |
+
)}
|
| 53 |
+
|
| 54 |
+
{/* Lungs */}
|
| 55 |
+
<ellipse cx="35" cy="68" rx="5" ry="9"
|
| 56 |
+
style={organFlags.lungs ? glowStyle : normalStyle}
|
| 57 |
+
className={organFlags.lungs && pulse ? "organ-pulse" : ""}/>
|
| 58 |
+
<ellipse cx="65" cy="68" rx="5" ry="9"
|
| 59 |
+
style={organFlags.lungs ? glowStyle : normalStyle}
|
| 60 |
+
className={organFlags.lungs && pulse ? "organ-pulse" : ""}/>
|
| 61 |
+
|
| 62 |
+
{/* Liver */}
|
| 63 |
+
<ellipse cx="58" cy="80" rx="10" ry="7"
|
| 64 |
+
style={organFlags.liver ? glowStyle : normalStyle}
|
| 65 |
+
className={organFlags.liver && pulse ? "organ-pulse" : ""}/>
|
| 66 |
+
{organFlags.liver && (
|
| 67 |
+
<text x="58" y="83" textAnchor="middle" fontSize="6" fill="white">liver</text>
|
| 68 |
+
)}
|
| 69 |
+
|
| 70 |
+
{/* Kidneys */}
|
| 71 |
+
<ellipse cx="38" cy="90" rx="5" ry="7"
|
| 72 |
+
style={organFlags.kidney ? glowStyle : normalStyle}
|
| 73 |
+
className={organFlags.kidney && pulse ? "organ-pulse" : ""}/>
|
| 74 |
+
<ellipse cx="62" cy="90" rx="5" ry="7"
|
| 75 |
+
style={organFlags.kidney ? glowStyle : normalStyle}
|
| 76 |
+
className={organFlags.kidney && pulse ? "organ-pulse" : ""}/>
|
| 77 |
+
|
| 78 |
+
{/* Arms */}
|
| 79 |
+
<rect x="12" y="46" width="14" height="45" rx="7"
|
| 80 |
+
style={{ fill: "#475569" }} />
|
| 81 |
+
<rect x="74" y="46" width="14" height="45" rx="7"
|
| 82 |
+
style={{ fill: "#475569" }} />
|
| 83 |
+
|
| 84 |
+
{/* Legs */}
|
| 85 |
+
<rect x="30" y="106" width="16" height="55" rx="8"
|
| 86 |
+
style={{ fill: "#475569" }} />
|
| 87 |
+
<rect x="54" y="106" width="16" height="55" rx="8"
|
| 88 |
+
style={{ fill: "#475569" }} />
|
| 89 |
+
</svg>
|
| 90 |
+
|
| 91 |
+
{/* Legend */}
|
| 92 |
+
<div className="mt-2 flex flex-wrap gap-2 justify-center">
|
| 93 |
+
{Object.entries(organFlags).map(([organ, active]) =>
|
| 94 |
+
active ? (
|
| 95 |
+
<span key={organ} className="text-xs px-2 py-1 rounded-full font-medium"
|
| 96 |
+
style={{ background: "rgba(255,153,51,0.2)", color: "#FF9933",
|
| 97 |
+
border: "1px solid rgba(255,153,51,0.4)" }}>
|
| 98 |
+
🟠 {organ.charAt(0).toUpperCase() + organ.slice(1)}
|
| 99 |
+
</span>
|
| 100 |
+
) : null
|
| 101 |
+
)}
|
| 102 |
+
{!Object.values(organFlags).some(Boolean) && (
|
| 103 |
+
<span className="text-xs text-slate-500">No specific organ detected</span>
|
| 104 |
+
)}
|
| 105 |
+
</div>
|
| 106 |
+
</div>
|
| 107 |
+
);
|
| 108 |
+
}
|