mekosotto Claude Opus 4.7 (1M context) commited on
Commit
c00e850
·
1 Parent(s): cc1c9fc

feat(deploy): demo-artifact seeding for Hugging Face Space build + entrypoint

Browse files

HF 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 CHANGED
@@ -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
 
Dockerfile.hf CHANGED
@@ -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
 
docker-entrypoint.sh CHANGED
@@ -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 "$@"
scripts/seed_demo_artifacts.py ADDED
@@ -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())
src/api/routes.py CHANGED
@@ -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
- logger.warning("no agent model responded; falling back to %s", candidates[-1])
743
- return candidates[-1]
 
 
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():