iints-sdk-python35 1.5.26__py3-none-any.whl → 1.5.30__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. iints/__init__.py +1 -1
  2. iints/ai/backends/ollama.py +8 -9
  3. iints/analysis/run_quality.py +475 -0
  4. iints/cli/cli.py +32 -14
  5. iints/core/formula_registry.py +20 -11
  6. iints/core/patient/advanced_metabolic_model.py +12 -2
  7. iints/core/patient/bergman_model.py +8 -2
  8. iints/core/patient/hovorka_model.py +8 -6
  9. iints/core/patient/patient_factory.py +48 -9
  10. iints/core/simulator.py +4 -3
  11. iints/core/supervisor.py +6 -2
  12. iints/data/virtual_patients/reference_free_living_t1d.yaml +2 -2
  13. iints/governance/__init__.py +17 -0
  14. iints/governance/research_policy.py +199 -0
  15. iints/research/alphafold_engine.py +23 -13
  16. iints/research/genomics_engine.py +71 -23
  17. iints/research/stem_cell_optimizer.py +147 -26
  18. iints/research/stem_cell_transplant.py +370 -0
  19. iints/research/tissue_stressor.py +70 -40
  20. iints/validation/schemas.py +6 -0
  21. iints/visualization/cockpit.py +8 -1
  22. iints_desktop/evidence_connectors.py +173 -0
  23. iints_desktop/local_ai.py +17 -2
  24. iints_desktop/qt_app.py +155 -67
  25. iints_desktop/tauri_bridge.py +439 -0
  26. iints_desktop/terminal_utils.py +51 -25
  27. iints_desktop/update.py +80 -0
  28. {iints_sdk_python35-1.5.26.dist-info → iints_sdk_python35-1.5.30.dist-info}/METADATA +16 -12
  29. {iints_sdk_python35-1.5.26.dist-info → iints_sdk_python35-1.5.30.dist-info}/RECORD +35 -29
  30. {iints_sdk_python35-1.5.26.dist-info → iints_sdk_python35-1.5.30.dist-info}/WHEEL +1 -1
  31. {iints_sdk_python35-1.5.26.dist-info → iints_sdk_python35-1.5.30.dist-info}/entry_points.txt +1 -0
  32. {iints_sdk_python35-1.5.26.dist-info → iints_sdk_python35-1.5.30.dist-info}/licenses/LICENSE +0 -0
  33. {iints_sdk_python35-1.5.26.dist-info → iints_sdk_python35-1.5.30.dist-info}/licenses/LICENSE-MIT-IINTS-LEGACY +0 -0
  34. {iints_sdk_python35-1.5.26.dist-info → iints_sdk_python35-1.5.30.dist-info}/licenses/NOTICE +0 -0
  35. {iints_sdk_python35-1.5.26.dist-info → iints_sdk_python35-1.5.30.dist-info}/top_level.txt +0 -0
iints/__init__.py CHANGED
@@ -11,7 +11,7 @@ except ImportError: # pragma: no cover - Python < 3.8 fallback
11
11
  try:
12
12
  __version__ = version("iints-sdk-python35")
13
13
  except PackageNotFoundError: # pragma: no cover - source tree fallback
14
- __version__ = "1.5.26"
14
+ __version__ = "1.5.30"
15
15
 
16
16
  # Note to developers: this SDK is currently maintained by a single author.
17
17
  # Please report bugs via GitHub issues and feel free to contribute fixes via PRs.
@@ -234,15 +234,14 @@ class OllamaBackend:
234
234
  resolved = installed_lookup.get(alias.lower())
235
235
  if resolved is not None:
236
236
  return resolved
237
-
238
- for installed_name in installed:
239
- lowered = installed_name.lower()
240
- if "ministral-3" in lowered and "8b" in lowered:
241
- return installed_name
242
- for installed_name in installed:
243
- lowered = installed_name.lower()
244
- if "ministral" in lowered and "8b" in lowered:
245
- return installed_name
237
+ for installed_name in installed:
238
+ lowered = installed_name.lower()
239
+ if "ministral-3" in lowered and "8b" in lowered:
240
+ return installed_name
241
+ for installed_name in installed:
242
+ lowered = installed_name.lower()
243
+ if "ministral" in lowered and "8b" in lowered:
244
+ return installed_name
246
245
 
247
246
  return None
248
247
 
