Pixedar commited on
Commit
ec06acd
·
verified ·
1 Parent(s): 595424b

Add MindVisualizer explanation quotas

Browse files
Files changed (1) hide show
  1. app.py +342 -239
app.py CHANGED
@@ -1,311 +1,414 @@
1
  import json
2
  import os
3
  import sys
 
 
4
  from functools import lru_cache
5
  from pathlib import Path
6
  from typing import Literal
7
 
8
  import numpy as np
9
- from fastapi import FastAPI, HTTPException
10
- from fastapi.middleware.cors import CORSMiddleware
11
- from fastapi.responses import StreamingResponse
12
- from pydantic import BaseModel, Field
13
-
14
-
15
- FieldMode = Literal["mean", "gated", "mu1", "mu2", "mu3", "mu4", "mu5", "mu6"]
16
-
17
-
18
- class TrajectoryRequest(BaseModel):
19
- seed: list[float] = Field(default_factory=lambda: [0.5, 0.5, 0.5], min_length=3, max_length=3)
20
- fieldMode: FieldMode = "mean"
21
- steps: int = Field(default=700, ge=8, le=1600)
22
- dt: float = Field(default=1.0, gt=0, le=8.0)
23
- speedScale: float = Field(default=1.0, gt=0, le=8.0)
24
-
25
-
26
  class ExplainRequest(BaseModel):
27
  trajectory: list[list[float]] = Field(min_length=2)
28
  fieldMode: FieldMode = "mean"
29
  callLlm: bool = True
30
 
31
 
32
- def mindvis_repo() -> Path:
33
- repo = Path(os.environ.get("MINDVIS_REPO", "/app/mindVisualizer")).resolve()
34
- if not repo.exists():
35
- raise RuntimeError(f"MINDVIS_REPO does not exist: {repo}")
36
- if str(repo) not in sys.path:
37
- sys.path.insert(0, str(repo))
38
- return repo
39
-
40
-
41
- @lru_cache(maxsize=1)
42
- def services():
43
- repo = mindvis_repo()
44
- data = repo / "data"
45
-
46
- from src.field_loader import TriLinearSampler, load_field
47
- from src.mesh_overlay import FlowMeshOverlay
48
-
49
- flow = load_field(data / "mdn" / "mdn_particles_rdcim_teacher_edge_hq_grid125_meta.json")
50
- mean = flow["mean"]
51
- mus = flow["mus"]
52
- pi = flow["pi"]
53
- ent = flow["ENT"]
54
- amin = flow["amin"]
55
- amax = flow["amax"]
56
- span = amax - amin
57
- diag = float(np.linalg.norm(span))
58
- target_step = 0.01 * max(diag, 1e-6)
59
-
60
- winner = np.argmax(pi, axis=-1)
61
- gated = np.zeros_like(mean, dtype=np.float32)
62
- for idx, mu in enumerate(mus):
63
- gated += mu * (winner[..., None] == idx).astype(np.float32)
64
-
65
- fields = {"mean": mean, "gated": gated}
66
- fields.update({f"mu{idx + 1}": mu for idx, mu in enumerate(mus)})
67
- samplers = {name: TriLinearSampler(field, amin, amax) for name, field in fields.items()}
68
-
69
- def vmax(field):
70
- mag = np.linalg.norm(field.reshape(-1, 3), axis=1)
71
- nz = mag[mag > 0]
72
- return float(np.percentile(nz, 100.0)) if nz.size else 1.0
73
-
74
- vmax_by_name = {name: vmax(field) for name, field in fields.items()}
75
-
76
- # The original Python app uses FlowMeshOverlay + its cached label grid for
77
- # probe interpretation. This API intentionally reuses that path.
78
- import vtk
79
-
80
- ren = vtk.vtkRenderer()
81
- win = vtk.vtkRenderWindow()
82
- win.OffScreenRenderingOn()
83
- win.AddRenderer(ren)
84
- mesh = FlowMeshOverlay(ren=ren, win=win, mesh_dir=data / "meshes", alignment_file=data / "brain_alignment.json")
85
- cache = data / "label_grid_cache.npz"
86
- if not mesh.load_label_grid(cache):
87
- raise RuntimeError("Python label_grid_cache is missing; run setup_brain_data.py in mindVisualizer first.")
88
-
89
- # Optional finer parcellation, exactly as the desktop flow mode auto-detects.
90
- try:
91
- from src.extra_parcellation import ExtraParcellation
92
-
93
- atlas = data / "extra_parcellation" / "combined_atlas.nii.gz"
94
- labels = data / "extra_parcellation" / "combined_atlas_labels.json"
95
- if atlas.exists():
96
- extra = ExtraParcellation(atlas, labels if labels.exists() else None)
97
- if extra.load():
98
- mesh.set_extra_parcellation(extra)
99
- except Exception as exc:
100
- print(f"[extra-parcellation] disabled: {exc}")
101
-
102
- ent_sampler = TriLinearSampler(mean, amin, amax) if ent is not None else None
103
-
104
- return {
105
- "amin": amin,
106
- "amax": amax,
107
- "span": span,
108
- "samplers": samplers,
109
- "vmax": vmax_by_name,
110
- "target_step": target_step,
111
- "mesh": mesh,
112
- "entropy": ent,
113
- "entropy_sampler": ent_sampler,
114
- }
115
-
116
-
117
- app = FastAPI(title="MindVisualizer API", version="1.0.0")
118
-
119
- origins = [origin.strip() for origin in os.environ.get("CORS_ORIGINS", "*").split(",") if origin.strip()]
120
- app.add_middleware(
121
- CORSMiddleware,
122
- allow_origins=origins,
123
- allow_methods=["GET", "POST", "OPTIONS"],
124
- allow_headers=["*"],
125
- )
126
-
127
-
128
- @app.get("/health")
129
- def health():
130
- try:
131
- svc = services()
132
- return {"ok": True, "fieldMin": svc["amin"].tolist(), "fieldMax": svc["amax"].tolist()}
133
- except Exception as exc:
134
- raise HTTPException(status_code=503, detail=str(exc)) from exc
135
-
136
-
137
- @app.post("/api/mindvis/trajectory")
138
- def trajectory(req: TrajectoryRequest):
139
- svc = services()
140
- world, mags, stopped = integrate_probe(req, svc)
141
- transitions = analyze_regions(world, mags, req.callLlm if hasattr(req, "callLlm") else False)
142
- return {
143
- "fieldMode": req.fieldMode,
144
- "trajectory": normalized(world, svc["amin"], svc["span"]).tolist(),
145
- "worldTrajectory": world.tolist(),
146
- "fieldMagnitudes": mags.tolist(),
147
- "stopped": stopped,
148
- "transitions": transitions,
149
- "regionText": format_transition_text(transitions),
150
- }
151
-
152
-
 
 
 
 
153
  @app.post("/api/mindvis/explain")
