OncoAgent / app.py
MaximoLopezChenlo's picture
Upload folder using huggingface_hub
3f48359 verified
"""
OncoAgent β€” Interactive Demo for Hugging Face Spaces.
Simulates the full multi-agent oncology triage pipeline with realistic
streaming, agent node transitions, and clinical recommendations.
Runs without GPU/vLLM β€” pure frontend showcase.
Hardware Target: AMD Instinct MI300X (production)
Demo Mode: CPU-only simulation for HF Spaces free tier
"""
import gradio as gr
import time
from typing import Generator
# ── Design System (inline for HF Spaces portability) ──────────────────
FONTS_LINK: str = (
'<link rel="stylesheet" href="https://fonts.googleapis.com/css2?'
'family=Figtree:wght@400;500;600;700&'
'family=Inter:wght@300;400;500;600&display=swap">'
)
CSS: str = """
/* OncoAgent β€” Clinical Dark Theme */
:root {
--shadow-drop: none !important;
--shadow-drop-lg: none !important;
--shadow-inset: none !important;
--block-shadow: none !important;
--body-background-fill: #0f172a !important;
--background-fill-primary: #0f172a !important;
}
html, body, gradio-app {
background-color: #0f172a !important;
margin: 0 !important; padding: 0 !important;
}
.gradio-container, .main, .wrap, .contain,
.gradio-container > div, footer, main {
background: #0f172a !important;
color: #e2e8f0 !important;
font-family: 'Inter', -apple-system, sans-serif !important;
box-shadow: none !important;
}
.gradio-container {
max-width: 960px !important;
margin: 0 auto !important;
border: none !important;
}
* { box-sizing: border-box; }
.gr-group, .gr-block, .gr-box, .gr-panel,
.block, .wrap, .panel { background: transparent !important; }
/* Header */
.header-bar {
display: flex; justify-content: space-between; align-items: center;
padding: 14px 24px;
background: #1e293b;
border: 1px solid #334155; border-radius: 14px;
margin-bottom: 16px;
}
.brand-name {
font-family: 'Figtree', sans-serif;
font-size: 1.6rem; font-weight: 700;
color: #f1f5f9; letter-spacing: -0.025em;
}
.hw-badge {
background: rgba(239, 68, 68, 0.15); color: #fca5a5;
padding: 5px 14px; border-radius: 6px;
font-size: 0.72rem; font-weight: 600;
letter-spacing: 0.05em;
border: 1px solid rgba(239, 68, 68, 0.25);
}
.demo-badge {
background: rgba(14, 165, 233, 0.15); color: #7dd3fc;
padding: 5px 14px; border-radius: 6px;
font-size: 0.72rem; font-weight: 600;
letter-spacing: 0.05em;
border: 1px solid rgba(14, 165, 233, 0.25);
}
/* Cards */
.card {
background: #1e293b !important;
border: 1px solid #334155 !important;
border-radius: 14px !important;
padding: 18px !important;
}
.card:hover { border-color: #475569 !important; }
/* Buttons */
.btn-primary {
background: linear-gradient(135deg, #0ea5e9, #0284c7) !important;
border: none !important; color: #fff !important;
font-weight: 600 !important; border-radius: 10px !important;
cursor: pointer !important;
transition: transform 0.15s ease-out, box-shadow 0.15s ease-out !important;
}
.btn-primary:hover {
transform: translateY(-1px) !important;
box-shadow: 0 4px 14px rgba(14, 165, 233, 0.4) !important;
}
.btn-demo {
background: linear-gradient(135deg, #10b981, #059669) !important;
border: none !important; color: #fff !important;
font-weight: 600 !important; border-radius: 10px !important;
cursor: pointer !important; font-size: 1rem !important;
padding: 12px 24px !important;
transition: transform 0.15s ease-out, box-shadow 0.15s ease-out !important;
}
.btn-demo:hover {
transform: translateY(-2px) !important;
box-shadow: 0 6px 20px rgba(16, 185, 129, 0.4) !important;
}
/* Chat */
.gr-chatbot, [class*="chatbot"] {
background: transparent !important;
border: none !important; box-shadow: none !important;
}
.message {
padding: 16px 20px !important;
border-radius: 18px !important;
margin-bottom: 12px !important;
line-height: 1.7 !important;
font-size: 0.94rem !important;
}
.message.user {
background: rgba(14, 165, 233, 0.08) !important;
border: 1px solid rgba(14, 165, 233, 0.15) !important;
border-bottom-right-radius: 4px !important;
margin-left: 15% !important;
}
.message.bot {
background: rgba(30, 41, 59, 0.6) !important;
border: 1px solid rgba(51, 65, 85, 0.3) !important;
border-bottom-left-radius: 4px !important;
margin-right: 10% !important;
backdrop-filter: blur(12px) !important;
}
/* Safety Badges */
.badge-safe {
display: inline-flex; align-items: center; gap: 6px;
background: rgba(16, 185, 129, 0.12); color: #34d399;
border: 1px solid rgba(16, 185, 129, 0.3);
padding: 4px 12px; border-radius: 6px;
font-weight: 600; font-size: 0.8rem;
}
/* Node Progress */
.node-step {
display: inline-flex; align-items: center; gap: 6px;
font-size: 0.78rem; color: #94a3b8;
padding: 4px 10px; border-radius: 6px;
background: rgba(14, 165, 233, 0.08);
border: 1px solid rgba(14, 165, 233, 0.15);
margin-right: 6px; margin-bottom: 4px;
}
.node-step.active {
color: #38bdf8; border-color: rgba(14, 165, 233, 0.4);
animation: pulse-node 1.5s ease-in-out infinite;
}
.node-step.done { color: #34d399; border-color: rgba(16,185,129,0.3); }
@keyframes pulse-node {
0%, 100% { opacity: 1; } 50% { opacity: 0.5; }
}
/* Info panel */
.info-panel {
background: rgba(14, 165, 233, 0.06);
border: 1px solid rgba(14, 165, 233, 0.15);
border-radius: 12px; padding: 16px;
margin-bottom: 12px;
}
/* Textarea & inputs */
textarea, input[type="text"] {
background: #0f172a !important;
border: 1px solid #334155 !important;
color: #e2e8f0 !important;
border-radius: 10px !important;
font-family: 'Inter', sans-serif !important;
}
textarea:focus, input[type="text"]:focus {
border-color: #0ea5e9 !important;
outline: none !important;
}
/* Labels */
label, .gr-input-label { color: #94a3b8 !important; }
/* KPI tiles */
.kpi-row { display: flex; gap: 12px; margin-top: 12px; }
.kpi-tile {
flex: 1; background: #1e293b; border: 1px solid #334155;
border-radius: 10px; padding: 14px; text-align: center;
}
.kpi-label {
font-size: 0.68rem; font-weight: 500; color: #64748b;
text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 4px;
}
.kpi-value {
font-family: 'Figtree', sans-serif;
font-size: 1.3rem; font-weight: 700; color: #f1f5f9;
}
/* Architecture diagram */
.arch-flow {
display: flex; align-items: center; gap: 8px;
flex-wrap: wrap; margin: 12px 0;
}
.arch-node {
background: #1e293b; border: 1px solid #334155;
border-radius: 8px; padding: 8px 14px;
font-size: 0.78rem; color: #cbd5e1;
font-weight: 500;
}
.arch-node.highlight {
border-color: #0ea5e9; color: #7dd3fc;
background: rgba(14, 165, 233, 0.08);
}
.arch-arrow { color: #475569; font-size: 1.2rem; }
/* Scrollbar */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: #0f172a; }
::-webkit-scrollbar-thumb { background: #334155; border-radius: 3px; }
/* Footer */
.footer-text {
text-align: center; color: #475569;
font-size: 0.72rem; margin-top: 20px;
padding: 12px; border-top: 1px solid #1e293b;
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
"""
# ── Demo Case Data ────────────────────────────────────────────────────
DEMO_CASE: str = (
"55-year-old female patient presents with postmenopausal bleeding. "
"Ultrasound shows an endometrial thickening of 12mm. "
"The endometrial biopsy report confirms Grade 1 endometrioid "
"adenocarcinoma. No evidence of myometrial invasion on MRI. "
"CA-125 within normal limits. Patient has BMI of 32 and "
"controlled type 2 diabetes."
)
# Simulated agent outputs β€” based on real NCCN Uterine guidelines
DEMO_STEPS: list = [
{
"node": "πŸ”€ Router Agent",
"delay": 0.8,
"output": (
"**Classification:** Oncological case detected.\n\n"
"- **Cancer Type:** Endometrial (Uterine)\n"
"- **Confidence:** 0.96\n"
"- **Routing Decision:** β†’ Specialist Agent (Tier 2 β€” Qwen3.6-27B)\n"
"- **Rationale:** Confirmed histopathology requires advanced reasoning."
),
},
{
"node": "πŸ” Clinical Extraction",
"delay": 1.0,
"output": (
"**Extracted Clinical Entities:**\n\n"
"| Field | Value |\n"
"|---|---|\n"
"| Age | 55 years |\n"
"| Sex | Female |\n"
"| Chief Complaint | Postmenopausal bleeding |\n"
"| Imaging | Endometrial thickening 12mm (US) |\n"
"| MRI | No myometrial invasion |\n"
"| Pathology | **Grade 1 endometrioid adenocarcinoma** |\n"
"| Biomarker | CA-125 normal |\n"
"| Comorbidities | BMI 32, T2DM (controlled) |\n"
"| FIGO Stage | Likely IA (pending surgical staging) |"
),
},
{
"node": "πŸ“š Corrective RAG",
"delay": 1.5,
"output": (
"**Retrieval Results** β€” NCCN Uterine Cancer Guidelines v2.2025\n\n"
"- πŸ“„ **Source:** `uterine.pdf` β€” Pages 12-18 (Endometrioid Adenocarcinoma)\n"
"- 🎯 **Bi-Encoder Score:** 0.89 | **Cross-Encoder Score:** 0.94\n"
"- βœ… **Distance Gate:** PASSED (threshold: 0.65)\n"
"- πŸ“Š **Chunks Retrieved:** 6 / 2,847 total\n\n"
"**Key Guideline Excerpts:**\n"
"> *\"For Grade 1 endometrioid adenocarcinoma confined to the endometrium "
"(Stage IA), total hysterectomy with bilateral salpingo-oophorectomy "
"(TH/BSO) is the primary treatment. Lymph node assessment should be "
"considered based on institutional protocols.\"*\n\n"
"> *\"Sentinel lymph node mapping is preferred over comprehensive "
"lymphadenectomy for clinically uterine-confined disease.\"*"
),
},
{
"node": "🧠 Specialist Agent",
"delay": 2.0,
"output": (
"**OncoAgent β€” Clinical Recommendation**\n\n"
"---\n\n"
"## πŸ“‹ Clinical Summary\n\n"
"55-year-old postmenopausal female with biopsy-confirmed Grade 1 "
"endometrioid adenocarcinoma. MRI shows no myometrial invasion. "
"Tumor markers within normal limits. Comorbidities include obesity "
"(BMI 32) and controlled T2DM.\n\n"
"## πŸ”¬ Diagnostic Findings\n\n"
"- **Histology:** Endometrioid adenocarcinoma, Grade 1 (well-differentiated)\n"
"- **Probable FIGO Stage:** IA β€” disease confined to endometrium\n"
"- **Myometrial Invasion:** Not detected on MRI\n"
"- **Lymphovascular Space Invasion (LVSI):** Not reported\n\n"
"## πŸ’Š Treatment Recommendation\n\n"
"**Primary Treatment (NCCN Category 1):**\n"
"1. **Total Hysterectomy with Bilateral Salpingo-Oophorectomy (TH/BSO)**\n"
" - Minimally invasive approach (laparoscopic/robotic) preferred\n"
" - Consider peritoneal washings at time of surgery\n\n"
"2. **Sentinel Lymph Node (SLN) Mapping**\n"
" - Preferred over comprehensive lymphadenectomy\n"
" - Per NCCN institutional SLN algorithm\n\n"
"**Adjuvant Therapy Considerations:**\n"
"- If final pathology confirms Stage IA, Grade 1: **Observation only**\n"
"- No adjuvant radiation or chemotherapy indicated for this stage\n"
"- If upstaged post-surgery: Refer to NCCN adjuvant guidelines\n\n"
"## ⚠️ Additional Considerations\n\n"
"- **Obesity Management:** BMI 32 β€” perioperative risk optimization recommended\n"
"- **Diabetes Control:** HbA1c target < 7% pre-surgery\n"
"- **Genetic Counseling:** Consider Lynch syndrome screening "
"(immunohistochemistry for MMR proteins or MSI testing)\n"
"- **Fertility Preservation:** Not applicable (postmenopausal)\n\n"
"## πŸ“š Evidence Level\n\n"
"- **NCCN Evidence Category:** 1 (High-level evidence, uniform consensus)\n"
"- **Guideline Source:** NCCN Uterine Neoplasms v2.2025, Pages 12-18\n"
"- **RAG Confidence:** 0.94 (Cross-Encoder validated)"
),
},
{
"node": "βœ… Critic (Reflexion Loop)",
"delay": 1.0,
"output": (
"**Critic Validation β€” PASSED βœ…**\n\n"
"| Check | Status |\n"
"|---|---|\n"
"| Clinical Summary present | βœ… |\n"
"| Diagnostic Findings present | βœ… |\n"
"| Treatment Recommendation present | βœ… |\n"
"| Evidence/Citations present | βœ… |\n"
"| Diagnostic Rigor (biopsy confirmed) | βœ… |\n"
"| Anti-Hallucination (RAG-grounded) | βœ… |\n"
"| PHI Sanitization | βœ… |\n\n"
"**Verdict:** Recommendation is clinically grounded and safe for review.\n\n"
"---\n"
"### Decision Status: "
"<span class='badge-safe'>"
"βœ… Clinically Validated"
"</span>"
),
},
]
def _node_progress_html(current_idx: int) -> str:
"""Generate the agent pipeline progress bar HTML."""
nodes = ["Router", "Extraction", "RAG", "Specialist", "Critic"]
icons = ["πŸ”€", "πŸ”", "πŸ“š", "🧠", "βœ…"]
parts = []
for i, (name, icon) in enumerate(zip(nodes, icons)):
if i < current_idx:
cls = "done"
elif i == current_idx:
cls = "active"
else:
cls = ""
parts.append(f"<span class='node-step {cls}'>{icon} {name}</span>")
if i < len(nodes) - 1:
parts.append("<span style='color:#475569;'>β†’</span>")
return " ".join(parts)
def run_demo() -> Generator:
"""Simulate the full OncoAgent pipeline with streaming."""
history = []
# Step 1: User message appears
history.append({"role": "user", "content": DEMO_CASE})
yield history
time.sleep(0.5)
# Step 2: Stream each agent node
for step_idx, step in enumerate(DEMO_STEPS):
node_name = step["node"]
delay = step["delay"]
output = step["output"]
# Build progress bar
progress = _node_progress_html(step_idx)
# Start with node header + progress
header = f"### {node_name}\n{progress}\n\n"
# Stream the output character by character (in chunks for speed)
full_text = header
chunk_size = 8
for i in range(0, len(output), chunk_size):
full_text += output[i:i + chunk_size]
# Update the last bot message
display_history = history.copy()
display_history.append({"role": "assistant", "content": full_text})
yield display_history
time.sleep(0.015)
# Finalize this step
history.append({"role": "assistant", "content": full_text})
yield history
# Pause between nodes
time.sleep(delay * 0.3)
# Final summary message
time.sleep(0.3)
final_msg = (
"---\n\n"
"### 🏁 Pipeline Complete\n\n"
"<div class='kpi-row'>"
"<div class='kpi-tile'><div class='kpi-label'>Agents Used</div>"
"<div class='kpi-value'>5</div></div>"
"<div class='kpi-tile'><div class='kpi-label'>RAG Sources</div>"
"<div class='kpi-value'>6</div></div>"
"<div class='kpi-tile'><div class='kpi-label'>Confidence</div>"
"<div class='kpi-value'>0.94</div></div>"
"<div class='kpi-tile'><div class='kpi-label'>Safety</div>"
"<div class='kpi-value'>βœ…</div></div>"
"</div>\n\n"
"<div style='margin-top:12px; font-size:0.8rem; color:#64748b;'>"
"⚑ In production, this pipeline runs on AMD Instinctβ„’ MI300X with "
"vLLM (PagedAttention) serving Qwen3.5-9B + Qwen3.6-27B models. "
"This demo simulates the agent flow for showcase purposes."
"</div>"
)
history.append({"role": "assistant", "content": final_msg})
yield history
def handle_user_message(
message: str,
history: list,
) -> Generator:
"""Handle custom user messages with a simulated response."""
if not message.strip():
yield history
return
history = history or []
history.append({"role": "user", "content": message})
yield history
time.sleep(0.5)
# Simulated response for any custom input
response = (
"### πŸ”€ Router Agent\n\n"
"**Note:** This is a demo environment running on HF Spaces "
"without GPU acceleration.\n\n"
"In the **production deployment** on AMD Instinctβ„’ MI300X, "
"your clinical case would be processed through our full "
"5-agent pipeline:\n\n"
"1. **Router** β€” Classifies oncological vs. non-oncological\n"
"2. **Clinical Extraction** β€” Extracts structured entities\n"
"3. **Corrective RAG** β€” Retrieves from NCCN/ESMO guidelines\n"
"4. **Specialist** β€” Generates evidence-based recommendation\n"
"5. **Critic (Reflexion)** β€” Validates safety and completeness\n\n"
"πŸ‘‰ Click **β–Ά View Demo** to see a complete simulated triage "
"with the endometrial cancer case.\n\n"
"πŸ”— **Production:** Deploy with `docker compose up` on MI300X hardware.\n"
"πŸ“– **Source:** [GitHub](https://github.com/maximolopezchenlo-lab/OncoAgent)"
)
# Stream it
partial = ""
chunk_size = 12
for i in range(0, len(response), chunk_size):
partial += response[i:i + chunk_size]
display = history.copy()
display.append({"role": "assistant", "content": partial})
yield display
time.sleep(0.01)
history.append({"role": "assistant", "content": response})
yield history
# ── Build the UI ──────────────────────────────────────────────────────
HEADER_HTML: str = """
<div class="header-bar">
<div style="display:flex; align-items:center; gap:12px;">
<span class="brand-name">🧬 OncoAgent</span>
<span class="demo-badge">INTERACTIVE DEMO</span>
</div>
<div style="display:flex; gap:8px; align-items:center;">
<span class="hw-badge">AMD INSTINCTβ„’ MI300X</span>
<span class="hw-badge">ROCm 7.2</span>
</div>
</div>
"""
INFO_HTML: str = """
<div class="info-panel">
<div style="font-size:0.95rem; font-weight:600; color:#e2e8f0; margin-bottom:8px;">
πŸ₯ Multi-Agent Oncology Triage System
</div>
<div style="font-size:0.82rem; color:#94a3b8; line-height:1.6;">
OncoAgent uses a <strong style="color:#7dd3fc;">5-agent LangGraph pipeline</strong>
to analyze clinical cases against <strong style="color:#7dd3fc;">NCCN/ESMO guidelines</strong>
with built-in safety validation and anti-hallucination guardrails.
</div>
<div class="arch-flow">
<span class="arch-node highlight">πŸ”€ Router</span>
<span class="arch-arrow">β†’</span>
<span class="arch-node">πŸ” Extraction</span>
<span class="arch-arrow">β†’</span>
<span class="arch-node">πŸ“š Corrective RAG</span>
<span class="arch-arrow">β†’</span>
<span class="arch-node">🧠 Specialist</span>
<span class="arch-arrow">β†’</span>
<span class="arch-node">βœ… Critic</span>
</div>
<div style="font-size:0.72rem; color:#64748b; margin-top:8px;">
⚑ Production: Qwen3.5-9B (Tier 1) + Qwen3.6-27B (Tier 2) via vLLM PagedAttention
&nbsp;|&nbsp; πŸ“„ 162 NCCN + 16 ESMO guidelines indexed
</div>
</div>
"""
FOOTER_HTML: str = """
<div class="footer-text">
🧬 OncoAgent β€” AMD Developer Hackathon 2026<br>
Built with LangGraph Β· vLLM Β· Gradio Β· ROCm 7.2<br>
<a href="https://github.com/maximolopezchenlo-lab/OncoAgent"
style="color:#0ea5e9; text-decoration:none;" target="_blank">
GitHub Repository</a>
&nbsp;Β·&nbsp;
<span style="color:#64748b;">100% Open Source Β· Apache 2.0</span>
</div>
"""
with gr.Blocks(
css=CSS,
head=FONTS_LINK,
title="OncoAgent β€” Oncology Triage Demo",
theme=gr.themes.Base(),
) as demo:
# Header
gr.HTML(HEADER_HTML)
gr.HTML(INFO_HTML)
# Chat
chatbot = gr.Chatbot(
type="messages",
label="Clinical Triage Chat",
height=520,
show_label=False,
show_copy_button=True,
render_markdown=True,
elem_classes=["card"],
)
# Controls
with gr.Row():
with gr.Column(scale=3):
txt = gr.Textbox(
placeholder="Enter a clinical case or click 'β–Ά View Demo'...",
show_label=False,
lines=2,
max_lines=5,
)
with gr.Column(scale=1, min_width=180):
demo_btn = gr.Button(
"β–Ά View Demo",
elem_classes=["btn-demo"],
size="lg",
)
with gr.Row():
send_btn = gr.Button("Send", elem_classes=["btn-primary"], size="sm")
clear_btn = gr.Button("πŸ—‘ Clear", variant="secondary", size="sm")
# Footer
gr.HTML(FOOTER_HTML)
# ── Event Handlers ────────────────────────────────────────────────
demo_btn.click(
fn=run_demo,
inputs=None,
outputs=chatbot,
)
send_btn.click(
fn=handle_user_message,
inputs=[txt, chatbot],
outputs=chatbot,
).then(lambda: "", outputs=txt)
txt.submit(
fn=handle_user_message,
inputs=[txt, chatbot],
outputs=chatbot,
).then(lambda: "", outputs=txt)
clear_btn.click(lambda: [], outputs=chatbot)
if __name__ == "__main__":
demo.launch(server_name="0.0.0.0", server_port=7860)