spanforge 1.0.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.
- spanforge/__init__.py +815 -0
- spanforge/_ansi.py +93 -0
- spanforge/_batch_exporter.py +409 -0
- spanforge/_cli.py +2094 -0
- spanforge/_cli_audit.py +639 -0
- spanforge/_cli_compliance.py +711 -0
- spanforge/_cli_cost.py +243 -0
- spanforge/_cli_ops.py +791 -0
- spanforge/_cli_phase11.py +356 -0
- spanforge/_hooks.py +337 -0
- spanforge/_server.py +1708 -0
- spanforge/_span.py +1036 -0
- spanforge/_store.py +288 -0
- spanforge/_stream.py +664 -0
- spanforge/_trace.py +335 -0
- spanforge/_tracer.py +254 -0
- spanforge/actor.py +141 -0
- spanforge/alerts.py +469 -0
- spanforge/auto.py +464 -0
- spanforge/baseline.py +335 -0
- spanforge/cache.py +635 -0
- spanforge/compliance.py +325 -0
- spanforge/config.py +532 -0
- spanforge/consent.py +228 -0
- spanforge/consumer.py +377 -0
- spanforge/core/__init__.py +5 -0
- spanforge/core/compliance_mapping.py +1254 -0
- spanforge/cost.py +600 -0
- spanforge/debug.py +548 -0
- spanforge/deprecations.py +205 -0
- spanforge/drift.py +482 -0
- spanforge/egress.py +58 -0
- spanforge/eval.py +648 -0
- spanforge/event.py +1064 -0
- spanforge/exceptions.py +240 -0
- spanforge/explain.py +178 -0
- spanforge/export/__init__.py +69 -0
- spanforge/export/append_only.py +337 -0
- spanforge/export/cloud.py +357 -0
- spanforge/export/datadog.py +497 -0
- spanforge/export/grafana.py +320 -0
- spanforge/export/jsonl.py +195 -0
- spanforge/export/openinference.py +158 -0
- spanforge/export/otel_bridge.py +294 -0
- spanforge/export/otlp.py +811 -0
- spanforge/export/otlp_bridge.py +233 -0
- spanforge/export/redis_backend.py +282 -0
- spanforge/export/siem_schema.py +98 -0
- spanforge/export/siem_splunk.py +264 -0
- spanforge/export/siem_syslog.py +212 -0
- spanforge/export/webhook.py +299 -0
- spanforge/exporters/__init__.py +30 -0
- spanforge/exporters/console.py +271 -0
- spanforge/exporters/jsonl.py +144 -0
- spanforge/exporters/sqlite.py +142 -0
- spanforge/gate.py +1150 -0
- spanforge/governance.py +181 -0
- spanforge/hitl.py +295 -0
- spanforge/http.py +187 -0
- spanforge/inspect.py +427 -0
- spanforge/integrations/__init__.py +45 -0
- spanforge/integrations/_pricing.py +280 -0
- spanforge/integrations/anthropic.py +388 -0
- spanforge/integrations/azure_openai.py +133 -0
- spanforge/integrations/bedrock.py +292 -0
- spanforge/integrations/crewai.py +251 -0
- spanforge/integrations/gemini.py +351 -0
- spanforge/integrations/groq.py +442 -0
- spanforge/integrations/langchain.py +349 -0
- spanforge/integrations/langgraph.py +306 -0
- spanforge/integrations/llamaindex.py +373 -0
- spanforge/integrations/ollama.py +287 -0
- spanforge/integrations/openai.py +368 -0
- spanforge/integrations/together.py +483 -0
- spanforge/io.py +214 -0
- spanforge/lint.py +322 -0
- spanforge/metrics.py +417 -0
- spanforge/metrics_export.py +343 -0
- spanforge/migrate.py +402 -0
- spanforge/model_registry.py +278 -0
- spanforge/models.py +389 -0
- spanforge/namespaces/__init__.py +254 -0
- spanforge/namespaces/audit.py +256 -0
- spanforge/namespaces/cache.py +237 -0
- spanforge/namespaces/chain.py +77 -0
- spanforge/namespaces/confidence.py +72 -0
- spanforge/namespaces/consent.py +92 -0
- spanforge/namespaces/cost.py +179 -0
- spanforge/namespaces/decision.py +143 -0
- spanforge/namespaces/diff.py +157 -0
- spanforge/namespaces/drift.py +80 -0
- spanforge/namespaces/eval_.py +251 -0
- spanforge/namespaces/feedback.py +241 -0
- spanforge/namespaces/fence.py +193 -0
- spanforge/namespaces/guard.py +105 -0
- spanforge/namespaces/hitl.py +91 -0
- spanforge/namespaces/latency.py +72 -0
- spanforge/namespaces/prompt.py +190 -0
- spanforge/namespaces/redact.py +173 -0
- spanforge/namespaces/retrieval.py +379 -0
- spanforge/namespaces/runtime_governance.py +494 -0
- spanforge/namespaces/template.py +208 -0
- spanforge/namespaces/tool_call.py +77 -0
- spanforge/namespaces/trace.py +1029 -0
- spanforge/normalizer.py +171 -0
- spanforge/plugins.py +82 -0
- spanforge/presidio_backend.py +349 -0
- spanforge/processor.py +258 -0
- spanforge/prompt_registry.py +418 -0
- spanforge/py.typed +0 -0
- spanforge/redact.py +914 -0
- spanforge/regression.py +192 -0
- spanforge/runtime_policy.py +159 -0
- spanforge/sampling.py +511 -0
- spanforge/schema.py +183 -0
- spanforge/schemas/v1.0/schema.json +170 -0
- spanforge/schemas/v2.0/schema.json +536 -0
- spanforge/sdk/__init__.py +625 -0
- spanforge/sdk/_base.py +584 -0
- spanforge/sdk/_base.pyi +71 -0
- spanforge/sdk/_exceptions.py +1096 -0
- spanforge/sdk/_types.py +2184 -0
- spanforge/sdk/alert.py +1514 -0
- spanforge/sdk/alert.pyi +56 -0
- spanforge/sdk/audit.py +1196 -0
- spanforge/sdk/audit.pyi +67 -0
- spanforge/sdk/cec.py +1215 -0
- spanforge/sdk/cec.pyi +37 -0
- spanforge/sdk/config.py +641 -0
- spanforge/sdk/config.pyi +55 -0
- spanforge/sdk/enterprise.py +714 -0
- spanforge/sdk/enterprise.pyi +79 -0
- spanforge/sdk/explain.py +170 -0
- spanforge/sdk/fallback.py +432 -0
- spanforge/sdk/feedback.py +351 -0
- spanforge/sdk/gate.py +874 -0
- spanforge/sdk/gate.pyi +51 -0
- spanforge/sdk/identity.py +2114 -0
- spanforge/sdk/identity.pyi +47 -0
- spanforge/sdk/lineage.py +175 -0
- spanforge/sdk/observe.py +1065 -0
- spanforge/sdk/observe.pyi +50 -0
- spanforge/sdk/operator.py +338 -0
- spanforge/sdk/pii.py +1473 -0
- spanforge/sdk/pii.pyi +119 -0
- spanforge/sdk/pipelines.py +458 -0
- spanforge/sdk/pipelines.pyi +39 -0
- spanforge/sdk/policy.py +930 -0
- spanforge/sdk/rag.py +594 -0
- spanforge/sdk/rbac.py +280 -0
- spanforge/sdk/registry.py +430 -0
- spanforge/sdk/registry.pyi +46 -0
- spanforge/sdk/scope.py +279 -0
- spanforge/sdk/secrets.py +293 -0
- spanforge/sdk/secrets.pyi +25 -0
- spanforge/sdk/security.py +560 -0
- spanforge/sdk/security.pyi +57 -0
- spanforge/sdk/trust.py +472 -0
- spanforge/sdk/trust.pyi +41 -0
- spanforge/secrets.py +799 -0
- spanforge/signing.py +1179 -0
- spanforge/stats.py +100 -0
- spanforge/stream.py +560 -0
- spanforge/testing.py +378 -0
- spanforge/testing_mocks.py +1052 -0
- spanforge/trace.py +199 -0
- spanforge/types.py +696 -0
- spanforge/ulid.py +300 -0
- spanforge/validate.py +379 -0
- spanforge-1.0.0.dist-info/METADATA +1509 -0
- spanforge-1.0.0.dist-info/RECORD +174 -0
- spanforge-1.0.0.dist-info/WHEEL +4 -0
- spanforge-1.0.0.dist-info/entry_points.txt +5 -0
- spanforge-1.0.0.dist-info/licenses/LICENSE +128 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""spanforge.namespaces.guard — Guard payload types (RFC-0001).
|
|
2
|
+
|
|
3
|
+
A single ``GuardPayload`` class is used for all four guard event types.
|
|
4
|
+
|
|
5
|
+
Classes
|
|
6
|
+
-------
|
|
7
|
+
GuardPayload llm.guard.input.blocked, llm.guard.input.passed,
|
|
8
|
+
llm.guard.output.blocked, llm.guard.output.passed
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
__all__ = ["GuardPayload"]
|
|
17
|
+
|
|
18
|
+
_VALID_DIRECTIONS = frozenset({"input", "output"})
|
|
19
|
+
_VALID_ACTIONS = frozenset({"blocked", "passed", "flagged", "modified", "escalated"})
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class GuardPayload:
|
|
24
|
+
"""RFC-0001 — Result of a guard classifier applied to LLM input or output.
|
|
25
|
+
|
|
26
|
+
Used with all four guard event types:
|
|
27
|
+
``llm.guard.input.blocked``, ``llm.guard.input.passed``,
|
|
28
|
+
``llm.guard.output.blocked``, ``llm.guard.output.passed``.
|
|
29
|
+
|
|
30
|
+
``content_hash`` stores a SHA-256 hash of the content that was classified.
|
|
31
|
+
Raw content MUST NOT be stored.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
classifier: str
|
|
35
|
+
direction: str # "input" | "output"
|
|
36
|
+
action: str # "blocked"|"passed"|"flagged"|"modified"|"escalated"
|
|
37
|
+
score: float
|
|
38
|
+
score_min: float | None = None
|
|
39
|
+
score_max: float | None = None
|
|
40
|
+
threshold: float | None = None
|
|
41
|
+
categories: list[str] = field(default_factory=list)
|
|
42
|
+
triggered_categories: list[str] = field(default_factory=list)
|
|
43
|
+
span_id: str | None = None
|
|
44
|
+
latency_ms: float | None = None
|
|
45
|
+
policy_id: str | None = None
|
|
46
|
+
content_hash: str | None = None # 64 lowercase hex chars, SHA-256
|
|
47
|
+
|
|
48
|
+
def __post_init__(self) -> None:
|
|
49
|
+
if not isinstance(self.classifier, str) or not self.classifier:
|
|
50
|
+
raise ValueError("GuardPayload.classifier must be non-empty")
|
|
51
|
+
if self.direction not in _VALID_DIRECTIONS:
|
|
52
|
+
raise ValueError(f"GuardPayload.direction must be one of {sorted(_VALID_DIRECTIONS)}")
|
|
53
|
+
if self.action not in _VALID_ACTIONS:
|
|
54
|
+
raise ValueError(f"GuardPayload.action must be one of {sorted(_VALID_ACTIONS)}")
|
|
55
|
+
if not isinstance(self.score, (int, float)):
|
|
56
|
+
raise ValueError("GuardPayload.score must be a number")
|
|
57
|
+
if self.latency_ms is not None and self.latency_ms < 0:
|
|
58
|
+
raise ValueError("GuardPayload.latency_ms must be non-negative")
|
|
59
|
+
|
|
60
|
+
def to_dict(self) -> dict[str, Any]:
|
|
61
|
+
"""Serialise the payload to a plain ``dict``."""
|
|
62
|
+
d: dict[str, Any] = {
|
|
63
|
+
"classifier": self.classifier,
|
|
64
|
+
"direction": self.direction,
|
|
65
|
+
"action": self.action,
|
|
66
|
+
"score": self.score,
|
|
67
|
+
}
|
|
68
|
+
if self.score_min is not None:
|
|
69
|
+
d["score_min"] = self.score_min
|
|
70
|
+
if self.score_max is not None:
|
|
71
|
+
d["score_max"] = self.score_max
|
|
72
|
+
if self.threshold is not None:
|
|
73
|
+
d["threshold"] = self.threshold
|
|
74
|
+
if self.categories:
|
|
75
|
+
d["categories"] = list(self.categories)
|
|
76
|
+
if self.triggered_categories:
|
|
77
|
+
d["triggered_categories"] = list(self.triggered_categories)
|
|
78
|
+
if self.span_id is not None:
|
|
79
|
+
d["span_id"] = self.span_id
|
|
80
|
+
if self.latency_ms is not None:
|
|
81
|
+
d["latency_ms"] = self.latency_ms
|
|
82
|
+
if self.policy_id is not None:
|
|
83
|
+
d["policy_id"] = self.policy_id
|
|
84
|
+
if self.content_hash is not None:
|
|
85
|
+
d["content_hash"] = self.content_hash
|
|
86
|
+
return d
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def from_dict(cls, data: dict[str, Any]) -> GuardPayload:
|
|
90
|
+
"""Deserialise from a plain ``dict``."""
|
|
91
|
+
return cls(
|
|
92
|
+
classifier=data["classifier"],
|
|
93
|
+
direction=data["direction"],
|
|
94
|
+
action=data["action"],
|
|
95
|
+
score=float(data["score"]),
|
|
96
|
+
score_min=float(data["score_min"]) if "score_min" in data else None,
|
|
97
|
+
score_max=float(data["score_max"]) if "score_max" in data else None,
|
|
98
|
+
threshold=float(data["threshold"]) if "threshold" in data else None,
|
|
99
|
+
categories=list(data.get("categories", [])),
|
|
100
|
+
triggered_categories=list(data.get("triggered_categories", [])),
|
|
101
|
+
span_id=data.get("span_id"),
|
|
102
|
+
latency_ms=float(data["latency_ms"]) if "latency_ms" in data else None,
|
|
103
|
+
policy_id=data.get("policy_id"),
|
|
104
|
+
content_hash=data.get("content_hash"),
|
|
105
|
+
)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""spanforge.namespaces.hitl — Human-in-the-Loop namespace payload types (RFC-0001 SPANFORGE).
|
|
2
|
+
|
|
3
|
+
Classes
|
|
4
|
+
-------
|
|
5
|
+
HITLPayload hitl.queued / hitl.reviewed / hitl.escalated / hitl.timeout
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any, Literal
|
|
12
|
+
|
|
13
|
+
__all__ = ["HITLPayload"]
|
|
14
|
+
|
|
15
|
+
_VALID_STATUSES = frozenset({"queued", "approved", "rejected", "escalated", "timeout"})
|
|
16
|
+
_VALID_RISK_TIERS = frozenset({"low", "medium", "high", "critical"})
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class HITLPayload:
|
|
21
|
+
"""RFC-0001 SPANFORGE — payload for hitl.* events.
|
|
22
|
+
|
|
23
|
+
Captures human-in-the-loop review decisions for EU AI Act mandatory
|
|
24
|
+
human oversight on high-risk AI systems (R — Responsibility).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
decision_id: str
|
|
28
|
+
agent_id: str
|
|
29
|
+
risk_tier: Literal["low", "medium", "high", "critical"]
|
|
30
|
+
status: Literal["queued", "approved", "rejected", "escalated", "timeout"]
|
|
31
|
+
reason: str
|
|
32
|
+
reviewer: str | None = None
|
|
33
|
+
sla_seconds: int = 3600
|
|
34
|
+
queued_at: str | None = None # ISO 8601
|
|
35
|
+
resolved_at: str | None = None # ISO 8601
|
|
36
|
+
escalation_tier: int = 0
|
|
37
|
+
confidence: float | None = None
|
|
38
|
+
|
|
39
|
+
def __post_init__(self) -> None:
|
|
40
|
+
if not self.decision_id:
|
|
41
|
+
raise ValueError("HITLPayload.decision_id must be non-empty")
|
|
42
|
+
if not self.agent_id:
|
|
43
|
+
raise ValueError("HITLPayload.agent_id must be non-empty")
|
|
44
|
+
if self.risk_tier not in _VALID_RISK_TIERS:
|
|
45
|
+
raise ValueError(f"HITLPayload.risk_tier must be one of {sorted(_VALID_RISK_TIERS)}")
|
|
46
|
+
if self.status not in _VALID_STATUSES:
|
|
47
|
+
raise ValueError(f"HITLPayload.status must be one of {sorted(_VALID_STATUSES)}")
|
|
48
|
+
if not self.reason:
|
|
49
|
+
raise ValueError("HITLPayload.reason must be non-empty")
|
|
50
|
+
if self.sla_seconds <= 0:
|
|
51
|
+
raise ValueError("HITLPayload.sla_seconds must be > 0")
|
|
52
|
+
if self.confidence is not None and not (0.0 <= self.confidence <= 1.0):
|
|
53
|
+
raise ValueError("HITLPayload.confidence must be in [0.0, 1.0]")
|
|
54
|
+
|
|
55
|
+
def to_dict(self) -> dict[str, Any]:
|
|
56
|
+
"""Serialise to a plain dict."""
|
|
57
|
+
d: dict[str, Any] = {
|
|
58
|
+
"decision_id": self.decision_id,
|
|
59
|
+
"agent_id": self.agent_id,
|
|
60
|
+
"risk_tier": self.risk_tier,
|
|
61
|
+
"status": self.status,
|
|
62
|
+
"reason": self.reason,
|
|
63
|
+
"sla_seconds": self.sla_seconds,
|
|
64
|
+
"escalation_tier": self.escalation_tier,
|
|
65
|
+
}
|
|
66
|
+
if self.reviewer is not None:
|
|
67
|
+
d["reviewer"] = self.reviewer
|
|
68
|
+
if self.queued_at is not None:
|
|
69
|
+
d["queued_at"] = self.queued_at
|
|
70
|
+
if self.resolved_at is not None:
|
|
71
|
+
d["resolved_at"] = self.resolved_at
|
|
72
|
+
if self.confidence is not None:
|
|
73
|
+
d["confidence"] = self.confidence
|
|
74
|
+
return d
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def from_dict(cls, data: dict[str, Any]) -> HITLPayload:
|
|
78
|
+
"""Deserialise from a plain dict."""
|
|
79
|
+
return cls(
|
|
80
|
+
decision_id=data["decision_id"],
|
|
81
|
+
agent_id=data["agent_id"],
|
|
82
|
+
risk_tier=data["risk_tier"],
|
|
83
|
+
status=data["status"],
|
|
84
|
+
reason=data["reason"],
|
|
85
|
+
reviewer=data.get("reviewer"),
|
|
86
|
+
sla_seconds=int(data.get("sla_seconds", 3600)),
|
|
87
|
+
queued_at=data.get("queued_at"),
|
|
88
|
+
resolved_at=data.get("resolved_at"),
|
|
89
|
+
escalation_tier=int(data.get("escalation_tier", 0)),
|
|
90
|
+
confidence=data.get("confidence"),
|
|
91
|
+
)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""spanforge.namespaces.latency \u2014 Latency namespace payload types (RFC-0001 SPANFORGE).
|
|
2
|
+
|
|
3
|
+
Classes
|
|
4
|
+
-------
|
|
5
|
+
LatencyPayload latency.sample / latency.sla_breach
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
__all__ = ["LatencyPayload"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class LatencyPayload:
|
|
18
|
+
"""RFC-0001 SPANFORGE \u2014 payload for latency.* events.
|
|
19
|
+
|
|
20
|
+
Captures end-to-end response time, per-step breakdown, and SLA compliance
|
|
21
|
+
tracking (T \u2014 Traceability / S \u2014 Safety Guardrails).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
agent_id: str
|
|
25
|
+
operation: str
|
|
26
|
+
latency_ms: float
|
|
27
|
+
sla_target_ms: float
|
|
28
|
+
sla_met: bool
|
|
29
|
+
p50_ms: float | None = None
|
|
30
|
+
p95_ms: float | None = None
|
|
31
|
+
p99_ms: float | None = None
|
|
32
|
+
|
|
33
|
+
def __post_init__(self) -> None:
|
|
34
|
+
if not self.agent_id:
|
|
35
|
+
raise ValueError("LatencyPayload.agent_id must be non-empty")
|
|
36
|
+
if not self.operation:
|
|
37
|
+
raise ValueError("LatencyPayload.operation must be non-empty")
|
|
38
|
+
if self.latency_ms < 0:
|
|
39
|
+
raise ValueError("LatencyPayload.latency_ms must be >= 0")
|
|
40
|
+
if self.sla_target_ms <= 0:
|
|
41
|
+
raise ValueError("LatencyPayload.sla_target_ms must be > 0")
|
|
42
|
+
|
|
43
|
+
def to_dict(self) -> dict[str, Any]:
|
|
44
|
+
"""Serialise to a plain dict."""
|
|
45
|
+
d: dict[str, Any] = {
|
|
46
|
+
"agent_id": self.agent_id,
|
|
47
|
+
"operation": self.operation,
|
|
48
|
+
"latency_ms": self.latency_ms,
|
|
49
|
+
"sla_target_ms": self.sla_target_ms,
|
|
50
|
+
"sla_met": self.sla_met,
|
|
51
|
+
}
|
|
52
|
+
if self.p50_ms is not None:
|
|
53
|
+
d["p50_ms"] = self.p50_ms
|
|
54
|
+
if self.p95_ms is not None:
|
|
55
|
+
d["p95_ms"] = self.p95_ms
|
|
56
|
+
if self.p99_ms is not None:
|
|
57
|
+
d["p99_ms"] = self.p99_ms
|
|
58
|
+
return d
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def from_dict(cls, data: dict[str, Any]) -> LatencyPayload:
|
|
62
|
+
"""Deserialise from a plain dict."""
|
|
63
|
+
return cls(
|
|
64
|
+
agent_id=data["agent_id"],
|
|
65
|
+
operation=data["operation"],
|
|
66
|
+
latency_ms=float(data["latency_ms"]),
|
|
67
|
+
sla_target_ms=float(data["sla_target_ms"]),
|
|
68
|
+
sla_met=bool(data["sla_met"]),
|
|
69
|
+
p50_ms=data.get("p50_ms"),
|
|
70
|
+
p95_ms=data.get("p95_ms"),
|
|
71
|
+
p99_ms=data.get("p99_ms"),
|
|
72
|
+
)
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""spanforge.namespaces.prompt — Prompt payload types (RFC-0001).
|
|
2
|
+
|
|
3
|
+
Classes
|
|
4
|
+
-------
|
|
5
|
+
PromptRenderedPayload llm.prompt.rendered
|
|
6
|
+
PromptTemplateLoadedPayload llm.prompt.template.loaded
|
|
7
|
+
PromptVersionChangedPayload llm.prompt.version.changed
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"PromptRenderedPayload",
|
|
17
|
+
"PromptTemplateLoadedPayload",
|
|
18
|
+
"PromptVersionChangedPayload",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
_VALID_SOURCES = frozenset({"registry", "file", "database", "remote_url", "inline"})
|
|
22
|
+
_SHA256_HEX_LEN = 64 # SHA-256 hex digest length (characters)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class PromptRenderedPayload:
|
|
27
|
+
"""RFC-0001 — A prompt template was rendered with variables.
|
|
28
|
+
|
|
29
|
+
``rendered_hash`` is the SHA-256 of the fully-rendered prompt text.
|
|
30
|
+
The rendered text MUST NOT be stored.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
template_id: str
|
|
34
|
+
version: str
|
|
35
|
+
rendered_hash: str # 64 lowercase hex chars, SHA-256 of rendered text
|
|
36
|
+
variable_count: int | None = None
|
|
37
|
+
variable_names: list[str] = field(default_factory=list)
|
|
38
|
+
char_count: int | None = None
|
|
39
|
+
token_estimate: int | None = None
|
|
40
|
+
language: str | None = None
|
|
41
|
+
span_id: str | None = None
|
|
42
|
+
|
|
43
|
+
def __post_init__(self) -> None:
|
|
44
|
+
if not self.template_id:
|
|
45
|
+
raise ValueError("PromptRenderedPayload.template_id must be non-empty")
|
|
46
|
+
if not self.version:
|
|
47
|
+
raise ValueError("PromptRenderedPayload.version must be non-empty")
|
|
48
|
+
if not self.rendered_hash or len(self.rendered_hash) != _SHA256_HEX_LEN:
|
|
49
|
+
raise ValueError("PromptRenderedPayload.rendered_hash must be 64 hex chars (SHA-256)")
|
|
50
|
+
|
|
51
|
+
def to_dict(self) -> dict[str, Any]:
|
|
52
|
+
"""Serialise the payload to a plain ``dict``."""
|
|
53
|
+
d: dict[str, Any] = {
|
|
54
|
+
"template_id": self.template_id,
|
|
55
|
+
"version": self.version,
|
|
56
|
+
"rendered_hash": self.rendered_hash,
|
|
57
|
+
}
|
|
58
|
+
if self.variable_count is not None:
|
|
59
|
+
d["variable_count"] = self.variable_count
|
|
60
|
+
if self.variable_names:
|
|
61
|
+
d["variable_names"] = list(self.variable_names)
|
|
62
|
+
if self.char_count is not None:
|
|
63
|
+
d["char_count"] = self.char_count
|
|
64
|
+
if self.token_estimate is not None:
|
|
65
|
+
d["token_estimate"] = self.token_estimate
|
|
66
|
+
if self.language is not None:
|
|
67
|
+
d["language"] = self.language
|
|
68
|
+
if self.span_id is not None:
|
|
69
|
+
d["span_id"] = self.span_id
|
|
70
|
+
return d
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def from_dict(cls, data: dict[str, Any]) -> PromptRenderedPayload:
|
|
74
|
+
"""Deserialise from a plain ``dict``."""
|
|
75
|
+
return cls(
|
|
76
|
+
template_id=data["template_id"],
|
|
77
|
+
version=data["version"],
|
|
78
|
+
rendered_hash=data["rendered_hash"],
|
|
79
|
+
variable_count=int(data["variable_count"]) if "variable_count" in data else None,
|
|
80
|
+
variable_names=list(data.get("variable_names", [])),
|
|
81
|
+
char_count=int(data["char_count"]) if "char_count" in data else None,
|
|
82
|
+
token_estimate=int(data["token_estimate"]) if "token_estimate" in data else None,
|
|
83
|
+
language=data.get("language"),
|
|
84
|
+
span_id=data.get("span_id"),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class PromptTemplateLoadedPayload:
|
|
90
|
+
"""RFC-0001 — A prompt template was loaded from a source."""
|
|
91
|
+
|
|
92
|
+
template_id: str
|
|
93
|
+
version: str
|
|
94
|
+
source: str # "registry"|"file"|"database"|"remote_url"|"inline"
|
|
95
|
+
template_hash: str | None = None # 64 hex chars
|
|
96
|
+
load_duration_ms: float | None = None
|
|
97
|
+
cache_hit: bool | None = None
|
|
98
|
+
|
|
99
|
+
def __post_init__(self) -> None:
|
|
100
|
+
if not self.template_id:
|
|
101
|
+
raise ValueError("PromptTemplateLoadedPayload.template_id must be non-empty")
|
|
102
|
+
if not self.version:
|
|
103
|
+
raise ValueError("PromptTemplateLoadedPayload.version must be non-empty")
|
|
104
|
+
if self.source not in _VALID_SOURCES:
|
|
105
|
+
raise ValueError(
|
|
106
|
+
f"PromptTemplateLoadedPayload.source must be one of {sorted(_VALID_SOURCES)}"
|
|
107
|
+
)
|
|
108
|
+
if self.template_hash is not None and len(self.template_hash) != _SHA256_HEX_LEN:
|
|
109
|
+
raise ValueError("PromptTemplateLoadedPayload.template_hash must be 64 hex chars")
|
|
110
|
+
|
|
111
|
+
def to_dict(self) -> dict[str, Any]:
|
|
112
|
+
"""Serialise the payload to a plain ``dict``."""
|
|
113
|
+
d: dict[str, Any] = {
|
|
114
|
+
"template_id": self.template_id,
|
|
115
|
+
"version": self.version,
|
|
116
|
+
"source": self.source,
|
|
117
|
+
}
|
|
118
|
+
if self.template_hash is not None:
|
|
119
|
+
d["template_hash"] = self.template_hash
|
|
120
|
+
if self.load_duration_ms is not None:
|
|
121
|
+
d["load_duration_ms"] = self.load_duration_ms
|
|
122
|
+
if self.cache_hit is not None:
|
|
123
|
+
d["cache_hit"] = self.cache_hit
|
|
124
|
+
return d
|
|
125
|
+
|
|
126
|
+
@classmethod
|
|
127
|
+
def from_dict(cls, data: dict[str, Any]) -> PromptTemplateLoadedPayload:
|
|
128
|
+
"""Deserialise from a plain ``dict``."""
|
|
129
|
+
return cls(
|
|
130
|
+
template_id=data["template_id"],
|
|
131
|
+
version=data["version"],
|
|
132
|
+
source=data["source"],
|
|
133
|
+
template_hash=data.get("template_hash"),
|
|
134
|
+
load_duration_ms=float(data["load_duration_ms"])
|
|
135
|
+
if "load_duration_ms" in data
|
|
136
|
+
else None,
|
|
137
|
+
cache_hit=bool(data["cache_hit"]) if "cache_hit" in data else None,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass
|
|
142
|
+
class PromptVersionChangedPayload:
|
|
143
|
+
"""RFC-0001 — A prompt template was promoted to a new version."""
|
|
144
|
+
|
|
145
|
+
template_id: str
|
|
146
|
+
previous_version: str
|
|
147
|
+
new_version: str
|
|
148
|
+
change_reason: str
|
|
149
|
+
changed_by: str | None = None
|
|
150
|
+
previous_hash: str | None = None # 64 hex chars
|
|
151
|
+
new_hash: str | None = None # 64 hex chars
|
|
152
|
+
|
|
153
|
+
def __post_init__(self) -> None:
|
|
154
|
+
if not self.template_id:
|
|
155
|
+
raise ValueError("PromptVersionChangedPayload.template_id must be non-empty")
|
|
156
|
+
if not self.previous_version:
|
|
157
|
+
raise ValueError("PromptVersionChangedPayload.previous_version must be non-empty")
|
|
158
|
+
if not self.new_version:
|
|
159
|
+
raise ValueError("PromptVersionChangedPayload.new_version must be non-empty")
|
|
160
|
+
if not self.change_reason:
|
|
161
|
+
raise ValueError("PromptVersionChangedPayload.change_reason must be non-empty")
|
|
162
|
+
|
|
163
|
+
def to_dict(self) -> dict[str, Any]:
|
|
164
|
+
"""Serialise the payload to a plain ``dict``."""
|
|
165
|
+
d: dict[str, Any] = {
|
|
166
|
+
"template_id": self.template_id,
|
|
167
|
+
"previous_version": self.previous_version,
|
|
168
|
+
"new_version": self.new_version,
|
|
169
|
+
"change_reason": self.change_reason,
|
|
170
|
+
}
|
|
171
|
+
if self.changed_by is not None:
|
|
172
|
+
d["changed_by"] = self.changed_by
|
|
173
|
+
if self.previous_hash is not None:
|
|
174
|
+
d["previous_hash"] = self.previous_hash
|
|
175
|
+
if self.new_hash is not None:
|
|
176
|
+
d["new_hash"] = self.new_hash
|
|
177
|
+
return d
|
|
178
|
+
|
|
179
|
+
@classmethod
|
|
180
|
+
def from_dict(cls, data: dict[str, Any]) -> PromptVersionChangedPayload:
|
|
181
|
+
"""Deserialise from a plain ``dict``."""
|
|
182
|
+
return cls(
|
|
183
|
+
template_id=data["template_id"],
|
|
184
|
+
previous_version=data["previous_version"],
|
|
185
|
+
new_version=data["new_version"],
|
|
186
|
+
change_reason=data["change_reason"],
|
|
187
|
+
changed_by=data.get("changed_by"),
|
|
188
|
+
previous_hash=data.get("previous_hash"),
|
|
189
|
+
new_hash=data.get("new_hash"),
|
|
190
|
+
)
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""spanforge.namespaces.redact — Redaction payload types (RFC-0001).
|
|
2
|
+
|
|
3
|
+
Classes
|
|
4
|
+
-------
|
|
5
|
+
RedactPiiDetectedPayload llm.redact.pii.detected
|
|
6
|
+
RedactPhiDetectedPayload llm.redact.phi.detected
|
|
7
|
+
RedactAppliedPayload llm.redact.applied
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"RedactAppliedPayload",
|
|
17
|
+
"RedactPhiDetectedPayload",
|
|
18
|
+
"RedactPiiDetectedPayload",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
_VALID_SENSITIVITY_LEVELS = frozenset({"LOW", "MEDIUM", "HIGH", "PII", "PHI"})
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class RedactPiiDetectedPayload:
|
|
26
|
+
"""RFC-0001 — PII was detected in an LLM input or output field."""
|
|
27
|
+
|
|
28
|
+
detected_categories: list[str] # minItems=1 — e.g. ["email", "phone"]
|
|
29
|
+
field_names: list[str] # minItems=1 — field paths where PII found
|
|
30
|
+
sensitivity_level: str # "LOW"|"MEDIUM"|"HIGH"|"PII"|"PHI"
|
|
31
|
+
detection_count: int | None = None
|
|
32
|
+
detector: str | None = None
|
|
33
|
+
subject_event_id: str | None = None
|
|
34
|
+
|
|
35
|
+
def __post_init__(self) -> None:
|
|
36
|
+
if not self.detected_categories:
|
|
37
|
+
raise ValueError("RedactPiiDetectedPayload.detected_categories must be non-empty")
|
|
38
|
+
if not self.field_names:
|
|
39
|
+
raise ValueError("RedactPiiDetectedPayload.field_names must be non-empty")
|
|
40
|
+
if self.sensitivity_level not in _VALID_SENSITIVITY_LEVELS:
|
|
41
|
+
raise ValueError(
|
|
42
|
+
f"RedactPiiDetectedPayload.sensitivity_level must be one of {sorted(_VALID_SENSITIVITY_LEVELS)}"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def to_dict(self) -> dict[str, Any]:
|
|
46
|
+
"""Serialise the payload to a plain ``dict``."""
|
|
47
|
+
d: dict[str, Any] = {
|
|
48
|
+
"detected_categories": list(self.detected_categories),
|
|
49
|
+
"field_names": list(self.field_names),
|
|
50
|
+
"sensitivity_level": self.sensitivity_level,
|
|
51
|
+
}
|
|
52
|
+
if self.detection_count is not None:
|
|
53
|
+
d["detection_count"] = self.detection_count
|
|
54
|
+
if self.detector is not None:
|
|
55
|
+
d["detector"] = self.detector
|
|
56
|
+
if self.subject_event_id is not None:
|
|
57
|
+
d["subject_event_id"] = self.subject_event_id
|
|
58
|
+
return d
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def from_dict(cls, data: dict[str, Any]) -> RedactPiiDetectedPayload:
|
|
62
|
+
"""Deserialise from a plain ``dict``."""
|
|
63
|
+
return cls(
|
|
64
|
+
detected_categories=list(data["detected_categories"]),
|
|
65
|
+
field_names=list(data["field_names"]),
|
|
66
|
+
sensitivity_level=data["sensitivity_level"],
|
|
67
|
+
detection_count=int(data["detection_count"]) if "detection_count" in data else None,
|
|
68
|
+
detector=data.get("detector"),
|
|
69
|
+
subject_event_id=data.get("subject_event_id"),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class RedactPhiDetectedPayload:
|
|
75
|
+
"""RFC-0001 — PHI was detected (HIPAA-covered health information).
|
|
76
|
+
|
|
77
|
+
``sensitivity_level`` MUST always be ``"PHI"`` for this payload type.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
detected_categories: list[str]
|
|
81
|
+
field_names: list[str]
|
|
82
|
+
sensitivity_level: str = "PHI" # MUST be "PHI"
|
|
83
|
+
detection_count: int | None = None
|
|
84
|
+
detector: str | None = None
|
|
85
|
+
subject_event_id: str | None = None
|
|
86
|
+
hipaa_covered: bool | None = None
|
|
87
|
+
|
|
88
|
+
def __post_init__(self) -> None:
|
|
89
|
+
if not self.detected_categories:
|
|
90
|
+
raise ValueError("RedactPhiDetectedPayload.detected_categories must be non-empty")
|
|
91
|
+
if not self.field_names:
|
|
92
|
+
raise ValueError("RedactPhiDetectedPayload.field_names must be non-empty")
|
|
93
|
+
if self.sensitivity_level != "PHI":
|
|
94
|
+
raise ValueError("RedactPhiDetectedPayload.sensitivity_level MUST be 'PHI'")
|
|
95
|
+
|
|
96
|
+
def to_dict(self) -> dict[str, Any]:
|
|
97
|
+
"""Serialise the payload to a plain ``dict``."""
|
|
98
|
+
d: dict[str, Any] = {
|
|
99
|
+
"detected_categories": list(self.detected_categories),
|
|
100
|
+
"field_names": list(self.field_names),
|
|
101
|
+
"sensitivity_level": self.sensitivity_level,
|
|
102
|
+
}
|
|
103
|
+
if self.detection_count is not None:
|
|
104
|
+
d["detection_count"] = self.detection_count
|
|
105
|
+
if self.detector is not None:
|
|
106
|
+
d["detector"] = self.detector
|
|
107
|
+
if self.subject_event_id is not None:
|
|
108
|
+
d["subject_event_id"] = self.subject_event_id
|
|
109
|
+
if self.hipaa_covered is not None:
|
|
110
|
+
d["hipaa_covered"] = self.hipaa_covered
|
|
111
|
+
return d
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
def from_dict(cls, data: dict[str, Any]) -> RedactPhiDetectedPayload:
|
|
115
|
+
"""Deserialise from a plain ``dict``."""
|
|
116
|
+
return cls(
|
|
117
|
+
detected_categories=list(data["detected_categories"]),
|
|
118
|
+
field_names=list(data["field_names"]),
|
|
119
|
+
sensitivity_level=data.get("sensitivity_level", "PHI"),
|
|
120
|
+
detection_count=int(data["detection_count"]) if "detection_count" in data else None,
|
|
121
|
+
detector=data.get("detector"),
|
|
122
|
+
subject_event_id=data.get("subject_event_id"),
|
|
123
|
+
hipaa_covered=bool(data["hipaa_covered"]) if "hipaa_covered" in data else None,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass
|
|
128
|
+
class RedactAppliedPayload:
|
|
129
|
+
"""RFC-0001 — A redaction policy was applied to one or more fields."""
|
|
130
|
+
|
|
131
|
+
policy_min_sensitivity: str # "LOW"|"MEDIUM"|"HIGH"|"PII"|"PHI"
|
|
132
|
+
redacted_by: str
|
|
133
|
+
redacted_count: int
|
|
134
|
+
redacted_field_names: list[str] = field(default_factory=list)
|
|
135
|
+
subject_event_id: str | None = None
|
|
136
|
+
verified: bool | None = None
|
|
137
|
+
|
|
138
|
+
def __post_init__(self) -> None:
|
|
139
|
+
if self.policy_min_sensitivity not in _VALID_SENSITIVITY_LEVELS:
|
|
140
|
+
raise ValueError(
|
|
141
|
+
f"RedactAppliedPayload.policy_min_sensitivity must be one of {sorted(_VALID_SENSITIVITY_LEVELS)}"
|
|
142
|
+
)
|
|
143
|
+
if not isinstance(self.redacted_by, str) or not self.redacted_by:
|
|
144
|
+
raise ValueError("RedactAppliedPayload.redacted_by must be non-empty")
|
|
145
|
+
if not isinstance(self.redacted_count, int) or self.redacted_count < 0:
|
|
146
|
+
raise ValueError("RedactAppliedPayload.redacted_count must be a non-negative int")
|
|
147
|
+
|
|
148
|
+
def to_dict(self) -> dict[str, Any]:
|
|
149
|
+
"""Serialise the payload to a plain ``dict``."""
|
|
150
|
+
d: dict[str, Any] = {
|
|
151
|
+
"policy_min_sensitivity": self.policy_min_sensitivity,
|
|
152
|
+
"redacted_by": self.redacted_by,
|
|
153
|
+
"redacted_count": self.redacted_count,
|
|
154
|
+
}
|
|
155
|
+
if self.redacted_field_names:
|
|
156
|
+
d["redacted_field_names"] = list(self.redacted_field_names)
|
|
157
|
+
if self.subject_event_id is not None:
|
|
158
|
+
d["subject_event_id"] = self.subject_event_id
|
|
159
|
+
if self.verified is not None:
|
|
160
|
+
d["verified"] = self.verified
|
|
161
|
+
return d
|
|
162
|
+
|
|
163
|
+
@classmethod
|
|
164
|
+
def from_dict(cls, data: dict[str, Any]) -> RedactAppliedPayload:
|
|
165
|
+
"""Deserialise from a plain ``dict``."""
|
|
166
|
+
return cls(
|
|
167
|
+
policy_min_sensitivity=data["policy_min_sensitivity"],
|
|
168
|
+
redacted_by=data["redacted_by"],
|
|
169
|
+
redacted_count=int(data["redacted_count"]),
|
|
170
|
+
redacted_field_names=list(data.get("redacted_field_names", [])),
|
|
171
|
+
subject_event_id=data.get("subject_event_id"),
|
|
172
|
+
verified=bool(data["verified"]) if "verified" in data else None,
|
|
173
|
+
)
|