"""BioIntel — biotech catalyst and wet-lab demand intelligence. Two public sources, joined into one view: * SEC EDGAR 8-K full-text search — catalysts that have already happened * ClinicalTrials.gov v2 — dated catalysts that have not happened yet Data is produced by the ingest pipeline and published as parquet to a Hugging Face Dataset repo; this app downloads it once at startup and queries it in memory. Not investment advice. """ from __future__ import annotations import json import os from datetime import datetime, timezone from pathlib import Path import gradio as gr import pandas as pd import plotly.express as px import plotly.graph_objects as go DATASET_REPO = os.environ.get("BIOINTEL_DATASET", "Umesh1608/biointel-data") # Dossiers live in a separate *static* Space: it renders HTML natively, never # sleeps, and costs nothing against the PRO allowance. REPORTS_BASE = os.environ.get( "BIOINTEL_REPORTS", "https://umesh1608-biointel-reports.static.hf.space") LOCAL_DATA = Path(os.environ.get("BIOINTEL_DATA", Path.home() / "biointel" / "data")) # A restrained palette: one accent, one alarm colour, everything else neutral. INK, ACCENT, ALARM, MUTED = "#0F172A", "#0D9488", "#DC2626", "#64748B" SERIES = ["#0D9488", "#0EA5E9", "#8B5CF6", "#F59E0B", "#DC2626", "#10B981", "#6366F1", "#EC4899", "#14B8A6", "#F97316", "#64748B", "#84CC16"] NEGATIVE_SIGNALS = {"endpoint_missed", "crl", "clinical_hold", "discontinue"} # -------------------------------------------------------------------------- # data # -------------------------------------------------------------------------- def _load(name: str, parquet: bool = True): local = LOCAL_DATA / (f"{name}.parquet" if parquet else name) if local.exists(): return pd.read_parquet(local) if parquet else json.loads(local.read_text()) try: from huggingface_hub import hf_hub_download p = hf_hub_download(DATASET_REPO, f"data/{name}.parquet" if parquet else f"data/{name}", repo_type="dataset") return pd.read_parquet(p) if parquet else json.loads(Path(p).read_text()) except Exception as e: # noqa: BLE001 — render the shell even with no data print(f"[warn] could not load {name}: {e}") return pd.DataFrame() if parquet else {} CATALYSTS = _load("catalysts") READOUTS = _load("readouts") MANIFEST = _load("manifest.json", parquet=False) PROC_DATE = (MANIFEST.get("generated_at") or "unknown")[:10] for _df, _col in ((CATALYSTS, "filed_date"), (READOUTS, "primary_completion")): if not _df.empty: _df["_dt"] = pd.to_datetime(_df[_col], errors="coerce") def _style(fig, height=380): """One chart style everywhere: no chartjunk, muted grid, honest axes.""" fig.update_layout( height=height, margin=dict(l=8, r=8, t=48, b=8), font=dict(family="Inter, ui-sans-serif, system-ui", size=12.5), title=dict(font=dict(size=15), x=0, xanchor="left"), plot_bgcolor="rgba(0,0,0,0)", paper_bgcolor="rgba(0,0,0,0)", legend=dict(title_text="", orientation="h", y=-0.18, font=dict(size=11)), colorway=SERIES, hoverlabel=dict(font_size=12), ) fig.update_xaxes(showgrid=False, linecolor="rgba(100,116,139,.25)", title=None) fig.update_yaxes(gridcolor="rgba(100,116,139,.15)", zeroline=False, linecolor="rgba(0,0,0,0)", title=None) return fig def _empty(msg="No data loaded"): f = go.Figure() f.add_annotation(text=msg, showarrow=False, font=dict(size=14, color=MUTED)) f.update_layout(height=320, plot_bgcolor="rgba(0,0,0,0)", paper_bgcolor="rgba(0,0,0,0)", xaxis=dict(visible=False), yaxis=dict(visible=False)) return f # -------------------------------------------------------------------------- # header # -------------------------------------------------------------------------- def kpis() -> str: if CATALYSTS.empty and READOUTS.empty: return "
No data
" n_cat = len(CATALYSTS) n_neg = int(CATALYSTS.negative.sum()) if not CATALYSTS.empty else 0 n_co = CATALYSTS.company.nunique() if not CATALYSTS.empty else 0 n_ro = len(READOUTS) n_ph3 = int(READOUTS.phase.str.contains("Ph3", na=False).sum()) if not READOUTS.empty else 0 cards = [ ("Catalyst events", f"{n_cat:,}", "8-K filings · last 90 days"), ("Companies", f"{n_co:,}", "distinct issuers"), ("Negative signals", f"{n_neg:,}", f"{n_neg / max(n_cat, 1):.0%} of events"), ("Upcoming readouts", f"{n_ro:,}", "industry trials · next 12 mo"), ("Phase 3 ahead", f"{n_ph3:,}", "highest-stakes subset"), ] html = "".join( f"
{lbl}
" f"
{val}
{sub}
" for lbl, val, sub in cards) return f"
{html}
" def freshness() -> str: ts = MANIFEST.get("generated_at") if not ts: return "
⚠ No manifest found — data may be missing.
" try: gen = datetime.fromisoformat(ts) age = (datetime.now(timezone.utc) - gen).total_seconds() / 3600 except ValueError: return f"
Data generated {ts}
" cls = "bi-stale" if age > 48 else "bi-fresh" note = " — stale, the refresh job may have failed" if age > 48 else "" return (f"
● Data generated " f"{gen:%d %b %Y, %H:%M} UTC · {age:.0f}h ago{note}
") # -------------------------------------------------------------------------- # tab 1 — catalyst feed # -------------------------------------------------------------------------- CAT_COLS = ["filed_date", "ticker", "company", "signal", "negative", "url"] def filter_catalysts(signals, neg_only, search, limit): df = CATALYSTS if df.empty: return pd.DataFrame({"note": ["No catalyst data loaded."]}) if signals: df = df[df.signal.isin(signals)] if neg_only: df = df[df.negative] if search: s = search.strip().lower() df = df[df.company.str.lower().str.contains(s, na=False) | df.ticker.fillna("").str.lower().str.contains(s)] out = df.sort_values("filed_date", ascending=False)[CAT_COLS].head(int(limit)).copy() out["negative"] = out.negative.map({True: "▼ negative", False: ""}) return out.rename(columns={"filed_date": "Filed", "ticker": "Ticker", "company": "Company", "signal": "Signal", "negative": "Tone", "url": "Filing"}) def catalyst_trend(signals): df = CATALYSTS if df.empty: return _empty() if signals: df = df[df.signal.isin(signals)] if df.empty: return _empty("No events match these filters") d = df.copy() d["week"] = d._dt.dt.to_period("W").dt.start_time g = d.groupby(["week", "signal"]).size().reset_index(name="filings") return _style(px.bar(g, x="week", y="filings", color="signal", title="Catalyst filings per week")) def catalyst_mix(signals): df = CATALYSTS if df.empty: return _empty() if signals: df = df[df.signal.isin(signals)] if df.empty: return _empty("No events match these filters") g = df.signal.value_counts().reset_index() g.columns = ["signal", "events"] g["tone"] = g.signal.isin(NEGATIVE_SIGNALS).map( {True: "negative", False: "neutral / positive"}) fig = px.bar(g, x="events", y="signal", orientation="h", color="tone", color_discrete_map={"negative": ALARM, "neutral / positive": ACCENT}, title="Which signals are firing") fig.update_layout(yaxis=dict(autorange="reversed")) return _style(fig) # -------------------------------------------------------------------------- # tab 2 — readout calendar # -------------------------------------------------------------------------- RO_COLS = ["primary_completion", "date_precision", "phase", "sponsor", "modality", "conditions", "enrollment", "url"] def filter_readouts(phases, modalities, sponsor, months, exact_only, limit): df = READOUTS if df.empty: return pd.DataFrame({"note": ["No readout data loaded."]}) if phases: df = df[df.phase.isin(phases)] if modalities: df = df[df.modality.isin(modalities)] if sponsor: df = df[df.sponsor.str.lower().str.contains(sponsor.strip().lower(), na=False)] if exact_only and "date_precision" in df.columns: df = df[df.date_precision == "day"] if months: cutoff = pd.Timestamp.today() + pd.DateOffset(months=int(months)) df = df[df._dt <= cutoff] cols = [c for c in RO_COLS if c in df.columns] out = df.sort_values("primary_completion")[cols].head(int(limit)).copy() if "date_precision" in out.columns: out["date_precision"] = out.date_precision.map( {"day": "exact", "month": "month only", "year": "year only"}).fillna("—") return out.rename(columns={"primary_completion": "Readout due", "date_precision": "Date precision", "phase": "Phase", "sponsor": "Sponsor", "modality": "Modality", "conditions": "Condition", "enrollment": "N", "url": "Registry"}) def readout_chart(phases, modalities): df = READOUTS if df.empty: return _empty() if phases: df = df[df.phase.isin(phases)] if modalities: df = df[df.modality.isin(modalities)] if df.empty: return _empty("No trials match these filters") d = df.copy() d["month"] = d._dt.dt.to_period("M").dt.start_time d["precision"] = (d.date_precision.map({"day": "exact date", "month": "month only"}) .fillna("month only") if "date_precision" in d.columns else "unknown") g = d.groupby(["month", "precision"]).size().reset_index(name="trials") fig = px.bar(g, x="month", y="trials", color="precision", color_discrete_map={"exact date": ACCENT, "month only": "#CBD5E1"}, title="Readouts by month — split by how precise the date is") return _style(fig) def modality_chart(phases): df = READOUTS if df.empty: return _empty() if phases: df = df[df.phase.isin(phases)] if df.empty: return _empty("No trials match these filters") g = df.modality.value_counts().reset_index() g.columns = ["modality", "trials"] fig = px.bar(g.head(12), x="trials", y="modality", orientation="h", title="Modality mix of the forward pipeline") fig.update_traces(marker_color=ACCENT) fig.update_layout(yaxis=dict(autorange="reversed")) return _style(fig) def phase_chart(): if READOUTS.empty: return _empty() g = READOUTS.phase.value_counts().reset_index() g.columns = ["phase", "trials"] fig = px.pie(g.head(8), names="phase", values="trials", hole=0.62, title="Phase mix") fig.update_traces(textposition="outside", textinfo="label+percent") return _style(fig) def _choices(df, col): return sorted(df[col].dropna().unique().tolist()) if not df.empty else [] # -------------------------------------------------------------------------- # tab 3 — pivotal (Phase 3) readouts # -------------------------------------------------------------------------- # "Pivotal" = anything carrying a Phase 3 label, so Ph2/Ph3 seamless designs # are included: they are registrational too, and excluding them would hide # real risk. PIVOTAL = (READOUTS[READOUTS.phase.str.contains("Ph3", na=False)].copy() if not READOUTS.empty else pd.DataFrame()) PIV_COLS = ["primary_completion", "date_precision", "phase", "sponsor", "enrollment", "modality", "conditions", "url"] def pivotal_kpis() -> str: if PIVOTAL.empty: return "
No pivotal trials
" soon = int((PIVOTAL._dt <= pd.Timestamp.today() + pd.DateOffset(days=90)).sum()) exact = int((PIVOTAL.date_precision == "day").sum()) med = PIVOTAL.enrollment.median() cards = [ ("Pivotal readouts", f"{len(PIVOTAL):,}", "Phase 3 or Ph2/Ph3"), ("Due in 90 days", f"{soon:,}", "nearest-term risk"), ("Exact-dated", f"{exact / len(PIVOTAL):.0%}", f"{exact:,} of {len(PIVOTAL):,}"), ("Sponsors", f"{PIVOTAL.sponsor.nunique():,}", "distinct companies"), ("Median enrolment", f"{med:,.0f}" if pd.notna(med) else "—", "patients per trial"), ] html = "".join( f"
{lbl}
" f"
{val}
{sub}
" for lbl, val, sub in cards) return f"
{html}
" def filter_pivotal(days, exact_only, sponsor, modalities, min_n, limit): df = PIVOTAL if df.empty: return pd.DataFrame({"note": ["No pivotal trial data loaded."]}) if days: df = df[df._dt <= pd.Timestamp.today() + pd.DateOffset(days=int(days))] if exact_only: df = df[df.date_precision == "day"] if sponsor: df = df[df.sponsor.str.lower().str.contains(sponsor.strip().lower(), na=False)] if modalities: df = df[df.modality.isin(modalities)] if min_n: df = df[df.enrollment.fillna(0) >= int(min_n)] out = df.sort_values("primary_completion")[PIV_COLS + ["nct_id"]].head(int(limit)).copy() out["date_precision"] = out.date_precision.map( {"day": "exact", "month": "month only", "year": "year only"}).fillna("—") # Markdown link so the dossier opens in its own window. out["dossier"] = out.nct_id.map( lambda n: f"[open report]({REPORTS_BASE}/{n}.html)") out = out.drop(columns=["nct_id"]) return out.rename(columns={"dossier": "Dossier", "primary_completion": "Readout due", "date_precision": "Date precision", "phase": "Phase", "sponsor": "Sponsor", "enrollment": "Patients", "modality": "Modality", "conditions": "Condition", "url": "Registry"}) def pivotal_month_chart(exact_only): df = PIVOTAL if df.empty: return _empty() if exact_only: df = df[df.date_precision == "day"] if df.empty: return _empty("No pivotal trials match") d = df.copy() d["month"] = d._dt.dt.to_period("M").dt.start_time d["precision"] = d.date_precision.map( {"day": "exact date", "month": "month only"}).fillna("month only") g = d.groupby(["month", "precision"]).size().reset_index(name="trials") fig = px.bar(g, x="month", y="trials", color="precision", color_discrete_map={"exact date": ACCENT, "month only": "#CBD5E1"}, title="Pivotal readouts by month") return _style(fig) def pivotal_sponsor_chart(days): df = PIVOTAL if df.empty: return _empty() if days: df = df[df._dt <= pd.Timestamp.today() + pd.DateOffset(days=int(days))] if df.empty: return _empty("No pivotal trials in this window") g = df.sponsor.value_counts().head(12).reset_index() g.columns = ["sponsor", "trials"] g["sponsor"] = g.sponsor.str.slice(0, 34) fig = px.bar(g, x="trials", y="sponsor", orientation="h", title="Most pivotal readouts ahead, by sponsor") fig.update_traces(marker_color=SERIES[1]) fig.update_layout(yaxis=dict(autorange="reversed")) return _style(fig) # -------------------------------------------------------------------------- # styling # -------------------------------------------------------------------------- CSS = """ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); .gradio-container { font-family:'Inter',ui-sans-serif,system-ui !important; max-width:1460px !important; } #bi-hero { padding:6px 0 0; } #bi-hero h1 { font-size:2.1rem; font-weight:700; letter-spacing:-.028em; margin:0; line-height:1.1; } #bi-hero p { margin:.35rem 0 0; color:#64748B; font-size:1.02rem; } .bi-kpis { display:grid; grid-template-columns:repeat(auto-fit,minmax(170px,1fr)); gap:12px; margin:14px 0 8px; } .bi-card { background:var(--block-background-fill); border:1px solid var(--border-color-primary); border-radius:14px; padding:14px 16px; } .bi-label { font-size:.7rem; font-weight:600; letter-spacing:.08em; text-transform:uppercase; color:#64748B; } .bi-value { font-size:1.9rem; font-weight:700; line-height:1.15; margin:.2rem 0; font-variant-numeric:tabular-nums; letter-spacing:-.02em; } .bi-sub { font-size:.75rem; color:#94A3B8; } .bi-fresh, .bi-stale { display:inline-block; font-size:.8rem; padding:5px 12px; border-radius:999px; margin:6px 0 2px; font-weight:500; } .bi-fresh { background:#ECFDF5; color:#047857; border:1px solid #A7F3D0; } .bi-stale { background:#FEF2F2; color:#B91C1C; border:1px solid #FECACA; } .dark .bi-fresh { background:#06281F; border-color:#115E4A; color:#5EEAD4; } .dark .bi-stale { background:#2A1113; border-color:#7F1D1D; color:#FCA5A5; } .bi-note { font-size:.84rem; color:#64748B; border-left:3px solid #0D9488; padding:3px 0 3px 12px; margin:6px 0 14px; line-height:1.5; } footer, .built-with { display:none !important; } .tab-nav button { font-weight:600 !important; font-size:.95rem !important; } """ ABOUT = f""" ## What this shows Two public sources, joined into one view of biotech catalysts. **SEC EDGAR 8-K full-text search** — *what already happened.* An 8-K is how a US-listed company must announce a material event, so it is the closest public thing to a ground-truth catalyst feed. Twelve signal types are matched on phrase groups chosen by **measurement, not guesswork**: over a sample window *"met its primary endpoint"* appeared in 54 filings where *"met the primary endpoint"* appeared in 19, so both are searched. EDGAR accepts uppercase `OR` between quoted phrases — a `|` silently returns zero results. **ClinicalTrials.gov API v2** — *what is coming.* A trial whose primary completion date falls next quarter is a dated, public, not-yet-happened catalyst. Only industry-sponsored trials are kept. ## Read these caveats before trusting a number * **40% of readout dates are month-only.** Sponsors often register just `2026-12`. Converting that to a day manufactures precision: in one sample 46% of trials landed on day 1 purely as an artifact of that conversion, and a further 28% sat on month-end placeholders. A naive chart shows a December "catalyst wall" of 913 trials that is mostly rounding. The calendar therefore **labels every date's precision** and can filter to exact dates. * **Signals co-occur, and that is real.** One 8-K can match both *endpoint met* and *endpoint missed* — a release reporting a hit on one endpoint and a miss on another. Treat the columns as flags, not a verdict. * **Trial dates slip.** A primary completion date is a plan, not a commitment. * **Modality is inferred**, not a registry field. It reports `unknown` rather than guessing, because a wrong label is worse than a missing one. * **Not every catalyst is an 8-K.** Conference presentations and releases not deemed material never appear. * **Both APIs truncate silently.** One EDGAR call returns at most 100 hits regardless of the reported total, and filtering trials client-side after a truncated fetch drops most of the result set. Both are paged and filtered server-side here. ## What is *not* here, and why The thesis this was modelled on promises CRO lab occupancy, lease renewal spreads and options flow. **Those are not obtainable from public data.** There is no free, redistributable source of options or intraday price data — OPRA licensing runs \\$650–1,500/month — so that signal is **not replicated** rather than approximated from a prohibited source. Public proxies for lab demand do exist and are the natural next addition. Facility-level utilisation is not among them: Charles River's own 10-K says it tracks room utilisation and prints no figure. ## Attribution and licensing **ClinicalTrials.gov.** Trial data courtesy of the U.S. National Library of Medicine, retrieved from the ClinicalTrials.gov API v2. **Processing date: {PROC_DATE}.** The data has been **modified** from source: filtered to industry-sponsored studies with a future primary completion date, dates normalised for sorting with their original precision retained in a separate column, and a `modality` label **inferred** by this project from intervention names and titles. NLM does not endorse this reuse. **SEC EDGAR.** Filings are public information and may be copied or further distributed without the SEC's permission. Accessed within the published 10 requests/second limit with a descriptive User-Agent. Sources whose terms forbid redistribution or automated retrieval are deliberately **excluded** — PMC full text, EPO OPS, and every commercial market-data free tier. The PatentsView API was retired in March 2026. **An information tool built on public filings. Not investment advice.** """ with gr.Blocks(title="BioIntel", css=CSS, fill_width=True) as demo: gr.HTML("