154
- def explain(req: ExplainRequest):
155
- return build_explanation(req)
156
 
157
 
158
  @app.post("/api/mindvis/explain/stream")
159
- def explain_stream(req: ExplainRequest):
160
  def event_stream():
161
  def event(payload: dict) -> str:
162
  return json.dumps(payload) + "\n"
163
 
164
  try:
 
 
 
 
 
 
165
  yield event({"type": "log", "message": "Loading MindVisualizer field samplers and brain label grid."})
166
  svc = services()
167
  traj = np.asarray(req.trajectory, dtype=np.float32)
168
  if traj.ndim != 2 or traj.shape[1] != 3:
169
  raise HTTPException(status_code=400, detail="trajectory must be an array of [x,y,z] points")
170
-
171
- yield event({"type": "log", "message": f"Received {len(traj)} normalized probe samples in {req.fieldMode} mode."})
172
- world = denormalized(np.clip(traj, 0.0, 1.0), svc["amin"], svc["span"])
173
- sampler = svc["samplers"].get(req.fieldMode, svc["samplers"]["mean"])
174
- mags = np.linalg.norm(sampler.sample_vec(world), axis=1).astype(np.float32)
175
-
176
- yield event({"type": "log", "message": "Sampling flow strength and entropy along the traveled path."})
177
- transitions = analyze_regions(world, mags, req.callLlm)
178
- yield event({"type": "log", "message": f"Detected {len(transitions)} entered or nearby anatomical regions."})
179
 
180
  text = format_transition_text(transitions)
181
  if req.callLlm:
 
182
  from src.region_analyzer import analyze_with_gpt
183
 
