spanforge 2.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 +695 -0
- spanforge/_batch_exporter.py +322 -0
- spanforge/_cli.py +3081 -0
- spanforge/_hooks.py +340 -0
- spanforge/_server.py +953 -0
- spanforge/_span.py +1015 -0
- spanforge/_store.py +287 -0
- spanforge/_stream.py +654 -0
- spanforge/_trace.py +334 -0
- spanforge/_tracer.py +253 -0
- spanforge/actor.py +141 -0
- spanforge/alerts.py +464 -0
- spanforge/auto.py +181 -0
- spanforge/baseline.py +336 -0
- spanforge/config.py +460 -0
- spanforge/consent.py +227 -0
- spanforge/consumer.py +379 -0
- spanforge/core/__init__.py +5 -0
- spanforge/core/compliance_mapping.py +1060 -0
- spanforge/cost.py +597 -0
- spanforge/debug.py +514 -0
- spanforge/drift.py +488 -0
- spanforge/egress.py +63 -0
- spanforge/eval.py +575 -0
- spanforge/event.py +1052 -0
- spanforge/exceptions.py +246 -0
- spanforge/explain.py +181 -0
- spanforge/export/__init__.py +50 -0
- spanforge/export/append_only.py +342 -0
- spanforge/export/cloud.py +349 -0
- spanforge/export/datadog.py +495 -0
- spanforge/export/grafana.py +331 -0
- spanforge/export/jsonl.py +198 -0
- spanforge/export/otel_bridge.py +291 -0
- spanforge/export/otlp.py +817 -0
- spanforge/export/otlp_bridge.py +231 -0
- spanforge/export/redis_backend.py +282 -0
- spanforge/export/webhook.py +302 -0
- spanforge/exporters/__init__.py +29 -0
- spanforge/exporters/console.py +271 -0
- spanforge/exporters/jsonl.py +144 -0
- spanforge/hitl.py +297 -0
- spanforge/inspect.py +429 -0
- spanforge/integrations/__init__.py +39 -0
- spanforge/integrations/_pricing.py +277 -0
- spanforge/integrations/anthropic.py +388 -0
- spanforge/integrations/bedrock.py +306 -0
- spanforge/integrations/crewai.py +251 -0
- spanforge/integrations/gemini.py +349 -0
- spanforge/integrations/groq.py +444 -0
- spanforge/integrations/langchain.py +349 -0
- spanforge/integrations/llamaindex.py +370 -0
- spanforge/integrations/ollama.py +286 -0
- spanforge/integrations/openai.py +370 -0
- spanforge/integrations/together.py +485 -0
- spanforge/metrics.py +393 -0
- spanforge/metrics_export.py +342 -0
- spanforge/migrate.py +278 -0
- spanforge/model_registry.py +282 -0
- spanforge/models.py +407 -0
- spanforge/namespaces/__init__.py +215 -0
- spanforge/namespaces/audit.py +253 -0
- spanforge/namespaces/cache.py +209 -0
- spanforge/namespaces/chain.py +74 -0
- spanforge/namespaces/confidence.py +69 -0
- spanforge/namespaces/consent.py +85 -0
- spanforge/namespaces/cost.py +175 -0
- spanforge/namespaces/decision.py +135 -0
- spanforge/namespaces/diff.py +146 -0
- spanforge/namespaces/drift.py +79 -0
- spanforge/namespaces/eval_.py +232 -0
- spanforge/namespaces/fence.py +180 -0
- spanforge/namespaces/guard.py +104 -0
- spanforge/namespaces/hitl.py +92 -0
- spanforge/namespaces/latency.py +69 -0
- spanforge/namespaces/prompt.py +185 -0
- spanforge/namespaces/redact.py +172 -0
- spanforge/namespaces/template.py +197 -0
- spanforge/namespaces/tool_call.py +76 -0
- spanforge/namespaces/trace.py +1006 -0
- spanforge/normalizer.py +183 -0
- spanforge/presidio_backend.py +149 -0
- spanforge/processor.py +258 -0
- spanforge/prompt_registry.py +415 -0
- spanforge/py.typed +0 -0
- spanforge/redact.py +780 -0
- spanforge/sampling.py +500 -0
- spanforge/schemas/v1.0/schema.json +170 -0
- spanforge/schemas/v2.0/schema.json +536 -0
- spanforge/signing.py +1152 -0
- spanforge/stream.py +559 -0
- spanforge/testing.py +376 -0
- spanforge/trace.py +199 -0
- spanforge/types.py +696 -0
- spanforge/ulid.py +304 -0
- spanforge/validate.py +383 -0
- spanforge-2.0.0.dist-info/METADATA +1777 -0
- spanforge-2.0.0.dist-info/RECORD +101 -0
- spanforge-2.0.0.dist-info/WHEEL +4 -0
- spanforge-2.0.0.dist-info/entry_points.txt +5 -0
- spanforge-2.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,69 @@
|
|
|
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
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
__all__ = ["LatencyPayload"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class LatencyPayload:
|
|
17
|
+
"""RFC-0001 SPANFORGE \u2014 payload for latency.* events.
|
|
18
|
+
|
|
19
|
+
Captures end-to-end response time, per-step breakdown, and SLA compliance
|
|
20
|
+
tracking (T \u2014 Traceability / S \u2014 Safety Guardrails).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
agent_id: str
|
|
24
|
+
operation: str
|
|
25
|
+
latency_ms: float
|
|
26
|
+
sla_target_ms: float
|
|
27
|
+
sla_met: bool
|
|
28
|
+
p50_ms: float | None = None
|
|
29
|
+
p95_ms: float | None = None
|
|
30
|
+
p99_ms: float | None = None
|
|
31
|
+
|
|
32
|
+
def __post_init__(self) -> None:
|
|
33
|
+
if not self.agent_id:
|
|
34
|
+
raise ValueError("LatencyPayload.agent_id must be non-empty")
|
|
35
|
+
if not self.operation:
|
|
36
|
+
raise ValueError("LatencyPayload.operation must be non-empty")
|
|
37
|
+
if self.latency_ms < 0:
|
|
38
|
+
raise ValueError("LatencyPayload.latency_ms must be >= 0")
|
|
39
|
+
if self.sla_target_ms <= 0:
|
|
40
|
+
raise ValueError("LatencyPayload.sla_target_ms must be > 0")
|
|
41
|
+
|
|
42
|
+
def to_dict(self) -> dict[str, Any]:
|
|
43
|
+
d: dict[str, Any] = {
|
|
44
|
+
"agent_id": self.agent_id,
|
|
45
|
+
"operation": self.operation,
|
|
46
|
+
"latency_ms": self.latency_ms,
|
|
47
|
+
"sla_target_ms": self.sla_target_ms,
|
|
48
|
+
"sla_met": self.sla_met,
|
|
49
|
+
}
|
|
50
|
+
if self.p50_ms is not None:
|
|
51
|
+
d["p50_ms"] = self.p50_ms
|
|
52
|
+
if self.p95_ms is not None:
|
|
53
|
+
d["p95_ms"] = self.p95_ms
|
|
54
|
+
if self.p99_ms is not None:
|
|
55
|
+
d["p99_ms"] = self.p99_ms
|
|
56
|
+
return d
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def from_dict(cls, data: dict[str, Any]) -> LatencyPayload:
|
|
60
|
+
return cls(
|
|
61
|
+
agent_id=data["agent_id"],
|
|
62
|
+
operation=data["operation"],
|
|
63
|
+
latency_ms=float(data["latency_ms"]),
|
|
64
|
+
sla_target_ms=float(data["sla_target_ms"]),
|
|
65
|
+
sla_met=bool(data["sla_met"]),
|
|
66
|
+
p50_ms=data.get("p50_ms"),
|
|
67
|
+
p95_ms=data.get("p95_ms"),
|
|
68
|
+
p99_ms=data.get("p99_ms"),
|
|
69
|
+
)
|
|
@@ -0,0 +1,185 @@
|
|
|
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
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"PromptRenderedPayload",
|
|
16
|
+
"PromptTemplateLoadedPayload",
|
|
17
|
+
"PromptVersionChangedPayload",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
_VALID_SOURCES = frozenset({"registry", "file", "database", "remote_url", "inline"})
|
|
21
|
+
_SHA256_HEX_LEN = 64 # SHA-256 hex digest length (characters)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class PromptRenderedPayload:
|
|
26
|
+
"""RFC-0001 — A prompt template was rendered with variables.
|
|
27
|
+
|
|
28
|
+
``rendered_hash`` is the SHA-256 of the fully-rendered prompt text.
|
|
29
|
+
The rendered text MUST NOT be stored.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
template_id: str
|
|
33
|
+
version: str
|
|
34
|
+
rendered_hash: str # 64 lowercase hex chars, SHA-256 of rendered text
|
|
35
|
+
variable_count: int | None = None
|
|
36
|
+
variable_names: list[str] = field(default_factory=list)
|
|
37
|
+
char_count: int | None = None
|
|
38
|
+
token_estimate: int | None = None
|
|
39
|
+
language: str | None = None
|
|
40
|
+
span_id: str | None = None
|
|
41
|
+
|
|
42
|
+
def __post_init__(self) -> None:
|
|
43
|
+
if not self.template_id:
|
|
44
|
+
raise ValueError("PromptRenderedPayload.template_id must be non-empty")
|
|
45
|
+
if not self.version:
|
|
46
|
+
raise ValueError("PromptRenderedPayload.version must be non-empty")
|
|
47
|
+
if not self.rendered_hash or len(self.rendered_hash) != _SHA256_HEX_LEN:
|
|
48
|
+
raise ValueError("PromptRenderedPayload.rendered_hash must be 64 hex chars (SHA-256)")
|
|
49
|
+
|
|
50
|
+
def to_dict(self) -> dict[str, Any]:
|
|
51
|
+
"""Serialise the payload to a plain ``dict``."""
|
|
52
|
+
d: dict[str, Any] = {
|
|
53
|
+
"template_id": self.template_id,
|
|
54
|
+
"version": self.version,
|
|
55
|
+
"rendered_hash": self.rendered_hash,
|
|
56
|
+
}
|
|
57
|
+
if self.variable_count is not None:
|
|
58
|
+
d["variable_count"] = self.variable_count
|
|
59
|
+
if self.variable_names:
|
|
60
|
+
d["variable_names"] = list(self.variable_names)
|
|
61
|
+
if self.char_count is not None:
|
|
62
|
+
d["char_count"] = self.char_count
|
|
63
|
+
if self.token_estimate is not None:
|
|
64
|
+
d["token_estimate"] = self.token_estimate
|
|
65
|
+
if self.language is not None:
|
|
66
|
+
d["language"] = self.language
|
|
67
|
+
if self.span_id is not None:
|
|
68
|
+
d["span_id"] = self.span_id
|
|
69
|
+
return d
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def from_dict(cls, data: dict[str, Any]) -> PromptRenderedPayload:
|
|
73
|
+
"""Deserialise from a plain ``dict``."""
|
|
74
|
+
return cls(
|
|
75
|
+
template_id=data["template_id"],
|
|
76
|
+
version=data["version"],
|
|
77
|
+
rendered_hash=data["rendered_hash"],
|
|
78
|
+
variable_count=int(data["variable_count"]) if "variable_count" in data else None,
|
|
79
|
+
variable_names=list(data.get("variable_names", [])),
|
|
80
|
+
char_count=int(data["char_count"]) if "char_count" in data else None,
|
|
81
|
+
token_estimate=int(data["token_estimate"]) if "token_estimate" in data else None,
|
|
82
|
+
language=data.get("language"),
|
|
83
|
+
span_id=data.get("span_id"),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class PromptTemplateLoadedPayload:
|
|
89
|
+
"""RFC-0001 — A prompt template was loaded from a source."""
|
|
90
|
+
|
|
91
|
+
template_id: str
|
|
92
|
+
version: str
|
|
93
|
+
source: str # "registry"|"file"|"database"|"remote_url"|"inline"
|
|
94
|
+
template_hash: str | None = None # 64 hex chars
|
|
95
|
+
load_duration_ms: float | None = None
|
|
96
|
+
cache_hit: bool | None = None
|
|
97
|
+
|
|
98
|
+
def __post_init__(self) -> None:
|
|
99
|
+
if not self.template_id:
|
|
100
|
+
raise ValueError("PromptTemplateLoadedPayload.template_id must be non-empty")
|
|
101
|
+
if not self.version:
|
|
102
|
+
raise ValueError("PromptTemplateLoadedPayload.version must be non-empty")
|
|
103
|
+
if self.source not in _VALID_SOURCES:
|
|
104
|
+
raise ValueError(f"PromptTemplateLoadedPayload.source must be one of {sorted(_VALID_SOURCES)}") # noqa: E501
|
|
105
|
+
if self.template_hash is not None and len(self.template_hash) != _SHA256_HEX_LEN:
|
|
106
|
+
raise ValueError("PromptTemplateLoadedPayload.template_hash must be 64 hex chars")
|
|
107
|
+
|
|
108
|
+
def to_dict(self) -> dict[str, Any]:
|
|
109
|
+
"""Serialise the payload to a plain ``dict``."""
|
|
110
|
+
d: dict[str, Any] = {
|
|
111
|
+
"template_id": self.template_id,
|
|
112
|
+
"version": self.version,
|
|
113
|
+
"source": self.source,
|
|
114
|
+
}
|
|
115
|
+
if self.template_hash is not None:
|
|
116
|
+
d["template_hash"] = self.template_hash
|
|
117
|
+
if self.load_duration_ms is not None:
|
|
118
|
+
d["load_duration_ms"] = self.load_duration_ms
|
|
119
|
+
if self.cache_hit is not None:
|
|
120
|
+
d["cache_hit"] = self.cache_hit
|
|
121
|
+
return d
|
|
122
|
+
|
|
123
|
+
@classmethod
|
|
124
|
+
def from_dict(cls, data: dict[str, Any]) -> PromptTemplateLoadedPayload:
|
|
125
|
+
"""Deserialise from a plain ``dict``."""
|
|
126
|
+
return cls(
|
|
127
|
+
template_id=data["template_id"],
|
|
128
|
+
version=data["version"],
|
|
129
|
+
source=data["source"],
|
|
130
|
+
template_hash=data.get("template_hash"),
|
|
131
|
+
load_duration_ms=float(data["load_duration_ms"]) if "load_duration_ms" in data else None, # noqa: E501
|
|
132
|
+
cache_hit=bool(data["cache_hit"]) if "cache_hit" in data else None,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass
|
|
137
|
+
class PromptVersionChangedPayload:
|
|
138
|
+
"""RFC-0001 — A prompt template was promoted to a new version."""
|
|
139
|
+
|
|
140
|
+
template_id: str
|
|
141
|
+
previous_version: str
|
|
142
|
+
new_version: str
|
|
143
|
+
change_reason: str
|
|
144
|
+
changed_by: str | None = None
|
|
145
|
+
previous_hash: str | None = None # 64 hex chars
|
|
146
|
+
new_hash: str | None = None # 64 hex chars
|
|
147
|
+
|
|
148
|
+
def __post_init__(self) -> None:
|
|
149
|
+
if not self.template_id:
|
|
150
|
+
raise ValueError("PromptVersionChangedPayload.template_id must be non-empty")
|
|
151
|
+
if not self.previous_version:
|
|
152
|
+
raise ValueError("PromptVersionChangedPayload.previous_version must be non-empty")
|
|
153
|
+
if not self.new_version:
|
|
154
|
+
raise ValueError("PromptVersionChangedPayload.new_version must be non-empty")
|
|
155
|
+
if not self.change_reason:
|
|
156
|
+
raise ValueError("PromptVersionChangedPayload.change_reason must be non-empty")
|
|
157
|
+
|
|
158
|
+
def to_dict(self) -> dict[str, Any]:
|
|
159
|
+
"""Serialise the payload to a plain ``dict``."""
|
|
160
|
+
d: dict[str, Any] = {
|
|
161
|
+
"template_id": self.template_id,
|
|
162
|
+
"previous_version": self.previous_version,
|
|
163
|
+
"new_version": self.new_version,
|
|
164
|
+
"change_reason": self.change_reason,
|
|
165
|
+
}
|
|
166
|
+
if self.changed_by is not None:
|
|
167
|
+
d["changed_by"] = self.changed_by
|
|
168
|
+
if self.previous_hash is not None:
|
|
169
|
+
d["previous_hash"] = self.previous_hash
|
|
170
|
+
if self.new_hash is not None:
|
|
171
|
+
d["new_hash"] = self.new_hash
|
|
172
|
+
return d
|
|
173
|
+
|
|
174
|
+
@classmethod
|
|
175
|
+
def from_dict(cls, data: dict[str, Any]) -> PromptVersionChangedPayload:
|
|
176
|
+
"""Deserialise from a plain ``dict``."""
|
|
177
|
+
return cls(
|
|
178
|
+
template_id=data["template_id"],
|
|
179
|
+
previous_version=data["previous_version"],
|
|
180
|
+
new_version=data["new_version"],
|
|
181
|
+
change_reason=data["change_reason"],
|
|
182
|
+
changed_by=data.get("changed_by"),
|
|
183
|
+
previous_hash=data.get("previous_hash"),
|
|
184
|
+
new_hash=data.get("new_hash"),
|
|
185
|
+
)
|
|
@@ -0,0 +1,172 @@
|
|
|
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
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"RedactAppliedPayload",
|
|
16
|
+
"RedactPhiDetectedPayload",
|
|
17
|
+
"RedactPiiDetectedPayload",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
_VALID_SENSITIVITY_LEVELS = frozenset({"LOW", "MEDIUM", "HIGH", "PII", "PHI"})
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class RedactPiiDetectedPayload:
|
|
25
|
+
"""RFC-0001 — PII was detected in an LLM input or output field."""
|
|
26
|
+
|
|
27
|
+
detected_categories: list[str] # minItems=1 — e.g. ["email", "phone"]
|
|
28
|
+
field_names: list[str] # minItems=1 — field paths where PII found
|
|
29
|
+
sensitivity_level: str # "LOW"|"MEDIUM"|"HIGH"|"PII"|"PHI"
|
|
30
|
+
detection_count: int | None = None
|
|
31
|
+
detector: str | None = None
|
|
32
|
+
subject_event_id: str | None = None
|
|
33
|
+
|
|
34
|
+
def __post_init__(self) -> None:
|
|
35
|
+
if not self.detected_categories:
|
|
36
|
+
raise ValueError("RedactPiiDetectedPayload.detected_categories must be non-empty")
|
|
37
|
+
if not self.field_names:
|
|
38
|
+
raise ValueError("RedactPiiDetectedPayload.field_names must be non-empty")
|
|
39
|
+
if self.sensitivity_level not in _VALID_SENSITIVITY_LEVELS:
|
|
40
|
+
raise ValueError(
|
|
41
|
+
f"RedactPiiDetectedPayload.sensitivity_level must be one of {sorted(_VALID_SENSITIVITY_LEVELS)}" # noqa: E501
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def to_dict(self) -> dict[str, Any]:
|
|
45
|
+
"""Serialise the payload to a plain ``dict``."""
|
|
46
|
+
d: dict[str, Any] = {
|
|
47
|
+
"detected_categories": list(self.detected_categories),
|
|
48
|
+
"field_names": list(self.field_names),
|
|
49
|
+
"sensitivity_level": self.sensitivity_level,
|
|
50
|
+
}
|
|
51
|
+
if self.detection_count is not None:
|
|
52
|
+
d["detection_count"] = self.detection_count
|
|
53
|
+
if self.detector is not None:
|
|
54
|
+
d["detector"] = self.detector
|
|
55
|
+
if self.subject_event_id is not None:
|
|
56
|
+
d["subject_event_id"] = self.subject_event_id
|
|
57
|
+
return d
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def from_dict(cls, data: dict[str, Any]) -> RedactPiiDetectedPayload:
|
|
61
|
+
"""Deserialise from a plain ``dict``."""
|
|
62
|
+
return cls(
|
|
63
|
+
detected_categories=list(data["detected_categories"]),
|
|
64
|
+
field_names=list(data["field_names"]),
|
|
65
|
+
sensitivity_level=data["sensitivity_level"],
|
|
66
|
+
detection_count=int(data["detection_count"]) if "detection_count" in data else None,
|
|
67
|
+
detector=data.get("detector"),
|
|
68
|
+
subject_event_id=data.get("subject_event_id"),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class RedactPhiDetectedPayload:
|
|
74
|
+
"""RFC-0001 — PHI was detected (HIPAA-covered health information).
|
|
75
|
+
|
|
76
|
+
``sensitivity_level`` MUST always be ``"PHI"`` for this payload type.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
detected_categories: list[str]
|
|
80
|
+
field_names: list[str]
|
|
81
|
+
sensitivity_level: str = "PHI" # MUST be "PHI"
|
|
82
|
+
detection_count: int | None = None
|
|
83
|
+
detector: str | None = None
|
|
84
|
+
subject_event_id: str | None = None
|
|
85
|
+
hipaa_covered: bool | None = None
|
|
86
|
+
|
|
87
|
+
def __post_init__(self) -> None:
|
|
88
|
+
if not self.detected_categories:
|
|
89
|
+
raise ValueError("RedactPhiDetectedPayload.detected_categories must be non-empty")
|
|
90
|
+
if not self.field_names:
|
|
91
|
+
raise ValueError("RedactPhiDetectedPayload.field_names must be non-empty")
|
|
92
|
+
if self.sensitivity_level != "PHI":
|
|
93
|
+
raise ValueError("RedactPhiDetectedPayload.sensitivity_level MUST be 'PHI'")
|
|
94
|
+
|
|
95
|
+
def to_dict(self) -> dict[str, Any]:
|
|
96
|
+
"""Serialise the payload to a plain ``dict``."""
|
|
97
|
+
d: dict[str, Any] = {
|
|
98
|
+
"detected_categories": list(self.detected_categories),
|
|
99
|
+
"field_names": list(self.field_names),
|
|
100
|
+
"sensitivity_level": self.sensitivity_level,
|
|
101
|
+
}
|
|
102
|
+
if self.detection_count is not None:
|
|
103
|
+
d["detection_count"] = self.detection_count
|
|
104
|
+
if self.detector is not None:
|
|
105
|
+
d["detector"] = self.detector
|
|
106
|
+
if self.subject_event_id is not None:
|
|
107
|
+
d["subject_event_id"] = self.subject_event_id
|
|
108
|
+
if self.hipaa_covered is not None:
|
|
109
|
+
d["hipaa_covered"] = self.hipaa_covered
|
|
110
|
+
return d
|
|
111
|
+
|
|
112
|
+
@classmethod
|
|
113
|
+
def from_dict(cls, data: dict[str, Any]) -> RedactPhiDetectedPayload:
|
|
114
|
+
"""Deserialise from a plain ``dict``."""
|
|
115
|
+
return cls(
|
|
116
|
+
detected_categories=list(data["detected_categories"]),
|
|
117
|
+
field_names=list(data["field_names"]),
|
|
118
|
+
sensitivity_level=data.get("sensitivity_level", "PHI"),
|
|
119
|
+
detection_count=int(data["detection_count"]) if "detection_count" in data else None,
|
|
120
|
+
detector=data.get("detector"),
|
|
121
|
+
subject_event_id=data.get("subject_event_id"),
|
|
122
|
+
hipaa_covered=bool(data["hipaa_covered"]) if "hipaa_covered" in data else None,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass
|
|
127
|
+
class RedactAppliedPayload:
|
|
128
|
+
"""RFC-0001 — A redaction policy was applied to one or more fields."""
|
|
129
|
+
|
|
130
|
+
policy_min_sensitivity: str # "LOW"|"MEDIUM"|"HIGH"|"PII"|"PHI"
|
|
131
|
+
redacted_by: str
|
|
132
|
+
redacted_count: int
|
|
133
|
+
redacted_field_names: list[str] = field(default_factory=list)
|
|
134
|
+
subject_event_id: str | None = None
|
|
135
|
+
verified: bool | None = None
|
|
136
|
+
|
|
137
|
+
def __post_init__(self) -> None:
|
|
138
|
+
if self.policy_min_sensitivity not in _VALID_SENSITIVITY_LEVELS:
|
|
139
|
+
raise ValueError(
|
|
140
|
+
f"RedactAppliedPayload.policy_min_sensitivity must be one of {sorted(_VALID_SENSITIVITY_LEVELS)}" # noqa: E501
|
|
141
|
+
)
|
|
142
|
+
if not isinstance(self.redacted_by, str) or not self.redacted_by:
|
|
143
|
+
raise ValueError("RedactAppliedPayload.redacted_by must be non-empty")
|
|
144
|
+
if not isinstance(self.redacted_count, int) or self.redacted_count < 0:
|
|
145
|
+
raise ValueError("RedactAppliedPayload.redacted_count must be a non-negative int")
|
|
146
|
+
|
|
147
|
+
def to_dict(self) -> dict[str, Any]:
|
|
148
|
+
"""Serialise the payload to a plain ``dict``."""
|
|
149
|
+
d: dict[str, Any] = {
|
|
150
|
+
"policy_min_sensitivity": self.policy_min_sensitivity,
|
|
151
|
+
"redacted_by": self.redacted_by,
|
|
152
|
+
"redacted_count": self.redacted_count,
|
|
153
|
+
}
|
|
154
|
+
if self.redacted_field_names:
|
|
155
|
+
d["redacted_field_names"] = list(self.redacted_field_names)
|
|
156
|
+
if self.subject_event_id is not None:
|
|
157
|
+
d["subject_event_id"] = self.subject_event_id
|
|
158
|
+
if self.verified is not None:
|
|
159
|
+
d["verified"] = self.verified
|
|
160
|
+
return d
|
|
161
|
+
|
|
162
|
+
@classmethod
|
|
163
|
+
def from_dict(cls, data: dict[str, Any]) -> RedactAppliedPayload:
|
|
164
|
+
"""Deserialise from a plain ``dict``."""
|
|
165
|
+
return cls(
|
|
166
|
+
policy_min_sensitivity=data["policy_min_sensitivity"],
|
|
167
|
+
redacted_by=data["redacted_by"],
|
|
168
|
+
redacted_count=int(data["redacted_count"]),
|
|
169
|
+
redacted_field_names=list(data.get("redacted_field_names", [])),
|
|
170
|
+
subject_event_id=data.get("subject_event_id"),
|
|
171
|
+
verified=bool(data["verified"]) if "verified" in data else None,
|
|
172
|
+
)
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""spanforge.namespaces.template — Template payload types (RFC-0001).
|
|
2
|
+
|
|
3
|
+
Classes
|
|
4
|
+
-------
|
|
5
|
+
TemplateRegisteredPayload llm.template.registered
|
|
6
|
+
TemplateVariableBoundPayload llm.template.variable.bound
|
|
7
|
+
TemplateValidationFailedPayload llm.template.validation.failed
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"TemplateRegisteredPayload",
|
|
16
|
+
"TemplateValidationFailedPayload",
|
|
17
|
+
"TemplateVariableBoundPayload",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
_VALID_VALUE_TYPES = frozenset({
|
|
21
|
+
"string", "integer", "float", "boolean", "array", "object", "null"
|
|
22
|
+
})
|
|
23
|
+
_VALID_FAILURE_TYPES = frozenset({
|
|
24
|
+
"missing_variable", "type_mismatch", "hash_mismatch",
|
|
25
|
+
"version_not_found", "syntax_error", "schema_violation"
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
_SHA256_HEX_LEN = 64 # SHA-256 hex digest length (characters)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class TemplateRegisteredPayload:
|
|
33
|
+
"""RFC-0001 — A prompt template was registered in the registry."""
|
|
34
|
+
|
|
35
|
+
template_id: str
|
|
36
|
+
version: str
|
|
37
|
+
template_hash: str # 64 lowercase hex chars, SHA-256 of template source
|
|
38
|
+
variable_names: list[str] = field(default_factory=list)
|
|
39
|
+
variable_count: int | None = None
|
|
40
|
+
language: str | None = None
|
|
41
|
+
char_count: int | None = None
|
|
42
|
+
registered_by: str | None = None
|
|
43
|
+
is_active: bool | None = None
|
|
44
|
+
tags: dict[str, str] | None = None
|
|
45
|
+
|
|
46
|
+
def __post_init__(self) -> None:
|
|
47
|
+
if not self.template_id:
|
|
48
|
+
raise ValueError("TemplateRegisteredPayload.template_id must be non-empty")
|
|
49
|
+
if not self.version:
|
|
50
|
+
raise ValueError("TemplateRegisteredPayload.version must be non-empty")
|
|
51
|
+
if not self.template_hash or len(self.template_hash) != _SHA256_HEX_LEN:
|
|
52
|
+
raise ValueError("TemplateRegisteredPayload.template_hash must be 64 hex chars (SHA-256)") # noqa: E501
|
|
53
|
+
|
|
54
|
+
def to_dict(self) -> dict[str, Any]:
|
|
55
|
+
"""Serialise the payload to a plain ``dict``."""
|
|
56
|
+
d: dict[str, Any] = {
|
|
57
|
+
"template_id": self.template_id,
|
|
58
|
+
"version": self.version,
|
|
59
|
+
"template_hash": self.template_hash,
|
|
60
|
+
}
|
|
61
|
+
if self.variable_names:
|
|
62
|
+
d["variable_names"] = list(self.variable_names)
|
|
63
|
+
if self.variable_count is not None:
|
|
64
|
+
d["variable_count"] = self.variable_count
|
|
65
|
+
if self.language is not None:
|
|
66
|
+
d["language"] = self.language
|
|
67
|
+
if self.char_count is not None:
|
|
68
|
+
d["char_count"] = self.char_count
|
|
69
|
+
if self.registered_by is not None:
|
|
70
|
+
d["registered_by"] = self.registered_by
|
|
71
|
+
if self.is_active is not None:
|
|
72
|
+
d["is_active"] = self.is_active
|
|
73
|
+
if self.tags is not None:
|
|
74
|
+
d["tags"] = dict(self.tags)
|
|
75
|
+
return d
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def from_dict(cls, data: dict[str, Any]) -> TemplateRegisteredPayload:
|
|
79
|
+
"""Deserialise from a plain ``dict``."""
|
|
80
|
+
return cls(
|
|
81
|
+
template_id=data["template_id"],
|
|
82
|
+
version=data["version"],
|
|
83
|
+
template_hash=data["template_hash"],
|
|
84
|
+
variable_names=list(data.get("variable_names", [])),
|
|
85
|
+
variable_count=int(data["variable_count"]) if "variable_count" in data else None,
|
|
86
|
+
language=data.get("language"),
|
|
87
|
+
char_count=int(data["char_count"]) if "char_count" in data else None,
|
|
88
|
+
registered_by=data.get("registered_by"),
|
|
89
|
+
is_active=bool(data["is_active"]) if "is_active" in data else None,
|
|
90
|
+
tags=dict(data["tags"]) if "tags" in data else None,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class TemplateVariableBoundPayload:
|
|
96
|
+
"""RFC-0001 — A variable was bound to a value for template rendering.
|
|
97
|
+
|
|
98
|
+
``value_hash`` stores a SHA-256 hash of the value. For sensitive variables,
|
|
99
|
+
the raw value MUST NOT be stored.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
template_id: str
|
|
103
|
+
version: str
|
|
104
|
+
variable_name: str
|
|
105
|
+
value_type: str | None = None # "string"|"integer"|"float"|"boolean"|"array"|"object"|"null"
|
|
106
|
+
value_length: int | None = None
|
|
107
|
+
value_hash: str | None = None # 64 hex chars, SHA-256
|
|
108
|
+
is_sensitive: bool | None = None
|
|
109
|
+
span_id: str | None = None
|
|
110
|
+
|
|
111
|
+
def __post_init__(self) -> None:
|
|
112
|
+
if not self.template_id:
|
|
113
|
+
raise ValueError("TemplateVariableBoundPayload.template_id must be non-empty")
|
|
114
|
+
if not self.version:
|
|
115
|
+
raise ValueError("TemplateVariableBoundPayload.version must be non-empty")
|
|
116
|
+
if not self.variable_name:
|
|
117
|
+
raise ValueError("TemplateVariableBoundPayload.variable_name must be non-empty")
|
|
118
|
+
if self.value_type is not None and self.value_type not in _VALID_VALUE_TYPES:
|
|
119
|
+
raise ValueError(f"TemplateVariableBoundPayload.value_type must be one of {sorted(_VALID_VALUE_TYPES)}") # noqa: E501
|
|
120
|
+
if self.value_hash is not None and len(self.value_hash) != _SHA256_HEX_LEN:
|
|
121
|
+
raise ValueError("TemplateVariableBoundPayload.value_hash must be 64 hex chars (SHA-256)") # noqa: E501
|
|
122
|
+
|
|
123
|
+
def to_dict(self) -> dict[str, Any]:
|
|
124
|
+
"""Serialise the payload to a plain ``dict``."""
|
|
125
|
+
d: dict[str, Any] = {
|
|
126
|
+
"template_id": self.template_id,
|
|
127
|
+
"version": self.version,
|
|
128
|
+
"variable_name": self.variable_name,
|
|
129
|
+
}
|
|
130
|
+
if self.value_type is not None:
|
|
131
|
+
d["value_type"] = self.value_type
|
|
132
|
+
if self.value_length is not None:
|
|
133
|
+
d["value_length"] = self.value_length
|
|
134
|
+
if self.value_hash is not None:
|
|
135
|
+
d["value_hash"] = self.value_hash
|
|
136
|
+
if self.is_sensitive is not None:
|
|
137
|
+
d["is_sensitive"] = self.is_sensitive
|
|
138
|
+
if self.span_id is not None:
|
|
139
|
+
d["span_id"] = self.span_id
|
|
140
|
+
return d
|
|
141
|
+
|
|
142
|
+
@classmethod
|
|
143
|
+
def from_dict(cls, data: dict[str, Any]) -> TemplateVariableBoundPayload:
|
|
144
|
+
"""Deserialise from a plain ``dict``."""
|
|
145
|
+
return cls(
|
|
146
|
+
template_id=data["template_id"],
|
|
147
|
+
version=data["version"],
|
|
148
|
+
variable_name=data["variable_name"],
|
|
149
|
+
value_type=data.get("value_type"),
|
|
150
|
+
value_length=int(data["value_length"]) if "value_length" in data else None,
|
|
151
|
+
value_hash=data.get("value_hash"),
|
|
152
|
+
is_sensitive=bool(data["is_sensitive"]) if "is_sensitive" in data else None,
|
|
153
|
+
span_id=data.get("span_id"),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass
|
|
158
|
+
class TemplateValidationFailedPayload:
|
|
159
|
+
"""RFC-0001 — Template validation failed during rendering or registration."""
|
|
160
|
+
|
|
161
|
+
template_id: str
|
|
162
|
+
version: str
|
|
163
|
+
failure_reason: str
|
|
164
|
+
failure_type: str | None = None
|
|
165
|
+
|
|
166
|
+
def __post_init__(self) -> None:
|
|
167
|
+
if not self.template_id:
|
|
168
|
+
raise ValueError("TemplateValidationFailedPayload.template_id must be non-empty")
|
|
169
|
+
if not self.version:
|
|
170
|
+
raise ValueError("TemplateValidationFailedPayload.version must be non-empty")
|
|
171
|
+
if not self.failure_reason:
|
|
172
|
+
raise ValueError("TemplateValidationFailedPayload.failure_reason must be non-empty")
|
|
173
|
+
if self.failure_type is not None and self.failure_type not in _VALID_FAILURE_TYPES:
|
|
174
|
+
raise ValueError(
|
|
175
|
+
f"TemplateValidationFailedPayload.failure_type must be one of {sorted(_VALID_FAILURE_TYPES)}" # noqa: E501
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
def to_dict(self) -> dict[str, Any]:
|
|
179
|
+
"""Serialise the payload to a plain ``dict``."""
|
|
180
|
+
d: dict[str, Any] = {
|
|
181
|
+
"template_id": self.template_id,
|
|
182
|
+
"version": self.version,
|
|
183
|
+
"failure_reason": self.failure_reason,
|
|
184
|
+
}
|
|
185
|
+
if self.failure_type is not None:
|
|
186
|
+
d["failure_type"] = self.failure_type
|
|
187
|
+
return d
|
|
188
|
+
|
|
189
|
+
@classmethod
|
|
190
|
+
def from_dict(cls, data: dict[str, Any]) -> TemplateValidationFailedPayload:
|
|
191
|
+
"""Deserialise from a plain ``dict``."""
|
|
192
|
+
return cls(
|
|
193
|
+
template_id=data["template_id"],
|
|
194
|
+
version=data["version"],
|
|
195
|
+
failure_reason=data["failure_reason"],
|
|
196
|
+
failure_type=data.get("failure_type"),
|
|
197
|
+
)
|