admina-framework 0.9.0__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 (102) hide show
  1. admina/__init__.py +34 -0
  2. admina/cli/__init__.py +14 -0
  3. admina/cli/commands/__init__.py +14 -0
  4. admina/cli/main.py +1522 -0
  5. admina/cli/templates/admina.yaml.j2 +77 -0
  6. admina/cli/templates/docker-compose.yml.j2 +254 -0
  7. admina/cli/templates/env.j2 +10 -0
  8. admina/cli/templates/main.py.j2 +95 -0
  9. admina/cli/templates/plugin.py.j2 +145 -0
  10. admina/cli/templates/plugin_pyproject.toml.j2 +15 -0
  11. admina/cli/templates/plugin_readme.md.j2 +27 -0
  12. admina/cli/templates/plugin_test.py.j2 +48 -0
  13. admina/core/__init__.py +14 -0
  14. admina/core/config.py +497 -0
  15. admina/core/event_bus.py +112 -0
  16. admina/core/secrets.py +257 -0
  17. admina/core/types.py +146 -0
  18. admina/dashboard/__init__.py +8 -0
  19. admina/dashboard/static/heimdall.png +0 -0
  20. admina/dashboard/static/index.html +1045 -0
  21. admina/dashboard/static/vendor/alpinejs.min.js +5 -0
  22. admina/domains/__init__.py +14 -0
  23. admina/domains/agent_security/__init__.py +41 -0
  24. admina/domains/agent_security/firewall.py +634 -0
  25. admina/domains/agent_security/loop_breaker.py +176 -0
  26. admina/domains/ai_infra/__init__.py +79 -0
  27. admina/domains/ai_infra/llm_engine.py +477 -0
  28. admina/domains/ai_infra/rag.py +817 -0
  29. admina/domains/ai_infra/webui.py +292 -0
  30. admina/domains/compliance/__init__.py +109 -0
  31. admina/domains/compliance/cross_regulation.py +314 -0
  32. admina/domains/compliance/eu_ai_act.py +367 -0
  33. admina/domains/compliance/forensic.py +380 -0
  34. admina/domains/compliance/gdpr.py +331 -0
  35. admina/domains/compliance/nis2.py +258 -0
  36. admina/domains/compliance/oisg.py +658 -0
  37. admina/domains/compliance/otel.py +101 -0
  38. admina/domains/data_sovereignty/__init__.py +42 -0
  39. admina/domains/data_sovereignty/classification.py +102 -0
  40. admina/domains/data_sovereignty/pii.py +260 -0
  41. admina/domains/data_sovereignty/residency.py +121 -0
  42. admina/integrations/__init__.py +14 -0
  43. admina/integrations/_engines.py +63 -0
  44. admina/integrations/cheshirecat/__init__.py +13 -0
  45. admina/integrations/cheshirecat/admina-plugin/admina_governance.py +207 -0
  46. admina/integrations/crewai/__init__.py +13 -0
  47. admina/integrations/crewai/callbacks.py +347 -0
  48. admina/integrations/langchain/__init__.py +13 -0
  49. admina/integrations/langchain/callbacks.py +341 -0
  50. admina/integrations/n8n/__init__.py +14 -0
  51. admina/integrations/openclaw/__init__.py +14 -0
  52. admina/plugins/__init__.py +49 -0
  53. admina/plugins/base.py +633 -0
  54. admina/plugins/builtin/__init__.py +14 -0
  55. admina/plugins/builtin/adapters/__init__.py +14 -0
  56. admina/plugins/builtin/adapters/ollama.py +120 -0
  57. admina/plugins/builtin/adapters/openai.py +138 -0
  58. admina/plugins/builtin/alerts/__init__.py +14 -0
  59. admina/plugins/builtin/alerts/log.py +66 -0
  60. admina/plugins/builtin/alerts/webhook.py +102 -0
  61. admina/plugins/builtin/auth/__init__.py +14 -0
  62. admina/plugins/builtin/auth/apikey.py +138 -0
  63. admina/plugins/builtin/compliance/__init__.py +14 -0
  64. admina/plugins/builtin/compliance/eu_ai_act.py +202 -0
  65. admina/plugins/builtin/connectors/__init__.py +14 -0
  66. admina/plugins/builtin/connectors/chromadb.py +137 -0
  67. admina/plugins/builtin/connectors/filesystem.py +111 -0
  68. admina/plugins/builtin/forensic/__init__.py +14 -0
  69. admina/plugins/builtin/forensic/filesystem.py +163 -0
  70. admina/plugins/builtin/forensic/minio.py +180 -0
  71. admina/plugins/builtin/guards/__init__.py +0 -0
  72. admina/plugins/builtin/guards/guardrailsai_guard.py +172 -0
  73. admina/plugins/builtin/pii/__init__.py +14 -0
  74. admina/plugins/builtin/pii/spacy_regex.py +160 -0
  75. admina/plugins/builtin/transports/__init__.py +14 -0
  76. admina/plugins/builtin/transports/http_rest.py +97 -0
  77. admina/plugins/builtin/transports/mcp.py +173 -0
  78. admina/plugins/registry.py +356 -0
  79. admina/proxy/__init__.py +15 -0
  80. admina/proxy/api/__init__.py +17 -0
  81. admina/proxy/api/dashboard.py +925 -0
  82. admina/proxy/api/integration.py +153 -0
  83. admina/proxy/config.py +214 -0
  84. admina/proxy/engine_bridge.py +306 -0
  85. admina/proxy/governance.py +232 -0
  86. admina/proxy/main.py +1484 -0
  87. admina/proxy/multi_upstream.py +156 -0
  88. admina/proxy/state.py +97 -0
  89. admina/py.typed +0 -0
  90. admina/sdk/__init__.py +34 -0
  91. admina/sdk/_compat.py +43 -0
  92. admina/sdk/compliance_kit.py +359 -0
  93. admina/sdk/governed_agent.py +391 -0
  94. admina/sdk/governed_data.py +434 -0
  95. admina/sdk/governed_model.py +241 -0
  96. admina_framework-0.9.0.dist-info/METADATA +575 -0
  97. admina_framework-0.9.0.dist-info/RECORD +102 -0
  98. admina_framework-0.9.0.dist-info/WHEEL +5 -0
  99. admina_framework-0.9.0.dist-info/entry_points.txt +2 -0
  100. admina_framework-0.9.0.dist-info/licenses/LICENSE +191 -0
  101. admina_framework-0.9.0.dist-info/licenses/NOTICE +16 -0
  102. admina_framework-0.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,434 @@