@@ -1,13 +1,28 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import json
4
+ import os
3
5
  from pathlib import Path
4
6
  from typing import Any, Dict, Optional
5
7
 
6
8
  import pandas as pd
7
9
 
10
+ from iints.ai.backends.ollama import DEFAULT_MINISTRAL_MODEL, OllamaBackend
8
11
  from iints.analysis.safety_visualizer import write_safety_visualizer
9
12
  from iints.data.realism_dashboard import write_realism_dashboard
10
13
  from iints.data.realism_validator import validate_realism_dataset, write_realism_report
14
+ from iints.governance import RESEARCH_ONLY_NOTICE, guard_ai_output
15
+
16
+
17
+ CORE_RESULT_COLUMNS = (
18
+ "time_minutes",
19
+ "glucose_actual_mgdl",
20
+ "delivered_insulin_units",
21
+ )
22
+
23
+ LOCAL_AI_REVIEW_ENV = "IINTS_LOCAL_AI_REVIEW"
24
+ LOCAL_AI_REVIEW_MODEL_ENV = "IINTS_LOCAL_AI_MODEL"
25
+ LOCAL_AI_REVIEW_TIMEOUT_ENV = "IINTS_LOCAL_AI_REVIEW_TIMEOUT_SECONDS"
11
26
 
12
27
 
13
28
  def _series_or_default(df: pd.DataFrame, column: str, default: float = 0.0) -> pd.Series:
@@ -53,6 +68,7 @@ def _write_run_quality_markdown(
53
68
  realism_report: Any,
54
69
  selected_reference: str | None,
55
70
  reference_selection: str | None,
71
+ quality_summary: Dict[str, Any],
56
72
  ) -> Path:
57
73
  failed = [check for check in realism_report.checks if check.status == "failed"]
58
74
  warnings = [check for check in realism_report.checks if check.status == "warning"]