184
  yield event({"type": "log", "message": "Calling the OpenAI model for the neuroscience interpretation."})
185
  text = analyze_with_gpt(
186
  transitions,
187
- use_rag=os.environ.get("MINDVIS_USE_RAG", "0") == "1",
188
  model=os.environ.get("OPENAI_MODEL", "gpt-5.4-mini"),
189
  debug=os.environ.get("MINDVIS_DEBUG", "0") == "1",
190
  )
 
191
  yield event({"type": "log", "message": "LLM interpretation finished."})
192
 
193
- yield event({"type": "result", "text": text, "transitions": transitions, "regionText": format_transition_text(transitions)})
 
 
 
 
 
194
  except Exception as exc:
195
  yield event({"type": "error", "message": str(exc)})
196
 
197
  return StreamingResponse(event_stream(), media_type="application/x-ndjson")
198
 
199
 
200
- def build_explanation(req: ExplainRequest, log=None):
 
 
 
 
201
  log = log or (lambda _message: None)
202
  log("Loading MindVisualizer field samplers and brain label grid.")
203
  svc = services()
204
- traj = np.asarray(req.trajectory, dtype=np.float32)
205
- if traj.ndim != 2 or traj.shape[1] != 3:
206
- raise HTTPException(status_code=400, detail="trajectory must be an array of [x,y,z] points")
207
-
208
- log(f"Received {len(traj)} normalized probe samples in {req.fieldMode} mode.")
209
- world = denormalized(np.clip(traj, 0.0, 1.0), svc["amin"], svc["span"])
210
- sampler = svc["samplers"].get(req.fieldMode, svc["samplers"]["mean"])
211
- mags = np.linalg.norm(sampler.sample_vec(world), axis=1).astype(np.float32)
212
- log("Sampling flow strength and entropy along the traveled path.")
213
- transitions = analyze_regions(world, mags, req.callLlm)
214
- log(f"Detected {len(transitions)} entered or nearby anatomical regions.")
215
- text = format_transition_text(transitions)
216
 
217
  if req.callLlm:
 
 
218
  from src.region_analyzer import analyze_with_gpt
219
 
220
  log("Calling the OpenAI model for the neuroscience interpretation.")
221
  text = analyze_with_gpt(
222
  transitions,
223
- use_rag=os.environ.get("MINDVIS_USE_RAG", "0") == "1",
224
  model=os.environ.get("OPENAI_MODEL", "gpt-5.4-mini"),
225
  debug=os.environ.get("MINDVIS_DEBUG", "0") == "1",
226
  )
 
227
  log("LLM interpretation finished.")
228
 
229
- return {"text": text, "transitions": transitions, "regionText": format_transition_text(transitions)}
230
-
231
-
232
- def integrate_probe(req: TrajectoryRequest, svc: dict) -> tuple[np.ndarray, np.ndarray, bool]:
233
- p = denormalized(np.asarray(req.seed[:3], dtype=np.float32)[None, :], svc["amin"], svc["span"])
234
- sampler = svc["samplers"].get(req.fieldMode, svc["samplers"]["mean"])
235
- step = req.dt * req.speedScale * (svc["target_step"] / max(svc["vmax"].get(req.fieldMode, 1.0), 1e-9))
236
- path = []
237
- mags = []
238
- stopped = False
239
 
240
- for _ in range(req.steps):
241
- path.append(p[0].copy())
242
- velocity = sampler.sample_vec(p)[0]
243
- mags.append(float(np.linalg.norm(velocity)))
244
- new_pos = np.clip(p[0] + velocity * step, svc["amin"], svc["amax"])
245
 
246
- # Original probe boundary behavior: if a step leaves labelled brain
247
- # space, try half-step; otherwise stop instead of wrapping.
248
- if not is_valid_probe_position(new_pos, svc["mesh"]):
249
- half = np.clip(p[0] + velocity * step * 0.5, svc["amin"], svc["amax"])
250
- if is_valid_probe_position(half, svc["mesh"]):
251
- new_pos = half
252
- else:
253
- stopped = True
254
- break
255
 
256
- if np.linalg.norm(new_pos - p[0]) < 1e-5:
257
- stopped = True
258
- break
259
- p[0] = new_pos.astype(np.float32)
260
 
