candle-fire / scripts /build_landscape.py
KevinIsInCoding
feat(landscape): bring experimental ALS therapy landscape to main (#19)
2e5367f unverified
Raw
History Blame Contribute Delete
17.7 kB
#!/usr/bin/env python3
"""
Build the Experimental ALS Therapy Landscape (offline) — v2: multi-label, grounded, abstaining.
Derives experimental therapies from clinical-trial interventions, then for each therapy:
1. retrieves genuine mechanism-of-action evidence (RAG over the paper corpus by drug name +
the therapy's own trial summaries) — NOT the noisy aggregated trial target_entities;
2. classifies MULTI-LABEL with Claude (Opus 4.8) via the Batch API — every mechanism must be
backed by a verbatim evidence quote, and the model abstains (empty list) when unsure;
3. cross-checks each asserted mechanism with a model-independent BioLORD cosine between the
therapy's evidence and the mechanism-class description (drops weakly-supported labels);
4. keeps only mechanisms above the confidence threshold τ; a therapy with none is "unclassified"
(shown honestly as "Mechanism not established"), never guessed.
Writes data/landscape/landscape.json (committed to git; the app loads it at startup).
Usage:
uv run python scripts/build_landscape.py
uv run python scripts/build_landscape.py --limit 30 # cheap validation on top 30 therapies
uv run python scripts/build_landscape.py --reset # ignore any in-flight batch state
"""
from __future__ import annotations
import argparse
import json
import re
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from dotenv import load_dotenv
load_dotenv()
import anthropic
import numpy as np
from anthropic.types.message_create_params import MessageCreateParamsNonStreaming
from anthropic.types.messages.batch_create_params import Request
from rich.console import Console
from config import (
LANDSCAPE_BATCH_STATE_PATH,
LANDSCAPE_EVIDENCE_ABSTRACTS,
LANDSCAPE_MIN_CONFIDENCE,
LANDSCAPE_MODEL,
LANDSCAPE_PATH,
LANDSCAPE_XCHECK_MIN_COSINE,
THERAPY_CLASSES_PATH,
TRIALS_PATH,
)
from prompts import LANDSCAPE_SYSTEM
from tools import LANDSCAPE_TOOLS
console = Console()
_THERAPEUTIC_TYPES = {"DRUG", "BIOLOGICAL", "DIETARY_SUPPLEMENT", "GENETIC", "COMBINATION_PRODUCT"}
_EXCLUDE_RE = re.compile(
r"(placebo|sham|matching|best supportive care|standard of care|blood sample|"
r"saline|vehicle|diagnostic|questionnaire|no intervention|usual care|dextrose)",
re.IGNORECASE,
)
_MODALITY_PREFIX_RE = re.compile(
r"^(drug|biological|device|other|dietary supplement|genetic|procedure|"
r"combination product|radiation|behavioral|diagnostic test)\s*:\s*",
re.IGNORECASE,
)
_STATUS_GROUP = {
"RECRUITING": "recruiting", "NOT_YET_RECRUITING": "recruiting", "ENROLLING_BY_INVITATION": "recruiting",
"ACTIVE_NOT_RECRUITING": "active",
"COMPLETED": "completed", "APPROVED_FOR_MARKETING": "completed", "AVAILABLE": "completed",
"TERMINATED": "terminated", "WITHDRAWN": "terminated", "SUSPENDED": "terminated",
"NO_LONGER_AVAILABLE": "terminated", "TEMPORARILY_NOT_AVAILABLE": "terminated",
}
_GROUP_ORDER = {"recruiting": 0, "active": 1, "completed": 2, "terminated": 3, "other": 4}
_THERAPIES_PER_REQUEST = 10 # richer evidence per therapy → smaller batches
_POLL_INTERVAL_S = 30
def _norm_name(name: str) -> str:
name = _MODALITY_PREFIX_RE.sub("", name or "").strip()
return re.sub(r"\s+", " ", name)
def _status_group(status: str) -> str:
return _STATUS_GROUP.get((status or "").upper(), "other")
def _load_trials(path: Path) -> list[dict]:
return [json.loads(line) for line in open(path, encoding="utf-8") if line.strip()]
def group_therapies(trials: list[dict]) -> dict[str, dict]:
"""Group trials by normalized therapeutic intervention name."""
groups: dict[str, dict] = {}
for t in trials:
trial_meta = {
"nct_id": t.get("nct_id", ""), "title": t.get("title", ""), "phase": t.get("phase", ""),
"status": t.get("status", ""), "status_group": _status_group(t.get("status", "")),
"start_date": t.get("start_date", ""), "sponsor": t.get("sponsor", ""),
"url": t.get("url", "") or f"https://clinicaltrials.gov/study/{t.get('nct_id','')}",
"summary": t.get("summary", ""),
}
for iv in t.get("interventions", []):
if iv.get("type") not in _THERAPEUTIC_TYPES:
continue
name = _norm_name(iv.get("name", ""))
if not name or _EXCLUDE_RE.search(name):
continue
key = name.lower()
g = groups.setdefault(key, {"display": name, "raw_names": set(), "trials": {}})
g["raw_names"].add(iv.get("name", ""))
g["trials"][trial_meta["nct_id"]] = trial_meta
for g in groups.values():
g["trials"] = list(g["trials"].values())
return groups
# ── evidence retrieval + cross-check embeddings ──────────────────────────────
def _load_collection():
try:
from config import CHROMA_COLLECTION, CHROMA_DIR
from rag.indexer import load_collection
return load_collection(CHROMA_DIR, CHROMA_COLLECTION)
except Exception as e:
console.print(f"[yellow]No chroma collection ({e}); evidence = trial summaries only, no cross-check[/yellow]")
return None
def _embed(texts: list[str]) -> np.ndarray:
from rag.indexer import _EMBED_FN
vecs = np.asarray(_EMBED_FN(texts), dtype=np.float32)
norms = np.linalg.norm(vecs, axis=1, keepdims=True)
return vecs / np.clip(norms, 1e-9, None)
def _retrieve_evidence(collection, g: dict) -> str:
"""MoA evidence for a therapy: top abstracts (by drug name) + its own trial summaries."""
parts: list[str] = []
summaries = [t["summary"] for t in g["trials"][:3] if t.get("summary")]
parts.extend(s[:400] for s in summaries)
if collection is not None:
try:
from rag.retriever import search
for r in search(collection, g["display"], n_results=LANDSCAPE_EVIDENCE_ABSTRACTS):
doc = (r.get("document") or "")[:500]
if doc:
parts.append(doc)
except Exception:
pass
return "\n".join(parts)[:3000]
# ── LLM batch ────────────────────────────────────────────────────────────────
def _therapy_context(key: str, g: dict) -> str:
lines = [
f"--- THERAPY_KEY:{key} ---",
f"Intervention name(s): {', '.join(sorted(g['raw_names']))[:160]}",
"EVIDENCE (paper abstracts about this therapy + its trial summaries):",
g.get("evidence", "(no evidence retrieved)"),
]
return "\n".join(lines)
def _format_batch(batch: list[tuple[str, dict]]) -> str:
parts = [
f"Classify each of the following {len(batch)} ALS experimental therapies from its EVIDENCE. "
"Call classify_therapy once per therapy (echo therapy_key). Emit only mechanisms you can quote "
"from the evidence; return an empty mechanisms list if the evidence establishes none.\n"
]
parts.extend(_therapy_context(key, g) for key, g in batch)
return "\n\n".join(parts)
def _build_params(batch: list[tuple[str, dict]]) -> MessageCreateParamsNonStreaming:
return MessageCreateParamsNonStreaming(
model=LANDSCAPE_MODEL,
max_tokens=8192,
system=LANDSCAPE_SYSTEM,
tools=list(LANDSCAPE_TOOLS),
tool_choice={"type": "any"},
messages=[{"role": "user", "content": _format_batch(batch)}],
)
def _run_batch(client, custom_id_to_batch: dict, state_path: Path, reset: bool) -> dict[str, dict]:
batch = None
if not reset and state_path.exists():
try:
existing = client.messages.batches.retrieve(json.loads(state_path.read_text())["batch_id"])
if existing.processing_status in {"in_progress", "validating", "finalizing", "ended"}:
console.print(f"[dim]Resuming batch {existing.id}[/dim]"); batch = existing
except anthropic.NotFoundError:
pass
if batch is None:
requests = [Request(custom_id=cid, params=_build_params(b)) for cid, b in custom_id_to_batch.items()]
batch = client.messages.batches.create(requests=requests)
state_path.parent.mkdir(parents=True, exist_ok=True)
state_path.write_text(json.dumps({"batch_id": batch.id}))
console.print(f"[cyan]Submitted batch {batch.id} ({len(requests)} requests, model={LANDSCAPE_MODEL})[/cyan]")
with console.status("Classifying therapies (batch)…"):
while batch.processing_status != "ended":
if batch.processing_status in {"canceling", "canceled", "expired"}:
console.print(f"[red]Batch ended early: {batch.processing_status}[/red]"); break
time.sleep(_POLL_INTERVAL_S)
batch = client.messages.batches.retrieve(batch.id)
records: dict[str, dict] = {}
for res in client.messages.batches.results(batch.id):
if res.result.type != "succeeded":
console.print(f"[yellow]Request {res.custom_id} {res.result.type}[/yellow]"); continue
for block in res.result.message.content:
if block.type == "tool_use" and block.name == "classify_therapy":
key = str(block.input.get("therapy_key", "")).strip()
if key:
records[key] = block.input
state_path.unlink(missing_ok=True)
return records
# ── filtering: confidence threshold + BioLORD cross-check ────────────────────
def _filter_all(records: dict, class_emb: dict, valid: set) -> dict[str, list[dict]]:
"""Per-mechanism filter: confidence ≥ τ AND justification↔class BioLORD cosine ≥ threshold.
The cross-check embeds each mechanism's own justification (evidence_quote) and compares it to
the claimed class description — catching internally-inconsistent labels (justification is about
a different mechanism than the class claimed). Batched so all embeddings are one pass.
"""
cand = [] # (key, class, role, conf, quote)
for key, rec in records.items():
for m in rec.get("mechanisms", []) or []:
cls = m.get("class")
if cls not in valid or float(m.get("confidence", 0.0)) < LANDSCAPE_MIN_CONFIDENCE:
continue
cand.append((key, cls, m.get("role", "contributing"),
float(m.get("confidence", 0.0)), (m.get("evidence_quote") or "")[:300]))
cosines = [None] * len(cand)
if class_emb and cand:
embs = _embed([q or cls for (_, cls, _, _, q) in cand])
for i, (_, cls, _, _, _) in enumerate(cand):
if cls in class_emb:
cosines[i] = float(np.dot(embs[i], class_emb[cls]))
out: dict[str, list[dict]] = {}
for i, (key, cls, role, conf, quote) in enumerate(cand):
cos = cosines[i]
if cos is not None and cos < LANDSCAPE_XCHECK_MIN_COSINE:
continue # justification doesn't semantically match the claimed class
out.setdefault(key, []).append({
"class": cls, "role": role, "confidence": round(conf, 2),
"xcheck_cosine": round(cos, 3) if cos is not None else None,
"evidence": quote,
})
for lst in out.values():
lst.sort(key=lambda m: (m["role"] != "primary", -m["confidence"]))
return out
# ── assembly ─────────────────────────────────────────────────────────────────
def _trial_block(m: dict) -> tuple[list[dict], dict]:
trials = sorted(m["_trials"].values(), key=lambda t: (_GROUP_ORDER.get(t["status_group"], 9), t.get("start_date", "")))
for t in trials:
t.pop("summary", None)
counts = {g: 0 for g in _GROUP_ORDER}
for t in trials:
counts[t["status_group"]] += 1
counts["total"] = len(trials)
return trials, counts
def build_landscape(limit=None, reset=False) -> dict:
taxonomy = json.loads(THERAPY_CLASSES_PATH.read_text())["classes"]
valid = {c["name"] for c in taxonomy}
trials = _load_trials(TRIALS_PATH)
groups = group_therapies(trials)
console.print(f"[cyan]{len(trials)} trials → {len(groups)} candidate therapies[/cyan]")
items = sorted(groups.items(), key=lambda kv: -len(kv[1]["trials"]))
if limit:
items = items[:limit]
console.print(f"[dim]--limit {limit}: top {len(items)} therapies[/dim]")
# evidence retrieval + cross-check embeddings (local, no API cost)
collection = _load_collection()
class_emb = {}
if collection is not None:
console.print("[dim]Embedding class descriptions + retrieving evidence (BioLORD)…[/dim]")
cls_vecs = _embed([f"{c['name']}: {c['description']}" for c in taxonomy])
class_emb = {c["name"]: cls_vecs[i] for i, c in enumerate(taxonomy)}
for _, g in items:
g["evidence"] = _retrieve_evidence(collection, g)
# classify
batches = [items[i : i + _THERAPIES_PER_REQUEST] for i in range(0, len(items), _THERAPIES_PER_REQUEST)]
client = anthropic.Anthropic()
records = _run_batch(client, {f"batch-{i}": b for i, b in enumerate(batches)}, LANDSCAPE_BATCH_STATE_PATH, reset)
console.print(f"[green]Classified {len(records)}/{len(items)} therapies[/green]")
# confidence threshold + justification↔class cross-check (batched)
filtered = _filter_all(records, class_emb, valid)
# merge by canonical name, attach trials
merged: dict[str, dict] = {}
for key, g in items:
rec = records.get(key)
if not rec:
continue
mechs = filtered.get(key, [])
canonical = (rec.get("canonical_name") or g["display"]).strip()
m = merged.setdefault(canonical.lower(), {
"name": canonical, "modality": rec.get("modality", "Other"),
"target": rec.get("target", "Unknown"), "aliases": set(),
"_mechs": {}, "_trials": {},
})
m["aliases"].update(a for a in rec.get("aliases", []) if a)
m["aliases"].update(g["raw_names"])
for me in mechs: # highest-confidence per class wins
cur = m["_mechs"].get(me["class"])
if cur is None or me["confidence"] > cur["confidence"]:
m["_mechs"][me["class"]] = me
for t in g["trials"]:
m["_trials"][t["nct_id"]] = t
# finalize therapies
therapies, abstained = [], []
for m in merged.values():
trials_, counts = _trial_block(m)
mechs = sorted(m["_mechs"].values(), key=lambda x: (x["role"] != "primary", -x["confidence"]))
base = {
"name": m["name"], "modality": m["modality"], "target": m["target"],
"aliases": sorted(a for a in m["aliases"] if a and a.lower() != m["name"].lower())[:8],
"mechanisms": mechs, "trials": trials_, "trial_counts": counts,
}
(therapies if mechs else abstained).append(base)
# group into taxonomy buckets (a therapy appears under EACH of its mechanism classes)
by_class: dict[str, list[dict]] = {}
for th in therapies:
for me in th["mechanisms"]:
entry = {**th, "role_here": me["role"], "confidence_here": me["confidence"]}
by_class.setdefault(me["class"], []).append(entry)
classifications = []
for cls in taxonomy:
ths = by_class.get(cls["name"], [])
if not ths:
continue
ths.sort(key=lambda t: (t["role_here"] != "primary", -t["trial_counts"]["recruiting"], -t["trial_counts"]["total"]))
classifications.append({
"id": cls["id"], "name": cls["name"], "description": cls["description"],
"primary_count": sum(1 for t in ths if t["role_here"] == "primary"),
"therapy_count": len(ths),
"trial_count": sum(t["trial_counts"]["total"] for t in ths if t["role_here"] == "primary"),
"therapies": ths,
})
return {
"generated_at": datetime.now(timezone.utc).isoformat(),
"model": LANDSCAPE_MODEL,
"thresholds": {"min_confidence": LANDSCAPE_MIN_CONFIDENCE, "min_xcheck_cosine": LANDSCAPE_XCHECK_MIN_COSINE},
"source": {"trials": len(trials), "therapies": len(therapies), "abstained": len(abstained)},
"classifications": classifications,
"unclassified": sorted(abstained, key=lambda t: -t["trial_counts"]["total"]),
}
def main() -> None:
parser = argparse.ArgumentParser(description="Build the ALS experimental therapy landscape (v2)")
parser.add_argument("--limit", type=int, default=None)
parser.add_argument("--reset", action="store_true")
args = parser.parse_args()
if not TRIALS_PATH.exists():
console.print(f"[red]Trials file not found: {TRIALS_PATH}[/red]"); sys.exit(1)
landscape = build_landscape(limit=args.limit, reset=args.reset)
LANDSCAPE_PATH.parent.mkdir(parents=True, exist_ok=True)
LANDSCAPE_PATH.write_text(json.dumps(landscape, indent=2))
s = landscape["source"]
console.print(f"\n[bold green]Done![/bold green] → {LANDSCAPE_PATH}")
console.print(f" Therapies classified: [bold]{s['therapies']}[/bold] · abstained (insufficient evidence): [bold]{s['abstained']}[/bold]")
for c in landscape["classifications"]:
console.print(f" [dim]{c['name']}: {c['primary_count']} primary / {c['therapy_count']} incl. contributing[/dim]")
if __name__ == "__main__":
main()