HantaLytics / input_variabel.py
RangGaraga's picture
Update input_variabel.py
5a1b40c verified
Raw
History Blame Contribute Delete
24.3 kB
import streamlit as st
import numpy as np
import pandas as pd
import pickle
MODEL_PATH = "HantaVarNet.pkl"
@st.cache_resource
def load_model():
with open("HantaVarNet.pkl", "rb") as f:
package = pickle.load(f)
return package["model"] if isinstance(package, dict) else package
# Clinical knowledge: display labels, units, normal ranges
FEATURE_INFO = {
"Creatinine_mg/dL": ("Kreatinin", "mg/dL", 0.6, 1.2, "high"),
"ALT_U/L": ("ALT (SGPT)", "U/L", 7.0, 56.0, "high"),
"AST_U/L": ("AST (SGOT)", "U/L", 10.0, 40.0, "high"),
"BUN_mg/dL": ("BUN", "mg/dL", 7.0, 25.0, "high"),
"Platelet_Count_K/uL": ("Trombosit", "K/µL", 150, 400, "low"),
}
# Exact thresholds
TREE_THRESHOLDS = {
"Creatinine_mg/dL": 1.35,
"ALT_U/L": 65.95,
"AST_U/L": 78.00,
"BUN_mg/dL": 39.05,
"Platelet_Count_K/uL": 146.14,
}
# Recommendation catalogue per feature
RECOMMENDATIONS = {
"Creatinine_mg/dL": {
"icon": "🫘",
"title": "Evaluasi Fungsi Ginjal",
"color": "orange",
"items": [
"Perbanyak konsumsi air putih (min. 2 liter/hari) untuk mendukung ekskresi kreatinin.",
"Hindari penggunaan obat nefrotoksik (NSAID, aminoglikosida) tanpa pengawasan dokter.",
"Lakukan pemeriksaan urinalisis, GFR, dan USG ginjal untuk evaluasi menyeluruh.",
"Pertimbangkan konsultasi dokter spesialis nefrologi.",
],
},
"ALT_U/L": {
"icon": "🫀",
"title": "Jaga Kesehatan Hati",
"color": "red",
"items": [
"Hindari konsumsi alkohol dan makanan tinggi lemak jenuh / makanan ultraproses.",
"Batasi obat-obatan hepatotoksik (parasetamol dosis tinggi, statin) tanpa resep.",
"Konsumsi sayuran hijau, buah-buahan, dan protein rendah lemak.",
"Lakukan pemeriksaan USG abdomen untuk menilai kondisi hati.",
],
},
"AST_U/L": {
"icon": "🔬",
"title": "Pemeriksaan Lanjutan Hati",
"color": "red",
"items": [
"Lakukan panel fungsi hati lengkap (LFT): ALT, AST, GGT, bilirubin, albumin.",
"Istirahat cukup dan hindari aktivitas fisik berat sementara waktu.",
"Monitor gejala seperti ikterus (kuning), mual, atau nyeri perut kanan atas.",
"Konsultasikan dengan dokter spesialis gastrohepatologi.",
],
},
"BUN_mg/dL": {
"icon": "💧",
"title": "Hidrasi & Evaluasi Ginjal",
"color": "orange",
"items": [
"Tingkatkan asupan cairan untuk membantu ekskresi urea oleh ginjal.",
"Kurangi sementara konsumsi protein hewani berlebih (daging merah, seafood).",
"Pantau output urin harian — penurunan volume bisa menjadi tanda perburukan.",
"Periksa rasio BUN/Kreatinin untuk membedakan penyebab pre-renal vs. renal.",
],
},
"Platelet_Count_K/uL": {
"icon": "🩸",
"title": "Pemeriksaan Darah Lanjutan",
"color": "purple",
"items": [
"Lakukan pemeriksaan darah lengkap (CBC) menyeluruh termasuk morfologi trombosit.",
"Hindari aspirin, ibuprofen, dan antikoagulan tanpa saran dokter.",
"Waspadai tanda perdarahan: lebam berlebih, mimisan, gusi berdarah, atau petekia.",
"Konsultasikan dengan dokter spesialis hematologi untuk evaluasi lebih lanjut.",
],
},
}
# CSS
PAGE_CSS = """
<style>
@import url('https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=Inter:wght@300;400;500;600;700&display=swap');
html, body, [data-testid="stAppViewContainer"], [data-testid="stMain"] {
background-color: #f0f7ff !important;
font-family: 'Inter', sans-serif !important;
}
[data-testid="stSidebar"] { display: none !important; }
header[data-testid="stHeader"] { display: none !important; }
footer { display: none !important; }
.block-container { padding: 2.5rem 3rem 4rem !important; max-width: 1060px !important; }
.stButton > button {
background: #2563eb !important;
color: #ffffff !important;
border: none !important;
font-family: 'Inter', sans-serif !important;
font-weight: 600 !important;
font-size: 0.9rem !important;
padding: 0.65rem 1.2rem !important;
border-radius: 8px !important;
width: 100% !important;
transition: background 0.2s !important;
}
.stButton > button:hover {
background: #1d4ed8 !important;
box-shadow: 0 4px 14px rgba(37,99,235,.25) !important;
}
.page-header {
background: linear-gradient(135deg, #eff6ff, #dbeafe);
border: 1px solid #bfdbfe;
border-radius: 14px;
padding: 1.6rem 2rem;
margin-bottom: 2rem;
display: flex;
align-items: center;
gap: 1.2rem;
}
.page-header-icon { font-size: 2.2rem; line-height: 1; }
.page-header-eyebrow {
font-family: 'Space Mono', monospace;
font-size: 0.67rem;
letter-spacing: 0.15em;
color: #3b82f6;
text-transform: uppercase;
margin-bottom: 0.25rem;
}
.page-header-title {
font-family: 'Space Mono', monospace;
font-size: 1.3rem;
font-weight: 700;
color: #0f172a;
margin-bottom: 0.2rem;
}
.page-header-sub { font-size: 0.85rem; color: #64748b; }
.section-label {
font-family: 'Space Mono', monospace;
font-size: 0.67rem;
letter-spacing: 0.18em;
color: #3b82f6;
text-transform: uppercase;
border-left: 3px solid #3b82f6;
padding-left: 0.7rem;
margin: 1.6rem 0 0.8rem;
display: block;
}
div[data-testid="stNumberInput"] label,
div[data-testid="stSelectbox"] label,
div[data-testid="stMultiSelect"] label {
font-size: 0.84rem !important;
font-weight: 500 !important;
color: #374151 !important;
}
div[data-testid="stNumberInput"] input {
background: #ffffff !important;
border: 1.5px solid #e2e8f0 !important;
border-radius: 7px !important;
color: #0f172a !important;
font-size: 0.9rem !important;
}
div[data-testid="stNumberInput"] input:focus {
border-color: #3b82f6 !important;
box-shadow: 0 0 0 3px rgba(59,130,246,.12) !important;
}
/* ── Prediction result ── */
.result-wrap {
border-radius: 14px;
padding: 2rem 2rem 1.6rem;
margin-top: 1.5rem;
position: relative;
overflow: hidden;
border: 1.5px solid;
}
.result-wrap.positive { background: linear-gradient(135deg, #fff7ed, #ffedd5); border-color: #fed7aa; }
.result-wrap.negative { background: linear-gradient(135deg, #eff6ff, #dbeafe); border-color: #bfdbfe; }
.result-wrap::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 4px;
border-radius: 14px 14px 0 0;
}
.result-wrap.positive::before { background: linear-gradient(90deg, #f97316, #ef4444); }
.result-wrap.negative::before { background: linear-gradient(90deg, #3b82f6, #38bdf8); }
.result-meta {
font-family: 'Space Mono', monospace;
font-size: 0.66rem;
letter-spacing: 0.13em;
text-transform: uppercase;
color: #64748b;
margin-bottom: 0.5rem;
}
.result-verdict {
font-family: 'Space Mono', monospace;
font-size: 1.9rem;
font-weight: 700;
line-height: 1;
margin-bottom: 0.4rem;
}
.result-verdict.pos { color: #ea580c; }
.result-verdict.neg { color: #1d4ed8; }
.result-sub { font-size: 0.87rem; color: #475569; margin-bottom: 1.6rem; line-height: 1.55; }
.conf-title {
font-family: 'Space Mono', monospace;
font-size: 0.66rem;
letter-spacing: 0.13em;
text-transform: uppercase;
color: #64748b;
margin-bottom: 0.75rem;
}
.conf-row { display: flex; align-items: center; gap: 0.9rem; margin-bottom: 0.65rem; }
.conf-lbl { font-size: 0.84rem; color: #374151; width: 72px; flex-shrink: 0; font-weight: 500; }
.conf-track { flex: 1; height: 9px; background: #e2e8f0; border-radius: 100px; overflow: hidden; }
.conf-fill { height: 100%; border-radius: 100px; }
.conf-fill.pos { background: linear-gradient(90deg, #f97316, #ef4444); }
.conf-fill.neg { background: linear-gradient(90deg, #3b82f6, #38bdf8); }
.conf-pct { font-family: 'Space Mono', monospace; font-size: 0.84rem; color: #0f172a; width: 50px; text-align: right; flex-shrink: 0; font-weight: 600; }
/* ── Decision path ── */
.path-wrap {
background: #f8fafc;
border: 1.5px solid #e2e8f0;
border-radius: 14px;
padding: 1.4rem 1.6rem;
margin-top: 0.4rem;
}
.path-title {
font-family: 'Space Mono', monospace;
font-size: 0.66rem;
letter-spacing: 0.13em;
text-transform: uppercase;
color: #64748b;
margin-bottom: 1rem;
}
.path-step {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.6rem 1rem;
border-radius: 8px;
margin-bottom: 0.4rem;
font-size: 0.83rem;
font-family: 'Space Mono', monospace;
}
.path-step.triggered { background: #fff7ed; border: 1px solid #fed7aa; color: #92400e; }
.path-step.normal { background: #f0fdf4; border: 1px solid #bbf7d0; color: #14532d; }
.ps-icon { font-size: 1rem; flex-shrink: 0; }
.ps-feat { font-weight: 700; min-width: 115px; }
.ps-val { opacity: 0.95; }
.ps-op { background: rgba(0,0,0,.07); padding: 0.1rem 0.4rem; border-radius: 4px; font-size: 0.76rem; }
.ps-thresh { opacity: 0.7; }
.ps-result { margin-left: auto; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.06em; flex-shrink: 0; }
.path-step.triggered .ps-result { color: #ea580c; }
.path-step.normal .ps-result { color: #16a34a; }
.path-connector { width: 2px; height: 9px; background: #cbd5e1; margin: 0 0 0 1.6rem; }
/* ── Parameter chips ── */
.param-grid { display: flex; flex-wrap: wrap; gap: 0.55rem; margin-top: 0.4rem; }
.param-chip {
display: flex;
align-items: center;
gap: 0.45rem;
padding: 0.42rem 0.85rem;
border-radius: 100px;
font-size: 0.82rem;
font-weight: 600;
border: 1.5px solid;
}
.param-chip.risk { background: #fff7ed; border-color: #fdba74; color: #92400e; }
.param-chip.ok { background: #f0fdf4; border-color: #86efac; color: #14532d; }
.chip-val { font-family: 'Space Mono', monospace; font-size: 0.79rem; }
/* ── Recommendation cards ── */
.rec-card {
background: #ffffff;
border: 1.5px solid #e2e8f0;
border-radius: 12px;
padding: 1.2rem 1.4rem;
margin-bottom: 0.7rem;
}
.rec-card.orange { border-left: 4px solid #f97316; }
.rec-card.red { border-left: 4px solid #ef4444; }
.rec-card.purple { border-left: 4px solid #8b5cf6; }
.rec-header { display: flex; align-items: center; gap: 0.6rem; margin-bottom: 0.75rem; }
.rec-icon { font-size: 1.3rem; line-height: 1; }
.rec-title { font-family: 'Space Mono', monospace; font-size: 0.82rem; font-weight: 700; color: #0f172a; }
.rec-item {
display: flex;
gap: 0.55rem;
font-size: 0.84rem;
color: #374151;
line-height: 1.6;
margin-bottom: 0.38rem;
}
.rec-item::before { content: '→'; color: #3b82f6; font-weight: 700; flex-shrink: 0; margin-top: 0.02rem; }
.no-risk-banner {
background: #f0fdf4;
border: 1.5px solid #bbf7d0;
border-radius: 12px;
padding: 1.1rem 1.4rem;
font-size: 0.87rem;
color: #14532d;
display: flex;
align-items: center;
gap: 0.75rem;
margin-top: 0.4rem;
}
.disclaimer {
margin-top: 1.4rem;
padding: 0.85rem 1rem;
background: rgba(251,191,36,.1);
border: 1px solid rgba(251,191,36,.35);
border-radius: 8px;
font-size: 0.79rem;
color: #92400e;
line-height: 1.55;
}
</style>
"""
ALL_SYMPTOMS = ["Abdominal Pain","Asymptomatic","Cough","Dyspnea",
"Fatigue","Fever","Headache","Missing","Muscle Aches"]
GENDER_OPTIONS = ["Male","Female"]
REGION_OPTIONS = ["Asia","Europe","North America","South America"]
EXPOSURE_OPTIONS = ["Agricultural","Camping","Dust Exposure",
"Occupational","Rodent Contact","Unknown"]
def build_feature_vector(age, symptom_count, wbc, platelet, alt, ast,
bun, creatinine, gender, region, exposure, symptoms):
num = {
"Age": age, "Symptom_Count": symptom_count,
"WBC_Count_K/uL": wbc, "Platelet_Count_K/uL": platelet,
"ALT_U/L": alt, "AST_U/L": ast,
"BUN_mg/dL": bun, "Creatinine_mg/dL": creatinine,
}
symp_vec = {s: int(s in symptoms) for s in ALL_SYMPTOMS}
gender_vec = {"Gender_F": int(gender=="Female"), "Gender_M": int(gender=="Male")}
region_vec = {
"Region_Asia": int(region=="Asia"),
"Region_Europe": int(region=="Europe"),
"Region_North America": int(region=="North America"),
"Region_South America": int(region=="South America"),
}
exposure_vec = {
"Exposure_Type_Agricultural": int(exposure=="Agricultural"),
"Exposure_Type_Camping": int(exposure=="Camping"),
"Exposure_Type_Dust Exposure": int(exposure=="Dust Exposure"),
"Exposure_Type_Occupational": int(exposure=="Occupational"),
"Exposure_Type_Rodent Contact": int(exposure=="Rodent Contact"),
"Exposure_Type_Unknown": int(exposure=="Unknown"),
}
return pd.DataFrame([{**num, **symp_vec, **gender_vec, **region_vec, **exposure_vec}])
def conf_bar(label, pct, cls):
return f"""
<div class="conf-row">
<div class="conf-lbl">{label}</div>
<div class="conf-track">
<div class="conf-fill {cls}" style="width:{pct:.1f}%"></div>
</div>
<div class="conf-pct">{pct:.1f}%</div>
</div>"""
def get_decision_path(model, X):
"""
Telusuri jalur node yang dilewati model untuk input X.
Hanya tampilkan node yang menggunakan fitur klinis utama (FEATURE_INFO).
"""
try:
tree_clf = model.estimator_
feat_names = list(X.columns)
tree_ = tree_clf.tree_
node_indicator = tree_clf.decision_path(X)
leaf_id = tree_clf.apply(X)
node_ids = node_indicator.indices[
node_indicator.indptr[0]:node_indicator.indptr[1]
]
steps = []
for node_id in node_ids:
if leaf_id[0] == node_id:
continue # skip leaf
feat_key = feat_names[tree_.feature[node_id]]
if feat_key not in FEATURE_INFO:
continue # skip non-clinical features
thresh = round(float(tree_.threshold[node_id]), 2)
val = round(float(X.iloc[0][feat_key]), 2)
went_left = val <= thresh
_, unit, _, _, direction = FEATURE_INFO[feat_key]
disp_name = FEATURE_INFO[feat_key][0]
# "is_risk": did this value cross toward the risk side?
is_risk = (not went_left) if direction == "high" else went_left
steps.append({
"name": disp_name,
"unit": unit,
"threshold": thresh,
"value": val,
"went_left": went_left,
"is_risk": is_risk,
})
return steps
except Exception:
return []
def get_triggered_features(creatinine, alt, ast, bun, platelet):
"""Return list of feature keys whose values cross the primary tree threshold."""
checks = {
"Creatinine_mg/dL": (creatinine, TREE_THRESHOLDS["Creatinine_mg/dL"], "high"),
"ALT_U/L": (alt, TREE_THRESHOLDS["ALT_U/L"], "high"),
"AST_U/L": (ast, TREE_THRESHOLDS["AST_U/L"], "high"),
"BUN_mg/dL": (bun, TREE_THRESHOLDS["BUN_mg/dL"], "high"),
"Platelet_Count_K/uL": (platelet, TREE_THRESHOLDS["Platelet_Count_K/uL"], "low"),
}
return [
feat for feat, (val, thresh, direction) in checks.items()
if (direction == "high" and val > thresh) or (direction == "low" and val < thresh)
]
def render_decision_path_html(steps):
if not steps:
return ""
html = '<div class="path-wrap"><div class="path-title">Jalur Keputusan Model</div>'
for i, s in enumerate(steps):
cls = "triggered" if s["is_risk"] else "normal"
icon = "⚠️" if s["is_risk"] else "✅"
op = "≤" if s["went_left"] else ">"
result_lbl = "MELEWATI AMBANG" if s["is_risk"] else "DALAM BATAS"
html += f"""
<div class="path-step {cls}">
<span class="ps-icon">{icon}</span>
<span class="ps-feat">{s['name']}</span>
<span class="ps-val">{s['value']} {s['unit']}</span>
<span class="ps-op">{op} {s['threshold']} {s['unit']}</span>
<span class="ps-result">{result_lbl}</span>
</div>"""
if i < len(steps) - 1:
html += '<div class="path-connector"></div>'
html += "</div>"
return html
def render_param_chips_html(creatinine, alt, ast, bun, platelet, triggered):
params = [
("Creatinine_mg/dL", creatinine),
("ALT_U/L", alt),
("AST_U/L", ast),
("BUN_mg/dL", bun),
("Platelet_Count_K/uL", platelet),
]
chips = ""
for feat, val in params:
name, unit, _, _, _ = FEATURE_INFO[feat]
cls = "risk" if feat in triggered else "ok"
badge = "⚠️" if feat in triggered else "✅"
chips += f'<div class="param-chip {cls}">{badge} {name} <span class="chip-val">{val} {unit}</span></div>'
return f'<div class="param-grid">{chips}</div>'
def render_recommendations_html(triggered):
if not triggered:
return (
'<div class="no-risk-banner">✅ '
'<span>Semua parameter klinis utama berada dalam batas ambang tree model. '
'Tidak ada rekomendasi khusus yang diperlukan saat ini.</span></div>'
)
html = ""
for feat in triggered:
rec = RECOMMENDATIONS[feat]
items = "".join(f'<div class="rec-item">{it}</div>' for it in rec["items"])
html += f"""
<div class="rec-card {rec['color']}">
<div class="rec-header">
<span class="rec-icon">{rec['icon']}</span>
<span class="rec-title">{rec['title']}</span>
</div>
{items}
</div>"""
return html
# Main page
def render(goto):
st.markdown(PAGE_CSS, unsafe_allow_html=True)
if st.button("← Kembali ke Home", key="back_var"):
goto("home")
st.markdown("""
<div class="page-header">
<div class="page-header-icon">📋</div>
<div>
<div class="page-header-eyebrow">Model 01 · HantaVarNet</div>
<div class="page-header-title">Input Variabel Klinis</div>
<div class="page-header-sub">
Isi seluruh data klinis pasien. Semua field wajib diisi sebelum prediksi dijalankan.
</div>
</div>
</div>
""", unsafe_allow_html=True)
# Numerik
st.markdown('<span class="section-label">📊 Variabel Numerik</span>', unsafe_allow_html=True)
c1, c2, c3, c4 = st.columns(4)
with c1: age = st.number_input("Age (tahun)", min_value=5, max_value=90, value=30, step=1)
with c2: symptom_count = st.number_input("Symptom Count", min_value=0, max_value=5, value=2, step=1)
with c3: wbc = st.number_input("WBC Count (K/uL)", min_value=1.0, max_value=20.0, value=7.0, step=0.1, format="%.1f")
with c4: platelet = st.number_input("Platelet Count (K/uL)", min_value=1.0, max_value=500.0, value=200.0, step=1.0, format="%.1f")
c5, c6, c7, c8 = st.columns(4)
with c5: alt = st.number_input("ALT (U/L)", min_value=5.0, max_value=300.0, value=35.0, step=1.0, format="%.1f")
with c6: ast = st.number_input("AST (U/L)", min_value=5.0, max_value=300.0, value=35.0, step=1.0, format="%.1f")
with c7: bun = st.number_input("BUN (mg/dL)", min_value=5.0, max_value=100.0, value=15.0, step=0.5, format="%.1f")
with c8: creatinine = st.number_input("Creatinine (mg/dL)", min_value=0.1, max_value=10.0, value=1.0, step=0.1, format="%.1f")
# Gejala
st.markdown('<span class="section-label">🤒 Gejala Klinis</span>', unsafe_allow_html=True)
symptoms = st.multiselect(
"Pilih semua gejala yang dialami pasien (boleh lebih dari satu)",
options=ALL_SYMPTOMS, default=[],
placeholder="Klik untuk memilih gejala…",
)
# Kategorikal
st.markdown('<span class="section-label">🗂️ Data Kategorikal</span>', unsafe_allow_html=True)
cg, cr, ce = st.columns(3)
with cg: gender = st.selectbox("Gender", options=GENDER_OPTIONS)
with cr: region = st.selectbox("Region", options=REGION_OPTIONS)
with ce: exposure = st.selectbox("Exposure Type", options=EXPOSURE_OPTIONS)
# Predict
st.markdown("<div style='height:0.6rem'></div>", unsafe_allow_html=True)
_, btn_col, _ = st.columns([1, 4, 1])
with btn_col:
predict_clicked = st.button("Jalankan Prediksi", key="predict_var", use_container_width=True)
if not predict_clicked:
return
# Load & predict
try:
model = load_model()
except FileNotFoundError:
st.error(f"Model tidak ditemukan di: {MODEL_PATH}")
return
X = build_feature_vector(age, symptom_count, wbc, platelet,
alt, ast, bun, creatinine,
gender, region, exposure, symptoms)
fn = None
if hasattr(model, "feature_names_in_"):
fn = model.feature_names_in_
elif hasattr(model, "estimator_") and hasattr(model.estimator_, "feature_names_in_"):
fn = model.estimator_.feature_names_in_
if fn is not None:
for col in fn:
if col not in X.columns:
X[col] = 0
X = X[fn]
pred = model.predict(X)[0]
proba = model.predict_proba(X)[0]
is_pos = int(pred) == 1
wrap_cls = "positive" if is_pos else "negative"
verd_cls = "pos" if is_pos else "neg"
verdict_txt = "POSITIF HANTAVIRUS" if is_pos else "NEGATIF HANTAVIRUS"
verdict_sub = (
"Pasien terindikasi terinfeksi Hantavirus. Segera lakukan konfirmasi laboratorium lebih lanjut."
if is_pos else
"Tidak terdeteksi indikasi infeksi Hantavirus berdasarkan data klinis yang diinput."
)
# 1. Prediction card
st.markdown(f"""
<div class="result-wrap {wrap_cls}">
<div class="result-meta">Hasil Prediksi · HantaVarNet (Decision Tree + Threshold Tuning)</div>
<div class="result-verdict {verd_cls}">{verdict_txt}</div>
<div class="result-sub">{verdict_sub}</div>
<div class="conf-title">Confidence Score</div>
{conf_bar("Negatif", proba[0]*100, "neg")}
{conf_bar("Positif", proba[1]*100, "pos")}
</div>
""", unsafe_allow_html=True)
# 2. Decision path
st.markdown('<span class="section-label">🌿 Alasan Prediksi — Jalur Tree</span>', unsafe_allow_html=True)
path_steps = get_decision_path(model, X)
if path_steps:
st.markdown(render_decision_path_html(path_steps), unsafe_allow_html=True)
else:
st.info("Jalur keputusan tidak tersedia untuk input ini.")
# 3. Parameter chips
triggered = get_triggered_features(creatinine, alt, ast, bun, platelet)
st.markdown('<span class="section-label">📋 Ringkasan Parameter</span>', unsafe_allow_html=True)
st.markdown(render_param_chips_html(creatinine, alt, ast, bun, platelet, triggered), unsafe_allow_html=True)
# 4. Recommendations
st.markdown('<span class="section-label">💊 Rekomendasi Klinis</span>', unsafe_allow_html=True)
st.markdown(render_recommendations_html(triggered), unsafe_allow_html=True)
# 5. Disclaimer
st.markdown("""
<div class="disclaimer">
⚠️ <strong>Perhatian:</strong> Hasil prediksi dan rekomendasi ini bersifat indikatif dan
<strong>tidak menggantikan diagnosis klinis</strong> oleh tenaga medis profesional.
Rekomendasi dihasilkan berdasarkan aturan ambang batas dari Decision Tree yang ditraining,
bukan dari evaluasi medis langsung. Gunakan sebagai alat bantu skrining awal saja.
</div>
""", unsafe_allow_html=True)