261
- return np.asarray(path, dtype=np.float32), np.asarray(mags, dtype=np.float32), stopped
262
-
263
-
264
- def is_valid_probe_position(point: np.ndarray, mesh) -> bool:
265
- key = mesh.get_region_at_point(point)
266
- if key is None:
267
- key = mesh.find_nearest_region(point, search_radius=2, max_distance_mm=5.0)
268
- return key is not None
269
-
270
-
271
- def analyze_regions(world: np.ndarray, mags: np.ndarray, call_llm: bool) -> list[dict]:
272
- svc = services()
273
- from src.region_analyzer import analyze_probe_path
274
-
275
- transitions = analyze_probe_path(
276
- world,
277
- svc["mesh"],
278
- sample_every=5,
279
- field_mags=mags,
280
- entropy_sampler=svc["entropy_sampler"],
281
- entropy_field=svc["entropy"],
282
- )
283
- return make_json_safe(transitions)
284
-
285
-
286
- def format_transition_text(transitions: list[dict]) -> str:
287
- from src.region_analyzer import format_transitions_text
288
-
289
- return format_transitions_text(transitions)
290
 
291
 
292
- def denormalized(points: np.ndarray, amin: np.ndarray, span: np.ndarray) -> np.ndarray:
293
- return points.astype(np.float32) * span[None, :] + amin[None, :]
 
 
 
 
 
294
 
295
 
296
- def normalized(points: np.ndarray, amin: np.ndarray, span: np.ndarray) -> np.ndarray:
297
- return (points.astype(np.float32) - amin[None, :]) / span[None, :]
 
 
 
298
 
299
 
300
- def make_json_safe(value):
301
- if isinstance(value, np.ndarray):
302
- return value.tolist()
303
- if isinstance(value, np.generic):
304
- return value.item()
305
- if isinstance(value, dict):
306
- return {str(k): make_json_safe(v) for k, v in value.items() if not str(k).startswith("_")}
307
- if isinstance(value, list):
308
- return [make_json_safe(v) for v in value]
309
- if isinstance(value, tuple):
310
- return [make_json_safe(v) for v in value]
311
- return value
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import json
2
  import os
3
  import sys
4
+ import hashlib
5
+ from datetime import datetime, timezone
6
  from functools import lru_cache
7
  from pathlib import Path
8
  from typing import Literal
9
 
10
  import numpy as np
11
+ from fastapi import FastAPI, HTTPException, Request
12
+ from fastapi.middleware.cors import CORSMiddleware
13
+ from fastapi.responses import StreamingResponse
14
+ from pydantic import BaseModel, Field
15
+
16
+
17
+ FieldMode = Literal["mean", "gated", "mu1", "mu2", "mu3", "mu4", "mu5", "mu6"]
18
+
19
+
20
+ class TrajectoryRequest(BaseModel):
21
+ seed: list[float] = Field(default_factory=lambda: [0.5, 0.5, 0.5], min_length=3, max_length=3)
22
+ fieldMode: FieldMode = "mean"
23
+ steps: int = Field(default=700, ge=8, le=1600)
24
+ dt: float = Field(default=1.0, gt=0, le=8.0)
25
+ speedScale: float = Field(default=1.0, gt=0, le=8.0)
26
+
27
+
28
  class ExplainRequest(BaseModel):
29
  trajectory: list[list[float]] = Field(min_length=2)
30
  fieldMode: FieldMode = "mean"
31
  callLlm: bool = True
32
 
33
 
