iints-sdk-python35 1.1.0__py3-none-any.whl → 1.1.2__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.
- iints/__init__.py +9 -1
- iints/ai/__init__.py +5 -0
- iints/ai/backends/mistral_api.py +1 -1
- iints/ai/backends/ollama.py +76 -3
- iints/ai/cli.py +176 -21
- iints/ai/model_catalog.py +55 -0
- iints/ai/prepare.py +342 -0
- iints/cli/cli.py +29 -0
- {iints_sdk_python35-1.1.0.dist-info → iints_sdk_python35-1.1.2.dist-info}/METADATA +39 -10
- {iints_sdk_python35-1.1.0.dist-info → iints_sdk_python35-1.1.2.dist-info}/RECORD +14 -12
- {iints_sdk_python35-1.1.0.dist-info → iints_sdk_python35-1.1.2.dist-info}/entry_points.txt +1 -0
- {iints_sdk_python35-1.1.0.dist-info → iints_sdk_python35-1.1.2.dist-info}/WHEEL +0 -0
- {iints_sdk_python35-1.1.0.dist-info → iints_sdk_python35-1.1.2.dist-info}/licenses/LICENSE +0 -0
- {iints_sdk_python35-1.1.0.dist-info → iints_sdk_python35-1.1.2.dist-info}/top_level.txt +0 -0
iints/__init__.py
CHANGED
|
@@ -3,7 +3,15 @@
|
|
|
3
3
|
import pandas as pd # Required for type hints like pd.DataFrame
|
|
4
4
|
from typing import Optional
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
try:
|
|
7
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
8
|
+
except ImportError: # pragma: no cover - Python < 3.8 fallback
|
|
9
|
+
from importlib_metadata import PackageNotFoundError, version # type: ignore
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
__version__ = version("iints-sdk-python35")
|
|
13
|
+
except PackageNotFoundError: # pragma: no cover - source tree fallback
|
|
14
|
+
__version__ = "1.1.2"
|
|
7
15
|
|
|
8
16
|
# Note to developers: this SDK is currently maintained by a single author.
|
|
9
17
|
# Please report bugs via GitHub issues and feel free to contribute fixes via PRs.
|
iints/ai/__init__.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
from .assistant import AIResponse, IINTSAssistant
|
|
2
2
|
from .backends import DEFAULT_MINISTRAL_MODEL, DEFAULT_OLLAMA_HOST, OllamaBackend
|
|
3
3
|
from .mdmp_guard import GuardResult, MDMPGuard
|
|
4
|
+
from .model_catalog import LocalMistralModelProfile, list_local_mistral_models
|
|
5
|
+
from .prepare import prepare_ai_ready_artifacts
|
|
4
6
|
|
|
5
7
|
__all__ = [
|
|
6
8
|
"AIResponse",
|
|
@@ -10,4 +12,7 @@ __all__ = [
|
|
|
10
12
|
"OllamaBackend",
|
|
11
13
|
"GuardResult",
|
|
12
14
|
"MDMPGuard",
|
|
15
|
+
"LocalMistralModelProfile",
|
|
16
|
+
"list_local_mistral_models",
|
|
17
|
+
"prepare_ai_ready_artifacts",
|
|
13
18
|
]
|
iints/ai/backends/mistral_api.py
CHANGED
|
@@ -13,5 +13,5 @@ class MistralAPIBackend:
|
|
|
13
13
|
def complete(self, *, system_prompt: str, user_prompt: str) -> str:
|
|
14
14
|
raise RuntimeError(
|
|
15
15
|
"Cloud fallback is not enabled in this SDK build yet. "
|
|
16
|
-
"Use mode='local' with Ollama for Ministral."
|
|
16
|
+
"Use mode='local' with Ollama for the open Ministral 3 model."
|
|
17
17
|
)
|
iints/ai/backends/ollama.py
CHANGED
|
@@ -6,12 +6,19 @@ from urllib import error, request
|
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
DEFAULT_OLLAMA_HOST = "http://127.0.0.1:11434"
|
|
9
|
-
DEFAULT_MINISTRAL_MODEL = "
|
|
9
|
+
DEFAULT_MINISTRAL_MODEL = "ministral-3:8b"
|
|
10
|
+
LEGACY_MINISTRAL_MODEL = "mistral/ministral-8b-instruct"
|
|
11
|
+
MIN_OLLAMA_VERSION_FOR_MINISTRAL_3 = (0, 13, 1)
|
|
10
12
|
MINISTRAL_MODEL_ALIASES = (
|
|
11
13
|
DEFAULT_MINISTRAL_MODEL,
|
|
14
|
+
"ministral-3",
|
|
15
|
+
"ministral-3:latest",
|
|
16
|
+
"ministral-3:8b",
|
|
17
|
+
"ministral-3:8b-instruct",
|
|
12
18
|
"ministral",
|
|
13
19
|
"ministral-8b",
|
|
14
20
|
"ministral-8b-instruct",
|
|
21
|
+
LEGACY_MINISTRAL_MODEL,
|
|
15
22
|
)
|
|
16
23
|
|
|
17
24
|
|
|
@@ -23,7 +30,7 @@ class OllamaBackend:
|
|
|
23
30
|
*,
|
|
24
31
|
model_name: str = DEFAULT_MINISTRAL_MODEL,
|
|
25
32
|
base_url: str | None = None,
|
|
26
|
-
timeout_seconds: float =
|
|
33
|
+
timeout_seconds: float = 120.0,
|
|
27
34
|
) -> None:
|
|
28
35
|
self.model_name = model_name
|
|
29
36
|
self.base_url = (base_url or os.getenv("OLLAMA_HOST") or DEFAULT_OLLAMA_HOST).rstrip("/")
|
|
@@ -33,6 +40,28 @@ class OllamaBackend:
|
|
|
33
40
|
def _pull_hint(self) -> str:
|
|
34
41
|
return f"ollama pull {self.model_name}"
|
|
35
42
|
|
|
43
|
+
def _requires_ministral_3_runtime(self) -> bool:
|
|
44
|
+
requested = self.model_name.strip().lower()
|
|
45
|
+
return requested.startswith("ministral-3") or requested == "ministral"
|
|
46
|
+
|
|
47
|
+
@staticmethod
|
|
48
|
+
def _parse_version(raw_version: str) -> tuple[int, ...] | None:
|
|
49
|
+
value = raw_version.strip().lower().lstrip("v")
|
|
50
|
+
numeric_parts: list[int] = []
|
|
51
|
+
for part in value.split("."):
|
|
52
|
+
digits = ""
|
|
53
|
+
for char in part:
|
|
54
|
+
if char.isdigit():
|
|
55
|
+
digits += char
|
|
56
|
+
else:
|
|
57
|
+
break
|
|
58
|
+
if not digits:
|
|
59
|
+
break
|
|
60
|
+
numeric_parts.append(int(digits))
|
|
61
|
+
if not numeric_parts:
|
|
62
|
+
return None
|
|
63
|
+
return tuple(numeric_parts)
|
|
64
|
+
|
|
36
65
|
def _request_json(
|
|
37
66
|
self,
|
|
38
67
|
path: str,
|
|
@@ -79,6 +108,27 @@ class OllamaBackend:
|
|
|
79
108
|
return False
|
|
80
109
|
return True
|
|
81
110
|
|
|
111
|
+
def server_version(self) -> str | None:
|
|
112
|
+
try:
|
|
113
|
+
response = self._request_json("/api/version", method="GET")
|
|
114
|
+
except Exception:
|
|
115
|
+
return None
|
|
116
|
+
raw_version = response.get("version")
|
|
117
|
+
if isinstance(raw_version, str) and raw_version.strip():
|
|
118
|
+
return raw_version.strip()
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
def version_supported(self) -> tuple[bool | None, str | None]:
|
|
122
|
+
version = self.server_version()
|
|
123
|
+
if version is None:
|
|
124
|
+
return None, None
|
|
125
|
+
if not self._requires_ministral_3_runtime():
|
|
126
|
+
return True, version
|
|
127
|
+
parsed = self._parse_version(version)
|
|
128
|
+
if parsed is None:
|
|
129
|
+
return None, version
|
|
130
|
+
return parsed >= MIN_OLLAMA_VERSION_FOR_MINISTRAL_3, version
|
|
131
|
+
|
|
82
132
|
def list_models(self) -> list[str]:
|
|
83
133
|
response = self._request_json("/api/tags", method="GET")
|
|
84
134
|
raw_models = response.get("models", [])
|
|
@@ -102,12 +152,24 @@ class OllamaBackend:
|
|
|
102
152
|
return installed_lookup[self.model_name.lower()]
|
|
103
153
|
|
|
104
154
|
requested = self.model_name.strip().lower()
|
|
105
|
-
if requested in {
|
|
155
|
+
if requested in {
|
|
156
|
+
"ministral",
|
|
157
|
+
"ministral-3",
|
|
158
|
+
"ministral-3:latest",
|
|
159
|
+
"ministral-3:8b",
|
|
160
|
+
"ministral-3:8b-instruct",
|
|
161
|
+
"ministral-8b",
|
|
162
|
+
"ministral-8b-instruct",
|
|
163
|
+
}:
|
|
106
164
|
for alias in MINISTRAL_MODEL_ALIASES:
|
|
107
165
|
resolved = installed_lookup.get(alias.lower())
|
|
108
166
|
if resolved is not None:
|
|
109
167
|
return resolved
|
|
110
168
|
|
|
169
|
+
for installed_name in installed:
|
|
170
|
+
lowered = installed_name.lower()
|
|
171
|
+
if "ministral-3" in lowered and "8b" in lowered:
|
|
172
|
+
return installed_name
|
|
111
173
|
for installed_name in installed:
|
|
112
174
|
lowered = installed_name.lower()
|
|
113
175
|
if "ministral" in lowered and "8b" in lowered:
|
|
@@ -116,6 +178,14 @@ class OllamaBackend:
|
|
|
116
178
|
return None
|
|
117
179
|
|
|
118
180
|
def ensure_model_ready(self) -> str:
|
|
181
|
+
version_ok, version = self.version_supported()
|
|
182
|
+
if version_ok is False:
|
|
183
|
+
required_version = ".".join(str(part) for part in MIN_OLLAMA_VERSION_FOR_MINISTRAL_3)
|
|
184
|
+
raise RuntimeError(
|
|
185
|
+
"The open Ministral 3 local model requires a newer Ollama runtime.\n"
|
|
186
|
+
f"Detected Ollama: {version}\n"
|
|
187
|
+
f"Required Ollama: >= {required_version}"
|
|
188
|
+
)
|
|
119
189
|
try:
|
|
120
190
|
resolved = self.resolve_model_name()
|
|
121
191
|
except RuntimeError:
|
|
@@ -137,6 +207,7 @@ class OllamaBackend:
|
|
|
137
207
|
return resolved
|
|
138
208
|
|
|
139
209
|
def healthcheck(self) -> dict[str, object]:
|
|
210
|
+
version_ok, version = self.version_supported()
|
|
140
211
|
installed = self.list_models()
|
|
141
212
|
resolved = self.resolve_model_name() if installed else None
|
|
142
213
|
return {
|
|
@@ -148,6 +219,8 @@ class OllamaBackend:
|
|
|
148
219
|
"ready": resolved is not None,
|
|
149
220
|
"pull_command": None if resolved is not None else self._pull_hint(),
|
|
150
221
|
"timeout_seconds": self.timeout_seconds,
|
|
222
|
+
"server_version": version,
|
|
223
|
+
"version_ok": version_ok,
|
|
151
224
|
}
|
|
152
225
|
|
|
153
226
|
def complete(self, *, system_prompt: str, user_prompt: str) -> str:
|
iints/ai/cli.py
CHANGED
|
@@ -7,10 +7,13 @@ from typing import Any, Optional
|
|
|
7
7
|
import typer
|
|
8
8
|
from rich.console import Console
|
|
9
9
|
from rich.panel import Panel
|
|
10
|
+
from rich.table import Table
|
|
10
11
|
from typing_extensions import Annotated
|
|
11
12
|
|
|
12
13
|
from .assistant import AIResponse, IINTSAssistant
|
|
13
14
|
from .backends import DEFAULT_MINISTRAL_MODEL, OllamaBackend
|
|
15
|
+
from .model_catalog import list_local_mistral_models
|
|
16
|
+
from .prepare import prepare_ai_ready_artifacts
|
|
14
17
|
|
|
15
18
|
|
|
16
19
|
app = typer.Typer(help="Research-only AI assistant commands gated by MDMP certification.")
|
|
@@ -34,6 +37,57 @@ def _load_json_payload(path: Path, label: str) -> Any:
|
|
|
34
37
|
return payload
|
|
35
38
|
|
|
36
39
|
|
|
40
|
+
def _default_prepared_payload(task: str, ai_dir: Path) -> Path:
|
|
41
|
+
candidates = {
|
|
42
|
+
"explain": ["step_riskiest.json", "step_latest.json"],
|
|
43
|
+
"trends": ["trends_payload.json"],
|
|
44
|
+
"anomalies": ["anomalies_payload.json"],
|
|
45
|
+
"report": ["report_payload.json"],
|
|
46
|
+
}.get(task, [])
|
|
47
|
+
for filename in candidates:
|
|
48
|
+
candidate = ai_dir / filename
|
|
49
|
+
if candidate.is_file():
|
|
50
|
+
return candidate
|
|
51
|
+
expected = ", ".join(candidates) if candidates else "prepared payload"
|
|
52
|
+
raise typer.BadParameter(
|
|
53
|
+
f"No prepared AI payload found in {ai_dir}. Expected one of: {expected}. "
|
|
54
|
+
"Run `iints ai prepare <run_dir>` first."
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _resolve_cli_inputs(
|
|
59
|
+
*,
|
|
60
|
+
task: str,
|
|
61
|
+
input_path: Path,
|
|
62
|
+
mdmp_cert: Path | None,
|
|
63
|
+
public_key: Path | None,
|
|
64
|
+
trust_store: Path | None,
|
|
65
|
+
) -> tuple[Path, Path, Path | None]:
|
|
66
|
+
resolved_input = input_path
|
|
67
|
+
resolved_cert = mdmp_cert
|
|
68
|
+
resolved_public_key = public_key
|
|
69
|
+
|
|
70
|
+
if input_path.is_dir():
|
|
71
|
+
ai_dir = input_path / "ai"
|
|
72
|
+
resolved_input = _default_prepared_payload(task, ai_dir)
|
|
73
|
+
if resolved_cert is None:
|
|
74
|
+
candidate_cert = ai_dir / "report.signed.mdmp"
|
|
75
|
+
if candidate_cert.is_file():
|
|
76
|
+
resolved_cert = candidate_cert
|
|
77
|
+
if resolved_public_key is None and trust_store is None:
|
|
78
|
+
candidate_public_key = ai_dir / "keys" / "mdmp_pub_v1.pem"
|
|
79
|
+
if candidate_public_key.is_file():
|
|
80
|
+
resolved_public_key = candidate_public_key
|
|
81
|
+
|
|
82
|
+
if resolved_cert is None:
|
|
83
|
+
raise typer.BadParameter(
|
|
84
|
+
"No MDMP certificate provided. Pass --mdmp-cert or run "
|
|
85
|
+
"`iints ai prepare <run_dir>` to generate a local development certificate."
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
return resolved_input, resolved_cert, resolved_public_key
|
|
89
|
+
|
|
90
|
+
|
|
37
91
|
def _write_output(path: Path | None, response: AIResponse) -> None:
|
|
38
92
|
if path is None:
|
|
39
93
|
return
|
|
@@ -64,6 +118,7 @@ def _render_local_check(console: Console, status: dict[str, object]) -> None:
|
|
|
64
118
|
f"Endpoint: {status.get('base_url')}",
|
|
65
119
|
f"Requested model: {status.get('requested_model')}",
|
|
66
120
|
f"Resolved local model: {resolved_model}",
|
|
121
|
+
f"Server version: {status.get('server_version') or 'unknown'}",
|
|
67
122
|
f"Timeout (s): {status.get('timeout_seconds')}",
|
|
68
123
|
f"Installed models: {installed_text}",
|
|
69
124
|
(
|
|
@@ -78,9 +133,81 @@ def _render_local_check(console: Console, status: dict[str, object]) -> None:
|
|
|
78
133
|
)
|
|
79
134
|
)
|
|
80
135
|
if ready:
|
|
81
|
-
console.print("[green]Local Ollama backend is ready for Ministral inference.[/green]")
|
|
136
|
+
console.print("[green]Local Ollama backend is ready for open Ministral 3 inference.[/green]")
|
|
82
137
|
else:
|
|
83
138
|
console.print("[bold red]Local Ollama backend is reachable, but the requested model is missing.[/bold red]")
|
|
139
|
+
if status.get("version_ok") is False:
|
|
140
|
+
console.print("[bold red]Ollama is too old for the open Ministral 3 runtime.[/bold red]")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@app.command("models")
|
|
144
|
+
def models() -> None:
|
|
145
|
+
console = Console()
|
|
146
|
+
table = Table(title="IINTS AI Local Mistral Model Guide")
|
|
147
|
+
table.add_column("Model Tag", style="cyan", no_wrap=True)
|
|
148
|
+
table.add_column("Best For", style="green")
|
|
149
|
+
table.add_column("Approx Download")
|
|
150
|
+
table.add_column("System RAM")
|
|
151
|
+
table.add_column("GPU VRAM")
|
|
152
|
+
table.add_column("Notes", overflow="fold")
|
|
153
|
+
|
|
154
|
+
for profile in list_local_mistral_models():
|
|
155
|
+
vram = f"{profile.recommended_vram_gb}+ GB" if profile.recommended_vram_gb is not None else "CPU-only"
|
|
156
|
+
table.add_row(
|
|
157
|
+
profile.tag,
|
|
158
|
+
profile.fit,
|
|
159
|
+
f"{profile.approx_download_gb:.1f} GB",
|
|
160
|
+
f"{profile.recommended_system_ram_gb}+ GB",
|
|
161
|
+
vram,
|
|
162
|
+
profile.notes,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
console.print(table)
|
|
166
|
+
console.print(
|
|
167
|
+
"[dim]Tip:[/dim] start with "
|
|
168
|
+
f"`{DEFAULT_MINISTRAL_MODEL}` unless you know your hardware can comfortably run a larger local model."
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@app.command("prepare")
|
|
173
|
+
def prepare(
|
|
174
|
+
run_dir: Annotated[Path, typer.Argument(help="Run output directory containing results.csv and run_metadata.json.")],
|
|
175
|
+
create_dev_mdmp_cert: Annotated[
|
|
176
|
+
bool,
|
|
177
|
+
typer.Option(
|
|
178
|
+
"--create-dev-mdmp-cert/--no-create-dev-mdmp-cert",
|
|
179
|
+
help="Generate a local development MDMP certificate and keypair for AI commands.",
|
|
180
|
+
),
|
|
181
|
+
] = True,
|
|
182
|
+
grade: Annotated[str, typer.Option(help="Grade to embed in the local development MDMP certificate.")] = "research_grade",
|
|
183
|
+
expires_days: Annotated[int, typer.Option(help="Certificate expiry window in days for local development certs.")] = 30,
|
|
184
|
+
key_dir: Annotated[Optional[Path], typer.Option(help="Optional directory to store the generated local MDMP keypair.")] = None,
|
|
185
|
+
) -> None:
|
|
186
|
+
console = Console()
|
|
187
|
+
try:
|
|
188
|
+
outputs = prepare_ai_ready_artifacts(
|
|
189
|
+
run_dir,
|
|
190
|
+
create_dev_mdmp_cert=create_dev_mdmp_cert,
|
|
191
|
+
grade=grade,
|
|
192
|
+
expires_days=expires_days,
|
|
193
|
+
key_dir=key_dir,
|
|
194
|
+
)
|
|
195
|
+
except Exception as exc:
|
|
196
|
+
console.print(f"[bold red]Error:[/bold red] {exc}")
|
|
197
|
+
raise typer.Exit(code=1)
|
|
198
|
+
|
|
199
|
+
table = Table(title="IINTS AI Prepared Artifacts")
|
|
200
|
+
table.add_column("Artifact", style="cyan")
|
|
201
|
+
table.add_column("Path", overflow="fold")
|
|
202
|
+
for key, value in outputs.items():
|
|
203
|
+
table.add_row(key, value)
|
|
204
|
+
console.print(table)
|
|
205
|
+
console.print("[green]Prepared AI payloads are ready.[/green]")
|
|
206
|
+
if "mdmp_cert" in outputs:
|
|
207
|
+
console.print(
|
|
208
|
+
"[green]You can now run:[/green] "
|
|
209
|
+
f"`iints ai report {run_dir}` or `iints ai explain {run_dir}`"
|
|
210
|
+
)
|
|
84
211
|
|
|
85
212
|
|
|
86
213
|
def _build_assistant(
|
|
@@ -135,8 +262,8 @@ def local_check(
|
|
|
135
262
|
|
|
136
263
|
@app.command("explain")
|
|
137
264
|
def explain(
|
|
138
|
-
input_json: Annotated[Path, typer.Argument(help="JSON file with a single simulation step or decision context.")],
|
|
139
|
-
mdmp_cert: Annotated[Path, typer.Option(help="Signed MDMP artifact required before AI analysis can run.")],
|
|
265
|
+
input_json: Annotated[Path, typer.Argument(help="Prepared run directory or JSON file with a single simulation step or decision context.")],
|
|
266
|
+
mdmp_cert: Annotated[Optional[Path], typer.Option(help="Signed MDMP artifact required before AI analysis can run.")] = None,
|
|
140
267
|
mode: Annotated[str, typer.Option(help="AI backend mode. Use 'local' for Ollama/Ministral.")] = "auto",
|
|
141
268
|
model: Annotated[str, typer.Option(help="Ollama model name to use.")] = DEFAULT_MINISTRAL_MODEL,
|
|
142
269
|
minimum_grade: Annotated[str, typer.Option(help="Minimum MDMP grade required to allow analysis.")] = "research_grade",
|
|
@@ -148,13 +275,20 @@ def explain(
|
|
|
148
275
|
) -> None:
|
|
149
276
|
console = Console()
|
|
150
277
|
try:
|
|
151
|
-
|
|
152
|
-
|
|
278
|
+
resolved_input, resolved_cert, resolved_public_key = _resolve_cli_inputs(
|
|
279
|
+
task="explain",
|
|
280
|
+
input_path=input_json,
|
|
153
281
|
mdmp_cert=mdmp_cert,
|
|
282
|
+
public_key=public_key,
|
|
283
|
+
trust_store=trust_store,
|
|
284
|
+
)
|
|
285
|
+
payload = _load_json_payload(resolved_input, "Input JSON")
|
|
286
|
+
assistant = _build_assistant(
|
|
287
|
+
mdmp_cert=resolved_cert,
|
|
154
288
|
mode=mode,
|
|
155
289
|
model=model,
|
|
156
290
|
minimum_grade=minimum_grade,
|
|
157
|
-
public_key=
|
|
291
|
+
public_key=resolved_public_key,
|
|
158
292
|
trust_store=trust_store,
|
|
159
293
|
ollama_host=ollama_host,
|
|
160
294
|
timeout_seconds=timeout_seconds,
|
|
@@ -169,8 +303,8 @@ def explain(
|
|
|
169
303
|
|
|
170
304
|
@app.command("trends")
|
|
171
305
|
def trends(
|
|
172
|
-
input_json: Annotated[Path, typer.Argument(help="JSON file with glucose trace data or a run payload.")],
|
|
173
|
-
mdmp_cert: Annotated[Path, typer.Option(help="Signed MDMP artifact required before AI analysis can run.")],
|
|
306
|
+
input_json: Annotated[Path, typer.Argument(help="Prepared run directory or JSON file with glucose trace data or a run payload.")],
|
|
307
|
+
mdmp_cert: Annotated[Optional[Path], typer.Option(help="Signed MDMP artifact required before AI analysis can run.")] = None,
|
|
174
308
|
mode: Annotated[str, typer.Option(help="AI backend mode. Use 'local' for Ollama/Ministral.")] = "auto",
|
|
175
309
|
model: Annotated[str, typer.Option(help="Ollama model name to use.")] = DEFAULT_MINISTRAL_MODEL,
|
|
176
310
|
minimum_grade: Annotated[str, typer.Option(help="Minimum MDMP grade required to allow analysis.")] = "research_grade",
|
|
@@ -182,13 +316,20 @@ def trends(
|
|
|
182
316
|
) -> None:
|
|
183
317
|
console = Console()
|
|
184
318
|
try:
|
|
185
|
-
|
|
186
|
-
|
|
319
|
+
resolved_input, resolved_cert, resolved_public_key = _resolve_cli_inputs(
|
|
320
|
+
task="trends",
|
|
321
|
+
input_path=input_json,
|
|
187
322
|
mdmp_cert=mdmp_cert,
|
|
323
|
+
public_key=public_key,
|
|
324
|
+
trust_store=trust_store,
|
|
325
|
+
)
|
|
326
|
+
payload = _load_json_payload(resolved_input, "Input JSON")
|
|
327
|
+
assistant = _build_assistant(
|
|
328
|
+
mdmp_cert=resolved_cert,
|
|
188
329
|
mode=mode,
|
|
189
330
|
model=model,
|
|
190
331
|
minimum_grade=minimum_grade,
|
|
191
|
-
public_key=
|
|
332
|
+
public_key=resolved_public_key,
|
|
192
333
|
trust_store=trust_store,
|
|
193
334
|
ollama_host=ollama_host,
|
|
194
335
|
timeout_seconds=timeout_seconds,
|
|
@@ -203,8 +344,8 @@ def trends(
|
|
|
203
344
|
|
|
204
345
|
@app.command("anomalies")
|
|
205
346
|
def anomalies(
|
|
206
|
-
input_json: Annotated[Path, typer.Argument(help="JSON file with simulation results or run summary.")],
|
|
207
|
-
mdmp_cert: Annotated[Path, typer.Option(help="Signed MDMP artifact required before AI analysis can run.")],
|
|
347
|
+
input_json: Annotated[Path, typer.Argument(help="Prepared run directory or JSON file with simulation results or run summary.")],
|
|
348
|
+
mdmp_cert: Annotated[Optional[Path], typer.Option(help="Signed MDMP artifact required before AI analysis can run.")] = None,
|
|
208
349
|
mode: Annotated[str, typer.Option(help="AI backend mode. Use 'local' for Ollama/Ministral.")] = "auto",
|
|
209
350
|
model: Annotated[str, typer.Option(help="Ollama model name to use.")] = DEFAULT_MINISTRAL_MODEL,
|
|
210
351
|
minimum_grade: Annotated[str, typer.Option(help="Minimum MDMP grade required to allow analysis.")] = "research_grade",
|
|
@@ -216,13 +357,20 @@ def anomalies(
|
|
|
216
357
|
) -> None:
|
|
217
358
|
console = Console()
|
|
218
359
|
try:
|
|
219
|
-
|
|
220
|
-
|
|
360
|
+
resolved_input, resolved_cert, resolved_public_key = _resolve_cli_inputs(
|
|
361
|
+
task="anomalies",
|
|
362
|
+
input_path=input_json,
|
|
221
363
|
mdmp_cert=mdmp_cert,
|
|
364
|
+
public_key=public_key,
|
|
365
|
+
trust_store=trust_store,
|
|
366
|
+
)
|
|
367
|
+
payload = _load_json_payload(resolved_input, "Input JSON")
|
|
368
|
+
assistant = _build_assistant(
|
|
369
|
+
mdmp_cert=resolved_cert,
|
|
222
370
|
mode=mode,
|
|
223
371
|
model=model,
|
|
224
372
|
minimum_grade=minimum_grade,
|
|
225
|
-
public_key=
|
|
373
|
+
public_key=resolved_public_key,
|
|
226
374
|
trust_store=trust_store,
|
|
227
375
|
ollama_host=ollama_host,
|
|
228
376
|
timeout_seconds=timeout_seconds,
|
|
@@ -237,8 +385,8 @@ def anomalies(
|
|
|
237
385
|
|
|
238
386
|
@app.command("report")
|
|
239
387
|
def report(
|
|
240
|
-
input_json: Annotated[Path, typer.Argument(help="JSON file with run-level simulation outputs.")],
|
|
241
|
-
mdmp_cert: Annotated[Path, typer.Option(help="Signed MDMP artifact required before AI analysis can run.")],
|
|
388
|
+
input_json: Annotated[Path, typer.Argument(help="Prepared run directory or JSON file with run-level simulation outputs.")],
|
|
389
|
+
mdmp_cert: Annotated[Optional[Path], typer.Option(help="Signed MDMP artifact required before AI analysis can run.")] = None,
|
|
242
390
|
mode: Annotated[str, typer.Option(help="AI backend mode. Use 'local' for Ollama/Ministral.")] = "auto",
|
|
243
391
|
model: Annotated[str, typer.Option(help="Ollama model name to use.")] = DEFAULT_MINISTRAL_MODEL,
|
|
244
392
|
minimum_grade: Annotated[str, typer.Option(help="Minimum MDMP grade required to allow analysis.")] = "research_grade",
|
|
@@ -250,13 +398,20 @@ def report(
|
|
|
250
398
|
) -> None:
|
|
251
399
|
console = Console()
|
|
252
400
|
try:
|
|
253
|
-
|
|
254
|
-
|
|
401
|
+
resolved_input, resolved_cert, resolved_public_key = _resolve_cli_inputs(
|
|
402
|
+
task="report",
|
|
403
|
+
input_path=input_json,
|
|
255
404
|
mdmp_cert=mdmp_cert,
|
|
405
|
+
public_key=public_key,
|
|
406
|
+
trust_store=trust_store,
|
|
407
|
+
)
|
|
408
|
+
payload = _load_json_payload(resolved_input, "Input JSON")
|
|
409
|
+
assistant = _build_assistant(
|
|
410
|
+
mdmp_cert=resolved_cert,
|
|
256
411
|
mode=mode,
|
|
257
412
|
model=model,
|
|
258
413
|
minimum_grade=minimum_grade,
|
|
259
|
-
public_key=
|
|
414
|
+
public_key=resolved_public_key,
|
|
260
415
|
trust_store=trust_store,
|
|
261
416
|
ollama_host=ollama_host,
|
|
262
417
|
timeout_seconds=timeout_seconds,
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from .backends.ollama import DEFAULT_MINISTRAL_MODEL
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class LocalMistralModelProfile:
|
|
10
|
+
tag: str
|
|
11
|
+
label: str
|
|
12
|
+
approx_download_gb: float
|
|
13
|
+
recommended_system_ram_gb: int
|
|
14
|
+
recommended_vram_gb: int | None
|
|
15
|
+
fit: str
|
|
16
|
+
notes: str
|
|
17
|
+
aliases: tuple[str, ...] = ()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
LOCAL_MISTRAL_MODEL_PROFILES: tuple[LocalMistralModelProfile, ...] = (
|
|
21
|
+
LocalMistralModelProfile(
|
|
22
|
+
tag="ministral-3:3b",
|
|
23
|
+
label="Ministral 3 3B",
|
|
24
|
+
approx_download_gb=3.0,
|
|
25
|
+
recommended_system_ram_gb=16,
|
|
26
|
+
recommended_vram_gb=6,
|
|
27
|
+
fit="Entry-level laptop / small edge box",
|
|
28
|
+
notes="Best starting point for CPU-only systems or modest GPUs. Fastest option, lowest memory pressure.",
|
|
29
|
+
aliases=("ministral-3:3b",),
|
|
30
|
+
),
|
|
31
|
+
LocalMistralModelProfile(
|
|
32
|
+
tag=DEFAULT_MINISTRAL_MODEL,
|
|
33
|
+
label="Ministral 3 8B",
|
|
34
|
+
approx_download_gb=6.0,
|
|
35
|
+
recommended_system_ram_gb=24,
|
|
36
|
+
recommended_vram_gb=10,
|
|
37
|
+
fit="Balanced desktop / strong laptop",
|
|
38
|
+
notes="Recommended default for most users. Best trade-off between quality, speed, and local memory footprint.",
|
|
39
|
+
aliases=("ministral", "ministral-3", "ministral-3:8b"),
|
|
40
|
+
),
|
|
41
|
+
LocalMistralModelProfile(
|
|
42
|
+
tag="ministral-3:14b",
|
|
43
|
+
label="Ministral 3 14B",
|
|
44
|
+
approx_download_gb=10.0,
|
|
45
|
+
recommended_system_ram_gb=32,
|
|
46
|
+
recommended_vram_gb=16,
|
|
47
|
+
fit="High-end workstation",
|
|
48
|
+
notes="Use when you have plenty of RAM or a strong GPU and want better reasoning depth at the cost of latency.",
|
|
49
|
+
aliases=("ministral-3:14b",),
|
|
50
|
+
),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def list_local_mistral_models() -> list[LocalMistralModelProfile]:
|
|
55
|
+
return list(LOCAL_MISTRAL_MODEL_PROFILES)
|
iints/ai/prepare.py
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
import importlib
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
|
|
10
|
+
import pandas as pd
|
|
11
|
+
|
|
12
|
+
from iints.utils.run_io import compute_sha256
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _now_utc() -> str:
|
|
16
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _read_json(path: Path) -> dict[str, Any]:
|
|
20
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
21
|
+
if not isinstance(payload, dict):
|
|
22
|
+
raise ValueError(f"Expected JSON object in {path}")
|
|
23
|
+
return payload
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _normalize_value(value: Any) -> Any:
|
|
27
|
+
if value is None:
|
|
28
|
+
return None
|
|
29
|
+
if isinstance(value, (str, bool, int)):
|
|
30
|
+
return value
|
|
31
|
+
if isinstance(value, float):
|
|
32
|
+
if math.isnan(value) or math.isinf(value):
|
|
33
|
+
return None
|
|
34
|
+
return round(value, 4)
|
|
35
|
+
if hasattr(value, "item"):
|
|
36
|
+
return _normalize_value(value.item())
|
|
37
|
+
return value
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _normalize_record(record: dict[str, Any]) -> dict[str, Any]:
|
|
41
|
+
return {key: _normalize_value(value) for key, value in record.items()}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _normalize_series_record(record: Any) -> dict[str, Any]:
|
|
45
|
+
if not isinstance(record, dict):
|
|
46
|
+
return {}
|
|
47
|
+
return {str(key): _normalize_value(value) for key, value in record.items()}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _glucose_column(df: pd.DataFrame) -> str:
|
|
51
|
+
for candidate in ("glucose_actual_mgdl", "glucose_to_algo_mgdl", "cgm"):
|
|
52
|
+
if candidate in df.columns:
|
|
53
|
+
return candidate
|
|
54
|
+
raise ValueError("Results CSV does not contain a supported glucose column.")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _bool_sum(df: pd.DataFrame, column: str) -> int:
|
|
58
|
+
if column not in df.columns:
|
|
59
|
+
return 0
|
|
60
|
+
return int(df[column].fillna(False).astype(bool).sum())
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _safe_sum(df: pd.DataFrame, column: str) -> float:
|
|
64
|
+
if column not in df.columns:
|
|
65
|
+
return 0.0
|
|
66
|
+
return float(pd.to_numeric(df[column], errors="coerce").fillna(0.0).sum())
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _time_in_band_pct(series: pd.Series, low: float, high: float) -> float:
|
|
70
|
+
clean = pd.to_numeric(series, errors="coerce").dropna()
|
|
71
|
+
if clean.empty:
|
|
72
|
+
return 0.0
|
|
73
|
+
mask = (clean >= low) & (clean <= high)
|
|
74
|
+
return float(mask.mean() * 100.0)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _sample_trace(df: pd.DataFrame, *, max_rows: int = 48) -> list[dict[str, Any]]:
|
|
78
|
+
interesting_columns = [
|
|
79
|
+
"time_minutes",
|
|
80
|
+
"glucose_actual_mgdl",
|
|
81
|
+
"glucose_to_algo_mgdl",
|
|
82
|
+
"glucose_trend_mgdl_min",
|
|
83
|
+
"predicted_glucose_30min",
|
|
84
|
+
"algo_recommended_insulin_units",
|
|
85
|
+
"delivered_insulin_units",
|
|
86
|
+
"safety_triggered",
|
|
87
|
+
"safety_reason",
|
|
88
|
+
]
|
|
89
|
+
present = [column for column in interesting_columns if column in df.columns]
|
|
90
|
+
if not present:
|
|
91
|
+
return []
|
|
92
|
+
if len(df) <= max_rows:
|
|
93
|
+
sampled = df[present]
|
|
94
|
+
else:
|
|
95
|
+
step = max(1, len(df) // max_rows)
|
|
96
|
+
sampled = df.iloc[::step][present].head(max_rows)
|
|
97
|
+
return [_normalize_series_record(record) for record in sampled.to_dict(orient="records")]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _position_for_label(df: pd.DataFrame, label: Any) -> int:
|
|
101
|
+
location = df.index.get_loc(label)
|
|
102
|
+
if isinstance(location, int):
|
|
103
|
+
return location
|
|
104
|
+
raise ValueError(f"Could not resolve row position for label: {label!r}")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _select_step_payload(df: pd.DataFrame) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
108
|
+
if df.empty:
|
|
109
|
+
raise ValueError("Results CSV is empty.")
|
|
110
|
+
|
|
111
|
+
glucose_column = _glucose_column(df)
|
|
112
|
+
risk_position = len(df.index) - 1
|
|
113
|
+
selection_reason = "latest_step"
|
|
114
|
+
|
|
115
|
+
if "safety_triggered" in df.columns and df["safety_triggered"].fillna(False).astype(bool).any():
|
|
116
|
+
safety_rows = df[df["safety_triggered"].fillna(False).astype(bool)]
|
|
117
|
+
risk_position = _position_for_label(df, safety_rows.index[0])
|
|
118
|
+
selection_reason = "first_safety_trigger"
|
|
119
|
+
else:
|
|
120
|
+
glucose_values = pd.to_numeric(df[glucose_column], errors="coerce")
|
|
121
|
+
if (glucose_values < 70).any():
|
|
122
|
+
risk_label = glucose_values.idxmin()
|
|
123
|
+
risk_position = _position_for_label(df, risk_label)
|
|
124
|
+
selection_reason = "lowest_glucose"
|
|
125
|
+
elif "predicted_glucose_30min" in df.columns:
|
|
126
|
+
predicted = pd.to_numeric(df["predicted_glucose_30min"], errors="coerce")
|
|
127
|
+
if (predicted > 180).any():
|
|
128
|
+
risk_label = predicted.idxmax()
|
|
129
|
+
risk_position = _position_for_label(df, risk_label)
|
|
130
|
+
selection_reason = "highest_predicted_glucose_30min"
|
|
131
|
+
latest_position = len(df.index) - 1
|
|
132
|
+
|
|
133
|
+
def _row_at(position: int) -> Optional[dict[str, Any]]:
|
|
134
|
+
if position < 0 or position >= len(df.index):
|
|
135
|
+
return None
|
|
136
|
+
return _normalize_series_record(df.iloc[position].to_dict())
|
|
137
|
+
|
|
138
|
+
risk_payload = {
|
|
139
|
+
"selection_reason": selection_reason,
|
|
140
|
+
"selected_step": _row_at(risk_position),
|
|
141
|
+
"previous_step": _row_at(risk_position - 1),
|
|
142
|
+
"next_step": _row_at(risk_position + 1),
|
|
143
|
+
}
|
|
144
|
+
latest_payload = {
|
|
145
|
+
"selection_reason": "latest_step",
|
|
146
|
+
"selected_step": _row_at(latest_position),
|
|
147
|
+
"previous_step": _row_at(latest_position - 1),
|
|
148
|
+
"next_step": None,
|
|
149
|
+
}
|
|
150
|
+
return risk_payload, latest_payload
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _build_summary(df: pd.DataFrame, run_metadata: dict[str, Any], audit_summary: dict[str, Any]) -> dict[str, Any]:
|
|
154
|
+
glucose_column = _glucose_column(df)
|
|
155
|
+
glucose = pd.to_numeric(df[glucose_column], errors="coerce").dropna()
|
|
156
|
+
if glucose.empty:
|
|
157
|
+
raise ValueError("Results CSV glucose series is empty.")
|
|
158
|
+
|
|
159
|
+
duration_minutes = run_metadata.get("config", {}).get("duration_minutes")
|
|
160
|
+
if duration_minutes is None and "time_minutes" in df.columns:
|
|
161
|
+
duration_minutes = _normalize_value(pd.to_numeric(df["time_minutes"], errors="coerce").max())
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
"steps": int(len(df)),
|
|
165
|
+
"duration_minutes": _normalize_value(duration_minutes),
|
|
166
|
+
"mean_glucose_mgdl": _normalize_value(float(glucose.mean())),
|
|
167
|
+
"min_glucose_mgdl": _normalize_value(float(glucose.min())),
|
|
168
|
+
"max_glucose_mgdl": _normalize_value(float(glucose.max())),
|
|
169
|
+
"time_in_range_70_180_pct": _normalize_value(_time_in_band_pct(glucose, 70.0, 180.0)),
|
|
170
|
+
"time_below_70_pct": _normalize_value(float((glucose < 70.0).mean() * 100.0)),
|
|
171
|
+
"time_above_180_pct": _normalize_value(float((glucose > 180.0).mean() * 100.0)),
|
|
172
|
+
"delivered_insulin_total_units": _normalize_value(_safe_sum(df, "delivered_insulin_units")),
|
|
173
|
+
"recommended_insulin_total_units": _normalize_value(_safe_sum(df, "algo_recommended_insulin_units")),
|
|
174
|
+
"safety_trigger_count": _bool_sum(df, "safety_triggered"),
|
|
175
|
+
"audit_override_count": int(audit_summary.get("total_overrides", 0)),
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _build_payloads(
|
|
180
|
+
*,
|
|
181
|
+
run_dir: Path,
|
|
182
|
+
results_df: pd.DataFrame,
|
|
183
|
+
run_metadata: dict[str, Any],
|
|
184
|
+
run_manifest: dict[str, Any],
|
|
185
|
+
audit_summary: dict[str, Any],
|
|
186
|
+
baseline_comparison: dict[str, Any] | None,
|
|
187
|
+
) -> dict[str, dict[str, Any]]:
|
|
188
|
+
summary = _build_summary(results_df, run_metadata, audit_summary)
|
|
189
|
+
risk_payload, latest_payload = _select_step_payload(results_df)
|
|
190
|
+
trace_sample = _sample_trace(results_df)
|
|
191
|
+
|
|
192
|
+
common = {
|
|
193
|
+
"generated_at_utc": _now_utc(),
|
|
194
|
+
"run_dir": str(run_dir),
|
|
195
|
+
"run_id": run_metadata.get("run_id"),
|
|
196
|
+
"sdk_version": run_metadata.get("sdk_version"),
|
|
197
|
+
"algorithm": run_metadata.get("config", {}).get("algorithm", {}),
|
|
198
|
+
"scenario": run_metadata.get("config", {}).get("scenario"),
|
|
199
|
+
"summary": summary,
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
payloads: dict[str, dict[str, Any]] = {
|
|
203
|
+
"report_payload.json": {
|
|
204
|
+
**common,
|
|
205
|
+
"artifacts": {
|
|
206
|
+
"run_metadata": str(run_dir / "run_metadata.json"),
|
|
207
|
+
"run_manifest": str(run_dir / "run_manifest.json"),
|
|
208
|
+
"results_csv": str(run_dir / "results.csv"),
|
|
209
|
+
"audit_summary": str(run_dir / "audit" / "audit_summary.json"),
|
|
210
|
+
"baseline_comparison": str(run_dir / "baseline" / "baseline_comparison.json"),
|
|
211
|
+
},
|
|
212
|
+
"audit_summary": audit_summary,
|
|
213
|
+
"baseline_comparison": baseline_comparison,
|
|
214
|
+
"trace_sample": trace_sample,
|
|
215
|
+
"run_manifest": run_manifest,
|
|
216
|
+
},
|
|
217
|
+
"anomalies_payload.json": {
|
|
218
|
+
**common,
|
|
219
|
+
"audit_summary": audit_summary,
|
|
220
|
+
"safety_events": [
|
|
221
|
+
record
|
|
222
|
+
for record in trace_sample
|
|
223
|
+
if bool(record.get("safety_triggered"))
|
|
224
|
+
],
|
|
225
|
+
},
|
|
226
|
+
"trends_payload.json": {
|
|
227
|
+
**common,
|
|
228
|
+
"trace_sample": trace_sample,
|
|
229
|
+
"baseline_comparison": baseline_comparison,
|
|
230
|
+
},
|
|
231
|
+
"step_riskiest.json": {
|
|
232
|
+
**common,
|
|
233
|
+
**risk_payload,
|
|
234
|
+
},
|
|
235
|
+
"step_latest.json": {
|
|
236
|
+
**common,
|
|
237
|
+
**latest_payload,
|
|
238
|
+
},
|
|
239
|
+
}
|
|
240
|
+
return payloads
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _load_mdmp_signer_tools() -> tuple[type[Any], Any]:
|
|
244
|
+
try:
|
|
245
|
+
module = importlib.import_module("mdmp_core")
|
|
246
|
+
except Exception as exc:
|
|
247
|
+
raise ImportError(
|
|
248
|
+
"Local AI certification requires the optional standalone MDMP package.\n"
|
|
249
|
+
"Install with: pip install 'iints-sdk-python35[mdmp]'"
|
|
250
|
+
) from exc
|
|
251
|
+
|
|
252
|
+
signer_cls = getattr(module, "MDMPSigner", None)
|
|
253
|
+
keygen_fn = getattr(module, "generate_keypair", None)
|
|
254
|
+
if signer_cls is None or keygen_fn is None:
|
|
255
|
+
raise ImportError("mdmp_core is installed but does not expose MDMPSigner/generate_keypair.")
|
|
256
|
+
return signer_cls, keygen_fn
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _write_json(path: Path, payload: dict[str, Any]) -> None:
|
|
260
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
261
|
+
path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def prepare_ai_ready_artifacts(
|
|
265
|
+
run_dir: str | Path,
|
|
266
|
+
*,
|
|
267
|
+
create_dev_mdmp_cert: bool = True,
|
|
268
|
+
grade: str = "research_grade",
|
|
269
|
+
expires_days: int = 30,
|
|
270
|
+
key_dir: str | Path | None = None,
|
|
271
|
+
) -> dict[str, str]:
|
|
272
|
+
bundle_dir = Path(run_dir).expanduser().resolve()
|
|
273
|
+
if not bundle_dir.is_dir():
|
|
274
|
+
raise FileNotFoundError(f"Run directory not found: {bundle_dir}")
|
|
275
|
+
|
|
276
|
+
results_csv = bundle_dir / "results.csv"
|
|
277
|
+
run_metadata_path = bundle_dir / "run_metadata.json"
|
|
278
|
+
run_manifest_path = bundle_dir / "run_manifest.json"
|
|
279
|
+
|
|
280
|
+
for required in (results_csv, run_metadata_path, run_manifest_path):
|
|
281
|
+
if not required.is_file():
|
|
282
|
+
raise FileNotFoundError(f"Required run artifact missing: {required}")
|
|
283
|
+
|
|
284
|
+
results_df = pd.read_csv(results_csv)
|
|
285
|
+
run_metadata = _read_json(run_metadata_path)
|
|
286
|
+
run_manifest = _read_json(run_manifest_path)
|
|
287
|
+
|
|
288
|
+
audit_summary_path = bundle_dir / "audit" / "audit_summary.json"
|
|
289
|
+
baseline_path = bundle_dir / "baseline" / "baseline_comparison.json"
|
|
290
|
+
audit_summary = _read_json(audit_summary_path) if audit_summary_path.is_file() else {}
|
|
291
|
+
baseline_comparison = _read_json(baseline_path) if baseline_path.is_file() else None
|
|
292
|
+
|
|
293
|
+
ai_dir = bundle_dir / "ai"
|
|
294
|
+
payloads = _build_payloads(
|
|
295
|
+
run_dir=bundle_dir,
|
|
296
|
+
results_df=results_df,
|
|
297
|
+
run_metadata=run_metadata,
|
|
298
|
+
run_manifest=run_manifest,
|
|
299
|
+
audit_summary=audit_summary,
|
|
300
|
+
baseline_comparison=baseline_comparison,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
written: dict[str, str] = {}
|
|
304
|
+
for filename, payload in payloads.items():
|
|
305
|
+
target = ai_dir / filename
|
|
306
|
+
_write_json(target, payload)
|
|
307
|
+
written[filename.removesuffix(".json")] = str(target)
|
|
308
|
+
|
|
309
|
+
if create_dev_mdmp_cert:
|
|
310
|
+
signer_cls, keygen_fn = _load_mdmp_signer_tools()
|
|
311
|
+
resolved_key_dir = Path(key_dir).expanduser().resolve() if key_dir else ai_dir / "keys"
|
|
312
|
+
private_key_path = resolved_key_dir / "mdmp_private_v1.pem"
|
|
313
|
+
public_key_path = resolved_key_dir / "mdmp_pub_v1.pem"
|
|
314
|
+
if not private_key_path.is_file() or not public_key_path.is_file():
|
|
315
|
+
keygen_fn(output_dir=resolved_key_dir)
|
|
316
|
+
|
|
317
|
+
cert_payload = {
|
|
318
|
+
"mdmp_object": "iints_ai_local_cert",
|
|
319
|
+
"spec_version": "1.0",
|
|
320
|
+
"grade": grade,
|
|
321
|
+
"generated_at_utc": _now_utc(),
|
|
322
|
+
"run_id": run_metadata.get("run_id"),
|
|
323
|
+
"run_dir": str(bundle_dir),
|
|
324
|
+
"sdk_version": run_metadata.get("sdk_version"),
|
|
325
|
+
"purpose": "local_research_ai",
|
|
326
|
+
"results_csv_sha256": f"sha256:{compute_sha256(results_csv)}",
|
|
327
|
+
"run_manifest_sha256": f"sha256:{compute_sha256(run_manifest_path)}",
|
|
328
|
+
"notes": "Local development certificate generated by IINTS AI prepare.",
|
|
329
|
+
}
|
|
330
|
+
signer = signer_cls(
|
|
331
|
+
private_key_path=private_key_path,
|
|
332
|
+
signed_by="IINTS-Local-AI",
|
|
333
|
+
key_id="iints_local_ai_v1",
|
|
334
|
+
)
|
|
335
|
+
signed_cert = signer.sign_card(cert_payload, expires_days=expires_days)
|
|
336
|
+
cert_path = ai_dir / "report.signed.mdmp"
|
|
337
|
+
_write_json(cert_path, signed_cert)
|
|
338
|
+
written["mdmp_cert"] = str(cert_path)
|
|
339
|
+
written["mdmp_public_key"] = str(public_key_path)
|
|
340
|
+
written["mdmp_private_key"] = str(private_key_path)
|
|
341
|
+
|
|
342
|
+
return written
|
iints/cli/cli.py
CHANGED
|
@@ -22,6 +22,7 @@ from rich.table import Table # type: ignore # For comparison table
|
|
|
22
22
|
from rich.panel import Panel # type: ignore # For nicer auto-doc output
|
|
23
23
|
|
|
24
24
|
import iints # Import the top-level SDK package
|
|
25
|
+
from iints.ai import prepare_ai_ready_artifacts
|
|
25
26
|
from iints.ai.cli import app as ai_app
|
|
26
27
|
from iints.analysis.baseline import run_baseline_comparison, write_baseline_comparison
|
|
27
28
|
from iints.api.registry import list_algorithm_plugins
|
|
@@ -278,6 +279,30 @@ def _write_certification_summary(
|
|
|
278
279
|
return summary_path
|
|
279
280
|
|
|
280
281
|
|
|
282
|
+
def _maybe_prepare_ai_artifacts(output_dir: Path, console: Console) -> None:
|
|
283
|
+
try:
|
|
284
|
+
outputs = prepare_ai_ready_artifacts(output_dir, create_dev_mdmp_cert=True)
|
|
285
|
+
console.print(f"[green]AI-ready artifacts:[/green] {output_dir / 'ai'}")
|
|
286
|
+
if "mdmp_cert" in outputs:
|
|
287
|
+
console.print(f"[green]AI quick start:[/green] iints ai report {output_dir}")
|
|
288
|
+
return
|
|
289
|
+
except ImportError as exc:
|
|
290
|
+
console.print(f"[yellow]AI dev certificate skipped:[/yellow] {exc}")
|
|
291
|
+
except Exception as exc:
|
|
292
|
+
console.print(f"[yellow]AI-ready export skipped:[/yellow] {exc}")
|
|
293
|
+
return
|
|
294
|
+
|
|
295
|
+
try:
|
|
296
|
+
prepare_ai_ready_artifacts(output_dir, create_dev_mdmp_cert=False)
|
|
297
|
+
console.print(f"[green]AI-ready payloads:[/green] {output_dir / 'ai'}")
|
|
298
|
+
console.print(
|
|
299
|
+
"[yellow]Tip:[/yellow] Install the MDMP extra or rerun "
|
|
300
|
+
f"`iints ai prepare {output_dir}` to generate a local development certificate."
|
|
301
|
+
)
|
|
302
|
+
except Exception as exc:
|
|
303
|
+
console.print(f"[yellow]AI-ready payload export skipped:[/yellow] {exc}")
|
|
304
|
+
|
|
305
|
+
|
|
281
306
|
def _get_preset(name: str) -> Dict[str, Any]:
|
|
282
307
|
presets = _load_presets()
|
|
283
308
|
for preset in presets:
|
|
@@ -1698,9 +1723,11 @@ def presets_run(
|
|
|
1698
1723
|
run_manifest_path = output_dir / "run_manifest.json"
|
|
1699
1724
|
write_json(run_manifest_path, run_manifest)
|
|
1700
1725
|
console.print(f"Run manifest: {run_manifest_path}")
|
|
1726
|
+
_maybe_prepare_ai_artifacts(output_dir, console)
|
|
1701
1727
|
signature_path = maybe_sign_manifest(run_manifest_path)
|
|
1702
1728
|
if signature_path:
|
|
1703
1729
|
console.print(f"Run manifest signature: {signature_path}")
|
|
1730
|
+
_maybe_prepare_ai_artifacts(output_dir, console)
|
|
1704
1731
|
|
|
1705
1732
|
|
|
1706
1733
|
@presets_app.command("create")
|
|
@@ -2295,6 +2322,8 @@ def run_full(
|
|
|
2295
2322
|
console.print(f"Profiling report: {outputs['profiling_path']}")
|
|
2296
2323
|
if "run_manifest_signature" in outputs:
|
|
2297
2324
|
console.print(f"Run manifest signature: {outputs['run_manifest_signature']}")
|
|
2325
|
+
if "output_dir" in outputs:
|
|
2326
|
+
_maybe_prepare_ai_artifacts(Path(outputs["output_dir"]), console)
|
|
2298
2327
|
|
|
2299
2328
|
|
|
2300
2329
|
@app.command("run-parallel")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: iints-sdk-python35
|
|
3
|
-
Version: 1.1.
|
|
3
|
+
Version: 1.1.2
|
|
4
4
|
Summary: A pre-clinical Edge-AI SDK for diabetes management validation.
|
|
5
5
|
Author-email: Rune Bobbaers <rune.bobbaers@gmail.com>
|
|
6
6
|
Project-URL: Homepage, https://github.com/python35/IINTS-SDK
|
|
@@ -81,7 +81,7 @@ cd iints_quickstart
|
|
|
81
81
|
iints presets run --name baseline_t1d --algo algorithms/example_algorithm.py
|
|
82
82
|
```
|
|
83
83
|
|
|
84
|
-
## AI Assistant (Ministral via Ollama)
|
|
84
|
+
## AI Assistant (Ministral 3 Open-Weight via Ollama)
|
|
85
85
|
|
|
86
86
|
The SDK now includes a research-only AI assistant layer for explanations and run summaries.
|
|
87
87
|
It is gated by MDMP verification before any LLM call is allowed.
|
|
@@ -95,30 +95,59 @@ python -m pip install -U pip
|
|
|
95
95
|
python -m pip install -e ".[mdmp]"
|
|
96
96
|
```
|
|
97
97
|
|
|
98
|
-
Run
|
|
98
|
+
Run the open local Mistral model locally with Ollama:
|
|
99
99
|
|
|
100
100
|
```bash
|
|
101
|
-
|
|
102
|
-
|
|
101
|
+
python -m pip install -e ".[mdmp]"
|
|
102
|
+
ollama pull ministral-3:8b
|
|
103
|
+
iints ai models
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Recommended first-time setup:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
ollama pull ministral-3:8b
|
|
110
|
+
iints ai local-check --model ministral-3:8b
|
|
103
111
|
```
|
|
104
112
|
|
|
105
|
-
|
|
113
|
+
Recommended flow:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
iints quickstart --project-name iints_quickstart
|
|
117
|
+
cd iints_quickstart
|
|
118
|
+
iints presets run --name baseline_t1d --algo algorithms/example_algorithm.py
|
|
119
|
+
iints ai prepare results/<run_id>
|
|
120
|
+
iints ai report results/<run_id>
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Direct JSON mode still works if you already have your own payloads and signed MDMP artifact:
|
|
106
124
|
|
|
107
125
|
```bash
|
|
108
126
|
iints ai explain results/step.json \
|
|
109
127
|
--mdmp-cert results/report.signed.mdmp
|
|
110
|
-
|
|
111
|
-
iints ai report results/simulation_run.json \
|
|
112
|
-
--mdmp-cert results/report.signed.mdmp \
|
|
113
|
-
--output results/ai_report.md
|
|
114
128
|
```
|
|
115
129
|
|
|
116
130
|
Notes:
|
|
117
131
|
- AI analysis is blocked if the MDMP artifact is invalid.
|
|
118
132
|
- Minimum required MDMP grade defaults to `research_grade`.
|
|
133
|
+
- The SDK now targets the open local `Ministral 3` Ollama model by default.
|
|
134
|
+
- Users can choose a larger or smaller local Mistral-family model with `--model ...`.
|
|
119
135
|
- Large JSON payloads are clipped automatically before prompt generation to keep local inference stable.
|
|
136
|
+
- `iints ai prepare <run_dir>` now creates AI-ready JSON payloads and, when MDMP is installed, a local development certificate plus keypair in `<run_dir>/ai/`.
|
|
137
|
+
- After `iints ai prepare`, you can point `iints ai explain|trends|anomalies|report` directly at the run directory.
|
|
120
138
|
- Output is research-only and not medical advice.
|
|
121
139
|
|
|
140
|
+
Troubleshooting:
|
|
141
|
+
- If `iints ai ...` says `No such command 'ai'`, your environment usually still has a legacy `iints` package installed alongside `iints-sdk-python35`.
|
|
142
|
+
- Run `iints-sdk-doctor` first.
|
|
143
|
+
- If it reports a conflict, repair the environment with:
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
python -m pip uninstall -y iints iints-sdk-python35
|
|
147
|
+
python -m pip install -U "iints-sdk-python35[mdmp]==1.1.2"
|
|
148
|
+
hash -r
|
|
149
|
+
```
|
|
150
|
+
|
|
122
151
|
## MDMP (Short)
|
|
123
152
|
MDMP is the data-quality protocol used by IINTS.
|
|
124
153
|
|
|
@@ -1,15 +1,17 @@
|
|
|
1
|
-
iints/__init__.py,sha256=
|
|
1
|
+
iints/__init__.py,sha256=wfAcfS7htgnV4JD-R8_WyKZHOwR8Z98vy-oLYxu0-rE,6391
|
|
2
2
|
iints/highlevel.py,sha256=DX12LRmL6YaYY99P0c_P93xfHe4mZjqyLhTYuS6L6hI,20491
|
|
3
3
|
iints/metrics.py,sha256=O9hqOqJpUhUJDqsbfuqRMS9dkV97gzcgh3Y2jYUqHzg,907
|
|
4
|
-
iints/ai/__init__.py,sha256=
|
|
4
|
+
iints/ai/__init__.py,sha256=nyRDcFfSHI4a3NbTvySipFc3_inqRMEsr6xIEipWuyo,575
|
|
5
5
|
iints/ai/assistant.py,sha256=0Ye1IaWEYg2rZnk3ny8f0GMoYqOWIa7U_GsV-sWrxtU,4346
|
|
6
|
-
iints/ai/cli.py,sha256=
|
|
6
|
+
iints/ai/cli.py,sha256=_1ogEAb36BAt7sZ2CQSRKIJSpdn5xrlM7nNTtBIqfRo,18345
|
|
7
7
|
iints/ai/mdmp_guard.py,sha256=BpFQX0oyP9WMCUZbFhhoBzomNeVKuI1HY1EFH9cG8EE,4249
|
|
8
|
+
iints/ai/model_catalog.py,sha256=gRW-i4eaXkrjX3mIKJlGzHqzU75lpIulEFKQsCX11CI,1804
|
|
9
|
+
iints/ai/prepare.py,sha256=z3y5elCAMv0p_aNq4gQfZA1uIT7_cX3FGRdzmoZoKho,12967
|
|
8
10
|
iints/ai/prompts.py,sha256=pGp9tC1wBZXGG5duxfktaJEF4p_cvmR0zEIxmMTEAyE,2812
|
|
9
11
|
iints/ai/backends/__init__.py,sha256=EAJRZS8G0DK7fffw_LHio9DkyYHwtzvz2Jo7AXk7pk4,303
|
|
10
12
|
iints/ai/backends/base.py,sha256=BLgP03X-jebYkF9D5n5crawoPBmy3RSh4q3jaT8a9XM,274
|
|
11
|
-
iints/ai/backends/mistral_api.py,sha256=
|
|
12
|
-
iints/ai/backends/ollama.py,sha256=
|
|
13
|
+
iints/ai/backends/mistral_api.py,sha256=dousHnzgzuik49822H8nCclYv5NoxHpTMLwtZPVj_TM,507
|
|
14
|
+
iints/ai/backends/ollama.py,sha256=pH_UkrCmm6jI4_Hl-NM_7W4tXOwHZdZ0heeKph72NUk,8739
|
|
13
15
|
iints/analysis/__init__.py,sha256=Qx49KDy0deoxSnVORJB10_BsdezZXKsuoXTR0KZRcqg,411
|
|
14
16
|
iints/analysis/algorithm_xray.py,sha256=-AtXkZsgnsiFQ_K-IozjIDWkq-dDn0i0zmqWVMhINP4,15952
|
|
15
17
|
iints/analysis/baseline.py,sha256=PCFVb5vX0lYKChZvVk-8I_B5NLQQwGyx7Y6M3XjpIEY,3458
|
|
@@ -34,7 +36,7 @@ iints/api/registry.py,sha256=h2syJwacFbgrtgnVK20JwlXivvVO31zeJ_Ez4KBkn1g,3240
|
|
|
34
36
|
iints/api/template_algorithm.py,sha256=AFs9AymL3ddWAjgpOkF1Oa3TeOSg56siyDt_BmsAND8,9195
|
|
35
37
|
iints/assets/iints_logo.png,sha256=rWzP8XqIYDrPCTp378w73zA1snKCUHrZ76vwslro-uk,700372
|
|
36
38
|
iints/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
37
|
-
iints/cli/cli.py,sha256=
|
|
39
|
+
iints/cli/cli.py,sha256=oDg0rkD_zXu83oQHb6kTzjKzQBr7ETJA9-C5diPASg0,210187
|
|
38
40
|
iints/core/__init__.py,sha256=rRH2lTmikavR7BgeJCUla0ZmPbZxATR6rEcSSv_tet4,28
|
|
39
41
|
iints/core/device.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
40
42
|
iints/core/device_manager.py,sha256=479_CNn6YescDLWDE7w1BbwuLwRUmCUOColAVTEWQc8,2078
|
|
@@ -140,9 +142,9 @@ iints/validation/schemas.py,sha256=uXhiPxyfyvOgCA83ZPBIzlITOu663fWctYxOMXUyf1I,4
|
|
|
140
142
|
iints/visualization/__init__.py,sha256=OdxVHDpY-9bDt8DTWWd-dspn1p0O9T908Cck-IGFaiM,640
|
|
141
143
|
iints/visualization/cockpit.py,sha256=Y7hoJXcTEWQ8yLiU5X5abT58uqGGsQllftXJwqerG1E,25057
|
|
142
144
|
iints/visualization/uncertainty_cloud.py,sha256=I5nNzSitgai21rkul31YNtJriSEmCeTsW0GWW2HUskY,19848
|
|
143
|
-
iints_sdk_python35-1.1.
|
|
144
|
-
iints_sdk_python35-1.1.
|
|
145
|
-
iints_sdk_python35-1.1.
|
|
146
|
-
iints_sdk_python35-1.1.
|
|
147
|
-
iints_sdk_python35-1.1.
|
|
148
|
-
iints_sdk_python35-1.1.
|
|
145
|
+
iints_sdk_python35-1.1.2.dist-info/licenses/LICENSE,sha256=b1luljj2mWWDW10t_qFIqd9Z6euXAcDBmIXowWuUlm4,1417
|
|
146
|
+
iints_sdk_python35-1.1.2.dist-info/METADATA,sha256=F-4k2b--KpKAGQpS6Kw8LP_AW3A1zzqATS7lgs_Mx8I,8887
|
|
147
|
+
iints_sdk_python35-1.1.2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
148
|
+
iints_sdk_python35-1.1.2.dist-info/entry_points.txt,sha256=aVioeLytTHG7WM7L3LIZ6XDJCKiSfqG-nVUQDVHPpQk,578
|
|
149
|
+
iints_sdk_python35-1.1.2.dist-info/top_level.txt,sha256=7Usr6NQKiC9SpNFyCis81MmgXy71lDCr5unR8BNXZ0E,6
|
|
150
|
+
iints_sdk_python35-1.1.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|