BioIntel

Biotech catalyst " "intelligence from public filings and trial registries

") gr.HTML(freshness()) gr.HTML(kpis()) with gr.Tab("Catalyst feed"): gr.HTML("
8-K filings matching clinical and " "regulatory catalyst language. Tone marks signals that are " "usually bad news — though a single filing can carry both.
") with gr.Row(): f_sig = gr.Dropdown(_choices(CATALYSTS, "signal"), multiselect=True, label="Signal", scale=3) f_q = gr.Textbox(label="Company or ticker", placeholder="e.g. BridgeBio, EYPT", scale=2) f_neg = gr.Checkbox(label="Negative only", scale=1) f_lim = gr.Slider(25, 1000, value=150, step=25, label="Rows", scale=2) t_cat = gr.Dataframe(value=filter_catalysts([], False, "", 150), wrap=True, max_height=430) with gr.Row(): p_trend = gr.Plot(catalyst_trend([])) p_mix = gr.Plot(catalyst_mix([])) cins = [f_sig, f_neg, f_q, f_lim] for c in cins: c.change(filter_catalysts, cins, t_cat) f_sig.change(catalyst_trend, f_sig, p_trend) f_sig.change(catalyst_mix, f_sig, p_mix) with gr.Tab("Readout calendar"): gr.HTML("
Industry trials with a primary completion " "date ahead. 40% of registry dates are month-only — the " "precision column says which, and the chart separates them so a " "rounding artifact cannot read as a catalyst cluster.
") with gr.Row(): r_ph = gr.Dropdown(_choices(READOUTS, "phase"), multiselect=True, label="Phase", scale=2) r_mod = gr.Dropdown(_choices(READOUTS, "modality"), multiselect=True, label="Modality", scale=2) r_sp = gr.Textbox(label="Sponsor contains", placeholder="e.g. Novartis", scale=2) r_mo = gr.Slider(1, 12, value=6, step=1, label="Within months", scale=2) r_ex = gr.Checkbox(label="Exact dates only", scale=1) r_lim = gr.Slider(25, 1000, value=150, step=25, label="Rows", scale=1) t_ro = gr.Dataframe(value=filter_readouts([], [], "", 6, False, 150), wrap=True, max_height=430) with gr.Row(): p_ro = gr.Plot(readout_chart([], [])) p_mod = gr.Plot(modality_chart([])) rins = [r_ph, r_mod, r_sp, r_mo, r_ex, r_lim] for c in rins: c.change(filter_readouts, rins, t_ro) for c in (r_ph, r_mod): c.change(readout_chart, [r_ph, r_mod], p_ro) r_ph.change(modality_chart, r_ph, p_mod) with gr.Tab("Pivotal readouts"): gr.HTML("
Phase 3 and Ph2/Ph3 trials only — the " "readouts that reprice a company. Exact dates only is on " "by default here: a pivotal date that is really just a month is " "not something you can plan around. Each row links to a " "dossier — design, endpoint, scale and sponsor context " "— which opens in its own window.
") gr.HTML(pivotal_kpis()) with gr.Row(): v_days = gr.Slider(30, 365, value=180, step=30, label="Within days", scale=2) v_sp = gr.Textbox(label="Sponsor contains", placeholder="e.g. AstraZeneca", scale=2) v_mod = gr.Dropdown(_choices(PIVOTAL, "modality"), multiselect=True, label="Modality", scale=2) v_n = gr.Slider(0, 2000, value=0, step=50, label="Min patients", scale=2) v_ex = gr.Checkbox(label="Exact dates only", value=True, scale=1) v_lim = gr.Slider(25, 1000, value=200, step=25, label="Rows", scale=1) t_piv = gr.Dataframe( value=filter_pivotal(180, True, "", [], 0, 200), wrap=True, max_height=430, datatype=["str", "str", "str", "str", "number", "str", "str", "markdown", "markdown"]) with gr.Row(): p_pm = gr.Plot(pivotal_month_chart(True)) p_ps = gr.Plot(pivotal_sponsor_chart(180)) vins = [v_days, v_ex, v_sp, v_mod, v_n, v_lim] for c in vins: c.change(filter_pivotal, vins, t_piv) v_ex.change(pivotal_month_chart, v_ex, p_pm) v_days.change(pivotal_sponsor_chart, v_days, p_ps) with gr.Tab("Pipeline mix"): gr.HTML("
Modality and phase mix of the forward " "pipeline — the closest public read on what kind of wet-lab and " "manufacturing capacity will be needed.
") with gr.Row(): gr.Plot(modality_chart([])) gr.Plot(phase_chart()) with gr.Tab("About & caveats"): gr.Markdown(ABOUT) gr.HTML("
" "Trial data courtesy of the U.S. National Library of Medicine " "(ClinicalTrials.gov), modified — see About. SEC filings are public " "domain. Not investment advice.
") if __name__ == "__main__": demo.launch(theme=gr.themes.Soft(primary_hue="teal", secondary_hue="slate"), server_name="0.0.0.0")