34
+ CACHE_DIR = Path(os.environ.get("MINDVIS_CACHE_DIR", "/tmp/mindvis-api-cache"))
35
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
36
+
37
+
38
+ def mindvis_repo() -> Path:
39
+ repo = Path(os.environ.get("MINDVIS_REPO", "/app/mindVisualizer")).resolve()
40
+ if not repo.exists():
41
+ raise RuntimeError(f"MINDVIS_REPO does not exist: {repo}")
42
+ if str(repo) not in sys.path:
43
+ sys.path.insert(0, str(repo))
44
+ return repo
45
+
46
+
47
+ @lru_cache(maxsize=1)
48
+ def services():
49
+ repo = mindvis_repo()
50
+ data = repo / "data"
51
+
52
+ from src.field_loader import TriLinearSampler, load_field
53
+ from src.mesh_overlay import FlowMeshOverlay
54
+
55
+ flow = load_field(data / "mdn" / "mdn_particles_rdcim_teacher_edge_hq_grid125_meta.json")
56
+ mean = flow["mean"]
57
+ mus = flow["mus"]
58
+ pi = flow["pi"]
59
+ ent = flow["ENT"]
60
+ amin = flow["amin"]
61
+ amax = flow["amax"]
62
+ span = amax - amin
63
+ diag = float(np.linalg.norm(span))
64
+ target_step = 0.01 * max(diag, 1e-6)
65
+
66
+ winner = np.argmax(pi, axis=-1)
67
+ gated = np.zeros_like(mean, dtype=np.float32)
68
+ for idx, mu in enumerate(mus):
69
+ gated += mu * (winner[..., None] == idx).astype(np.float32)
70
+
71
+ fields = {"mean": mean, "gated": gated}
72
+ fields.update({f"mu{idx + 1}": mu for idx, mu in enumerate(mus)})
73
+ samplers = {name: TriLinearSampler(field, amin, amax) for name, field in fields.items()}
74
+
75
+ def vmax(field):
76
+ mag = np.linalg.norm(field.reshape(-1, 3), axis=1)
77
+ nz = mag[mag > 0]
78
+ return float(np.percentile(nz, 100.0)) if nz.size else 1.0
79
+
80
+ vmax_by_name = {name: vmax(field) for name, field in fields.items()}
81
+
82
+ # The original Python app uses FlowMeshOverlay + its cached label grid for
83
+ # probe interpretation. This API intentionally reuses that path.
84
+ import vtk
85
+
86
+ ren = vtk.vtkRenderer()
87
+ win = vtk.vtkRenderWindow()
88
+ win.OffScreenRenderingOn()
89
+ win.AddRenderer(ren)
90
+ mesh = FlowMeshOverlay(ren=ren, win=win, mesh_dir=data / "meshes", alignment_file=data / "brain_alignment.json")
91
+ cache = data / "label_grid_cache.npz"
92
+ if not mesh.load_label_grid(cache):
93
+ raise RuntimeError("Python label_grid_cache is missing; run setup_brain_data.py in mindVisualizer first.")
94
+
95
+ # Optional finer parcellation, exactly as the desktop flow mode auto-detects.
96
+ try:
97
+ from src.extra_parcellation import ExtraParcellation
98
+
99
+ atlas = data / "extra_parcellation" / "combined_atlas.nii.gz"
100
+ labels = data / "extra_parcellation" / "combined_atlas_labels.json"
101
+ if atlas.exists():
102
+ extra = ExtraParcellation(atlas, labels if labels.exists() else None)
103
+ if extra.load():
104
+ mesh.set_extra_parcellation(extra)
105
+ except Exception as exc:
106
+ print(f"[extra-parcellation] disabled: {exc}")
107
+
108
+ ent_sampler = TriLinearSampler(mean, amin, amax) if ent is not None else None
109
+
110
+ return {
111
+ "amin": amin,
112
+ "amax": amax,
113
+ "span": span,
114
+ "samplers": samplers,
115
+ "vmax": vmax_by_name,
116
+ "target_step": target_step,
117
+ "mesh": mesh,
118
+ "entropy": ent,
119
+ "entropy_sampler": ent_sampler,
120
+ }
121
+
122
+
123
+ app = FastAPI(title="MindVisualizer API", version="1.0.0")
124
+
125
+ origins = [origin.strip() for origin in os.environ.get("CORS_ORIGINS", "*").split(",") if origin.strip()]
126
+ app.add_middleware(
127
+ CORSMiddleware,
128
+ allow_origins=origins,
129
+ allow_methods=["GET", "POST", "OPTIONS"],
130
+ allow_headers=["*"],
131
+ )
132
+
133
+
134
+ @app.get("/health")
135
+ def health():
136
+ try:
137
+ svc = services()
138
+ return {"ok": True, "fieldMin": svc["amin"].tolist(), "fieldMax": svc["amax"].tolist()}
139
+ except Exception as exc:
140
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
141
+
142
+
143
+ @app.post("/api/mindvis/trajectory")
144
+ def trajectory(req: TrajectoryRequest):
145
+ svc = services()
146
+ world, mags, stopped = integrate_probe(req, svc)
147
+ transitions = analyze_regions(world, mags, req.callLlm if hasattr(req, "callLlm") else False)
148
+ return {
149
+ "fieldMode": req.fieldMode,
150
+ "trajectory": normalized(world, svc["amin"], svc["span"]).tolist(),
151
+ "worldTrajectory": world.tolist(),
152
+ "fieldMagnitudes": mags.tolist(),
153
+ "stopped": stopped,
154
+ "transitions": transitions,
155
+ "regionText": format_transition_text(transitions),
156
+ }
157
+
158
+
159
  @app.post("/api/mindvis/explain")