@@ -65,6 +81,8 @@ def _write_run_quality_markdown(
65
81
  "",
66
82
  f"- Verdict: `{realism_report.verdict}`",
67
83
  f"- Realism score: `{realism_report.realism_score:.2f}`",
84
+ f"- Result quality grade: `{quality_summary['grade']}`",
85
+ f"- Result quality score: `{quality_summary['score']:.1f}/100`",
68
86
  f"- Reference selection: `{reference_selection}`",
69
87
  f"- Applied reference: `{selected_reference or 'none'}`",
70
88
  f"- Mean glucose: `{realism_report.metrics.get('mean_glucose_mgdl')} mg/dL`",
@@ -77,6 +95,11 @@ def _write_run_quality_markdown(
77
95
  realism_report.summary,
78
96
  "",
79
97
  ]
98
+ if quality_summary["review_reasons"]:
99
+ lines.extend(["## Result Quality Gate", ""])
100
+ for reason in quality_summary["review_reasons"]:
101
+ lines.append(f"- {reason}")
102
+ lines.append("")
80
103
  if failed or warnings:
81
104
  lines.extend(["## Review Items", ""])
82
105
  for check in failed + warnings:
@@ -106,6 +129,415 @@ def _write_run_quality_markdown(
106
129
  return output_path
107
130
 
108
131
 
132
+ def _count_safety_interventions(results_df: pd.DataFrame, safety_report: Optional[Dict[str, Any]]) -> int:
133
+ if safety_report:
134
+ for key in (
135
+ "bolus_interventions_count",
136
+ "interventions_count",
137
+ "safety_interventions_count",
138
+ "total_overrides",
139
+ ):
140
+ if key in safety_report:
141
+ try:
142
+ return int(safety_report[key])
143
+ except (TypeError, ValueError):
144
+ pass
145
+ if "safety_triggered" not in results_df.columns:
146
+ return 0
147
+ return int(results_df["safety_triggered"].fillna(False).astype(bool).sum())
148
+
149
+
150
+ def _routine_safety_adjustment_mask(results_df: pd.DataFrame) -> pd.Series:
151
+ """Identify routine deterministic dose shaping, not hard safety failures."""
152
+
153
+ if "safety_reason" not in results_df.columns:
154
+ return pd.Series([False] * len(results_df), index=results_df.index)
155
+ reasons = results_df["safety_reason"].fillna("").astype(str)
156
+ return (
157
+ reasons.str.startswith("PD_STACKING_PREVENTION")
158
+ | reasons.str.startswith("PD_CLEARANCE_LIMIT")
159
+ | reasons.str.startswith("WARNING: Hyperglycemia detected")
160
+ )
161
+
162
+
163
+ def _count_routine_safety_adjustments(results_df: pd.DataFrame) -> int:
164
+ if "safety_triggered" not in results_df.columns:
165
+ return 0
166
+ triggered = results_df["safety_triggered"].fillna(False).astype(bool)
167
+ return int((triggered & _routine_safety_adjustment_mask(results_df)).sum())
168
+
169
+
170
+ def _count_material_safety_interventions(results_df: pd.DataFrame) -> int:
171
+ if "safety_triggered" not in results_df.columns:
172
+ return 0
173
+ triggered = results_df["safety_triggered"].fillna(False).astype(bool)
174
+ routine = _routine_safety_adjustment_mask(results_df)
175
+ return int((triggered & ~routine).sum())
176
+
177
+
178
+ def build_result_quality_summary(
179
+ results_df: pd.DataFrame,
180
+ *,
181
+ realism_report: Any,
182
+ safety_report: Optional[Dict[str, Any]] = None,
183
+ ) -> Dict[str, Any]:
184
+ """Build a deterministic reviewer-facing quality gate for one run."""
185
+ review_reasons: list[str] = []
186
+ score = float(realism_report.realism_score) * 100.0
187
+
188
+ missing_columns = [column for column in CORE_RESULT_COLUMNS if column not in results_df.columns]
189
+ if missing_columns:
190
+ review_reasons.append(f"Missing core result columns: {', '.join(missing_columns)}.")
191
+ score -= 25.0
192
+
193
+ glucose = pd.to_numeric(results_df.get("glucose_actual_mgdl", pd.Series(dtype=float)), errors="coerce")
194
+ nan_glucose_rows = int(glucose.isna().sum())
195
+ if nan_glucose_rows:
196
+ review_reasons.append(f"{nan_glucose_rows} glucose row(s) are NaN or non-numeric.")
197
+ score -= min(20.0, nan_glucose_rows * 2.0)
198
+
199
+ timestamps = pd.to_numeric(results_df.get("time_minutes", pd.Series(dtype=float)), errors="coerce")
200
+ duplicate_timestamp_rows = int(timestamps.duplicated().sum()) if not timestamps.empty else 0
201
+ if duplicate_timestamp_rows:
202
+ review_reasons.append(f"{duplicate_timestamp_rows} duplicate timestamp row(s) detected.")
203
+ score -= min(15.0, duplicate_timestamp_rows * 1.5)
204
+
205
+ terminated_early = bool((safety_report or {}).get("terminated_early", False))
206
+ if terminated_early:
207
+ review_reasons.append("Simulation terminated early.")
208
+ score -= 35.0
209
+
210
+ intervention_count = _count_safety_interventions(results_df, safety_report)
211
+ routine_adjustment_count = _count_routine_safety_adjustments(results_df)
212
+ material_intervention_count = _count_material_safety_interventions(results_df)
213
+ if material_intervention_count:
214
+ review_reasons.append(f"{material_intervention_count} material safety intervention(s) occurred.")
215
+ score -= min(20.0, material_intervention_count * 0.75)
216
+ if routine_adjustment_count:
217
+ review_reasons.append(
218
+ f"{routine_adjustment_count} routine pharmacodynamic dose-shaping adjustment(s) occurred."
219
+ )
220
+ score -= min(4.0, routine_adjustment_count * 0.02)
221
+
222
+ fail_soft_count = int((safety_report or {}).get("input_validator_fail_soft_count", 0) or 0)
223
+ if fail_soft_count:
224
+ review_reasons.append(f"{fail_soft_count} input-validator fail-soft event(s) occurred.")
225
+ score -= min(20.0, fail_soft_count * 2.0)
226
+
227
+ if realism_report.verdict == "likely_unrealistic":
228
+ review_reasons.append("Realism verdict is likely_unrealistic.")
229
+ score -= 30.0
230
+ elif realism_report.verdict == "needs_review":
231
+ review_reasons.append("Realism verdict needs_review.")
232
+ score -= 10.0
233
+
234
+ score = max(0.0, min(100.0, score))
235
+ if terminated_early or missing_columns or realism_report.verdict == "likely_unrealistic":
236
+ grade = "do_not_use"
237
+ elif score < 75.0 or realism_report.verdict == "needs_review" or fail_soft_count:
238
+ grade = "review_before_use"
239
+ else:
240
+ grade = "research_ready"
241
+
242
+ return {
243
+ "grade": grade,
244
+ "score": round(score, 3),
245
+ "review_reasons": review_reasons,
246
+ "row_count": int(len(results_df)),
247
+ "missing_columns": missing_columns,
248
+ "nan_glucose_rows": nan_glucose_rows,
249
+ "duplicate_timestamp_rows": duplicate_timestamp_rows,
250
+ "safety_intervention_count": intervention_count,
251
+ "material_safety_intervention_count": material_intervention_count,
252
+ "routine_dose_shaping_count": routine_adjustment_count,
253
+ "input_validator_fail_soft_count": fail_soft_count,
254
+ "terminated_early": terminated_early,
255
+ "realism_verdict": realism_report.verdict,
256
+ "realism_score": float(realism_report.realism_score),
257
+ }
258
+
259
+
260
+ def _json_safe(value: Any) -> Any:
261
+ if isinstance(value, dict):
262
+ return {str(key): _json_safe(item) for key, item in value.items()}
263
+ if isinstance(value, (list, tuple, set)):
264
+ return [_json_safe(item) for item in value]
265
+ if isinstance(value, Path):
266
+ return str(value)
267
+ if hasattr(value, "item"):
268
+ try:
269
+ return _json_safe(value.item())
270
+ except Exception:
271
+ pass
272
+ try:
273
+ if pd.isna(value):
274
+ return None
275
+ except (TypeError, ValueError):
276
+ pass
277
+ if isinstance(value, (str, int, float, bool)) or value is None:
278
+ return value
279
+ return str(value)
280
+
281
+
282
+ def _resolve_local_ai_review_mode(local_ai_review: bool | str | None) -> str:
283
+ raw_value: bool | str | None = local_ai_review
284
+ if raw_value is None:
285
+ raw_value = os.getenv(LOCAL_AI_REVIEW_ENV, "auto")
286
+ if isinstance(raw_value, bool):
287
+ return "auto" if raw_value else "off"
288
+ normalized = str(raw_value).strip().lower()
289
+ if normalized in {"1", "true", "yes", "on", "enabled"}:
290
+ return "auto"
291
+ if normalized in {"0", "false", "no", "off", "disabled", "skip"}:
292
+ return "off"
293
+ if normalized in {"required", "force", "strict"}:
294
+ return "required"
295
+ if normalized == "auto":
296
+ return "auto"
297
+ return "auto"
298
+
299
+
300
+ def _local_ai_timeout_seconds(default: float) -> float:
301
+ raw_value = os.getenv(LOCAL_AI_REVIEW_TIMEOUT_ENV)
302
+ if not raw_value:
303
+ return default
304
+ try:
305
+ return max(1.0, float(raw_value))
306
+ except ValueError:
307
+ return default
308
+
309
+
310
+ def _sample_results_rows(results_df: pd.DataFrame, *, max_rows: int = 72) -> list[dict[str, Any]]:
311
+ preferred_columns = [
312
+ "time_minutes",
313
+ "glucose_actual_mgdl",
314
+ "glucose_sensor_mgdl",
315
+ "carb_intake_grams",
316
+ "delivered_insulin_units",
317
+ "algo_recommended_insulin_units",
318
+ "active_insulin_units",
319
+ "safety_triggered",
320
+ "safety_reason",
321
+ ]
322
+ columns = [column for column in preferred_columns if column in results_df.columns]
323
+ if not columns or results_df.empty:
324
+ return []
325
+ step = max(1, len(results_df) // max_rows)
326
+ sampled = results_df.loc[:, columns].iloc[::step].head(max_rows)
327
+ return _json_safe(sampled.to_dict(orient="records"))
328
+
329
+
330
+ def _scalar_safety_summary(safety_report: Optional[Dict[str, Any]]) -> Dict[str, Any]:
331
+ if not safety_report:
332
+ return {}
333
+ summary: Dict[str, Any] = {}
334
+ for key, value in safety_report.items():
335
+ if isinstance(value, (str, int, float, bool)) or value is None:
336
+ summary[key] = _json_safe(value)
337
+ return summary
338
+
339
+
340
+ def _build_local_ai_review_payload(
341
+ results_df: pd.DataFrame,
342
+ *,
343
+ run_label: str,
344
+ realism_report: Any,
345
+ selected_reference: str | None,
346
+ reference_selection: str | None,
347
+ quality_summary: Dict[str, Any],
348
+ safety_report: Optional[Dict[str, Any]],
349
+ ) -> Dict[str, Any]:
350
+ return {
351
+ "run_label": run_label,
352
+ "research_only": True,
353
+ "medical_device": False,
354
+ "instruction": (
355
+ "Use the deterministic run_quality grade as source of truth. "
356
+ "The local AI verifier may explain risks and suggest research checks, "
357
+ "but must not provide treatment or dosing advice."
358
+ ),
359
+ "realism": {
360
+ "verdict": realism_report.verdict,
361
+ "score": float(realism_report.realism_score),
362
+ "summary": realism_report.summary,
363
+ "metrics": _json_safe(realism_report.metrics),
364
+ "reference": selected_reference,
365
+ "reference_selection": reference_selection,
366
+ "checks": [
367
+ {
368
+ "title": check.title,
369
+ "status": check.status,
370
+ "detail": check.detail,
371
+ }
372
+ for check in realism_report.checks
373
+ if check.status in {"failed", "warning"}
374
+ ],
375
+ },
376
+ "deterministic_quality_gate": _json_safe(quality_summary),
377
+ "safety": _scalar_safety_summary(safety_report),
378
+ "sampled_results": _sample_results_rows(results_df),
379
+ }
380
+
381
+
382
+ def _write_local_ai_metadata(
383
+ metadata_path: Path,
384
+ *,
385
+ status: str,
386
+ model: str,
387
+ reason: str | None = None,
388
+ markdown_path: Path | None = None,
389
+ policy_violations: tuple[str, ...] = (),
390
+ policy_warnings: tuple[str, ...] = (),
391
+ policy_action: str = "allow",
392
+ ) -> Dict[str, Any]:
393
+ metadata = {
394
+ "status": status,
395
+ "model": model,
396
+ "reason": reason,
397
+ "markdown": str(markdown_path) if markdown_path else None,
398
+ "research_only": True,
399
+ "medical_device": False,
400
+ "policy_violations": list(policy_violations),
401
+ "policy_warnings": list(policy_warnings),
402
+ "policy_action": policy_action,
403
+ }
404
+ metadata_path.parent.mkdir(parents=True, exist_ok=True)
405
+ metadata_path.write_text(json.dumps(metadata, indent=2), encoding="utf-8")
406
+ return metadata
407
+
408
+
409
+ def _write_local_ai_run_verification(
410
+ results_df: pd.DataFrame,
411
+ output_path: Path,
412
+ *,
413
+ run_label: str,
414
+ realism_report: Any,
415
+ selected_reference: str | None,
416
+ reference_selection: str | None,
417
+ quality_summary: Dict[str, Any],
418
+ safety_report: Optional[Dict[str, Any]],
419
+ local_ai_review: bool | str | None,
420
+ local_ai_model: str,
421
+ local_ai_timeout_seconds: float,
422
+ ollama_host: str | None,
423
+ ) -> Dict[str, Any]:
424
+ ai_dir = output_path / "ai"
425
+ markdown_path = ai_dir / "local_run_verification.md"
426
+ metadata_path = ai_dir / "local_run_verification.json"
427
+ mode = _resolve_local_ai_review_mode(local_ai_review)
428
+
429
+ if mode == "off":
430
+ metadata = _write_local_ai_metadata(
431
+ metadata_path,
432
+ status="skipped",
433
+ model=local_ai_model,
434
+ reason=f"disabled via {LOCAL_AI_REVIEW_ENV}",
435
+ )
436
+ return {
437
+ "local_ai_review_status": metadata["status"],
438
+ "local_ai_review_json": str(metadata_path),
439
+ }
440
+
441
+ if mode == "auto" and os.getenv("CI", "").strip().lower() in {"1", "true", "yes"}:
442
+ metadata = _write_local_ai_metadata(
443
+ metadata_path,
444
+ status="skipped",
445
+ model=local_ai_model,
446
+ reason="disabled during CI; set IINTS_LOCAL_AI_REVIEW=required to force it",
447
+ )
448
+ return {
449
+ "local_ai_review_status": metadata["status"],
450
+ "local_ai_review_json": str(metadata_path),
451
+ }
452
+
453
+ try:
454
+ backend = OllamaBackend(
455
+ model_name=local_ai_model,
456
+ base_url=ollama_host,
457
+ timeout_seconds=_local_ai_timeout_seconds(local_ai_timeout_seconds),
458
+ temperature=0.0,
459
+ top_p=0.7,
460
+ num_predict=900,
461
+ num_ctx=8192,
462
+ )
463
+ if not backend.available():
464
+ reason = (
465
+ f"local Ollama is not reachable at {backend.base_url}; "
466
+ "start Ollama to enable automatic local AI verification"
467
+ )
468
+ if mode == "required":
469
+ raise RuntimeError(reason)
470
+ metadata = _write_local_ai_metadata(
471
+ metadata_path,
472
+ status="skipped",
473
+ model=local_ai_model,
474
+ reason=reason,
475
+ )
476
+ return {
477
+ "local_ai_review_status": metadata["status"],
478
+ "local_ai_review_json": str(metadata_path),
479
+ }
480
+ resolved_model = backend.ensure_model_ready()
481
+ payload = _build_local_ai_review_payload(
482
+ results_df,
483
+ run_label=run_label,
484
+ realism_report=realism_report,
485
+ selected_reference=selected_reference,
486
+ reference_selection=reference_selection,
487
+ quality_summary=quality_summary,
488
+ safety_report=safety_report,
489
+ )
490
+ system_prompt = (
491
+ "You are the local IINTS-AF run verifier. You review simulation output for "
492
+ "research and education only. You are not a medical device and must not give "
493
+ "insulin, glucagon, diagnosis, or treatment advice. Treat the deterministic "
494
+ "quality gate as authoritative; your role is to explain what looks trustworthy, "
495
+ "what needs review, and what next checks would improve the research result. "
496
+ f"Boundary: {RESEARCH_ONLY_NOTICE}"
497
+ )
498
+ user_prompt = (
499
+ "Review this bounded IINTS run summary and return concise Markdown with exactly "
500
+ "these sections: ## Verdict, ## What Looks Trustworthy, ## What Needs Review, "
501
+ "## Next Checks, ## Research-Only Note. Keep it concrete and cite the provided "
502
+ "metrics. Do not invent clinical validation.\n\n"
503
+ f"```json\n{json.dumps(_json_safe(payload), indent=2)}\n```"
504
+ )
505
+ response_text = backend.complete(system_prompt=system_prompt, user_prompt=user_prompt).strip()
506
+ if not response_text:
507
+ raise RuntimeError("local AI verifier returned an empty response")
508
+ guarded = guard_ai_output(response_text, source="run_quality_local_ai")
509
+ ai_dir.mkdir(parents=True, exist_ok=True)
510
+ markdown_path.write_text(guarded.text + "\n", encoding="utf-8")
511
+ metadata = _write_local_ai_metadata(
512
+ metadata_path,
513
+ status="completed" if guarded.allowed else "blocked_policy",
514
+ model=resolved_model,
515
+ markdown_path=markdown_path,
516
+ policy_violations=guarded.violations,
517
+ policy_warnings=guarded.warnings,
518
+ policy_action=guarded.action,
519
+ )
520
+ return {
521
+ "local_ai_review_status": metadata["status"],
522
+ "local_ai_review_md": str(markdown_path),
523
+ "local_ai_review_json": str(metadata_path),
524
+ "local_ai_review_model": resolved_model,
525
+ }
526
+ except Exception as exc:
527
+ if mode == "required":
528
+ raise
529
+ metadata = _write_local_ai_metadata(
530
+ metadata_path,
531
+ status="failed",
532
+ model=local_ai_model,
533
+ reason=str(exc),
534
+ )
535
+ return {
536
+ "local_ai_review_status": metadata["status"],
537
+ "local_ai_review_json": str(metadata_path),
538
+ }
539
+
540
+
109
541
  def write_run_quality_artifacts(
110
542
  results_df: pd.DataFrame,
111
543
  output_dir: str | Path,
@@ -113,6 +545,10 @@ def write_run_quality_artifacts(
113
545
  run_label: Optional[str] = None,
114
546
  safety_report: Optional[Dict[str, Any]] = None,
115
547
  realism_reference: Optional[str] = "auto",
548
+ local_ai_review: bool | str | None = None,
549
+ local_ai_model: str | None = None,
550
+ local_ai_timeout_seconds: float = 90.0,
551
+ ollama_host: str | None = None,
116
552
  ) -> Dict[str, Any]:
117
553
  """Write reviewer-facing quality artifacts for one run.
118
554
 
@@ -132,7 +568,14 @@ def write_run_quality_artifacts(
132
568
  realism_json = output_path / "realism_report.json"
133
569
  realism_html = output_path / "realism_dashboard.html"
134
570
  realism_markdown = output_path / "run_quality_review.md"
571
+ quality_json = output_path / "run_quality_summary.json"
135
572
  write_realism_report(realism_report, realism_json)
573
+ quality_summary = build_result_quality_summary(
574
+ results_df,
575
+ realism_report=realism_report,
576
+ safety_report=safety_report,
577
+ )
578
+ quality_json.write_text(json.dumps(quality_summary, indent=2), encoding="utf-8")
136
579
  write_realism_dashboard(
137
580
  realism_report,
138
581
  realism_frame,
@@ -146,6 +589,7 @@ def write_run_quality_artifacts(
146
589
  realism_report=realism_report,
147
590
  selected_reference=selected_reference,
148
591
  reference_selection=realism_reference,
592
+ quality_summary=quality_summary,
149
593
  )
150
594
  realism_summary = {
151
595
  "verdict": realism_report.verdict,
@@ -153,17 +597,48 @@ def write_run_quality_artifacts(
153
597
  "summary": realism_report.summary,
154
598
  "reference": selected_reference,
155
599
  "reference_selection": realism_reference,
600
+ "quality_grade": quality_summary["grade"],
601
+ "quality_score": quality_summary["score"],
156
602
  }
157
603
  if safety_report is not None:
158
604
  safety_report["realism_review"] = realism_summary
605
+ safety_report["run_quality"] = quality_summary
159
606
  outputs.update(
160
607
  {
161
608
  "realism_report_json": str(realism_json),
162
609
  "realism_dashboard_html": str(realism_html),
163
610
  "run_quality_review_md": str(realism_markdown),
611
+ "run_quality_summary_json": str(quality_json),
164
612
  "realism_review": realism_summary,
613
+ "run_quality": quality_summary,
165
614
  }
166
615
  )
616
+ local_ai_outputs = _write_local_ai_run_verification(
617
+ results_df,
618
+ output_path,
619
+ run_label=label,
620
+ realism_report=realism_report,
621
+ selected_reference=selected_reference,
622
+ reference_selection=realism_reference,
623
+ quality_summary=quality_summary,
624
+ safety_report=safety_report,
625
+ local_ai_review=local_ai_review,
626
+ local_ai_model=local_ai_model
627
+ or os.getenv(LOCAL_AI_REVIEW_MODEL_ENV)
628
+ or DEFAULT_MINISTRAL_MODEL,
629
+ local_ai_timeout_seconds=local_ai_timeout_seconds,
630
+ ollama_host=ollama_host,
631
+ )
632
+ outputs.update(local_ai_outputs)
633
+ if local_ai_outputs:
634
+ quality_summary["local_ai_review_status"] = local_ai_outputs.get("local_ai_review_status")
635
+ if local_ai_outputs.get("local_ai_review_md"):
636
+ quality_summary["local_ai_review_md"] = local_ai_outputs["local_ai_review_md"]
637
+ if local_ai_outputs.get("local_ai_review_json"):
638
+ quality_summary["local_ai_review_json"] = local_ai_outputs["local_ai_review_json"]
639
+ quality_json.write_text(json.dumps(quality_summary, indent=2), encoding="utf-8")
640
+ if safety_report is not None:
641
+ safety_report["run_quality"] = quality_summary
167
642
  except Exception as exc:
168
643
  outputs["realism_warning"] = str(exc)
169
644
 
iints/cli/cli.py CHANGED
@@ -488,6 +488,27 @@ def _maybe_prepare_ai_artifacts(output_dir: Path, console: Console) -> None:
488
488
  console.print(f"[yellow]AI-ready payload export skipped:[/yellow] {exc}")
489
489
 
490
490
 
491
+ def _print_run_quality_review(console: Console, quality_outputs: Dict[str, Any]) -> None:
492
+ realism_review = quality_outputs.get("realism_review")
493
+ if not realism_review:
494
+ return
495
+ verdict = realism_review.get("verdict")
496
+ realism_score = float(realism_review.get("realism_score", 0.0))
497
+ quality_grade = realism_review.get("quality_grade", "unknown")
498
+ quality_score = float(realism_review.get("quality_score", 0.0))
499
+ console.print(
500
+ "[green]Run quality:[/green] "
501
+ f"{quality_grade} ({quality_score:.1f}/100); "
502
+ f"realism {verdict} ({realism_score:.2f})"
503
+ )
504
+ ai_status = quality_outputs.get("local_ai_review_status")
505
+ if ai_status == "completed":
506
+ console.print(f"[green]Local AI verifier:[/green] {quality_outputs.get('local_ai_review_md')}")
507
+ elif ai_status in {"failed", "skipped"}:
508
+ ai_json = quality_outputs.get("local_ai_review_json")
509
+ console.print(f"[yellow]Local AI verifier:[/yellow] {ai_status} ({ai_json})")
510
+
511
+
491
512
  def _get_preset(name: str) -> Dict[str, Any]:
492
513
  presets = _load_presets()
493
514
  for preset in presets:
@@ -4786,12 +4807,7 @@ def presets_run(
4786
4807
  run_label=run_id,
4787
4808
  safety_report=safety_report,
4788
4809
  )
4789
- if quality_outputs.get("realism_review"):
4790
- realism_review = quality_outputs["realism_review"]
4791
- console.print(
4792
- "[green]Realism review:[/green] "
4793
- f"{realism_review.get('verdict')} (score {float(realism_review.get('realism_score', 0.0)):.2f})"
4794
- )
4810
+ _print_run_quality_review(console, quality_outputs)
4795
4811
  console.print(f"Safety visualizer: {quality_outputs.get('safety_visualizer_html')}")
4796
4812
 
4797
4813
  manifest_files = {
@@ -4811,6 +4827,8 @@ def presets_run(
4811
4827
  "realism_dashboard": "realism_dashboard_html",
4812
4828
  "safety_visualizer": "safety_visualizer_html",
4813
4829
  "safety_visualizer_json": "safety_visualizer_json",
4830
+ "local_ai_review": "local_ai_review_md",
4831
+ "local_ai_review_json": "local_ai_review_json",
4814
4832
  }.items():
4815
4833
  output_value = quality_outputs.get(output_key)
4816
4834
  if output_value:
@@ -5536,6 +5554,8 @@ def run(
5536
5554
  "realism_dashboard": "realism_dashboard_html",
5537
5555
  "safety_visualizer": "safety_visualizer_html",
5538
5556
  "safety_visualizer_json": "safety_visualizer_json",
5557
+ "local_ai_review": "local_ai_review_md",
5558
+ "local_ai_review_json": "local_ai_review_json",
5539
5559
  }.items():
5540
5560
  output_value = quality_outputs.get(output_key)
5541
5561
  if output_value:
@@ -5548,12 +5568,7 @@ def run(
5548
5568
  console.print(f"Using compute device: [blue]{device}[/blue]")
5549
5569
  if baseline_paths is not None:
5550
5570
  console.print(f"Baseline comparison saved to: {baseline_paths}")
5551
- if quality_outputs.get("realism_review"):
5552
- realism_review = quality_outputs["realism_review"]
5553
- console.print(
5554
- "[green]Realism review:[/green] "
5555
- f"{realism_review.get('verdict')} (score {float(realism_review.get('realism_score', 0.0)):.2f})"
5556
- )
5571
+ _print_run_quality_review(console, quality_outputs)
5557
5572
  if quality_outputs.get("safety_visualizer_html"):
5558
5573
  console.print(f"Safety visualizer: {quality_outputs.get('safety_visualizer_html')}")
5559
5574
  console.print(f"Run metadata: {run_metadata_path}")
@@ -10955,9 +10970,12 @@ def medtronic_live_cmd(
10955
10970
  start=1,
10956
10971
  ):
10957
10972
  if not timeline.empty:
10973
+ if merged.empty:
10974
+ merged = timeline.copy()
10975
+ else:
10976
+ merged = pd.concat([merged, timeline], ignore_index=True)
10958
10977
  merged = (
10959
- pd.concat([merged, timeline], ignore_index=True)
10960
- .sort_values("timestamp_dt")
10978
+ merged.sort_values("timestamp_dt")
10961
10979
  .drop_duplicates("timestamp_dt", keep="last")
10962
10980
  .reset_index(drop=True)
10963
10981
  )