1
+ # Copyright © 2025–2026 Stefano Noferi & Admina contributors
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Admina — GovernedData SDK primitive.
16
+
17
+ Wraps a data connector with automatic data classification, residency
18
+ enforcement, PII redaction, and governance event emission.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import time
24
+ import uuid
25
+ from abc import ABC, abstractmethod
26
+ from dataclasses import dataclass, field
27
+ from typing import Any
28
+
29
+ from admina.core.event_bus import EventType, GovernanceEvent, bus
30
+ from admina.sdk._compat import run_sync
31
+
32
+ __all__ = ["GovernedData", "GovernedDocument", "IngestResult", "BaseDataConnector"]
33
+
34
+
35
+ class BaseDataConnector(ABC):
36
+ """Abstract base for data connectors.
37
+
38
+ Concrete connectors (filesystem, ChromaDB, etc.) implement this
39
+ interface. See :mod:`admina.plugins.base.BaseDataConnector` for the
40
+ canonical plugin definition used by the registry.
41
+ """
42
+
43
+ @abstractmethod
44
+ async def ingest(self, source: Any, **kwargs: Any) -> dict:
45
+ """Ingest data from source.
46
+
47
+ Returns:
48
+ Dict with at least ``doc_count`` and ``chunk_count`` keys.
49
+ """
50
+
51
+ @abstractmethod
52
+ async def query(self, query: str, **kwargs: Any) -> list[dict]:
53
+ """Query the data store.
54
+
55
+ Returns:
56
+ List of dicts with ``text``, ``metadata``, and ``score`` keys.
57
+ """
58
+
59
+ @property
60
+ @abstractmethod
61
+ def name(self) -> str:
62
+ """Human-readable connector name."""
63
+
64
+
65
+ @dataclass
66
+ class IngestResult:
67
+ """Result of a GovernedData.ingest() call.
68
+
69
+ Attributes:
70
+ doc_count: Number of documents ingested.
71
+ chunk_count: Number of chunks produced.
72
+ classification: Data classification results (PII stats, sensitivity).
73
+ governance: Governance decisions applied during ingest.
74
+ """
75
+
76
+ doc_count: int = 0
77
+ chunk_count: int = 0
78
+ classification: dict[str, Any] = field(default_factory=dict)
79
+ governance: dict[str, Any] = field(default_factory=dict)
80
+
81
+
82
+ @dataclass
83
+ class GovernedDocument:
84
+ """A single document returned by GovernedData.query().
85
+
86
+ Attributes:
87
+ text: Document text (PII-redacted if applicable).
88
+ metadata: Document metadata from the connector.
89
+ score: Relevance score from the connector.
90
+ governance: Governance decisions applied to this document.
91
+ """
92
+
93
+ text: str
94
+ metadata: dict[str, Any] = field(default_factory=dict)
95
+ score: float = 0.0
96
+ governance: dict[str, Any] = field(default_factory=dict)
97
+
98
+
99
+ # Allowed residency zones
100
+ VALID_ZONES = {"local", "eu", "us", "custom"}
101
+
102
+
103
+ def _load_pii_redactor() -> Any:
104
+ """Lazily load the PII redactor to avoid import-time spaCy load."""
105
+ from admina.domains.data_sovereignty.pii import PIIRedactor
106
+
107
+ return PIIRedactor()
108
+
109
+
110
+ def _classify_content(text: str, pii_result: dict) -> dict[str, Any]:
111
+ """Classify content sensitivity based on PII scan results.
112
+
113
+ Args:
114
+ text: The original text.
115
+ pii_result: Result dict from PIIRedactor.redact().
116
+
117
+ Returns:
118
+ Classification dict with sensitivity level and details.
119
+ """
120
+ pii_count = pii_result.get("count", 0)
121
+ pii_types = {e["type"] for e in pii_result.get("entities", [])}
122
+
123
+ high_risk_types = {"CREDIT_CARD", "SSN", "IBAN"}
124
+ has_high_risk = bool(pii_types & high_risk_types)
125
+
126
+ if has_high_risk or pii_count >= 5:
127
+ sensitivity = "HIGH"
128
+ elif pii_count >= 1:
129
+ sensitivity = "MEDIUM"
130
+ else:
131
+ sensitivity = "LOW"
132
+
133
+ return {
134
+ "sensitivity": sensitivity,
135
+ "pii_count": pii_count,
136
+ "pii_types": sorted(pii_types),
137
+ "has_high_risk_pii": has_high_risk,
138
+ }
139
+
140
+
141
+ class GovernedData:
142
+ """SDK primitive for governed data access.
143
+
144
+ Wraps a data connector with automatic data classification,
145
+ residency zone enforcement, PII redaction, and event emission.
146
+
147
+ Args:
148
+ connector: A BaseDataConnector instance, or None.
149
+ residency_zone: Data residency zone (local, eu, us, custom).
150
+ allowed_zones: Set of zones this instance may access.
151
+ audit: Whether to emit governance events.
152
+ pii_redaction: Whether to run PII redaction on query results.
153
+ """
154
+
155
+ def __init__(
156
+ self,
157
+ connector: BaseDataConnector | None = None,
158
+ residency_zone: str = "local",
159
+ allowed_zones: set[str] | None = None,
160
+ audit: bool = True,
161
+ pii_redaction: bool = True,
162
+ ) -> None:
163
+ """Initialize GovernedData.
164
+
165
+ Args:
166
+ connector: Data connector instance. Can be resolved via
167
+ :class:`PluginRegistry` or passed explicitly.
168
+ residency_zone: Zone where this data resides.
169
+ allowed_zones: Zones allowed for data access. Defaults to
170
+ {residency_zone}.
171
+ audit: If True, emit events to the event bus.
172
+ pii_redaction: If True, redact PII from query results.
173
+ """
174
+ self._connector = connector
175
+ self.residency_zone = residency_zone
176
+ self.allowed_zones = allowed_zones or {residency_zone}
177
+ self._audit = audit
178
+ self._pii_redaction = pii_redaction
179
+ self._pii_redactor: Any = None
180
+
181
+ def _get_pii_redactor(self) -> Any:
182
+ """Return the PII redactor, creating it lazily."""
183
+ if self._pii_redactor is None:
184
+ self._pii_redactor = _load_pii_redactor()
185
+ return self._pii_redactor
186
+
187
+ def _check_residency(self, target_zone: str) -> bool:
188
+ """Check if access to target_zone is allowed.
189
+
190
+ Args:
191
+ target_zone: The zone being accessed.
192
+
193
+ Returns:
194
+ True if allowed.
195
+
196
+ Raises:
197
+ PermissionError: If the zone is not in allowed_zones.
198
+ """
199
+ if target_zone not in self.allowed_zones:
200
+ raise PermissionError(
201
+ f"Data residency violation: zone '{target_zone}' "
202
+ f"not in allowed zones {self.allowed_zones}"
203
+ )
204
+ return True
205
+
206
+ async def ingest(
207
+ self,
208
+ source: Any,
209
+ target_zone: str | None = None,
210
+ **kwargs: Any,
211
+ ) -> IngestResult:
212
+ """Ingest data with governance checks.
213
+
214
+ Classifies data (PII scan, sensitivity), checks residency rules,
215
+ emits events, and passes to the connector.
216
+
217
+ Args:
218
+ source: Data source (string content, file path, etc.).
219
+ target_zone: Zone to ingest into. Defaults to residency_zone.
220
+ **kwargs: Forwarded to connector.ingest().
221
+
222
+ Returns:
223
+ IngestResult with counts, classification, and governance info.
224
+
225
+ Raises:
226
+ RuntimeError: If no connector is configured.
227
+ PermissionError: If residency check fails.
228
+ """
229
+ if self._connector is None:
230
+ raise RuntimeError(
231
+ "No data connector configured. Pass a connector to "
232
+ "GovernedData() or resolve one via PluginRegistry."
233
+ )
234
+
235
+ zone = target_zone or self.residency_zone
236
+ session_id = kwargs.pop("session_id", None) or str(uuid.uuid4())
237
+ start_us = time.time() * 1_000_000
238
+ governance: dict[str, Any] = {"residency": {"zone": zone, "allowed": True}}
239
+
240
+ # 1. Check residency
241
+ try:
242
+ self._check_residency(zone)
243
+ except PermissionError:
244
+ governance["residency"]["allowed"] = False
245
+ if self._audit:
246
+ await bus.emit(
247
+ GovernanceEvent(
248
+ event_type=EventType.DATA_ACCESS,
249
+ session_id=session_id,
250
+ domain="data-sovereignty",
251
+ action="BLOCK",
252
+ risk_level="CRITICAL",
253
+ metadata={"reason": "residency_violation", "zone": zone},
254
+ )
255
+ )
256
+ raise
257
+
258
+ # 2. Classify content (PII scan)
259
+ classification: dict[str, Any] = {}
260
+ content_for_scan = source if isinstance(source, str) else str(source)
261
+ pii_result = self._get_pii_redactor().redact(content_for_scan)
262
+ classification = _classify_content(content_for_scan, pii_result)
263
+
264
+ if self._audit:
265
+ await bus.emit(
266
+ GovernanceEvent(
267
+ event_type=EventType.DATA_CLASSIFY,
268
+ session_id=session_id,
269
+ domain="data-sovereignty",
270
+ metadata={
271
+ "sensitivity": classification["sensitivity"],
272
+ "pii_count": classification["pii_count"],
273
+ },
274
+ )
275
+ )
276
+
277
+ # 3. Emit DATA_ACCESS event
278
+ if self._audit:
279
+ await bus.emit(
280
+ GovernanceEvent(
281
+ event_type=EventType.DATA_ACCESS,
282
+ session_id=session_id,
283
+ domain="data-sovereignty",
284
+ action="ALLOW",
285
+ metadata={
286
+ "operation": "ingest",
287
+ "zone": zone,
288
+ "connector": self._connector.name,
289
+ },
290
+ )
291
+ )
292
+
293
+ # 4. Pass to connector
294
+ connector_result = await self._connector.ingest(source, **kwargs)
295
+
296
+ latency_us = time.time() * 1_000_000 - start_us
297
+ governance["latency_us"] = latency_us
298
+
299
+ return IngestResult(
300
+ doc_count=connector_result.get("doc_count", 0),
301
+ chunk_count=connector_result.get("chunk_count", 0),
302
+ classification=classification,
303
+ governance=governance,
304
+ )
305
+
306
+ async def query(
307
+ self,
308
+ query: str,
309
+ target_zone: str | None = None,
310
+ **kwargs: Any,
311
+ ) -> list[GovernedDocument]:
312
+ """Query data with governance checks.
313
+
314
+ Checks residency, retrieves from connector, redacts PII if needed,
315
+ and emits events.
316
+
317
+ Args:
318
+ query: The query string.
319
+ target_zone: Zone to query from. Defaults to residency_zone.
320
+ **kwargs: Forwarded to connector.query().
321
+
322
+ Returns:
323
+ List of GovernedDocument with redacted text and governance info.
324
+
325
+ Raises:
326
+ RuntimeError: If no connector is configured.
327
+ PermissionError: If residency check fails.
328
+ """
329
+ if self._connector is None:
330
+ raise RuntimeError(
331
+ "No data connector configured. Pass a connector to "
332
+ "GovernedData() or resolve one via PluginRegistry."
333
+ )
334
+
335
+ zone = target_zone or self.residency_zone
336
+ session_id = kwargs.pop("session_id", None) or str(uuid.uuid4())
337
+
338
+ # 1. Check residency
339
+ try:
340
+ self._check_residency(zone)
341
+ except PermissionError:
342
+ if self._audit:
343
+ await bus.emit(
344
+ GovernanceEvent(
345
+ event_type=EventType.DATA_ACCESS,
346
+ session_id=session_id,
347
+ domain="data-sovereignty",
348
+ action="BLOCK",
349
+ risk_level="CRITICAL",
350
+ metadata={"reason": "residency_violation", "zone": zone},
351
+ )
352
+ )
353
+ raise
354
+
355
+ # 2. Emit DATA_ACCESS event
356
+ if self._audit:
357
+ await bus.emit(
358
+ GovernanceEvent(
359
+ event_type=EventType.DATA_ACCESS,
360
+ session_id=session_id,
361
+ domain="data-sovereignty",
362
+ action="ALLOW",
363
+ metadata={
364
+ "operation": "query",
365
+ "zone": zone,
366
+ "connector": self._connector.name,
367
+ },
368
+ )
369
+ )
370
+
371
+ # 3. Retrieve from connector
372
+ raw_results = await self._connector.query(query, **kwargs)
373
+
374
+ # 4. Redact PII and wrap results
375
+ documents: list[GovernedDocument] = []
376
+ for raw in raw_results:
377
+ raw_text = raw.get("text", "")
378
+ doc_governance: dict[str, Any] = {"pii": {"redacted": False, "count": 0}}
379
+
380
+ if self._pii_redaction and raw_text:
381
+ pii_result = self._get_pii_redactor().redact(raw_text)
382
+ text = pii_result["redacted_text"]
383
+ if pii_result["count"] > 0:
384
+ doc_governance["pii"] = {
385
+ "redacted": True,
386
+ "count": pii_result["count"],
387
+ }
388
+ if self._audit:
389
+ await bus.emit(
390
+ GovernanceEvent(
391
+ event_type=EventType.DATA_REDACT,
392
+ session_id=session_id,
393
+ domain="data-sovereignty",
394
+ action="REDACT",
395
+ metadata={"pii_count": pii_result["count"]},
396
+ )
397
+ )
398
+ else:
399
+ text = raw_text
400
+
401
+ documents.append(
402
+ GovernedDocument(
403
+ text=text,
404
+ metadata=raw.get("metadata", {}),
405
+ score=raw.get("score", 0.0),
406
+ governance=doc_governance,
407
+ )
408
+ )
409
+
410
+ return documents
411
+
412
+ def ingest_sync(self, source: Any, **kwargs: Any) -> IngestResult:
413
+ """Synchronous convenience wrapper around ingest().
414
+
415
+ Args:
416
+ source: Data source.
417
+ **kwargs: Forwarded to ingest().
418
+
419
+ Returns:
420
+ IngestResult.
421
+ """
422
+ return run_sync(self.ingest(source, **kwargs))
423
+
424
+ def query_sync(self, query: str, **kwargs: Any) -> list[GovernedDocument]:
425
+ """Synchronous convenience wrapper around query().
426
+
427
+ Args:
428
+ query: The query string.
429
+ **kwargs: Forwarded to query().
430
+
431
+ Returns:
432
+ List of GovernedDocument.
433
+ """
434
+ return run_sync(self.query(query, **kwargs))
@@ -0,0 +1,241 @@
1
+ # Copyright © 2025–2026 Stefano Noferi & Admina contributors
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Admina — GovernedModel SDK primitive.
16
+
17
+ Wraps a model adapter with automatic PII redaction and governance
18
+ event emission on every call.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import time
24
+ import uuid
25
+ from abc import ABC, abstractmethod
26
+ from dataclasses import dataclass, field
27
+ from typing import Any
28
+
29
+ from admina.core.event_bus import EventType, GovernanceEvent, bus
30
+ from admina.sdk._compat import run_sync
31
+
32
+ __all__ = ["GovernedModel", "GovernedResponse", "BaseModelAdapter"]
33
+
34
+
35
+ class BaseModelAdapter(ABC):
36
+ """Abstract base for model adapters.
37
+
38
+ Concrete adapters (Ollama, OpenAI, etc.) implement this interface.
39
+ See :mod:`admina.plugins.base.BaseModelAdapter` for the canonical
40
+ plugin definition used by the registry.
41
+ """
42
+
43
+ @abstractmethod
44
+ async def send(self, prompt: str, context: Any = None, **kwargs: Any) -> dict:
45
+ """Send a prompt and return a response dict.
46
+
47
+ Returns:
48
+ Dict with at least ``text`` key and optional ``metadata``.
49
+ """
50
+
51
+ @abstractmethod
52
+ def supports_model(self, model_name: str) -> bool:
53
+ """Return True if this adapter can serve the given model."""
54
+
55
+ @property
56
+ @abstractmethod
57
+ def name(self) -> str:
58
+ """Human-readable adapter name."""
59
+
60
+
61
+ @dataclass
62
+ class GovernedResponse:
63
+ """Response from GovernedModel.ask().
64
+
65
+ Attributes:
66
+ text: The model's response text (PII-redacted).
67
+ metadata: Model metadata (tokens, latency, etc.).
68
+ governance: Governance decisions applied to this call.
69
+ """
70
+
71
+ text: str
72
+ metadata: dict[str, Any] = field(default_factory=dict)
73
+ governance: dict[str, Any] = field(default_factory=dict)
74
+
75
+
76
+ def _load_pii_redactor() -> Any:
77
+ """Lazily load the PII redactor to avoid import-time spaCy load."""
78
+ from admina.domains.data_sovereignty.pii import PIIRedactor
79
+
80
+ return PIIRedactor()
81
+
82
+
83
+ class GovernedModel:
84
+ """SDK primitive for governed model inference.
85
+
86
+ Wraps a model adapter with automatic PII redaction on prompts
87
+ and responses, and emits governance events for every call.
88
+
89
+ Args:
90
+ model_name: Name of the model to use (e.g. "llama3").
91
+ adapter: A BaseModelAdapter instance, or None for default.
92
+ audit: Whether to emit governance events.
93
+ pii_redaction: Whether to run PII redaction on prompts/responses.
94
+ """
95
+
96
+ def __init__(
97
+ self,
98
+ model_name: str,
99
+ adapter: BaseModelAdapter | None = None,
100
+ audit: bool = True,
101
+ pii_redaction: bool = True,
102
+ ) -> None:
103
+ """Initialize GovernedModel.
104
+
105
+ Args:
106
+ model_name: Model identifier passed to the adapter.
107
+ adapter: Adapter instance. Can be resolved via
108
+ :class:`PluginRegistry` or passed explicitly.
109
+ audit: If True, emit events to the event bus.
110
+ pii_redaction: If True, redact PII from prompts and responses.
111
+ """
112
+ self.model_name = model_name
113
+ self._adapter = adapter
114
+ self._audit = audit
115
+ self._pii_redaction = pii_redaction
116
+ self._pii_redactor: Any = None
117
+
118
+ def _get_pii_redactor(self) -> Any:
119
+ """Return the PII redactor, creating it lazily."""
120
+ if self._pii_redactor is None:
121
+ self._pii_redactor = _load_pii_redactor()
122
+ return self._pii_redactor
123
+
124
+ async def ask(
125
+ self,
126
+ prompt: str,
127
+ context: Any = None,
128
+ **kwargs: Any,
129
+ ) -> GovernedResponse:
130
+ """Send a governed prompt to the model.
131
+
132
+ Applies PII redaction, calls the adapter, redacts the response,
133
+ and emits governance events.
134
+
135
+ Args:
136
+ prompt: The user prompt.
137
+ context: Optional context passed to the adapter.
138
+ **kwargs: Additional arguments forwarded to the adapter.
139
+
140
+ Returns:
141
+ GovernedResponse with redacted text, metadata, and governance info.
142
+
143
+ Raises:
144
+ RuntimeError: If no adapter is configured.
145
+ """
146
+ if self._adapter is None:
147
+ raise RuntimeError(
148
+ "No model adapter configured. Pass an adapter to GovernedModel() "
149
+ "or resolve one via PluginRegistry."
150
+ )
151
+
152
+ session_id = kwargs.pop("session_id", None) or str(uuid.uuid4())
153
+ start_us = time.time() * 1_000_000
154
+ governance: dict[str, Any] = {
155
+ "pii_prompt": {"redacted": False, "count": 0},
156
+ "pii_response": {"redacted": False, "count": 0},
157
+ }
158
+
159
+ # 1. Emit MODEL_CALL event
160
+ if self._audit:
161
+ await bus.emit(
162
+ GovernanceEvent(
163
+ event_type=EventType.MODEL_CALL,
164
+ session_id=session_id,
165
+ domain="ai-infra",
166
+ metadata={"model": self.model_name, "adapter": self._adapter.name},
167
+ )
168
+ )
169
+
170
+ # 2. Run PII redaction on prompt
171
+ redacted_prompt = prompt
172
+ if self._pii_redaction:
173
+ pii_result = self._get_pii_redactor().redact(prompt)
174
+ redacted_prompt = pii_result["redacted_text"]
175
+ governance["pii_prompt"] = {
176
+ "redacted": pii_result["count"] > 0,
177
+ "count": pii_result["count"],
178
+ "entities": pii_result["entities"],
179
+ }
180
+
181
+ # 3. Call adapter
182
+ adapter_result = await self._adapter.send(
183
+ redacted_prompt,
184
+ context=context,
185
+ **kwargs,
186
+ )
187
+ raw_text = adapter_result.get("text", "")
188
+ adapter_metadata = adapter_result.get("metadata", {})
189
+
190
+ # 4. Run PII redaction on response
191
+ response_text = raw_text
192
+ if self._pii_redaction:
193
+ pii_result = self._get_pii_redactor().redact(raw_text)
194
+ response_text = pii_result["redacted_text"]
195
+ governance["pii_response"] = {
196
+ "redacted": pii_result["count"] > 0,
197
+ "count": pii_result["count"],
198
+ "entities": pii_result["entities"],
199
+ }
200
+
201
+ latency_us = time.time() * 1_000_000 - start_us
202
+ governance["latency_us"] = latency_us
203
+
204
+ # 5. Emit MODEL_RESPONSE event
205
+ if self._audit:
206
+ action = "ALLOW"
207
+ if governance["pii_prompt"]["redacted"] or governance["pii_response"]["redacted"]:
208
+ action = "REDACT"
209
+ await bus.emit(
210
+ GovernanceEvent(
211
+ event_type=EventType.MODEL_RESPONSE,
212
+ session_id=session_id,
213
+ domain="ai-infra",
214
+ action=action,
215
+ metadata={
216
+ "model": self.model_name,
217
+ "latency_us": latency_us,
218
+ "pii_prompt_count": governance["pii_prompt"]["count"],
219
+ "pii_response_count": governance["pii_response"]["count"],
220
+ },
221
+ )
222
+ )
223
+
224
+ # 6. Return GovernedResponse
225
+ return GovernedResponse(
226
+ text=response_text,
227
+ metadata=adapter_metadata,
228
+ governance=governance,
229
+ )
230
+
231
+ def ask_sync(self, prompt: str, **kwargs: Any) -> GovernedResponse:
232
+ """Synchronous convenience wrapper around ask().
233
+
234
+ Args:
235
+ prompt: The user prompt.
236
+ **kwargs: Forwarded to ask().
237
+
238
+ Returns:
239
+ GovernedResponse.
240
+ """
241
+ return run_sync(self.ask(prompt, **kwargs))