160
+ def explain(req: ExplainRequest, request: Request):
161
+ return build_explanation(req, request=request)
162
 
163
 
164
  @app.post("/api/mindvis/explain/stream")
165
+ def explain_stream(req: ExplainRequest, request: Request):
166
  def event_stream():
167
  def event(payload: dict) -> str:
168
  return json.dumps(payload) + "\n"
169
 
170
  try:
171
+ cached = cached_explanation(req)
172
+ if cached is not None:
173
+ yield event({"type": "log", "message": "Returning a cached path explanation."})
174
+ yield event({"type": "result", **cached})
175
+ return
176
+
177
  yield event({"type": "log", "message": "Loading MindVisualizer field samplers and brain label grid."})
178
  svc = services()
179
  traj = np.asarray(req.trajectory, dtype=np.float32)
180
  if traj.ndim != 2 or traj.shape[1] != 3:
181
  raise HTTPException(status_code=400, detail="trajectory must be an array of [x,y,z] points")
182
+
183
+ yield event({"type": "log", "message": f"Received {len(traj)} normalized probe samples in {req.fieldMode} mode."})
184
+ world = denormalized(np.clip(traj, 0.0, 1.0), svc["amin"], svc["span"])
185
+ sampler = svc["samplers"].get(req.fieldMode, svc["samplers"]["mean"])
186
+ mags = np.linalg.norm(sampler.sample_vec(world), axis=1).astype(np.float32)
187
+
188
+ yield event({"type": "log", "message": "Sampling flow strength and entropy along the traveled path."})
189
+ transitions = analyze_regions(world, mags, req.callLlm)
190
+ yield event({"type": "log", "message": f"Detected {len(transitions)} entered or nearby anatomical regions."})
191
 
192
  text = format_transition_text(transitions)
193
  if req.callLlm:
194
+ ensure_free_budget(request)
195
  from src.region_analyzer import analyze_with_gpt
196
 
197
  yield event({"type": "log", "message": "Calling the OpenAI model for the neuroscience interpretation."})
198
  text = analyze_with_gpt(
199
  transitions,
200
+ use_rag=os.environ.get("MINDVIS_USE_RAG", "0") == "1",
201
  model=os.environ.get("OPENAI_MODEL", "gpt-5.4-mini"),
202
  debug=os.environ.get("MINDVIS_DEBUG", "0") == "1",
203
  )
204
+ ensure_not_budget_error(text)
205
  yield event({"type": "log", "message": "LLM interpretation finished."})
206
 
207
+ result = {"text": text, "transitions": transitions, "regionText": format_transition_text(transitions)}
208
+ write_explanation_cache(req, result)
209
+ yield event({"type": "result", **result})
210
+ except HTTPException as exc:
211
+ detail = exc.detail if isinstance(exc.detail, dict) else {"error": str(exc.detail)}
212
+ yield event({"type": "error", "status": exc.status_code, **detail})
213
  except Exception as exc:
214
  yield event({"type": "error", "message": str(exc)})
215
 
216
  return StreamingResponse(event_stream(), media_type="application/x-ndjson")
217
 
218
 
219
+ def build_explanation(req: ExplainRequest, log=None, request: Request | None = None):
220
+ cached = cached_explanation(req)
221
+ if cached is not None:
222
+ return cached
223
+
224
  log = log or (lambda _message: None)
225
  log("Loading MindVisualizer field samplers and brain label grid.")
226
  svc = services()
