Spaces:
Sleeping
Sleeping
| import os | |
| import uuid | |
| import json | |
| import logging | |
| from typing import List, Optional | |
| from datetime import datetime, timedelta | |
| from pathlib import Path | |
| import jwt | |
| from fastapi import FastAPI, File, UploadFile, HTTPException, Depends, Form, Body, Header, Query | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.responses import JSONResponse | |
| from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm | |
| from pydantic import BaseModel, EmailStr, Field | |
| from passlib.context import CryptContext | |
| import ai_models | |
| import label_bridge | |
| from pymongo import MongoClient | |
| from bson.objectid import ObjectId | |
| from dotenv import load_dotenv | |
| # Load environment variables | |
| load_dotenv() | |
| # Setup logging | |
| logging.basicConfig(level=logging.INFO) | |
| logger = logging.getLogger(__name__) | |
| # Initialize FastAPI app | |
| app = FastAPI(title="ZemedicAI API", description="API for ZemedicAI medical image analysis") | |
| # Configure CORS | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], # In production, replace with specific origins | |
| allow_credentials=True, | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| # MongoDB connection | |
| MONGO_URL = os.environ.get("MONGO_URL") | |
| DB_NAME = os.environ.get("DB_NAME", "zemedic_db") | |
| try: | |
| client = MongoClient(MONGO_URL) | |
| # Test the connection | |
| client.admin.command('ping') | |
| db = client[DB_NAME] | |
| logger.info("Successfully connected to MongoDB") | |
| except Exception as e: | |
| logger.error(f"Failed to connect to MongoDB: {str(e)}") | |
| # For demo purposes, we'll still initialize the app but won't use the database | |
| client = None | |
| db = None | |
| # JWT Configuration | |
| SECRET_KEY = os.environ.get("SECRET_KEY", "your-secret-key") # Should be properly secured in production | |
| ALGORITHM = "HS256" | |
| ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 1 week | |
| # Password hashing | |
| pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") | |
| # OAuth2 setup | |
| oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/auth/token") | |
| # File upload directory | |
| UPLOAD_DIR = Path("./uploads") | |
| UPLOAD_DIR.mkdir(parents=True, exist_ok=True) | |
| # ---------------------------------------- | |
| # Models | |
| # ---------------------------------------- | |
| class UserBase(BaseModel): | |
| email: EmailStr | |
| name: str | |
| class UserCreate(UserBase): | |
| password: str | |
| class User(UserBase): | |
| id: str | |
| created_at: datetime | |
| class Config: | |
| from_attributes = True | |
| class UserInDB(User): | |
| hashed_password: str | |
| class Token(BaseModel): | |
| access_token: str | |
| token_type: str | |
| user_id: str | |
| email: str | |
| name: str | |
| class TokenData(BaseModel): | |
| user_id: Optional[str] = None | |
| class LoginData(BaseModel): | |
| email: EmailStr | |
| password: str | |
| class RegisterData(UserCreate): | |
| pass | |
| class Prediction(BaseModel): | |
| label: str | |
| confidence: float | |
| description: Optional[str] = None | |
| class AnalysisResult(BaseModel): | |
| id: str | |
| user_id: str | |
| type: str | |
| date: datetime | |
| image_url: str | |
| predictions: List[Prediction] | |
| recommendations: Optional[List[str]] = None | |
| # ---------------------------------------- | |
| # Auth functions | |
| # ---------------------------------------- | |
| def verify_password(plain_password, hashed_password): | |
| return pwd_context.verify(plain_password, hashed_password) | |
| def get_password_hash(password): | |
| return pwd_context.hash(password) | |
| def get_user(email: str): | |
| user_doc = db.users.find_one({"email": email}) | |
| if user_doc: | |
| user_doc["id"] = str(user_doc["_id"]) | |
| del user_doc["_id"] | |
| return UserInDB(**user_doc) | |
| return None | |
| def authenticate_user(email: str, password: str): | |
| user = get_user(email) | |
| if not user: | |
| return False | |
| if not verify_password(password, user.hashed_password): | |
| return False | |
| return user | |
| def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): | |
| to_encode = data.copy() | |
| if expires_delta: | |
| expire = datetime.utcnow() + expires_delta | |
| else: | |
| expire = datetime.utcnow() + timedelta(minutes=15) | |
| to_encode.update({"exp": expire}) | |
| encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) | |
| return encoded_jwt | |
| async def get_current_user(token: str = Depends(oauth2_scheme)): | |
| credentials_exception = HTTPException( | |
| status_code=401, | |
| detail="Could not validate credentials", | |
| headers={"WWW-Authenticate": "Bearer"}, | |
| ) | |
| # Demo mode for MongoDB unavailability | |
| if client is None or db is None: | |
| try: | |
| payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) | |
| user_id: str = payload.get("sub") | |
| if user_id is None: | |
| raise credentials_exception | |
| # Create a demo user | |
| return User( | |
| id=user_id, | |
| email="demo@example.com", | |
| name="Demo User", | |
| created_at=datetime.utcnow() | |
| ) | |
| except jwt.PyJWTError: | |
| raise credentials_exception | |
| # Normal flow with MongoDB | |
| try: | |
| payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) | |
| user_id: str = payload.get("sub") | |
| if user_id is None: | |
| raise credentials_exception | |
| token_data = TokenData(user_id=user_id) | |
| except jwt.PyJWTError: | |
| raise credentials_exception | |
| user_doc = db.users.find_one({"_id": ObjectId(token_data.user_id)}) | |
| if user_doc is None: | |
| raise credentials_exception | |
| user_doc["id"] = str(user_doc["_id"]) | |
| del user_doc["_id"] | |
| return UserInDB(**user_doc) | |
| # ---------------------------------------- | |
| # Image Analysis Functions (delegated to ai_models — real models, no mock/random) | |
| # ---------------------------------------- | |
| def analyze_xray_image(image_data: bytes, patient_context: Optional[dict] = None): | |
| return ai_models.analyze_xray_image(image_data, patient_context) | |
| def analyze_skin_image(image_data: bytes, patient_context: Optional[dict] = None): | |
| return ai_models.analyze_skin_image(image_data, patient_context) | |
| def analyze_ct_scan(image_data: bytes, patient_context: Optional[dict] = None): | |
| # The legacy "CT" endpoint is repurposed as ultrasound; route to the | |
| # breast-ultrasound (BUSI) model, which is our current ultrasound model. | |
| return ai_models.analyze_breast_ultrasound(image_data, patient_context) | |
| def analyze_breast_image(image_data: bytes, patient_context: Optional[dict] = None): | |
| return ai_models.analyze_breast_ultrasound(image_data, patient_context) | |
| def _analysis_response(analysis_id: str, type_: str, analysis_result: dict, image_url: str) -> dict: | |
| """Shape the API response, passing through the richer ai_models fields | |
| (model_status, severity, risk, and the dual-language Assistant).""" | |
| return { | |
| "id": analysis_id, | |
| "type": type_, | |
| "model_status": analysis_result.get("model_status"), | |
| "predictions": analysis_result.get("predictions", []), | |
| "top_finding": analysis_result.get("top_finding"), | |
| "severity": analysis_result.get("severity"), | |
| "risk": analysis_result.get("risk"), | |
| "recommendations": analysis_result.get("recommendations", []), | |
| "assistant": analysis_result.get("assistant"), | |
| "assistant_name": analysis_result.get("assistant_name", "Zafya AI"), | |
| "region_of_interest": analysis_result.get("region_of_interest"), | |
| "disclaimer": analysis_result.get("disclaimer"), | |
| "image_url": image_url, | |
| } | |
| def _build_patient_context( | |
| age: Optional[int], sex: Optional[str], | |
| is_known_diabetic: Optional[bool], diabetes_duration_years: Optional[int], | |
| ) -> Optional[dict]: | |
| """Collects optional patient demographics submitted alongside an image. | |
| None of our vision models take these as literal model inputs (they're | |
| pure image classifiers) — but age/sex/diabetes history materially affect | |
| clinical interpretation (e.g. DR risk scales with diabetes duration; | |
| melanoma risk rises with age), so we thread them into the Zafya AI | |
| assistant's clinician-facing text and into Gemini's foot-ulcer prompt. | |
| They're also stored with the analysis record to enrich future | |
| ZemedicLabel training data with real demographic metadata. | |
| """ | |
| if age is None and not sex and is_known_diabetic is None and diabetes_duration_years is None: | |
| return None | |
| return { | |
| "age": age, | |
| "sex": sex, | |
| "is_known_diabetic": is_known_diabetic, | |
| "diabetes_duration_years": diabetes_duration_years, | |
| } | |
| def _flag_for_review_async(modality: str, image_data: bytes, filename: str, analysis_result: dict): | |
| """Fire-and-forget: push low-confidence predictions to ZemedicLabel for | |
| human review, without blocking or risking the diagnostic response.""" | |
| import threading | |
| threading.Thread( | |
| target=label_bridge.maybe_flag_for_review, | |
| args=(modality, image_data, filename, analysis_result), | |
| daemon=True, | |
| ).start() | |
| # ---------------------------------------- | |
| # API Endpoints | |
| # ---------------------------------------- | |
| def health_check(): | |
| """Health check endpoint.""" | |
| return {"status": "ok", "timestamp": datetime.now().isoformat()} | |
| # Model readiness — warm models in a background thread so the server is | |
| # immediately ready. Models are cached on first load (lru_cache), so requests | |
| # that arrive before warmup finishes simply trigger a lazy load for that model. | |
| def _do_warmup(): | |
| logger.info("Warming up AI models (background)...") | |
| result = ai_models.warmup() | |
| logger.info("Model status: %s", result) | |
| def _warmup_models(): | |
| import threading | |
| threading.Thread(target=_do_warmup, daemon=True).start() | |
| def models_status(): | |
| """Reports which AI models are loaded and ready. Use before a live demo. | |
| Reads the live, incrementally-updated status (ready models show even while | |
| a slow one is still downloading).""" | |
| return { | |
| "models": ai_models.STATUS, | |
| "model_ids": { | |
| "xray": ai_models.XRAY_WEIGHTS, | |
| "skin": ai_models.SKIN_MODEL_ID, | |
| "retina": ai_models.RETINA_MODEL_ID, | |
| "foot": "gemini-1.5-flash" if ai_models.GEMINI_API_KEY else "not-configured", | |
| "breast": ai_models.BREAST_MODEL_ID, | |
| }, | |
| "model_source": ai_models.MODEL_SOURCE, | |
| } | |
| def reload_model(modality: str, model_path: str): | |
| """Hot-swap a ViT model to a locally fine-tuned checkpoint produced by | |
| training/finetune.py — the Label->MVP accuracy improvement loop. | |
| Usage: POST /api/models/reload?modality=skin&model_path=/abs/path/to/checkpoint""" | |
| try: | |
| return ai_models.reload_model(modality, model_path) | |
| except (ValueError, FileNotFoundError) as e: | |
| raise HTTPException(status_code=400, detail=str(e)) | |
| except Exception as e: # noqa: BLE001 | |
| raise HTTPException(status_code=500, detail=f"Reload failed: {e}") | |
| def active_learning_status(): | |
| """How many low-confidence predictions are queued in ZemedicLabel awaiting | |
| human review, per modality — the MVP -> Label side of the feedback loop.""" | |
| return label_bridge.queue_status() | |
| # Auth endpoints | |
| async def register(data: RegisterData): | |
| """Register a new user.""" | |
| # Demo mode for MongoDB unavailability | |
| if client is None or db is None: | |
| # Generate a demo user ID and token for testing | |
| user_id = str(uuid.uuid4()) | |
| access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) | |
| access_token = create_access_token( | |
| data={"sub": user_id}, expires_delta=access_token_expires | |
| ) | |
| return { | |
| "access_token": access_token, | |
| "token_type": "bearer", | |
| "user_id": user_id, | |
| "email": data.email, | |
| "name": data.name | |
| } | |
| # Normal flow with MongoDB | |
| # Check if email already exists | |
| existing_user = db.users.find_one({"email": data.email}) | |
| if existing_user: | |
| raise HTTPException( | |
| status_code=400, | |
| detail="Email already registered" | |
| ) | |
| # Create new user document | |
| hashed_password = get_password_hash(data.password) | |
| user_id = str(ObjectId()) | |
| user_data = { | |
| "_id": ObjectId(user_id), | |
| "email": data.email, | |
| "name": data.name, | |
| "hashed_password": hashed_password, | |
| "created_at": datetime.utcnow() | |
| } | |
| db.users.insert_one(user_data) | |
| # Create access token | |
| access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) | |
| access_token = create_access_token( | |
| data={"sub": user_id}, expires_delta=access_token_expires | |
| ) | |
| return { | |
| "access_token": access_token, | |
| "token_type": "bearer", | |
| "user_id": user_id, | |
| "email": data.email, | |
| "name": data.name | |
| } | |
| async def login(data: LoginData): | |
| """Login and get access token.""" | |
| # Demo mode for MongoDB unavailability | |
| if client is None or db is None: | |
| # Generate a demo user ID and token for testing | |
| user_id = str(uuid.uuid4()) | |
| access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) | |
| access_token = create_access_token( | |
| data={"sub": user_id}, expires_delta=access_token_expires | |
| ) | |
| return { | |
| "access_token": access_token, | |
| "token_type": "bearer", | |
| "user_id": user_id, | |
| "email": data.email, | |
| "name": "Demo User" | |
| } | |
| # Normal flow with MongoDB | |
| user = authenticate_user(data.email, data.password) | |
| if not user: | |
| raise HTTPException( | |
| status_code=401, | |
| detail="Incorrect email or password", | |
| headers={"WWW-Authenticate": "Bearer"}, | |
| ) | |
| access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) | |
| access_token = create_access_token( | |
| data={"sub": user.id}, expires_delta=access_token_expires | |
| ) | |
| return { | |
| "access_token": access_token, | |
| "token_type": "bearer", | |
| "user_id": user.id, | |
| "email": user.email, | |
| "name": user.name | |
| } | |
| # Analysis endpoints | |
| async def analyze_xray( | |
| file: UploadFile = File(...), | |
| age: Optional[int] = Form(None), | |
| sex: Optional[str] = Form(None), | |
| is_known_diabetic: Optional[bool] = Form(None), | |
| diabetes_duration_years: Optional[int] = Form(None), | |
| current_user: User = Depends(get_current_user) | |
| ): | |
| """Analyze a chest X-ray image.""" | |
| if not file.content_type.startswith("image/"): | |
| raise HTTPException( | |
| status_code=400, | |
| detail="File must be an image" | |
| ) | |
| # Read the image file | |
| image_data = await file.read() | |
| patient_context = _build_patient_context(age, sex, is_known_diabetic, diabetes_duration_years) | |
| # Analyze the image | |
| analysis_result = analyze_xray_image(image_data, patient_context) | |
| # Generate a unique ID | |
| analysis_id = str(uuid.uuid4()) | |
| # Save the image if possible | |
| try: | |
| file_extension = file.filename.split(".")[-1] | |
| unique_filename = f"{uuid.uuid4()}.{file_extension}" | |
| file_path = UPLOAD_DIR / unique_filename | |
| with open(file_path, "wb") as f: | |
| f.write(image_data) | |
| image_url = f"/uploads/{unique_filename}" | |
| except Exception as e: | |
| logger.error(f"Error saving image: {str(e)}") | |
| # Use a placeholder if saving fails | |
| image_url = "https://images.unsplash.com/photo-1584555684040-bad07f46a21f" | |
| # Store in MongoDB if available | |
| if client is not None and db is not None: | |
| try: | |
| analysis_doc = { | |
| "_id": ObjectId(analysis_id), | |
| "user_id": current_user.id, | |
| "type": "xray", | |
| "date": datetime.utcnow(), | |
| "image_url": image_url, | |
| "predictions": analysis_result["predictions"], | |
| "recommendations": analysis_result["recommendations"], | |
| "patient_context": patient_context, | |
| # Real region-of-interest if the model produced one; X-ray's | |
| # DenseNet backbone has no Grad-CAM wired yet, so this is None. | |
| "region_of_interest": analysis_result.get("region_of_interest"), | |
| } | |
| db.analyses.insert_one(analysis_doc) | |
| except Exception as e: | |
| logger.error(f"Error storing analysis in MongoDB: {str(e)}") | |
| _flag_for_review_async("xray", image_data, file.filename, analysis_result) | |
| # Return the result | |
| return _analysis_response(analysis_id, "xray", analysis_result, image_url) | |
| async def analyze_skin_lesion( | |
| file: UploadFile = File(...), | |
| age: Optional[int] = Form(None), | |
| sex: Optional[str] = Form(None), | |
| is_known_diabetic: Optional[bool] = Form(None), | |
| diabetes_duration_years: Optional[int] = Form(None), | |
| current_user: User = Depends(get_current_user) | |
| ): | |
| """Analyze a skin lesion image.""" | |
| if not file.content_type.startswith("image/"): | |
| raise HTTPException( | |
| status_code=400, | |
| detail="File must be an image" | |
| ) | |
| # Read the image file | |
| image_data = await file.read() | |
| patient_context = _build_patient_context(age, sex, is_known_diabetic, diabetes_duration_years) | |
| # Analyze the image | |
| analysis_result = analyze_skin_image(image_data, patient_context) | |
| # Generate a unique ID | |
| analysis_id = str(uuid.uuid4()) | |
| # Save the image if possible | |
| try: | |
| file_extension = file.filename.split(".")[-1] | |
| unique_filename = f"{uuid.uuid4()}.{file_extension}" | |
| file_path = UPLOAD_DIR / unique_filename | |
| with open(file_path, "wb") as f: | |
| f.write(image_data) | |
| image_url = f"/uploads/{unique_filename}" | |
| except Exception as e: | |
| logger.error(f"Error saving image: {str(e)}") | |
| # Use a placeholder if saving fails | |
| image_url = "https://images.unsplash.com/photo-1606501190025-f3ad6d3ea6ae" | |
| # Store in MongoDB if available | |
| if client is not None and db is not None: | |
| try: | |
| analysis_doc = { | |
| "_id": ObjectId(analysis_id), | |
| "user_id": current_user.id, | |
| "type": "skin", | |
| "date": datetime.utcnow(), | |
| "image_url": image_url, | |
| "predictions": analysis_result["predictions"], | |
| "recommendations": analysis_result["recommendations"], | |
| "patient_context": patient_context, | |
| "region_of_interest": analysis_result.get("region_of_interest"), | |
| } | |
| db.analyses.insert_one(analysis_doc) | |
| except Exception as e: | |
| logger.error(f"Error storing analysis in MongoDB: {str(e)}") | |
| _flag_for_review_async("skin", image_data, file.filename, analysis_result) | |
| # Return the result | |
| return _analysis_response(analysis_id, "skin", analysis_result, image_url) | |
| async def analyze_ct_scan_image( | |
| file: UploadFile = File(...), | |
| age: Optional[int] = Form(None), | |
| sex: Optional[str] = Form(None), | |
| is_known_diabetic: Optional[bool] = Form(None), | |
| diabetes_duration_years: Optional[int] = Form(None), | |
| current_user: User = Depends(get_current_user) | |
| ): | |
| """Analyze a Ultrasound image.""" | |
| if not file.content_type.startswith("image/"): | |
| raise HTTPException( | |
| status_code=400, | |
| detail="File must be an image" | |
| ) | |
| # Read the image file | |
| image_data = await file.read() | |
| patient_context = _build_patient_context(age, sex, is_known_diabetic, diabetes_duration_years) | |
| # Analyze the image | |
| analysis_result = analyze_ct_scan(image_data, patient_context) | |
| # Generate a unique ID | |
| analysis_id = str(uuid.uuid4()) | |
| # Save the image if possible | |
| try: | |
| file_extension = file.filename.split(".")[-1] | |
| unique_filename = f"{uuid.uuid4()}.{file_extension}" | |
| file_path = UPLOAD_DIR / unique_filename | |
| with open(file_path, "wb") as f: | |
| f.write(image_data) | |
| image_url = f"/uploads/{unique_filename}" | |
| except Exception as e: | |
| logger.error(f"Error saving image: {str(e)}") | |
| # Use a placeholder if saving fails | |
| image_url = "https://images.unsplash.com/photo-1631563019676-dade0dbdb8fc" | |
| # Store in MongoDB if available | |
| if client is not None and db is not None: | |
| try: | |
| analysis_doc = { | |
| "_id": ObjectId(analysis_id), | |
| "user_id": current_user.id, | |
| "type": "ct-scan", | |
| "date": datetime.utcnow(), | |
| "image_url": image_url, | |
| "predictions": analysis_result["predictions"], | |
| "recommendations": analysis_result["recommendations"], | |
| "patient_context": patient_context, | |
| "region_of_interest": analysis_result.get("region_of_interest"), | |
| } | |
| db.analyses.insert_one(analysis_doc) | |
| except Exception as e: | |
| logger.error(f"Error storing analysis in MongoDB: {str(e)}") | |
| _flag_for_review_async("breast", image_data, file.filename, analysis_result) | |
| # Return the result | |
| return _analysis_response(analysis_id, "ultrasound", analysis_result, image_url) | |
| async def analyze_breast( | |
| file: UploadFile = File(...), | |
| age: Optional[int] = Form(None), | |
| sex: Optional[str] = Form(None), | |
| is_known_diabetic: Optional[bool] = Form(None), | |
| diabetes_duration_years: Optional[int] = Form(None), | |
| current_user: User = Depends(get_current_user) | |
| ): | |
| """Analyze a breast ultrasound image (BUSI: benign / malignant / normal).""" | |
| if not file.content_type.startswith("image/"): | |
| raise HTTPException(status_code=400, detail="File must be an image") | |
| image_data = await file.read() | |
| patient_context = _build_patient_context(age, sex, is_known_diabetic, diabetes_duration_years) | |
| analysis_result = analyze_breast_image(image_data, patient_context) | |
| analysis_id = str(uuid.uuid4()) | |
| try: | |
| file_extension = file.filename.split(".")[-1] | |
| unique_filename = f"{uuid.uuid4()}.{file_extension}" | |
| file_path = UPLOAD_DIR / unique_filename | |
| with open(file_path, "wb") as f: | |
| f.write(image_data) | |
| image_url = f"/uploads/{unique_filename}" | |
| except Exception as e: | |
| logger.error(f"Error saving image: {str(e)}") | |
| image_url = "https://images.unsplash.com/photo-1631563019676-dade0dbdb8fc" | |
| if client is not None and db is not None: | |
| try: | |
| db.analyses.insert_one({ | |
| "_id": ObjectId(analysis_id), | |
| "user_id": current_user.id, | |
| "type": "breast", | |
| "date": datetime.utcnow(), | |
| "image_url": image_url, | |
| "predictions": analysis_result.get("predictions", []), | |
| "recommendations": analysis_result.get("recommendations", []), | |
| "patient_context": patient_context, | |
| "region_of_interest": analysis_result.get("region_of_interest"), | |
| }) | |
| except Exception as e: | |
| logger.error(f"Error storing analysis in MongoDB: {str(e)}") | |
| _flag_for_review_async("breast", image_data, file.filename, analysis_result) | |
| return _analysis_response(analysis_id, "breast", analysis_result, image_url) | |
| async def analyze_retina( | |
| file: UploadFile = File(...), | |
| age: Optional[int] = Form(None), | |
| sex: Optional[str] = Form(None), | |
| is_known_diabetic: Optional[bool] = Form(None), | |
| diabetes_duration_years: Optional[int] = Form(None), | |
| current_user: User = Depends(get_current_user), | |
| ): | |
| """Diabetic retinopathy grading from fundus photography. | |
| Uses a ViT fine-tuned on APTOS 2019 / EyePACS (5-class DR grading: | |
| No DR → Proliferative DR). Returns structured predictions + dual-language | |
| clinician / patient assistant output. Age/diabetes-duration are clinically | |
| relevant here — DR risk and severity correlate strongly with how long a | |
| patient has had diabetes — so they're captured and passed to the assistant.""" | |
| if not file.content_type.startswith("image/"): | |
| raise HTTPException(status_code=400, detail="File must be an image") | |
| image_data = await file.read() | |
| patient_context = _build_patient_context(age, sex, is_known_diabetic, diabetes_duration_years) | |
| analysis_result = ai_models.analyze_retina_image(image_data, patient_context) | |
| analysis_id = str(uuid.uuid4()) | |
| try: | |
| ext = file.filename.rsplit(".", 1)[-1] | |
| unique_filename = f"{uuid.uuid4()}.{ext}" | |
| with open(UPLOAD_DIR / unique_filename, "wb") as f: | |
| f.write(image_data) | |
| image_url = f"/uploads/{unique_filename}" | |
| except Exception as e: | |
| logger.error("Error saving retina image: %s", e) | |
| image_url = "" | |
| if client is not None and db is not None: | |
| try: | |
| db.analyses.insert_one({ | |
| "_id": ObjectId(analysis_id), | |
| "user_id": current_user.id, | |
| "type": "retina", | |
| "date": datetime.utcnow(), | |
| "image_url": image_url, | |
| "predictions": analysis_result.get("predictions", []), | |
| "recommendations": analysis_result.get("recommendations", []), | |
| "patient_context": patient_context, | |
| "region_of_interest": analysis_result.get("region_of_interest"), | |
| }) | |
| except Exception as e: | |
| logger.error("MongoDB insert failed (retina): %s", e) | |
| _flag_for_review_async("retina", image_data, file.filename, analysis_result) | |
| return _analysis_response(analysis_id, "retina", analysis_result, image_url) | |
| async def analyze_foot( | |
| file: UploadFile = File(...), | |
| age: Optional[int] = Form(None), | |
| sex: Optional[str] = Form(None), | |
| is_known_diabetic: Optional[bool] = Form(None), | |
| diabetes_duration_years: Optional[int] = Form(None), | |
| current_user: User = Depends(get_current_user), | |
| ): | |
| """Diabetic foot ulcer classification from clinical photography. | |
| Uses Gemini 1.5 Flash vision for wound classification (normal | DFU | | |
| infected | neuropathic | ischemic | callus | healed | other). | |
| Requires GEMINI_API_KEY in the backend .env. Diabetes duration is | |
| clinically relevant to foot ulcer risk, so it's passed into the Gemini | |
| prompt as patient context alongside the image.""" | |
| if not file.content_type.startswith("image/"): | |
| raise HTTPException(status_code=400, detail="File must be an image") | |
| image_data = await file.read() | |
| patient_context = _build_patient_context(age, sex, is_known_diabetic, diabetes_duration_years) | |
| analysis_result = ai_models.analyze_foot_image(image_data, patient_context) | |
| analysis_id = str(uuid.uuid4()) | |
| try: | |
| ext = file.filename.rsplit(".", 1)[-1] | |
| unique_filename = f"{uuid.uuid4()}.{ext}" | |
| with open(UPLOAD_DIR / unique_filename, "wb") as f: | |
| f.write(image_data) | |
| image_url = f"/uploads/{unique_filename}" | |
| except Exception as e: | |
| logger.error("Error saving foot image: %s", e) | |
| image_url = "" | |
| if client is not None and db is not None: | |
| try: | |
| db.analyses.insert_one({ | |
| "_id": ObjectId(analysis_id), | |
| "user_id": current_user.id, | |
| "type": "foot", | |
| "date": datetime.utcnow(), | |
| "image_url": image_url, | |
| "predictions": analysis_result.get("predictions", []), | |
| "recommendations": analysis_result.get("recommendations", []), | |
| "patient_context": patient_context, | |
| "region_of_interest": analysis_result.get("region_of_interest"), | |
| }) | |
| except Exception as e: | |
| logger.error("MongoDB insert failed (foot): %s", e) | |
| _flag_for_review_async("foot", image_data, file.filename, analysis_result) | |
| return _analysis_response(analysis_id, "foot", analysis_result, image_url) | |
| # ---------------------------------------- | |
| # Zafya AI — conversational follow-up assistant | |
| # ---------------------------------------- | |
| import zafya_chat # noqa: E402 | |
| class ZafyaChatTurn(BaseModel): | |
| role: str # "user" | "assistant" | |
| content: str | |
| class ZafyaChatRequest(BaseModel): | |
| message: str | |
| history: List[ZafyaChatTurn] = [] | |
| audience: str = "patient" # "doctor" | "patient" | |
| context: Optional[dict] = None # {modality, top_finding, severity, risk, age, sex} | |
| async def zafya_chat_endpoint( | |
| payload: ZafyaChatRequest, | |
| current_user: User = Depends(get_current_user), | |
| ): | |
| """Follow-up conversational Q&A on a screening result, tailored to the | |
| asker (doctor gets clinical register, patient gets plain-language register). | |
| Powered by Gemini; distinct from ZafyaLM (the offline on-device model).""" | |
| history = [turn.dict() for turn in payload.history] | |
| result = zafya_chat.chat(payload.message, history, payload.audience, payload.context) | |
| return result | |
| async def get_user_history( | |
| current_user: User = Depends(get_current_user) | |
| ): | |
| """Get user's analysis history.""" | |
| # Demo mode for MongoDB unavailability | |
| if client is None or db is None: | |
| # Return demo data | |
| demo_history = [ | |
| { | |
| "id": "demo1", | |
| "user_id": current_user.id, | |
| "type": "xray", | |
| "date": datetime.utcnow() - timedelta(days=2), | |
| "findings": ["Pneumonia", "Pleural Effusion"], | |
| "image_url": "https://images.unsplash.com/photo-1584555684040-bad07f46a21f", | |
| "predictions": [ | |
| {"label": "Pneumonia", "confidence": 0.82}, | |
| {"label": "Pleural Effusion", "confidence": 0.67} | |
| ] | |
| }, | |
| { | |
| "id": "demo2", | |
| "user_id": current_user.id, | |
| "type": "skin", | |
| "date": datetime.utcnow() - timedelta(days=5), | |
| "findings": ["Melanoma"], | |
| "image_url": "https://images.unsplash.com/photo-1606501190025-f3ad6d3ea6ae", | |
| "predictions": [ | |
| {"label": "Melanoma", "confidence": 0.75} | |
| ] | |
| }, | |
| { | |
| "id": "demo3", | |
| "user_id": current_user.id, | |
| "type": "ct-scan", | |
| "date": datetime.utcnow() - timedelta(days=7), | |
| "findings": ["Brain Tumor"], | |
| "image_url": "https://images.unsplash.com/photo-1631563019676-dade0dbdb8fc", | |
| "predictions": [ | |
| {"label": "Brain Tumor", "confidence": 0.68} | |
| ] | |
| } | |
| ] | |
| return demo_history | |
| # Normal flow with MongoDB | |
| try: | |
| analyses = db.analyses.find({"user_id": current_user.id}).sort("date", -1) | |
| result = [] | |
| for analysis in analyses: | |
| analysis["id"] = str(analysis["_id"]) | |
| del analysis["_id"] | |
| result.append(analysis) | |
| return result | |
| except Exception as e: | |
| logger.error(f"Error retrieving history: {str(e)}") | |
| return [] | |
| async def get_analysis_by_id( | |
| analysis_id: str, | |
| current_user: User = Depends(get_current_user) | |
| ): | |
| """Get a specific analysis by ID.""" | |
| # Demo mode for MongoDB unavailability | |
| if client is None or db is None or analysis_id.startswith("demo"): | |
| # Return demo data | |
| if analysis_id == "demo1": | |
| return { | |
| "id": "demo1", | |
| "user_id": current_user.id, | |
| "type": "xray", | |
| "date": datetime.utcnow() - timedelta(days=2), | |
| "image_url": "https://images.unsplash.com/photo-1584555684040-bad07f46a21f", | |
| "predictions": [ | |
| {"label": "Pneumonia", "confidence": 0.82}, | |
| {"label": "Pleural Effusion", "confidence": 0.67} | |
| ], | |
| "recommendations": [ | |
| "Consult with a healthcare professional for proper diagnosis", | |
| "Consider follow-up imaging to monitor any changes", | |
| "Complete the full course of prescribed antibiotics", | |
| "Rest and hydrate properly" | |
| ] | |
| } | |
| elif analysis_id == "demo2": | |
| return { | |
| "id": "demo2", | |
| "user_id": current_user.id, | |
| "type": "skin", | |
| "date": datetime.utcnow() - timedelta(days=5), | |
| "image_url": "https://images.unsplash.com/photo-1606501190025-f3ad6d3ea6ae", | |
| "predictions": [ | |
| {"label": "Melanoma", "confidence": 0.75}, | |
| {"label": "Seborrheic Keratosis", "confidence": 0.15} | |
| ], | |
| "recommendations": [ | |
| "Schedule a follow-up with a dermatologist", | |
| "Protect your skin from sun exposure", | |
| "Monitor any changes in size, shape, or color of the lesion", | |
| "Apply prescribed topical treatments as directed" | |
| ] | |
| } | |
| elif analysis_id == "demo3": | |
| return { | |
| "id": "demo3", | |
| "user_id": current_user.id, | |
| "type": "ct-scan", | |
| "date": datetime.utcnow() - timedelta(days=7), | |
| "image_url": "https://images.unsplash.com/photo-1631563019676-dade0dbdb8fc", | |
| "predictions": [ | |
| {"label": "Brain Tumor", "confidence": 0.68}, | |
| {"label": "Normal Findings", "confidence": 0.22} | |
| ], | |
| "recommendations": [ | |
| "Immediate neurosurgery consultation", | |
| "MRI with contrast for further characterization", | |
| "Discuss biopsy options if appropriate", | |
| "Consider second opinion from neuro-oncologist" | |
| ] | |
| } | |
| else: | |
| raise HTTPException( | |
| status_code=404, | |
| detail="Analysis not found" | |
| ) | |
| # Normal flow with MongoDB | |
| try: | |
| analysis = db.analyses.find_one({"_id": ObjectId(analysis_id)}) | |
| if not analysis: | |
| raise HTTPException( | |
| status_code=404, | |
| detail="Analysis not found" | |
| ) | |
| if analysis["user_id"] != current_user.id: | |
| raise HTTPException( | |
| status_code=403, | |
| detail="Not authorized to access this analysis" | |
| ) | |
| analysis["id"] = str(analysis["_id"]) | |
| del analysis["_id"] | |
| return analysis | |
| except Exception as e: | |
| logger.error(f"Error retrieving analysis: {str(e)}") | |
| raise HTTPException( | |
| status_code=500, | |
| detail=f"Error retrieving analysis: {str(e)}" | |
| ) | |
| async def get_user_profile( | |
| current_user: User = Depends(get_current_user) | |
| ): | |
| """Get user's profile information.""" | |
| return { | |
| "id": current_user.id, | |
| "email": current_user.email, | |
| "name": current_user.name, | |
| "created_at": current_user.created_at | |
| } | |
| async def update_user_profile( | |
| name: str = Form(None), | |
| current_user: UserInDB = Depends(get_current_user) | |
| ): | |
| """Update user's profile information.""" | |
| updates = {} | |
| if name: | |
| updates["name"] = name | |
| if updates: | |
| db.users.update_one( | |
| {"_id": ObjectId(current_user.id)}, | |
| {"$set": updates} | |
| ) | |
| updated_user = db.users.find_one({"_id": ObjectId(current_user.id)}) | |
| updated_user["id"] = str(updated_user["_id"]) | |
| del updated_user["_id"] | |
| del updated_user["hashed_password"] | |
| return updated_user | |
| if __name__ == "__main__": | |
| import uvicorn | |
| # Run the FastAPI app | |
| uvicorn.run("server:app", host="0.0.0.0", port=8001, reload=True) | |