feat(deploy): demo-artifact seeding for Hugging Face Space build + entrypoint
Browse filesHF Space deployment needs every showcase path to work without external
data. Five artifacts are gitignored (model binaries, RAG index, derived
fixtures) so they have to be (re)generated at image-build time.
- scripts/seed_demo_artifacts.py: idempotent generator for
data/processed/mri_dl_2d/best_model.pt (random resnet18 4-class),
data/processed/mri_model.onnx (dynamic-D/H/W ONNX biased to 'abnormal'),
data/processed/eeg_clf.joblib (synthetic-separable RandomForest),
data/external_rag/index/rag_index.pkl (4-chunk synthetic clinical
TF-IDF index), and tests/fixtures/mri_sample/subject_0_axial.png
(axial slice from the bundled NIfTI). Each step is a no-op when the
artifact already exists.
- Dockerfile.hf: COPY scripts/, RUN seed_demo_artifacts.py after the
pipeline + FAISS-index build steps. Keeps Dockerfile (HF
auto-discovered name) byte-identical to Dockerfile.hf.
- docker-entrypoint.sh: re-run the seed script on container start so a
mounted-volume deploy can re-seed without a rebuild.
- src/api/routes.py: cache the agent-model ping result per process.
First /agent/run call probes the fallback chain; subsequent calls
reuse the picked model. Avoids 1-2s probe latency per request and
spares the OpenRouter free-tier rate limit.
Test suite: 362 passed (orchestrator_live skipped — flaky on free tier).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Dockerfile +7 -0
- Dockerfile.hf +7 -0
- docker-entrypoint.sh +4 -0
- scripts/seed_demo_artifacts.py +155 -0
- src/api/routes.py +18 -3
|
@@ -29,6 +29,7 @@ RUN pip install -r requirements.txt
|
|
| 29 |
# --- project source ---
|
| 30 |
COPY src/ ./src/
|
| 31 |
COPY tests/fixtures/ ./tests/fixtures/
|
|
|
|
| 32 |
COPY supervisord.conf ./supervisord.conf
|
| 33 |
COPY docker-entrypoint.sh ./docker-entrypoint.sh
|
| 34 |
RUN chmod +x /app/docker-entrypoint.sh
|
|
@@ -53,6 +54,12 @@ RUN mkdir -p data/raw data/processed && \
|
|
| 53 |
COPY tests/fixtures/kb_sample/ ./data/knowledge_base/seed/
|
| 54 |
RUN python -m src.rag.ingest data/knowledge_base data/processed/faiss_index
|
| 55 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
# --- HF Spaces convention ---
|
| 57 |
EXPOSE 7860
|
| 58 |
|
|
|
|
| 29 |
# --- project source ---
|
| 30 |
COPY src/ ./src/
|
| 31 |
COPY tests/fixtures/ ./tests/fixtures/
|
| 32 |
+
COPY scripts/ ./scripts/
|
| 33 |
COPY supervisord.conf ./supervisord.conf
|
| 34 |
COPY docker-entrypoint.sh ./docker-entrypoint.sh
|
| 35 |
RUN chmod +x /app/docker-entrypoint.sh
|
|
|
|
| 54 |
COPY tests/fixtures/kb_sample/ ./data/knowledge_base/seed/
|
| 55 |
RUN python -m src.rag.ingest data/knowledge_base data/processed/faiss_index
|
| 56 |
|
| 57 |
+
# --- Demo-time artifacts (MRI 2D / MRI volumetric ONNX / EEG joblib /
|
| 58 |
+
# clinical TF-IDF RAG / axial PNG fixture). Idempotent script;
|
| 59 |
+
# entrypoint also re-runs it on container start so a mounted-volume
|
| 60 |
+
# deployment can re-seed without a rebuild.
|
| 61 |
+
RUN python scripts/seed_demo_artifacts.py
|
| 62 |
+
|
| 63 |
# --- HF Spaces convention ---
|
| 64 |
EXPOSE 7860
|
| 65 |
|
|
@@ -29,6 +29,7 @@ RUN pip install -r requirements.txt
|
|
| 29 |
# --- project source ---
|
| 30 |
COPY src/ ./src/
|
| 31 |
COPY tests/fixtures/ ./tests/fixtures/
|
|
|
|
| 32 |
COPY supervisord.conf ./supervisord.conf
|
| 33 |
COPY docker-entrypoint.sh ./docker-entrypoint.sh
|
| 34 |
RUN chmod +x /app/docker-entrypoint.sh
|
|
@@ -53,6 +54,12 @@ RUN mkdir -p data/raw data/processed && \
|
|
| 53 |
COPY tests/fixtures/kb_sample/ ./data/knowledge_base/seed/
|
| 54 |
RUN python -m src.rag.ingest data/knowledge_base data/processed/faiss_index
|
| 55 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
# --- HF Spaces convention ---
|
| 57 |
EXPOSE 7860
|
| 58 |
|
|
|
|
| 29 |
# --- project source ---
|
| 30 |
COPY src/ ./src/
|
| 31 |
COPY tests/fixtures/ ./tests/fixtures/
|
| 32 |
+
COPY scripts/ ./scripts/
|
| 33 |
COPY supervisord.conf ./supervisord.conf
|
| 34 |
COPY docker-entrypoint.sh ./docker-entrypoint.sh
|
| 35 |
RUN chmod +x /app/docker-entrypoint.sh
|
|
|
|
| 54 |
COPY tests/fixtures/kb_sample/ ./data/knowledge_base/seed/
|
| 55 |
RUN python -m src.rag.ingest data/knowledge_base data/processed/faiss_index
|
| 56 |
|
| 57 |
+
# --- Demo-time artifacts (MRI 2D / MRI volumetric ONNX / EEG joblib /
|
| 58 |
+
# clinical TF-IDF RAG / axial PNG fixture). Idempotent script;
|
| 59 |
+
# entrypoint also re-runs it on container start so a mounted-volume
|
| 60 |
+
# deployment can re-seed without a rebuild.
|
| 61 |
+
RUN python scripts/seed_demo_artifacts.py
|
| 62 |
+
|
| 63 |
# --- HF Spaces convention ---
|
| 64 |
EXPOSE 7860
|
| 65 |
|
|
@@ -27,4 +27,8 @@ if [ ! -f data/processed/faiss_index/index.bin ]; then
|
|
| 27 |
python -m src.rag.ingest data/knowledge_base data/processed/faiss_index
|
| 28 |
fi
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
exec "$@"
|
|
|
|
| 27 |
python -m src.rag.ingest data/knowledge_base data/processed/faiss_index
|
| 28 |
fi
|
| 29 |
|
| 30 |
+
# Demo-time stub artifacts (MRI 2D, volumetric ONNX, EEG joblib, clinical
|
| 31 |
+
# TF-IDF RAG, axial PNG). Idempotent — only fills missing ones.
|
| 32 |
+
python scripts/seed_demo_artifacts.py || true
|
| 33 |
+
|
| 34 |
exec "$@"
|
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Seed demo artifacts so every showcase path works without external data.
|
| 2 |
+
|
| 3 |
+
Idempotent — skips any artifact that already exists. Safe to call during
|
| 4 |
+
Docker build OR at container start.
|
| 5 |
+
|
| 6 |
+
Generates:
|
| 7 |
+
- data/processed/mri_dl_2d/best_model.pt (random resnet18 4-class)
|
| 8 |
+
- data/processed/mri_model.onnx (dynamic-D/H/W ONNX, biased toward 'abnormal')
|
| 9 |
+
- data/processed/eeg_clf.joblib (synthetic-separable RandomForest)
|
| 10 |
+
- data/external_rag/index/rag_index.pkl (4-chunk synthetic clinical TF-IDF)
|
| 11 |
+
- tests/fixtures/mri_sample/subject_0_axial.png (axial slice from the bundled NIfTI)
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import sys
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def seed_mri_dl_2d() -> Path:
|
| 20 |
+
out = Path("data/processed/mri_dl_2d/best_model.pt")
|
| 21 |
+
if out.exists():
|
| 22 |
+
return out
|
| 23 |
+
out.parent.mkdir(parents=True, exist_ok=True)
|
| 24 |
+
import torch
|
| 25 |
+
from torchvision import models
|
| 26 |
+
model = models.resnet18(weights=None)
|
| 27 |
+
model.fc = torch.nn.Linear(model.fc.in_features, 4)
|
| 28 |
+
torch.save(model.state_dict(), str(out))
|
| 29 |
+
return out
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def seed_mri_volumetric_onnx() -> Path:
|
| 33 |
+
out = Path("data/processed/mri_model.onnx")
|
| 34 |
+
if out.exists():
|
| 35 |
+
return out
|
| 36 |
+
out.parent.mkdir(parents=True, exist_ok=True)
|
| 37 |
+
import onnx
|
| 38 |
+
from onnx import TensorProto, helper
|
| 39 |
+
|
| 40 |
+
input_info = helper.make_tensor_value_info(
|
| 41 |
+
"input", TensorProto.FLOAT, [1, 1, "D", "H", "W"],
|
| 42 |
+
)
|
| 43 |
+
output_info = helper.make_tensor_value_info("logits", TensorProto.FLOAT, [1, 2])
|
| 44 |
+
value = helper.make_tensor("const_logits", TensorProto.FLOAT, [1, 2], [0.3, 2.1])
|
| 45 |
+
node = helper.make_node("Constant", inputs=[], outputs=["logits"], value=value)
|
| 46 |
+
graph = helper.make_graph([node], "demo_mri_classifier", [input_info], [output_info])
|
| 47 |
+
model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 13)])
|
| 48 |
+
model.ir_version = 10
|
| 49 |
+
onnx.save(model, str(out))
|
| 50 |
+
return out
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def seed_eeg_clf() -> Path:
|
| 54 |
+
out = Path("data/processed/eeg_clf.joblib")
|
| 55 |
+
if out.exists():
|
| 56 |
+
return out
|
| 57 |
+
out.parent.mkdir(parents=True, exist_ok=True)
|
| 58 |
+
import joblib
|
| 59 |
+
import numpy as np
|
| 60 |
+
from sklearn.ensemble import RandomForestClassifier
|
| 61 |
+
|
| 62 |
+
rng = np.random.default_rng(0)
|
| 63 |
+
n_features = 16
|
| 64 |
+
X_ctrl = rng.normal(0.0, 1.0, size=(100, n_features))
|
| 65 |
+
X_alz = rng.normal(2.0, 1.0, size=(100, n_features))
|
| 66 |
+
X = np.vstack([X_ctrl, X_alz])
|
| 67 |
+
y = np.array([0] * 100 + [1] * 100)
|
| 68 |
+
clf = RandomForestClassifier(n_estimators=12, max_depth=6, random_state=0)
|
| 69 |
+
clf.fit(X, y)
|
| 70 |
+
joblib.dump(clf, str(out))
|
| 71 |
+
return out
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def seed_clinical_rag_index() -> Path:
|
| 75 |
+
"""Tiny synthetic clinical TF-IDF index (4 chunks). Replace with the real
|
| 76 |
+
pre-built pickle to upgrade quality without code changes."""
|
| 77 |
+
out = Path("data/external_rag/index/rag_index.pkl")
|
| 78 |
+
if out.exists():
|
| 79 |
+
return out
|
| 80 |
+
out.parent.mkdir(parents=True, exist_ok=True)
|
| 81 |
+
|
| 82 |
+
import pickle
|
| 83 |
+
from datetime import datetime
|
| 84 |
+
from sklearn.feature_extraction.text import TfidfVectorizer
|
| 85 |
+
from src.rag.clinical.types import ClinicalChunk
|
| 86 |
+
|
| 87 |
+
chunks = [
|
| 88 |
+
ClinicalChunk(0, "alzheimers_lifestyle.pdf", 1, 1,
|
| 89 |
+
"Aerobic exercise and Mediterranean diet are associated with reduced cognitive decline in older adults at risk for Alzheimer's disease."),
|
| 90 |
+
ClinicalChunk(1, "parkinsons_motor.pdf", 1, 1,
|
| 91 |
+
"Levodopa remains the most effective symptomatic treatment for motor symptoms of Parkinson's disease."),
|
| 92 |
+
ClinicalChunk(2, "alzheimers_mci.pdf", 2, 2,
|
| 93 |
+
"Mild cognitive impairment may progress to dementia; MMSE and MoCA are standard screening tools."),
|
| 94 |
+
ClinicalChunk(3, "parkinsons_nutrition.pdf", 1, 1,
|
| 95 |
+
"Dietary patterns rich in antioxidants and omega-3 fatty acids are linked to lower Parkinson's risk."),
|
| 96 |
+
]
|
| 97 |
+
vectorizer = TfidfVectorizer(lowercase=True, ngram_range=(1, 2), min_df=1, norm="l2")
|
| 98 |
+
matrix = vectorizer.fit_transform([c.text for c in chunks])
|
| 99 |
+
|
| 100 |
+
payload = {
|
| 101 |
+
"created_at": datetime.now().isoformat(timespec="seconds"),
|
| 102 |
+
"source_dir": str(out.parent),
|
| 103 |
+
"chunk_words": 220,
|
| 104 |
+
"overlap_words": 45,
|
| 105 |
+
"chunks": chunks,
|
| 106 |
+
"vectorizer": vectorizer,
|
| 107 |
+
"matrix": matrix,
|
| 108 |
+
}
|
| 109 |
+
with out.open("wb") as f:
|
| 110 |
+
pickle.dump(payload, f)
|
| 111 |
+
return out
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def seed_axial_png() -> Path:
|
| 115 |
+
"""Axial mid-slice PNG from the bundled NIfTI fixture for the Researcher tab."""
|
| 116 |
+
out = Path("tests/fixtures/mri_sample/subject_0_axial.png")
|
| 117 |
+
if out.exists():
|
| 118 |
+
return out
|
| 119 |
+
out.parent.mkdir(parents=True, exist_ok=True)
|
| 120 |
+
import nibabel as nib
|
| 121 |
+
import numpy as np
|
| 122 |
+
from PIL import Image
|
| 123 |
+
|
| 124 |
+
src = Path("tests/fixtures/mri_sample/subject_0.nii.gz")
|
| 125 |
+
vol = np.asarray(nib.load(str(src)).get_fdata(), dtype=np.float32)
|
| 126 |
+
mid = vol.shape[2] // 2
|
| 127 |
+
slc = vol[:, :, mid]
|
| 128 |
+
norm = (slc - slc.min()) / max(slc.max() - slc.min(), 1e-6)
|
| 129 |
+
Image.fromarray((norm * 255).astype(np.uint8), mode="L").save(str(out))
|
| 130 |
+
return out
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def main() -> int:
|
| 134 |
+
seeds = [
|
| 135 |
+
("MRI 2D resnet18 state_dict", seed_mri_dl_2d),
|
| 136 |
+
("MRI volumetric ONNX", seed_mri_volumetric_onnx),
|
| 137 |
+
("EEG sklearn classifier", seed_eeg_clf),
|
| 138 |
+
("Clinical TF-IDF RAG index", seed_clinical_rag_index),
|
| 139 |
+
("Axial PNG fixture", seed_axial_png),
|
| 140 |
+
]
|
| 141 |
+
print("Seeding demo artifacts...", flush=True)
|
| 142 |
+
for name, fn in seeds:
|
| 143 |
+
try:
|
| 144 |
+
path = fn()
|
| 145 |
+
kb = path.stat().st_size // 1024 if path.is_file() else 0
|
| 146 |
+
print(f" OK {name:35s} {path} ({kb} KB)", flush=True)
|
| 147 |
+
except Exception as e:
|
| 148 |
+
print(f" FAIL {name}: {type(e).__name__}: {e}", flush=True)
|
| 149 |
+
return 1
|
| 150 |
+
print("Done.", flush=True)
|
| 151 |
+
return 0
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
if __name__ == "__main__":
|
| 155 |
+
sys.exit(main())
|
|
@@ -726,8 +726,20 @@ _AGENT_FALLBACK_CHAIN: tuple[str, ...] = (
|
|
| 726 |
)
|
| 727 |
|
| 728 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 729 |
def _pick_working_agent_model(client: Any, candidates: tuple[str, ...]) -> str:
|
| 730 |
-
"""Return the first candidate that responds to a tiny ping; else last one.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 731 |
for m in candidates:
|
| 732 |
try:
|
| 733 |
client.chat.completions.create(
|
|
@@ -736,11 +748,14 @@ def _pick_working_agent_model(client: Any, candidates: tuple[str, ...]) -> str:
|
|
| 736 |
max_tokens=4, temperature=0,
|
| 737 |
)
|
| 738 |
logger.info("agent model selected: %s", m)
|
|
|
|
| 739 |
return m
|
| 740 |
except Exception as e:
|
| 741 |
logger.info("agent model unavailable: %s (%s)", m, type(e).__name__)
|
| 742 |
-
|
| 743 |
-
|
|
|
|
|
|
|
| 744 |
|
| 745 |
|
| 746 |
def _build_orchestrator():
|
|
|
|
| 726 |
)
|
| 727 |
|
| 728 |
|
| 729 |
+
# Cache the chosen model per process so we don't probe on every agent call.
|
| 730 |
+
_AGENT_MODEL_CACHE: dict[str, str] = {}
|
| 731 |
+
|
| 732 |
+
|
| 733 |
def _pick_working_agent_model(client: Any, candidates: tuple[str, ...]) -> str:
|
| 734 |
+
"""Return the first candidate that responds to a tiny ping; else last one.
|
| 735 |
+
|
| 736 |
+
Cached per process — first /agent/run call probes once; subsequent calls
|
| 737 |
+
reuse the picked model. To force a re-probe set NEUROBRIDGE_AGENT_MODEL_CHAIN
|
| 738 |
+
or restart the worker.
|
| 739 |
+
"""
|
| 740 |
+
cache_key = "|".join(candidates)
|
| 741 |
+
if cache_key in _AGENT_MODEL_CACHE:
|
| 742 |
+
return _AGENT_MODEL_CACHE[cache_key]
|
| 743 |
for m in candidates:
|
| 744 |
try:
|
| 745 |
client.chat.completions.create(
|
|
|
|
| 748 |
max_tokens=4, temperature=0,
|
| 749 |
)
|
| 750 |
logger.info("agent model selected: %s", m)
|
| 751 |
+
_AGENT_MODEL_CACHE[cache_key] = m
|
| 752 |
return m
|
| 753 |
except Exception as e:
|
| 754 |
logger.info("agent model unavailable: %s (%s)", m, type(e).__name__)
|
| 755 |
+
fallback = candidates[-1]
|
| 756 |
+
logger.warning("no agent model responded; falling back to %s", fallback)
|
| 757 |
+
_AGENT_MODEL_CACHE[cache_key] = fallback
|
| 758 |
+
return fallback
|
| 759 |
|
| 760 |
|
| 761 |
def _build_orchestrator():
|