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,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()
|