| """
|
| 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
|
|
|
|
|
|
|
| _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
|
|
|
|
|
| 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
|
|
|
|
|
| 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.basicConfig(
|
| level=logging.INFO,
|
| format="[%(levelname)s] %(asctime)s %(name)s: %(message)s",
|
| stream=sys.stdout
|
| )
|
| logger = logging.getLogger("tb_guard")
|
|
|
|
|
| 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()
|
|
|
|
|
| @asynccontextmanager
|
| async def lifespan(app: FastAPI):
|
| """
|
| Lifespan context manager handles startup and shutdown
|
| Issues #7, #8: Replaces global state mutation
|
| """
|
|
|
| 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}")
|
|
|
|
|
| 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:
|
| f.unlink()
|
| cleanup_count += 1
|
| except Exception:
|
| pass
|
| if cleanup_count > 0:
|
| logger.info(f"Cleaned up {cleanup_count} old batch reports")
|
|
|
|
|
| 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
|
|
|
|
|
| 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)
|
|
|
|
|
|
|
| app = FastAPI(
|
| title="TB-Guard-XAI Clinical AI",
|
| description="Enterprise-grade AI screening for Tuberculosis",
|
| version="3.0.0",
|
| lifespan=lifespan
|
| )
|
|
|
|
|
|
|
| app.add_middleware(
|
| CORSMiddleware,
|
| allow_origins=settings.cors_origins,
|
| allow_credentials=False,
|
| allow_methods=settings.cors_methods,
|
| allow_headers=["Content-Type", "Authorization"],
|
| )
|
|
|
|
|
| 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}")
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| @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
|
| )
|
|
|
|
|
|
|
| @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
|
| }
|
|
|
|
|
|
|
| @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:
|
|
|
| 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
|
| )
|
|
|
|
|
| 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)}")
|
|
|
|
|
| symptoms = symptoms.strip()[:500]
|
| 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}")
|
|
|
|
|
|
|
| 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:
|
|
|
| logger.info(f"[REQ {request_id}] Running inference...")
|
|
|
|
|
| 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")
|
| }
|
|
|
|
|
| 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
|
| )
|
|
|
|
|
| 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
|
| )
|
|
|
|
|
| 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:
|
|
|
| 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))
|
|
|
|
|
|
|
| @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
|
|
|
|
|
| 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
|
|
|
|
|
| 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
|
| )
|
|
|
|
|
|
|
| @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}")
|
|
|
|
|
| transcript = await asyncio.to_thread(
|
| explainer.transcribe_audio, audio_bytes
|
| )
|
|
|
| if not transcript:
|
| raise ModelError("Transcription returned empty")
|
|
|
|
|
| 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)}")
|
|
|
|
|
|
|
| @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)")
|
|
|
|
|
| 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}] ✓ 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
|
|
|
|
|
|
|
| @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")
|
|
|
|
|
|
|
| @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>"
|
|
|
|
|
|
|
| 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
|
| )
|
|
|