KevinIsInCoding Claude Fable 5 commited on
Commit
2e5367f
·
unverified ·
1 Parent(s): 65d917c

feat(landscape): bring experimental ALS therapy landscape to main (#19)

Browse files

Applies 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 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, streams responses |
 
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. run application
 
 
 
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.Column(elem_classes="container"):
129
-
130
- gr.Markdown(_TITLE_MD)
131
-
132
- gr.HTML(
133
- f'<div class="status-bar">'
134
- f'{_n_chunks} paper chunks &nbsp;·&nbsp; '
135
- f'{_n_trials} clinical trials &nbsp;·&nbsp; '
136
- f'{_kg_nodes} knowledge graph nodes'
137
- f'</div>'
138
- )
139
-
140
- chatbot = gr.Chatbot(
141
- value=[],
142
- height=520,
143
- show_label=False,
144
- sanitize_html=False,
145
- avatar_images=(None, "assets/flame.svg"),
146
- placeholder="Ask a question about ALS research to get started.",
147
- )
148
-
149
- with gr.Row():
150
- msg_box = gr.Textbox(
151
- placeholder="e.g. What is the evidence for tofersen targeting SOD1?",
152
- show_label=False,
153
- scale=9,
154
- autofocus=True,
155
- lines=1,
156
- )
157
- send_btn = gr.Button("Ask", scale=1, variant="primary", min_width=80)
158
-
159
- gr.Markdown("**Example questions** — click to populate:")
160
-
161
- with gr.Row():
162
- with gr.Column(scale=1):
163
- for ex in _EXAMPLES[:3]:
164
- btn = gr.Button(ex, size="sm", variant="secondary")
165
- btn.click(fn=lambda t=ex: t, outputs=[msg_box])
166
- with gr.Column(scale=1):
167
- for ex in _EXAMPLES[3:]:
168
- btn = gr.Button(ex, size="sm", variant="secondary")
169
- btn.click(fn=lambda t=ex: t, outputs=[msg_box])
170
-
171
- gr.HTML(_DISCLAIMER_MD)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 &nbsp;·&nbsp; '
168
+ f'{_n_trials} clinical trials &nbsp;·&nbsp; '
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"