import os import re import uuid from pathlib import Path from typing import Optional import requests import uvicorn from fastapi import Cookie, FastAPI, File, Form, HTTPException, Response, UploadFile from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel from agents.agent_decision import ( load_report_from_disk, medical_agent_instance, reports_db, ) from config import Config config = Config() app = FastAPI( title="AI-MD Integrated Multi-Agent Medical API", version="5.0", description="One API for medical chat, anatomical-layer triage, image analysis, RAG, voice transcription, and speech.", ) app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) UPLOAD_DIR = Path("uploads/backend") REPORTS_DIR = Path("uploads/backend/reports") SKIN_LESION_OUTPUT_DIR = Path("uploads/skin_lesion_output") SPEECH_DIR = Path("uploads/speech") for directory in [UPLOAD_DIR, REPORTS_DIR, SKIN_LESION_OUTPUT_DIR, SPEECH_DIR]: directory.mkdir(parents=True, exist_ok=True) app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads") ALLOWED_IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "webp"} ALLOWED_AUDIO_EXTENSIONS = {"webm", "wav", "mp3", "m4a", "ogg"} _image_agent = None class StartRequest(BaseModel): body_part: str chief_complaint: Optional[str] = None class DiagnosticRequest(BaseModel): session_id: str body_part: str message: str class SpeechRequest(BaseModel): text: str voice_id: Optional[str] = None def secure_filename(filename: str) -> str: filename = os.path.basename(filename or "upload") filename = re.sub(r"[^A-Za-z0-9_.-]+", "_", filename).strip("._") return filename or "upload" def allowed_file(filename: str, allowed: set[str]) -> bool: return "." in filename and filename.rsplit(".", 1)[1].lower() in allowed def get_image_agent(): global _image_agent if _image_agent is None: from agents.image_analysis_agent import ImageAnalysisAgent _image_agent = ImageAnalysisAgent(config) return _image_agent def save_upload(upload: UploadFile, folder: Path, allowed: set[str], max_mb: int) -> Path: if not upload.filename or not allowed_file(upload.filename, allowed): raise HTTPException(status_code=400, detail="Unsupported file type.") content = upload.file.read() if not content: raise HTTPException(status_code=400, detail="Empty uploaded file.") if len(content) > max_mb * 1024 * 1024: raise HTTPException(status_code=413, detail=f"File too large. Max: {max_mb}MB.") filename = f"{uuid.uuid4()}_{secure_filename(upload.filename)}" path = folder / filename path.write_bytes(content) return path def run_image_analysis(image_path: Path, image_type: str, text: str = "") -> dict: agent = get_image_agent() normalized_type = (image_type or "auto").strip().lower() if normalized_type == "auto": try: classifier_result = str(agent.analyze_image(str(image_path))) except Exception as exc: classifier_result = f"auto-classification unavailable: {exc}" lower = classifier_result.lower() if "xray" in lower or "x-ray" in lower or "chest" in lower: normalized_type = "chest_xray" elif "skin" in lower or "lesion" in lower: normalized_type = "skin_lesion" else: return { "agent": "IMAGE_CLASSIFIER", "image_type": "auto", "result": classifier_result, "medical_note": "Image type was not confidently routed to a specialist image agent.", } if normalized_type in {"chest", "xray", "x-ray", "chest_xray"}: result = agent.classify_chest_xray(str(image_path)) return { "agent": "CHEST_XRAY_AGENT", "image_type": "chest_xray", "result": result, "medical_note": "AI image output must be reviewed by a qualified clinician.", } if normalized_type in {"skin", "lesion", "skin_lesion"}: result = agent.segment_skin_lesion(str(image_path)) mask_url = None mask_path = Path(config.medical_cv.skin_lesion_segmentation_output_path) if mask_path.exists(): mask_url = "/" + str(mask_path).replace("\\", "/") return { "agent": "SKIN_LESION_AGENT", "image_type": "skin_lesion", "result": result, "mask_url": mask_url, "medical_note": "AI image output must be reviewed by a qualified clinician.", } raise HTTPException( status_code=400, detail="image_type must be auto, chest_xray, or skin_lesion.", ) @app.get("/") def root(): return { "status": "ok", "service": "AI-MD Integrated Multi-Agent Medical API", "docs": "/docs", "endpoints": [ "GET /health", "POST /api/v1/start", "POST /api/v1/chat", "POST /api/v1/image/analyze", "POST /api/v1/multimodal-chat", "POST /api/v1/transcribe", "POST /api/v1/generate-speech", "GET /api/v1/report/{session_id}", ], } @app.get("/health") def health_check(): return {"status": "healthy"} @app.post("/api/v1/start") def start_diagnostic_session(request: StartRequest): try: return medical_agent_instance.start_session( body_part=request.body_part, chief_complaint=request.chief_complaint, ) except Exception as exc: raise HTTPException(status_code=500, detail=str(exc)) from exc @app.post("/api/v1/chat") def process_diagnostic_chat(request: DiagnosticRequest): try: return medical_agent_instance.process( session_id=request.session_id, body_part=request.body_part, user_message=request.message, ) except Exception as exc: raise HTTPException(status_code=500, detail=str(exc)) from exc @app.get("/api/v1/report/{session_id}") def get_final_report(session_id: str): entry = reports_db.get(session_id) if not entry: saved = load_report_from_disk(session_id) if saved: entry = {"status": "COMPLETED", "report": saved} reports_db[session_id] = entry if not entry: raise HTTPException(status_code=404, detail="Session not found or report is not ready.") if entry.get("status") == "COMPLETED" or entry.get("report"): medical_agent_instance.cleanup_session(session_id) return { "status": "REPORT", "session_status": "COMPLETED", "report": entry.get("report", {}), } return entry @app.post("/api/v1/image/analyze") async def analyze_image( image: UploadFile = File(...), image_type: str = Form("auto"), text: str = Form(""), session_id: Optional[str] = Form(None), body_part: Optional[str] = Form(None), ): path = save_upload(image, UPLOAD_DIR, ALLOWED_IMAGE_EXTENSIONS, config.api.max_image_upload_size) try: image_result = run_image_analysis(path, image_type=image_type, text=text) diagnostic_update = None if session_id and body_part: message = ( f"Image analysis result from {image_result['agent']} " f"for {image_result['image_type']}: {image_result.get('result')}. " f"Patient context: {text}" ) diagnostic_update = medical_agent_instance.process( session_id=session_id, body_part=body_part, user_message=message, ) return { "status": "success", "session_id": session_id, "image": image_result, "diagnostic_update": diagnostic_update, } except HTTPException: raise except Exception as exc: raise HTTPException(status_code=500, detail=str(exc)) from exc @app.post("/api/v1/upload") async def upload_image_legacy( response: Response, image: UploadFile = File(...), text: str = Form(""), image_type: str = Form("auto"), session_id: Optional[str] = Cookie(None), body_part: Optional[str] = Form(None), ): result = await analyze_image( image=image, image_type=image_type, text=text, session_id=session_id, body_part=body_part, ) if result.get("session_id"): response.set_cookie(key="session_id", value=result["session_id"]) return result @app.post("/api/v1/multimodal-chat") async def multimodal_chat( session_id: str = Form(...), body_part: str = Form(...), message: str = Form(""), image: Optional[UploadFile] = File(None), image_type: str = Form("auto"), ): image_result = None combined_message = message if image is not None: path = save_upload(image, UPLOAD_DIR, ALLOWED_IMAGE_EXTENSIONS, config.api.max_image_upload_size) image_result = run_image_analysis(path, image_type=image_type, text=message) combined_message = ( f"{message}\n\nAttached image analysis result: " f"{image_result['agent']} / {image_result['image_type']} -> {image_result.get('result')}" ) diagnostic_response = medical_agent_instance.process( session_id=session_id, body_part=body_part, user_message=combined_message, ) return { "status": "success", "session_id": session_id, "image": image_result, "diagnostic_response": diagnostic_response, } @app.post("/api/v1/transcribe") async def transcribe_audio(audio: UploadFile = File(...)): if not config.speech.eleven_labs_api_key: raise HTTPException(status_code=503, detail="ELEVEN_LABS_API_KEY is not configured.") path = save_upload(audio, SPEECH_DIR, ALLOWED_AUDIO_EXTENSIONS, max_mb=25) try: from elevenlabs.client import ElevenLabs client = ElevenLabs(api_key=config.speech.eleven_labs_api_key) with path.open("rb") as audio_file: transcription = client.speech_to_text.convert( file=audio_file, model_id="scribe_v1", tag_audio_events=True, language_code="eng", diarize=True, ) return {"status": "success", "transcript": transcription.text} except Exception as exc: raise HTTPException(status_code=500, detail=str(exc)) from exc @app.post("/api/v1/generate-speech") async def generate_speech(request: SpeechRequest): if not config.speech.eleven_labs_api_key: raise HTTPException(status_code=503, detail="ELEVEN_LABS_API_KEY is not configured.") voice_id = request.voice_id or config.speech.eleven_labs_voice_id url = f"https://api.elevenlabs.io/v1/text-to-speech/{voice_id}/stream" headers = { "Accept": "audio/mpeg", "Content-Type": "application/json", "xi-api-key": config.speech.eleven_labs_api_key, } payload = { "text": request.text, "model_id": "eleven_monolingual_v1", "voice_settings": {"stability": 0.5, "similarity_boost": 0.5}, } response = requests.post(url, headers=headers, json=payload, timeout=120) if response.status_code != 200: raise HTTPException(status_code=500, detail=response.text) output_path = SPEECH_DIR / f"{uuid.uuid4()}.mp3" output_path.write_bytes(response.content) return FileResponse(path=output_path, media_type="audio/mpeg", filename="generated_speech.mp3") @app.exception_handler(413) async def request_entity_too_large(request, exc): return JSONResponse( status_code=413, content={"status": "error", "response": f"File too large. Max: {config.api.max_image_upload_size}MB"}, ) if __name__ == "__main__": uvicorn.run(app, host=config.api.host, port=config.api.port)