227
+ traj = np.asarray(req.trajectory, dtype=np.float32)
228
+ if traj.ndim != 2 or traj.shape[1] != 3:
229
+ raise HTTPException(status_code=400, detail="trajectory must be an array of [x,y,z] points")
230
+
231
+ log(f"Received {len(traj)} normalized probe samples in {req.fieldMode} mode.")
232
+ world = denormalized(np.clip(traj, 0.0, 1.0), svc["amin"], svc["span"])
233
+ sampler = svc["samplers"].get(req.fieldMode, svc["samplers"]["mean"])
234
+ mags = np.linalg.norm(sampler.sample_vec(world), axis=1).astype(np.float32)
235
+ log("Sampling flow strength and entropy along the traveled path.")
236
+ transitions = analyze_regions(world, mags, req.callLlm)
237
+ log(f"Detected {len(transitions)} entered or nearby anatomical regions.")
238
+ text = format_transition_text(transitions)
239
 
240
  if req.callLlm:
241
+ if request is not None:
242
+ ensure_free_budget(request)
243
  from src.region_analyzer import analyze_with_gpt
244
 
245
  log("Calling the OpenAI model for the neuroscience interpretation.")
246
  text = analyze_with_gpt(
247
  transitions,
248
+ use_rag=os.environ.get("MINDVIS_USE_RAG", "0") == "1",
249
  model=os.environ.get("OPENAI_MODEL", "gpt-5.4-mini"),
250
  debug=os.environ.get("MINDVIS_DEBUG", "0") == "1",
251
  )
252
+ ensure_not_budget_error(text)
253
  log("LLM interpretation finished.")
254
 
255
+ result = {"text": text, "transitions": transitions, "regionText": format_transition_text(transitions)}
256
+ write_explanation_cache(req, result)
257
+ return result
 
 
 
 
 
 
 
258
 
 
 
 
 
 
259
 
260
+ def cache_key(req: ExplainRequest) -> Path:
261
+ payload = req.model_dump()
262
+ digest = hashlib.sha256(json.dumps(payload, sort_keys=True).encode("utf-8")).hexdigest()
263
+ return CACHE_DIR / f"mindvis-{digest}.json"
 
 
 
 
 
264
 
 
 
 
 
265
 
266
+ def cached_explanation(req: ExplainRequest) -> dict | None:
267
+ path = cache_key(req)
268
+ if not path.exists():
269
+ return None
270
+ try:
271
+ return json.loads(path.read_text(encoding="utf-8"))
272
+ except Exception:
273
+ return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
274
 
275
 
276
+ def write_explanation_cache(req: ExplainRequest, result: dict) -> None:
277
+ if not req.callLlm:
278
+ return
279
+ try:
280
+ cache_key(req).write_text(json.dumps(result, ensure_ascii=False), encoding="utf-8")
281
+ except Exception:
282
+ pass
283
 
284
 
285
+ def ensure_free_budget(request: Request) -> None:
286
+ if not client_budget_available(request):
287
+ raise HTTPException(status_code=429, detail={"error": "client_free_limit_exhausted"})
288
+ if not daily_budget_available():
289
+ raise HTTPException(status_code=429, detail={"error": "budget_exhausted"})
290
 
291
 
