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,144 @@
1
+ """spanforge.exporters.jsonl — Synchronous JSONL file exporter.
2
+
3
+ Appends one canonical JSON line per event to a file on disk. Zero external
4
+ dependencies (stdlib only).
5
+
6
+ Usage::
7
+
8
+ from spanforge import configure
9
+ configure(exporter="jsonl", endpoint="./events.jsonl")
10
+
11
+ # Now all tracer.span() / agent_run() / agent_step() calls write to
12
+ # events.jsonl automatically.
13
+
14
+ You can also instantiate directly for testing::
15
+
16
+ from spanforge.exporters.jsonl import SyncJSONLExporter
17
+ from spanforge.event import Event, EventType, Tags
18
+
19
+ exporter = SyncJSONLExporter("/tmp/test.jsonl") # noqa: S108 # NOSONAR
20
+ exporter.export(my_event)
21
+ exporter.close()
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import sys
27
+ import threading
28
+ from pathlib import Path
29
+ from typing import IO, TYPE_CHECKING, Union
30
+
31
+ if TYPE_CHECKING:
32
+ from spanforge.event import Event
33
+
34
+ __all__ = ["SyncJSONLExporter"]
35
+
36
+ _PathLike = Union[str, Path]
37
+
38
+
39
+ class SyncJSONLExporter:
40
+ """Synchronous exporter that appends events as newline-delimited JSON.
41
+
42
+ Thread-safe: a :class:`threading.Lock` serialises concurrent writes so
43
+ the output file is never corrupted when multiple threads share one
44
+ instance.
45
+
46
+ Args:
47
+ path: File path, :class:`pathlib.Path`, or ``"-"`` for stdout.
48
+ mode: File open mode — ``"a"`` (append, default) or ``"w"``
49
+ (overwrite / truncate on first write).
50
+ encoding: File encoding (default ``"utf-8"``).
51
+
52
+ Raises:
53
+ OSError: If the file cannot be opened or written.
54
+ """
55
+
56
+ def __init__(
57
+ self,
58
+ path: _PathLike | str = "spanforge_events.jsonl",
59
+ mode: str = "a",
60
+ encoding: str = "utf-8",
61
+ ) -> None:
62
+ if mode not in ("a", "w"):
63
+ raise ValueError("mode must be 'a' or 'w'")
64
+ self._path_str = str(path)
65
+ self._mode = mode
66
+ self._encoding = encoding
67
+ self._file: IO[str] | None = None
68
+ self._lock = threading.Lock()
69
+ self._closed = False
70
+
71
+ # ------------------------------------------------------------------
72
+ # Internal file management
73
+ # ------------------------------------------------------------------
74
+
75
+ def _ensure_open(self) -> IO[str]:
76
+ """Open the file lazily on first write."""
77
+ if self._file is not None and not self._file.closed:
78
+ return self._file
79
+ if self._path_str == "-":
80
+ self._file = sys.stdout
81
+ return self._file
82
+ # Create parent directories if they don't exist.
83
+ p = Path(self._path_str)
84
+ p.parent.mkdir(parents=True, exist_ok=True)
85
+ self._file = p.open(self._mode, encoding=self._encoding)
86
+ # After first open, always append.
87
+ self._mode = "a"
88
+ return self._file
89
+
90
+ # ------------------------------------------------------------------
91
+ # Public interface
92
+ # ------------------------------------------------------------------
93
+
94
+ def export(self, event: Event) -> None:
95
+ """Write *event* as a single JSON line.
96
+
97
+ Args:
98
+ event: A fully-formed :class:`~spanforge.event.Event` instance.
99
+
100
+ Raises:
101
+ RuntimeError: If :meth:`close` has already been called.
102
+ OSError: If the file write fails.
103
+ """
104
+ if self._closed:
105
+ raise RuntimeError("SyncJSONLExporter is closed")
106
+ line = event.to_json() + "\n"
107
+ with self._lock:
108
+ fh = self._ensure_open()
109
+ fh.write(line)
110
+ fh.flush()
111
+
112
+ def flush(self) -> None:
113
+ """Flush any buffered data to disk."""
114
+ with self._lock:
115
+ if self._file is not None and not self._file.closed:
116
+ self._file.flush()
117
+
118
+ def close(self) -> None:
119
+ """Flush and close the output file. Safe to call multiple times."""
120
+ with self._lock:
121
+ if not self._closed:
122
+ if self._file is not None and self._file is not sys.stdout:
123
+ try:
124
+ self._file.flush()
125
+ self._file.close()
126
+ except OSError:
127
+ pass
128
+ self._file = None
129
+ self._closed = True
130
+
131
+ # ------------------------------------------------------------------
132
+ # Context manager
133
+ # ------------------------------------------------------------------
134
+
135
+ def __enter__(self) -> SyncJSONLExporter:
136
+ return self
137
+
138
+ def __exit__(self, *_: object) -> bool:
139
+ self.close()
140
+ return False
141
+
142
+ def __repr__(self) -> str:
143
+ state = "closed" if self._closed else "open"
144
+ return f"SyncJSONLExporter(path={self._path_str!r}, state={state})"
spanforge/hitl.py ADDED
@@ -0,0 +1,297 @@
1
+ """Human-in-the-Loop (HITL) review queue for SpanForge compliance pipeline.
2
+
3
+ Provides a runtime mechanism to intercept low-confidence or high-risk
4
+ agent decisions, queue them for human review, and track approval/rejection
5
+ outcomes in the HMAC audit chain.
6
+
7
+ Required for EU AI Act high-risk mandatory human oversight (Art. 14).
8
+
9
+ Configuration
10
+ -------------
11
+ * ``hitl_enabled=True`` activates the HITL queue.
12
+ * ``hitl_confidence_threshold`` — decisions below this confidence are auto-queued.
13
+ * ``hitl_risk_tiers`` — set of risk tiers that always require review.
14
+ * ``hitl_sla_seconds`` — SLA timeout for pending reviews.
15
+
16
+ Emits ``hitl.queued``, ``hitl.reviewed``, ``hitl.escalated``, ``hitl.timeout``
17
+ events into the HMAC audit chain via :func:`emit_rfc_event`.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import threading
23
+ from dataclasses import dataclass, field
24
+ from typing import Any, Literal
25
+
26
+ from spanforge.namespaces.hitl import HITLPayload
27
+
28
+ __all__ = [
29
+ "HITLQueue",
30
+ "HITLItem",
31
+ "queue_for_review",
32
+ "review_item",
33
+ "list_pending",
34
+ ]
35
+
36
+
37
+ @dataclass
38
+ class HITLItem:
39
+ """A single item pending human review."""
40
+
41
+ decision_id: str
42
+ agent_id: str
43
+ risk_tier: Literal["low", "medium", "high", "critical"]
44
+ reason: str
45
+ confidence: float | None = None
46
+ sla_seconds: int = 3600
47
+ queued_at: str | None = None
48
+ payload: dict[str, Any] = field(default_factory=dict)
49
+ status: Literal["queued", "approved", "rejected", "escalated", "timeout"] = "queued"
50
+ reviewer: str | None = None
51
+ resolved_at: str | None = None
52
+ escalation_tier: int = 0
53
+
54
+
55
+ class HITLQueue:
56
+ """Thread-safe human-in-the-loop review queue.
57
+
58
+ Intercepts agent decisions matching configurable risk criteria
59
+ (confidence below threshold, high-risk event type) and holds them
60
+ pending a named reviewer's approval.
61
+ """
62
+
63
+ def __init__(
64
+ self,
65
+ *,
66
+ confidence_threshold: float = 0.7,
67
+ risk_tiers: frozenset[str] | None = None,
68
+ sla_seconds: int = 3600,
69
+ auto_emit: bool = True,
70
+ ) -> None:
71
+ self._lock = threading.Lock()
72
+ self._items: dict[str, HITLItem] = {}
73
+ self._confidence_threshold = confidence_threshold
74
+ self._risk_tiers: frozenset[str] = risk_tiers or frozenset({"high", "critical"})
75
+ self._sla_seconds = sla_seconds
76
+ self._auto_emit = auto_emit
77
+
78
+ @property
79
+ def confidence_threshold(self) -> float:
80
+ return self._confidence_threshold
81
+
82
+ @property
83
+ def sla_seconds(self) -> int:
84
+ return self._sla_seconds
85
+
86
+ def should_review(
87
+ self,
88
+ *,
89
+ confidence: float | None = None,
90
+ risk_tier: str = "low",
91
+ ) -> bool:
92
+ """Determine if a decision should be queued for human review."""
93
+ if risk_tier in self._risk_tiers:
94
+ return True
95
+ if confidence is not None and confidence < self._confidence_threshold:
96
+ return True
97
+ return False
98
+
99
+ def enqueue(
100
+ self,
101
+ decision_id: str,
102
+ agent_id: str,
103
+ risk_tier: Literal["low", "medium", "high", "critical"],
104
+ reason: str,
105
+ *,
106
+ confidence: float | None = None,
107
+ queued_at: str | None = None,
108
+ payload: dict[str, Any] | None = None,
109
+ ) -> HITLItem:
110
+ """Add a decision to the review queue and emit ``hitl.queued``."""
111
+ if not decision_id:
112
+ raise ValueError("decision_id must be non-empty")
113
+ if not agent_id:
114
+ raise ValueError("agent_id must be non-empty")
115
+ if not reason:
116
+ raise ValueError("reason must be non-empty")
117
+
118
+ if queued_at is None:
119
+ import datetime # noqa: PLC0415
120
+ queued_at = datetime.datetime.now(datetime.timezone.utc).strftime(
121
+ "%Y-%m-%dT%H:%M:%S.%fZ"
122
+ )
123
+
124
+ item = HITLItem(
125
+ decision_id=decision_id,
126
+ agent_id=agent_id,
127
+ risk_tier=risk_tier,
128
+ reason=reason,
129
+ confidence=confidence,
130
+ sla_seconds=self._sla_seconds,
131
+ queued_at=queued_at,
132
+ payload=payload or {},
133
+ status="queued",
134
+ )
135
+ with self._lock:
136
+ self._items[decision_id] = item
137
+
138
+ if self._auto_emit:
139
+ self._emit_event(item, "queued")
140
+ return item
141
+
142
+ def review(
143
+ self,
144
+ decision_id: str,
145
+ reviewer: str,
146
+ outcome: Literal["approved", "rejected"],
147
+ *,
148
+ reason: str | None = None,
149
+ ) -> HITLItem | None:
150
+ """Record a reviewer's decision and emit ``hitl.reviewed``."""
151
+ if not reviewer:
152
+ raise ValueError("reviewer must be non-empty")
153
+
154
+ import datetime # noqa: PLC0415
155
+ now = datetime.datetime.now(datetime.timezone.utc).strftime(
156
+ "%Y-%m-%dT%H:%M:%S.%fZ"
157
+ )
158
+
159
+ with self._lock:
160
+ item = self._items.get(decision_id)
161
+ if item is None:
162
+ return None
163
+ item.status = outcome
164
+ item.reviewer = reviewer
165
+ item.resolved_at = now
166
+ if reason:
167
+ item.reason = reason
168
+
169
+ if self._auto_emit:
170
+ self._emit_event(item, "reviewed")
171
+ return item
172
+
173
+ def escalate(
174
+ self,
175
+ decision_id: str,
176
+ *,
177
+ reason: str = "SLA breach or reviewer escalation",
178
+ ) -> HITLItem | None:
179
+ """Escalate an item to the next reviewer tier."""
180
+ with self._lock:
181
+ item = self._items.get(decision_id)
182
+ if item is None:
183
+ return None
184
+ item.status = "escalated"
185
+ item.escalation_tier += 1
186
+ item.reason = reason
187
+
188
+ if self._auto_emit:
189
+ self._emit_event(item, "escalated")
190
+ return item
191
+
192
+ def timeout(self, decision_id: str) -> HITLItem | None:
193
+ """Mark an item as timed out (SLA expired)."""
194
+ import datetime # noqa: PLC0415
195
+ now = datetime.datetime.now(datetime.timezone.utc).strftime(
196
+ "%Y-%m-%dT%H:%M:%S.%fZ"
197
+ )
198
+
199
+ with self._lock:
200
+ item = self._items.get(decision_id)
201
+ if item is None:
202
+ return None
203
+ item.status = "timeout"
204
+ item.resolved_at = now
205
+
206
+ if self._auto_emit:
207
+ self._emit_event(item, "timeout")
208
+ return item
209
+
210
+ def get(self, decision_id: str) -> HITLItem | None:
211
+ """Look up an item by decision_id."""
212
+ with self._lock:
213
+ return self._items.get(decision_id)
214
+
215
+ def list_pending(self) -> list[HITLItem]:
216
+ """Return all items still in ``queued`` status."""
217
+ with self._lock:
218
+ return [i for i in self._items.values() if i.status == "queued"]
219
+
220
+ def list_all(self) -> list[HITLItem]:
221
+ """Return all items regardless of status."""
222
+ with self._lock:
223
+ return list(self._items.values())
224
+
225
+ def clear(self) -> None:
226
+ """Remove all items (for testing)."""
227
+ with self._lock:
228
+ self._items.clear()
229
+
230
+ @staticmethod
231
+ def _emit_event(item: HITLItem, action: str) -> None:
232
+ """Emit an HITL event into the HMAC audit chain."""
233
+ try:
234
+ from spanforge._stream import emit_rfc_event # noqa: PLC0415
235
+ from spanforge.types import EventType # noqa: PLC0415
236
+
237
+ _action_to_event = {
238
+ "queued": EventType.HITL_QUEUED,
239
+ "reviewed": EventType.HITL_REVIEWED,
240
+ "escalated": EventType.HITL_ESCALATED,
241
+ "timeout": EventType.HITL_TIMEOUT,
242
+ }
243
+ et = _action_to_event.get(action)
244
+ if et is None:
245
+ return
246
+ payload = HITLPayload(
247
+ decision_id=item.decision_id,
248
+ agent_id=item.agent_id,
249
+ risk_tier=item.risk_tier,
250
+ status=item.status,
251
+ reason=item.reason,
252
+ reviewer=item.reviewer,
253
+ sla_seconds=item.sla_seconds,
254
+ queued_at=item.queued_at,
255
+ resolved_at=item.resolved_at,
256
+ escalation_tier=item.escalation_tier,
257
+ confidence=item.confidence,
258
+ )
259
+ try:
260
+ emit_rfc_event(et, payload.to_dict())
261
+ except Exception: # noqa: BLE001
262
+ pass
263
+ except ImportError:
264
+ pass
265
+
266
+
267
+ # ---------------------------------------------------------------------------
268
+ # Module-level singleton & convenience functions
269
+ # ---------------------------------------------------------------------------
270
+
271
+ _queue = HITLQueue()
272
+
273
+
274
+ def queue_for_review(
275
+ decision_id: str,
276
+ agent_id: str,
277
+ risk_tier: Literal["low", "medium", "high", "critical"],
278
+ reason: str,
279
+ **kwargs: Any,
280
+ ) -> HITLItem:
281
+ """Enqueue a decision via the module-level :class:`HITLQueue`."""
282
+ return _queue.enqueue(decision_id, agent_id, risk_tier, reason, **kwargs)
283
+
284
+
285
+ def review_item(
286
+ decision_id: str,
287
+ reviewer: str,
288
+ outcome: Literal["approved", "rejected"],
289
+ **kwargs: Any,
290
+ ) -> HITLItem | None:
291
+ """Record a review via the module-level :class:`HITLQueue`."""
292
+ return _queue.review(decision_id, reviewer, outcome, **kwargs)
293
+
294
+
295
+ def list_pending() -> list[HITLItem]:
296
+ """List pending items via the module-level :class:`HITLQueue`."""
297
+ return _queue.list_pending()