import gradio as gr import base64 import re import os import tempfile from openai import OpenAI from sarvamai import SarvamAI from sarvamai.play import save # ─── API KEYS ───────────────────────────────────────────────────────────────── OPENAI_KEY = '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' SARVAM_KEY = 'sk_lnnt2cq6_BsU3lYlvinKhKfR4x2v8WqGC' # ─── SYSTEM PROMPT ──────────────────────────────────────────────────────────── SYSTEM_PROMPT = """ You are a medical report summarization assistant. STRICT RULES: 1. Only process medical report images (lab reports, prescriptions, scans, test reports). 2. If the image is NOT a medical report, respond ONLY with: "ERROR: The uploaded image is not a valid medical report." 3. Summarize in VERY SIMPLE language so a non-medical person can understand. 4. Avoid medical jargon. If needed, explain it simply. Your response MUST follow this EXACT structure with these two section headers: ## DETAILED SUMMARY Write a thorough, easy-to-understand breakdown of the report. Cover: key values, abnormal findings (in simple terms), what each result means for the person's health, and anything they should be aware of. End this section with: "AI Generated Summary: Always consult a doctor before taking any further steps." ## CONCISE SUMMARY Write a short paragraph of AT LEAST 40 WORDS and NO MORE THAN 50 WORDS. It must cover ALL key findings — normal results, abnormal values, and what the person should watch out for. Miss nothing important. Every aspect of the report must be reflected. """ # ─── HELPERS ────────────────────────────────────────────────────────────────── def encode_image(path: str) -> str: with open(path, "rb") as f: return base64.b64encode(f.read()).decode("utf-8") def extract_section(text: str, header: str) -> str: pattern = rf"##\s*{re.escape(header)}\s*\n(.*?)(?=\n##\s|\Z)" match = re.search(pattern, text, re.DOTALL | re.IGNORECASE) return match.group(1).strip() if match else "" def generate_tts(text: str, api_key: str) -> tuple[str | None, str]: """Generate TTS audio using Sarvam AI and return (path_to_wav, status_message).""" try: if not text or not text.strip(): return None, "No text provided for TTS" client = SarvamAI(api_subscription_key=api_key) audio = client.text_to_speech.convert( target_language_code="en-IN", text=text.strip(), model="bulbul:v3", speaker="varun", ) tmp = tempfile.NamedTemporaryFile( delete=False, suffix=".wav", dir=tempfile.gettempdir() ) tmp.close() save(audio, tmp.name) if not os.path.exists(tmp.name) or os.path.getsize(tmp.name) == 0: return None, "TTS generated empty audio" return tmp.name, "TTS generated successfully" except Exception as e: return None, f"[TTS ERROR] {str(e)}" # ─── CORE LOGIC ─────────────────────────────────────────────────────────────── def analyse_report(image_path): if image_path is None: return ( "
⚠️ PLEASE UPLOAD A MEDICAL DOCUMENT FIRST.
", "", None, "" ) openai_client = OpenAI(api_key=OPENAI_KEY) img_b64 = encode_image(image_path) # ── GPT-4o call ────────────────────────────────────────────────────────── full_output = "" try: with openai_client.responses.stream( model="gpt-4o", input=[ {"role": "system", "content": SYSTEM_PROMPT}, { "role": "user", "content": [ {"type": "input_text", "text": "Analyze and summarize this medical report"}, {"type": "input_image", "image_url": f"data:image/jpeg;base64,{img_b64}"} ] } ] ) as stream: for event in stream: if event.type == "response.output_text.delta": full_output += event.delta final_response = stream.get_final_response() except Exception as e: return (f"
❌ OPENAI ERROR: {e}
", "", None, "") if full_output.strip().startswith("ERROR:"): return ( f"
⚠️ {full_output.strip()}
", "", None, "" ) detailed_summary = extract_section(full_output, "DETAILED SUMMARY") summary_50 = extract_section(full_output, "CONCISE SUMMARY") words = summary_50.split() if len(words) > 50: summary_50 = " ".join(words[:50]) + "..." # ── Token cost ──────────────────────────────────────────────────────────── usage = final_response.usage total_cost = (usage.input_tokens * 2.5 + usage.output_tokens * 10.0) / 1_000_000 metrics_html = f"""
{usage.input_tokens:,}INPUT TOKENS
{usage.output_tokens:,}OUTPUT TOKENS
${total_cost:.5f}ANALYSIS COST
""" # ── Sarvam AI TTS ───────────────────────────────────────────────────────── audio_path, tts_status = generate_tts(summary_50, SARVAM_KEY) safe_detailed = detailed_summary.replace("<", "<").replace(">", ">") safe_concise = summary_50.replace("<", "<").replace(">", ">") detailed_html = f"""
COMPREHENSIVE BREAKDOWN
{safe_detailed}
{metrics_html}
""" # Animated Waveform integration waveform_html = """
""" if audio_path else "" tts_indicator = ( f'
{waveform_html} AUDIO SIGNAL ACTIVE
' if audio_path else '
✗ AUDIO SIGNAL OFFLINE
' ) concise_html = f"""
QUICK BRIEFING
{tts_indicator}
{safe_concise}
""" disclaimer = """
⚠️ MEDICAL OVERRIDE WARNING: THIS INTELLIGENCE IS AI-GENERATED FOR INFORMATIONAL PURPOSES. IT DOES NOT REPLACE PROFESSIONAL MEDICAL ADVICE. ALWAYS CONSULT YOUR HEALTHCARE PROVIDER.
""" return detailed_html, concise_html, audio_path, disclaimer # ─── CUSTOM CSS (Neon Cyberpunk UI/UX) ───────────────────────────────────────── CSS = """ @import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;700&family=Share+Tech+Mono&display=swap'); :root { --bg-main: #000000; --bg-card: #080808; --primary: #00FFFF; /* Neon Cyan */ --primary-glow: rgba(0, 255, 255, 0.6); --primary-soft: rgba(0, 255, 255, 0.1); --accent: #FF00FF; /* Neon Magenta */ --accent-glow: rgba(255, 0, 255, 0.6); --accent-soft: rgba(255, 0, 255, 0.1); --border-soft: rgba(0, 255, 255, 0.3); --text-neon: #E0FFFF; /* Bright icy blue for main readability */ --text-muted: #008B8B; --shadow-glow: 0 0 10px var(--primary-glow), inset 0 0 10px var(--primary-soft); --shadow-accent: 0 0 10px var(--accent-glow), inset 0 0 10px var(--accent-soft); } body, .gradio-container { background: var(--bg-main) !important; font-family: 'Share Tech Mono', monospace !important; color: var(--text-neon) !important; } .gradio-container { max-width: 1280px !important; margin: 0 auto !important; padding: 20px !important; } footer { display: none !important; } /* HERO SECTION */ .hero { text-align: left; padding: 20px 10px 40px; } .hero-title { font-family: 'Outfit', sans-serif; font-size: clamp(32px, 4vw, 48px); font-weight: 700; color: #FFF; margin: 0; letter-spacing: 2px; text-transform: uppercase; text-shadow: 0 0 8px var(--primary), 0 0 20px var(--primary); } .hero-title span { color: #FFF; text-shadow: 0 0 8px var(--accent), 0 0 20px var(--accent); } .hero-sub { font-family: 'Share Tech Mono', monospace; font-size: 16px; color: var(--primary); margin: 12px 0 0; line-height: 1.6; max-width: 550px; text-shadow: 0 0 4px var(--primary); } /* PANELS (For single screen fitting) */ .left-panel { display: flex; flex-direction: column; gap: 20px; } .right-panel { display: flex; flex-direction: column; gap: 20px; max-height: 85vh; overflow-y: auto; padding-right: 15px; } .right-panel::-webkit-scrollbar { width: 4px; } .right-panel::-webkit-scrollbar-thumb { background: var(--primary); box-shadow: 0 0 5px var(--primary); border-radius: 10px; } /* UPLOAD AREA */ .upload-panel { background: var(--bg-card); border: 1px dashed var(--primary); border-radius: 12px; overflow: hidden; transition: all 0.3s ease; box-shadow: var(--shadow-glow); } .upload-panel:hover { border: 1px solid var(--accent); box-shadow: var(--shadow-accent); } .upload-panel .wrap { background: transparent !important; border: none !important; } .upload-panel svg { color: var(--primary) !important; filter: drop-shadow(0 0 5px var(--primary)); } .upload-panel span { color: var(--text-neon) !important; text-shadow: 0 0 5px var(--primary); } /* ACTION BUTTON */ #analyse-btn { background: var(--bg-card) !important; border: 1px solid var(--primary) !important; border-radius: 8px !important; color: var(--primary) !important; font-family: 'Share Tech Mono', monospace !important; font-size: 18px !important; letter-spacing: 2px !important; padding: 18px !important; box-shadow: var(--shadow-glow) !important; text-shadow: 0 0 5px var(--primary) !important; transition: all 0.2s ease !important; text-transform: uppercase; } #analyse-btn:hover { background: var(--primary-soft) !important; border-color: var(--accent) !important; color: var(--accent) !important; box-shadow: var(--shadow-accent) !important; text-shadow: 0 0 8px var(--accent) !important; transform: scale(1.02) !important; } /* RESULT CARDS */ .result-card { background: var(--bg-card); border: 1px solid var(--border-soft); border-radius: 12px; padding: 28px 32px; box-shadow: inset 0 0 15px rgba(0,255,255,0.05); position: relative; } .result-card::before { content: ''; position: absolute; top: 0; left: 0; width: 100%; height: 2px; background: linear-gradient(90deg, transparent, var(--primary), transparent); } .result-card.concise::before { background: linear-gradient(90deg, transparent, var(--accent), transparent); } .card-header { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; border-bottom: 1px solid var(--border-soft); padding-bottom: 16px;} .card-icon { width: 36px; height: 36px; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 18px; border: 1px solid; } .card-icon.blue { background: var(--primary-soft); color: var(--primary); border-color: var(--primary); box-shadow: 0 0 8px var(--primary); text-shadow: 0 0 5px var(--primary); } .card-icon.coral { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); box-shadow: 0 0 8px var(--accent); text-shadow: 0 0 5px var(--accent); } .card-title { font-size: 16px; font-weight: 700; letter-spacing: 0.1em; color: var(--primary); text-shadow: 0 0 5px var(--primary); } .result-card.concise .card-title { color: var(--accent); text-shadow: 0 0 5px var(--accent); } .card-body { font-family: 'Outfit', sans-serif; font-size: 16px; font-weight: 300; line-height: 1.8; color: var(--text-neon); white-space: pre-wrap; } .focus-text { font-size: 18px; font-weight: 400; line-height: 1.6; color: #FFF; text-shadow: 0 0 4px var(--accent); } /* METRICS */ .metrics-row { display: flex; gap: 12px; margin-top: 24px; } .chip { flex: 1; background: #000; border-radius: 8px; padding: 12px 14px; text-align: center; border: 1px solid var(--border-soft); box-shadow: inset 0 0 8px rgba(0,255,255,0.1); } .chip .val { display: block; font-size: 20px; font-weight: 700; color: var(--primary); text-shadow: 0 0 5px var(--primary); } .chip .lbl { display: block; font-size: 12px; color: var(--text-muted); margin-top: 4px; } /* AUDIO COMPONENT STYLING */ #audio-out { background: transparent !important; border: none !important; padding: 0 !important; margin-bottom: 10px !important; } #audio-out audio { width: 100% !important; border-radius: 8px !important; height: 50px !important; border: 1px solid var(--accent) !important; box-shadow: 0 0 10px var(--accent-glow) !important; filter: invert(1) hue-rotate(180deg); /* Forces a dark look on default audio players */ } /* WAVEFORM ANIMATION */ .audio-badge { display: flex; align-items: center; gap: 8px; font-size: 12px; font-weight: 700; padding: 6px 12px; border-radius: 4px; border: 1px solid; } .audio-badge.ready { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); box-shadow: 0 0 8px var(--accent); text-shadow: 0 0 4px var(--accent); } .audio-badge.failed { background: rgba(255,0,0,0.1); color: #FF0000; border-color: #FF0000; box-shadow: 0 0 8px #FF0000; text-shadow: 0 0 4px #FF0000; } .waveform-container { display: flex; align-items: center; gap: 2px; height: 14px; margin-right: 4px; } .bar { width: 3px; background: var(--accent); border-radius: 1px; box-shadow: 0 0 5px var(--accent); animation: wave 1s ease-in-out infinite; } .bar:nth-child(1) { height: 40%; animation-delay: 0.0s; } .bar:nth-child(2) { height: 80%; animation-delay: 0.1s; } .bar:nth-child(3) { height: 100%; animation-delay: 0.2s; } .bar:nth-child(4) { height: 60%; animation-delay: 0.3s; } .bar:nth-child(5) { height: 40%; animation-delay: 0.4s; } @keyframes wave { 0%, 100% { transform: scaleY(0.4); } 50% { transform: scaleY(1); } } /* DISCLAIMER */ .disclaimer { background: rgba(255, 255, 0, 0.05); border-left: 4px solid #FFFF00; border-radius: 0 8px 8px 0; padding: 16px 20px; font-size: 13px; color: #FFFF00; display: flex; align-items: center; gap: 12px; line-height: 1.5; box-shadow: 0 0 10px rgba(255, 255, 0, 0.2); text-shadow: 0 0 4px rgba(255, 255, 0, 0.5); } .error-box { background: rgba(255,0,0,0.1); border: 1px solid #FF0000; padding: 15px; border-radius: 8px; color: #FF0000; text-shadow: 0 0 5px #FF0000; box-shadow: 0 0 10px rgba(255,0,0,0.3); text-align: center; } /* MOBILE RESPONSIVENESS */ @media (max-width: 900px) { .right-panel { max-height: none; overflow-y: visible; padding-right: 0; } .hero-title { font-size: 28px; } .card-header { flex-direction: column; align-items: flex-start; } } """ # ─── GRADIO UI ──────────────────────────────────────────────────────────────── with gr.Blocks(css=CSS, theme=gr.themes.Base(), title="MediScan AI") as demo: with gr.Row(): # LEFT COLUMN (Uploader & Controls) with gr.Column(scale=4, elem_classes="left-panel"): gr.HTML("""
MEDISCAN AI

INITIALIZE UPLOAD. AI CLINICAL ENGINE STANDING BY FOR PLAIN-LANGUAGE DECRYPTION & AUDIO SYNTHESIS.

""") with gr.Column(elem_classes="upload-panel"): image_input = gr.Image( type="filepath", label="DRAG & DROP MEDICAL DATAPAD HERE", show_label=True, height=380, ) analyse_btn = gr.Button("INITIALIZE SCAN & SYNTHESIZE AUDIO", elem_id="analyse-btn") # RIGHT COLUMN (Outputs - scrollable on desktop, fits on one screen) with gr.Column(scale=6, elem_classes="right-panel"): audio_out = gr.Audio( label="", type="filepath", autoplay=True, elem_id="audio-out", show_label=False ) concise_out = gr.HTML() detailed_out = gr.HTML() disclaimer_out = gr.HTML() # Link button logic analyse_btn.click( fn=analyse_report, inputs=[image_input], outputs=[detailed_out, concise_out, audio_out, disclaimer_out], ) if __name__ == "__main__": demo.launch(server_name="0.0.0.0", server_port=7860)