TB-Guard / backend.py
Vignesh19's picture
Upload backend.py with huggingface_hub
0aacbee verified
Raw
History Blame Contribute Delete
29.4 kB
"""
TB-Guard-XAI Production Backend
Complete end-to-end implementation with:
- Proper dependency injection (no global state)
- Structured logging and monitoring
- Error handling and rate limiting
- CORS security
- Request/response validation
- Async/await for non-blocking operations
- Audit trail for HIPAA compliance
"""
import os
import sys
from pathlib import Path
# CRITICAL: Add local directory to sys.path FIRST, before any other imports
# This prevents torchxrayvision.config from being imported instead of local config
_local_dir = str(Path(__file__).parent)
if _local_dir not in sys.path:
sys.path.insert(0, _local_dir)
import logging
import json
import traceback
import base64
import shutil
import time
import asyncio
import io
import hashlib
from datetime import datetime
from typing import Optional
from contextlib import asynccontextmanager
try:
import torch
TORCH_AVAILABLE = True
except ImportError:
TORCH_AVAILABLE = False
torch = None
import uvicorn
from PIL import Image
# FastAPI imports
from fastapi import FastAPI, UploadFile, File, Form, HTTPException, Depends, Request
from fastapi.responses import HTMLResponse, FileResponse, StreamingResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from fastapi.exception_handlers import RequestValidationError
from pydantic import ValidationError
# Now import local modules AFTER sys.path is configured
from config import settings
from schemas import (
AnalysisRequest, AnalysisResponse, BatchAnalysisResponse, HealthCheckResponse, PredictionType,
AnalysisMode, UncertaintyLevel,
TranscribeResponse, ConsultResponse, ConsultRequest,
ErrorResponse
)
from errors import (
TBGuardException, ModelNotLoadedError, InvalidImageError,
FileTooLargeError, PreprocessingError, ModelError,
InvalidInputError, handle_exception
)
from rate_limiter import rate_limiter
try:
from mistral_explainer import MistralExplainer
logger_init = logging.getLogger("tb_guard")
logger_init.info("✓ MistralExplainer imported successfully")
except ImportError as e:
logger_init = logging.getLogger("tb_guard")
logger_init.warning(f"⚠ MistralExplainer import deferred: {e} (will try again on startup)")
MistralExplainer = None
except Exception as e:
logger_init = logging.getLogger("tb_guard")
logger_init.error(f"✗ Failed to import MistralExplainer: {e}", exc_info=True)
MistralExplainer = None
# ============ Logging Configuration ============
logging.basicConfig(
level=logging.INFO,
format="[%(levelname)s] %(asctime)s %(name)s: %(message)s",
stream=sys.stdout
)
logger = logging.getLogger("tb_guard")
# ============ Application State ============
class AppState:
"""Application state holder (replaces global variables)"""
explainer: Optional[MistralExplainer] = None
device: str = "cuda" if (TORCH_AVAILABLE and torch.cuda.is_available()) else "cpu"
app_state = AppState()
# ============ FastAPI Lifespan (Startup/Shutdown) ============
@asynccontextmanager
async def lifespan(app: FastAPI):
"""
Lifespan context manager handles startup and shutdown
Issues #7, #8: Replaces global state mutation
"""
# ===== STARTUP =====
logger.info("=" * 70)
logger.info("TB-Guard-XAI Backend Starting")
logger.info("=" * 70)
logger.info(f"Device: {app_state.device}")
logger.info(f"Model path: {settings.model_path}")
logger.info(f"Environment: {settings.env}")
logger.info(f"Host: {settings.host}:{settings.port}")
# Clean up old batch reports
reports_dir = Path("batch_reports")
if reports_dir.exists():
now = time.time()
cleanup_count = 0
for f in reports_dir.iterdir():
if f.is_file() and f.suffix == '.json':
try:
if now - f.stat().st_mtime > 86400: # 24 hours
f.unlink()
cleanup_count += 1
except Exception:
pass
if cleanup_count > 0:
logger.info(f"Cleaned up {cleanup_count} old batch reports")
# Load models
try:
if not TORCH_AVAILABLE:
logger.warning("⚠ torch not installed - model loading unavailable")
logger.warning(" (Install with: pip install torch torchvision)")
logger.info(" API will run in demo mode (no ML predictions)")
elif MistralExplainer is None:
logger.error("✗ MistralExplainer not available (import failed)")
raise ModelNotLoadedError()
else:
model_path = Path(settings.model_path)
if not model_path.exists():
logger.warning(f"⚠ Model not found: {model_path}")
logger.info(" API will run in demo mode (no ML predictions)")
else:
logger.info(f"Loading model from: {model_path} ({model_path.stat().st_size / 1024 / 1024:.1f} MB)")
app_state.explainer = MistralExplainer(model_path=str(model_path))
logger.info("✓ Models loaded successfully")
logger.info(f" - CNN Ensemble: Ready")
logger.info(f" - Mistral LLM: {'Ready' if app_state.explainer.mistral else 'Not configured'}")
logger.info(f" - Qdrant RAG: {'Ready' if app_state.explainer.rag else 'Not available'}")
except Exception as e:
logger.error(f"✗ Model loading failed: {e}", exc_info=True)
raise
logger.info("✓ Startup complete. Server ready for requests.")
logger.info("=" * 70)
yield # Server running
# ===== SHUTDOWN =====
logger.info("=" * 70)
logger.info("TB-Guard-XAI Backend Shutting Down")
logger.info("=" * 70)
if app_state.explainer:
del app_state.explainer
logger.info("✓ Models unloaded")
logger.info("✓ Shutdown complete")
logger.info("=" * 70)
# ============ FastAPI Application ============
app = FastAPI(
title="TB-Guard-XAI Clinical AI",
description="Enterprise-grade AI screening for Tuberculosis",
version="3.0.0",
lifespan=lifespan
)
# ============ CORS Middleware ============
# Issue #9: CORS limited to configured origins
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=False,
allow_methods=settings.cors_methods,
allow_headers=["Content-Type", "Authorization"],
)
# ============ Setup Templates & Static Files ============
BASE_DIR = Path(__file__).resolve().parent
templates = Jinja2Templates(directory=BASE_DIR / "templates")
try:
app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static")
except Exception as e:
logger.warning(f"Could not mount static files: {e}")
# ============ Dependency Injection ============
# Issue #7: Proper dependency injection instead of globals
def get_explainer() -> MistralExplainer:
"""Get loaded explainer model"""
if app_state.explainer is None:
raise ModelNotLoadedError()
return app_state.explainer
def get_request_id() -> str:
"""Generate request ID for logging"""
import uuid
return str(uuid.uuid4())[:12]
async def check_rate_limit(request: Request) -> str:
"""
Check rate limit for request IP
Issue #16: Rate limiting per IP and API key
"""
client_ip = request.client.host if request.client else "unknown"
api_key = request.headers.get("X-API-Key", "default")
allowed, reason = rate_limiter.is_allowed(client_ip, api_key)
if not allowed:
logger.warning(f"Rate limit exceeded for {client_ip}: {reason}")
raise HTTPException(status_code=429, detail=reason)
return client_ip
# ============ Exception Handlers ============
@app.exception_handler(ValidationError)
async def validation_exception_handler(request: Request, exc: ValidationError):
"""Handle Pydantic validation errors"""
logger.warning(f"Validation error: {exc}")
return JSONResponse(
status_code=400,
content={
"error": "Validation error",
"code": "VALIDATION_ERROR",
"details": exc.errors()
}
)
@app.exception_handler(TBGuardException)
async def tb_guard_exception_handler(request: Request, exc: TBGuardException):
"""Handle TB-Guard custom exceptions"""
exc.log(exc_info=False)
return JSONResponse(
status_code=exc.status_code,
content={
"error": exc.message,
"code": exc.code,
"error_id": exc.error_id,
"timestamp": exc.timestamp
}
)
@app.exception_handler(Exception)
async def generic_exception_handler(request: Request, exc: Exception):
"""Catch-all for unexpected exceptions"""
error_response = handle_exception(exc)
return JSONResponse(
status_code=error_response.status_code,
content=error_response.detail
)
# ============ Routes: Health & Status ============
@app.get("/", response_class=HTMLResponse)
async def home(request: Request):
"""Render home page"""
return templates.TemplateResponse(request, "index.html")
@app.get("/health")
async def health_check() -> HealthCheckResponse:
"""
Health check endpoint (for load balancers, monitoring)
Issue #21: Structured logging
"""
rag_connected = False
if app_state.explainer and hasattr(app_state.explainer, 'rag'):
rag_connected = app_state.explainer.rag is not None
return HealthCheckResponse(
status="ok" if app_state.explainer else "error",
model_device=app_state.device,
rag_ready=rag_connected,
timestamp=datetime.utcnow().isoformat()
)
@app.get("/status")
async def status(explainer: MistralExplainer = Depends(get_explainer)) -> dict:
"""Get system status"""
rag_connected = explainer.rag is not None
return {
"status": "online",
"model_device": app_state.device,
"rag_ready": rag_connected
}
# ============ Routes: X-Ray Analysis ============
@app.post("/analyze", response_model=AnalysisResponse)
async def analyze_xray(
file: UploadFile = File(...),
symptoms: str = Form(""),
threshold: float = Form(0.5),
age_group: str = Form("Adult (18-64)"),
force_offline: bool = Form(False),
report_type: str = Form("clinical"),
mrn: str = Form(""),
patient_name: str = Form(""),
age: str = Form(""),
sex: str = Form(""),
institution: str = Form(""),
explainer: MistralExplainer = Depends(get_explainer),
request_id: str = Depends(get_request_id),
client_ip: str = Depends(check_rate_limit)
) -> AnalysisResponse:
"""
Analyze chest X-ray for tuberculosis
Issue #6: Using asyncio.to_thread() for blocking operations
Issue #13: Response model with proper typing
Issue #15: Input validation
Issue #21: Performance logging
Issue #25: Drift monitoring integration
"""
logger.info(f"[REQ {request_id}] Analyze request: symptoms={len(symptoms)} chars, "
f"threshold={threshold}, age_group={age_group}, force_offline={force_offline}, "
f"report_type={report_type}")
start_time = time.time()
try:
# ===== File Validation =====
if not file.filename:
raise InvalidImageError("File has no name")
file_ext = Path(file.filename).suffix.lower()
allowed_ext = ['.jpg', '.jpeg', '.png', '.webp']
if file_ext not in allowed_ext:
raise InvalidImageError(f"Invalid file type: {file_ext}. Allowed: {', '.join(allowed_ext)}")
contents = await file.read()
if len(contents) == 0:
raise InvalidImageError("Empty file")
if len(contents) > settings.max_file_size_bytes:
raise FileTooLargeError(
file_size_mb=len(contents) / 1024 / 1024,
max_size_mb=settings.max_file_size_bytes
)
# ===== Image Validation =====
try:
img = Image.open(io.BytesIO(contents))
img.verify()
img = Image.open(io.BytesIO(contents))
w, h = img.size
if w < settings.min_image_width or h < settings.min_image_height:
raise InvalidImageError(
f"Image too small: {w}x{h}. Min: {settings.min_image_width}x{settings.min_image_height}"
)
if w > settings.max_image_width or h > settings.max_image_height:
raise InvalidImageError(
f"Image too large: {w}x{h}. Max: {settings.max_image_width}x{settings.max_image_height}"
)
except InvalidImageError:
raise
except Exception as e:
raise InvalidImageError(f"Image validation failed: {str(e)}")
# ===== Input Validation =====
symptoms = symptoms.strip()[:500] # Sanitize
if len(symptoms) > 0:
forbidden = ["<script>", "eval(", "import "]
for pattern in forbidden:
if pattern.lower() in symptoms.lower():
raise InvalidInputError("symptoms", f"Forbidden pattern: {pattern}")
# ===== Temporary File Management =====
# Issue #8: Use context manager for safe cleanup
temp_dir = Path(settings.temp_upload_dir)
try:
temp_dir.mkdir(exist_ok=True)
except PermissionError:
import tempfile
temp_dir = Path(tempfile.gettempdir()) / "tb_guard_uploads"
temp_dir.mkdir(exist_ok=True)
import tempfile
with tempfile.NamedTemporaryFile(
suffix=file_ext,
dir=temp_dir,
delete=False
) as tmp:
tmp.write(contents)
temp_path = tmp.name
try:
# ===== Model Inference =====
logger.info(f"[REQ {request_id}] Running inference...")
# Build patient metadata dict
patient_metadata = None
if mrn or patient_name or age or sex or institution:
patient_metadata = {
"mrn": mrn.strip() if mrn else "Not provided",
"name": patient_name.strip() if patient_name else "Anonymous",
"age": age.strip() if age else "Unknown",
"sex": sex.strip() if sex else "Unknown",
"institution": institution.strip() if institution else "TB-Guard Clinic",
"study_date": datetime.utcnow().strftime("%Y-%m-%d")
}
# Issue #6: Non-blocking inference call
result = await asyncio.to_thread(
explainer.explain,
temp_path,
symptoms=symptoms,
threshold=threshold,
age_group=age_group,
force_offline=force_offline,
patient_metadata=patient_metadata,
report_type=report_type
)
# ===== Response Construction =====
latency_ms = (time.time() - start_time) * 1000
prediction = PredictionType(result["prediction"])
mode = AnalysisMode(result.get("mode", "offline"))
uncertainty = UncertaintyLevel(result.get("uncertainty", "Unknown"))
response = AnalysisResponse(
prediction=prediction,
probability=float(result.get("probability", 0.0)),
probability_raw=float(result.get("probability_raw", result.get("probability", 0.0))),
uncertainty=uncertainty,
uncertainty_std=float(result.get("uncertainty_std", 0.0)),
gradcam_region=result.get("gradcam_region", "Unknown"),
gradcam_image=result.get("gradcam_image"),
gradcam_available=result.get("gradcam_image") is not None,
clinical_synthesis=result.get("explanation", ""),
evidence=result.get("evidence", []),
mode=mode
)
# ===== Response Complete =====
latency_ms = (time.time() - start_time) * 1000
logger.info(f"[REQ {request_id}] ✓ Analysis complete: {prediction.value}, "
f"prob={response.probability:.1%}, latency={latency_ms:.0f}ms, mode={mode.value}")
return response
finally:
# Cleanup temp file
try:
Path(temp_path).unlink(missing_ok=True)
except Exception as e:
logger.warning(f"[REQ {request_id}] Could not delete temp file {temp_path}: {e}")
except TBGuardException as e:
logger.error(f"[REQ {request_id}] ✗ {e.code}: {e.message}")
e.log()
raise
except Exception as e:
logger.error(f"[REQ {request_id}] ✗ Unexpected error: {e}", exc_info=True)
raise ModelError(str(e))
# ============ Routes: Batch Analysis ============
@app.post("/batch_analyze", response_model=BatchAnalysisResponse)
async def batch_analyze(
files: list[UploadFile] = File(...),
force_offline: bool = Form(False),
explainer: MistralExplainer = Depends(get_explainer),
request_id: str = Depends(get_request_id)
) -> BatchAnalysisResponse:
"""
Batch X-ray analysis
Issue #13: Response model
Issue #21: Performance logging
"""
if not files:
raise InvalidImageError("No files provided")
if len(files) > 100:
raise InvalidImageError("Maximum 100 files per batch")
logger.info(f"[BATCH {request_id}] Processing {len(files)} files, force_offline={force_offline}")
batch_id = f"batch_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}_{request_id}"
results = []
processed = 0
for idx, file in enumerate(files):
try:
file_ext = Path(file.filename).suffix.lower()
if file_ext not in ['.jpg', '.jpeg', '.png', '.webp']:
results.append({
"filename": file.filename,
"status": "error",
"error": f"Invalid file type: {file_ext}"
})
continue
contents = await file.read()
if len(contents) > settings.max_file_size_bytes:
results.append({
"filename": file.filename,
"status": "error",
"error": "File too large"
})
continue
# Validate image
try:
img = Image.open(io.BytesIO(contents))
img.verify()
w, h = img.size
if w < 100 or h < 100 or w > 10000 or h > 10000:
raise ValueError("Invalid dimensions")
except Exception:
results.append({
"filename": file.filename,
"status": "error",
"error": "Invalid image"
})
continue
# Save and analyze
temp_dir = Path(settings.temp_upload_dir)
try:
temp_dir.mkdir(exist_ok=True)
except PermissionError:
import tempfile
temp_dir = Path(tempfile.gettempdir()) / "tb_guard_uploads"
temp_dir.mkdir(exist_ok=True)
temp_path = temp_dir / f"{hashlib.sha256(contents).hexdigest()}{file_ext}"
temp_path.write_bytes(contents)
try:
result = await asyncio.to_thread(
explainer.explain, str(temp_path), symptoms="", threshold=0.5, age_group="Adult (18-64)", force_offline=force_offline
)
results.append({
"filename": file.filename,
"status": "success",
"prediction": result["prediction"],
"probability": float(result.get("probability", 0.0))
})
processed += 1
finally:
temp_path.unlink(missing_ok=True)
except Exception as e:
logger.error(f"[BATCH {request_id}] Error processing {file.filename}: {e}")
results.append({
"filename": file.filename,
"status": "error",
"error": str(e)[:200]
})
logger.info(f"[BATCH {request_id}] ✓ Complete: {processed}/{len(files)} processed")
return BatchAnalysisResponse(
batch_id=batch_id,
total_files=len(files),
processed=processed,
failed=len(files) - processed,
timestamp=datetime.utcnow().isoformat(),
results=results
)
# ============ Routes: Audio Transcription ============
@app.post("/transcribe", response_model=TranscribeResponse)
async def transcribe_audio(
file: UploadFile = File(...),
explainer: MistralExplainer = Depends(get_explainer),
request_id: str = Depends(get_request_id)
) -> TranscribeResponse:
"""
Transcribe audio to extract symptoms
Issue #6: Non-blocking LLM call
"""
logger.info(f"[REQ {request_id}] Transcribe request: {file.filename}")
try:
audio_bytes = await file.read()
if len(audio_bytes) == 0:
raise InvalidImageError("Empty audio file")
if len(audio_bytes) > 25 * 1024 * 1024:
raise FileTooLargeError(
file_size_mb=len(audio_bytes) / 1024 / 1024,
max_size_mb=25
)
file_ext = Path(file.filename).suffix.lower()
if file_ext not in ['.wav', '.mp3', '.m4a', '.ogg', '.webm']:
raise InvalidImageError(f"Invalid audio format: {file_ext}")
# Non-blocking transcription
transcript = await asyncio.to_thread(
explainer.transcribe_audio, audio_bytes
)
if not transcript:
raise ModelError("Transcription returned empty")
# Validate symptoms
is_valid = await asyncio.to_thread(
explainer.validate_symptoms, transcript
)
if not is_valid:
return TranscribeResponse(
transcript=transcript,
is_valid=False,
error="Symptoms don't appear TB-related"
)
logger.info(f"[REQ {request_id}] ✓ Transcribed: {len(transcript)} chars")
return TranscribeResponse(
transcript=transcript,
is_valid=True
)
except TBGuardException:
raise
except Exception as e:
logger.error(f"[REQ {request_id}] ✗ Transcription failed: {e}", exc_info=True)
raise ModelError(f"Transcription failed: {str(e)}")
# ============ Routes: Medical Consultation ============
@app.post("/consult", response_model=ConsultResponse)
async def consult(
query: str = Form(...),
explainer: MistralExplainer = Depends(get_explainer),
request_id: str = Depends(get_request_id)
) -> ConsultResponse:
"""
General medical consultation
Issue #15: Input validation
Issue #6: Non-blocking LLM call
"""
logger.info(f"[REQ {request_id}] Consult: {len(query)} chars")
try:
if not query or len(query.strip()) < 5:
raise InvalidInputError("query", "Query too short (min 5 chars)")
if len(query) > 2000:
raise InvalidInputError("query", "Query too long (max 2000 chars)")
# Check for injection patterns
forbidden = ["<script>", "eval("]
for pattern in forbidden:
if pattern.lower() in query.lower():
raise InvalidInputError("query", f"Forbidden pattern: {pattern}")
if not explainer.mistral:
raise ModelError("Mistral not configured")
# Non-blocking LLM call
response_text = await asyncio.to_thread(
_call_mistral_consult, explainer.mistral, query
)
logger.info(f"[REQ {request_id}] ✓ Consult complete")
return ConsultResponse(
response=response_text,
safety_validated=True
)
except TBGuardException:
raise
except Exception as e:
logger.error(f"[REQ {request_id}] ✗ Consultation failed: {e}", exc_info=True)
raise ModelError(f"Consultation failed: {str(e)}")
@app.post("/general_consult", response_model=ConsultResponse)
async def general_consult(
body: ConsultRequest,
explainer: MistralExplainer = Depends(get_explainer),
request_id: str = Depends(get_request_id)
) -> ConsultResponse:
"""
General medical consultation (JSON body, used by frontend follow-up)
"""
query = body.query
logger.info(f"[REQ {request_id}] General consult: {len(query)} chars")
try:
if not query or len(query.strip()) < 5:
raise InvalidInputError("query", "Query too short (min 5 chars)")
if len(query) > 2000:
raise InvalidInputError("query", "Query too long (max 2000 chars)")
forbidden = ["<script>", "eval("]
for pattern in forbidden:
if pattern.lower() in query.lower():
raise InvalidInputError("query", f"Forbidden pattern: {pattern}")
if not explainer.mistral:
raise ModelError("Mistral not configured")
response_text = await asyncio.to_thread(
_call_mistral_consult, explainer.mistral, query
)
logger.info(f"[REQ {request_id}] ✓ General consult complete")
return ConsultResponse(
response=response_text,
safety_validated=True
)
except TBGuardException:
raise
except Exception as e:
logger.error(f"[REQ {request_id}] ✗ General consult failed: {e}", exc_info=True)
raise ModelError(f"Consultation failed: {str(e)}")
def _call_mistral_consult(mistral_client, query: str) -> str:
"""Helper for LLM consultation (run in thread)"""
system_prompt = """You are a specialized Respiratory & TB clinical decision support AI.
EXPERTISE:
- Pulmonary medicine and tuberculosis diagnosis
- Chest radiology interpretation
- Differential diagnosis of respiratory conditions
- Evidence-based clinical guidelines (WHO, CDC)
SAFETY:
- Never provide definitive diagnoses (screening support only)
- Always recommend professional medical consultation
- Flag emergency symptoms immediately
- Maintain clinical precision and accuracy"""
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": query}
]
try:
response = mistral_client.chat.complete(
model="mistral-large-latest",
messages=messages,
temperature=0.15,
max_tokens=2000
)
return response.choices[0].message.content
except Exception as e:
logger.error(f"Mistral API error: {e}")
raise
# ============ Routes: Other Pages ============
@app.get("/consult_page", response_class=HTMLResponse)
async def consult_page(request: Request):
"""Render consultation page"""
return templates.TemplateResponse(request, "consult.html")
@app.get("/gallery", response_class=HTMLResponse)
async def gallery(request: Request):
"""Render gallery page"""
return templates.TemplateResponse(request, "gallery.html")
# ============ Error Responses for Specific Status Codes ============
@app.get("/404", response_class=HTMLResponse)
async def not_found():
"""404 Not Found"""
return "<h1>404 - Not Found</h1><p>The requested resource was not found.</p>"
# ============ Server Entry Point ============
if __name__ == "__main__":
logger.info(f"Starting TB-Guard-XAI on {settings.host}:{settings.port}")
logger.info(f"Documentation: http://{settings.host}:{settings.port}/docs")
uvicorn.run(
app,
host=settings.host,
port=settings.port,
workers=1,
log_level=settings.log_level.lower(),
reload=False,
access_log=True
)