feat(researcher): DCE-MRI BBB permeability bridge + drug-dose adjuster
Browse filesAdds the only legitimate BBB <-> MRI bridge in the platform — researcher
lane only. Fusion engine still excludes BBB as a diagnostic modality.
Modules:
- src/models/bbb_permeability_map.py: scalar BBB leakage score in [0,1]
with two modes. heuristic_proxy (default) reuses the 2D resnet18
Alzheimer classifier and computes 1 - P(NonDemented). dce_onnx loads
a real 4D DCE-MRI ONNX model from BBB_PERMEABILITY_DCE_PATH and
averages a normalised Ktrans map; raises a clear FileNotFoundError
with contract documentation when no artifact exists.
- src/research/drug_dose_adjuster.py: pure-function dose revision keyed
on (BBB intact?, drug BBB-permeable?). Permeable drugs see an
aggressive linear cut floored at 30%; non-permeable drugs see a mild
cut floored at 60%; intact BBB returns baseline unchanged. Unknown
drug permeability is treated as permeable (safer). Rationale always
includes the 'research suggestion, not medical advice' disclaimer.
API:
- POST /predict/bbb_permeability_map (request: input_path + mode)
- POST /research/drug_dose_adjustment (request: smiles? + baseline_dose
+ bbb_score + drug_permeable?). When smiles is provided, the BBB
classifier auto-resolves drug_permeable, closing the researcher loop
end-to-end.
- New research_router mounted in src/api/main.py.
Agent tools (orchestrator-callable):
- compute_bbb_leakage_score (wraps the BBB permeability route)
- adjust_drug_dose (wraps the dose-adjustment route, BBB classifier
auto-resolves drug permeability when smiles is given)
Frontend:
- New 'Researcher' Streamlit tab with a 2-column flow: BBB leakage
scorer on the left, dose-adjuster on the right, with risk-level
emoji and rationale card.
Tests: 361 passed, 2 skipped (was 330+2; +31 tests, 0 regressions).
- tests/models/test_bbb_permeability_map.py: heuristic_proxy + DCE
contract; case-insensitive label matching; threshold semantics.
- tests/research/test_drug_dose_adjuster.py: all four (BBB x drug-perm)
quadrants; safety floors; monotonicity; rationale disclaimer; pydantic
validation edges.
- tests/api/test_bbb_research_routes.py: both new routes end-to-end.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- README.md +44 -0
- docs/superpowers/plans/2026-05-02-bbb-mri-bridge-and-dose-adjuster.md +72 -0
- src/agents/schemas.py +34 -0
- src/agents/tools.py +83 -1
- src/api/main.py +3 -1
- src/api/routes.py +78 -0
- src/api/schemas.py +55 -0
- src/frontend/app.py +92 -1
- src/models/bbb_permeability_map.py +146 -0
- src/research/__init__.py +0 -0
- src/research/drug_dose_adjuster.py +103 -0
- tests/agents/test_tools.py +5 -3
- tests/api/test_bbb_research_routes.py +113 -0
- tests/models/test_bbb_permeability_map.py +86 -0
- tests/research/__init__.py +0 -0
- tests/research/test_drug_dose_adjuster.py +100 -0
|
@@ -42,6 +42,7 @@ short_description: Living decision system for BBB, EEG, and MRI clinical ML
|
|
| 42 |
| 9 | Agent/RAG hardening + MRI DL decision layer | Guarded orchestration + `POST /predict/mri` ONNX surface | Shipped — 242 passed, 2 skipped |
|
| 43 |
| 10 | Multi-modal fusion engine | `POST /fusion/predict` + `run_fusion` agent tool — MRI + EEG + clinical scores → per-disease confidence with attribution | Shipped — 295 passed, 1 skipped |
|
| 44 |
| 11 | External assets integration | 2D resnet18 MRI Alzheimer's path · TF-IDF clinical RAG with TR query expansion · stub-able EEG pretrained classifier | Shipped — 330 passed, 2 skipped |
|
|
|
|
| 45 |
|
| 46 |
### Fusion Engine
|
| 47 |
|
|
@@ -96,6 +97,49 @@ The Turkish keywords `alzheimer`, `parkinson`, `egzersiz`, `beslenme`,
|
|
| 96 |
`tani`, `tedavi`, `risk`, `unutkanlik`, `titreme`, `demans` auto-expand
|
| 97 |
to English equivalents so Turkish queries hit English chunks.
|
| 98 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
### EEG Pretrained Classifier (stub-able for demo)
|
| 100 |
|
| 101 |
`POST /predict/eeg` runs an sklearn-style classifier (any `predict_proba`
|
|
|
|
| 42 |
| 9 | Agent/RAG hardening + MRI DL decision layer | Guarded orchestration + `POST /predict/mri` ONNX surface | Shipped — 242 passed, 2 skipped |
|
| 43 |
| 10 | Multi-modal fusion engine | `POST /fusion/predict` + `run_fusion` agent tool — MRI + EEG + clinical scores → per-disease confidence with attribution | Shipped — 295 passed, 1 skipped |
|
| 44 |
| 11 | External assets integration | 2D resnet18 MRI Alzheimer's path · TF-IDF clinical RAG with TR query expansion · stub-able EEG pretrained classifier | Shipped — 330 passed, 2 skipped |
|
| 45 |
+
| 12 | DCE-MRI BBB bridge + drug-dose adjuster | `POST /predict/bbb_permeability_map` (heuristic_proxy or dce_onnx) + `POST /research/drug_dose_adjustment` + Researcher Streamlit tab + `compute_bbb_leakage_score` & `adjust_drug_dose` agent tools | Shipped |
|
| 46 |
|
| 47 |
### Fusion Engine
|
| 48 |
|
|
|
|
| 97 |
`tani`, `tedavi`, `risk`, `unutkanlik`, `titreme`, `demans` auto-expand
|
| 98 |
to English equivalents so Turkish queries hit English chunks.
|
| 99 |
|
| 100 |
+
### DCE-MRI BBB Bridge + Drug-Dose Adjuster (Researcher persona)
|
| 101 |
+
|
| 102 |
+
Clinical fact: Dynamic Contrast-Enhanced (DCE) MRI measures BBB leakage by
|
| 103 |
+
tracking gadolinium contrast washout. A leaky BBB lets drugs cross into
|
| 104 |
+
the brain at unsafe levels, so concentrations need revising.
|
| 105 |
+
|
| 106 |
+
This is the **only legitimate place where BBB and MRI couple** in the
|
| 107 |
+
platform — the Researcher lane only. The fusion engine's "BBB is NOT a
|
| 108 |
+
diagnostic modality" rule is preserved.
|
| 109 |
+
|
| 110 |
+
**`POST /predict/bbb_permeability_map`** — two modes:
|
| 111 |
+
|
| 112 |
+
- `heuristic_proxy` (default, demo-ready): reuses the 2D resnet18
|
| 113 |
+
Alzheimer's classifier; score = `1 - P(NonDemented)`. Anchored in the
|
| 114 |
+
published correlation between disease severity and BBB breakdown.
|
| 115 |
+
- `dce_onnx` (real DCE artifact, swap-in later): loads an ONNX model
|
| 116 |
+
trained on 4D DCE-MRI data, emits a Ktrans map normalised to `[0, 1]`.
|
| 117 |
+
Drop the artifact at `data/processed/bbb_permeability_dce.onnx` (or set
|
| 118 |
+
`BBB_PERMEABILITY_DCE_PATH`).
|
| 119 |
+
|
| 120 |
+
**`POST /research/drug_dose_adjustment`** — pure-function logic:
|
| 121 |
+
|
| 122 |
+
| BBB score | Drug BBB-permeable | Recommended dose |
|
| 123 |
+
|---|---|---|
|
| 124 |
+
| < 0.20 (intact) | any | 100% of baseline (low risk) |
|
| 125 |
+
| ≥ 0.20 (leaky) | yes | `max(30%, 1 − 0.7·score)` of baseline (moderate / high risk) |
|
| 126 |
+
| ≥ 0.20 (leaky) | no | `max(60%, 1 − 0.4·score)` of baseline (moderate risk) |
|
| 127 |
+
| ≥ 0.20 (leaky) | unknown | treated as permeable (safer assumption) |
|
| 128 |
+
|
| 129 |
+
When `smiles` is supplied, the BBB classifier auto-resolves the drug's
|
| 130 |
+
permeability — closes the researcher loop end-to-end. The rationale always
|
| 131 |
+
includes the sentence "Research suggestion, not medical advice."
|
| 132 |
+
|
| 133 |
+
Streamlit `Researcher` tab combines both into a single 2-column flow:
|
| 134 |
+
left side picks an MRI image and runs the leakage scorer; right side
|
| 135 |
+
takes a SMILES + baseline dose and computes a revised dose with risk
|
| 136 |
+
badge and rationale card.
|
| 137 |
+
|
| 138 |
+
Agent tools (orchestrator-callable):
|
| 139 |
+
|
| 140 |
+
- `compute_bbb_leakage_score` — wraps `/predict/bbb_permeability_map`.
|
| 141 |
+
- `adjust_drug_dose` — wraps `/research/drug_dose_adjustment`.
|
| 142 |
+
|
| 143 |
### EEG Pretrained Classifier (stub-able for demo)
|
| 144 |
|
| 145 |
`POST /predict/eeg` runs an sklearn-style classifier (any `predict_proba`
|
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# BBB ↔ MRI Bridge + Drug-Dose Adjuster — Implementation Notes
|
| 2 |
+
|
| 3 |
+
> Implemented inline on `feat/external-assets-integration`. This document is the design record.
|
| 4 |
+
|
| 5 |
+
## Why
|
| 6 |
+
|
| 7 |
+
Clinical fact: **DCE-MRI** (Dynamic Contrast-Enhanced MRI) measures BBB leakage by tracking gadolinium contrast washout — Ktrans, Kep, Ve maps. Compromised BBB lets molecules cross into the brain that normally wouldn't. Two consequences:
|
| 8 |
+
|
| 9 |
+
1. A "BBB Permeability Map" or scalar score from a patient's MRI is the **primary data point for the researcher persona**.
|
| 10 |
+
2. **If the BBB is leaky, drug concentrations need revising** — a CNS-permeable drug crosses too easily; even a non-CNS drug may now reach the brain at unsafe levels.
|
| 11 |
+
|
| 12 |
+
This plan adds both: the bridge (MRI → BBB permeability score) and the adjuster (drug dose revision based on the score).
|
| 13 |
+
|
| 14 |
+
## Independence
|
| 15 |
+
|
| 16 |
+
This is the **only legitimate place BBB and MRI couple** in the platform — and it sits in the **researcher** lane, not the doctor's diagnostic lane. The fusion engine's "BBB is NOT a fusion modality" rule is preserved: this bridge does not feed into Alzheimer's/Parkinson's confidence.
|
| 17 |
+
|
| 18 |
+
## Architecture (two modules + two routes + two agent tools)
|
| 19 |
+
|
| 20 |
+
```
|
| 21 |
+
src/models/bbb_permeability_map.py pure-Python scorer with two modes
|
| 22 |
+
src/research/drug_dose_adjuster.py pure-function dose revision logic
|
| 23 |
+
|
| 24 |
+
POST /predict/bbb_permeability_map {input_path, mode} -> {score, interpretation, method}
|
| 25 |
+
POST /research/drug_dose_adjustment {smiles?, baseline_dose_mg, bbb_score, drug_permeable?}
|
| 26 |
+
-> {recommended_dose_mg, factor, risk_level, rationale}
|
| 27 |
+
|
| 28 |
+
agent tool: compute_bbb_leakage_score
|
| 29 |
+
agent tool: adjust_drug_dose
|
| 30 |
+
```
|
| 31 |
+
|
| 32 |
+
### Permeability scoring modes
|
| 33 |
+
|
| 34 |
+
- **`heuristic_proxy`** (default, demo-ready). Uses the existing 2D resnet18 4-class
|
| 35 |
+
classifier's class probabilities. Score = `1 - P(NonDemented)` — published
|
| 36 |
+
literature shows BBB breakdown correlates with disease severity. Auditable
|
| 37 |
+
and demonstrable today.
|
| 38 |
+
- **`dce_onnx`** (real-DCE artifact, swap-in later). Loads an ONNX model from
|
| 39 |
+
`data/processed/bbb_permeability_dce.onnx` (env: `BBB_PERMEABILITY_DCE_PATH`).
|
| 40 |
+
Contract: input 4D NIfTI `(X, Y, Z, T)`, output a 3D Ktrans map normalized
|
| 41 |
+
to `[0, 1]`. Stub for now; works when artifact lands.
|
| 42 |
+
|
| 43 |
+
### Dose-adjustment math
|
| 44 |
+
|
| 45 |
+
`adjust(baseline_mg, perm_score, drug_permeable, safety_factor=0.5)`:
|
| 46 |
+
|
| 47 |
+
| BBB intact (score < 0.2) | any drug | factor=1.0, risk=low |
|
| 48 |
+
| BBB leaky, drug permeable | factor = max(0.3, 1 - 0.7·score), risk = high if score > 0.6 else moderate |
|
| 49 |
+
| BBB leaky, drug NOT permeable | factor = max(0.6, 1 - 0.4·score), risk = moderate |
|
| 50 |
+
| drug_permeable=None (unknown) | treat as permeable (safer assumption) |
|
| 51 |
+
|
| 52 |
+
The adjuster is a **pure function**. The route can optionally call `bbb_model.predict_one(smiles)` to populate `drug_permeable` from the existing BBB classifier — closing the researcher loop end-to-end.
|
| 53 |
+
|
| 54 |
+
## Tests
|
| 55 |
+
|
| 56 |
+
- `tests/models/test_bbb_permeability_map.py` — heuristic-proxy correctness, mode dispatch, missing-artifact errors.
|
| 57 |
+
- `tests/research/test_drug_dose_adjuster.py` — all four (BBB × drug-permeability) quadrants, monotonicity, safety-floor invariants.
|
| 58 |
+
- `tests/api/test_bbb_research_routes.py` — both new routes end-to-end.
|
| 59 |
+
|
| 60 |
+
## What this does NOT do
|
| 61 |
+
|
| 62 |
+
- Does not retrain anything.
|
| 63 |
+
- Does not compute real Ktrans/Kep/Ve from DCE data — that requires a 4D pharmacokinetic model we don't ship. The `dce_onnx` mode is a contract for an external trainer.
|
| 64 |
+
- Does not feed into the fusion engine. Researcher-only flow.
|
| 65 |
+
- Does not prescribe — the rationale string explicitly says "research suggestion, not medical advice".
|
| 66 |
+
|
| 67 |
+
## Demo path
|
| 68 |
+
|
| 69 |
+
1. Researcher pastes a SMILES (e.g., `CCO`) and a baseline dose (e.g., `200 mg`).
|
| 70 |
+
2. Researcher uploads/picks an MRI image.
|
| 71 |
+
3. Streamlit calls `/predict/mri` (resnet18_2d if active, else volumetric ONNX) → BBB classifier on SMILES → `/research/drug_dose_adjustment`.
|
| 72 |
+
4. Card shows: BBB leakage gauge, drug BBB permeability badge, recommended dose with adjustment factor, plain-language rationale.
|
|
@@ -34,6 +34,40 @@ class MRIPipelineInput(BaseModel):
|
|
| 34 |
)
|
| 35 |
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
class RetrieveContextInput(BaseModel):
|
| 38 |
"""Input for `retrieve_context` — natural-language query into the KB."""
|
| 39 |
query: str = Field(..., min_length=2, description="Search query for the knowledge base")
|
|
|
|
| 34 |
)
|
| 35 |
|
| 36 |
|
| 37 |
+
class BBBPermeabilityMapInput(BaseModel):
|
| 38 |
+
"""Input for `compute_bbb_leakage_score` — MRI input + scoring mode."""
|
| 39 |
+
input_path: str = Field(..., description="Path to MRI input (2D image for heuristic_proxy; 4D NIfTI for dce_onnx).")
|
| 40 |
+
mode: Literal["heuristic_proxy", "dce_onnx"] = Field(
|
| 41 |
+
"heuristic_proxy",
|
| 42 |
+
description="'heuristic_proxy' (default) | 'dce_onnx' (real DCE artifact)",
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class BBBPermeabilityMapOutput(BaseModel):
|
| 47 |
+
permeability_score: float
|
| 48 |
+
interpretation: str
|
| 49 |
+
method: str
|
| 50 |
+
voxel_map_available: bool
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class DrugDoseAdjustmentInput(BaseModel):
|
| 54 |
+
"""Input for `adjust_drug_dose` — baseline + patient + drug profile."""
|
| 55 |
+
baseline_dose_mg: float = Field(..., gt=0.0)
|
| 56 |
+
bbb_permeability_score: float = Field(..., ge=0.0, le=1.0)
|
| 57 |
+
drug_bbb_permeable: bool | None = None
|
| 58 |
+
smiles: str | None = Field(
|
| 59 |
+
None, description="Optional SMILES; auto-resolves drug_bbb_permeable when given.",
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class DrugDoseAdjustmentOutput(BaseModel):
|
| 64 |
+
recommended_dose_mg: float
|
| 65 |
+
adjustment_factor: float
|
| 66 |
+
risk_level: str
|
| 67 |
+
rationale: str
|
| 68 |
+
drug_bbb_permeable: bool | None = None
|
| 69 |
+
|
| 70 |
+
|
| 71 |
class RetrieveContextInput(BaseModel):
|
| 72 |
"""Input for `retrieve_context` — natural-language query into the KB."""
|
| 73 |
query: str = Field(..., min_length=2, description="Search query for the knowledge base")
|
|
@@ -1,7 +1,7 @@
|
|
| 1 |
"""Tool dataclass + registry. Wraps each pipeline + the RAG retriever as a
|
| 2 |
function-callable tool the orchestrator can invoke.
|
| 3 |
|
| 4 |
-
Public entry: `build_default_tools(rag_index_dir)` returns the
|
| 5 |
"""
|
| 6 |
from __future__ import annotations
|
| 7 |
|
|
@@ -12,8 +12,12 @@ from typing import Any, Callable
|
|
| 12 |
from pydantic import BaseModel, ValidationError
|
| 13 |
|
| 14 |
from src.agents.schemas import (
|
|
|
|
|
|
|
| 15 |
BBBPipelineInput,
|
| 16 |
BBBPipelineOutput,
|
|
|
|
|
|
|
| 17 |
EEGPipelineInput,
|
| 18 |
EEGPipelineOutput,
|
| 19 |
MRIPipelineInput,
|
|
@@ -203,6 +207,59 @@ def _make_retrieve_executor(
|
|
| 203 |
return execute
|
| 204 |
|
| 205 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
def build_default_tools(
|
| 207 |
rag_index_dir: Path | None,
|
| 208 |
processed_dir: Path = Path("data/processed"),
|
|
@@ -272,4 +329,29 @@ def build_default_tools(
|
|
| 272 |
output_model=FusionOutput,
|
| 273 |
execute=lambda inp: fuse_engine(inp),
|
| 274 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 275 |
]
|
|
|
|
| 1 |
"""Tool dataclass + registry. Wraps each pipeline + the RAG retriever as a
|
| 2 |
function-callable tool the orchestrator can invoke.
|
| 3 |
|
| 4 |
+
Public entry: `build_default_tools(rag_index_dir)` returns the 7 tools.
|
| 5 |
"""
|
| 6 |
from __future__ import annotations
|
| 7 |
|
|
|
|
| 12 |
from pydantic import BaseModel, ValidationError
|
| 13 |
|
| 14 |
from src.agents.schemas import (
|
| 15 |
+
BBBPermeabilityMapInput,
|
| 16 |
+
BBBPermeabilityMapOutput,
|
| 17 |
BBBPipelineInput,
|
| 18 |
BBBPipelineOutput,
|
| 19 |
+
DrugDoseAdjustmentInput,
|
| 20 |
+
DrugDoseAdjustmentOutput,
|
| 21 |
EEGPipelineInput,
|
| 22 |
EEGPipelineOutput,
|
| 23 |
MRIPipelineInput,
|
|
|
|
| 207 |
return execute
|
| 208 |
|
| 209 |
|
| 210 |
+
def _make_bbb_permeability_executor() -> Callable[[BBBPermeabilityMapInput], BBBPermeabilityMapOutput]:
|
| 211 |
+
def execute(inp: BBBPermeabilityMapInput) -> BBBPermeabilityMapOutput:
|
| 212 |
+
from src.models import bbb_permeability_map as bbb_perm
|
| 213 |
+
result = bbb_perm.compute_permeability(
|
| 214 |
+
input_path=Path(inp.input_path),
|
| 215 |
+
mode=inp.mode,
|
| 216 |
+
)
|
| 217 |
+
return BBBPermeabilityMapOutput(
|
| 218 |
+
permeability_score=float(result["permeability_score"]),
|
| 219 |
+
interpretation=str(result["interpretation"]),
|
| 220 |
+
method=str(result["method"]),
|
| 221 |
+
voxel_map_available=bool(result.get("voxel_map_available", False)),
|
| 222 |
+
)
|
| 223 |
+
|
| 224 |
+
return execute
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def _make_dose_adjuster_executor() -> Callable[[DrugDoseAdjustmentInput], DrugDoseAdjustmentOutput]:
|
| 228 |
+
def execute(inp: DrugDoseAdjustmentInput) -> DrugDoseAdjustmentOutput:
|
| 229 |
+
from src.research import drug_dose_adjuster
|
| 230 |
+
|
| 231 |
+
drug_permeable = inp.drug_bbb_permeable
|
| 232 |
+
if inp.smiles:
|
| 233 |
+
try:
|
| 234 |
+
from src.models import bbb_model
|
| 235 |
+
import os as _os
|
| 236 |
+
artifact = Path(_os.environ.get("BBB_MODEL_PATH", "data/processed/bbb_model.pkl"))
|
| 237 |
+
if artifact.exists():
|
| 238 |
+
model = bbb_model.load_model(artifact)
|
| 239 |
+
pred = bbb_model.predict_one(model, inp.smiles)
|
| 240 |
+
drug_permeable = bool(pred["label"] == 1)
|
| 241 |
+
except (FileNotFoundError, ValueError, KeyError) as e:
|
| 242 |
+
logger.warning(
|
| 243 |
+
"agent dose-adjuster could not auto-resolve BBB for smiles=%s: %s",
|
| 244 |
+
inp.smiles, e,
|
| 245 |
+
)
|
| 246 |
+
|
| 247 |
+
adj = drug_dose_adjuster.adjust(
|
| 248 |
+
baseline_dose_mg=inp.baseline_dose_mg,
|
| 249 |
+
bbb_permeability_score=inp.bbb_permeability_score,
|
| 250 |
+
drug_bbb_permeable=drug_permeable,
|
| 251 |
+
)
|
| 252 |
+
return DrugDoseAdjustmentOutput(
|
| 253 |
+
recommended_dose_mg=adj.recommended_dose_mg,
|
| 254 |
+
adjustment_factor=adj.adjustment_factor,
|
| 255 |
+
risk_level=adj.risk_level,
|
| 256 |
+
rationale=adj.rationale,
|
| 257 |
+
drug_bbb_permeable=drug_permeable,
|
| 258 |
+
)
|
| 259 |
+
|
| 260 |
+
return execute
|
| 261 |
+
|
| 262 |
+
|
| 263 |
def build_default_tools(
|
| 264 |
rag_index_dir: Path | None,
|
| 265 |
processed_dir: Path = Path("data/processed"),
|
|
|
|
| 329 |
output_model=FusionOutput,
|
| 330 |
execute=lambda inp: fuse_engine(inp),
|
| 331 |
),
|
| 332 |
+
Tool(
|
| 333 |
+
name="compute_bbb_leakage_score",
|
| 334 |
+
description=(
|
| 335 |
+
"Researcher-only. Compute a BBB permeability score (0..1) "
|
| 336 |
+
"from a patient MRI. Mode 'heuristic_proxy' (default) uses "
|
| 337 |
+
"the 2D Alzheimer's classifier; 'dce_onnx' uses a real DCE "
|
| 338 |
+
"ONNX model when available."
|
| 339 |
+
),
|
| 340 |
+
input_model=BBBPermeabilityMapInput,
|
| 341 |
+
output_model=BBBPermeabilityMapOutput,
|
| 342 |
+
execute=_make_bbb_permeability_executor(),
|
| 343 |
+
),
|
| 344 |
+
Tool(
|
| 345 |
+
name="adjust_drug_dose",
|
| 346 |
+
description=(
|
| 347 |
+
"Researcher-only. Suggest a revised drug dose given the patient's "
|
| 348 |
+
"BBB permeability score and the drug's BBB classification. If "
|
| 349 |
+
"smiles is supplied, the BBB classifier auto-resolves whether "
|
| 350 |
+
"the drug crosses the BBB. Output is a research suggestion, "
|
| 351 |
+
"NOT medical advice."
|
| 352 |
+
),
|
| 353 |
+
input_model=DrugDoseAdjustmentInput,
|
| 354 |
+
output_model=DrugDoseAdjustmentOutput,
|
| 355 |
+
execute=_make_dose_adjuster_executor(),
|
| 356 |
+
),
|
| 357 |
]
|
|
@@ -12,7 +12,8 @@ from src.api.routes import (
|
|
| 12 |
explain_router,
|
| 13 |
experiments_router,
|
| 14 |
agent_router,
|
| 15 |
-
fusion_router,
|
|
|
|
| 16 |
)
|
| 17 |
from src.api.schemas import HealthResponse
|
| 18 |
|
|
@@ -28,6 +29,7 @@ app.include_router(explain_router)
|
|
| 28 |
app.include_router(experiments_router)
|
| 29 |
app.include_router(agent_router)
|
| 30 |
app.include_router(fusion_router)
|
|
|
|
| 31 |
|
| 32 |
|
| 33 |
@app.get("/health", response_model=HealthResponse)
|
|
|
|
| 12 |
explain_router,
|
| 13 |
experiments_router,
|
| 14 |
agent_router,
|
| 15 |
+
fusion_router,
|
| 16 |
+
research_router,
|
| 17 |
)
|
| 18 |
from src.api.schemas import HealthResponse
|
| 19 |
|
|
|
|
| 29 |
app.include_router(experiments_router)
|
| 30 |
app.include_router(agent_router)
|
| 31 |
app.include_router(fusion_router)
|
| 32 |
+
app.include_router(research_router)
|
| 33 |
|
| 34 |
|
| 35 |
@app.get("/health", response_model=HealthResponse)
|
|
@@ -27,6 +27,10 @@ from src.api.schemas import (
|
|
| 27 |
BBBPredictResponse,
|
| 28 |
BBBRequest,
|
| 29 |
CalibrationContext,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
EEGClassProbability,
|
| 31 |
EEGExplainRequest,
|
| 32 |
EEGPredictRequest,
|
|
@@ -63,6 +67,7 @@ router = APIRouter(prefix="/pipeline")
|
|
| 63 |
predict_router = APIRouter(prefix="/predict")
|
| 64 |
explain_router = APIRouter(prefix="/explain")
|
| 65 |
experiments_router = APIRouter(prefix="/experiments")
|
|
|
|
| 66 |
|
| 67 |
|
| 68 |
def _wrap(
|
|
@@ -320,6 +325,79 @@ def predict_bbb(req: BBBPredictRequest) -> BBBPredictResponse:
|
|
| 320 |
)
|
| 321 |
|
| 322 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 323 |
@predict_router.post("/eeg", response_model=EEGPredictResponse)
|
| 324 |
def predict_eeg(req: EEGPredictRequest) -> EEGPredictResponse:
|
| 325 |
"""Predict from EEG features using an externally-trained sklearn classifier.
|
|
|
|
| 27 |
BBBPredictResponse,
|
| 28 |
BBBRequest,
|
| 29 |
CalibrationContext,
|
| 30 |
+
BBBPermeabilityMapRequest,
|
| 31 |
+
BBBPermeabilityMapResponse,
|
| 32 |
+
DrugDoseAdjustmentRequest,
|
| 33 |
+
DrugDoseAdjustmentResponse,
|
| 34 |
EEGClassProbability,
|
| 35 |
EEGExplainRequest,
|
| 36 |
EEGPredictRequest,
|
|
|
|
| 67 |
predict_router = APIRouter(prefix="/predict")
|
| 68 |
explain_router = APIRouter(prefix="/explain")
|
| 69 |
experiments_router = APIRouter(prefix="/experiments")
|
| 70 |
+
research_router = APIRouter(prefix="/research")
|
| 71 |
|
| 72 |
|
| 73 |
def _wrap(
|
|
|
|
| 325 |
)
|
| 326 |
|
| 327 |
|
| 328 |
+
@predict_router.post("/bbb_permeability_map", response_model=BBBPermeabilityMapResponse)
|
| 329 |
+
def predict_bbb_permeability_map(req: BBBPermeabilityMapRequest) -> BBBPermeabilityMapResponse:
|
| 330 |
+
"""Compute a BBB permeability score from MRI input.
|
| 331 |
+
|
| 332 |
+
Two modes:
|
| 333 |
+
- heuristic_proxy (default): reuses the 2D resnet18 4-class classifier;
|
| 334 |
+
score = 1 - P(NonDemented). Demo-ready today.
|
| 335 |
+
- dce_onnx (real DCE artifact): loads an ONNX model trained on 4D DCE
|
| 336 |
+
data; emits a Ktrans map normalised to [0, 1]. Stub — drop the real
|
| 337 |
+
artifact at data/processed/bbb_permeability_dce.onnx (or set
|
| 338 |
+
BBB_PERMEABILITY_DCE_PATH).
|
| 339 |
+
|
| 340 |
+
Researcher-persona route — does NOT feed into the fusion engine.
|
| 341 |
+
"""
|
| 342 |
+
from src.models import bbb_permeability_map as bbb_perm
|
| 343 |
+
|
| 344 |
+
try:
|
| 345 |
+
result = bbb_perm.compute_permeability(
|
| 346 |
+
input_path=Path(req.input_path),
|
| 347 |
+
mode=req.mode, # type: ignore[arg-type]
|
| 348 |
+
)
|
| 349 |
+
except FileNotFoundError as e:
|
| 350 |
+
raise HTTPException(status_code=404, detail=str(e))
|
| 351 |
+
except ValueError as e:
|
| 352 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 353 |
+
|
| 354 |
+
return BBBPermeabilityMapResponse(
|
| 355 |
+
permeability_score=float(result["permeability_score"]),
|
| 356 |
+
interpretation=str(result["interpretation"]),
|
| 357 |
+
method=str(result["method"]),
|
| 358 |
+
voxel_map_available=bool(result.get("voxel_map_available", False)),
|
| 359 |
+
)
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
@research_router.post("/drug_dose_adjustment", response_model=DrugDoseAdjustmentResponse)
|
| 363 |
+
def research_drug_dose_adjustment(req: DrugDoseAdjustmentRequest) -> DrugDoseAdjustmentResponse:
|
| 364 |
+
"""Suggest a revised drug dose given patient BBB permeability + drug profile.
|
| 365 |
+
|
| 366 |
+
If `smiles` is supplied, the BBB classifier is consulted to populate
|
| 367 |
+
`drug_bbb_permeable` (overriding any explicit value). Researcher-persona
|
| 368 |
+
route — output is a research suggestion, NOT medical advice.
|
| 369 |
+
"""
|
| 370 |
+
from src.research import drug_dose_adjuster
|
| 371 |
+
|
| 372 |
+
drug_permeable: bool | None = req.drug_bbb_permeable
|
| 373 |
+
if req.smiles:
|
| 374 |
+
try:
|
| 375 |
+
artifact = _bbb_model_path()
|
| 376 |
+
if artifact.exists():
|
| 377 |
+
model = bbb_model.load_model(artifact)
|
| 378 |
+
bbb_pred = bbb_model.predict_one(model, req.smiles)
|
| 379 |
+
drug_permeable = bool(bbb_pred["label"] == 1)
|
| 380 |
+
except (FileNotFoundError, ValueError, KeyError) as e:
|
| 381 |
+
logger.warning("could not auto-resolve BBB permeability for smiles=%s: %s", req.smiles, e)
|
| 382 |
+
|
| 383 |
+
try:
|
| 384 |
+
adj = drug_dose_adjuster.adjust(
|
| 385 |
+
baseline_dose_mg=req.baseline_dose_mg,
|
| 386 |
+
bbb_permeability_score=req.bbb_permeability_score,
|
| 387 |
+
drug_bbb_permeable=drug_permeable,
|
| 388 |
+
)
|
| 389 |
+
except ValueError as e:
|
| 390 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 391 |
+
|
| 392 |
+
return DrugDoseAdjustmentResponse(
|
| 393 |
+
recommended_dose_mg=adj.recommended_dose_mg,
|
| 394 |
+
adjustment_factor=adj.adjustment_factor,
|
| 395 |
+
risk_level=adj.risk_level,
|
| 396 |
+
rationale=adj.rationale,
|
| 397 |
+
drug_bbb_permeable=drug_permeable,
|
| 398 |
+
)
|
| 399 |
+
|
| 400 |
+
|
| 401 |
@predict_router.post("/eeg", response_model=EEGPredictResponse)
|
| 402 |
def predict_eeg(req: EEGPredictRequest) -> EEGPredictResponse:
|
| 403 |
"""Predict from EEG features using an externally-trained sklearn classifier.
|
|
@@ -113,6 +113,61 @@ class BBBPredictResponse(BaseModel):
|
|
| 113 |
)
|
| 114 |
|
| 115 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
class EEGPredictRequest(BaseModel):
|
| 117 |
"""Single-subject EEG-features prediction request."""
|
| 118 |
features: list[float] = Field(
|
|
|
|
| 113 |
)
|
| 114 |
|
| 115 |
|
| 116 |
+
class BBBPermeabilityMapRequest(BaseModel):
|
| 117 |
+
"""Compute a per-patient BBB permeability score from MRI input."""
|
| 118 |
+
input_path: str = Field(
|
| 119 |
+
...,
|
| 120 |
+
description=(
|
| 121 |
+
"Path to MRI input. heuristic_proxy mode: 2D image (.png/.jpg) "
|
| 122 |
+
"consumed by the resnet18 4-class Alzheimer's classifier. "
|
| 123 |
+
"dce_onnx mode: 4D NIfTI (X,Y,Z,T) for the DCE Ktrans pipeline."
|
| 124 |
+
),
|
| 125 |
+
)
|
| 126 |
+
mode: str = Field(
|
| 127 |
+
"heuristic_proxy",
|
| 128 |
+
description="'heuristic_proxy' (default, demo-ready) | 'dce_onnx' (real DCE artifact)",
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
class BBBPermeabilityMapResponse(BaseModel):
|
| 133 |
+
"""Researcher-persona BBB leakage payload."""
|
| 134 |
+
permeability_score: float = Field(..., ge=0.0, le=1.0,
|
| 135 |
+
description="Scalar in [0,1]; 0=intact, 1=fully leaky.")
|
| 136 |
+
interpretation: str = Field(..., description="'BBB intact' | 'mild leakage' | 'moderate leakage' | 'severe leakage'")
|
| 137 |
+
method: str = Field(..., description="'heuristic_proxy' | 'dce_onnx'")
|
| 138 |
+
voxel_map_available: bool = False
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
class DrugDoseAdjustmentRequest(BaseModel):
|
| 142 |
+
"""Researcher-persona dose-revision request, given patient BBB + drug profile."""
|
| 143 |
+
smiles: str | None = Field(
|
| 144 |
+
None,
|
| 145 |
+
description=(
|
| 146 |
+
"Optional SMILES. When provided, the route auto-resolves "
|
| 147 |
+
"drug_bbb_permeable via the BBB classifier (overrides any "
|
| 148 |
+
"explicit value below)."
|
| 149 |
+
),
|
| 150 |
+
)
|
| 151 |
+
baseline_dose_mg: float = Field(..., gt=0.0, description="Standard adult dose in mg.")
|
| 152 |
+
bbb_permeability_score: float = Field(..., ge=0.0, le=1.0)
|
| 153 |
+
drug_bbb_permeable: bool | None = Field(
|
| 154 |
+
None,
|
| 155 |
+
description="If known, whether the drug crosses the BBB. Auto-resolved when smiles is given.",
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
class DrugDoseAdjustmentResponse(BaseModel):
|
| 160 |
+
"""Recommended dose with rationale. NOT medical advice."""
|
| 161 |
+
recommended_dose_mg: float
|
| 162 |
+
adjustment_factor: float = Field(..., ge=0.0, le=1.0)
|
| 163 |
+
risk_level: str = Field(..., description="'low' | 'moderate' | 'high'")
|
| 164 |
+
rationale: str
|
| 165 |
+
drug_bbb_permeable: bool | None = Field(
|
| 166 |
+
None,
|
| 167 |
+
description="Echoed back; reflects what was used in the calculation (auto-resolved if smiles was given).",
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
|
| 171 |
class EEGPredictRequest(BaseModel):
|
| 172 |
"""Single-subject EEG-features prediction request."""
|
| 173 |
features: list[float] = Field(
|
|
@@ -1678,6 +1678,94 @@ def _render_combat_diagnostics(result: dict) -> None:
|
|
| 1678 |
st.error(f"Cannot reach FastAPI: {e!r}")
|
| 1679 |
|
| 1680 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1681 |
def _render_ai_assistant_tab() -> None:
|
| 1682 |
"""Chat-style explainer for the most recent BBB prediction."""
|
| 1683 |
_render_section(
|
|
@@ -1889,10 +1977,11 @@ def main() -> None:
|
|
| 1889 |
"Run `uvicorn src.api.main:app --port 8000` or `docker compose up`."
|
| 1890 |
)
|
| 1891 |
|
| 1892 |
-
bbb_tab, eeg_tab, mri_tab, assistant_tab, experiments_tab, agent_tab = st.tabs([
|
| 1893 |
"Molecule",
|
| 1894 |
"Signal",
|
| 1895 |
"Image",
|
|
|
|
| 1896 |
"AI Assistant",
|
| 1897 |
"Experiments",
|
| 1898 |
"🤖 Agent",
|
|
@@ -1904,6 +1993,8 @@ def main() -> None:
|
|
| 1904 |
_render_eeg_tab()
|
| 1905 |
with mri_tab:
|
| 1906 |
_render_mri_tab()
|
|
|
|
|
|
|
| 1907 |
with assistant_tab:
|
| 1908 |
_render_ai_assistant_tab()
|
| 1909 |
with experiments_tab:
|
|
|
|
| 1678 |
st.error(f"Cannot reach FastAPI: {e!r}")
|
| 1679 |
|
| 1680 |
|
| 1681 |
+
def _render_researcher_tab() -> None:
|
| 1682 |
+
"""Drug researcher view: BBB permeability map + dose adjustment."""
|
| 1683 |
+
st.markdown("### Drug Researcher")
|
| 1684 |
+
st.caption(
|
| 1685 |
+
"DCE-MRI inspired BBB leakage score → revised dose suggestion. "
|
| 1686 |
+
"Output is a research signal, NOT medical advice."
|
| 1687 |
+
)
|
| 1688 |
+
|
| 1689 |
+
col_left, col_right = st.columns(2)
|
| 1690 |
+
with col_left:
|
| 1691 |
+
st.markdown("**1. Patient BBB permeability**")
|
| 1692 |
+
mri_path = st.text_input(
|
| 1693 |
+
"MRI image path (server-side)",
|
| 1694 |
+
"tests/fixtures/mri_sample/subject_0_axial.png",
|
| 1695 |
+
key="researcher_mri_path",
|
| 1696 |
+
)
|
| 1697 |
+
mode = st.selectbox(
|
| 1698 |
+
"Scoring mode",
|
| 1699 |
+
["heuristic_proxy", "dce_onnx"],
|
| 1700 |
+
index=0,
|
| 1701 |
+
key="researcher_perm_mode",
|
| 1702 |
+
help="heuristic_proxy uses the 2D classifier; dce_onnx requires a trained DCE artifact.",
|
| 1703 |
+
)
|
| 1704 |
+
if st.button("Compute BBB leakage score", key="researcher_compute_perm"):
|
| 1705 |
+
with st.spinner("Running BBB permeability scorer..."):
|
| 1706 |
+
try:
|
| 1707 |
+
result = _post(
|
| 1708 |
+
"/predict/bbb_permeability_map",
|
| 1709 |
+
{"input_path": mri_path, "mode": mode},
|
| 1710 |
+
timeout=60.0,
|
| 1711 |
+
)
|
| 1712 |
+
except httpx.HTTPStatusError as e:
|
| 1713 |
+
st.error(f"BBB permeability failed (HTTP {e.response.status_code}): {e.response.text}")
|
| 1714 |
+
except httpx.RequestError as e:
|
| 1715 |
+
st.error(f"Cannot reach FastAPI: {e!r}")
|
| 1716 |
+
else:
|
| 1717 |
+
st.session_state["researcher_perm"] = result
|
| 1718 |
+
st.metric(
|
| 1719 |
+
label=result.get("interpretation", "BBB"),
|
| 1720 |
+
value=f"{float(result['permeability_score']) * 100:.1f}%",
|
| 1721 |
+
help=f"method={result.get('method', '?')}",
|
| 1722 |
+
)
|
| 1723 |
+
|
| 1724 |
+
with col_right:
|
| 1725 |
+
st.markdown("**2. Drug + baseline dose**")
|
| 1726 |
+
smiles = st.text_input("SMILES", "CCO", key="researcher_smiles")
|
| 1727 |
+
baseline = st.number_input(
|
| 1728 |
+
"Baseline dose (mg)",
|
| 1729 |
+
min_value=0.1, max_value=2000.0, value=100.0, step=10.0,
|
| 1730 |
+
key="researcher_baseline",
|
| 1731 |
+
)
|
| 1732 |
+
score_default = float(
|
| 1733 |
+
st.session_state.get("researcher_perm", {}).get("permeability_score", 0.0)
|
| 1734 |
+
)
|
| 1735 |
+
score = st.number_input(
|
| 1736 |
+
"BBB permeability score",
|
| 1737 |
+
min_value=0.0, max_value=1.0, value=score_default, step=0.05,
|
| 1738 |
+
key="researcher_score",
|
| 1739 |
+
help="Auto-fills from the BBB leakage score above; override manually if you want.",
|
| 1740 |
+
)
|
| 1741 |
+
if st.button("Suggest revised dose", key="researcher_compute_dose"):
|
| 1742 |
+
payload = {
|
| 1743 |
+
"smiles": smiles or None,
|
| 1744 |
+
"baseline_dose_mg": float(baseline),
|
| 1745 |
+
"bbb_permeability_score": float(score),
|
| 1746 |
+
}
|
| 1747 |
+
with st.spinner("Computing dose adjustment..."):
|
| 1748 |
+
try:
|
| 1749 |
+
result = _post("/research/drug_dose_adjustment", payload, timeout=30.0)
|
| 1750 |
+
except httpx.HTTPStatusError as e:
|
| 1751 |
+
st.error(f"Dose adjustment failed (HTTP {e.response.status_code}): {e.response.text}")
|
| 1752 |
+
except httpx.RequestError as e:
|
| 1753 |
+
st.error(f"Cannot reach FastAPI: {e!r}")
|
| 1754 |
+
else:
|
| 1755 |
+
risk = result.get("risk_level", "unknown")
|
| 1756 |
+
risk_emoji = {"low": "🟢", "moderate": "🟡", "high": "🔴"}.get(risk, "⚪️")
|
| 1757 |
+
st.metric(
|
| 1758 |
+
label=f"{risk_emoji} Recommended dose",
|
| 1759 |
+
value=f"{result['recommended_dose_mg']:.1f} mg",
|
| 1760 |
+
delta=f"{(result['adjustment_factor'] - 1.0) * 100:+.0f}%",
|
| 1761 |
+
delta_color="inverse",
|
| 1762 |
+
)
|
| 1763 |
+
drug_perm = result.get("drug_bbb_permeable")
|
| 1764 |
+
if drug_perm is not None:
|
| 1765 |
+
st.caption(f"Drug BBB-permeable: **{drug_perm}**")
|
| 1766 |
+
st.info(result.get("rationale", ""))
|
| 1767 |
+
|
| 1768 |
+
|
| 1769 |
def _render_ai_assistant_tab() -> None:
|
| 1770 |
"""Chat-style explainer for the most recent BBB prediction."""
|
| 1771 |
_render_section(
|
|
|
|
| 1977 |
"Run `uvicorn src.api.main:app --port 8000` or `docker compose up`."
|
| 1978 |
)
|
| 1979 |
|
| 1980 |
+
bbb_tab, eeg_tab, mri_tab, researcher_tab, assistant_tab, experiments_tab, agent_tab = st.tabs([
|
| 1981 |
"Molecule",
|
| 1982 |
"Signal",
|
| 1983 |
"Image",
|
| 1984 |
+
"Researcher",
|
| 1985 |
"AI Assistant",
|
| 1986 |
"Experiments",
|
| 1987 |
"🤖 Agent",
|
|
|
|
| 1993 |
_render_eeg_tab()
|
| 1994 |
with mri_tab:
|
| 1995 |
_render_mri_tab()
|
| 1996 |
+
with researcher_tab:
|
| 1997 |
+
_render_researcher_tab()
|
| 1998 |
with assistant_tab:
|
| 1999 |
_render_ai_assistant_tab()
|
| 2000 |
with experiments_tab:
|
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""BBB Permeability Map / Score from MRI input.
|
| 2 |
+
|
| 3 |
+
Two modes:
|
| 4 |
+
- `heuristic_proxy` (default, demo-ready): reuses the 2D resnet18 4-class
|
| 5 |
+
Alzheimer's classifier output. Score = 1 - P(NonDemented). Anchored in the
|
| 6 |
+
published correlation between disease severity and BBB breakdown.
|
| 7 |
+
- `dce_onnx` (real-DCE artifact, future): loads an ONNX model trained on
|
| 8 |
+
4D DCE-MRI inputs that emit per-voxel Ktrans maps. Stub for now; works
|
| 9 |
+
when the artifact drops in.
|
| 10 |
+
|
| 11 |
+
Researcher-persona module. Does NOT feed into the fusion engine — fusion's
|
| 12 |
+
'BBB is NOT a modality' rule is preserved. The only legitimate place where
|
| 13 |
+
BBB and MRI couple in this platform.
|
| 14 |
+
"""
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import os
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
from typing import Any, Literal
|
| 20 |
+
|
| 21 |
+
import numpy as np
|
| 22 |
+
|
| 23 |
+
from src.core.logger import get_logger
|
| 24 |
+
|
| 25 |
+
logger = get_logger(__name__)
|
| 26 |
+
|
| 27 |
+
PermeabilityMode = Literal["heuristic_proxy", "dce_onnx"]
|
| 28 |
+
DEFAULT_MODE: PermeabilityMode = "heuristic_proxy"
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _interpret(score: float) -> str:
|
| 32 |
+
if score < 0.2:
|
| 33 |
+
return "BBB intact"
|
| 34 |
+
if score < 0.4:
|
| 35 |
+
return "mild leakage"
|
| 36 |
+
if score < 0.7:
|
| 37 |
+
return "moderate leakage"
|
| 38 |
+
return "severe leakage"
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def compute_from_classifier_probs(probabilities: list[dict[str, Any]]) -> float:
|
| 42 |
+
"""Heuristic: 1 - P(NonDemented) from a 4-class probability list.
|
| 43 |
+
|
| 44 |
+
Accepts the standard prediction-dict shape used across this repo
|
| 45 |
+
(`probabilities=[{"label_text", "probability"}, ...]`).
|
| 46 |
+
"""
|
| 47 |
+
p_nondemented = 0.0
|
| 48 |
+
for entry in probabilities:
|
| 49 |
+
if str(entry.get("label_text", "")).lower() == "nondemented":
|
| 50 |
+
p_nondemented = float(entry.get("probability", 0.0))
|
| 51 |
+
break
|
| 52 |
+
score = max(0.0, min(1.0, 1.0 - p_nondemented))
|
| 53 |
+
return float(score)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def heuristic_proxy_score(input_path: Path, checkpoint_path: Path) -> dict[str, Any]:
|
| 57 |
+
"""Run the 2D resnet18 classifier and derive the permeability score from
|
| 58 |
+
its NonDemented probability.
|
| 59 |
+
"""
|
| 60 |
+
from src.models import mri_dl_2d
|
| 61 |
+
|
| 62 |
+
model = mri_dl_2d.load(checkpoint_path)
|
| 63 |
+
pred = mri_dl_2d.predict_image(model, input_path)
|
| 64 |
+
score = compute_from_classifier_probs(pred["probabilities"])
|
| 65 |
+
return {
|
| 66 |
+
"permeability_score": score,
|
| 67 |
+
"interpretation": _interpret(score),
|
| 68 |
+
"method": "heuristic_proxy",
|
| 69 |
+
"voxel_map_available": False,
|
| 70 |
+
"source_class_probabilities": pred["probabilities"],
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def dce_onnx_score(input_path: Path, checkpoint_path: Path) -> dict[str, Any]:
|
| 75 |
+
"""Load a DCE-MRI ONNX model and compute per-voxel Ktrans → scalar score.
|
| 76 |
+
|
| 77 |
+
Contract for the future artifact:
|
| 78 |
+
- Input: 4D NIfTI `(X, Y, Z, T)` with at least one timepoint.
|
| 79 |
+
- Output: 3D Ktrans map (mL/min/100g). We normalise to `[0, 1]` by
|
| 80 |
+
dividing by a clinically-conservative cap (`_DCE_KTRANS_CAP_MAX`)
|
| 81 |
+
and clipping. Mean over the brain mask becomes the scalar score.
|
| 82 |
+
|
| 83 |
+
No real artifact ships with the repo — this raises a clear error
|
| 84 |
+
explaining the contract until one lands at `data/processed/bbb_permeability_dce.onnx`
|
| 85 |
+
(override via `BBB_PERMEABILITY_DCE_PATH`).
|
| 86 |
+
"""
|
| 87 |
+
checkpoint_path = Path(checkpoint_path)
|
| 88 |
+
if not checkpoint_path.exists():
|
| 89 |
+
raise FileNotFoundError(
|
| 90 |
+
f"DCE-MRI BBB permeability artifact not found at {checkpoint_path}. "
|
| 91 |
+
"Train and export an ONNX model that consumes a 4D DCE volume "
|
| 92 |
+
"(X, Y, Z, T) and emits a 3D Ktrans map; drop it at this path or "
|
| 93 |
+
"set BBB_PERMEABILITY_DCE_PATH."
|
| 94 |
+
)
|
| 95 |
+
import nibabel as nib
|
| 96 |
+
import onnxruntime as ort
|
| 97 |
+
|
| 98 |
+
img = nib.load(str(input_path))
|
| 99 |
+
arr = np.asarray(img.get_fdata(dtype=np.float32), dtype=np.float32)
|
| 100 |
+
if arr.ndim != 4:
|
| 101 |
+
raise ValueError(
|
| 102 |
+
f"DCE-MRI mode expects a 4D NIfTI (X,Y,Z,T); got shape {arr.shape}."
|
| 103 |
+
)
|
| 104 |
+
session = ort.InferenceSession(str(checkpoint_path), providers=["CPUExecutionProvider"])
|
| 105 |
+
input_name = session.get_inputs()[0].name
|
| 106 |
+
ktrans = session.run(None, {input_name: arr[np.newaxis, ...]})[0]
|
| 107 |
+
# Normalise to [0, 1]; clinically-conservative cap of 0.5 mL/min/100g.
|
| 108 |
+
_DCE_KTRANS_CAP = 0.5
|
| 109 |
+
normalised = np.clip(ktrans / _DCE_KTRANS_CAP, 0.0, 1.0).astype(np.float32)
|
| 110 |
+
score = float(np.mean(normalised))
|
| 111 |
+
return {
|
| 112 |
+
"permeability_score": score,
|
| 113 |
+
"interpretation": _interpret(score),
|
| 114 |
+
"method": "dce_onnx",
|
| 115 |
+
"voxel_map_available": True,
|
| 116 |
+
"voxel_map_shape": list(normalised.shape),
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def compute_permeability(
|
| 121 |
+
input_path: Path,
|
| 122 |
+
mode: PermeabilityMode = DEFAULT_MODE,
|
| 123 |
+
checkpoint_path: Path | None = None,
|
| 124 |
+
) -> dict[str, Any]:
|
| 125 |
+
"""Dispatch to the requested mode and return a unified payload."""
|
| 126 |
+
input_path = Path(input_path)
|
| 127 |
+
if not input_path.exists():
|
| 128 |
+
raise FileNotFoundError(f"MRI input not found: {input_path}")
|
| 129 |
+
|
| 130 |
+
if mode == "heuristic_proxy":
|
| 131 |
+
ckpt = checkpoint_path or Path(os.environ.get(
|
| 132 |
+
"MRI_MODEL_PATH_2D", "data/processed/mri_dl_2d/best_model.pt",
|
| 133 |
+
))
|
| 134 |
+
return heuristic_proxy_score(input_path, ckpt)
|
| 135 |
+
|
| 136 |
+
if mode == "dce_onnx":
|
| 137 |
+
ckpt = checkpoint_path or Path(os.environ.get(
|
| 138 |
+
"BBB_PERMEABILITY_DCE_PATH",
|
| 139 |
+
"data/processed/bbb_permeability_dce.onnx",
|
| 140 |
+
))
|
| 141 |
+
return dce_onnx_score(input_path, ckpt)
|
| 142 |
+
|
| 143 |
+
raise ValueError(
|
| 144 |
+
f"unknown BBB permeability mode={mode!r}; expected one of "
|
| 145 |
+
"('heuristic_proxy', 'dce_onnx')"
|
| 146 |
+
)
|
|
File without changes
|
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Drug-dose adjustment given patient BBB permeability + drug BBB classification.
|
| 2 |
+
|
| 3 |
+
Pure-function logic. Researcher-persona module — does NOT touch the fusion
|
| 4 |
+
engine. Output is a research suggestion, NOT medical advice.
|
| 5 |
+
|
| 6 |
+
Decision matrix:
|
| 7 |
+
|
| 8 |
+
BBB intact (perm < 0.2) -> factor=1.0, risk=low
|
| 9 |
+
BBB leaky, drug BBB-permeable -> factor=max(0.3, 1 - 0.7*perm),
|
| 10 |
+
risk=high if perm > 0.6 else moderate
|
| 11 |
+
BBB leaky, drug NOT permeable -> factor=max(0.6, 1 - 0.4*perm),
|
| 12 |
+
risk=moderate
|
| 13 |
+
drug_permeable=None (unknown) -> treat as permeable (safer)
|
| 14 |
+
"""
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
from dataclasses import dataclass
|
| 18 |
+
from typing import Literal
|
| 19 |
+
|
| 20 |
+
RiskLevel = Literal["low", "moderate", "high"]
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@dataclass(frozen=True)
|
| 24 |
+
class DoseAdjustment:
|
| 25 |
+
recommended_dose_mg: float
|
| 26 |
+
adjustment_factor: float # 0..1; 1.0 = no change
|
| 27 |
+
risk_level: RiskLevel
|
| 28 |
+
rationale: str
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
# Threshold below which BBB is considered intact and no adjustment applies.
|
| 32 |
+
_INTACT_THRESHOLD = 0.2
|
| 33 |
+
|
| 34 |
+
# Floor on the adjustment factor (we never recommend going below this fraction
|
| 35 |
+
# of the baseline dose — anything more aggressive belongs to a clinician).
|
| 36 |
+
_PERMEABLE_DRUG_MIN_FACTOR = 0.3
|
| 37 |
+
_NON_PERMEABLE_DRUG_MIN_FACTOR = 0.6
|
| 38 |
+
|
| 39 |
+
# Slope on the linear adjustment.
|
| 40 |
+
_PERMEABLE_DRUG_SLOPE = 0.7
|
| 41 |
+
_NON_PERMEABLE_DRUG_SLOPE = 0.4
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def adjust(
|
| 45 |
+
baseline_dose_mg: float,
|
| 46 |
+
bbb_permeability_score: float,
|
| 47 |
+
drug_bbb_permeable: bool | None = None,
|
| 48 |
+
) -> DoseAdjustment:
|
| 49 |
+
"""Return a DoseAdjustment for the (patient, drug) pair."""
|
| 50 |
+
if baseline_dose_mg <= 0.0:
|
| 51 |
+
raise ValueError(f"baseline_dose_mg must be > 0; got {baseline_dose_mg}")
|
| 52 |
+
if not 0.0 <= bbb_permeability_score <= 1.0:
|
| 53 |
+
raise ValueError(
|
| 54 |
+
f"bbb_permeability_score must be in [0, 1]; got {bbb_permeability_score}"
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
if bbb_permeability_score < _INTACT_THRESHOLD:
|
| 58 |
+
return DoseAdjustment(
|
| 59 |
+
recommended_dose_mg=baseline_dose_mg,
|
| 60 |
+
adjustment_factor=1.0,
|
| 61 |
+
risk_level="low",
|
| 62 |
+
rationale=(
|
| 63 |
+
f"BBB permeability score {bbb_permeability_score:.2f} is below the "
|
| 64 |
+
f"intact threshold ({_INTACT_THRESHOLD:.2f}). No dose adjustment "
|
| 65 |
+
"recommended. Research suggestion, not medical advice."
|
| 66 |
+
),
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
# Treat unknown permeability conservatively as 'permeable'.
|
| 70 |
+
permeable = True if drug_bbb_permeable is None else bool(drug_bbb_permeable)
|
| 71 |
+
|
| 72 |
+
if permeable:
|
| 73 |
+
factor = max(
|
| 74 |
+
_PERMEABLE_DRUG_MIN_FACTOR,
|
| 75 |
+
1.0 - _PERMEABLE_DRUG_SLOPE * bbb_permeability_score,
|
| 76 |
+
)
|
| 77 |
+
risk: RiskLevel = "high" if bbb_permeability_score > 0.6 else "moderate"
|
| 78 |
+
permeable_note = (
|
| 79 |
+
"Drug is BBB-permeable" if drug_bbb_permeable is True
|
| 80 |
+
else "Drug BBB permeability is unknown (treated as permeable for safety)"
|
| 81 |
+
)
|
| 82 |
+
else:
|
| 83 |
+
factor = max(
|
| 84 |
+
_NON_PERMEABLE_DRUG_MIN_FACTOR,
|
| 85 |
+
1.0 - _NON_PERMEABLE_DRUG_SLOPE * bbb_permeability_score,
|
| 86 |
+
)
|
| 87 |
+
risk = "moderate"
|
| 88 |
+
permeable_note = "Drug is normally BBB-excluded"
|
| 89 |
+
|
| 90 |
+
recommended = baseline_dose_mg * factor
|
| 91 |
+
rationale = (
|
| 92 |
+
f"BBB permeability score {bbb_permeability_score:.2f} indicates "
|
| 93 |
+
f"compromised barrier. {permeable_note}; recommended dose reduced "
|
| 94 |
+
f"to {factor*100:.0f}% of baseline ({recommended:.1f} mg of "
|
| 95 |
+
f"{baseline_dose_mg:.1f} mg). Risk level: {risk}. "
|
| 96 |
+
"Research suggestion, not medical advice."
|
| 97 |
+
)
|
| 98 |
+
return DoseAdjustment(
|
| 99 |
+
recommended_dose_mg=float(recommended),
|
| 100 |
+
adjustment_factor=float(factor),
|
| 101 |
+
risk_level=risk,
|
| 102 |
+
rationale=rationale,
|
| 103 |
+
)
|
|
@@ -70,7 +70,7 @@ class TestTool:
|
|
| 70 |
|
| 71 |
|
| 72 |
class TestBuildDefaultTools:
|
| 73 |
-
def
|
| 74 |
# build with placeholder paths; tools won't be invoked here
|
| 75 |
tools = build_default_tools(rag_index_dir=None)
|
| 76 |
names = {t.name for t in tools}
|
|
@@ -80,6 +80,8 @@ class TestBuildDefaultTools:
|
|
| 80 |
"run_mri_pipeline",
|
| 81 |
"retrieve_context",
|
| 82 |
"run_fusion",
|
|
|
|
|
|
|
| 83 |
}
|
| 84 |
|
| 85 |
def test_each_tool_has_pydantic_input_model(self) -> None:
|
|
@@ -116,8 +118,8 @@ class TestBuildDefaultTools:
|
|
| 116 |
def test_default_processed_dir_when_omitted(self) -> None:
|
| 117 |
# backwards-compat: omitting processed_dir keeps existing behavior
|
| 118 |
tools = build_default_tools(rag_index_dir=None)
|
| 119 |
-
# just ensure no exception and
|
| 120 |
-
assert len(tools) ==
|
| 121 |
|
| 122 |
def test_bbb_executor_translates_httpexception_to_valueerror(self) -> None:
|
| 123 |
from fastapi import HTTPException
|
|
|
|
| 70 |
|
| 71 |
|
| 72 |
class TestBuildDefaultTools:
|
| 73 |
+
def test_default_set_has_seven_tools(self, tmp_path: Path) -> None:
|
| 74 |
# build with placeholder paths; tools won't be invoked here
|
| 75 |
tools = build_default_tools(rag_index_dir=None)
|
| 76 |
names = {t.name for t in tools}
|
|
|
|
| 80 |
"run_mri_pipeline",
|
| 81 |
"retrieve_context",
|
| 82 |
"run_fusion",
|
| 83 |
+
"compute_bbb_leakage_score",
|
| 84 |
+
"adjust_drug_dose",
|
| 85 |
}
|
| 86 |
|
| 87 |
def test_each_tool_has_pydantic_input_model(self) -> None:
|
|
|
|
| 118 |
def test_default_processed_dir_when_omitted(self) -> None:
|
| 119 |
# backwards-compat: omitting processed_dir keeps existing behavior
|
| 120 |
tools = build_default_tools(rag_index_dir=None)
|
| 121 |
+
# just ensure no exception and 7 tools returned
|
| 122 |
+
assert len(tools) == 7
|
| 123 |
|
| 124 |
def test_bbb_executor_translates_httpexception_to_valueerror(self) -> None:
|
| 125 |
from fastapi import HTTPException
|
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Integration tests for /predict/bbb_permeability_map and /research/drug_dose_adjustment."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
import pytest
|
| 8 |
+
from fastapi.testclient import TestClient
|
| 9 |
+
from PIL import Image
|
| 10 |
+
|
| 11 |
+
from src.api.main import app
|
| 12 |
+
from tests.fixtures.build_dummy_resnet18_2d import build as build_dummy_2d
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _png(path: Path) -> Path:
|
| 16 |
+
arr = (np.random.RandomState(0).rand(170, 170, 3) * 255).astype(np.uint8)
|
| 17 |
+
Image.fromarray(arr, mode="RGB").save(str(path))
|
| 18 |
+
return path
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@pytest.fixture()
|
| 22 |
+
def client_proxy(monkeypatch, tmp_path):
|
| 23 |
+
ckpt = build_dummy_2d(tmp_path / "best.pt")
|
| 24 |
+
monkeypatch.setenv("MRI_MODEL_PATH_2D", str(ckpt))
|
| 25 |
+
return TestClient(app), tmp_path
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class TestBBBPermeabilityMapRoute:
|
| 29 |
+
def test_heuristic_proxy_happy_path(self, client_proxy) -> None:
|
| 30 |
+
client, tmp_path = client_proxy
|
| 31 |
+
img = _png(tmp_path / "scan.png")
|
| 32 |
+
r = client.post(
|
| 33 |
+
"/predict/bbb_permeability_map",
|
| 34 |
+
json={"input_path": str(img), "mode": "heuristic_proxy"},
|
| 35 |
+
)
|
| 36 |
+
assert r.status_code == 200, r.text
|
| 37 |
+
data = r.json()
|
| 38 |
+
assert 0.0 <= data["permeability_score"] <= 1.0
|
| 39 |
+
assert data["interpretation"] in {
|
| 40 |
+
"BBB intact", "mild leakage", "moderate leakage", "severe leakage",
|
| 41 |
+
}
|
| 42 |
+
assert data["method"] == "heuristic_proxy"
|
| 43 |
+
assert data["voxel_map_available"] is False
|
| 44 |
+
|
| 45 |
+
def test_unknown_mode_returns_400(self, client_proxy) -> None:
|
| 46 |
+
client, tmp_path = client_proxy
|
| 47 |
+
img = _png(tmp_path / "scan.png")
|
| 48 |
+
r = client.post(
|
| 49 |
+
"/predict/bbb_permeability_map",
|
| 50 |
+
json={"input_path": str(img), "mode": "bogus_mode"},
|
| 51 |
+
)
|
| 52 |
+
assert r.status_code == 400
|
| 53 |
+
|
| 54 |
+
def test_missing_input_returns_404(self, client_proxy) -> None:
|
| 55 |
+
client, tmp_path = client_proxy
|
| 56 |
+
r = client.post(
|
| 57 |
+
"/predict/bbb_permeability_map",
|
| 58 |
+
json={"input_path": str(tmp_path / "missing.png"), "mode": "heuristic_proxy"},
|
| 59 |
+
)
|
| 60 |
+
assert r.status_code == 404
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class TestDrugDoseAdjustmentRoute:
|
| 64 |
+
def test_intact_bbb_returns_baseline(self) -> None:
|
| 65 |
+
client = TestClient(app)
|
| 66 |
+
r = client.post("/research/drug_dose_adjustment", json={
|
| 67 |
+
"baseline_dose_mg": 100.0,
|
| 68 |
+
"bbb_permeability_score": 0.05,
|
| 69 |
+
"drug_bbb_permeable": True,
|
| 70 |
+
})
|
| 71 |
+
assert r.status_code == 200, r.text
|
| 72 |
+
data = r.json()
|
| 73 |
+
assert data["recommended_dose_mg"] == pytest.approx(100.0)
|
| 74 |
+
assert data["risk_level"] == "low"
|
| 75 |
+
|
| 76 |
+
def test_leaky_bbb_permeable_drug_reduced(self) -> None:
|
| 77 |
+
client = TestClient(app)
|
| 78 |
+
r = client.post("/research/drug_dose_adjustment", json={
|
| 79 |
+
"baseline_dose_mg": 100.0,
|
| 80 |
+
"bbb_permeability_score": 0.5,
|
| 81 |
+
"drug_bbb_permeable": True,
|
| 82 |
+
})
|
| 83 |
+
assert r.status_code == 200
|
| 84 |
+
data = r.json()
|
| 85 |
+
assert data["recommended_dose_mg"] == pytest.approx(65.0)
|
| 86 |
+
assert data["risk_level"] == "moderate"
|
| 87 |
+
assert "research suggestion" in data["rationale"].lower()
|
| 88 |
+
|
| 89 |
+
def test_severe_leakage_high_risk(self) -> None:
|
| 90 |
+
client = TestClient(app)
|
| 91 |
+
r = client.post("/research/drug_dose_adjustment", json={
|
| 92 |
+
"baseline_dose_mg": 100.0,
|
| 93 |
+
"bbb_permeability_score": 0.85,
|
| 94 |
+
"drug_bbb_permeable": True,
|
| 95 |
+
})
|
| 96 |
+
data = r.json()
|
| 97 |
+
assert data["risk_level"] == "high"
|
| 98 |
+
|
| 99 |
+
def test_negative_baseline_returns_422(self) -> None:
|
| 100 |
+
client = TestClient(app)
|
| 101 |
+
r = client.post("/research/drug_dose_adjustment", json={
|
| 102 |
+
"baseline_dose_mg": -1.0,
|
| 103 |
+
"bbb_permeability_score": 0.5,
|
| 104 |
+
})
|
| 105 |
+
assert r.status_code == 422 # pydantic gt=0.0 validation
|
| 106 |
+
|
| 107 |
+
def test_score_out_of_range_returns_422(self) -> None:
|
| 108 |
+
client = TestClient(app)
|
| 109 |
+
r = client.post("/research/drug_dose_adjustment", json={
|
| 110 |
+
"baseline_dose_mg": 100.0,
|
| 111 |
+
"bbb_permeability_score": 1.5,
|
| 112 |
+
})
|
| 113 |
+
assert r.status_code == 422
|
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for src.models.bbb_permeability_map — researcher BBB scorer."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
import pytest
|
| 8 |
+
from PIL import Image
|
| 9 |
+
|
| 10 |
+
from src.models import bbb_permeability_map as bbb_perm
|
| 11 |
+
from tests.fixtures.build_dummy_resnet18_2d import build as build_dummy_2d
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _png(path: Path, size: tuple[int, int] = (170, 170)) -> Path:
|
| 15 |
+
arr = (np.random.RandomState(0).rand(size[1], size[0], 3) * 255).astype(np.uint8)
|
| 16 |
+
Image.fromarray(arr, mode="RGB").save(str(path))
|
| 17 |
+
return path
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class TestComputeFromClassifierProbs:
|
| 21 |
+
def test_full_nondemented_yields_zero_score(self) -> None:
|
| 22 |
+
probs = [
|
| 23 |
+
{"label_text": "MildDemented", "probability": 0.0},
|
| 24 |
+
{"label_text": "ModerateDemented", "probability": 0.0},
|
| 25 |
+
{"label_text": "NonDemented", "probability": 1.0},
|
| 26 |
+
{"label_text": "VeryMildDemented", "probability": 0.0},
|
| 27 |
+
]
|
| 28 |
+
assert bbb_perm.compute_from_classifier_probs(probs) == pytest.approx(0.0)
|
| 29 |
+
|
| 30 |
+
def test_no_nondemented_yields_one(self) -> None:
|
| 31 |
+
probs = [
|
| 32 |
+
{"label_text": "MildDemented", "probability": 0.5},
|
| 33 |
+
{"label_text": "ModerateDemented", "probability": 0.5},
|
| 34 |
+
]
|
| 35 |
+
assert bbb_perm.compute_from_classifier_probs(probs) == pytest.approx(1.0)
|
| 36 |
+
|
| 37 |
+
def test_partial_yields_complement(self) -> None:
|
| 38 |
+
probs = [
|
| 39 |
+
{"label_text": "NonDemented", "probability": 0.7},
|
| 40 |
+
{"label_text": "MildDemented", "probability": 0.3},
|
| 41 |
+
]
|
| 42 |
+
assert bbb_perm.compute_from_classifier_probs(probs) == pytest.approx(0.3)
|
| 43 |
+
|
| 44 |
+
def test_case_insensitive_label_match(self) -> None:
|
| 45 |
+
probs = [{"label_text": "nondemented", "probability": 0.6}]
|
| 46 |
+
assert bbb_perm.compute_from_classifier_probs(probs) == pytest.approx(0.4)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class TestComputePermeability:
|
| 50 |
+
def test_heuristic_proxy_with_dummy_2d(self, tmp_path: Path) -> None:
|
| 51 |
+
ckpt = build_dummy_2d(tmp_path / "best.pt")
|
| 52 |
+
img = _png(tmp_path / "scan.png")
|
| 53 |
+
result = bbb_perm.compute_permeability(
|
| 54 |
+
input_path=img, mode="heuristic_proxy", checkpoint_path=ckpt,
|
| 55 |
+
)
|
| 56 |
+
assert 0.0 <= result["permeability_score"] <= 1.0
|
| 57 |
+
assert result["interpretation"] in {
|
| 58 |
+
"BBB intact", "mild leakage", "moderate leakage", "severe leakage",
|
| 59 |
+
}
|
| 60 |
+
assert result["method"] == "heuristic_proxy"
|
| 61 |
+
assert result["voxel_map_available"] is False
|
| 62 |
+
|
| 63 |
+
def test_unknown_mode_raises(self, tmp_path: Path) -> None:
|
| 64 |
+
img = _png(tmp_path / "scan.png")
|
| 65 |
+
with pytest.raises(ValueError, match="unknown BBB permeability mode"):
|
| 66 |
+
bbb_perm.compute_permeability(input_path=img, mode="bogus") # type: ignore[arg-type]
|
| 67 |
+
|
| 68 |
+
def test_missing_input_raises(self, tmp_path: Path) -> None:
|
| 69 |
+
with pytest.raises(FileNotFoundError, match="MRI input not found"):
|
| 70 |
+
bbb_perm.compute_permeability(input_path=tmp_path / "nope.png")
|
| 71 |
+
|
| 72 |
+
def test_dce_mode_without_artifact_raises(self, tmp_path: Path) -> None:
|
| 73 |
+
# Existence check on the input is the first gate; placeholder file is fine.
|
| 74 |
+
img = tmp_path / "fake_dce.nii.gz"
|
| 75 |
+
img.write_bytes(b"")
|
| 76 |
+
with pytest.raises(FileNotFoundError, match="DCE-MRI BBB permeability artifact"):
|
| 77 |
+
bbb_perm.compute_permeability(
|
| 78 |
+
input_path=img, mode="dce_onnx",
|
| 79 |
+
checkpoint_path=tmp_path / "missing.onnx",
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
def test_interpretation_thresholds(self) -> None:
|
| 83 |
+
assert bbb_perm._interpret(0.10) == "BBB intact"
|
| 84 |
+
assert bbb_perm._interpret(0.30) == "mild leakage"
|
| 85 |
+
assert bbb_perm._interpret(0.55) == "moderate leakage"
|
| 86 |
+
assert bbb_perm._interpret(0.80) == "severe leakage"
|
|
File without changes
|
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for src.research.drug_dose_adjuster — pure-function dose revision."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import pytest
|
| 5 |
+
|
| 6 |
+
from src.research.drug_dose_adjuster import adjust
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class TestAdjust:
|
| 10 |
+
# --- BBB intact ---------------------------------------------------------
|
| 11 |
+
|
| 12 |
+
def test_intact_bbb_no_change_permeable_drug(self) -> None:
|
| 13 |
+
out = adjust(baseline_dose_mg=100.0, bbb_permeability_score=0.05, drug_bbb_permeable=True)
|
| 14 |
+
assert out.recommended_dose_mg == pytest.approx(100.0)
|
| 15 |
+
assert out.adjustment_factor == 1.0
|
| 16 |
+
assert out.risk_level == "low"
|
| 17 |
+
|
| 18 |
+
def test_intact_bbb_no_change_non_permeable_drug(self) -> None:
|
| 19 |
+
out = adjust(baseline_dose_mg=200.0, bbb_permeability_score=0.0, drug_bbb_permeable=False)
|
| 20 |
+
assert out.adjustment_factor == 1.0
|
| 21 |
+
assert out.risk_level == "low"
|
| 22 |
+
|
| 23 |
+
# --- BBB leaky, drug permeable -----------------------------------------
|
| 24 |
+
|
| 25 |
+
def test_leaky_bbb_permeable_drug_reduces_dose(self) -> None:
|
| 26 |
+
out = adjust(baseline_dose_mg=100.0, bbb_permeability_score=0.5, drug_bbb_permeable=True)
|
| 27 |
+
# factor = max(0.3, 1 - 0.7*0.5) = max(0.3, 0.65) = 0.65
|
| 28 |
+
assert out.adjustment_factor == pytest.approx(0.65)
|
| 29 |
+
assert out.recommended_dose_mg == pytest.approx(65.0)
|
| 30 |
+
assert out.risk_level == "moderate"
|
| 31 |
+
|
| 32 |
+
def test_severe_leakage_permeable_drug_high_risk(self) -> None:
|
| 33 |
+
out = adjust(baseline_dose_mg=100.0, bbb_permeability_score=0.8, drug_bbb_permeable=True)
|
| 34 |
+
assert out.risk_level == "high"
|
| 35 |
+
# factor = max(0.3, 1 - 0.7*0.8) = max(0.3, 0.44) = 0.44
|
| 36 |
+
assert out.adjustment_factor == pytest.approx(0.44)
|
| 37 |
+
|
| 38 |
+
def test_safety_floor_not_breached(self) -> None:
|
| 39 |
+
# At perm=1.0: 1 - 0.7 = 0.3 — the floor.
|
| 40 |
+
out = adjust(baseline_dose_mg=100.0, bbb_permeability_score=1.0, drug_bbb_permeable=True)
|
| 41 |
+
assert out.adjustment_factor >= 0.3 - 1e-9
|
| 42 |
+
# And anything above 1.0 (clipped by validator) — won't get there.
|
| 43 |
+
|
| 44 |
+
# --- BBB leaky, drug NOT permeable -------------------------------------
|
| 45 |
+
|
| 46 |
+
def test_leaky_bbb_non_permeable_drug_mild_reduction(self) -> None:
|
| 47 |
+
out = adjust(baseline_dose_mg=100.0, bbb_permeability_score=0.5, drug_bbb_permeable=False)
|
| 48 |
+
# factor = max(0.6, 1 - 0.4*0.5) = max(0.6, 0.8) = 0.8
|
| 49 |
+
assert out.adjustment_factor == pytest.approx(0.8)
|
| 50 |
+
assert out.risk_level == "moderate"
|
| 51 |
+
|
| 52 |
+
def test_non_permeable_drug_safety_floor(self) -> None:
|
| 53 |
+
out = adjust(baseline_dose_mg=100.0, bbb_permeability_score=1.0, drug_bbb_permeable=False)
|
| 54 |
+
assert out.adjustment_factor == pytest.approx(0.6)
|
| 55 |
+
|
| 56 |
+
# --- Drug permeability unknown -----------------------------------------
|
| 57 |
+
|
| 58 |
+
def test_unknown_permeability_treated_as_permeable(self) -> None:
|
| 59 |
+
out_unknown = adjust(baseline_dose_mg=100.0, bbb_permeability_score=0.5, drug_bbb_permeable=None)
|
| 60 |
+
out_perm = adjust(baseline_dose_mg=100.0, bbb_permeability_score=0.5, drug_bbb_permeable=True)
|
| 61 |
+
assert out_unknown.adjustment_factor == pytest.approx(out_perm.adjustment_factor)
|
| 62 |
+
|
| 63 |
+
def test_unknown_permeability_rationale_says_so(self) -> None:
|
| 64 |
+
out = adjust(baseline_dose_mg=100.0, bbb_permeability_score=0.5, drug_bbb_permeable=None)
|
| 65 |
+
assert "unknown" in out.rationale.lower()
|
| 66 |
+
|
| 67 |
+
# --- Validation ---------------------------------------------------------
|
| 68 |
+
|
| 69 |
+
def test_zero_baseline_raises(self) -> None:
|
| 70 |
+
with pytest.raises(ValueError, match="baseline_dose_mg must be > 0"):
|
| 71 |
+
adjust(baseline_dose_mg=0.0, bbb_permeability_score=0.5)
|
| 72 |
+
|
| 73 |
+
def test_negative_baseline_raises(self) -> None:
|
| 74 |
+
with pytest.raises(ValueError, match="baseline_dose_mg must be > 0"):
|
| 75 |
+
adjust(baseline_dose_mg=-10.0, bbb_permeability_score=0.5)
|
| 76 |
+
|
| 77 |
+
def test_score_out_of_range_raises(self) -> None:
|
| 78 |
+
with pytest.raises(ValueError, match="bbb_permeability_score must be in"):
|
| 79 |
+
adjust(baseline_dose_mg=100.0, bbb_permeability_score=1.5)
|
| 80 |
+
with pytest.raises(ValueError, match="bbb_permeability_score must be in"):
|
| 81 |
+
adjust(baseline_dose_mg=100.0, bbb_permeability_score=-0.1)
|
| 82 |
+
|
| 83 |
+
# --- Monotonicity ------------------------------------------------------
|
| 84 |
+
|
| 85 |
+
def test_increasing_leakage_lowers_recommended_dose_for_permeable_drug(self) -> None:
|
| 86 |
+
scores = [0.25, 0.4, 0.55, 0.7, 0.85]
|
| 87 |
+
doses = [
|
| 88 |
+
adjust(100.0, s, drug_bbb_permeable=True).recommended_dose_mg
|
| 89 |
+
for s in scores
|
| 90 |
+
]
|
| 91 |
+
# Strictly non-increasing.
|
| 92 |
+
assert all(a >= b - 1e-9 for a, b in zip(doses, doses[1:]))
|
| 93 |
+
|
| 94 |
+
# --- Rationale always says it's not medical advice --------------------
|
| 95 |
+
|
| 96 |
+
def test_rationale_disclaims_medical_advice(self) -> None:
|
| 97 |
+
for score in [0.0, 0.3, 0.7, 1.0]:
|
| 98 |
+
out = adjust(100.0, score, drug_bbb_permeable=True)
|
| 99 |
+
assert "research suggestion" in out.rationale.lower()
|
| 100 |
+
assert "medical advice" in out.rationale.lower()
|