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.
- admina/__init__.py +34 -0
- admina/cli/__init__.py +14 -0
- admina/cli/commands/__init__.py +14 -0
- admina/cli/main.py +1522 -0
- admina/cli/templates/admina.yaml.j2 +77 -0
- admina/cli/templates/docker-compose.yml.j2 +254 -0
- admina/cli/templates/env.j2 +10 -0
- admina/cli/templates/main.py.j2 +95 -0
- admina/cli/templates/plugin.py.j2 +145 -0
- admina/cli/templates/plugin_pyproject.toml.j2 +15 -0
- admina/cli/templates/plugin_readme.md.j2 +27 -0
- admina/cli/templates/plugin_test.py.j2 +48 -0
- admina/core/__init__.py +14 -0
- admina/core/config.py +497 -0
- admina/core/event_bus.py +112 -0
- admina/core/secrets.py +257 -0
- admina/core/types.py +146 -0
- admina/dashboard/__init__.py +8 -0
- admina/dashboard/static/heimdall.png +0 -0
- admina/dashboard/static/index.html +1045 -0
- admina/dashboard/static/vendor/alpinejs.min.js +5 -0
- admina/domains/__init__.py +14 -0
- admina/domains/agent_security/__init__.py +41 -0
- admina/domains/agent_security/firewall.py +634 -0
- admina/domains/agent_security/loop_breaker.py +176 -0
- admina/domains/ai_infra/__init__.py +79 -0
- admina/domains/ai_infra/llm_engine.py +477 -0
- admina/domains/ai_infra/rag.py +817 -0
- admina/domains/ai_infra/webui.py +292 -0
- admina/domains/compliance/__init__.py +109 -0
- admina/domains/compliance/cross_regulation.py +314 -0
- admina/domains/compliance/eu_ai_act.py +367 -0
- admina/domains/compliance/forensic.py +380 -0
- admina/domains/compliance/gdpr.py +331 -0
- admina/domains/compliance/nis2.py +258 -0
- admina/domains/compliance/oisg.py +658 -0
- admina/domains/compliance/otel.py +101 -0
- admina/domains/data_sovereignty/__init__.py +42 -0
- admina/domains/data_sovereignty/classification.py +102 -0
- admina/domains/data_sovereignty/pii.py +260 -0
- admina/domains/data_sovereignty/residency.py +121 -0
- admina/integrations/__init__.py +14 -0
- admina/integrations/_engines.py +63 -0
- admina/integrations/cheshirecat/__init__.py +13 -0
- admina/integrations/cheshirecat/admina-plugin/admina_governance.py +207 -0
- admina/integrations/crewai/__init__.py +13 -0
- admina/integrations/crewai/callbacks.py +347 -0
- admina/integrations/langchain/__init__.py +13 -0
- admina/integrations/langchain/callbacks.py +341 -0
- admina/integrations/n8n/__init__.py +14 -0
- admina/integrations/openclaw/__init__.py +14 -0
- admina/plugins/__init__.py +49 -0
- admina/plugins/base.py +633 -0
- admina/plugins/builtin/__init__.py +14 -0
- admina/plugins/builtin/adapters/__init__.py +14 -0
- admina/plugins/builtin/adapters/ollama.py +120 -0
- admina/plugins/builtin/adapters/openai.py +138 -0
- admina/plugins/builtin/alerts/__init__.py +14 -0
- admina/plugins/builtin/alerts/log.py +66 -0
- admina/plugins/builtin/alerts/webhook.py +102 -0
- admina/plugins/builtin/auth/__init__.py +14 -0
- admina/plugins/builtin/auth/apikey.py +138 -0
- admina/plugins/builtin/compliance/__init__.py +14 -0
- admina/plugins/builtin/compliance/eu_ai_act.py +202 -0
- admina/plugins/builtin/connectors/__init__.py +14 -0
- admina/plugins/builtin/connectors/chromadb.py +137 -0
- admina/plugins/builtin/connectors/filesystem.py +111 -0
- admina/plugins/builtin/forensic/__init__.py +14 -0
- admina/plugins/builtin/forensic/filesystem.py +163 -0
- admina/plugins/builtin/forensic/minio.py +180 -0
- admina/plugins/builtin/guards/__init__.py +0 -0
- admina/plugins/builtin/guards/guardrailsai_guard.py +172 -0
- admina/plugins/builtin/pii/__init__.py +14 -0
- admina/plugins/builtin/pii/spacy_regex.py +160 -0
- admina/plugins/builtin/transports/__init__.py +14 -0
- admina/plugins/builtin/transports/http_rest.py +97 -0
- admina/plugins/builtin/transports/mcp.py +173 -0
- admina/plugins/registry.py +356 -0
- admina/proxy/__init__.py +15 -0
- admina/proxy/api/__init__.py +17 -0
- admina/proxy/api/dashboard.py +925 -0
- admina/proxy/api/integration.py +153 -0
- admina/proxy/config.py +214 -0
- admina/proxy/engine_bridge.py +306 -0
- admina/proxy/governance.py +232 -0
- admina/proxy/main.py +1484 -0
- admina/proxy/multi_upstream.py +156 -0
- admina/proxy/state.py +97 -0
- admina/py.typed +0 -0
- admina/sdk/__init__.py +34 -0
- admina/sdk/_compat.py +43 -0
- admina/sdk/compliance_kit.py +359 -0
- admina/sdk/governed_agent.py +391 -0
- admina/sdk/governed_data.py +434 -0
- admina/sdk/governed_model.py +241 -0
- admina_framework-0.9.0.dist-info/METADATA +575 -0
- admina_framework-0.9.0.dist-info/RECORD +102 -0
- admina_framework-0.9.0.dist-info/WHEEL +5 -0
- admina_framework-0.9.0.dist-info/entry_points.txt +2 -0
- admina_framework-0.9.0.dist-info/licenses/LICENSE +191 -0
- admina_framework-0.9.0.dist-info/licenses/NOTICE +16 -0
- 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))
|