iints-sdk-python35 1.0.0__py3-none-any.whl → 1.1.1__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 CHANGED
@@ -61,6 +61,7 @@ from .data.synthetic_mirror import generate_synthetic_mirror, SyntheticMirrorArt
61
61
  from .analysis.metrics import generate_benchmark_metrics # Added for benchmark
62
62
  from .analysis.reporting import ClinicalReportGenerator
63
63
  from .analysis.edge_efficiency import EnergyEstimate, estimate_energy_per_decision
64
+ from .ai import AIResponse, IINTSAssistant, MDMPGuard
64
65
  from .highlevel import run_simulation, run_full, run_population
65
66
  from .scenarios import ScenarioGeneratorConfig, generate_random_scenario
66
67
 
@@ -169,6 +170,9 @@ __all__ = [
169
170
  "ClinicalReportGenerator",
170
171
  "EnergyEstimate",
171
172
  "estimate_energy_per_decision",
173
+ "AIResponse",
174
+ "IINTSAssistant",
175
+ "MDMPGuard",
172
176
  # Reporting
173
177
  "generate_report",
174
178
  "generate_quickstart_report",
iints/ai/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ from .assistant import AIResponse, IINTSAssistant
2
+ from .backends import DEFAULT_MINISTRAL_MODEL, DEFAULT_OLLAMA_HOST, OllamaBackend
3
+ from .mdmp_guard import GuardResult, MDMPGuard
4
+ from .model_catalog import LocalMistralModelProfile, list_local_mistral_models
5
+
6
+ __all__ = [
7
+ "AIResponse",
8
+ "IINTSAssistant",
9
+ "DEFAULT_MINISTRAL_MODEL",
10
+ "DEFAULT_OLLAMA_HOST",
11
+ "OllamaBackend",
12
+ "GuardResult",
13
+ "MDMPGuard",
14
+ "LocalMistralModelProfile",
15
+ "list_local_mistral_models",
16
+ ]
iints/ai/assistant.py ADDED
@@ -0,0 +1,124 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from .backends import DEFAULT_MINISTRAL_MODEL, CompletionBackend, MistralAPIBackend, OllamaBackend
8
+ from .mdmp_guard import GuardResult, MDMPGuard
9
+ from .prompts import TaskName, build_prompt
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class AIResponse:
14
+ task: str
15
+ text: str
16
+ backend: str
17
+ model: str
18
+ certification: GuardResult
19
+
20
+ def to_dict(self) -> dict[str, Any]:
21
+ return {
22
+ "task": self.task,
23
+ "text": self.text,
24
+ "backend": self.backend,
25
+ "model": self.model,
26
+ "certification": self.certification.to_dict(),
27
+ }
28
+
29
+
30
+ class IINTSAssistant:
31
+ """Research-only LLM assistant gated by MDMP certification."""
32
+
33
+ def __init__(
34
+ self,
35
+ mdmp_cert: str | Path,
36
+ *,
37
+ mode: str = "auto",
38
+ model: str = DEFAULT_MINISTRAL_MODEL,
39
+ minimum_grade: str = "research_grade",
40
+ public_key_path: str | Path | None = None,
41
+ trust_store_path: str | Path | None = None,
42
+ ollama_host: str | None = None,
43
+ timeout_seconds: float = 120.0,
44
+ backend: CompletionBackend | None = None,
45
+ guard: MDMPGuard | None = None,
46
+ ) -> None:
47
+ self.guard = guard or MDMPGuard(
48
+ mdmp_cert,
49
+ minimum_grade=minimum_grade,
50
+ public_key_path=public_key_path,
51
+ trust_store_path=trust_store_path,
52
+ )
53
+ self.backend = backend or self._detect_backend(
54
+ mode=mode,
55
+ model=model,
56
+ ollama_host=ollama_host,
57
+ timeout_seconds=timeout_seconds,
58
+ )
59
+
60
+ def _detect_backend(
61
+ self,
62
+ *,
63
+ mode: str,
64
+ model: str,
65
+ ollama_host: str | None,
66
+ timeout_seconds: float,
67
+ ) -> CompletionBackend:
68
+ requested = mode.strip().lower()
69
+ if requested in {"auto", "local", "ollama"}:
70
+ ollama_backend = OllamaBackend(
71
+ model_name=model,
72
+ base_url=ollama_host,
73
+ timeout_seconds=timeout_seconds,
74
+ )
75
+ local_backend: CompletionBackend = ollama_backend
76
+ if not ollama_backend.available():
77
+ raise RuntimeError(
78
+ "No local Ollama backend is available. "
79
+ f"Could not reach {ollama_backend.base_url}. "
80
+ "Start Ollama and try again."
81
+ )
82
+ ollama_backend.ensure_model_ready()
83
+ return local_backend
84
+ if requested == "api":
85
+ api_backend: CompletionBackend = MistralAPIBackend()
86
+ if api_backend.available():
87
+ return api_backend
88
+ raise RuntimeError(
89
+ "Cloud API fallback is not enabled in this SDK build yet. "
90
+ "Use mode='local' with Ollama."
91
+ )
92
+ raise ValueError(f"Unsupported AI mode: {mode}")
93
+
94
+ def _run_task(self, task: TaskName, payload: Any) -> AIResponse:
95
+ certification = self.guard.check()
96
+ system_prompt, user_prompt = build_prompt(task, payload)
97
+ text = self.guard.wrap(
98
+ self.backend.complete(system_prompt=system_prompt, user_prompt=user_prompt)
99
+ )
100
+ resolved_model = getattr(self.backend, "resolved_model_name", None)
101
+ response_model = (
102
+ str(resolved_model)
103
+ if isinstance(resolved_model, str) and resolved_model.strip()
104
+ else str(getattr(self.backend, "model_name", DEFAULT_MINISTRAL_MODEL))
105
+ )
106
+ return AIResponse(
107
+ task=task,
108
+ text=text,
109
+ backend=getattr(self.backend, "backend_name", type(self.backend).__name__),
110
+ model=response_model,
111
+ certification=certification,
112
+ )
113
+
114
+ def explain_decision(self, step: dict[str, Any]) -> AIResponse:
115
+ return self._run_task("explain_decision", step)
116
+
117
+ def analyze_trends(self, glucose_payload: list[Any] | dict[str, Any]) -> AIResponse:
118
+ return self._run_task("analyze_trends", glucose_payload)
119
+
120
+ def detect_anomalies(self, results: dict[str, Any]) -> AIResponse:
121
+ return self._run_task("detect_anomalies", results)
122
+
123
+ def generate_report(self, run: dict[str, Any]) -> AIResponse:
124
+ return self._run_task("generate_report", run)
@@ -0,0 +1,11 @@
1
+ from .base import CompletionBackend
2
+ from .mistral_api import MistralAPIBackend
3
+ from .ollama import DEFAULT_MINISTRAL_MODEL, DEFAULT_OLLAMA_HOST, OllamaBackend
4
+
5
+ __all__ = [
6
+ "CompletionBackend",
7
+ "DEFAULT_MINISTRAL_MODEL",
8
+ "DEFAULT_OLLAMA_HOST",
9
+ "OllamaBackend",
10
+ "MistralAPIBackend",
11
+ ]
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Protocol
4
+
5
+
6
+ class CompletionBackend(Protocol):
7
+ backend_name: str
8
+ model_name: str
9
+
10
+ def available(self) -> bool:
11
+ ...
12
+
13
+ def complete(self, *, system_prompt: str, user_prompt: str) -> str:
14
+ ...
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class MistralAPIBackend:
5
+ backend_name = "mistral_api"
6
+
7
+ def __init__(self, *args, **kwargs) -> None:
8
+ self.model_name = "mistral_api_unconfigured"
9
+
10
+ def available(self) -> bool:
11
+ return False
12
+
13
+ def complete(self, *, system_prompt: str, user_prompt: str) -> str:
14
+ raise RuntimeError(
15
+ "Cloud fallback is not enabled in this SDK build yet. "
16
+ "Use mode='local' with Ollama for the open Ministral 3 model."
17
+ )
@@ -0,0 +1,238 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from urllib import error, request
6
+
7
+
8
+ DEFAULT_OLLAMA_HOST = "http://127.0.0.1:11434"
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)
12
+ MINISTRAL_MODEL_ALIASES = (
13
+ DEFAULT_MINISTRAL_MODEL,
14
+ "ministral-3",
15
+ "ministral-3:latest",
16
+ "ministral-3:8b",
17
+ "ministral-3:8b-instruct",
18
+ "ministral",
19
+ "ministral-8b",
20
+ "ministral-8b-instruct",
21
+ LEGACY_MINISTRAL_MODEL,
22
+ )
23
+
24
+
25
+ class OllamaBackend:
26
+ backend_name = "ollama"
27
+
28
+ def __init__(
29
+ self,
30
+ *,
31
+ model_name: str = DEFAULT_MINISTRAL_MODEL,
32
+ base_url: str | None = None,
33
+ timeout_seconds: float = 120.0,
34
+ ) -> None:
35
+ self.model_name = model_name
36
+ self.base_url = (base_url or os.getenv("OLLAMA_HOST") or DEFAULT_OLLAMA_HOST).rstrip("/")
37
+ self.timeout_seconds = timeout_seconds
38
+ self.resolved_model_name: str | None = None
39
+
40
+ def _pull_hint(self) -> str:
41
+ return f"ollama pull {self.model_name}"
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
+
65
+ def _request_json(
66
+ self,
67
+ path: str,
68
+ payload: dict[str, object] | None = None,
69
+ *,
70
+ method: str = "POST",
71
+ ) -> dict[str, object]:
72
+ url = f"{self.base_url}{path}"
73
+ body = None
74
+ headers = {"Accept": "application/json"}
75
+ if payload is not None:
76
+ body = json.dumps(payload).encode("utf-8")
77
+ headers["Content-Type"] = "application/json"
78
+ req = request.Request(url, data=body, headers=headers, method=method)
79
+ try:
80
+ with request.urlopen(req, timeout=self.timeout_seconds) as response:
81
+ text = response.read().decode("utf-8")
82
+ except error.HTTPError as exc:
83
+ detail = exc.read().decode("utf-8", errors="replace").strip()
84
+ if exc.code == 404 and path == "/api/generate":
85
+ raise RuntimeError(
86
+ f"Ollama model '{self.model_name}' is not available locally. "
87
+ f"Run: ollama pull {self.model_name}"
88
+ ) from exc
89
+ raise RuntimeError(f"Ollama request failed ({exc.code}): {detail or exc.reason}") from exc
90
+ except error.URLError as exc:
91
+ raise RuntimeError(
92
+ f"Could not reach Ollama at {self.base_url}. "
93
+ "Start Ollama or set OLLAMA_HOST to the correct endpoint."
94
+ ) from exc
95
+
96
+ try:
97
+ payload_json = json.loads(text)
98
+ except json.JSONDecodeError as exc:
99
+ raise RuntimeError("Ollama returned invalid JSON.") from exc
100
+ if not isinstance(payload_json, dict):
101
+ raise RuntimeError("Ollama returned an unexpected response shape.")
102
+ return payload_json
103
+
104
+ def available(self) -> bool:
105
+ try:
106
+ self._request_json("/api/tags", method="GET")
107
+ except Exception:
108
+ return False
109
+ return True
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
+
132
+ def list_models(self) -> list[str]:
133
+ response = self._request_json("/api/tags", method="GET")
134
+ raw_models = response.get("models", [])
135
+ if not isinstance(raw_models, list):
136
+ raise RuntimeError("Ollama returned an unexpected model list.")
137
+
138
+ discovered: list[str] = []
139
+ for entry in raw_models:
140
+ if not isinstance(entry, dict):
141
+ continue
142
+ name = entry.get("name")
143
+ if isinstance(name, str) and name.strip():
144
+ discovered.append(name.strip())
145
+ return discovered
146
+
147
+ def resolve_model_name(self) -> str | None:
148
+ installed = self.list_models()
149
+ installed_lookup = {name.lower(): name for name in installed}
150
+
151
+ if self.model_name.lower() in installed_lookup:
152
+ return installed_lookup[self.model_name.lower()]
153
+
154
+ requested = self.model_name.strip().lower()
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
+ }:
164
+ for alias in MINISTRAL_MODEL_ALIASES:
165
+ resolved = installed_lookup.get(alias.lower())
166
+ if resolved is not None:
167
+ return resolved
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
173
+ for installed_name in installed:
174
+ lowered = installed_name.lower()
175
+ if "ministral" in lowered and "8b" in lowered:
176
+ return installed_name
177
+
178
+ return None
179
+
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
+ )
189
+ try:
190
+ resolved = self.resolve_model_name()
191
+ except RuntimeError:
192
+ raise
193
+ except Exception as exc:
194
+ raise RuntimeError(f"Failed to inspect local Ollama models: {exc}") from exc
195
+
196
+ if resolved is None:
197
+ self.resolved_model_name = None
198
+ installed = self.list_models()
199
+ installed_hint = ", ".join(installed) if installed else "none"
200
+ raise RuntimeError(
201
+ "Ollama is running, but the requested Ministral model is not installed locally.\n"
202
+ f"Requested: {self.model_name}\n"
203
+ f"Installed: {installed_hint}\n"
204
+ f"Run: {self._pull_hint()}"
205
+ )
206
+ self.resolved_model_name = resolved
207
+ return resolved
208
+
209
+ def healthcheck(self) -> dict[str, object]:
210
+ version_ok, version = self.version_supported()
211
+ installed = self.list_models()
212
+ resolved = self.resolve_model_name() if installed else None
213
+ return {
214
+ "available": True,
215
+ "base_url": self.base_url,
216
+ "requested_model": self.model_name,
217
+ "resolved_model": resolved,
218
+ "installed_models": installed,
219
+ "ready": resolved is not None,
220
+ "pull_command": None if resolved is not None else self._pull_hint(),
221
+ "timeout_seconds": self.timeout_seconds,
222
+ "server_version": version,
223
+ "version_ok": version_ok,
224
+ }
225
+
226
+ def complete(self, *, system_prompt: str, user_prompt: str) -> str:
227
+ resolved_model = self.ensure_model_ready()
228
+ payload = {
229
+ "model": resolved_model,
230
+ "system": system_prompt,
231
+ "prompt": user_prompt,
232
+ "stream": False,
233
+ }
234
+ response = self._request_json("/api/generate", payload)
235
+ text = response.get("response")
236
+ if not isinstance(text, str) or not text.strip():
237
+ raise RuntimeError("Ollama returned an empty completion.")
238
+ return text.strip()