import gradio as gr
import os
import json
from datetime import datetime
import theme
import model
import config
# Model is lazy-loaded on first PREDICT call inside @spaces.GPU
MAX_MEASUREMENTS = 25
INITIAL_N = 4 # show 4 measurement rows on first load
EXAMPLE = config.EXAMPLE_PROFILE
# ============================================================================
# CUSTOM CSS — dark scientific aesthetic
# ============================================================================
CUSTOM_CSS = """
.gradio-container {
max-width: 1400px !important;
margin: 0 auto !important;
padding: 24px !important;
}
.gradio-container, body {
background: #020617 !important;
color: #e2e8f0 !important;
}
/* Hero header */
.hero {
background: linear-gradient(135deg, #064e3b 0%, #022c22 50%, #020617 100%);
border-radius: 16px;
padding: 32px 40px;
margin-bottom: 24px;
border: 1px solid #134e4a;
}
.hero h1 {
font-size: 38px !important;
font-weight: 700 !important;
margin: 0 0 6px 0 !important;
color: #ecfdf5 !important;
letter-spacing: -0.02em;
}
.hero .subtitle {
color: #6ee7b7 !important;
font-style: italic;
font-size: 16px;
margin-bottom: 14px;
}
.hero .description {
color: #cbd5e1 !important;
font-size: 14px;
line-height: 1.6;
}
.hero a {
color: #34d399 !important;
text-decoration: none;
border-bottom: 1px dashed #34d399;
}
/* Step cards — gr.Column with elem_classes renders as a visible
;
force transparent backgrounds on internal Gradio wrappers so the card shows through */
.gradio-container .step-card,
div.step-card {
background: #0a1428 !important;
border: 1px solid #334155 !important;
border-radius: 14px !important;
padding: 28px !important;
margin: 0 0 40px 0 !important;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3) !important;
}
.step-card > .form,
.step-card > .gr-form,
.step-card > .gr-block,
.step-card > .gr-group {
background: transparent !important;
border: none !important;
padding: 0 !important;
}
.step-label {
font-family: inherit !important;
font-size: 14px !important;
font-weight: 700 !important;
letter-spacing: 0.06em !important;
color: #10b981 !important;
text-transform: uppercase;
margin-right: 10px;
}
.step-title {
font-family: inherit !important;
font-size: 18px !important;
font-weight: 700 !important;
color: #10b981 !important;
margin-bottom: 22px !important;
display: block;
letter-spacing: 0.02em;
}
.step-hint {
color: #94a3b8 !important;
font-size: 12px !important;
margin-bottom: 16px !important;
line-height: 1.5;
}
/* Inputs */
input, textarea, select {
background: #1e293b !important;
color: #e2e8f0 !important;
border: 1px solid #334155 !important;
border-radius: 8px !important;
}
input:focus, textarea:focus {
border-color: #10b981 !important;
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2) !important;
}
label {
color: #94a3b8 !important;
font-size: 11px !important;
font-weight: 600 !important;
letter-spacing: 0.08em !important;
text-transform: uppercase;
}
/* ── Dropdown popup (fixes transparent-overlay bug) ──────────────────────── */
[role="listbox"], .options, ul[role="listbox"], .wrap-inner ul {
background: #1e293b !important;
border: 1px solid #334155 !important;
border-radius: 8px !important;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.7) !important;
z-index: 1000 !important;
backdrop-filter: blur(8px);
}
[role="option"], li.item {
background: transparent !important;
color: #e2e8f0 !important;
padding: 8px 12px !important;
cursor: pointer !important;
}
[role="option"]:hover, li.item:hover {
background: #334155 !important;
}
[role="option"][aria-selected="true"], li.item.selected {
background: rgba(16, 185, 129, 0.15) !important;
color: #6ee7b7 !important;
font-weight: 600 !important;
}
/* ── Radio horizontal layout ─────────────────────────────────────────────── */
.gr-radio fieldset, .gr-radio .wrap, fieldset.svelte-radio {
display: flex !important;
flex-direction: row !important;
gap: 12px !important;
flex-wrap: nowrap !important;
}
.gr-radio label, fieldset.svelte-radio label {
white-space: nowrap !important;
font-size: 13px !important;
text-transform: none !important;
letter-spacing: 0 !important;
}
/* ── Measurement table header ────────────────────────────────────────────── */
.measurement-header {
display: grid;
grid-template-columns: 4fr 3fr 3fr;
gap: 8px;
padding: 0 4px 10px;
font-family: 'JetBrains Mono', 'SF Mono', ui-monospace, monospace;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #64748b;
border-bottom: 1px solid #1e293b;
margin-bottom: 12px;
}
/* Primary button */
.gr-button-primary, button.primary {
background: linear-gradient(135deg, #10b981 0%, #059669 100%) !important;
color: #020617 !important;
font-weight: 700 !important;
letter-spacing: 0.08em !important;
border: none !important;
border-radius: 10px !important;
padding: 14px 24px !important;
font-size: 14px !important;
text-transform: uppercase;
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.25) !important;
}
.gr-button-primary:hover, button.primary:hover {
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(16, 185, 129, 0.4) !important;
}
/* Secondary button */
.gr-button-secondary, button.secondary {
background: #1e293b !important;
color: #cbd5e1 !important;
border: 1px solid #334155 !important;
border-radius: 8px !important;
font-size: 12px !important;
letter-spacing: 0.05em !important;
text-transform: uppercase;
}
/* Question preview */
.question-box textarea {
font-family: 'JetBrains Mono', 'SF Mono', ui-monospace, monospace !important;
font-size: 13px !important;
background: #0c1322 !important;
color: #cbd5e1 !important;
border-left: 3px solid #10b981 !important;
}
/* Chatbot */
.chatbot {
background: #0c1322 !important;
}
.chatbot .message {
background: #1e293b !important;
border-radius: 10px !important;
padding: 16px !important;
line-height: 1.6 !important;
}
/* Clinical disclaimer footer */
.disclaimer {
background: rgba(239, 68, 68, 0.08);
border: 1px solid rgba(239, 68, 68, 0.25);
border-radius: 8px;
padding: 12px 16px;
margin-top: 24px;
color: #fca5a5;
font-size: 12px;
text-align: center;
letter-spacing: 0.02em;
}
footer { display: none !important; }
"""
# ============================================================================
# QUESTION BUILDER
# ============================================================================
def build_question(species, biosample, measurements, target_antibiotic):
species = (species or "").strip() or "bacterial"
biosample = (biosample or "").strip()
target = (target_antibiotic or "").strip()
if not target:
return "Please select a target antibiotic."
susc, resist = [], []
for ab, pheno, mic in measurements:
if not ab or not str(ab).strip():
continue
ab = str(ab).strip()
mic = str(mic or "").strip()
entry = f"{ab} ({mic})" if mic else ab
if str(pheno).lower() == "susceptible":
susc.append(entry)
elif str(pheno).lower() == "resistant":
resist.append(entry)
parts = []
if susc:
parts.append("susceptible to " + ", ".join(susc))
if resist:
parts.append("resistant to " + ", ".join(resist))
if not parts:
profile_str = "no prior susceptibility data available"
elif len(parts) == 1:
profile_str = parts[0]
else:
profile_str = " and ".join(parts)
if biosample:
intro = f"You are given a {species} clinical isolate (BioSample {biosample})"
subject = biosample
else:
intro = f"You are given a {species} clinical isolate"
subject = "this isolate"
return (
f"{intro} tested against multiple antibiotics. "
f"Phenotype profile: {profile_str}. "
f"Based on the phenotype pattern across antibiotics, determine whether "
f"the isolate is susceptible or resistant to {target}. "
f"Provide a concise reasoning and end your answer with exactly: "
f"'Therefore, {subject} is susceptible/resistant to {target}.'"
)
def extract_verdict(text):
if not text:
return None
cleaned = text.replace("**", "")
sentences = [s.strip() for s in cleaned.replace("?", ".").replace("!", ".").split(".")]
for sent in reversed(sentences):
s = sent.lower()
has_s = "susceptible" in s
has_r = "resistant" in s
if has_s and not has_r:
return "susceptible"
if has_r and not has_s:
return "resistant"
return None
# ============================================================================
# UI HELPERS
# ============================================================================
def _row_updates(new_count):
updates = []
for i in range(MAX_MEASUREMENTS):
visible = i < new_count
updates.append(gr.update(visible=visible))
updates.append(gr.update(visible=visible))
updates.append(gr.update(visible=visible))
return updates
def add_measurement(count):
new_count = min(count + 1, MAX_MEASUREMENTS)
return [new_count] + _row_updates(new_count)
def remove_measurement(count):
new_count = max(count - 1, 1)
return [new_count] + _row_updates(new_count)
def load_example():
n = len(EXAMPLE["measurements"])
updates = [EXAMPLE["species"], "", EXAMPLE["target_antibiotic"], n]
for i in range(MAX_MEASUREMENTS):
if i < n:
ab, pheno, mic = EXAMPLE["measurements"][i]
updates += [
gr.update(value=ab, visible=True),
gr.update(value=pheno, visible=True),
gr.update(value=mic, visible=True),
]
else:
updates += [
gr.update(value=None, visible=False),
gr.update(value="susceptible", visible=False),
gr.update(value="", visible=False),
]
return updates
def update_question_preview(species, biosample, target, *measurement_fields):
measurements = []
for i in range(MAX_MEASUREMENTS):
ab = measurement_fields[i * 3]
pheno = measurement_fields[i * 3 + 1]
mic = measurement_fields[i * 3 + 2]
measurements.append((ab, pheno, mic))
return build_question(species, biosample, measurements, target)
# ============================================================================
# INFERENCE
# ============================================================================
def run_prediction(question, history):
# Each PREDICT starts a fresh trace — no prior context, no echoed user question
history = []
if not question or "Please select" in question:
history.append({"role": "assistant", "content": "⚠️ Please complete the form first."})
yield history, gr.update(interactive=True, value="▶ Predict")
return
history.append({"role": "assistant", "content": "⏳ Loading model & starting generation..."})
yield history, gr.update(interactive=False, value="● Running")
try:
accumulated = ""
for partial in model.run_inference_stream(question):
accumulated = partial
history[-1] = {"role": "assistant", "content": accumulated}
yield history, gr.update(interactive=False, value="● Running")
except Exception as e:
accumulated = f"**Error during inference:** `{e}`"
history[-1] = {"role": "assistant", "content": accumulated}
# Final pass: add verdict banner
verdict = extract_verdict(accumulated)
if verdict:
verdict_color = "🟢" if verdict == "susceptible" else "🔴"
banner = f"\n\n---\n\n## {verdict_color} Final Verdict: **{verdict.upper()}**"
history[-1] = {"role": "assistant", "content": accumulated + banner}
else:
history[-1] = {
"role": "assistant",
"content": accumulated + "\n\n---\n\n*(verdict could not be extracted automatically)*",
}
yield history, gr.update(interactive=True, value="▶ Predict")
def export_result(history, question):
if not history:
return None
last = history[-1]
if not isinstance(last, dict):
return None
reasoning = last.get("content", "")
verdict = extract_verdict(reasoning)
payload = {
"timestamp": datetime.utcnow().isoformat() + "Z",
"model": model.MODEL_ID,
"question": question,
"reasoning_trace": reasoning,
"predicted_label": verdict,
}
os.makedirs("tmp", exist_ok=True)
out_path = "tmp/prediction.json"
with open(out_path, "w") as f:
json.dump(payload, f, indent=2, ensure_ascii=False)
return out_path
# ============================================================================
# UI
# ============================================================================
with gr.Blocks(title="BacteReason") as demo:
measurement_count = gr.State(INITIAL_N)
# Hero header
gr.HTML("""
🦠 BacteReason
BacteReason is a fine-tuned
QwQ-32B that predicts whether a bacterial isolate
is susceptible or resistant to a target antibiotic, while explaining the underlying
molecular mechanism. It was distilled from reasoning traces generated by
Claude Opus 4.5 +
TogoMCP
(UniProt, ChEMBL, GO, PDB, etc).
See the
model weights.
Like all AI models, BacteReason can hallucinate — predictions are for research only.
""")
with gr.Row():
# ── LEFT: input form ─────────────────────────────────────────────────
with gr.Column(scale=4):
with gr.Column(elem_classes="step-card"):
gr.HTML('
STEP 01 //Species
')
species = gr.Dropdown(
label=None, show_label=False,
choices=config.SPECIES_LIST,
value=EXAMPLE["species"], allow_custom_value=True,
container=False,
)
# Hidden — never shown in UI; empty default keeps the question phrasing
# clean ("...clinical isolate tested against..." instead of "(BioSample X)...").
biosample = gr.Textbox(value="", visible=False)
with gr.Column(elem_classes="step-card"):
gr.HTML('
STEP 02 //Phenotype profile
')
gr.HTML(
''
)
m_antibiotics, m_phenotypes, m_mics = [], [], []
example_n = INITIAL_N
for i in range(MAX_MEASUREMENTS):
visible = i < example_n
if visible and i < len(EXAMPLE["measurements"]):
ab_val, pheno_val, mic_val = EXAMPLE["measurements"][i]
else:
ab_val, pheno_val, mic_val = None, "susceptible", ""
with gr.Row():
ab = gr.Dropdown(
choices=config.ANTIBIOTIC_LIST, value=ab_val,
allow_custom_value=True, show_label=False,
container=False, scale=4, visible=visible,
)
pheno = gr.Radio(
choices=["susceptible", "resistant"], value=pheno_val,
show_label=False, container=False, scale=3, visible=visible,
)
mic = gr.Textbox(
value=mic_val, placeholder="MIC",
show_label=False, container=False, scale=3, visible=visible,
)
m_antibiotics.append(ab)
m_phenotypes.append(pheno)
m_mics.append(mic)
with gr.Row():
add_btn = gr.Button("+ Add measurement", variant="secondary", size="sm")
remove_btn = gr.Button("− Remove last", variant="secondary", size="sm")
with gr.Column(elem_classes="step-card"):
gr.HTML('
STEP 03 //Target antibiotic
')
target = gr.Dropdown(
label=None, show_label=False,
choices=config.ANTIBIOTIC_LIST,
value=EXAMPLE["target_antibiotic"],
allow_custom_value=True,
container=False,
)
example_btn = gr.Button("📋 Load demo example", variant="secondary")
# ── RIGHT: question preview + output ─────────────────────────────────
with gr.Column(scale=6):
with gr.Column(elem_classes="step-card"):
gr.HTML('
STEP 04 //Submit question
')
question_box = gr.Textbox(
label="Auto-built question (editable)",
value=build_question(
EXAMPLE["species"], "",
EXAMPLE["measurements"][:INITIAL_N], EXAMPLE["target_antibiotic"],
),
lines=6, interactive=True,
elem_classes="question-box",
)
run_btn = gr.Button("▶ Predict", variant="primary", size="lg")
chatbot = gr.Chatbot(
show_label=False, height=640, layout="panel",
value=[], elem_classes="chatbot",
)
download_btn = gr.DownloadButton(
"📥 Download result (JSON)",
size="sm",
)
# ── EVENTS ────────────────────────────────────────────────────────────────
measurement_components = []
for ab, pheno, mic in zip(m_antibiotics, m_phenotypes, m_mics):
measurement_components.extend([ab, pheno, mic])
add_btn.click(
add_measurement, inputs=measurement_count,
outputs=[measurement_count] + measurement_components, api_name=False,
)
remove_btn.click(
remove_measurement, inputs=measurement_count,
outputs=[measurement_count] + measurement_components, api_name=False,
)
all_form_inputs = [species, biosample, target] + measurement_components
for inp in all_form_inputs:
inp.change(
update_question_preview, inputs=all_form_inputs,
outputs=question_box, show_progress="hidden", api_name=False,
)
example_btn.click(
load_example,
outputs=[species, biosample, target, measurement_count] + measurement_components,
api_name=False,
)
run_btn.click(
run_prediction, inputs=[question_box, chatbot],
outputs=[chatbot, run_btn], api_name=False,
)
def _update_download(history, question):
path = export_result(history, question)
if path and os.path.exists(path):
return gr.DownloadButton(value=path, visible=True)
return gr.DownloadButton(value=None, visible=True)
chatbot.change(
_update_download, inputs=[chatbot, question_box],
outputs=download_btn, api_name=False,
)
if __name__ == "__main__":
demo.launch(theme=theme.get_theme(), css=CUSTOM_CSS)