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.
Files changed (101) hide show
  1. spanforge/__init__.py +695 -0
  2. spanforge/_batch_exporter.py +322 -0
  3. spanforge/_cli.py +3081 -0
  4. spanforge/_hooks.py +340 -0
  5. spanforge/_server.py +953 -0
  6. spanforge/_span.py +1015 -0
  7. spanforge/_store.py +287 -0
  8. spanforge/_stream.py +654 -0
  9. spanforge/_trace.py +334 -0
  10. spanforge/_tracer.py +253 -0
  11. spanforge/actor.py +141 -0
  12. spanforge/alerts.py +464 -0
  13. spanforge/auto.py +181 -0
  14. spanforge/baseline.py +336 -0
  15. spanforge/config.py +460 -0
  16. spanforge/consent.py +227 -0
  17. spanforge/consumer.py +379 -0
  18. spanforge/core/__init__.py +5 -0
  19. spanforge/core/compliance_mapping.py +1060 -0
  20. spanforge/cost.py +597 -0
  21. spanforge/debug.py +514 -0
  22. spanforge/drift.py +488 -0
  23. spanforge/egress.py +63 -0
  24. spanforge/eval.py +575 -0
  25. spanforge/event.py +1052 -0
  26. spanforge/exceptions.py +246 -0
  27. spanforge/explain.py +181 -0
  28. spanforge/export/__init__.py +50 -0
  29. spanforge/export/append_only.py +342 -0
  30. spanforge/export/cloud.py +349 -0
  31. spanforge/export/datadog.py +495 -0
  32. spanforge/export/grafana.py +331 -0
  33. spanforge/export/jsonl.py +198 -0
  34. spanforge/export/otel_bridge.py +291 -0
  35. spanforge/export/otlp.py +817 -0
  36. spanforge/export/otlp_bridge.py +231 -0
  37. spanforge/export/redis_backend.py +282 -0
  38. spanforge/export/webhook.py +302 -0
  39. spanforge/exporters/__init__.py +29 -0
  40. spanforge/exporters/console.py +271 -0
  41. spanforge/exporters/jsonl.py +144 -0
  42. spanforge/hitl.py +297 -0
  43. spanforge/inspect.py +429 -0
  44. spanforge/integrations/__init__.py +39 -0
  45. spanforge/integrations/_pricing.py +277 -0
  46. spanforge/integrations/anthropic.py +388 -0
  47. spanforge/integrations/bedrock.py +306 -0
  48. spanforge/integrations/crewai.py +251 -0
  49. spanforge/integrations/gemini.py +349 -0
  50. spanforge/integrations/groq.py +444 -0
  51. spanforge/integrations/langchain.py +349 -0
  52. spanforge/integrations/llamaindex.py +370 -0
  53. spanforge/integrations/ollama.py +286 -0
  54. spanforge/integrations/openai.py +370 -0
  55. spanforge/integrations/together.py +485 -0
  56. spanforge/metrics.py +393 -0
  57. spanforge/metrics_export.py +342 -0
  58. spanforge/migrate.py +278 -0
  59. spanforge/model_registry.py +282 -0
  60. spanforge/models.py +407 -0
  61. spanforge/namespaces/__init__.py +215 -0
  62. spanforge/namespaces/audit.py +253 -0
  63. spanforge/namespaces/cache.py +209 -0
  64. spanforge/namespaces/chain.py +74 -0
  65. spanforge/namespaces/confidence.py +69 -0
  66. spanforge/namespaces/consent.py +85 -0
  67. spanforge/namespaces/cost.py +175 -0
  68. spanforge/namespaces/decision.py +135 -0
  69. spanforge/namespaces/diff.py +146 -0
  70. spanforge/namespaces/drift.py +79 -0
  71. spanforge/namespaces/eval_.py +232 -0
  72. spanforge/namespaces/fence.py +180 -0
  73. spanforge/namespaces/guard.py +104 -0
  74. spanforge/namespaces/hitl.py +92 -0
  75. spanforge/namespaces/latency.py +69 -0
  76. spanforge/namespaces/prompt.py +185 -0
  77. spanforge/namespaces/redact.py +172 -0
  78. spanforge/namespaces/template.py +197 -0
  79. spanforge/namespaces/tool_call.py +76 -0
  80. spanforge/namespaces/trace.py +1006 -0
  81. spanforge/normalizer.py +183 -0
  82. spanforge/presidio_backend.py +149 -0
  83. spanforge/processor.py +258 -0
  84. spanforge/prompt_registry.py +415 -0
  85. spanforge/py.typed +0 -0
  86. spanforge/redact.py +780 -0
  87. spanforge/sampling.py +500 -0
  88. spanforge/schemas/v1.0/schema.json +170 -0
  89. spanforge/schemas/v2.0/schema.json +536 -0
  90. spanforge/signing.py +1152 -0
  91. spanforge/stream.py +559 -0
  92. spanforge/testing.py +376 -0
  93. spanforge/trace.py +199 -0
  94. spanforge/types.py +696 -0
  95. spanforge/ulid.py +304 -0
  96. spanforge/validate.py +383 -0
  97. spanforge-2.0.0.dist-info/METADATA +1777 -0
  98. spanforge-2.0.0.dist-info/RECORD +101 -0
  99. spanforge-2.0.0.dist-info/WHEEL +4 -0
  100. spanforge-2.0.0.dist-info/entry_points.txt +5 -0
  101. spanforge-2.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,246 @@
