CoolFace
Apppublic

TechKay/AI_Security_Advisor

sourceHugging Faceupdated 10mo agoView on Hugging Face
0likes
app.py682 linesDownload Raw Back to root
1# AI Security Advisor (Static Dashboard)2# - Paste/upload logs OR generate synthetic "live" logs3# - Heuristic detection + optional LLM summary via Hugging Face4# - Downloadable PDF report5 6import os, re, io7from datetime import datetime, timedelta8import random9 10import gradio as gr11import pandas as pd12import matplotlib13matplotlib.use("Agg")  # headless for Spaces14import matplotlib.pyplot as plt15import textwrap16 17# LLM client18try:19    from huggingface_hub import InferenceClient20    HAVE_HF = True21except Exception:22    HAVE_HF = False23 24# reportlab25try:26    from reportlab.lib.pagesizes import letter27    from reportlab.pdfgen import canvas28    HAVE_PDF = True29except Exception:30    HAVE_PDF = False31 32# ---------- Sample Logs (baseline) ----------33SAMPLE_LOGS = """\342025-10-21 08:02:14 AUTH host=app01 user=svc_sync event=LOGIN_FAIL src_ip=185.199.220.23 detail=invalid password352025-10-21 08:02:18 AUTH host=app01 user=svc_sync event=LOGIN_FAIL src_ip=185.199.220.23 detail=invalid password362025-10-21 08:03:01 STORAGE host=stg01 event=LATENCY_SPIKE target=iscsi-03 value_ms=289 threshold_ms=50372025-10-21 08:03:44 NET host=core-sw12 event=PKT_DROP iface=eth0 drops=327 interval=60s382025-10-21 08:04:33 EDR host=wks-445 event=BEACON dst_ip=198.51.100.5 status=isolated392025-10-21 08:05:12 COMPUTE host=vm221 event=CPU_HIGH pct=95 duration=5m402025-10-21 08:05:56 STORAGE host=raid-b event=DEGRADED disk=disk2 status=offline412025-10-21 08:07:08 NET host=edge-fw event=OUT_SPIKE vlan=200 outbound_mbps=1200 baseline_mbps=200422025-10-21 08:08:30 COMPUTE host=vm203 event=RESTORE action=snapshot reason=failed_boot432025-10-21 08:09:02 BACKUP host=bkp01 event=JOB_FAIL job=daily_full_1 reason=timeout node=sn-07442025-10-21 08:09:27 AUTH host=netops-gw user=netops event=SSH_FAIL src_ip=192.0.2.14 count=5 interval=1m452025-10-21 08:10:11 NET host=edge-rtr event=BGP_RESET peer=10.0.0.254462025-10-21 08:11:02 FACILITY host=ups-2 event=UPS_ON_BAT runtime=18m47""".strip()48 49# ---------- Log Normalizer ----------50LOG_RE = re.compile(51    r'(?P<ts>\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+'52    r'(?P<src>[A-Z]+)\s+'53    r'host=(?P<host>\S+)'54    r'(?:\s+user=(?P<user>\S+))?'55    r'(?:\s+event=(?P<event>\S+))?'56    r'(?:\s+src_ip=(?P<src_ip>\S+))?'57    r'(?:\s+dst_ip=(?P<dst_ip>\S+))?'58    r'(?:\s+detail=(?P<detail>.+?))?'59    r'(?:\s+pct=(?P<pct>\d+))?'60    r'(?:\s+drops=(?P<drops>\d+))?'61    r'(?:\s+value_ms=(?P<value_ms>\d+))?'62    r'(?:\s+baseline_mbps=(?P<bbps>\d+))?'63    r'(?:\s+outbound_mbps=(?P<ombps>\d+))?'64    r'(?:\s+count=(?P<count>\d+))?'65    r'(?:\s+event=\S+)?'66)67 68def parse_logs(raw: str) -> pd.DataFrame:69    rows = []70    for line in (raw or "").splitlines():71        m = LOG_RE.match(line.strip())72        if not m:73            continue74        d = m.groupdict()75        d["ts_dt"] = datetime.strptime(d["ts"], "%Y-%m-%d %H:%M:%S")76        rows.append(d)77    if not rows:78        return pd.DataFrame()79    df = pd.DataFrame(rows).fillna("")80    for col in ("pct", "drops", "value_ms", "bbps", "ombps", "count"):81        if col in df.columns:82            df[col] = pd.to_numeric(df[col], errors="coerce")83    return df84 85# ---------- Heuristic Detector ----------86def detect_candidates(df: pd.DataFrame) -> pd.DataFrame:87    if df.empty:88        return df89    out = []90    for _, r in df.iterrows():91        src = r.get("src", "")92        ev = r.get("event", "")93        sev = None94        conf = 0.695        reason = []96 97        if src == "AUTH" and ("LOGIN_FAIL" in ev or "SSH_FAIL" in ev):98            sev = "MEDIUM"; reason.append("Repeated authentication failures")99            if pd.notna(r.get("count")) and r["count"] and r["count"] >= 5:100                sev = "HIGH"; conf = 0.85101 102        if src == "EDR" and "BEACON" in ev:103            sev = "HIGH"; conf = 0.9; reason.append("Endpoint beacon suggests possible C2")104 105        if src == "STORAGE" and ("DEGRADED" in ev or (pd.notna(r.get("value_ms")) and r["value_ms"] > 200)):106            sev = "HIGH"; conf = max(conf, 0.8); reason.append("Storage issue may impact latency/availability")107 108        if src == "NET" and any(x in ev for x in ("PKT_DROP", "OUT_SPIKE", "BGP_RESET")):109            sev = "HIGH"; conf = max(conf, 0.8); reason.append("Network instability / possible exfil or routing fault")110 111        if src == "COMPUTE" and any(x in ev for x in ("CPU_HIGH", "RESTORE")):112            sev = "MEDIUM"; conf = max(conf, 0.7); reason.append("Compute performance/boot recovery anomaly")113 114        if src == "BACKUP" and "JOB_FAIL" in ev:115            sev = "MEDIUM"; conf = max(conf, 0.7); reason.append("Backup failure could risk RPO/RTO")116 117        if src == "FACILITY" and "UPS_ON_BAT" in ev:118            sev = "HIGH"; conf = max(conf, 0.85); reason.append("Power risk: UPS on battery")119 120        if sev:121            out.append({122                "timestamp": r["ts"],123                "source": src,124                "host": r.get("host", ""),125                "event": ev,126                "severity": sev,127                "confidence": round(conf, 2),128                "note": "; ".join(reason)129            })130    return pd.DataFrame(out)131 132# ---------- Color-coded Incident Table ----------133def render_candidates_md(cand_df: pd.DataFrame) -> str:134    if cand_df.empty:135        return "<div style='color:#9ca3af;'>✅ No candidate incidents detected.</div>"136 137    html = """138    <style>139        .incident-table { width:100%; border-collapse:separate; border-spacing:0 8px; color:#e5e7eb; font-family:Inter;}140        .incident-header th {141            background:rgba(255,255,255,0.08);142            padding:10px;143            text-align:left;144            font-size:14px;145            color:#cbd5e1;146            border-bottom:1px solid rgba(255,255,255,0.1);147        }148        .incident-row {149            background:rgba(255,255,255,0.05);150            border-radius:10px;151            cursor:pointer;152            transition:0.2s;153        }154        .incident-row:hover { background:rgba(255,255,255,0.12); }155 156        .incident-row td { padding:12px; font-size:14px; }157 158        .details {159            display:none;160            background:rgba(255,255,255,0.03);161            padding:12px;162            border-radius:10px;163            margin-bottom:12px;164            font-size:13px;165            color:#cbd5e1;166        }167        .sev-badge { font-weight:700; }168        .sev-high { color:#ff4b5c; }169        .sev-medium { color:#ffb453; }170        .sev-low { color:#4cd27f; }171 172        .sev-dot {173            height:10px; width:10px; border-radius:50%; display:inline-block; margin-right:6px;174        }175        .dot-high { background:#ff4b5c; box-shadow:0 0 6px #ff4b5c; }176        .dot-medium { background:#ffb453; box-shadow:0 0 6px #ffb453; }177        .dot-low { background:#4cd27f; box-shadow:0 0 6px #4cd27f; }178    </style>179 180    <script>181        function toggleDetails(id) {182            var elem = document.getElementById(id);183            elem.style.display = elem.style.display === "none" ? "block" : "none";184        }185    </script>186 187    <table class='incident-table'>188        <thead class='incident-header'>189            <tr>190                <th>Timestamp</th>191                <th>Source</th>192                <th>Host</th>193                <th>Event</th>194                <th>Severity</th>195                <th>Confidence</th>196            </tr>197        </thead>198        <tbody>199    """200 201    for idx, r in cand_df.iterrows():202        sev = r["severity"].upper()203        sev_html = {204            "HIGH": "<span class='sev-dot dot-high'></span><span class='sev-badge sev-high'>HIGH</span>",205            "MEDIUM": "<span class='sev-dot dot-medium'></span><span class='sev-badge sev-medium'>MEDIUM</span>",206            "LOW": "<span class='sev-dot dot-low'></span><span class='sev-badge sev-low'>LOW</span>"207        }.get(sev, sev)208 209        row_id = f"details_{idx}"210 211        html += f"""212        <tr class='incident-row' onclick="toggleDetails('{row_id}')">213            <td>{r['timestamp']}</td>214            <td>{r['source']}</td>215            <td>{r['host']}</td>216            <td>{r['event']}</td>217            <td>{sev_html}</td>218            <td>{r['confidence']}</td>219        </tr>220 221        <tr>222          <td colspan="6">223            <div id="{row_id}" class="details">224                <strong>Full Incident Details:</strong><br>225                <b>Source:</b> {r['source']}<br>226                <b>Host:</b> {r['host']}<br>227                <b>Event:</b> {r['event']}<br>228                <b>Severity:</b> {r['severity']}<br>229                <b>Confidence:</b> {r['confidence']}<br>230                <b>Notes:</b> {r['note']}<br>231                <b>Raw Data:</b> {r.to_dict()}232            </div>233          </td>234        </tr>235        """236 237    html += "</tbody></table>"238    return html239 240 241 242# ---------- Static Charts ----------243def fig_severity_counts(cand_df: pd.DataFrame):244    fig = plt.figure(figsize=(5.2, 3.2), dpi=140)245    plt.style.use("dark_background")246    if cand_df.empty:247        plt.text(0.5, 0.5, "No incidents", ha="center", va="center")248        return fig249    counts = cand_df["severity"].value_counts().reindex(250        ["LOW", "MEDIUM", "HIGH", "CRITICAL"]251    ).fillna(0)252    counts.plot(kind="bar")253    plt.title("Severity Distribution")254    plt.xlabel("Severity")255    plt.ylabel("Count")256    plt.tight_layout()257    return fig258 259def fig_trend(df: pd.DataFrame):260    fig = plt.figure(figsize=(5.6, 3.2), dpi=140)261    plt.style.use("dark_background")262    if df.empty:263        plt.text(0.5, 0.5, "No data", ha="center", va="center")264        return fig265    tmp = df.copy()266    tmp["minute"] = tmp["ts_dt"].dt.floor("min")267    series = tmp.groupby("minute").size()268    series.plot(marker="o")269    plt.title("Event Volume Over Time")270    plt.xlabel("Time (minute)")271    plt.ylabel("Events")272    plt.grid(True, alpha=0.3)273    plt.tight_layout()274    return fig275 276def fig_risk_heatmap(cand_df: pd.DataFrame):277    fig = plt.figure(figsize=(5, 4), dpi=140)278    plt.style.use("dark_background")279 280    if cand_df.empty:281        plt.text(0.5, 0.5, "No incidents", ha="center", va="center")282        return fig283 284    # Convert severity → numeric risk weight285    sev_map = {"LOW": 1, "MEDIUM": 2, "HIGH": 3, "CRITICAL": 4}286    cand_df["sev_score"] = cand_df["severity"].map(sev_map)287 288    # 2D heatmap matrix289    heat = cand_df.pivot_table(290        values="confidence",291        index="sev_score",292        columns="host",293        aggfunc="mean"294    )295 296    plt.imshow(heat, cmap="inferno", aspect="auto")297    plt.colorbar(label="Mean Confidence")298    plt.title("Risk Heatmap (Severity × Host)")299    plt.xlabel("Host")300    plt.ylabel("Severity Level")301    plt.xticks(range(len(heat.columns)), heat.columns, rotation=45)302    plt.yticks(range(len(heat.index)), heat.index)303    plt.tight_layout()304    return fig305 306 307# ---------- LLM Summary (HF chat) ----------308MODEL = os.getenv("LLM_MODEL", "Qwen/Qwen2.5-7B-Instruct")309HF_TOKEN = os.getenv("HF_USER")310 311def llm_summary(cand_df: pd.DataFrame, raw: str) -> str:312    if cand_df.empty:313        baseline = "No candidate incidents detected. Continue monitoring."314    else:315        sev_counts = cand_df["severity"].value_counts().to_dict()316        top_hosts = cand_df["host"].value_counts().head(3).to_dict()317        baseline = f"Incidents: {len(cand_df)} | Sev: {sev_counts} | Top hosts: {top_hosts}"318 319    if not (HF_TOKEN and HAVE_HF):320        return (321            f"⚠️ LLM unavailable — rule-based summary only.\n\n"322            f"**Overview:** {baseline}\n"323            f"**Action:** Review HIGH severity events first; "324            f"validate auth failures, EDR beacons, and network anomalies."325        )326 327    try:328        client = InferenceClient(token=HF_TOKEN)329        sys = (330            "You are an enterprise AI Security Advisor in a SOC. "331            "Analyze detected authentication, firewall, and endpoint anomalies. "332            "Prioritize HIGH/CRITICAL threats, highlight likely root causes, "333            "affected systems/users, and immediate remediation steps. "334            "Briefly mention false-positive risk and data/privacy considerations. "335            "Return 4–6 concise bullet points. No preambles or explanations of the prompt."336        )337        user = (338            f"Detected incidents (JSON): {cand_df.to_json(orient='records')[:6000]}\n\n"339            f"Raw logs (truncated):\n{raw[:2000]}"340        )341        resp = client.chat.completions.create(342            model=MODEL,343            messages=[{"role": "system", "content": sys},344                      {"role": "user", "content": user}],345            temperature=0.4,346            max_tokens=700,347        )348        return resp.choices[0].message["content"].strip()349    except Exception as e:350        return (351            f"⚠️ LLM error; using rule-based summary.\n\n"352            f"**Overview:** {baseline}\n"353            f"**Action:** Triage HIGH first; gather PCAP/EDR/auth history. ({e})"354        )355 356# ---------- Synthetic "Live" Log Generator ----------357AUTH_IPS = ["185.199.220.23", "192.0.2.14", "203.0.113.45"]358HOSTS = ["app01", "netops-gw", "edge-fw", "core-sw12", "wks-445", "vm221"]359EVENT_TEMPLATES = [360    "AUTH host={host} user=svc_sync event=LOGIN_FAIL src_ip={ip} detail=invalid password",361    "AUTH host={host} user=netops event=SSH_FAIL src_ip={ip} count=5 interval=1m",362    "NET host=edge-fw event=OUT_SPIKE vlan=200 outbound_mbps=1300 baseline_mbps=200",363    "NET host=core-sw12 event=PKT_DROP iface=eth0 drops=420 interval=60s",364    "EDR host=wks-445 event=BEACON dst_ip=198.51.100.5 status=isolated",365    "STORAGE host=raid-b event=DEGRADED disk=disk2 status=offline",366]367 368def generate_synthetic_logs(current_logs: str) -> str:369    """Simulate a 'live' snapshot of security logs."""370    base_time = datetime.now().replace(second=0, microsecond=0)371    lines = []372    for i in range(10):373        ts = (base_time + timedelta(seconds=i * 30)).strftime("%Y-%m-%d %H:%M:%S")374        tmpl = random.choice(EVENT_TEMPLATES)375        line = tmpl.format(376            host=random.choice(HOSTS),377            ip=random.choice(AUTH_IPS),378        )379        lines.append(f"{ts} {line}")380    new_block = "\n".join(lines)381    if current_logs.strip():382        return current_logs.strip() + "\n" + new_block383    return new_block384 385# ---------- Core Analysis ----------386def run_analysis(log_text, file_obj):387    if file_obj is not None:388        try:389            with open(file_obj.name, "r", encoding="utf-8", errors="ignore") as f:390                log_text = f.read()391        except Exception as e:392            return ("⚠️ File read error: " + str(e), "", None, None, None, "")393 394    df = parse_logs(log_text or "")395    cand = detect_candidates(df)396 397    total = len(df)398    incidents = len(cand)399    high = (cand["severity"] == "HIGH").sum() if not cand.empty else 0400 401    kpi_html = f"""402<div class="kpi-row">403  <div class="kpi-card">404    <div class="kpi-label">Total Events</div>405    <div class="kpi-value">{total}</div>406  </div>407  <div class="kpi-card">408    <div class="kpi-label">Candidate Incidents</div>409    <div class="kpi-value">{incidents}</div>410  </div>411  <div class="kpi-card kpi-high">412    <div class="kpi-label">High Severity</div>413    <div class="kpi-value">{high}</div>414  </div>415</div>416"""417 418    # Correct HTML table419    cand_md_text = render_candidates_md(cand)420 421    # All charts422    fig1 = fig_severity_counts(cand)423    fig2 = fig_trend(df)424    fig3 = fig_risk_heatmap(cand)425 426    summary = llm_summary(cand, log_text or "")427 428    # FIXED: cand_md_text (NOT cand_html)429    return (kpi_html, cand_md_text, fig1, fig2, fig3, summary)430 431 432# ---------- PDF Report Export ----------433def wrap_pdf_text(text, width=110):434    """Wrap long lines for PDF output."""435    wrapped = []436    for line in text.splitlines():437        wrapped.extend(textwrap.wrap(line, width=width))438    return wrapped439    440def render_candidates_text(cand_df: pd.DataFrame) -> list:441    """Return a plain-text list of incidents for PDF export."""442    if cand_df.empty:443        return ["No candidate incidents detected."]444 445    lines = []446    for _, r in cand_df.iterrows():447        lines.append(f"Timestamp:  {r['timestamp']}")448        lines.append(f"Source:     {r['source']}")449        lines.append(f"Host:       {r['host']}")450        lines.append(f"Event:      {r['event']}")451        lines.append(f"Severity:   {r['severity']}")452        lines.append(f"Confidence: {r['confidence']}")453        lines.append(f"Notes:      {r['note']}")454        lines.append("-" * 50)455 456    return lines457 458def generate_pdf_report(log_text, file_obj):459    if not HAVE_PDF:460        return None461 462    # recompute analysis for the report463    if file_obj is not None:464        try:465            with open(file_obj.name, "r", encoding="utf-8", errors="ignore") as f:466                log_text = f.read()467        except Exception:468            pass469 470    df = parse_logs(log_text or "")471    cand = detect_candidates(df)472    total = len(df)473    incidents = len(cand)474    high = (cand["severity"] == "HIGH").sum() if not cand.empty else 0475    summary = llm_summary(cand, log_text or "")476    cand_md_text = render_candidates_md(cand)477 478    buffer = io.BytesIO()479    c = canvas.Canvas(buffer, pagesize=letter)480    width, height = letter481 482    text_obj = c.beginText(40, height - 50)483    text_obj.setFont("Helvetica-Bold", 14)484    text_obj.textLine("AI Security Advisor — Summary Report")485    text_obj.setFont("Helvetica", 10)486    text_obj.textLine("")487    text_obj.textLine(f"Total events: {total}")488    text_obj.textLine(f"Candidate incidents: {incidents}")489    text_obj.textLine(f"High severity: {high}")490    text_obj.textLine("")491    text_obj.textLine("Executive Summary:")492    text_obj.textLine("")493 494    for line in wrap_pdf_text(summary, width=110):495        text_obj.textLine(line)496 497 498    text_obj.textLine("")499    text_obj.textLine("Candidate Incidents:")500    text_obj.textLine("(Markdown-style table)")501    cand_lines = render_candidates_text(cand)502    for line in cand_lines[:50]:503        text_obj.textLine(line[:110])504 505 506    c.drawText(text_obj)507    c.showPage()508    c.save()509    buffer.seek(0)510 511    # write to temp file for Gradio512    out_path = "security_report.pdf"513    with open(out_path, "wb") as f:514        f.write(buffer.read())515    return out_path516 517# ---------- Dark Theme CSS ----------518 519DARK_CSS = """520body {521  background: radial-gradient(circle at top, #1d2345 0, #030712 50%, #000000 100%);522}523 524/* Make ALL text in the Gradio app light/white */525.gradio-container,526.gradio-container * {527  color: #f9fafb !important;528  font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;529}530 531/* Headings */532h1, h2, h3, h4, h5, h6 {533  color: #f9fafb !important;534}535 536/* Markdown blocks */537.markdown, .markdown * {538  color: #f9fafb !important;539}540 541/* KPI cards */542.kpi-row {543  display: flex;544  gap: 0.75rem;545  margin-bottom: 0.75rem;546}547.kpi-card {548  flex: 1;549  padding: 0.75rem 1rem;550  border-radius: 0.9rem;551  background: linear-gradient(135deg, #0f172a, #111827);552  box-shadow: 0 10px 25px rgba(15,23,42,0.7);553  border: 1px solid rgba(148,163,184,0.35);554}555.kpi-card .kpi-label {556  font-size: 0.75rem;557  text-transform: uppercase;558  letter-spacing: 0.08em;559  color: #9ca3af;560}561.kpi-card .kpi-value {562  font-size: 1.6rem;563  font-weight: 700;564  margin-top: 0.25rem;565  color: #f9fafb;566}567.kpi-card.kpi-high {568  background: linear-gradient(135deg, #7f1d1d, #b91c1c);569  border-color: rgba(248,113,113,0.6);570}571 572/* Buttons */573button, .gr-button {574  border-radius: 999px !important;575}576 577/* ------------------------------- */578/* LOG INPUT TEXTBOX IN BLACK TEXT */579/* ------------------------------- */580.logs-box input,581.logs-box textarea {582  color: #000 !important;          /* Black text */583  background: #f3f4f6 !important;  /* Light gray background */584  border: 1px solid #d1d5db !important;585  font-family: monospace !important;586}587 588/* Keep the label white */589.logs-box label {590  color: #ffffff !important;591}592 593/* ============================== */594/* BLACK TEXT INPUT / OUTPUT BOX */595/* ============================== */596.black-box * {597    color: #000 !important;              /* Black text */598    background: #f3f4f6 !important;      /* Light grey background */599    border: 1px solid #d1d5db !important;600    font-family: monospace !important;601}602 603/* Keep labels white */604.black-box label {605    color: #ffffff !important;606}607 608"""609 610 611# ---------- UI ----------612with gr.Blocks(title="AI Security Advisor", css=DARK_CSS) as demo:613    gr.Markdown(614        "## AI Security Advisor\n"615        "An LLM-assisted SOC dashboard that inspects authentication, firewall, and endpoint logs, "616        "highlights potential threats, and summarizes risk posture."617    )618    with gr.Row():619        fig1 = gr.Plot(label="SD")620        fig2 = gr.Plot(label="EVOT")621        fig3 = gr.Plot(label="RHM")622 623    with gr.Row():624        with gr.Column(scale=1):625            gr.Markdown("### Log Input")626            logs_in = gr.Textbox(627                label="Paste Logs",628                lines=14,629                value=SAMPLE_LOGS,630                placeholder="Paste authentication, firewall, or endpoint logs here...",631                elem_classes=["logs-box"]632 633    )634            file_in = gr.File(label="Or Upload .txt Logs", file_types=["text"], elem_classes=["black-box"])635 636            with gr.Row():637                synth_btn = gr.Button("Generate Synthetic Live Snapshot", variant="secondary")638                run_btn = gr.Button("Run Analysis", variant="primary")639 640            gr.Markdown(641                "### SOC Notes\n"642                "- Synthetic logs simulate live authentication, network, and endpoint activity.\n"643                "- Do **not** upload real PII/PHI/PCI data to this demo Space.\n"644                "- LLM summaries are advisory and require human analyst validation."645            )646 647            pdf_btn = gr.Button("Generate PDF Report")648            pdf_out = gr.File(label="Download SOC Report (PDF)", elem_classes=["black-box"])649 650        with gr.Column(scale=2):651            gr.Markdown("### Threat Overview")652            kpi_out = gr.HTML()653 654            gr.Markdown("### Candidate Incidents (Color Coded)")655            cand_md = gr.HTML()656 657            658            gr.Markdown("### AI Security Advisor Summary")659            exec_sum = gr.Markdown()660 661    # wiring662    run_btn.click(663        fn=run_analysis,664        inputs=[logs_in, file_in],665        outputs=[kpi_out, cand_md, fig1, fig2, fig3, exec_sum]666    )667 668    synth_btn.click(669        fn=generate_synthetic_logs,670        inputs=[logs_in],671        outputs=[logs_in]672    )673 674    pdf_btn.click(675        fn=generate_pdf_report,676        inputs=[logs_in, file_in],677        outputs=[pdf_out]678    )679 680if __name__ == "__main__":681    demo.queue().launch()682