Spaces:
Sleeping
Sleeping
KevinIsInCoding Claude Fable 5 commited on
feat(landscape): bring experimental ALS therapy landscape to main (#19)
Browse filesApplies the landscape feature (only content main was missing from the hf-clean deploy
lineage) on top of main: the Therapy Landscape tab, offline multi-label classifier
(Opus 4.8 + BioLORD cross-check + abstention, 0.90 precision on a 42-drug gold set),
landscape.json, and plotly==6.9.0. Preserves main's batch extractor + SSR/Gradio fixes.
Note: main and hf-clean have unrelated git histories, so this is applied as a feature
patch rather than a branch merge. The deduped data blobs are intentionally NOT included
(main does not track data/ — it lives in the dataset repo / deploy branch).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
- CLAUDE.md +10 -3
- COE-candle-fire-space-outage.md +294 -0
- app.py +133 -44
- config.py +12 -0
- data/landscape/landscape.json +0 -0
- data/seeds/therapy_classes.json +17 -0
- data/seeds/therapy_gold.json +47 -0
- data/tools/classify_therapy.json +58 -0
- docs/DESIGN-vector-db.md +150 -0
- landscape.py +204 -0
- prompts.py +45 -0
- pyproject.toml +1 -0
- requirements.txt +2 -0
- scripts/build_landscape.py +393 -0
- scripts/eval_landscape.py +102 -0
- tools.py +11 -0
- uv.lock +15 -0
CLAUDE.md
CHANGED
|
@@ -36,9 +36,10 @@ Candle-fire is a physician-facing ALS research intelligence tool. A physician as
|
|
| 36 |
| `rag/indexer.py` | Build ChromaDB collection; section-aware chunking; `citation_count` in metadata |
|
| 37 |
| `rag/retriever.py` | `search()`, `search_by_entities()`, citation-weighted re-ranking |
|
| 38 |
| `agents/research_agent.py` | Multi-step synthesis agent (streaming): entity extraction → KG expansion → RAG → synthesis |
|
| 39 |
-
| `app.py` | Gradio UI: loads graph + ChromaDB once at startup
|
|
|
|
| 40 |
| `main.py` | CLI interface (Rich console) |
|
| 41 |
-
| `scripts/` | Offline pipeline scripts: run once in order (ingest → extract → build_graph → build_index) |
|
| 42 |
|
| 43 |
## Offline Pipeline Run Order
|
| 44 |
|
|
@@ -60,7 +61,10 @@ uv run python scripts/build_graph.py
|
|
| 60 |
# 5. Build ChromaDB vector index
|
| 61 |
uv run python scripts/build_index.py
|
| 62 |
|
| 63 |
-
# 6.
|
|
|
|
|
|
|
|
|
|
| 64 |
uv run gradio app.py
|
| 65 |
```
|
| 66 |
|
|
@@ -83,6 +87,9 @@ data/graph/als_graph.pkl — NetworkX DiGraph (fast load)
|
|
| 83 |
data/graph/als_graph.json — human-readable graph export
|
| 84 |
data/chroma/ — ChromaDB SQLite store
|
| 85 |
data/tools/ — Claude tool input schemas (JSON)
|
|
|
|
|
|
|
|
|
|
| 86 |
```
|
| 87 |
|
| 88 |
## Environment Variables
|
|
|
|
| 36 |
| `rag/indexer.py` | Build ChromaDB collection; section-aware chunking; `citation_count` in metadata |
|
| 37 |
| `rag/retriever.py` | `search()`, `search_by_entities()`, citation-weighted re-ranking |
|
| 38 |
| `agents/research_agent.py` | Multi-step synthesis agent (streaming): entity extraction → KG expansion → RAG → synthesis |
|
| 39 |
+
| `app.py` | Gradio UI (tabbed: Ask + Therapy Landscape): loads graph + ChromaDB + landscape once at startup |
|
| 40 |
+
| `landscape.py` | Therapy Landscape rendering: Plotly sunburst + detail-panel HTML from `landscape.json` |
|
| 41 |
| `main.py` | CLI interface (Rich console) |
|
| 42 |
+
| `scripts/` | Offline pipeline scripts: run once in order (ingest → extract → build_graph → build_index → build_landscape) |
|
| 43 |
|
| 44 |
## Offline Pipeline Run Order
|
| 45 |
|
|
|
|
| 61 |
# 5. Build ChromaDB vector index
|
| 62 |
uv run python scripts/build_index.py
|
| 63 |
|
| 64 |
+
# 6. Build the experimental therapy landscape (offline LLM classification via Batch API)
|
| 65 |
+
uv run python scripts/build_landscape.py
|
| 66 |
+
|
| 67 |
+
# 7. run application
|
| 68 |
uv run gradio app.py
|
| 69 |
```
|
| 70 |
|
|
|
|
| 87 |
data/graph/als_graph.json — human-readable graph export
|
| 88 |
data/chroma/ — ChromaDB SQLite store
|
| 89 |
data/tools/ — Claude tool input schemas (JSON)
|
| 90 |
+
data/seeds/therapy_classes.json — ALS mechanism taxonomy for the therapy landscape
|
| 91 |
+
data/seeds/therapy_gold.json — gold-labeled therapies for classifier eval/calibration
|
| 92 |
+
data/landscape/landscape.json — experimental therapy landscape (offline-built, committed to git)
|
| 93 |
```
|
| 94 |
|
| 95 |
## Environment Variables
|
COE-candle-fire-space-outage.md
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Correction of Errors (COE): candle-fire HuggingFace Space Outage
|
| 2 |
+
|
| 3 |
+
> **Status:** Draft · **Owner:** _<you>_ · **Reviewers:** _<add>_
|
| 4 |
+
> **Service:** candle-fire — physician-facing ALS research intelligence tool (Gradio app on a HuggingFace Space)
|
| 5 |
+
> **Severity:** _<assign>_ · **Date of incident:** 2026-07-23/24 · **Author:** _<you>_
|
| 6 |
+
|
| 7 |
+
## 1. Summary
|
| 8 |
+
|
| 9 |
+
While deploying a corrected (de-duplicated) knowledge-graph dataset to the
|
| 10 |
+
candle-fire HuggingFace Space, the Space became intermittently and then fully
|
| 11 |
+
unavailable, returning HTTP 403 / 503 / 500 to users. The underlying Gradio
|
| 12 |
+
application was healthy the entire time (it booted and loaded data on every
|
| 13 |
+
attempt); the failure was in the **serving layer** — Gradio's experimental
|
| 14 |
+
server-side rendering (SSR) layer, which is unstable on this Space and was
|
| 15 |
+
exposed when a forced restart triggered a cold rebuild. The incident was
|
| 16 |
+
prolonged by the remediation process itself (repeated rebuilds/restarts while
|
| 17 |
+
diagnosing). Service was restored by disabling Gradio SSR (`ssr_mode=False`) and
|
| 18 |
+
pinning the Gradio version.
|
| 19 |
+
|
| 20 |
+
## 2. Customer / User Impact
|
| 21 |
+
|
| 22 |
+
- The candle-fire Space (a physician-facing research tool) returned errors
|
| 23 |
+
(403/503/500) for the duration of the incident — from intermittent failures
|
| 24 |
+
to a period of complete unavailability (all requests 503).
|
| 25 |
+
- No data was lost or corrupted. The corrected dataset deployed successfully;
|
| 26 |
+
the outage was purely in application serving.
|
| 27 |
+
- Duration: approximately _<fill in>_ (spanned the diagnosis + several rebuild
|
| 28 |
+
cycles). Each cold rebuild re-downloaded a ~1.3 GB index, adding multi-minute
|
| 29 |
+
unavailability windows.
|
| 30 |
+
|
| 31 |
+
## 3. Timeline (ordered; times approximate)
|
| 32 |
+
|
| 33 |
+
| Phase | Event |
|
| 34 |
+
|---|---|
|
| 35 |
+
| Deploy start | Corrected graph pickle uploaded to the `candle-fire-data` dataset repo (the Space downloads data from here at startup). |
|
| 36 |
+
| Trigger | `factory_reboot=True` issued on the Space to force it to re-download the corrected pickle (its `_ensure_data()` only downloads "if not present"). This wiped the Space's storage and forced a full cold rebuild. |
|
| 37 |
+
| Data-to-git | Deduped data files pushed to the Space git repo; initial pushes rejected by HuggingFace's 10 MB non-LFS file limit (files had grown to ~19–20 MB); resolved by moving them to Git LFS. |
|
| 38 |
+
| First symptoms | Space began returning 403 → 503 → 500 intermittently (~2/10 success). |
|
| 39 |
+
| Diagnosis 1 | Identified Gradio's experimental Node SSR as the failing layer. Deployed `ssr_mode=False`; success rate improved (~partial), but measurements were taken during rebuild churn and were noisy. |
|
| 40 |
+
| Regression | A second `factory_reboot` (remediation attempt) moved the Space from partial to **fully down** (0/12, all 503). |
|
| 41 |
+
| Wrong turn | Reverted `ssr_mode=False` on a "restore known-good config" assumption; this re-enabled SSR and kept the Space fully down (0/12). |
|
| 42 |
+
| Correction | Log evidence showed SSR-on → 503 vs SSR-off → recovers. Re-applied `ssr_mode=False` and pinned `gradio==6.14.0`; **stopped rebuilding** and allowed the Space to fully settle. |
|
| 43 |
+
| Resolved | After the post-boot warmup window, the Space held **12/12 HTTP 200**. Fix backfilled to git and synced to `main`. |
|
| 44 |
+
|
| 45 |
+
## 4. Root Cause
|
| 46 |
+
|
| 47 |
+
**The application depended on Gradio's experimental SSR serving layer (enabled by
|
| 48 |
+
default in Gradio 6.x), which is unstable on this Space. This dependency was
|
| 49 |
+
unvalidated and had been masked because the Space had been serving from warm/
|
| 50 |
+
persistent state and had not cold-started under that configuration — until a
|
| 51 |
+
forced `factory_reboot` (required to pick up new data) triggered a clean cold
|
| 52 |
+
rebuild that exposed it.**
|
| 53 |
+
|
| 54 |
+
### 5 Whys
|
| 55 |
+
1. **Why was the Space down?** HuggingFace's proxy returned 503 — the app was
|
| 56 |
+
running on port 7860 but was unreachable through the serving layer.
|
| 57 |
+
2. **Why was it unreachable?** Gradio's experimental Node SSR layer failed to
|
| 58 |
+
serve on this Space.
|
| 59 |
+
3. **Why did SSR fail now and not before?** A forced factory-reboot cold-started
|
| 60 |
+
the app under the default (SSR-on) configuration; the Space had previously
|
| 61 |
+
been running on warm state and had never cleanly cold-started under it.
|
| 62 |
+
4. **Why was a factory-reboot needed at all?** The data-refresh path
|
| 63 |
+
(`_ensure_data()` downloads data only if it is not already present on disk)
|
| 64 |
+
required wiping Space storage to force a re-download of the corrected pickle.
|
| 65 |
+
5. **Why did the app rely on an unstable serving mode?** Gradio 6.x turns on
|
| 66 |
+
experimental SSR by default; it was never explicitly disabled, pinned, or
|
| 67 |
+
validated for this Space.
|
| 68 |
+
|
| 69 |
+
## 5. Detection
|
| 70 |
+
|
| 71 |
+
- Detection was **user-reported** (errors observed directly on the Space URL).
|
| 72 |
+
- There was no automated health check / uptime probe on the Space that would
|
| 73 |
+
have caught the regression at deploy time.
|
| 74 |
+
|
| 75 |
+
## 6. Resolution
|
| 76 |
+
|
| 77 |
+
- Set `ssr_mode=False` in `demo.launch()` (bypass Gradio's experimental SSR;
|
| 78 |
+
serve the classic client-rendered app directly).
|
| 79 |
+
- Pinned `gradio==6.14.0` in `requirements.txt` and `pyproject.toml` to match the
|
| 80 |
+
Space's README `sdk_version` and prevent version drift on future rebuilds.
|
| 81 |
+
- Allowed the Space to fully settle after the final deploy before validating,
|
| 82 |
+
and confirmed 12/12 HTTP 200.
|
| 83 |
+
- Backfilled the fix to `origin/hf-clean`, synced it to `main` (PR #15), and
|
| 84 |
+
realigned `origin/hf-clean` with the Space's `hf/main` history so future
|
| 85 |
+
deploys are clean fast-forwards.
|
| 86 |
+
|
| 87 |
+
## 7. What Went Wrong in the Response (contributing factors)
|
| 88 |
+
|
| 89 |
+
- **Over-churning:** Multiple `factory_reboot`s and rebuilds during diagnosis
|
| 90 |
+
each forced a ~1.3 GB cold-start re-download, extending downtime and adding
|
| 91 |
+
noise. One `factory_reboot` moved the Space from partial to fully down.
|
| 92 |
+
- **Measuring during warmup:** Success-rate probes were run during rebuild/
|
| 93 |
+
warmup windows, producing misleading partial results and one false "worse".
|
| 94 |
+
- **Reverting a working mitigation:** `ssr_mode=False` was reverted on an
|
| 95 |
+
assumption ("restore known-good") rather than evidence, which re-broke it.
|
| 96 |
+
- **No staging/validation before prod:** Changes were validated directly on the
|
| 97 |
+
production Space.
|
| 98 |
+
|
| 99 |
+
## 8. Action Items
|
| 100 |
+
|
| 101 |
+
| # | Action | Type | Owner | Status |
|
| 102 |
+
|---|---|---|---|---|
|
| 103 |
+
| 1 | Disable Gradio SSR (`ssr_mode=False`) and pin `gradio==6.14.0` | Fix | _<you>_ | ✅ Done |
|
| 104 |
+
| 2 | Redesign data refresh so updating the dataset repo does **not** require a `factory_reboot` / full cold rebuild (e.g., version the data path or use a lighter refresh signal) — removes the destabilizing trigger and the 1.3 GB re-download | Prevent | | Todo |
|
| 105 |
+
| 3 | Pin all runtime-critical dependencies (no `>=,<` ranges that drift on rebuild); keep `requirements.txt` in sync with README `sdk_version` | Prevent | | Todo |
|
| 106 |
+
| 4 | Write a deploy runbook: two deploy targets (dataset repo for data, Space git for code), the LFS >10 MB requirement, the cold-start warmup window, and how to verify (probe for stable 200s) | Prevent | | Todo |
|
| 107 |
+
| 5 | Add post-deploy verification that waits out the warmup window before declaring success (don't measure during rebuild) | Detect | | Todo |
|
| 108 |
+
| 6 | Add an automated uptime/health probe on the Space with alerting | Detect | | Todo |
|
| 109 |
+
| 7 | Incident-response guidance: during diagnosis, change one variable at a time, avoid repeated factory-reboots, and don't revert a working mitigation without evidence | Process | | Todo |
|
| 110 |
+
| 8 | Reduce cold-start fragility (the ~1.3 GB index download) — evaluate persistent storage strategy or a smaller/streamed index | Prevent | | Todo |
|
| 111 |
+
| 9 | Consider a staging Space to validate rebuilds before touching production | Prevent | | Todo |
|
| 112 |
+
|
| 113 |
+
## 9. Corrective Actions — Concrete Changes (Action Items 2–4)
|
| 114 |
+
|
| 115 |
+
### Item 2 — Data refresh without a factory-reboot
|
| 116 |
+
|
| 117 |
+
**Problem.** `_ensure_data()` short-circuits when the files already exist
|
| 118 |
+
(`if not need_chroma and not need_graph: return`), so a running Space never
|
| 119 |
+
picks up updated data. That is what forced the `factory_reboot` (storage wipe),
|
| 120 |
+
which cold-rebuilt the image and exposed the SSR failure.
|
| 121 |
+
|
| 122 |
+
**Code change (`app.py`).** Always call the HF download helpers and rely on
|
| 123 |
+
`huggingface_hub`'s etag caching — an unchanged file is a cheap metadata check,
|
| 124 |
+
a changed file is re-fetched. This also removes the fragile "flatten" hack by
|
| 125 |
+
downloading into the correct layout in the first place.
|
| 126 |
+
|
| 127 |
+
```python
|
| 128 |
+
def _ensure_data() -> None:
|
| 129 |
+
"""Sync the chroma index + graph pickle from the HF dataset repo.
|
| 130 |
+
|
| 131 |
+
Always calls the HF download helpers. huggingface_hub does etag-based
|
| 132 |
+
caching, so an unchanged file is a cheap metadata check and a changed file
|
| 133 |
+
is re-downloaded. A plain restart therefore picks up new data — no
|
| 134 |
+
factory-reboot / storage wipe required.
|
| 135 |
+
"""
|
| 136 |
+
try:
|
| 137 |
+
from huggingface_hub import hf_hub_download, snapshot_download
|
| 138 |
+
_logger.info("Syncing data from HF dataset (etag-cached)...")
|
| 139 |
+
# Repo layout is chroma/… -> land it at CHROMA_DIR/chroma.sqlite3 by
|
| 140 |
+
# downloading into CHROMA_DIR's parent (no post-hoc file moves, so etag
|
| 141 |
+
# caching keeps working across restarts).
|
| 142 |
+
snapshot_download(
|
| 143 |
+
repo_id=_HF_DATASET,
|
| 144 |
+
repo_type="dataset",
|
| 145 |
+
local_dir=str(CHROMA_DIR.parent),
|
| 146 |
+
allow_patterns=["chroma/**"],
|
| 147 |
+
)
|
| 148 |
+
hf_hub_download(
|
| 149 |
+
repo_id=_HF_DATASET,
|
| 150 |
+
repo_type="dataset",
|
| 151 |
+
filename="graph/als_graph.pkl",
|
| 152 |
+
local_dir=str(GRAPH_PICKLE_PATH.parent.parent),
|
| 153 |
+
)
|
| 154 |
+
_logger.info("Data sync complete")
|
| 155 |
+
except Exception as e:
|
| 156 |
+
_logger.warning(f"Data sync from HF dataset failed: {e}")
|
| 157 |
+
```
|
| 158 |
+
|
| 159 |
+
**Procedure change.** To ship new data, update the dataset repo, then issue a
|
| 160 |
+
**plain restart** — never `factory_reboot=True`:
|
| 161 |
+
|
| 162 |
+
```python
|
| 163 |
+
from huggingface_hub import HfApi
|
| 164 |
+
# Re-runs app.py on the SAME built image (no dep reinstall, no version drift);
|
| 165 |
+
# _ensure_data() then pulls only the changed file via etag.
|
| 166 |
+
HfApi().restart_space("KevinIsCoding/candle-fire")
|
| 167 |
+
```
|
| 168 |
+
|
| 169 |
+
**Infra change (removes the 1.3 GB cold-start entirely).** Enable **persistent
|
| 170 |
+
storage** on the Space (Settings → Storage). With persistence the chroma index
|
| 171 |
+
survives restarts and `_ensure_data()`'s etag check skips it — only a changed
|
| 172 |
+
pickle downloads. Without persistence, storage is ephemeral and every restart
|
| 173 |
+
re-downloads ~1.3 GB. Reserve `factory_reboot=True` for genuine image/dependency
|
| 174 |
+
changes, and treat it as a change that can expose cold-start regressions.
|
| 175 |
+
|
| 176 |
+
### Item 3 — Pin runtime-critical dependencies
|
| 177 |
+
|
| 178 |
+
**Problem.** `requirements.txt` used floors/ranges (`gradio>=6.14.0,<7.0.0`,
|
| 179 |
+
`chromadb>=0.5.0`, …). A rebuild can resolve *newer* versions than the
|
| 180 |
+
last-known-good, and the Gradio pin drifted away from the README `sdk_version`.
|
| 181 |
+
|
| 182 |
+
**Code change (`requirements.txt`).** Pin exact versions (source them from a
|
| 183 |
+
`pip freeze` on the currently-healthy Space). The Gradio pin MUST equal the
|
| 184 |
+
README front-matter `sdk_version`.
|
| 185 |
+
|
| 186 |
+
```text
|
| 187 |
+
# Pin exact versions — regenerate from `pip freeze` on the known-good Space.
|
| 188 |
+
# gradio MUST equal README front-matter sdk_version ("6.14.0").
|
| 189 |
+
anthropic==<frozen>
|
| 190 |
+
gradio==6.14.0
|
| 191 |
+
chromadb==<frozen>
|
| 192 |
+
networkx==<frozen>
|
| 193 |
+
biopython==<frozen>
|
| 194 |
+
httpx==<frozen>
|
| 195 |
+
python-dotenv==<frozen>
|
| 196 |
+
rich==<frozen>
|
| 197 |
+
sentence-transformers==<frozen>
|
| 198 |
+
openai==<frozen>
|
| 199 |
+
rapidfuzz==<frozen>
|
| 200 |
+
```
|
| 201 |
+
|
| 202 |
+
Mirror the same `gradio==6.14.0` pin in `pyproject.toml`, and prefer committing a
|
| 203 |
+
lockfile (`uv.lock`) as the source of truth.
|
| 204 |
+
|
| 205 |
+
**Guardrail (CI / pre-commit)** — fail the build if README `sdk_version` and the
|
| 206 |
+
`requirements.txt` Gradio pin disagree:
|
| 207 |
+
|
| 208 |
+
```bash
|
| 209 |
+
#!/usr/bin/env bash
|
| 210 |
+
# scripts/check_gradio_pin.sh
|
| 211 |
+
set -euo pipefail
|
| 212 |
+
readme_ver=$(grep -E '^sdk_version:' README.md | tr -d ' "' | cut -d: -f2)
|
| 213 |
+
req_ver=$(grep -E '^gradio==' requirements.txt | cut -d= -f3)
|
| 214 |
+
[[ "$readme_ver" == "$req_ver" ]] || {
|
| 215 |
+
echo "Gradio mismatch: README sdk_version=$readme_ver vs requirements gradio==$req_ver" >&2
|
| 216 |
+
exit 1
|
| 217 |
+
}
|
| 218 |
+
echo "Gradio pin OK ($readme_ver)"
|
| 219 |
+
```
|
| 220 |
+
|
| 221 |
+
### Item 4 — Deploy runbook (`docs/DEPLOY.md`)
|
| 222 |
+
|
| 223 |
+
candle-fire has **two deploy targets**: **data** → dataset repo
|
| 224 |
+
`KevinIsCoding/candle-fire-data`; **code** → Space git `hf/main` (deploy lineage
|
| 225 |
+
`hf-clean`). The app downloads data at startup — data is NOT served from the
|
| 226 |
+
Space git.
|
| 227 |
+
|
| 228 |
+
**A. Data-only update (most common)**
|
| 229 |
+
1. Rebuild artifacts: `uv run python scripts/build_graph.py` (plus
|
| 230 |
+
`build_index.py` if the corpus changed).
|
| 231 |
+
2. Upload to the dataset repo:
|
| 232 |
+
```bash
|
| 233 |
+
hf upload KevinIsCoding/candle-fire-data data/graph/als_graph.pkl graph/als_graph.pkl --repo-type dataset
|
| 234 |
+
```
|
| 235 |
+
3. Plain restart (NOT factory reboot):
|
| 236 |
+
```bash
|
| 237 |
+
python -c "from huggingface_hub import HfApi; HfApi().restart_space('KevinIsCoding/candle-fire')"
|
| 238 |
+
```
|
| 239 |
+
4. Verify (section D). No code push needed.
|
| 240 |
+
|
| 241 |
+
**B. Code update**
|
| 242 |
+
1. Make the change on `hf-clean` (or PR into it).
|
| 243 |
+
2. Any tracked data file > 10 MB MUST be Git LFS (HF rejects >10 MB non-LFS):
|
| 244 |
+
```bash
|
| 245 |
+
git lfs track "data/graph/als_graph.json" "data/extracted/entities.jsonl"
|
| 246 |
+
```
|
| 247 |
+
3. Push to the Space (fast-forward; `hf/main` and `origin/hf-clean` stay in lockstep):
|
| 248 |
+
```bash
|
| 249 |
+
git push origin hf-clean && git push hf hf-clean:main
|
| 250 |
+
```
|
| 251 |
+
4. The Space rebuilds automatically. Verify (section D).
|
| 252 |
+
|
| 253 |
+
**C. Golden rules**
|
| 254 |
+
- Do NOT `factory_reboot` for routine deploys — it wipes storage and rebuilds the
|
| 255 |
+
image, which can expose cold-start regressions. Use a plain restart.
|
| 256 |
+
- `ssr_mode=False` must stay in `demo.launch()` (Gradio SSR 503s on this Space).
|
| 257 |
+
- `requirements.txt` gradio pin MUST equal README `sdk_version`.
|
| 258 |
+
|
| 259 |
+
**D. Post-deploy verification (wait out the warmup window).** The app logs
|
| 260 |
+
"Running on local URL" *before* HF's proxy is ready; a warmup window (~1–2 min,
|
| 261 |
+
longer on a cold 1.3 GB download) returns 503 until it settles. Wait, then probe:
|
| 262 |
+
|
| 263 |
+
```bash
|
| 264 |
+
python - <<'PY'
|
| 265 |
+
import time, urllib.request
|
| 266 |
+
from huggingface_hub import HfApi
|
| 267 |
+
while HfApi().get_space_runtime("KevinIsCoding/candle-fire").stage != "RUNNING":
|
| 268 |
+
time.sleep(15)
|
| 269 |
+
time.sleep(120) # warmup buffer
|
| 270 |
+
url, ok = "https://keviniscoding-candle-fire.hf.space/", 0
|
| 271 |
+
for _ in range(12):
|
| 272 |
+
try: ok += urllib.request.urlopen(url, timeout=30).status == 200
|
| 273 |
+
except Exception: pass
|
| 274 |
+
time.sleep(6)
|
| 275 |
+
print(f"{ok}/12 healthy —", "DEPLOY OK" if ok >= 11 else "INVESTIGATE")
|
| 276 |
+
PY
|
| 277 |
+
```
|
| 278 |
+
|
| 279 |
+
Target: ≥ 11/12 HTTP 200. If it stays 503 *after* warmup, read the run logs —
|
| 280 |
+
do not reflexively rebuild.
|
| 281 |
+
|
| 282 |
+
## 10. Lessons Learned
|
| 283 |
+
|
| 284 |
+
- A running Space can be serving on **warm state** that a cold rebuild will not
|
| 285 |
+
reproduce; forcing a cold rebuild (factory-reboot) is a high-risk action, not
|
| 286 |
+
a routine one.
|
| 287 |
+
- **Unpinned/defaulted serving behavior** (Gradio's experimental SSR, unpinned
|
| 288 |
+
Gradio version) is a latent risk that surfaces only on rebuild.
|
| 289 |
+
- During an incident, **stop changing things and let the system settle** before
|
| 290 |
+
measuring; rapid iteration on a slow-cold-start service produces misleading
|
| 291 |
+
signals and extends the outage.
|
| 292 |
+
- The application layer being healthy does not mean the service is up — the
|
| 293 |
+
**serving/proxy layer** is a distinct failure domain worth checking first
|
| 294 |
+
(the 500/503 body identified the source as the platform, not the app).
|
app.py
CHANGED
|
@@ -53,6 +53,12 @@ _n_chunks = _collection.count() if _collection else 0
|
|
| 53 |
_n_trials = len(_trials)
|
| 54 |
_kg_nodes = _graph.number_of_nodes() if _graph else 0
|
| 55 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
# ── Example questions ─────────────────────────────────────────────────────────
|
| 57 |
|
| 58 |
_EXAMPLES = [
|
|
@@ -123,52 +129,135 @@ Always verify claims with primary sources before applying to patient care.
|
|
| 123 |
</div>"""
|
| 124 |
|
| 125 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
with gr.Blocks(title="Candle-Fire — ALS Research Intelligence") as demo:
|
| 127 |
|
| 128 |
-
with gr.
|
| 129 |
-
|
| 130 |
-
gr.
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
|
| 173 |
submit_kwargs = dict(
|
| 174 |
fn=respond,
|
|
|
|
| 53 |
_n_trials = len(_trials)
|
| 54 |
_kg_nodes = _graph.number_of_nodes() if _graph else 0
|
| 55 |
|
| 56 |
+
# Experimental therapy landscape (offline-built artifact; loaded once)
|
| 57 |
+
import landscape as landscape_mod
|
| 58 |
+
|
| 59 |
+
_landscape = landscape_mod.load_landscape()
|
| 60 |
+
_landscape_classes = landscape_mod.class_names(_landscape)
|
| 61 |
+
|
| 62 |
# ── Example questions ─────────────────────────────────────────────────────────
|
| 63 |
|
| 64 |
_EXAMPLES = [
|
|
|
|
| 129 |
</div>"""
|
| 130 |
|
| 131 |
|
| 132 |
+
def _landscape_select(class_name: str, phases):
|
| 133 |
+
"""When the class (or phase filter) changes: repopulate therapies and show the first one's detail."""
|
| 134 |
+
labels = landscape_mod.therapy_labels(_landscape, class_name, phases)
|
| 135 |
+
first = labels[0] if labels else None
|
| 136 |
+
return (
|
| 137 |
+
gr.update(choices=labels, value=first),
|
| 138 |
+
landscape_mod.therapy_detail_md(_landscape, class_name, first or ""),
|
| 139 |
+
landscape_mod.trials_table_html(_landscape, class_name, first or ""),
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def _phase_change(phases, class_name: str):
|
| 144 |
+
"""Phase filter: rebuild the sunburst and repopulate the current class's therapies."""
|
| 145 |
+
therapy_update, detail, trials = _landscape_select(class_name, phases)
|
| 146 |
+
return (landscape_mod.build_sunburst(_landscape, phases), therapy_update, detail, trials)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def _therapy_select(class_name: str, therapy_label: str):
|
| 150 |
+
return (
|
| 151 |
+
landscape_mod.therapy_detail_md(_landscape, class_name, therapy_label),
|
| 152 |
+
landscape_mod.trials_table_html(_landscape, class_name, therapy_label),
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
with gr.Blocks(title="Candle-Fire — ALS Research Intelligence") as demo:
|
| 157 |
|
| 158 |
+
with gr.Tabs():
|
| 159 |
+
|
| 160 |
+
with gr.Tab("💬 Ask"):
|
| 161 |
+
with gr.Column(elem_classes="container"):
|
| 162 |
+
|
| 163 |
+
gr.Markdown(_TITLE_MD)
|
| 164 |
+
|
| 165 |
+
gr.HTML(
|
| 166 |
+
f'<div class="status-bar">'
|
| 167 |
+
f'{_n_chunks} paper chunks · '
|
| 168 |
+
f'{_n_trials} clinical trials · '
|
| 169 |
+
f'{_kg_nodes} knowledge graph nodes'
|
| 170 |
+
f'</div>'
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
chatbot = gr.Chatbot(
|
| 174 |
+
value=[],
|
| 175 |
+
height=520,
|
| 176 |
+
show_label=False,
|
| 177 |
+
sanitize_html=False,
|
| 178 |
+
avatar_images=(None, "assets/flame.svg"),
|
| 179 |
+
placeholder="Ask a question about ALS research to get started.",
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
with gr.Row():
|
| 183 |
+
msg_box = gr.Textbox(
|
| 184 |
+
placeholder="e.g. What is the evidence for tofersen targeting SOD1?",
|
| 185 |
+
show_label=False,
|
| 186 |
+
scale=9,
|
| 187 |
+
autofocus=True,
|
| 188 |
+
lines=1,
|
| 189 |
+
)
|
| 190 |
+
send_btn = gr.Button("Ask", scale=1, variant="primary", min_width=80)
|
| 191 |
+
|
| 192 |
+
gr.Markdown("**Example questions** — click to populate:")
|
| 193 |
+
|
| 194 |
+
with gr.Row():
|
| 195 |
+
with gr.Column(scale=1):
|
| 196 |
+
for ex in _EXAMPLES[:3]:
|
| 197 |
+
btn = gr.Button(ex, size="sm", variant="secondary")
|
| 198 |
+
btn.click(fn=lambda t=ex: t, outputs=[msg_box])
|
| 199 |
+
with gr.Column(scale=1):
|
| 200 |
+
for ex in _EXAMPLES[3:]:
|
| 201 |
+
btn = gr.Button(ex, size="sm", variant="secondary")
|
| 202 |
+
btn.click(fn=lambda t=ex: t, outputs=[msg_box])
|
| 203 |
+
|
| 204 |
+
gr.HTML(_DISCLAIMER_MD)
|
| 205 |
+
|
| 206 |
+
with gr.Tab("🧭 Therapy Landscape"):
|
| 207 |
+
with gr.Column(elem_classes="container"):
|
| 208 |
+
gr.Markdown(
|
| 209 |
+
"### 🧭 Experimental ALS Therapy Landscape\n"
|
| 210 |
+
"Explore experimental therapies by **mechanism class → therapy → clinical trials** "
|
| 211 |
+
"(recruiting & closed). Click a wedge to zoom; use the selectors for trial details."
|
| 212 |
+
)
|
| 213 |
+
if _landscape is None:
|
| 214 |
+
gr.Markdown(
|
| 215 |
+
"*Landscape not built yet — run `uv run python scripts/build_landscape.py`.*"
|
| 216 |
+
)
|
| 217 |
+
else:
|
| 218 |
+
_init_class = _landscape_classes[0]
|
| 219 |
+
_init_labels = landscape_mod.therapy_labels(_landscape, _init_class)
|
| 220 |
+
_init_label = _init_labels[0] if _init_labels else None
|
| 221 |
+
|
| 222 |
+
sunburst = gr.Plot(landscape_mod.build_sunburst(_landscape), show_label=False)
|
| 223 |
+
|
| 224 |
+
phase_cb = gr.CheckboxGroup(
|
| 225 |
+
choices=landscape_mod.PHASE_OPTIONS, value=landscape_mod.PHASE_OPTIONS,
|
| 226 |
+
label="Filter by trial phase",
|
| 227 |
+
info="Show therapies with a trial in the selected phase(s). All selected = the whole picture.",
|
| 228 |
+
)
|
| 229 |
+
|
| 230 |
+
with gr.Row():
|
| 231 |
+
class_dd = gr.Dropdown(
|
| 232 |
+
choices=_landscape_classes, value=_init_class,
|
| 233 |
+
label="Mechanism class", scale=1,
|
| 234 |
+
)
|
| 235 |
+
therapy_dd = gr.Dropdown(
|
| 236 |
+
choices=_init_labels, value=_init_label,
|
| 237 |
+
label="Therapy", scale=1,
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
detail_md = gr.Markdown(
|
| 241 |
+
landscape_mod.therapy_detail_md(_landscape, _init_class, _init_label or "")
|
| 242 |
+
)
|
| 243 |
+
trials_html = gr.HTML(
|
| 244 |
+
landscape_mod.trials_table_html(_landscape, _init_class, _init_label or "")
|
| 245 |
+
)
|
| 246 |
+
|
| 247 |
+
class_dd.change(
|
| 248 |
+
_landscape_select, inputs=[class_dd, phase_cb],
|
| 249 |
+
outputs=[therapy_dd, detail_md, trials_html],
|
| 250 |
+
)
|
| 251 |
+
therapy_dd.change(
|
| 252 |
+
_therapy_select, inputs=[class_dd, therapy_dd],
|
| 253 |
+
outputs=[detail_md, trials_html],
|
| 254 |
+
)
|
| 255 |
+
phase_cb.change(
|
| 256 |
+
_phase_change, inputs=[phase_cb, class_dd],
|
| 257 |
+
outputs=[sunburst, therapy_dd, detail_md, trials_html],
|
| 258 |
+
)
|
| 259 |
+
|
| 260 |
+
gr.HTML(_DISCLAIMER_MD)
|
| 261 |
|
| 262 |
submit_kwargs = dict(
|
| 263 |
fn=respond,
|
config.py
CHANGED
|
@@ -23,6 +23,18 @@ GRAPH_JSON_PATH = DATA_DIR / "graph" / "als_graph.json"
|
|
| 23 |
CHROMA_DIR = DATA_DIR / "chroma"
|
| 24 |
CHROMA_COLLECTION = "als_papers"
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
# Seed entity files
|
| 27 |
MANUAL_SEEDS_PATH = DATA_DIR / "seeds" / "manual_seeds.json"
|
| 28 |
DERIVED_SEEDS_PATH = DATA_DIR / "seeds" / "derived_seeds.json"
|
|
|
|
| 23 |
CHROMA_DIR = DATA_DIR / "chroma"
|
| 24 |
CHROMA_COLLECTION = "als_papers"
|
| 25 |
|
| 26 |
+
# Experimental therapy landscape (offline-built, committed to git — small)
|
| 27 |
+
LANDSCAPE_PATH = DATA_DIR / "landscape" / "landscape.json"
|
| 28 |
+
LANDSCAPE_PROGRESS_PATH = DATA_DIR / "landscape" / ".progress.json"
|
| 29 |
+
LANDSCAPE_BATCH_STATE_PATH = DATA_DIR / "landscape" / ".batch_state.json"
|
| 30 |
+
THERAPY_CLASSES_PATH = DATA_DIR / "seeds" / "therapy_classes.json"
|
| 31 |
+
THERAPY_GOLD_PATH = DATA_DIR / "seeds" / "therapy_gold.json"
|
| 32 |
+
# Mechanism classification (v2): frontier model, grounded + multi-label + abstaining.
|
| 33 |
+
LANDSCAPE_MODEL = "claude-opus-4-8"
|
| 34 |
+
LANDSCAPE_MIN_CONFIDENCE = 0.55 # τ — drop LLM mechanisms below this confidence
|
| 35 |
+
LANDSCAPE_XCHECK_MIN_COSINE = 0.20 # BioLORD guard: drop a mechanism whose justification↔class cosine is below this (loose; catches gross mismatch)
|
| 36 |
+
LANDSCAPE_EVIDENCE_ABSTRACTS = 4 # top abstracts retrieved per therapy as MoA evidence
|
| 37 |
+
|
| 38 |
# Seed entity files
|
| 39 |
MANUAL_SEEDS_PATH = DATA_DIR / "seeds" / "manual_seeds.json"
|
| 40 |
DERIVED_SEEDS_PATH = DATA_DIR / "seeds" / "derived_seeds.json"
|
data/landscape/landscape.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
data/seeds/therapy_classes.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"description": "Curated ALS mechanism/target taxonomy for the Experimental Therapy Landscape. The 'name' values are the canonical class labels used as the classification enum in data/tools/classify_therapy.json and as the top ring of the sunburst. Seeded from the graph's strongest Mechanism/Gene nodes.",
|
| 3 |
+
"classes": [
|
| 4 |
+
{"id": "tdp43", "name": "TDP-43 proteinopathy", "description": "Therapies targeting TDP-43 aggregation, mislocalization, or cryptic splicing (TARDBP, STMN2, UNC13A)."},
|
| 5 |
+
{"id": "sod1", "name": "SOD1", "description": "Therapies targeting SOD1 in SOD1-ALS — e.g., SOD1-lowering antisense oligonucleotides, misfolded-SOD1 clearance."},
|
| 6 |
+
{"id": "c9orf72", "name": "C9orf72", "description": "Therapies targeting the C9orf72 hexanucleotide repeat expansion, RNA foci, or dipeptide-repeat proteins."},
|
| 7 |
+
{"id": "fus", "name": "FUS", "description": "Therapies targeting FUS aggregation or nuclear mislocalization in FUS-ALS."},
|
| 8 |
+
{"id": "neuroinflammation", "name": "Neuroinflammation", "description": "Therapies modulating microglia, astrocytes, or immune/inflammatory signaling."},
|
| 9 |
+
{"id": "oxidative_stress", "name": "Oxidative stress", "description": "Antioxidants and therapies countering reactive oxygen species and free-radical damage (e.g., edaravone)."},
|
| 10 |
+
{"id": "mitochondrial", "name": "Mitochondrial dysfunction", "description": "Therapies improving mitochondrial bioenergetics, membrane stability, or apoptosis regulation."},
|
| 11 |
+
{"id": "excitotoxicity", "name": "Glutamate excitotoxicity", "description": "Therapies reducing glutamate-mediated excitotoxic motor-neuron injury (e.g., riluzole, AMPA-receptor antagonists)."},
|
| 12 |
+
{"id": "proteostasis", "name": "Proteostasis / autophagy", "description": "Therapies enhancing protein clearance — chaperones, autophagy induction, or the ubiquitin-proteasome system (e.g., arimoclomol)."},
|
| 13 |
+
{"id": "rna_metabolism", "name": "RNA metabolism", "description": "Therapies targeting broad RNA processing/splicing dysregulation not specific to a single gene above."},
|
| 14 |
+
{"id": "neurotrophic", "name": "Neurotrophic / regenerative", "description": "Neurotrophic factors, stem-cell, and gene therapies aiming to protect or regenerate motor neurons."},
|
| 15 |
+
{"id": "symptomatic_other", "name": "Symptomatic / Other", "description": "Symptomatic treatments, repurposed drugs, or therapies whose ALS mechanism is unclear or does not fit another class."}
|
| 16 |
+
]
|
| 17 |
+
}
|
data/seeds/therapy_gold.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"description": "Hand-labeled gold set of well-known ALS therapies with their established mechanism class(es) from data/seeds/therapy_classes.json. Used by scripts/eval_landscape.py to measure multi-label precision/recall and to calibrate the confidence threshold, and as few-shot exemplars in the classifier prompt. Class names must match therapy_classes.json exactly. 'mechanisms' lists all well-supported classes (first = primary).",
|
| 3 |
+
"gold": [
|
| 4 |
+
{"therapy": "Riluzole", "mechanisms": ["Glutamate excitotoxicity"]},
|
| 5 |
+
{"therapy": "Edaravone", "aliases": ["MCI-186", "MT-1186", "Radicava"], "mechanisms": ["Oxidative stress"]},
|
| 6 |
+
{"therapy": "Tofersen", "aliases": ["Qalsody", "BIIB067"], "mechanisms": ["SOD1"]},
|
| 7 |
+
{"therapy": "CNM-Au8", "mechanisms": ["Mitochondrial dysfunction", "Oxidative stress"]},
|
| 8 |
+
{"therapy": "Arimoclomol", "mechanisms": ["Proteostasis / autophagy"]},
|
| 9 |
+
{"therapy": "AMX0035", "aliases": ["Relyvrio", "sodium phenylbutyrate and taurursodiol"], "mechanisms": ["Mitochondrial dysfunction", "Proteostasis / autophagy"]},
|
| 10 |
+
{"therapy": "Masitinib", "mechanisms": ["Neuroinflammation"]},
|
| 11 |
+
{"therapy": "Ibudilast", "aliases": ["MN-166"], "mechanisms": ["Neuroinflammation"]},
|
| 12 |
+
{"therapy": "NP001", "mechanisms": ["Neuroinflammation"]},
|
| 13 |
+
{"therapy": "Verdiperstat", "aliases": ["AZD3241"], "mechanisms": ["Neuroinflammation", "Oxidative stress"]},
|
| 14 |
+
{"therapy": "RNS60", "mechanisms": ["Neuroinflammation"]},
|
| 15 |
+
{"therapy": "Zilucoplan", "mechanisms": ["Neuroinflammation"]},
|
| 16 |
+
{"therapy": "Ravulizumab", "mechanisms": ["Neuroinflammation"]},
|
| 17 |
+
{"therapy": "Fingolimod", "aliases": ["Gilenya"], "mechanisms": ["Neuroinflammation"]},
|
| 18 |
+
{"therapy": "Dexpramipexole", "mechanisms": ["Mitochondrial dysfunction"]},
|
| 19 |
+
{"therapy": "Rasagiline", "mechanisms": ["Mitochondrial dysfunction", "Oxidative stress"]},
|
| 20 |
+
{"therapy": "Tauroursodeoxycholic acid", "aliases": ["TUDCA", "taurursodiol"], "mechanisms": ["Mitochondrial dysfunction", "Proteostasis / autophagy"]},
|
| 21 |
+
{"therapy": "Olesoxime", "mechanisms": ["Mitochondrial dysfunction"]},
|
| 22 |
+
{"therapy": "Cu(II)ATSM", "aliases": ["CuATSM", "copper ATSM"], "mechanisms": ["Oxidative stress", "Mitochondrial dysfunction"]},
|
| 23 |
+
{"therapy": "Deferiprone", "mechanisms": ["Oxidative stress"]},
|
| 24 |
+
{"therapy": "Ceftriaxone", "mechanisms": ["Glutamate excitotoxicity"]},
|
| 25 |
+
{"therapy": "Talampanel", "mechanisms": ["Glutamate excitotoxicity"]},
|
| 26 |
+
{"therapy": "Memantine", "mechanisms": ["Glutamate excitotoxicity"]},
|
| 27 |
+
{"therapy": "Lithium carbonate", "aliases": ["lithium"], "mechanisms": ["Proteostasis / autophagy"]},
|
| 28 |
+
{"therapy": "Rapamycin", "aliases": ["sirolimus"], "mechanisms": ["Proteostasis / autophagy"]},
|
| 29 |
+
{"therapy": "Trehalose", "mechanisms": ["Proteostasis / autophagy"]},
|
| 30 |
+
{"therapy": "Sodium phenylbutyrate", "mechanisms": ["Proteostasis / autophagy"]},
|
| 31 |
+
{"therapy": "Guanabenz", "mechanisms": ["Proteostasis / autophagy"]},
|
| 32 |
+
{"therapy": "Pridopidine", "mechanisms": ["Neurotrophic / regenerative", "Proteostasis / autophagy"]},
|
| 33 |
+
{"therapy": "Methylcobalamin", "aliases": ["mecobalamin", "E0302"], "mechanisms": ["Neurotrophic / regenerative"]},
|
| 34 |
+
{"therapy": "Fasudil", "mechanisms": ["Neurotrophic / regenerative", "Neuroinflammation"]},
|
| 35 |
+
{"therapy": "NurOwn", "aliases": ["MSC-NTF", "debamestrocel"], "mechanisms": ["Neurotrophic / regenerative"]},
|
| 36 |
+
{"therapy": "Lenzumestrocel", "aliases": ["Neuronata-R"], "mechanisms": ["Neurotrophic / regenerative"]},
|
| 37 |
+
{"therapy": "Ozanezumab", "mechanisms": ["Neurotrophic / regenerative"]},
|
| 38 |
+
{"therapy": "Jacifusen", "aliases": ["ION363"], "mechanisms": ["FUS"]},
|
| 39 |
+
{"therapy": "BIIB078", "mechanisms": ["C9orf72"]},
|
| 40 |
+
{"therapy": "WVE-004", "mechanisms": ["C9orf72"]},
|
| 41 |
+
{"therapy": "SAR443820", "aliases": ["DNL788"], "mechanisms": ["Neuroinflammation"]},
|
| 42 |
+
{"therapy": "Reldesemtiv", "aliases": ["CK-2127107"], "mechanisms": ["Symptomatic / Other"]},
|
| 43 |
+
{"therapy": "Tirasemtiv", "mechanisms": ["Symptomatic / Other"]},
|
| 44 |
+
{"therapy": "Mexiletine", "mechanisms": ["Symptomatic / Other"]},
|
| 45 |
+
{"therapy": "Levosimendan", "mechanisms": ["Symptomatic / Other"]}
|
| 46 |
+
]
|
| 47 |
+
}
|
data/tools/classify_therapy.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"type": "object",
|
| 3 |
+
"properties": {
|
| 4 |
+
"therapy_key": {
|
| 5 |
+
"type": "string",
|
| 6 |
+
"description": "Echo back verbatim the therapy_key given for this therapy in the prompt."
|
| 7 |
+
},
|
| 8 |
+
"canonical_name": {
|
| 9 |
+
"type": "string",
|
| 10 |
+
"description": "Most recognizable canonical name for this therapy. Merge development codes and synonyms to the common name (e.g., 'MCI-186' and 'MT-1186' -> 'Edaravone')."
|
| 11 |
+
},
|
| 12 |
+
"aliases": {
|
| 13 |
+
"type": "array",
|
| 14 |
+
"items": {"type": "string"},
|
| 15 |
+
"description": "Other names or development codes for this therapy seen across its trials."
|
| 16 |
+
},
|
| 17 |
+
"modality": {
|
| 18 |
+
"type": "string",
|
| 19 |
+
"enum": ["Small molecule", "Antisense oligonucleotide", "Biologic/Antibody", "Cell therapy", "Gene therapy", "Peptide", "Dietary supplement", "Combination", "Other"],
|
| 20 |
+
"description": "Therapeutic modality."
|
| 21 |
+
},
|
| 22 |
+
"target": {
|
| 23 |
+
"type": "string",
|
| 24 |
+
"description": "Primary molecular/biological target (e.g., 'SOD1 mRNA', 'AMPA receptor'). Use 'Unknown' if it cannot be determined from the provided evidence."
|
| 25 |
+
},
|
| 26 |
+
"mechanisms": {
|
| 27 |
+
"type": "array",
|
| 28 |
+
"description": "All ALS mechanism classes this therapy is proposed to act through, each supported by a direct quote from the provided evidence. MULTI-LABEL: include every well-supported class. If the evidence does not establish any mechanism, return an EMPTY array (abstain) rather than guessing.",
|
| 29 |
+
"items": {
|
| 30 |
+
"type": "object",
|
| 31 |
+
"properties": {
|
| 32 |
+
"class": {
|
| 33 |
+
"type": "string",
|
| 34 |
+
"enum": ["TDP-43 proteinopathy", "SOD1", "C9orf72", "FUS", "Neuroinflammation", "Oxidative stress", "Mitochondrial dysfunction", "Glutamate excitotoxicity", "Proteostasis / autophagy", "RNA metabolism", "Neurotrophic / regenerative", "Symptomatic / Other"],
|
| 35 |
+
"description": "The mechanism class from the ALS taxonomy."
|
| 36 |
+
},
|
| 37 |
+
"role": {
|
| 38 |
+
"type": "string",
|
| 39 |
+
"enum": ["primary", "contributing"],
|
| 40 |
+
"description": "Whether this is the therapy's primary mechanism or a secondary/contributing one."
|
| 41 |
+
},
|
| 42 |
+
"confidence": {
|
| 43 |
+
"type": "number",
|
| 44 |
+
"minimum": 0.0,
|
| 45 |
+
"maximum": 1.0,
|
| 46 |
+
"description": "Confidence that this therapy acts through this mechanism, based ONLY on the provided evidence (1.0 = well-established, 0.4 = weak/inferred)."
|
| 47 |
+
},
|
| 48 |
+
"evidence_quote": {
|
| 49 |
+
"type": "string",
|
| 50 |
+
"description": "A short verbatim quote from the provided evidence that supports this mechanism. Required — if you cannot quote supporting evidence, do not include this mechanism."
|
| 51 |
+
}
|
| 52 |
+
},
|
| 53 |
+
"required": ["class", "role", "confidence", "evidence_quote"]
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
},
|
| 57 |
+
"required": ["therapy_key", "canonical_name", "modality", "target", "mechanisms"]
|
| 58 |
+
}
|
docs/DESIGN-vector-db.md
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Design Doc: candle-fire Vector DB — Deployment Fix & Migration Path
|
| 2 |
+
|
| 3 |
+
> **Status:** Draft for review · **Author:** _<you>_ · **Reviewers:** _<add>_ · **Date:** 2026-07
|
| 4 |
+
> This is a design doc to review/modify **before** implementation. It ends with open
|
| 5 |
+
> decisions (Section 10) you should resolve; the recommendation is phased so Phase 0 can
|
| 6 |
+
> proceed independently of the Phase 1 decision.
|
| 7 |
+
|
| 8 |
+
## 1. Context & Problem
|
| 9 |
+
|
| 10 |
+
candle-fire's RAG layer uses an **embedded ChromaDB (SQLite)** vector index. The concern
|
| 11 |
+
raised: the vector DB "keeps getting larger" — is it time to migrate (e.g., to AWS)?
|
| 12 |
+
|
| 13 |
+
**Current numbers (measured):**
|
| 14 |
+
- **30,967 vectors** (chunks) from ~10k papers; **1.2 GB** on disk (`data/chroma/chroma.sqlite3`).
|
| 15 |
+
- Corpus capped at **20k papers** (`config.PUBMED_DEFAULT_MAX`) → ~62k chunks / ~2.4 GB at the cap.
|
| 16 |
+
- Embeddings: **BioLORD-2023-C** (768-dim). Reranker: `cross-encoder/ms-marco-MiniLM-L-6-v2`.
|
| 17 |
+
- **Deployment:** the index is stored in the HF **dataset** repo (`KevinIsCoding/candle-fire-data`)
|
| 18 |
+
and **downloaded to the Space at cold start** (`app.py:_ensure_data`). The 1.2 GB download
|
| 19 |
+
amplified the recent 503 outage.
|
| 20 |
+
|
| 21 |
+
## 2. Key Finding — this is a *deployment* problem, not a *scale* problem
|
| 22 |
+
|
| 23 |
+
- **31k–62k vectors is trivially small** for any vector store (they scale to millions/billions).
|
| 24 |
+
Chroma is not being outgrown in capability.
|
| 25 |
+
- **The 1.2 GB is mostly text, not vectors.** Vectors alone are ~100 MB (31k × 768 × 4 bytes).
|
| 26 |
+
The rest is full document text + a full-text index — because the retriever relies on Chroma's
|
| 27 |
+
substring search (`where_document $contains`) to ground drug codes / gene IDs. That text is
|
| 28 |
+
**load-bearing** for citation grounding; it can't just be dropped to shrink the index.
|
| 29 |
+
- Therefore the pain is **where the DB lives** (bundled + downloaded per cold start), not its size.
|
| 30 |
+
|
| 31 |
+
## 3. Goals / Non-Goals
|
| 32 |
+
|
| 33 |
+
**Goals**
|
| 34 |
+
- Remove the 1.2 GB cold-start download that couples the DB to the Space lifecycle.
|
| 35 |
+
- Preserve the **hybrid retrieval** (semantic ANN + exact substring + metadata filter) — load-bearing for grounding.
|
| 36 |
+
- Keep a low-risk path that also scales if the corpus grows.
|
| 37 |
+
|
| 38 |
+
**Non-Goals**
|
| 39 |
+
- Shrinking the index by dropping document text (breaks grounding).
|
| 40 |
+
- Rewriting the ranking pipeline (RRF, cross-encoder, citation/recency boost) — it is backend-independent and stays as-is.
|
| 41 |
+
|
| 42 |
+
## 4. Current Architecture (what any migration must preserve)
|
| 43 |
+
|
| 44 |
+
**Retriever (`rag/retriever.py`)** splits cleanly into two layers:
|
| 45 |
+
|
| 46 |
+
- **Candidate fetch — backend-specific (the only code that must change on migration):**
|
| 47 |
+
- `search()` / `search_by_entities()`: semantic ANN via `collection.query(query_texts=...)`.
|
| 48 |
+
- `search_by_keyword()`, `is_grounded_in_corpus()`, `is_grounded_in_abstract()`: **exact substring**
|
| 49 |
+
via `where_document={"$contains": ...}`, expanded over `_term_variants()` (SPG302 / SPG 302 / SPG-302).
|
| 50 |
+
- `get_paper()`, `paper_texts_for_pmids()`: metadata fetch via `collection.get(where=...)`.
|
| 51 |
+
- **Ranking — backend-independent (REUSE UNCHANGED):**
|
| 52 |
+
- `_parse_raw()` normalizes any backend response to the result-dict shape.
|
| 53 |
+
- `rrf_merge()`, `cross_encoder_rerank()`, `apply_citation_boost()` operate purely on result dicts.
|
| 54 |
+
|
| 55 |
+
**Indexer (`rag/indexer.py`)**: `build_collection()` chunks papers (≤6 chunks/paper), embeds via a
|
| 56 |
+
`SentenceTransformerEmbeddingFunction` **baked into the Chroma collection**, IDs `{pmid}_s{i}`, scalarized metadata.
|
| 57 |
+
|
| 58 |
+
> **Key design lever:** only the candidate-fetch functions touch the backend. If a new backend keeps
|
| 59 |
+
> the `_parse_raw` output shape, the entire ranking/fusion/boost pipeline is reused verbatim. This
|
| 60 |
+
> bounds migration scope to ~6 functions + the indexer writer.
|
| 61 |
+
|
| 62 |
+
## 5. Options Considered
|
| 63 |
+
|
| 64 |
+
| Option | Fit | Verdict |
|
| 65 |
+
|---|---|---|
|
| 66 |
+
| **A. Cheap deploy fix** (persistent storage on the Space, and/or Chroma client/server on a small host) | Keeps all Chroma features; ~no code change | **Recommended first (Phase 0)** |
|
| 67 |
+
| **B. Postgres + pgvector** (AWS Aurora/RDS, or Neon/Supabase) | One store for vectors (`<=>`) + full-text (ILIKE/tsvector) + metadata (SQL); preserves the hybrid | **Recommended migration target (Phase 1)** |
|
| 68 |
+
| **C. AWS OpenSearch** (kNN + BM25 + filters) | Fits the hybrid; AWS-native | Viable but heavier ops/tuning; overkill at 31k vectors |
|
| 69 |
+
| **D. Vector-only managed** (Pinecone / Qdrant Cloud) | No native substring/full-text | **Rejected** — would require a separate keyword store to keep grounding |
|
| 70 |
+
| **E. Amazon Bedrock Knowledge Bases** | Managed end-to-end RAG | **Rejected** — replaces the custom hybrid + grounding + citation-weighting (the product's differentiators) |
|
| 71 |
+
|
| 72 |
+
## 6. Recommendation (phased)
|
| 73 |
+
|
| 74 |
+
- **Phase 0 — now (do regardless of the migration decision):** Option A. Enable **persistent storage**
|
| 75 |
+
on the Space so `_ensure_data` stops re-downloading 1.2 GB each restart; optionally run **Chroma in
|
| 76 |
+
client/server mode** so the Space queries it over HTTP. Removes the cold-start pain with little/no
|
| 77 |
+
rewrite. (Pairs with the separate COE action item to make data refresh not require a factory-reboot.)
|
| 78 |
+
- **Phase 1 — when a trigger hits:** Option B (pgvector). **Triggers:** corpus scaled toward 100k+
|
| 79 |
+
chunks / continuous ingestion; OR a need to decouple the DB from the Space (multi-instance, independent
|
| 80 |
+
updates); OR wanting to stop shipping a data blob entirely. Abstract the backend first (Section 7) so
|
| 81 |
+
the swap is localized and reversible.
|
| 82 |
+
|
| 83 |
+
## 7. Phase 1 Detailed Design (pgvector)
|
| 84 |
+
|
| 85 |
+
**Backend abstraction (do this even before migrating):** introduce `rag/backends/` with a thin interface —
|
| 86 |
+
`semantic_candidates(query, n)`, `substring_candidates(term_variants, n)`, `fetch_by_pmids(pmids)`,
|
| 87 |
+
`abstract_contains(variant)`, `count()` — implemented by `chroma_backend` (wraps today's code) and
|
| 88 |
+
`pg_backend`. `retriever.py` calls the interface; the ranking layer is untouched. `indexer.py` gains a pg writer.
|
| 89 |
+
|
| 90 |
+
**Schema (single table):**
|
| 91 |
+
```sql
|
| 92 |
+
CREATE TABLE chunks (
|
| 93 |
+
chunk_id text PRIMARY KEY,
|
| 94 |
+
pmid text,
|
| 95 |
+
section text,
|
| 96 |
+
chunk_index int,
|
| 97 |
+
title text,
|
| 98 |
+
year int,
|
| 99 |
+
doi text,
|
| 100 |
+
citation_count int,
|
| 101 |
+
entity_names text,
|
| 102 |
+
has_full_text boolean,
|
| 103 |
+
document text,
|
| 104 |
+
embedding vector(768),
|
| 105 |
+
doc_tsv tsvector
|
| 106 |
+
);
|
| 107 |
+
-- indexes: HNSW on embedding (vector_cosine_ops); GIN on doc_tsv; btree on pmid
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
**Query mapping (preserve current semantics):**
|
| 111 |
+
- `collection.query(query_texts)` → embed query with BioLORD explicitly, then
|
| 112 |
+
`SELECT ... ORDER BY embedding <=> $qvec LIMIT n`.
|
| 113 |
+
- `where_document $contains` → `WHERE document ILIKE '%'||$variant||'%'` (exact substring — matches
|
| 114 |
+
Chroma `$contains` behavior for drug codes; **use ILIKE, not tsvector**, to keep grounding parity).
|
| 115 |
+
- `where={pmid $in}` / `chunk_index` / `get` → plain SQL `WHERE`.
|
| 116 |
+
- Query embeddings use the same `config` model; the ranking pipeline consumes `_parse_raw`-shaped dicts unchanged.
|
| 117 |
+
|
| 118 |
+
**Config:** DB URL via env var; embedding model unchanged. Graph/agent layers untouched.
|
| 119 |
+
|
| 120 |
+
## 8. Rollout / Migration
|
| 121 |
+
|
| 122 |
+
1. Stand up Postgres + pgvector (RDS/Aurora/Neon); `CREATE EXTENSION vector`.
|
| 123 |
+
2. **Backfill** with a one-off script that reuses the existing `rag/indexer.py` chunker on `papers.jsonl`,
|
| 124 |
+
embeds, and writes rows (idempotent on `chunk_id`).
|
| 125 |
+
3. **Dual-read validation** (Section 11) — compare Chroma vs pgvector on a fixed query set.
|
| 126 |
+
4. Flip the Space to the pg backend via an **env flag**, keeping the Chroma path behind the flag for rollback.
|
| 127 |
+
5. Once stable, drop the 1.2 GB blob from the dataset repo.
|
| 128 |
+
|
| 129 |
+
## 9. Risks & Mitigations
|
| 130 |
+
|
| 131 |
+
- **Substring-grounding parity:** ILIKE must reproduce `$contains` over `_term_variants()`. Port the variant
|
| 132 |
+
expansion; add tests comparing grounding booleans and keyword hits against Chroma.
|
| 133 |
+
- **Per-request query fan-out:** `search_by_entities()` issues up to 12 queries/request — batch into a single
|
| 134 |
+
SQL round-trip or reduce `RETRIEVAL_ENTITY_QUERY_CAP`; HNSW keeps ANN fast.
|
| 135 |
+
- **DB cost/ops:** smallest managed tier is ample at this scale.
|
| 136 |
+
- **Embedding drift:** pin the embedding model; re-embed if it changes.
|
| 137 |
+
|
| 138 |
+
## 10. Open Questions / Decisions for Reviewer
|
| 139 |
+
|
| 140 |
+
1. **Growth:** staying ~10–20k papers, or scaling toward full PubMed / continuous ingestion? (Decides whether Phase 1 is needed at all.)
|
| 141 |
+
2. **Host:** AWS specifically (Aurora/RDS), or is managed-elsewhere (Neon/Supabase) acceptable — often cheaper/simpler at this scale?
|
| 142 |
+
3. **Driver:** is decoupling the DB from the Space a goal in itself, or is cold-start relief (Phase 0) enough for now?
|
| 143 |
+
4. **Ops/budget:** appetite for an always-on DB instance vs the current zero-infra file model.
|
| 144 |
+
|
| 145 |
+
## 11. Verification
|
| 146 |
+
|
| 147 |
+
- **Parity harness:** fixed query set; assert the final top-15 cited PMIDs and the grounding booleans
|
| 148 |
+
(`is_grounded_in_corpus`, `is_grounded_in_abstract`) match Chroma within tolerance.
|
| 149 |
+
- **Latency:** p50/p95 per query under both backends.
|
| 150 |
+
- **Cold-start:** Space boot time before/after — Phase 0 target: no 1.2 GB re-download; Phase 1 target: no data blob shipped at all.
|
landscape.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Rendering helpers for the Experimental ALS Therapy Landscape tab (v2: multi-label).
|
| 2 |
+
|
| 3 |
+
Loads data/landscape/landscape.json and turns it into a Plotly sunburst (mechanism class →
|
| 4 |
+
primary therapy) plus a detail panel that lists every mechanism a therapy acts through (with role,
|
| 5 |
+
confidence, and justification) and a trials table with status badges + NCT links. Therapies whose
|
| 6 |
+
mechanism could not be established are shown honestly under "Insufficient evidence".
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import json
|
| 11 |
+
import html
|
| 12 |
+
|
| 13 |
+
from config import LANDSCAPE_PATH
|
| 14 |
+
|
| 15 |
+
_INSUFFICIENT = "Insufficient evidence"
|
| 16 |
+
|
| 17 |
+
_CLASS_COLORS = {
|
| 18 |
+
"TDP-43 proteinopathy": "#6C5CE7", "SOD1": "#0984E3", "C9orf72": "#00B894", "FUS": "#00CEC9",
|
| 19 |
+
"Neuroinflammation": "#E17055", "Oxidative stress": "#D63031", "Mitochondrial dysfunction": "#E84393",
|
| 20 |
+
"Glutamate excitotoxicity": "#FDCB6E", "Proteostasis / autophagy": "#A29BFE",
|
| 21 |
+
"RNA metabolism": "#74B9FF", "Neurotrophic / regenerative": "#55EFC4",
|
| 22 |
+
"Symptomatic / Other": "#B2BEC3", _INSUFFICIENT: "#DFE6E9",
|
| 23 |
+
}
|
| 24 |
+
_DEFAULT_COLOR = "#B2BEC3"
|
| 25 |
+
_STATUS_BADGE = {
|
| 26 |
+
"recruiting": ("#00B894", "Recruiting"), "active": ("#0984E3", "Active"),
|
| 27 |
+
"completed": ("#636E72", "Completed"), "terminated": ("#D63031", "Terminated"),
|
| 28 |
+
"other": ("#B2BEC3", "Unknown"),
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
PHASE_OPTIONS = ["Phase 1", "Phase 2", "Phase 3", "Phase 4", "Not applicable"]
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _phase_buckets(phase: str) -> set:
|
| 36 |
+
"""Map a ClinicalTrials.gov phase string (incl. combos like 'PHASE1, PHASE2') to UI buckets."""
|
| 37 |
+
p = (phase or "").upper()
|
| 38 |
+
b = set()
|
| 39 |
+
if "PHASE1" in p: # also catches EARLY_PHASE1
|
| 40 |
+
b.add("Phase 1")
|
| 41 |
+
if "PHASE2" in p:
|
| 42 |
+
b.add("Phase 2")
|
| 43 |
+
if "PHASE3" in p:
|
| 44 |
+
b.add("Phase 3")
|
| 45 |
+
if "PHASE4" in p:
|
| 46 |
+
b.add("Phase 4")
|
| 47 |
+
return b or {"Not applicable"} # NA / Expanded Access / blank
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _therapy_matches_phases(t: dict, selected: set | None) -> bool:
|
| 51 |
+
"""A therapy is shown if any of its trials falls in a selected phase. None/all = whole picture."""
|
| 52 |
+
if not selected or len(selected) >= len(PHASE_OPTIONS):
|
| 53 |
+
return True
|
| 54 |
+
return any(_phase_buckets(tr.get("phase", "")) & selected for tr in t["trials"])
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def load_landscape() -> dict | None:
|
| 58 |
+
if not LANDSCAPE_PATH.exists():
|
| 59 |
+
return None
|
| 60 |
+
try:
|
| 61 |
+
return json.loads(LANDSCAPE_PATH.read_text())
|
| 62 |
+
except Exception:
|
| 63 |
+
return None
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def class_names(landscape: dict | None) -> list[str]:
|
| 67 |
+
if not landscape:
|
| 68 |
+
return []
|
| 69 |
+
names = [c["name"] for c in landscape["classifications"]]
|
| 70 |
+
if landscape.get("unclassified"):
|
| 71 |
+
names.append(_INSUFFICIENT)
|
| 72 |
+
return names
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _therapies_of(landscape: dict, class_name: str) -> list[dict]:
|
| 76 |
+
if class_name == _INSUFFICIENT:
|
| 77 |
+
return landscape.get("unclassified", [])
|
| 78 |
+
for c in landscape["classifications"]:
|
| 79 |
+
if c["name"] == class_name:
|
| 80 |
+
return c["therapies"]
|
| 81 |
+
return []
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def therapy_labels(landscape: dict | None, class_name: str, phases: list | None = None) -> list[str]:
|
| 85 |
+
"""Dropdown labels, e.g. 'CNM-Au8 [contributing] — 5 trials, 3 recruiting'."""
|
| 86 |
+
sel = set(phases) if phases else None
|
| 87 |
+
labels = []
|
| 88 |
+
for t in _therapies_of(landscape or {}, class_name):
|
| 89 |
+
if not _therapy_matches_phases(t, sel):
|
| 90 |
+
continue
|
| 91 |
+
c = t["trial_counts"]
|
| 92 |
+
rec = f", {c['recruiting']} recruiting" if c["recruiting"] else ""
|
| 93 |
+
role = t.get("role_here")
|
| 94 |
+
tag = f" [{role}]" if (role and class_name != _INSUFFICIENT) else ""
|
| 95 |
+
labels.append(f"{t['name']}{tag} — {c['total']} trial{'s' if c['total'] != 1 else ''}{rec}")
|
| 96 |
+
return labels
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _therapy_by_label(landscape: dict, class_name: str, label: str) -> dict | None:
|
| 100 |
+
name = label.split(" — ")[0].split(" [")[0] if label else ""
|
| 101 |
+
for t in _therapies_of(landscape, class_name):
|
| 102 |
+
if t["name"] == name:
|
| 103 |
+
return t
|
| 104 |
+
return None
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def build_sunburst(landscape: dict | None, phases: list | None = None):
|
| 108 |
+
"""Two-ring sunburst: inner = mechanism class, outer = its PRIMARY therapies (sized by trials).
|
| 109 |
+
Contributing memberships live in the detail panel; abstentions get an 'Insufficient evidence' wedge.
|
| 110 |
+
"""
|
| 111 |
+
import plotly.graph_objects as go
|
| 112 |
+
|
| 113 |
+
sel = set(phases) if phases else None
|
| 114 |
+
|
| 115 |
+
def _keep(t):
|
| 116 |
+
return _therapy_matches_phases(t, sel)
|
| 117 |
+
|
| 118 |
+
ids, labels, parents, values, colors, hover = [], [], [], [], [], []
|
| 119 |
+
|
| 120 |
+
def add_class(name, therapies, color, desc=""):
|
| 121 |
+
if not therapies:
|
| 122 |
+
return
|
| 123 |
+
cid = f"cls::{name}"
|
| 124 |
+
total = sum(t["trial_counts"]["total"] for t in therapies)
|
| 125 |
+
ids.append(cid); labels.append(name); parents.append(""); values.append(total); colors.append(color)
|
| 126 |
+
hover.append(f"<b>{name}</b><br>{len(therapies)} therapies · {total} trials<br>{desc}")
|
| 127 |
+
for t in therapies:
|
| 128 |
+
cnt = t["trial_counts"]
|
| 129 |
+
ids.append(f"th::{name}::{t['name']}"); labels.append(t["name"]); parents.append(cid)
|
| 130 |
+
values.append(cnt["total"]); colors.append(color)
|
| 131 |
+
hover.append(f"<b>{t['name']}</b> ({t['modality']})<br>Target: {t['target']}<br>"
|
| 132 |
+
f"{cnt['total']} trials · {cnt['recruiting']} recruiting · {cnt['completed']} completed")
|
| 133 |
+
|
| 134 |
+
if landscape:
|
| 135 |
+
for c in landscape["classifications"]:
|
| 136 |
+
primaries = [t for t in c["therapies"] if t.get("role_here") == "primary" and _keep(t)]
|
| 137 |
+
add_class(c["name"], primaries, _CLASS_COLORS.get(c["name"], _DEFAULT_COLOR), c.get("description", ""))
|
| 138 |
+
un = [t for t in landscape.get("unclassified", []) if _keep(t)]
|
| 139 |
+
if un:
|
| 140 |
+
add_class(_INSUFFICIENT, un, _CLASS_COLORS[_INSUFFICIENT],
|
| 141 |
+
"Mechanism not established from available evidence")
|
| 142 |
+
|
| 143 |
+
fig = go.Figure(go.Sunburst(
|
| 144 |
+
ids=ids, labels=labels, parents=parents, values=values, branchvalues="total",
|
| 145 |
+
marker=dict(colors=colors), hovertext=hover, hoverinfo="text",
|
| 146 |
+
insidetextorientation="radial", maxdepth=2,
|
| 147 |
+
))
|
| 148 |
+
fig.update_layout(margin=dict(t=10, l=0, r=0, b=0), height=520, paper_bgcolor="rgba(0,0,0,0)")
|
| 149 |
+
return fig
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def therapy_detail_md(landscape: dict | None, class_name: str, therapy_label: str) -> str:
|
| 153 |
+
if not landscape or not class_name:
|
| 154 |
+
return "*Select a mechanism class and therapy to see its mechanisms and trials.*"
|
| 155 |
+
t = _therapy_by_label(landscape, class_name, therapy_label)
|
| 156 |
+
if not t:
|
| 157 |
+
return f"*{class_name}* — select a therapy above."
|
| 158 |
+
header = f"### {t['name']}\n<sub>{t['modality']} · Target: {t['target']}</sub>"
|
| 159 |
+
if t.get("aliases"):
|
| 160 |
+
header += f"\n<sub>Also: {', '.join(t['aliases'][:6])}</sub>"
|
| 161 |
+
mechs = t.get("mechanisms") or []
|
| 162 |
+
if not mechs:
|
| 163 |
+
return header + "\n\n**Mechanism of action:** _Not established from the available evidence._"
|
| 164 |
+
lines = [header, "\n**Mechanism(s) of action:**"]
|
| 165 |
+
for m in mechs:
|
| 166 |
+
conf = int(round(m["confidence"] * 100))
|
| 167 |
+
ev = f" — {m['evidence']}" if m.get("evidence") else ""
|
| 168 |
+
lines.append(f"- **{m['class']}** · _{m['role']}, {conf}% confidence_{ev}")
|
| 169 |
+
return "\n".join(lines)
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def trials_table_html(landscape: dict | None, class_name: str, therapy_label: str) -> str:
|
| 173 |
+
if not landscape or not class_name:
|
| 174 |
+
return ""
|
| 175 |
+
t = _therapy_by_label(landscape, class_name, therapy_label)
|
| 176 |
+
if not t:
|
| 177 |
+
return ""
|
| 178 |
+
rows = []
|
| 179 |
+
for tr in t["trials"]:
|
| 180 |
+
color, label = _STATUS_BADGE.get(tr["status_group"], _STATUS_BADGE["other"])
|
| 181 |
+
badge = (f'<span style="background:{color};color:#fff;border-radius:10px;'
|
| 182 |
+
f'padding:1px 8px;font-size:0.72rem;white-space:nowrap;">{label}</span>')
|
| 183 |
+
phase = html.escape((tr.get("phase") or "—").replace("PHASE", "Ph"))
|
| 184 |
+
title = html.escape(tr.get("title", "")[:110])
|
| 185 |
+
nct = html.escape(tr.get("nct_id", ""))
|
| 186 |
+
url = html.escape(tr.get("url", ""))
|
| 187 |
+
sponsor = html.escape((tr.get("sponsor") or "")[:40])
|
| 188 |
+
rows.append(
|
| 189 |
+
f'<tr><td style="padding:4px 8px;">{badge}</td>'
|
| 190 |
+
f'<td style="padding:4px 8px;color:#666;">{phase}</td>'
|
| 191 |
+
f'<td style="padding:4px 8px;"><a href="{url}" target="_blank" rel="noopener">{nct}</a> — {title}</td>'
|
| 192 |
+
f'<td style="padding:4px 8px;color:#888;font-size:0.8rem;">{sponsor}</td></tr>'
|
| 193 |
+
)
|
| 194 |
+
counts = t["trial_counts"]
|
| 195 |
+
caption = (f'<div style="font-size:0.85rem;color:#666;margin:6px 0;">{counts["total"]} trials — '
|
| 196 |
+
f'<b style="color:#00B894;">{counts["recruiting"]} recruiting</b>, '
|
| 197 |
+
f'{counts["active"]} active, {counts["completed"]} completed, {counts["terminated"]} terminated</div>')
|
| 198 |
+
return caption + (
|
| 199 |
+
'<table style="width:100%;border-collapse:collapse;font-size:0.88rem;">'
|
| 200 |
+
'<thead><tr style="text-align:left;border-bottom:1px solid #ddd;color:#888;">'
|
| 201 |
+
'<th style="padding:4px 8px;">Status</th><th style="padding:4px 8px;">Phase</th>'
|
| 202 |
+
'<th style="padding:4px 8px;">Trial</th><th style="padding:4px 8px;">Sponsor</th></tr></thead>'
|
| 203 |
+
f'<tbody>{"".join(rows)}</tbody></table>'
|
| 204 |
+
)
|
prompts.py
CHANGED
|
@@ -24,6 +24,51 @@ Be precise. Only extract entities explicitly mentioned. Confidence reflects how
|
|
| 24 |
the entity is identified in the text (1.0 = unambiguous, 0.5 = inferred, 0.3 = uncertain).
|
| 25 |
"""
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
SYNTHESIS_SYSTEM = """\
|
| 28 |
You are a clinical research synthesis expert specializing in ALS (amyotrophic lateral sclerosis).
|
| 29 |
You help physicians understand the research evidence behind ALS biology, drug targets, and clinical trials.
|
|
|
|
| 24 |
the entity is identified in the text (1.0 = unambiguous, 0.5 = inferred, 0.3 = uncertain).
|
| 25 |
"""
|
| 26 |
|
| 27 |
+
LANDSCAPE_SYSTEM = """\
|
| 28 |
+
You are an ALS-pharmacology expert classifying experimental therapies by mechanism of action.
|
| 29 |
+
For each therapy you are given EVIDENCE (its trial summaries + retrieved paper abstracts).
|
| 30 |
+
Use the evidence together with your established knowledge of ALS therapeutics to classify each
|
| 31 |
+
therapy with the classify_therapy tool — call it exactly once per therapy, echoing therapy_key.
|
| 32 |
+
|
| 33 |
+
MULTI-LABEL: a therapy may act through several mechanisms. Return EVERY mechanism class that is
|
| 34 |
+
well established for THIS therapy, each with a role ("primary" vs "contributing"), a confidence,
|
| 35 |
+
and a one-line `evidence_quote` justification (quote the evidence when it supports you; otherwise
|
| 36 |
+
state the established mechanism concisely). The highest-confidence entry is the primary mechanism.
|
| 37 |
+
|
| 38 |
+
Mechanism classes:
|
| 39 |
+
- TDP-43 proteinopathy, SOD1, C9orf72, FUS, Neuroinflammation, Oxidative stress,
|
| 40 |
+
Mitochondrial dysfunction, Glutamate excitotoxicity, Proteostasis / autophagy, RNA metabolism,
|
| 41 |
+
Neurotrophic / regenerative, Symptomatic / Other.
|
| 42 |
+
|
| 43 |
+
CRITICAL — misleading information is worse than no information:
|
| 44 |
+
- Only assert a mechanism you are genuinely confident is established for THIS specific therapy.
|
| 45 |
+
Set confidence honestly (1.0 = textbook-established; 0.6 = reasonable; below that, omit it).
|
| 46 |
+
- Classify by how THIS therapy acts — NEVER infer a mechanism from co-mentioned entities or from
|
| 47 |
+
other drugs in a combination trial. (Example: an antioxidant tested in a trial that also studies
|
| 48 |
+
neuroinflammation is NOT itself a neuroinflammation therapy.)
|
| 49 |
+
- POPULATION IS NOT MECHANISM. Assign a genetic class (SOD1, C9orf72, FUS, TDP-43 proteinopathy)
|
| 50 |
+
ONLY when the therapy directly targets that gene/protein/RNA (e.g., an ASO or gene therapy that
|
| 51 |
+
lowers it). A drug merely tested in patients with that mutation, or a general neuroprotectant, does
|
| 52 |
+
NOT get the genetic class (e.g., arimoclomol is Proteostasis, not SOD1, even when trialed in SOD1-ALS).
|
| 53 |
+
- Prefer FEWER, higher-confidence mechanisms. Emit a "contributing" mechanism only when it is
|
| 54 |
+
well-established for this drug, not merely plausible — when in doubt, leave it out.
|
| 55 |
+
- If you do not know the therapy and the evidence does not establish a mechanism, return an EMPTY
|
| 56 |
+
mechanisms array. Abstaining is correct and expected for obscure or repurposed drugs you cannot
|
| 57 |
+
place confidently — never guess to fill the field.
|
| 58 |
+
- Use "Symptomatic / Other" only for therapies that genuinely act symptomatically (muscle function,
|
| 59 |
+
cramps, respiration), not as a dumping ground for uncertainty.
|
| 60 |
+
|
| 61 |
+
Also return canonical_name (merge synonyms/codes), modality, and the primary molecular target
|
| 62 |
+
("Unknown" if not determinable).
|
| 63 |
+
|
| 64 |
+
Examples of correct classification:
|
| 65 |
+
- Riluzole → [{"class":"Glutamate excitotoxicity","role":"primary"}] (reduces glutamate excitotoxicity).
|
| 66 |
+
- CNM-Au8 → [{"class":"Mitochondrial dysfunction","role":"primary"},{"class":"Oxidative stress","role":"contributing"}]
|
| 67 |
+
— a gold nanocrystal catalyst that improves neuronal energy metabolism and reduces oxidative stress;
|
| 68 |
+
it is NOT a neuroinflammation therapy even if its trials mention neuroinflammation.
|
| 69 |
+
- An obscure development-code drug you cannot place confidently → mechanisms: [] (abstain).
|
| 70 |
+
"""
|
| 71 |
+
|
| 72 |
SYNTHESIS_SYSTEM = """\
|
| 73 |
You are a clinical research synthesis expert specializing in ALS (amyotrophic lateral sclerosis).
|
| 74 |
You help physicians understand the research evidence behind ALS biology, drug targets, and clinical trials.
|
pyproject.toml
CHANGED
|
@@ -16,6 +16,7 @@ dependencies = [
|
|
| 16 |
"sentence-transformers>=3.0.0",
|
| 17 |
"openai>=2.44.0",
|
| 18 |
"rapidfuzz>=3.14.5",
|
|
|
|
| 19 |
]
|
| 20 |
|
| 21 |
[project.optional-dependencies]
|
|
|
|
| 16 |
"sentence-transformers>=3.0.0",
|
| 17 |
"openai>=2.44.0",
|
| 18 |
"rapidfuzz>=3.14.5",
|
| 19 |
+
"plotly==6.9.0",
|
| 20 |
]
|
| 21 |
|
| 22 |
[project.optional-dependencies]
|
requirements.txt
CHANGED
|
@@ -8,3 +8,5 @@ python-dotenv>=1.2.2
|
|
| 8 |
rich>=13.0.0
|
| 9 |
sentence-transformers>=3.0.0
|
| 10 |
openai>=2.44.0
|
|
|
|
|
|
|
|
|
| 8 |
rich>=13.0.0
|
| 9 |
sentence-transformers>=3.0.0
|
| 10 |
openai>=2.44.0
|
| 11 |
+
rapidfuzz>=3.14.5
|
| 12 |
+
plotly==6.9.0
|
scripts/build_landscape.py
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Build the Experimental ALS Therapy Landscape (offline) — v2: multi-label, grounded, abstaining.
|
| 4 |
+
|
| 5 |
+
Derives experimental therapies from clinical-trial interventions, then for each therapy:
|
| 6 |
+
1. retrieves genuine mechanism-of-action evidence (RAG over the paper corpus by drug name +
|
| 7 |
+
the therapy's own trial summaries) — NOT the noisy aggregated trial target_entities;
|
| 8 |
+
2. classifies MULTI-LABEL with Claude (Opus 4.8) via the Batch API — every mechanism must be
|
| 9 |
+
backed by a verbatim evidence quote, and the model abstains (empty list) when unsure;
|
| 10 |
+
3. cross-checks each asserted mechanism with a model-independent BioLORD cosine between the
|
| 11 |
+
therapy's evidence and the mechanism-class description (drops weakly-supported labels);
|
| 12 |
+
4. keeps only mechanisms above the confidence threshold τ; a therapy with none is "unclassified"
|
| 13 |
+
(shown honestly as "Mechanism not established"), never guessed.
|
| 14 |
+
|
| 15 |
+
Writes data/landscape/landscape.json (committed to git; the app loads it at startup).
|
| 16 |
+
|
| 17 |
+
Usage:
|
| 18 |
+
uv run python scripts/build_landscape.py
|
| 19 |
+
uv run python scripts/build_landscape.py --limit 30 # cheap validation on top 30 therapies
|
| 20 |
+
uv run python scripts/build_landscape.py --reset # ignore any in-flight batch state
|
| 21 |
+
"""
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
import argparse
|
| 25 |
+
import json
|
| 26 |
+
import re
|
| 27 |
+
import sys
|
| 28 |
+
import time
|
| 29 |
+
from datetime import datetime, timezone
|
| 30 |
+
from pathlib import Path
|
| 31 |
+
|
| 32 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 33 |
+
|
| 34 |
+
from dotenv import load_dotenv
|
| 35 |
+
|
| 36 |
+
load_dotenv()
|
| 37 |
+
|
| 38 |
+
import anthropic
|
| 39 |
+
import numpy as np
|
| 40 |
+
from anthropic.types.message_create_params import MessageCreateParamsNonStreaming
|
| 41 |
+
from anthropic.types.messages.batch_create_params import Request
|
| 42 |
+
from rich.console import Console
|
| 43 |
+
|
| 44 |
+
from config import (
|
| 45 |
+
LANDSCAPE_BATCH_STATE_PATH,
|
| 46 |
+
LANDSCAPE_EVIDENCE_ABSTRACTS,
|
| 47 |
+
LANDSCAPE_MIN_CONFIDENCE,
|
| 48 |
+
LANDSCAPE_MODEL,
|
| 49 |
+
LANDSCAPE_PATH,
|
| 50 |
+
LANDSCAPE_XCHECK_MIN_COSINE,
|
| 51 |
+
THERAPY_CLASSES_PATH,
|
| 52 |
+
TRIALS_PATH,
|
| 53 |
+
)
|
| 54 |
+
from prompts import LANDSCAPE_SYSTEM
|
| 55 |
+
from tools import LANDSCAPE_TOOLS
|
| 56 |
+
|
| 57 |
+
console = Console()
|
| 58 |
+
|
| 59 |
+
_THERAPEUTIC_TYPES = {"DRUG", "BIOLOGICAL", "DIETARY_SUPPLEMENT", "GENETIC", "COMBINATION_PRODUCT"}
|
| 60 |
+
_EXCLUDE_RE = re.compile(
|
| 61 |
+
r"(placebo|sham|matching|best supportive care|standard of care|blood sample|"
|
| 62 |
+
r"saline|vehicle|diagnostic|questionnaire|no intervention|usual care|dextrose)",
|
| 63 |
+
re.IGNORECASE,
|
| 64 |
+
)
|
| 65 |
+
_MODALITY_PREFIX_RE = re.compile(
|
| 66 |
+
r"^(drug|biological|device|other|dietary supplement|genetic|procedure|"
|
| 67 |
+
r"combination product|radiation|behavioral|diagnostic test)\s*:\s*",
|
| 68 |
+
re.IGNORECASE,
|
| 69 |
+
)
|
| 70 |
+
_STATUS_GROUP = {
|
| 71 |
+
"RECRUITING": "recruiting", "NOT_YET_RECRUITING": "recruiting", "ENROLLING_BY_INVITATION": "recruiting",
|
| 72 |
+
"ACTIVE_NOT_RECRUITING": "active",
|
| 73 |
+
"COMPLETED": "completed", "APPROVED_FOR_MARKETING": "completed", "AVAILABLE": "completed",
|
| 74 |
+
"TERMINATED": "terminated", "WITHDRAWN": "terminated", "SUSPENDED": "terminated",
|
| 75 |
+
"NO_LONGER_AVAILABLE": "terminated", "TEMPORARILY_NOT_AVAILABLE": "terminated",
|
| 76 |
+
}
|
| 77 |
+
_GROUP_ORDER = {"recruiting": 0, "active": 1, "completed": 2, "terminated": 3, "other": 4}
|
| 78 |
+
_THERAPIES_PER_REQUEST = 10 # richer evidence per therapy → smaller batches
|
| 79 |
+
_POLL_INTERVAL_S = 30
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _norm_name(name: str) -> str:
|
| 83 |
+
name = _MODALITY_PREFIX_RE.sub("", name or "").strip()
|
| 84 |
+
return re.sub(r"\s+", " ", name)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def _status_group(status: str) -> str:
|
| 88 |
+
return _STATUS_GROUP.get((status or "").upper(), "other")
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def _load_trials(path: Path) -> list[dict]:
|
| 92 |
+
return [json.loads(line) for line in open(path, encoding="utf-8") if line.strip()]
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def group_therapies(trials: list[dict]) -> dict[str, dict]:
|
| 96 |
+
"""Group trials by normalized therapeutic intervention name."""
|
| 97 |
+
groups: dict[str, dict] = {}
|
| 98 |
+
for t in trials:
|
| 99 |
+
trial_meta = {
|
| 100 |
+
"nct_id": t.get("nct_id", ""), "title": t.get("title", ""), "phase": t.get("phase", ""),
|
| 101 |
+
"status": t.get("status", ""), "status_group": _status_group(t.get("status", "")),
|
| 102 |
+
"start_date": t.get("start_date", ""), "sponsor": t.get("sponsor", ""),
|
| 103 |
+
"url": t.get("url", "") or f"https://clinicaltrials.gov/study/{t.get('nct_id','')}",
|
| 104 |
+
"summary": t.get("summary", ""),
|
| 105 |
+
}
|
| 106 |
+
for iv in t.get("interventions", []):
|
| 107 |
+
if iv.get("type") not in _THERAPEUTIC_TYPES:
|
| 108 |
+
continue
|
| 109 |
+
name = _norm_name(iv.get("name", ""))
|
| 110 |
+
if not name or _EXCLUDE_RE.search(name):
|
| 111 |
+
continue
|
| 112 |
+
key = name.lower()
|
| 113 |
+
g = groups.setdefault(key, {"display": name, "raw_names": set(), "trials": {}})
|
| 114 |
+
g["raw_names"].add(iv.get("name", ""))
|
| 115 |
+
g["trials"][trial_meta["nct_id"]] = trial_meta
|
| 116 |
+
for g in groups.values():
|
| 117 |
+
g["trials"] = list(g["trials"].values())
|
| 118 |
+
return groups
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
# ── evidence retrieval + cross-check embeddings ──────────────────────────────
|
| 122 |
+
|
| 123 |
+
def _load_collection():
|
| 124 |
+
try:
|
| 125 |
+
from config import CHROMA_COLLECTION, CHROMA_DIR
|
| 126 |
+
from rag.indexer import load_collection
|
| 127 |
+
return load_collection(CHROMA_DIR, CHROMA_COLLECTION)
|
| 128 |
+
except Exception as e:
|
| 129 |
+
console.print(f"[yellow]No chroma collection ({e}); evidence = trial summaries only, no cross-check[/yellow]")
|
| 130 |
+
return None
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def _embed(texts: list[str]) -> np.ndarray:
|
| 134 |
+
from rag.indexer import _EMBED_FN
|
| 135 |
+
vecs = np.asarray(_EMBED_FN(texts), dtype=np.float32)
|
| 136 |
+
norms = np.linalg.norm(vecs, axis=1, keepdims=True)
|
| 137 |
+
return vecs / np.clip(norms, 1e-9, None)
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def _retrieve_evidence(collection, g: dict) -> str:
|
| 141 |
+
"""MoA evidence for a therapy: top abstracts (by drug name) + its own trial summaries."""
|
| 142 |
+
parts: list[str] = []
|
| 143 |
+
summaries = [t["summary"] for t in g["trials"][:3] if t.get("summary")]
|
| 144 |
+
parts.extend(s[:400] for s in summaries)
|
| 145 |
+
if collection is not None:
|
| 146 |
+
try:
|
| 147 |
+
from rag.retriever import search
|
| 148 |
+
for r in search(collection, g["display"], n_results=LANDSCAPE_EVIDENCE_ABSTRACTS):
|
| 149 |
+
doc = (r.get("document") or "")[:500]
|
| 150 |
+
if doc:
|
| 151 |
+
parts.append(doc)
|
| 152 |
+
except Exception:
|
| 153 |
+
pass
|
| 154 |
+
return "\n".join(parts)[:3000]
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
# ── LLM batch ────────────────────────────────────────────────────────────────
|
| 158 |
+
|
| 159 |
+
def _therapy_context(key: str, g: dict) -> str:
|
| 160 |
+
lines = [
|
| 161 |
+
f"--- THERAPY_KEY:{key} ---",
|
| 162 |
+
f"Intervention name(s): {', '.join(sorted(g['raw_names']))[:160]}",
|
| 163 |
+
"EVIDENCE (paper abstracts about this therapy + its trial summaries):",
|
| 164 |
+
g.get("evidence", "(no evidence retrieved)"),
|
| 165 |
+
]
|
| 166 |
+
return "\n".join(lines)
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def _format_batch(batch: list[tuple[str, dict]]) -> str:
|
| 170 |
+
parts = [
|
| 171 |
+
f"Classify each of the following {len(batch)} ALS experimental therapies from its EVIDENCE. "
|
| 172 |
+
"Call classify_therapy once per therapy (echo therapy_key). Emit only mechanisms you can quote "
|
| 173 |
+
"from the evidence; return an empty mechanisms list if the evidence establishes none.\n"
|
| 174 |
+
]
|
| 175 |
+
parts.extend(_therapy_context(key, g) for key, g in batch)
|
| 176 |
+
return "\n\n".join(parts)
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def _build_params(batch: list[tuple[str, dict]]) -> MessageCreateParamsNonStreaming:
|
| 180 |
+
return MessageCreateParamsNonStreaming(
|
| 181 |
+
model=LANDSCAPE_MODEL,
|
| 182 |
+
max_tokens=8192,
|
| 183 |
+
system=LANDSCAPE_SYSTEM,
|
| 184 |
+
tools=list(LANDSCAPE_TOOLS),
|
| 185 |
+
tool_choice={"type": "any"},
|
| 186 |
+
messages=[{"role": "user", "content": _format_batch(batch)}],
|
| 187 |
+
)
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def _run_batch(client, custom_id_to_batch: dict, state_path: Path, reset: bool) -> dict[str, dict]:
|
| 191 |
+
batch = None
|
| 192 |
+
if not reset and state_path.exists():
|
| 193 |
+
try:
|
| 194 |
+
existing = client.messages.batches.retrieve(json.loads(state_path.read_text())["batch_id"])
|
| 195 |
+
if existing.processing_status in {"in_progress", "validating", "finalizing", "ended"}:
|
| 196 |
+
console.print(f"[dim]Resuming batch {existing.id}[/dim]"); batch = existing
|
| 197 |
+
except anthropic.NotFoundError:
|
| 198 |
+
pass
|
| 199 |
+
if batch is None:
|
| 200 |
+
requests = [Request(custom_id=cid, params=_build_params(b)) for cid, b in custom_id_to_batch.items()]
|
| 201 |
+
batch = client.messages.batches.create(requests=requests)
|
| 202 |
+
state_path.parent.mkdir(parents=True, exist_ok=True)
|
| 203 |
+
state_path.write_text(json.dumps({"batch_id": batch.id}))
|
| 204 |
+
console.print(f"[cyan]Submitted batch {batch.id} ({len(requests)} requests, model={LANDSCAPE_MODEL})[/cyan]")
|
| 205 |
+
with console.status("Classifying therapies (batch)…"):
|
| 206 |
+
while batch.processing_status != "ended":
|
| 207 |
+
if batch.processing_status in {"canceling", "canceled", "expired"}:
|
| 208 |
+
console.print(f"[red]Batch ended early: {batch.processing_status}[/red]"); break
|
| 209 |
+
time.sleep(_POLL_INTERVAL_S)
|
| 210 |
+
batch = client.messages.batches.retrieve(batch.id)
|
| 211 |
+
records: dict[str, dict] = {}
|
| 212 |
+
for res in client.messages.batches.results(batch.id):
|
| 213 |
+
if res.result.type != "succeeded":
|
| 214 |
+
console.print(f"[yellow]Request {res.custom_id} {res.result.type}[/yellow]"); continue
|
| 215 |
+
for block in res.result.message.content:
|
| 216 |
+
if block.type == "tool_use" and block.name == "classify_therapy":
|
| 217 |
+
key = str(block.input.get("therapy_key", "")).strip()
|
| 218 |
+
if key:
|
| 219 |
+
records[key] = block.input
|
| 220 |
+
state_path.unlink(missing_ok=True)
|
| 221 |
+
return records
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
# ── filtering: confidence threshold + BioLORD cross-check ────────────────────
|
| 225 |
+
|
| 226 |
+
def _filter_all(records: dict, class_emb: dict, valid: set) -> dict[str, list[dict]]:
|
| 227 |
+
"""Per-mechanism filter: confidence ≥ τ AND justification↔class BioLORD cosine ≥ threshold.
|
| 228 |
+
|
| 229 |
+
The cross-check embeds each mechanism's own justification (evidence_quote) and compares it to
|
| 230 |
+
the claimed class description — catching internally-inconsistent labels (justification is about
|
| 231 |
+
a different mechanism than the class claimed). Batched so all embeddings are one pass.
|
| 232 |
+
"""
|
| 233 |
+
cand = [] # (key, class, role, conf, quote)
|
| 234 |
+
for key, rec in records.items():
|
| 235 |
+
for m in rec.get("mechanisms", []) or []:
|
| 236 |
+
cls = m.get("class")
|
| 237 |
+
if cls not in valid or float(m.get("confidence", 0.0)) < LANDSCAPE_MIN_CONFIDENCE:
|
| 238 |
+
continue
|
| 239 |
+
cand.append((key, cls, m.get("role", "contributing"),
|
| 240 |
+
float(m.get("confidence", 0.0)), (m.get("evidence_quote") or "")[:300]))
|
| 241 |
+
|
| 242 |
+
cosines = [None] * len(cand)
|
| 243 |
+
if class_emb and cand:
|
| 244 |
+
embs = _embed([q or cls for (_, cls, _, _, q) in cand])
|
| 245 |
+
for i, (_, cls, _, _, _) in enumerate(cand):
|
| 246 |
+
if cls in class_emb:
|
| 247 |
+
cosines[i] = float(np.dot(embs[i], class_emb[cls]))
|
| 248 |
+
|
| 249 |
+
out: dict[str, list[dict]] = {}
|
| 250 |
+
for i, (key, cls, role, conf, quote) in enumerate(cand):
|
| 251 |
+
cos = cosines[i]
|
| 252 |
+
if cos is not None and cos < LANDSCAPE_XCHECK_MIN_COSINE:
|
| 253 |
+
continue # justification doesn't semantically match the claimed class
|
| 254 |
+
out.setdefault(key, []).append({
|
| 255 |
+
"class": cls, "role": role, "confidence": round(conf, 2),
|
| 256 |
+
"xcheck_cosine": round(cos, 3) if cos is not None else None,
|
| 257 |
+
"evidence": quote,
|
| 258 |
+
})
|
| 259 |
+
for lst in out.values():
|
| 260 |
+
lst.sort(key=lambda m: (m["role"] != "primary", -m["confidence"]))
|
| 261 |
+
return out
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
# ── assembly ─────────────────────────────────────────────────────────────────
|
| 265 |
+
|
| 266 |
+
def _trial_block(m: dict) -> tuple[list[dict], dict]:
|
| 267 |
+
trials = sorted(m["_trials"].values(), key=lambda t: (_GROUP_ORDER.get(t["status_group"], 9), t.get("start_date", "")))
|
| 268 |
+
for t in trials:
|
| 269 |
+
t.pop("summary", None)
|
| 270 |
+
counts = {g: 0 for g in _GROUP_ORDER}
|
| 271 |
+
for t in trials:
|
| 272 |
+
counts[t["status_group"]] += 1
|
| 273 |
+
counts["total"] = len(trials)
|
| 274 |
+
return trials, counts
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
def build_landscape(limit=None, reset=False) -> dict:
|
| 278 |
+
taxonomy = json.loads(THERAPY_CLASSES_PATH.read_text())["classes"]
|
| 279 |
+
valid = {c["name"] for c in taxonomy}
|
| 280 |
+
trials = _load_trials(TRIALS_PATH)
|
| 281 |
+
groups = group_therapies(trials)
|
| 282 |
+
console.print(f"[cyan]{len(trials)} trials → {len(groups)} candidate therapies[/cyan]")
|
| 283 |
+
|
| 284 |
+
items = sorted(groups.items(), key=lambda kv: -len(kv[1]["trials"]))
|
| 285 |
+
if limit:
|
| 286 |
+
items = items[:limit]
|
| 287 |
+
console.print(f"[dim]--limit {limit}: top {len(items)} therapies[/dim]")
|
| 288 |
+
|
| 289 |
+
# evidence retrieval + cross-check embeddings (local, no API cost)
|
| 290 |
+
collection = _load_collection()
|
| 291 |
+
class_emb = {}
|
| 292 |
+
if collection is not None:
|
| 293 |
+
console.print("[dim]Embedding class descriptions + retrieving evidence (BioLORD)…[/dim]")
|
| 294 |
+
cls_vecs = _embed([f"{c['name']}: {c['description']}" for c in taxonomy])
|
| 295 |
+
class_emb = {c["name"]: cls_vecs[i] for i, c in enumerate(taxonomy)}
|
| 296 |
+
for _, g in items:
|
| 297 |
+
g["evidence"] = _retrieve_evidence(collection, g)
|
| 298 |
+
|
| 299 |
+
# classify
|
| 300 |
+
batches = [items[i : i + _THERAPIES_PER_REQUEST] for i in range(0, len(items), _THERAPIES_PER_REQUEST)]
|
| 301 |
+
client = anthropic.Anthropic()
|
| 302 |
+
records = _run_batch(client, {f"batch-{i}": b for i, b in enumerate(batches)}, LANDSCAPE_BATCH_STATE_PATH, reset)
|
| 303 |
+
console.print(f"[green]Classified {len(records)}/{len(items)} therapies[/green]")
|
| 304 |
+
|
| 305 |
+
# confidence threshold + justification↔class cross-check (batched)
|
| 306 |
+
filtered = _filter_all(records, class_emb, valid)
|
| 307 |
+
|
| 308 |
+
# merge by canonical name, attach trials
|
| 309 |
+
merged: dict[str, dict] = {}
|
| 310 |
+
for key, g in items:
|
| 311 |
+
rec = records.get(key)
|
| 312 |
+
if not rec:
|
| 313 |
+
continue
|
| 314 |
+
mechs = filtered.get(key, [])
|
| 315 |
+
canonical = (rec.get("canonical_name") or g["display"]).strip()
|
| 316 |
+
m = merged.setdefault(canonical.lower(), {
|
| 317 |
+
"name": canonical, "modality": rec.get("modality", "Other"),
|
| 318 |
+
"target": rec.get("target", "Unknown"), "aliases": set(),
|
| 319 |
+
"_mechs": {}, "_trials": {},
|
| 320 |
+
})
|
| 321 |
+
m["aliases"].update(a for a in rec.get("aliases", []) if a)
|
| 322 |
+
m["aliases"].update(g["raw_names"])
|
| 323 |
+
for me in mechs: # highest-confidence per class wins
|
| 324 |
+
cur = m["_mechs"].get(me["class"])
|
| 325 |
+
if cur is None or me["confidence"] > cur["confidence"]:
|
| 326 |
+
m["_mechs"][me["class"]] = me
|
| 327 |
+
for t in g["trials"]:
|
| 328 |
+
m["_trials"][t["nct_id"]] = t
|
| 329 |
+
|
| 330 |
+
# finalize therapies
|
| 331 |
+
therapies, abstained = [], []
|
| 332 |
+
for m in merged.values():
|
| 333 |
+
trials_, counts = _trial_block(m)
|
| 334 |
+
mechs = sorted(m["_mechs"].values(), key=lambda x: (x["role"] != "primary", -x["confidence"]))
|
| 335 |
+
base = {
|
| 336 |
+
"name": m["name"], "modality": m["modality"], "target": m["target"],
|
| 337 |
+
"aliases": sorted(a for a in m["aliases"] if a and a.lower() != m["name"].lower())[:8],
|
| 338 |
+
"mechanisms": mechs, "trials": trials_, "trial_counts": counts,
|
| 339 |
+
}
|
| 340 |
+
(therapies if mechs else abstained).append(base)
|
| 341 |
+
|
| 342 |
+
# group into taxonomy buckets (a therapy appears under EACH of its mechanism classes)
|
| 343 |
+
by_class: dict[str, list[dict]] = {}
|
| 344 |
+
for th in therapies:
|
| 345 |
+
for me in th["mechanisms"]:
|
| 346 |
+
entry = {**th, "role_here": me["role"], "confidence_here": me["confidence"]}
|
| 347 |
+
by_class.setdefault(me["class"], []).append(entry)
|
| 348 |
+
|
| 349 |
+
classifications = []
|
| 350 |
+
for cls in taxonomy:
|
| 351 |
+
ths = by_class.get(cls["name"], [])
|
| 352 |
+
if not ths:
|
| 353 |
+
continue
|
| 354 |
+
ths.sort(key=lambda t: (t["role_here"] != "primary", -t["trial_counts"]["recruiting"], -t["trial_counts"]["total"]))
|
| 355 |
+
classifications.append({
|
| 356 |
+
"id": cls["id"], "name": cls["name"], "description": cls["description"],
|
| 357 |
+
"primary_count": sum(1 for t in ths if t["role_here"] == "primary"),
|
| 358 |
+
"therapy_count": len(ths),
|
| 359 |
+
"trial_count": sum(t["trial_counts"]["total"] for t in ths if t["role_here"] == "primary"),
|
| 360 |
+
"therapies": ths,
|
| 361 |
+
})
|
| 362 |
+
|
| 363 |
+
return {
|
| 364 |
+
"generated_at": datetime.now(timezone.utc).isoformat(),
|
| 365 |
+
"model": LANDSCAPE_MODEL,
|
| 366 |
+
"thresholds": {"min_confidence": LANDSCAPE_MIN_CONFIDENCE, "min_xcheck_cosine": LANDSCAPE_XCHECK_MIN_COSINE},
|
| 367 |
+
"source": {"trials": len(trials), "therapies": len(therapies), "abstained": len(abstained)},
|
| 368 |
+
"classifications": classifications,
|
| 369 |
+
"unclassified": sorted(abstained, key=lambda t: -t["trial_counts"]["total"]),
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
def main() -> None:
|
| 374 |
+
parser = argparse.ArgumentParser(description="Build the ALS experimental therapy landscape (v2)")
|
| 375 |
+
parser.add_argument("--limit", type=int, default=None)
|
| 376 |
+
parser.add_argument("--reset", action="store_true")
|
| 377 |
+
args = parser.parse_args()
|
| 378 |
+
if not TRIALS_PATH.exists():
|
| 379 |
+
console.print(f"[red]Trials file not found: {TRIALS_PATH}[/red]"); sys.exit(1)
|
| 380 |
+
|
| 381 |
+
landscape = build_landscape(limit=args.limit, reset=args.reset)
|
| 382 |
+
LANDSCAPE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
| 383 |
+
LANDSCAPE_PATH.write_text(json.dumps(landscape, indent=2))
|
| 384 |
+
|
| 385 |
+
s = landscape["source"]
|
| 386 |
+
console.print(f"\n[bold green]Done![/bold green] → {LANDSCAPE_PATH}")
|
| 387 |
+
console.print(f" Therapies classified: [bold]{s['therapies']}[/bold] · abstained (insufficient evidence): [bold]{s['abstained']}[/bold]")
|
| 388 |
+
for c in landscape["classifications"]:
|
| 389 |
+
console.print(f" [dim]{c['name']}: {c['primary_count']} primary / {c['therapy_count']} incl. contributing[/dim]")
|
| 390 |
+
|
| 391 |
+
|
| 392 |
+
if __name__ == "__main__":
|
| 393 |
+
main()
|
scripts/eval_landscape.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Evaluate the mechanism classifier against the hand-labeled gold set.
|
| 4 |
+
|
| 5 |
+
Reports multi-label precision / recall / F1 (micro), exact-set-match rate, and abstention rate,
|
| 6 |
+
plus a per-therapy breakdown so misclassifications are visible. Physician's rule — misleading is
|
| 7 |
+
worse than missing — so we optimize for PRECISION: a wrong label counts against us; an abstention
|
| 8 |
+
(no label) does not count as a precision error, only against recall.
|
| 9 |
+
|
| 10 |
+
Usage:
|
| 11 |
+
uv run python scripts/eval_landscape.py
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import json
|
| 16 |
+
import sys
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 20 |
+
|
| 21 |
+
from rich.console import Console
|
| 22 |
+
|
| 23 |
+
from config import LANDSCAPE_PATH, THERAPY_GOLD_PATH
|
| 24 |
+
|
| 25 |
+
console = Console()
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _predicted_index(landscape: dict) -> dict[str, set]:
|
| 29 |
+
"""name/alias (lower) -> set of predicted mechanism classes. Abstained therapies map to set()."""
|
| 30 |
+
idx: dict[str, set] = {}
|
| 31 |
+
seen = {}
|
| 32 |
+
def add(th: dict, mech_classes: set):
|
| 33 |
+
for nm in [th["name"], *th.get("aliases", [])]:
|
| 34 |
+
key = nm.lower()
|
| 35 |
+
# a therapy appears in multiple class buckets; union its mechanism set once
|
| 36 |
+
seen.setdefault(key, set()).update(mech_classes)
|
| 37 |
+
idx[key] = seen[key]
|
| 38 |
+
for c in landscape.get("classifications", []):
|
| 39 |
+
for th in c["therapies"]:
|
| 40 |
+
add(th, {m["class"] for m in th.get("mechanisms", [])})
|
| 41 |
+
for th in landscape.get("unclassified", []):
|
| 42 |
+
add(th, set())
|
| 43 |
+
return idx
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def main() -> None:
|
| 47 |
+
if not LANDSCAPE_PATH.exists():
|
| 48 |
+
console.print("[red]No landscape.json — run scripts/build_landscape.py first[/red]"); sys.exit(1)
|
| 49 |
+
landscape = json.loads(LANDSCAPE_PATH.read_text())
|
| 50 |
+
gold = json.loads(THERAPY_GOLD_PATH.read_text())["gold"]
|
| 51 |
+
idx = _predicted_index(landscape)
|
| 52 |
+
|
| 53 |
+
tp = fp = fn = 0
|
| 54 |
+
exact = matched = abstained = missing = 0
|
| 55 |
+
rows = []
|
| 56 |
+
for g in gold:
|
| 57 |
+
names = [g["therapy"], *g.get("aliases", [])]
|
| 58 |
+
pred = None
|
| 59 |
+
for nm in names:
|
| 60 |
+
if nm.lower() in idx:
|
| 61 |
+
pred = idx[nm.lower()]; break
|
| 62 |
+
gset = set(g["mechanisms"])
|
| 63 |
+
if pred is None:
|
| 64 |
+
missing += 1
|
| 65 |
+
rows.append(("?", g["therapy"], "NOT IN LANDSCAPE", gset, set()))
|
| 66 |
+
continue
|
| 67 |
+
matched += 1
|
| 68 |
+
if not pred:
|
| 69 |
+
abstained += 1
|
| 70 |
+
inter = pred & gset
|
| 71 |
+
tp += len(inter); fp += len(pred - gset); fn += len(gset - pred)
|
| 72 |
+
if pred == gset:
|
| 73 |
+
exact += 1
|
| 74 |
+
mark = "✓" if pred == gset else ("~" if inter else "✗")
|
| 75 |
+
rows.append((mark, g["therapy"], "", gset, pred))
|
| 76 |
+
|
| 77 |
+
prec = tp / (tp + fp) if (tp + fp) else 0.0
|
| 78 |
+
rec = tp / (tp + fn) if (tp + fn) else 0.0
|
| 79 |
+
f1 = 2 * prec * rec / (prec + rec) if (prec + rec) else 0.0
|
| 80 |
+
|
| 81 |
+
console.print("\n[bold]Per-therapy (✓ exact, ~ partial, ✗ wrong):[/bold]")
|
| 82 |
+
for mark, name, note, gset, pred in sorted(rows, key=lambda r: r[0]):
|
| 83 |
+
color = {"✓": "green", "~": "yellow", "✗": "red", "?": "dim"}.get(mark, "white")
|
| 84 |
+
console.print(f" [{color}]{mark}[/{color}] {name:22s} gold={sorted(gset)} pred={sorted(pred)} {note}")
|
| 85 |
+
|
| 86 |
+
console.print(f"\n[bold]Matched {matched}/{len(gold)} gold therapies[/bold] "
|
| 87 |
+
f"({missing} not in landscape, {abstained} abstained)")
|
| 88 |
+
console.print(f" Micro precision: [bold]{prec:.2f}[/bold] recall: {rec:.2f} F1: {f1:.2f}")
|
| 89 |
+
console.print(f" Exact-set match: {exact}/{matched}")
|
| 90 |
+
|
| 91 |
+
# hard assertions for the flagged cases
|
| 92 |
+
def pred_of(name):
|
| 93 |
+
return idx.get(name.lower())
|
| 94 |
+
console.print("\n[bold]Flagged-case checks:[/bold]")
|
| 95 |
+
cnm = pred_of("CNM-Au8") or set()
|
| 96 |
+
ok_cnm = "Neuroinflammation" not in cnm and ({"Mitochondrial dysfunction", "Oxidative stress"} & cnm)
|
| 97 |
+
console.print(f" CNM-Au8 not Neuroinflammation & has bioenergetic/oxidative: "
|
| 98 |
+
f"[{'green' if ok_cnm else 'red'}]{bool(ok_cnm)}[/] (pred={sorted(cnm)})")
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
if __name__ == "__main__":
|
| 102 |
+
main()
|
tools.py
CHANGED
|
@@ -43,6 +43,17 @@ EXTRACT_TRIAL_TARGETS_TOOL: anthropic.types.ToolParam = {
|
|
| 43 |
"input_schema": _load("extract_trial_targets"),
|
| 44 |
}
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
EXTRACTION_TOOLS: list[anthropic.types.ToolParam] = [EXTRACT_ENTITIES_TOOL]
|
| 47 |
TRIAL_EXTRACTION_TOOLS: list[anthropic.types.ToolParam] = [EXTRACT_TRIAL_TARGETS_TOOL]
|
| 48 |
RESEARCH_TOOLS: list[anthropic.types.ToolParam] = [SEARCH_LANDSCAPE_TOOL]
|
|
|
|
|
|
| 43 |
"input_schema": _load("extract_trial_targets"),
|
| 44 |
}
|
| 45 |
|
| 46 |
+
CLASSIFY_THERAPY_TOOL: anthropic.types.ToolParam = {
|
| 47 |
+
"name": "classify_therapy",
|
| 48 |
+
"description": (
|
| 49 |
+
"Classify one experimental ALS therapy: its canonical name, modality, molecular target, "
|
| 50 |
+
"mechanism of action, and best-fit mechanism class from the ALS taxonomy. "
|
| 51 |
+
"Call once per therapy, echoing back the therapy_key verbatim."
|
| 52 |
+
),
|
| 53 |
+
"input_schema": _load("classify_therapy"),
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
EXTRACTION_TOOLS: list[anthropic.types.ToolParam] = [EXTRACT_ENTITIES_TOOL]
|
| 57 |
TRIAL_EXTRACTION_TOOLS: list[anthropic.types.ToolParam] = [EXTRACT_TRIAL_TARGETS_TOOL]
|
| 58 |
RESEARCH_TOOLS: list[anthropic.types.ToolParam] = [SEARCH_LANDSCAPE_TOOL]
|
| 59 |
+
LANDSCAPE_TOOLS: list[anthropic.types.ToolParam] = [CLASSIFY_THERAPY_TOOL]
|
uv.lock
CHANGED
|
@@ -452,6 +452,7 @@ dependencies = [
|
|
| 452 |
{ name = "httpx" },
|
| 453 |
{ name = "networkx" },
|
| 454 |
{ name = "openai" },
|
|
|
|
| 455 |
{ name = "python-dotenv" },
|
| 456 |
{ name = "rapidfuzz" },
|
| 457 |
{ name = "rich" },
|
|
@@ -475,6 +476,7 @@ requires-dist = [
|
|
| 475 |
{ name = "httpx", specifier = ">=0.27.0" },
|
| 476 |
{ name = "networkx", specifier = ">=3.3" },
|
| 477 |
{ name = "openai", specifier = ">=2.44.0" },
|
|
|
|
| 478 |
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
|
| 479 |
{ name = "pytest-cov", marker = "extra == 'dev'" },
|
| 480 |
{ name = "pytest-httpx", marker = "extra == 'dev'", specifier = ">=0.35" },
|
|
@@ -2433,6 +2435,19 @@ wheels = [
|
|
| 2433 |
{ url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" },
|
| 2434 |
]
|
| 2435 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2436 |
[[package]]
|
| 2437 |
name = "pluggy"
|
| 2438 |
version = "1.6.0"
|
|
|
|
| 452 |
{ name = "httpx" },
|
| 453 |
{ name = "networkx" },
|
| 454 |
{ name = "openai" },
|
| 455 |
+
{ name = "plotly" },
|
| 456 |
{ name = "python-dotenv" },
|
| 457 |
{ name = "rapidfuzz" },
|
| 458 |
{ name = "rich" },
|
|
|
|
| 476 |
{ name = "httpx", specifier = ">=0.27.0" },
|
| 477 |
{ name = "networkx", specifier = ">=3.3" },
|
| 478 |
{ name = "openai", specifier = ">=2.44.0" },
|
| 479 |
+
{ name = "plotly", specifier = "==6.9.0" },
|
| 480 |
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
|
| 481 |
{ name = "pytest-cov", marker = "extra == 'dev'" },
|
| 482 |
{ name = "pytest-httpx", marker = "extra == 'dev'", specifier = ">=0.35" },
|
|
|
|
| 2435 |
{ url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" },
|
| 2436 |
]
|
| 2437 |
|
| 2438 |
+
[[package]]
|
| 2439 |
+
name = "plotly"
|
| 2440 |
+
version = "6.9.0"
|
| 2441 |
+
source = { registry = "https://pypi.org/simple" }
|
| 2442 |
+
dependencies = [
|
| 2443 |
+
{ name = "narwhals" },
|
| 2444 |
+
{ name = "packaging" },
|
| 2445 |
+
]
|
| 2446 |
+
sdist = { url = "https://files.pythonhosted.org/packages/96/07/795c79dbce40c39bece88e69d049babbd23ffa95b5d117f248db8ea03abb/plotly-6.9.0.tar.gz", hash = "sha256:967ad33e8c704fed051800d11d985eb206a9c795c14206b30a6f463ed9c67d0d", size = 6919903, upload-time = "2026-07-09T14:55:59.982Z" }
|
| 2447 |
+
wheels = [
|
| 2448 |
+
{ url = "https://files.pythonhosted.org/packages/24/18/d8544811ab076f876c4892b3714f5b0dad335e1dc33aef826df431b8325d/plotly-6.9.0-py3-none-any.whl", hash = "sha256:36bebe2f1bb13884774fe61689c329071446f6ce4a8927fb1f0d6fb24f581236", size = 9909646, upload-time = "2026-07-09T14:55:55.421Z" },
|
| 2449 |
+
]
|
| 2450 |
+
|
| 2451 |
[[package]]
|
| 2452 |
name = "pluggy"
|
| 2453 |
version = "1.6.0"
|