292
+ def daily_budget_available() -> bool:
293
+ limit = int(os.environ.get("MINDVIS_DAILY_REQUEST_LIMIT", "80"))
294
+ if limit <= 0:
295
+ return False
296
+ today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
297
+ counter_path = CACHE_DIR / f"counter-{today}.txt"
298
+ try:
299
+ current = int(counter_path.read_text()) if counter_path.exists() else 0
300
+ except Exception:
301
+ current = 0
302
+ if current >= limit:
303
+ return False
304
+ counter_path.write_text(str(current + 1))
305
+ return True
306
+
307
+
308
+ def client_budget_available(request: Request) -> bool:
309
+ limit = int(os.environ.get("MINDVIS_FREE_EXPLAINS_PER_CLIENT", "2"))
310
+ if limit <= 0:
311
+ return False
312
+ forwarded_for = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
313
+ host = forwarded_for or (request.client.host if request.client else "unknown")
314
+ user_agent = request.headers.get("user-agent", "unknown")[:180]
315
+ client_id = request.headers.get("x-mindvis-client-id", "unknown")[:120]
316
+ client_hash = hashlib.sha256(f"{host}|{user_agent}|{client_id}".encode("utf-8")).hexdigest()[:16]
317
+ today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
318
+ counter_path = CACHE_DIR / f"client-{today}-{client_hash}.txt"
319
+ try:
320
+ current = int(counter_path.read_text()) if counter_path.exists() else 0
321
+ except Exception:
322
+ current = 0
323
+ if current >= limit:
324
+ return False
325
+ counter_path.write_text(str(current + 1))
326
+ return True
327
+
328
+
329
+ def ensure_not_budget_error(text: str) -> None:
330
+ msg = (text or "").lower()
331
+ if any(token in msg for token in ["insufficient_quota", "quota", "billing", "rate limit", "[gpt error]"]):
332
+ raise HTTPException(status_code=429, detail={"error": "openai_budget_exhausted"})
333
+
334
+
335
+ def integrate_probe(req: TrajectoryRequest, svc: dict) -> tuple[np.ndarray, np.ndarray, bool]:
336
+ p = denormalized(np.asarray(req.seed[:3], dtype=np.float32)[None, :], svc["amin"], svc["span"])
337
+ sampler = svc["samplers"].get(req.fieldMode, svc["samplers"]["mean"])
338
+ step = req.dt * req.speedScale * (svc["target_step"] / max(svc["vmax"].get(req.fieldMode, 1.0), 1e-9))
339
+ path = []
340
+ mags = []
341
+ stopped = False
342
+
343
+ for _ in range(req.steps):
344
+ path.append(p[0].copy())
345
+ velocity = sampler.sample_vec(p)[0]
346
+ mags.append(float(np.linalg.norm(velocity)))
347
+ new_pos = np.clip(p[0] + velocity * step, svc["amin"], svc["amax"])
348
+
349
+ # Original probe boundary behavior: if a step leaves labelled brain
350
+ # space, try half-step; otherwise stop instead of wrapping.
351
+ if not is_valid_probe_position(new_pos, svc["mesh"]):
352
+ half = np.clip(p[0] + velocity * step * 0.5, svc["amin"], svc["amax"])
353
+ if is_valid_probe_position(half, svc["mesh"]):
354
+ new_pos = half
355
+ else:
356
+ stopped = True
357
+ break
358
+
359
+ if np.linalg.norm(new_pos - p[0]) < 1e-5:
360
+ stopped = True
361
+ break
362
+ p[0] = new_pos.astype(np.float32)
363
+
364
+ return np.asarray(path, dtype=np.float32), np.asarray(mags, dtype=np.float32), stopped
365
+
366
+
367
+ def is_valid_probe_position(point: np.ndarray, mesh) -> bool:
368
+ key = mesh.get_region_at_point(point)
369
+ if key is None:
370
+ key = mesh.find_nearest_region(point, search_radius=2, max_distance_mm=5.0)
371
+ return key is not None
372
+
373
+
374
+ def analyze_regions(world: np.ndarray, mags: np.ndarray, call_llm: bool) -> list[dict]:
375
+ svc = services()
376
+ from src.region_analyzer import analyze_probe_path
377
+
378
+ transitions = analyze_probe_path(
379
+ world,
380
+ svc["mesh"],
381
+ sample_every=5,
382
+ field_mags=mags,
383
+ entropy_sampler=svc["entropy_sampler"],
384
+ entropy_field=svc["entropy"],
385
+ )
386
+ return make_json_safe(transitions)
387
+
388
+
389
+ def format_transition_text(transitions: list[dict]) -> str:
390
+ from src.region_analyzer import format_transitions_text
391
+
392
+ return format_transitions_text(transitions)
393
+
394
+
395
+ def denormalized(points: np.ndarray, amin: np.ndarray, span: np.ndarray) -> np.ndarray:
396
+ return points.astype(np.float32) * span[None, :] + amin[None, :]
397
+
398
+
399
+ def normalized(points: np.ndarray, amin: np.ndarray, span: np.ndarray) -> np.ndarray:
400
+ return (points.astype(np.float32) - amin[None, :]) / span[None, :]
401
+
402
+
403
+ def make_json_safe(value):
404
+ if isinstance(value, np.ndarray):
405
+ return value.tolist()
406
+ if isinstance(value, np.generic):
407
+ return value.item()
408
+ if isinstance(value, dict):
409
+ return {str(k): make_json_safe(v) for k, v in value.items() if not str(k).startswith("_")}
410
+ if isinstance(value, list):
411
+ return [make_json_safe(v) for v in value]
412
+ if isinstance(value, tuple):
413
+ return [make_json_safe(v) for v in value]
414
+ return value