1
+ """Typed exception hierarchy for spanforge.
2
+
3
+ All exceptions raised by spanforge inherit from :class:`LLMSchemaError` so
4
+ callers can catch the whole family with a single ``except LLMSchemaError``.
5
+
6
+ Design rules
7
+ ------------
8
+ * Exceptions carry enough context to be actionable — field name, received value,
9
+ and an explanation of what was expected.
10
+ * HMAC keys and PII-tagged content are **never** embedded in exception messages
11
+ or ``__cause__`` chains.
12
+ * No bare ``raise`` — every raise site uses a typed subclass.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ __all__ = [
18
+ "AuditStorageError",
19
+ "DeserializationError",
20
+ "EgressViolationError",
21
+ "EventTypeError",
22
+ "ExportError",
23
+ "LLMSchemaError",
24
+ "SchemaValidationError",
25
+ "SchemaVersionError",
26
+ "SerializationError",
27
+ "SigningError",
28
+ "ULIDError",
29
+ "VerificationError",
30
+ ]
31
+
32
+
33
+ class LLMSchemaError(Exception):
34
+ """Base class for all spanforge exceptions.
35
+
36
+ All public-facing exceptions derive from this class, enabling callers to
37
+ write a single broad ``except LLMSchemaError`` guard as a safety net while
38
+ still being able to catch specific sub-types for targeted handling.
39
+ """
40
+
41
+
42
+ class SchemaValidationError(LLMSchemaError):
43
+ """Raised when an :class:`~spanforge.event.Event` fails validation.
44
+
45
+ Attributes:
46
+ field: The dotted field path that failed (e.g. ``"event_id"``).
47
+ received: The actual value that was provided (redacted if sensitive).
48
+ reason: Human-readable explanation of the constraint that was violated.
49
+
50
+ Example::
51
+
52
+ try:
53
+ event.validate()
54
+ except SchemaValidationError as exc:
55
+ logger.error("Invalid event field=%s reason=%s", exc.field, exc.reason)
56
+ """
57
+
58
+ def __init__(self, field: str, received: object, reason: str) -> None:
59
+ self.field = field
60
+ self.received = received
61
+ self.reason = reason
62
+ super().__init__(
63
+ f"Validation failed for field '{field}': {reason} "
64
+ f"(received type={type(received).__name__!r})"
65
+ )
66
+
67
+
68
+ class ULIDError(LLMSchemaError):
69
+ """Raised when ULID generation or parsing fails.
70
+
71
+ Attributes:
72
+ detail: Human-readable description of the failure.
73
+
74
+ This exception is intentionally opaque about internal state to avoid
75
+ leaking timing information that could aid side-channel attacks.
76
+ """
77
+
78
+ def __init__(self, detail: str) -> None:
79
+ self.detail = detail
80
+ super().__init__(f"ULID error: {detail}")
81
+
82
+
83
+ class SerializationError(LLMSchemaError):
84
+ """Raised when an :class:`~spanforge.event.Event` cannot be serialized.
85
+
86
+ Attributes:
87
+ event_id: The ULID of the event that failed (safe to log).
88
+ reason: Human-readable description of the failure.
89
+ """
90
+
91
+ def __init__(self, event_id: str, reason: str) -> None:
92
+ self.event_id = event_id
93
+ self.reason = reason
94
+ super().__init__(
95
+ f"Serialization failed for event '{event_id}': {reason}"
96
+ )
97
+
98
+
99
+ class DeserializationError(LLMSchemaError):
100
+ """Raised when a JSON blob cannot be deserialized into an Event.
101
+
102
+ Attributes:
103
+ reason: Human-readable description of the failure.
104
+ source_hint: A short, non-PII hint about the source (e.g. filename).
105
+ """
106
+
107
+ def __init__(self, reason: str, source_hint: str = "<unknown>") -> None:
108
+ self.reason = reason
109
+ self.source_hint = source_hint
110
+ super().__init__(
111
+ f"Deserialization failed (source={source_hint!r}): {reason}"
112
+ )
113
+
114
+
115
+ class EventTypeError(LLMSchemaError):
116
+ """Raised when an unknown or malformed event type string is encountered.
117
+
118
+ Attributes:
119
+ event_type: The offending event type string.
120
+ reason: Human-readable description of the failure.
121
+ """
122
+
123
+ def __init__(self, event_type: str, reason: str) -> None:
124
+ self.event_type = event_type
125
+ self.reason = reason
126
+ super().__init__(
127
+ f"Invalid event type '{event_type}': {reason}"
128
+ )
129
+
130
+
131
+ class SigningError(LLMSchemaError):
132
+ """Raised when HMAC event signing fails.
133
+
134
+ Security: the ``org_secret`` value is **never** included in the message.
135
+
136
+ Attributes:
137
+ reason: Human-readable description of why signing failed.
138
+ """
139
+
140
+ def __init__(self, reason: str) -> None:
141
+ self.reason = reason
142
+ super().__init__(f"Signing failed: {reason}")
143
+
144
+
145
+ class VerificationError(LLMSchemaError):
146
+ """Raised when an event fails cryptographic verification.
147
+
148
+ Raised by :func:`~spanforge.signing.assert_verified` on verification failure.
149
+
150
+ Attributes:
151
+ event_id: The ULID of the event that failed (safe to log).
152
+ """
153
+
154
+ def __init__(self, event_id: str) -> None:
155
+ self.event_id = event_id
156
+ super().__init__(
157
+ f"Event '{event_id}' failed cryptographic verification. "
158
+ "The event may have been tampered with or the wrong key was used."
159
+ )
160
+
161
+
162
+ class ExportError(LLMSchemaError):
163
+ """Raised when exporting events to an external backend fails.
164
+
165
+ Attributes:
166
+ backend: Short identifier for the backend (e.g. ``"otlp"``,
167
+ ``"webhook"``, ``"jsonl"``).
168
+ reason: Human-readable description of the failure.
169
+ event_id: The ULID of the event that failed, or ``""`` for batch
170
+ failures where no single event is responsible.
171
+
172
+ Security: HMAC secrets and PII-tagged payloads are **never** embedded in
173
+ the message or ``__cause__``.
174
+
175
+ Example::
176
+
177
+ try:
178
+ await exporter.export(event)
179
+ except ExportError as exc:
180
+ logger.error("backend=%s reason=%s", exc.backend, exc.reason)
181
+ """
182
+
183
+ def __init__(self, backend: str, reason: str, event_id: str = "") -> None:
184
+ self.backend = backend
185
+ self.reason = reason
186
+ self.event_id = event_id
187
+ msg = f"Export to '{backend}' failed: {reason}"
188
+ if event_id:
189
+ msg += f" (event_id={event_id!r})"
190
+ super().__init__(msg)
191
+
192
+
193
+ class SchemaVersionError(LLMSchemaError):
194
+ """Raised when an event carries an unsupported ``schema_version`` value.
195
+
196
+ RFC-0001 §15.5 specifies that v2.0 consumers MUST accept events with
197
+ ``schema_version`` ``"1.0"`` or ``"2.0"`` and MUST raise this error
198
+ for any other value.
199
+
200
+ Attributes:
201
+ version: The unsupported schema version string that was encountered.
202
+
203
+ Example::
204
+
205
+ try:
206
+ consumer.ingest(event)
207
+ except SchemaVersionError as exc:
208
+ logger.warning("Skipping event with unknown version %s", exc.version)
209
+ """
210
+
211
+ def __init__(self, version: str) -> None:
212
+ self.version = version
213
+ super().__init__(
214
+ f"Unsupported schema_version {version!r}. "
215
+ "Accepted values: '1.0', '2.0' (RFC-0001 §15.5)."
216
+ )
217
+
218
+
219
+ class EgressViolationError(LLMSchemaError):
220
+ """Raised when an exporter attempts a network call in no-egress mode.
221
+
222
+ Attributes:
223
+ backend: Short identifier for the backend that violated the policy.
224
+ endpoint: The endpoint URL that was blocked (may be ``""`` if unknown).
225
+ """
226
+
227
+ def __init__(self, backend: str, endpoint: str = "") -> None:
228
+ self.backend = backend
229
+ self.endpoint = endpoint
230
+ msg = f"Egress violation: exporter '{backend}' attempted a network call"
231
+ if endpoint:
232
+ msg += f" to {endpoint!r}"
233
+ msg += " but no_egress mode is active"
234
+ super().__init__(msg)
235
+
236
+
237
+ class AuditStorageError(LLMSchemaError):
238
+ """Raised when the audit log storage layer detects a violation.
239
+
240
+ Attributes:
241
+ reason: Human-readable description of the storage violation.
242
+ """
243
+
244
+ def __init__(self, reason: str) -> None:
245
+ self.reason = reason
246
+ super().__init__(f"Audit storage error: {reason}")
spanforge/explain.py ADDED
@@ -0,0 +1,181 @@
1
+ """spanforge.explain — Explainability record generation for AI compliance.
2
+
3
+ Aggregates decision drivers from span payloads into human-readable
4
+ explanations compliant with EU AI Act transparency requirements
5
+ (Articles 13-14) and the T.R.U.S.T. Framework Transparency pillar.
6
+
7
+ Emits ``explanation.generated`` events into the HMAC audit chain.
8
+
9
+ Usage::
10
+
11
+ from spanforge.explain import ExplainabilityRecord
12
+
13
+ record = ExplainabilityRecord(
14
+ trace_id="01HQZF...",
15
+ agent_id="support-agent@1.0",
16
+ decision_id="01HQZF...",
17
+ factors=[
18
+ {"factor_name": "user_intent", "weight": 0.6,
19
+ "contribution": 0.42, "evidence": "keyword match",
20
+ "confidence": 0.85},
21
+ ],
22
+ summary="Routed to billing team based on keyword match.",
23
+ )
24
+ print(record.to_text())
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ from dataclasses import dataclass, field
31
+ from typing import Any
32
+
33
+ __all__ = [
34
+ "ExplainabilityRecord",
35
+ "generate_explanation",
36
+ ]
37
+
38
+
39
+ @dataclass
40
+ class ExplainabilityRecord:
41
+ """A human-readable explanation of one or more AI decisions.
42
+
43
+ Designed to satisfy EU AI Act Art. 13 transparency obligations.
44
+ Each record can be serialised to plain text, JSON, or dict for
45
+ audit trail inclusion.
46
+ """
47
+
48
+ trace_id: str
49
+ agent_id: str
50
+ decision_id: str
51
+ factors: list[dict[str, Any]]
52
+ summary: str
53
+ model_id: str | None = None
54
+ confidence: float | None = None
55
+ risk_tier: str | None = None
56
+ metadata: dict[str, Any] = field(default_factory=dict)
57
+
58
+ def __post_init__(self) -> None:
59
+ if not self.trace_id:
60
+ raise ValueError("ExplainabilityRecord.trace_id must be non-empty")
61
+ if not self.agent_id:
62
+ raise ValueError("ExplainabilityRecord.agent_id must be non-empty")
63
+ if not self.decision_id:
64
+ raise ValueError("ExplainabilityRecord.decision_id must be non-empty")
65
+ if not self.summary:
66
+ raise ValueError("ExplainabilityRecord.summary must be non-empty")
67
+ if self.confidence is not None and not (0.0 <= self.confidence <= 1.0):
68
+ raise ValueError(
69
+ "ExplainabilityRecord.confidence must be in [0.0, 1.0]"
70
+ )
71
+
72
+ def to_dict(self) -> dict[str, Any]:
73
+ """Serialise to a plain dict."""
74
+ d: dict[str, Any] = {
75
+ "trace_id": self.trace_id,
76
+ "agent_id": self.agent_id,
77
+ "decision_id": self.decision_id,
78
+ "factors": self.factors,
79
+ "summary": self.summary,
80
+ }
81
+ if self.model_id is not None:
82
+ d["model_id"] = self.model_id
83
+ if self.confidence is not None:
84
+ d["confidence"] = self.confidence
85
+ if self.risk_tier is not None:
86
+ d["risk_tier"] = self.risk_tier
87
+ if self.metadata:
88
+ d["metadata"] = self.metadata
89
+ return d
90
+
91
+ @classmethod
92
+ def from_dict(cls, data: dict[str, Any]) -> ExplainabilityRecord:
93
+ """Reconstruct from a plain dict."""
94
+ return cls(
95
+ trace_id=data["trace_id"],
96
+ agent_id=data["agent_id"],
97
+ decision_id=data["decision_id"],
98
+ factors=data.get("factors", []),
99
+ summary=data["summary"],
100
+ model_id=data.get("model_id"),
101
+ confidence=data.get("confidence"),
102
+ risk_tier=data.get("risk_tier"),
103
+ metadata=data.get("metadata", {}),
104
+ )
105
+
106
+ def to_json(self) -> str:
107
+ """Serialise to JSON string."""
108
+ return json.dumps(self.to_dict(), default=str)
109
+
110
+ def to_text(self) -> str:
111
+ """Generate a human-readable explanation text.
112
+
113
+ Returns a multi-line string suitable for end-user display or
114
+ compliance documentation.
115
+ """
116
+ lines: list[str] = []
117
+ lines.append(f"Explanation for decision {self.decision_id}")
118
+ lines.append(f" Agent: {self.agent_id}")
119
+ lines.append(f" Trace: {self.trace_id}")
120
+ if self.model_id:
121
+ lines.append(f" Model: {self.model_id}")
122
+ if self.confidence is not None:
123
+ lines.append(f" Confidence: {self.confidence:.2%}")
124
+ if self.risk_tier:
125
+ lines.append(f" Risk tier: {self.risk_tier}")
126
+ lines.append(f" Summary: {self.summary}")
127
+ if self.factors:
128
+ lines.append(" Contributing factors:")
129
+ for f in self.factors:
130
+ name = f.get("factor_name", "unknown")
131
+ weight = f.get("weight", 0)
132
+ evidence = f.get("evidence", "")
133
+ lines.append(f" - {name} (weight={weight:.2f}): {evidence}")
134
+ return "\n".join(lines)
135
+
136
+
137
+ def generate_explanation(
138
+ trace_id: str,
139
+ agent_id: str,
140
+ decision_id: str,
141
+ factors: list[dict[str, Any]],
142
+ summary: str,
143
+ *,
144
+ model_id: str | None = None,
145
+ confidence: float | None = None,
146
+ risk_tier: str | None = None,
147
+ auto_emit: bool = True,
148
+ metadata: dict[str, Any] | None = None,
149
+ ) -> ExplainabilityRecord:
150
+ """Create an :class:`ExplainabilityRecord` and optionally emit an event.
151
+
152
+ This is the primary convenience function for the explainability module.
153
+ """
154
+ record = ExplainabilityRecord(
155
+ trace_id=trace_id,
156
+ agent_id=agent_id,
157
+ decision_id=decision_id,
158
+ factors=factors,
159
+ summary=summary,
160
+ model_id=model_id,
161
+ confidence=confidence,
162
+ risk_tier=risk_tier,
163
+ metadata=metadata or {},
164
+ )
165
+ if auto_emit:
166
+ _emit_explanation(record)
167
+ return record
168
+
169
+
170
+ def _emit_explanation(record: ExplainabilityRecord) -> None:
171
+ """Emit an explanation.generated event into the HMAC audit chain."""
172
+ try:
173
+ from spanforge._stream import emit_rfc_event # noqa: PLC0415
174
+ from spanforge.types import EventType # noqa: PLC0415
175
+
176
+ try:
177
+ emit_rfc_event(EventType.EXPLANATION_GENERATED, record.to_dict())
178
+ except Exception: # noqa: BLE001
179
+ pass
180
+ except ImportError:
181
+ pass
@@ -0,0 +1,50 @@
1
+ """RFC-0001 export backends for spanforge SDK events.
2
+
3
+ All exporters are **opt-in** — importing this package does not open any network
4
+ connections or file handles. Instantiate an exporter explicitly to activate it.
5
+
6
+ Core exporters (RFC-0001 §14)
7
+ ------------------------------
8
+ * :class:`~spanforge.export.otlp.OTLPExporter` — OTLP/JSON HTTP exporter
9
+ (zero dependencies; builds OTLP wire format from stdlib).
10
+ * :class:`~spanforge.export.otel_bridge.OTelBridgeExporter` — OTel SDK bridge
11
+ that emits real OTel spans via a configured ``TracerProvider``.
12
+ Requires ``pip install "spanforge[otel]"``.
13
+ * :class:`~spanforge.export.webhook.WebhookExporter` — HTTP webhook with
14
+ HMAC-SHA256 request signing.
15
+ * :class:`~spanforge.export.jsonl.JSONLExporter` — NDJSON for local development
16
+ and audit trail persistence.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from spanforge.export.append_only import (
22
+ AppendOnlyJSONLExporter,
23
+ WORMBackend,
24
+ WORMUploadResult,
25
+ )
26
+ from spanforge.export.datadog import DatadogExporter, DatadogResourceAttributes
27
+ from spanforge.export.grafana import GrafanaLokiExporter
28
+ from spanforge.export.jsonl import JSONLExporter
29
+ from spanforge.export.otlp import OTLPExporter, ResourceAttributes
30
+ from spanforge.export.webhook import WebhookExporter
31
+
32
+ # OTelBridgeExporter is an optional import — requires opentelemetry-sdk
33
+ try:
34
+ from spanforge.export.otel_bridge import OTelBridgeExporter
35
+ except ImportError:
36
+ OTelBridgeExporter = None # type: ignore[assignment,misc]
37
+
38
+ __all__ = [
39
+ "AppendOnlyJSONLExporter",
40
+ "DatadogExporter",
41
+ "DatadogResourceAttributes",
42
+ "GrafanaLokiExporter",
43
+ "JSONLExporter",
44
+ "OTLPExporter",
45
+ "OTelBridgeExporter",
46
+ "ResourceAttributes",
47
+ "WORMBackend",
48
+ "WORMUploadResult",
49
+ "WebhookExporter",
50
+ ]