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
spanforge/sdk/gate.py
ADDED
|
@@ -0,0 +1,874 @@
|
|
|
1
|
+
"""spanforge.sdk.gate — SpanForge sf-gate CI/CD Gate Pipeline Client (Phase 8).
|
|
2
|
+
|
|
3
|
+
Implements the full sf-gate SDK surface: gate evaluation, trust gate checks,
|
|
4
|
+
PRRI governance evaluation, artifact management, and cross-service integration
|
|
5
|
+
with sf-audit, sf-observe, and sf-alert.
|
|
6
|
+
|
|
7
|
+
Architecture
|
|
8
|
+
------------
|
|
9
|
+
* :meth:`evaluate` is the **primary evaluation entry point**. It runs a
|
|
10
|
+
named gate check against a payload dict, writes the result to the artifact
|
|
11
|
+
store, emits an ``hc.gate.evaluated`` span via sf-observe, and appends an
|
|
12
|
+
audit record under schema ``halluccheck.gate.v1``.
|
|
13
|
+
* :meth:`run_trust_gate` queries the local audit store for HRI, PII, and
|
|
14
|
+
Secrets records to determine whether the trust gate passes. On failure it
|
|
15
|
+
publishes a ``halluccheck.trust_gate.failed`` alert via sf-alert at
|
|
16
|
+
CRITICAL severity.
|
|
17
|
+
* :meth:`evaluate_prri` scores a PRRI payload and returns a
|
|
18
|
+
:class:`~spanforge.sdk._types.PRRIResult` with a GREEN / AMBER / RED verdict.
|
|
19
|
+
* All methods operate in local-fallback mode when ``config.endpoint`` is empty
|
|
20
|
+
or the remote service is unreachable and ``config.local_fallback_enabled``
|
|
21
|
+
is ``True``.
|
|
22
|
+
|
|
23
|
+
Cross-service integration
|
|
24
|
+
--------------------------
|
|
25
|
+
All integrations use **lazy imports** inside methods to prevent circular
|
|
26
|
+
import cycles:
|
|
27
|
+
|
|
28
|
+
* ``sf_audit`` ← queries for HRI / PII / Secrets records (run_trust_gate)
|
|
29
|
+
* ``sf_observe`` ← emits ``hc.gate.evaluated`` span (evaluate)
|
|
30
|
+
* ``sf_alert`` ← publishes trust-gate failure alert (run_trust_gate)
|
|
31
|
+
|
|
32
|
+
Gate topics (GAT-025)
|
|
33
|
+
----------------------
|
|
34
|
+
Eight built-in gate-related alert topics:
|
|
35
|
+
|
|
36
|
+
* ``halluccheck.trust_gate.failed`` — CRITICAL
|
|
37
|
+
* ``halluccheck.gate.blocked`` — HIGH
|
|
38
|
+
* ``halluccheck.gate.warn`` — MEDIUM
|
|
39
|
+
* ``halluccheck.prri.red`` — HIGH
|
|
40
|
+
* ``halluccheck.prri.amber`` — MEDIUM
|
|
41
|
+
* ``halluccheck.schema.violation`` — HIGH
|
|
42
|
+
* ``halluccheck.dependency.critical`` — CRITICAL
|
|
43
|
+
* ``halluccheck.secrets.leak`` — CRITICAL
|
|
44
|
+
|
|
45
|
+
Security requirements
|
|
46
|
+
---------------------
|
|
47
|
+
* API keys are never logged or included in exception messages.
|
|
48
|
+
* Artifact paths are restricted to the ``.sf-gate/`` directory; no path
|
|
49
|
+
traversal is possible (paths are validated against the base dir).
|
|
50
|
+
* Trust gate failure alerts are only sent once per ``(project_id, pipeline_id)``
|
|
51
|
+
within the deduplication window.
|
|
52
|
+
* Thread-safety: in-memory counters and artifact caches use locks.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
from __future__ import annotations
|
|
56
|
+
|
|
57
|
+
import json
|
|
58
|
+
import logging
|
|
59
|
+
import os
|
|
60
|
+
import threading
|
|
61
|
+
import time
|
|
62
|
+
import uuid
|
|
63
|
+
from datetime import datetime, timezone
|
|
64
|
+
from pathlib import Path
|
|
65
|
+
from typing import Any
|
|
66
|
+
|
|
67
|
+
from spanforge.sdk._base import (
|
|
68
|
+
SFClientConfig,
|
|
69
|
+
SFServiceClient,
|
|
70
|
+
_CircuitBreaker,
|
|
71
|
+
)
|
|
72
|
+
from spanforge.sdk._exceptions import (
|
|
73
|
+
SFGateError,
|
|
74
|
+
SFGateEvaluationError,
|
|
75
|
+
)
|
|
76
|
+
from spanforge.sdk._types import (
|
|
77
|
+
GateArtifact,
|
|
78
|
+
GateEvaluationResult,
|
|
79
|
+
GateStatusInfo,
|
|
80
|
+
GateVerdict,
|
|
81
|
+
PRRIResult,
|
|
82
|
+
PRRIVerdict,
|
|
83
|
+
TrustGateResult,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
__all__ = [
|
|
87
|
+
"GATE_KNOWN_TOPICS",
|
|
88
|
+
"SFGateClient",
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
_log = logging.getLogger(__name__)
|
|
92
|
+
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
# Constants
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
#: Built-in gate-related alert topics (GAT-025).
|
|
98
|
+
GATE_KNOWN_TOPICS: frozenset[str] = frozenset(
|
|
99
|
+
{
|
|
100
|
+
"halluccheck.trust_gate.failed",
|
|
101
|
+
"halluccheck.gate.blocked",
|
|
102
|
+
"halluccheck.gate.warn",
|
|
103
|
+
"halluccheck.prri.red",
|
|
104
|
+
"halluccheck.prri.amber",
|
|
105
|
+
"halluccheck.schema.violation",
|
|
106
|
+
"halluccheck.dependency.critical",
|
|
107
|
+
"halluccheck.secrets.leak",
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
#: PRRI thresholds
|
|
112
|
+
_PRRI_RED_THRESHOLD: int = 70
|
|
113
|
+
_PRRI_AMBER_THRESHOLD: int = 40
|
|
114
|
+
|
|
115
|
+
#: HRI critical rate threshold for trust gate
|
|
116
|
+
_HRI_CRITICAL_THRESHOLD: float = 0.05
|
|
117
|
+
|
|
118
|
+
#: Artifact base directory (relative to CWD)
|
|
119
|
+
_ARTIFACT_BASE: str = ".sf-gate/artifacts"
|
|
120
|
+
|
|
121
|
+
#: Per-project/pipeline trust-gate alert dedup window
|
|
122
|
+
_ALERT_DEDUP_WINDOW_SECONDS: float = 300.0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
# SFGateClient
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class SFGateClient(SFServiceClient):
|
|
131
|
+
"""Client for the SpanForge CI/CD Gate Pipeline service (sf-gate).
|
|
132
|
+
|
|
133
|
+
Provides gate evaluation, trust gate checks, PRRI governance scoring,
|
|
134
|
+
and artifact management.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
config: SDK configuration. Loads from environment variables if
|
|
138
|
+
not supplied explicitly.
|
|
139
|
+
|
|
140
|
+
Environment variables
|
|
141
|
+
----------------------
|
|
142
|
+
``SPANFORGE_API_KEY`` — service API key
|
|
143
|
+
``SPANFORGE_ENDPOINT`` — remote API endpoint
|
|
144
|
+
``SPANFORGE_LOCAL_FALLBACK`` — ``"true"`` to enable local mode
|
|
145
|
+
``SPANFORGE_GATE_ARTIFACT_DIR`` — override for artifact directory
|
|
146
|
+
``SPANFORGE_GATE_HRI_WINDOW`` — number of HRI records to sample
|
|
147
|
+
``SPANFORGE_GATE_PII_WINDOW_HOURS`` — hours window for PII check
|
|
148
|
+
``SPANFORGE_GATE_SECRETS_WINDOW_HOURS``— hours window for secrets check
|
|
149
|
+
|
|
150
|
+
Example::
|
|
151
|
+
|
|
152
|
+
from spanforge.sdk import sf_gate
|
|
153
|
+
|
|
154
|
+
result = sf_gate.evaluate(
|
|
155
|
+
"gate5_governance",
|
|
156
|
+
{"prri_score": 42, "framework": "eu-ai-act"},
|
|
157
|
+
project_id="my-project",
|
|
158
|
+
pipeline_id="ci-12",
|
|
159
|
+
)
|
|
160
|
+
print(result.verdict) # "PASS"
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
def __init__(self, config: SFClientConfig) -> None:
|
|
164
|
+
super().__init__(config, "gate")
|
|
165
|
+
self._lock = threading.Lock()
|
|
166
|
+
# Per-gate-sink circuit breakers (GAT-040)
|
|
167
|
+
self._gate_circuit_breakers: dict[str, _CircuitBreaker] = {}
|
|
168
|
+
# Artifact base directory
|
|
169
|
+
artifact_dir_env = os.environ.get("SPANFORGE_GATE_ARTIFACT_DIR", "")
|
|
170
|
+
self._artifact_dir = Path(artifact_dir_env) if artifact_dir_env else Path(_ARTIFACT_BASE)
|
|
171
|
+
self._artifact_dir.mkdir(parents=True, exist_ok=True)
|
|
172
|
+
# Dedup store for trust-gate alerts: set of (project_id, pipeline_id)
|
|
173
|
+
self._alerted_trust_gates: dict[str, float] = {}
|
|
174
|
+
# Stats
|
|
175
|
+
self._evaluate_count: int = 0
|
|
176
|
+
self._trust_gate_count: int = 0
|
|
177
|
+
self._last_evaluate_at: str | None = None
|
|
178
|
+
|
|
179
|
+
# ------------------------------------------------------------------
|
|
180
|
+
# Circuit breaker helpers
|
|
181
|
+
# ------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
def _get_cb(self, sink_id: str) -> _CircuitBreaker:
|
|
184
|
+
"""Return (or create) a per-sink circuit breaker."""
|
|
185
|
+
with self._lock:
|
|
186
|
+
if sink_id not in self._gate_circuit_breakers:
|
|
187
|
+
self._gate_circuit_breakers[sink_id] = _CircuitBreaker()
|
|
188
|
+
return self._gate_circuit_breakers[sink_id]
|
|
189
|
+
|
|
190
|
+
# ------------------------------------------------------------------
|
|
191
|
+
# Artifact helpers
|
|
192
|
+
# ------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
def _artifact_path(self, gate_id: str) -> Path:
|
|
195
|
+
"""Return the canonical artifact path for *gate_id*.
|
|
196
|
+
|
|
197
|
+
Path traversal is prevented by resolving against ``_artifact_dir``
|
|
198
|
+
and asserting the result is still inside that directory.
|
|
199
|
+
"""
|
|
200
|
+
safe_id = gate_id.replace("/", "_").replace("..", "_")
|
|
201
|
+
candidate = (self._artifact_dir / f"{safe_id}_result.json").resolve()
|
|
202
|
+
base_resolved = self._artifact_dir.resolve()
|
|
203
|
+
if not str(candidate).startswith(str(base_resolved)):
|
|
204
|
+
raise SFGateError(f"Unsafe artifact path detected for gate_id={gate_id!r}.")
|
|
205
|
+
return candidate
|
|
206
|
+
|
|
207
|
+
def _write_artifact(self, gate_id: str, data: dict[str, Any]) -> Path:
|
|
208
|
+
"""Serialise *data* as JSON and write to the artifact store."""
|
|
209
|
+
path = self._artifact_path(gate_id)
|
|
210
|
+
try:
|
|
211
|
+
path.write_text(json.dumps(data, indent=2, default=str), encoding="utf-8")
|
|
212
|
+
except OSError as exc:
|
|
213
|
+
_log.warning("Could not write gate artifact for %r: %s", gate_id, exc)
|
|
214
|
+
return path
|
|
215
|
+
|
|
216
|
+
def _read_artifact(self, gate_id: str) -> dict[str, Any] | None:
|
|
217
|
+
"""Read and parse the artifact JSON for *gate_id*."""
|
|
218
|
+
path = self._artifact_path(gate_id)
|
|
219
|
+
if not path.exists():
|
|
220
|
+
return None
|
|
221
|
+
try:
|
|
222
|
+
return json.loads(path.read_text(encoding="utf-8")) # type: ignore[no-any-return]
|
|
223
|
+
except (json.JSONDecodeError, OSError):
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
# ------------------------------------------------------------------
|
|
227
|
+
# Public API — evaluate
|
|
228
|
+
# ------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
def evaluate(
|
|
231
|
+
self,
|
|
232
|
+
gate_id: str,
|
|
233
|
+
payload: dict[str, Any],
|
|
234
|
+
*,
|
|
235
|
+
project_id: str = "",
|
|
236
|
+
pipeline_id: str = "",
|
|
237
|
+
) -> GateEvaluationResult:
|
|
238
|
+
"""Evaluate a gate condition and record the result (GAT-004).
|
|
239
|
+
|
|
240
|
+
Writes the result to ``.sf-gate/artifacts/<gate_id>_result.json``,
|
|
241
|
+
emits an ``hc.gate.evaluated`` span to sf-observe, and appends an
|
|
242
|
+
audit record under schema ``halluccheck.gate.v1``.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
gate_id: Unique gate identifier (e.g. ``"gate5_governance"``).
|
|
246
|
+
payload: Metrics dict to evaluate. Content depends on the
|
|
247
|
+
gate type.
|
|
248
|
+
project_id: Optional project scoping.
|
|
249
|
+
pipeline_id: Optional CI pipeline identifier.
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
:class:`~spanforge.sdk._types.GateEvaluationResult`
|
|
253
|
+
|
|
254
|
+
Raises:
|
|
255
|
+
:exc:`~spanforge.sdk._exceptions.SFGateEvaluationError`: If
|
|
256
|
+
gate evaluation encounters a fatal error.
|
|
257
|
+
"""
|
|
258
|
+
if not gate_id or not gate_id.strip():
|
|
259
|
+
raise SFGateEvaluationError("gate_id must be a non-empty string.")
|
|
260
|
+
|
|
261
|
+
started = time.monotonic()
|
|
262
|
+
timestamp = datetime.now(timezone.utc).isoformat()
|
|
263
|
+
pipeline_id = pipeline_id or str(uuid.uuid4())
|
|
264
|
+
|
|
265
|
+
try:
|
|
266
|
+
# Determine verdict from payload
|
|
267
|
+
verdict = self._infer_verdict(gate_id, payload)
|
|
268
|
+
duration_ms = int((time.monotonic() - started) * 1000)
|
|
269
|
+
|
|
270
|
+
# Build artifact
|
|
271
|
+
artifact_data: dict[str, Any] = {
|
|
272
|
+
"gate_id": gate_id,
|
|
273
|
+
"verdict": verdict,
|
|
274
|
+
"metrics": payload,
|
|
275
|
+
"timestamp": timestamp,
|
|
276
|
+
"duration_ms": duration_ms,
|
|
277
|
+
"project_id": project_id,
|
|
278
|
+
"pipeline_id": pipeline_id,
|
|
279
|
+
}
|
|
280
|
+
artifact_path = self._write_artifact(gate_id, artifact_data)
|
|
281
|
+
artifact_url = f"file://{artifact_path}"
|
|
282
|
+
|
|
283
|
+
result = GateEvaluationResult(
|
|
284
|
+
gate_id=gate_id,
|
|
285
|
+
verdict=verdict,
|
|
286
|
+
metrics=payload,
|
|
287
|
+
artifact_url=artifact_url,
|
|
288
|
+
duration_ms=duration_ms,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
# Async audit + observe (best-effort)
|
|
292
|
+
self._post_evaluate_hooks(
|
|
293
|
+
gate_id=gate_id,
|
|
294
|
+
result=result,
|
|
295
|
+
project_id=project_id,
|
|
296
|
+
pipeline_id=pipeline_id,
|
|
297
|
+
timestamp=timestamp,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
with self._lock:
|
|
301
|
+
self._evaluate_count += 1
|
|
302
|
+
self._last_evaluate_at = timestamp
|
|
303
|
+
|
|
304
|
+
return result # noqa: TRY300
|
|
305
|
+
|
|
306
|
+
except SFGateEvaluationError:
|
|
307
|
+
raise
|
|
308
|
+
except Exception as exc:
|
|
309
|
+
raise SFGateEvaluationError(
|
|
310
|
+
f"Gate evaluation failed for gate_id={gate_id!r}: {exc}"
|
|
311
|
+
) from exc
|
|
312
|
+
|
|
313
|
+
def _infer_verdict(self, gate_id: str, payload: dict[str, Any]) -> str: # noqa: PLR0911
|
|
314
|
+
"""Derive a verdict string from *payload*.
|
|
315
|
+
|
|
316
|
+
Looks for a top-level ``"verdict"`` key first, then falls back to
|
|
317
|
+
checking ``"pass"``, ``"failed"``, ``"status"``.
|
|
318
|
+
|
|
319
|
+
Returns one of :class:`~spanforge.sdk._types.GateVerdict` constants.
|
|
320
|
+
"""
|
|
321
|
+
if "verdict" in payload:
|
|
322
|
+
v = str(payload["verdict"]).upper()
|
|
323
|
+
if v in {GateVerdict.PASS, GateVerdict.FAIL, GateVerdict.WARN, GateVerdict.SKIPPED}:
|
|
324
|
+
return v
|
|
325
|
+
if payload.get("pass") is True or payload.get("passed") is True:
|
|
326
|
+
return GateVerdict.PASS
|
|
327
|
+
if payload.get("failed") is True or payload.get("pass") is False:
|
|
328
|
+
return GateVerdict.FAIL
|
|
329
|
+
status = str(payload.get("status", "")).lower()
|
|
330
|
+
if status in {"pass", "passed", "green", "ok"}:
|
|
331
|
+
return GateVerdict.PASS
|
|
332
|
+
if status in {"fail", "failed", "red", "error"}:
|
|
333
|
+
return GateVerdict.FAIL
|
|
334
|
+
if status in {"warn", "warning", "amber"}:
|
|
335
|
+
return GateVerdict.WARN
|
|
336
|
+
# Default to PASS when payload contains no explicit failure indicators
|
|
337
|
+
return GateVerdict.PASS
|
|
338
|
+
|
|
339
|
+
def _post_evaluate_hooks(
|
|
340
|
+
self,
|
|
341
|
+
*,
|
|
342
|
+
gate_id: str,
|
|
343
|
+
result: GateEvaluationResult,
|
|
344
|
+
project_id: str,
|
|
345
|
+
pipeline_id: str,
|
|
346
|
+
timestamp: str,
|
|
347
|
+
) -> None:
|
|
348
|
+
"""Best-effort sf-observe span + sf-audit append after evaluate()."""
|
|
349
|
+
# sf-observe: emit hc.gate.evaluated span (GAT-004)
|
|
350
|
+
try:
|
|
351
|
+
from spanforge.sdk import sf_observe
|
|
352
|
+
|
|
353
|
+
sf_observe.emit_span(
|
|
354
|
+
"hc.gate.evaluated",
|
|
355
|
+
attributes={
|
|
356
|
+
"gate_id": gate_id,
|
|
357
|
+
"verdict": result.verdict,
|
|
358
|
+
"project_id": project_id,
|
|
359
|
+
"pipeline_id": pipeline_id,
|
|
360
|
+
"duration_ms": result.duration_ms,
|
|
361
|
+
},
|
|
362
|
+
)
|
|
363
|
+
except Exception:
|
|
364
|
+
_log.debug("sf_observe.emit_span failed for gate %r", gate_id)
|
|
365
|
+
|
|
366
|
+
# sf-audit: append halluccheck.gate.v1 (GAT-004)
|
|
367
|
+
try:
|
|
368
|
+
from spanforge.sdk import sf_audit
|
|
369
|
+
|
|
370
|
+
sf_audit.append(
|
|
371
|
+
{
|
|
372
|
+
"gate_id": gate_id,
|
|
373
|
+
"verdict": result.verdict,
|
|
374
|
+
"metrics": result.metrics,
|
|
375
|
+
"project_id": project_id,
|
|
376
|
+
"pipeline_id": pipeline_id,
|
|
377
|
+
"timestamp": timestamp,
|
|
378
|
+
},
|
|
379
|
+
"halluccheck.gate.v1",
|
|
380
|
+
)
|
|
381
|
+
except Exception:
|
|
382
|
+
_log.debug("sf_audit.append failed for gate %r", gate_id)
|
|
383
|
+
|
|
384
|
+
# ------------------------------------------------------------------
|
|
385
|
+
# Public API — run_trust_gate
|
|
386
|
+
# ------------------------------------------------------------------
|
|
387
|
+
|
|
388
|
+
def run_trust_gate(
|
|
389
|
+
self,
|
|
390
|
+
project_id: str,
|
|
391
|
+
*,
|
|
392
|
+
pipeline_id: str = "",
|
|
393
|
+
hri_window: int | None = None,
|
|
394
|
+
pii_window_hours: int = 24,
|
|
395
|
+
secrets_window_hours: int = 24,
|
|
396
|
+
) -> TrustGateResult:
|
|
397
|
+
"""Run the HallucCheck Trust Gate (GAT-020/021).
|
|
398
|
+
|
|
399
|
+
Queries sf-audit for:
|
|
400
|
+
* Last N ``halluccheck.score.v1`` records → compute ``hri_critical_rate``
|
|
401
|
+
* Last 24 h ``halluccheck.pii.v1`` records → ``pii_detected``
|
|
402
|
+
* Last 24 h ``halluccheck.secrets.v1`` records → ``secrets_detected``
|
|
403
|
+
|
|
404
|
+
On failure, publishes ``halluccheck.trust_gate.failed`` via sf-alert
|
|
405
|
+
at CRITICAL severity.
|
|
406
|
+
|
|
407
|
+
Args:
|
|
408
|
+
project_id: Project to evaluate.
|
|
409
|
+
pipeline_id: Optional CI pipeline identifier.
|
|
410
|
+
hri_window: Number of score records to sample.
|
|
411
|
+
Defaults to env var
|
|
412
|
+
``SPANFORGE_GATE_HRI_WINDOW`` or 100.
|
|
413
|
+
pii_window_hours: Hours window for PII detections.
|
|
414
|
+
secrets_window_hours: Hours window for secrets detections.
|
|
415
|
+
|
|
416
|
+
Returns:
|
|
417
|
+
:class:`~spanforge.sdk._types.TrustGateResult`
|
|
418
|
+
|
|
419
|
+
Raises:
|
|
420
|
+
:exc:`~spanforge.sdk._exceptions.SFGateTrustFailedError`: When the
|
|
421
|
+
trust gate fails AND ``raise_on_fail=True`` (default False in
|
|
422
|
+
this method; the caller may inspect ``result.pass_`` instead).
|
|
423
|
+
"""
|
|
424
|
+
if hri_window is None:
|
|
425
|
+
hri_window = int(os.environ.get("SPANFORGE_GATE_HRI_WINDOW", "100"))
|
|
426
|
+
|
|
427
|
+
pipeline_id = pipeline_id or str(uuid.uuid4())
|
|
428
|
+
timestamp = datetime.now(timezone.utc).isoformat()
|
|
429
|
+
|
|
430
|
+
hri_critical_rate, _ = self._compute_hri_critical_rate(project_id, hri_window)
|
|
431
|
+
pii_detected, pii_count = self._check_pii_window(project_id, pii_window_hours)
|
|
432
|
+
secrets_detected, secrets_count = self._check_secrets_window(
|
|
433
|
+
project_id, secrets_window_hours
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
failures: list[str] = []
|
|
437
|
+
if hri_critical_rate >= _HRI_CRITICAL_THRESHOLD:
|
|
438
|
+
failures.append(
|
|
439
|
+
f"hri_critical_rate={hri_critical_rate:.4f} >= threshold={_HRI_CRITICAL_THRESHOLD}"
|
|
440
|
+
)
|
|
441
|
+
if pii_detected:
|
|
442
|
+
failures.append(
|
|
443
|
+
f"pii_detected=true ({pii_count} detection(s) in last {pii_window_hours}h)"
|
|
444
|
+
)
|
|
445
|
+
if secrets_detected:
|
|
446
|
+
failures.append(
|
|
447
|
+
"secrets_detected=true "
|
|
448
|
+
f"({secrets_count} detection(s) in last {secrets_window_hours}h)"
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
pass_ = len(failures) == 0
|
|
452
|
+
verdict = GateVerdict.PASS if pass_ else GateVerdict.FAIL
|
|
453
|
+
|
|
454
|
+
result = TrustGateResult(
|
|
455
|
+
gate_id="gate6_trust",
|
|
456
|
+
verdict=verdict,
|
|
457
|
+
hri_critical_rate=hri_critical_rate,
|
|
458
|
+
hri_critical_threshold=_HRI_CRITICAL_THRESHOLD,
|
|
459
|
+
pii_detected=pii_detected,
|
|
460
|
+
pii_detections_24h=pii_count,
|
|
461
|
+
secrets_detected=secrets_detected,
|
|
462
|
+
secrets_detections_24h=secrets_count,
|
|
463
|
+
failures=failures,
|
|
464
|
+
timestamp=timestamp,
|
|
465
|
+
pipeline_id=pipeline_id,
|
|
466
|
+
project_id=project_id,
|
|
467
|
+
pass_=pass_,
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
# Write artifact
|
|
471
|
+
self._write_artifact(
|
|
472
|
+
"gate6_trust",
|
|
473
|
+
{
|
|
474
|
+
"gate_id": "gate6_trust",
|
|
475
|
+
"verdict": verdict,
|
|
476
|
+
"hri_critical_rate": hri_critical_rate,
|
|
477
|
+
"hri_critical_threshold": _HRI_CRITICAL_THRESHOLD,
|
|
478
|
+
"pii_detected": pii_detected,
|
|
479
|
+
"pii_detections_24h": pii_count,
|
|
480
|
+
"secrets_detected": secrets_detected,
|
|
481
|
+
"secrets_detections_24h": secrets_count,
|
|
482
|
+
"failures": failures,
|
|
483
|
+
"timestamp": timestamp,
|
|
484
|
+
"pipeline_id": pipeline_id,
|
|
485
|
+
"project_id": project_id,
|
|
486
|
+
},
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
with self._lock:
|
|
490
|
+
self._trust_gate_count += 1
|
|
491
|
+
|
|
492
|
+
if not pass_:
|
|
493
|
+
self._send_trust_gate_alert(
|
|
494
|
+
project_id=project_id,
|
|
495
|
+
pipeline_id=pipeline_id,
|
|
496
|
+
failures=failures,
|
|
497
|
+
timestamp=timestamp,
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
return result
|
|
501
|
+
|
|
502
|
+
def _compute_hri_critical_rate(
|
|
503
|
+
self,
|
|
504
|
+
project_id: str,
|
|
505
|
+
window: int,
|
|
506
|
+
) -> tuple[float, int]:
|
|
507
|
+
"""Return (hri_critical_rate, total_records) from sf-audit.
|
|
508
|
+
|
|
509
|
+
Queries last *window* ``halluccheck.score.v1`` records and computes the
|
|
510
|
+
fraction that have ``is_critical=true``.
|
|
511
|
+
"""
|
|
512
|
+
try:
|
|
513
|
+
from datetime import datetime, timedelta
|
|
514
|
+
from datetime import timezone as _tz
|
|
515
|
+
|
|
516
|
+
from spanforge.sdk import sf_audit
|
|
517
|
+
|
|
518
|
+
since = datetime.now(_tz.utc) - timedelta(hours=24 * 30)
|
|
519
|
+
records = sf_audit.export(
|
|
520
|
+
date_range=(since.isoformat(), datetime.now(_tz.utc).isoformat()),
|
|
521
|
+
limit=window,
|
|
522
|
+
)
|
|
523
|
+
total = len(records)
|
|
524
|
+
if total == 0:
|
|
525
|
+
return 0.0, 0
|
|
526
|
+
critical = sum(
|
|
527
|
+
1
|
|
528
|
+
for r in records
|
|
529
|
+
if r.get("is_critical") is True or str(r.get("category", "")).lower() == "critical"
|
|
530
|
+
)
|
|
531
|
+
return critical / total, total
|
|
532
|
+
except Exception:
|
|
533
|
+
_log.debug("Could not query HRI records from sf_audit")
|
|
534
|
+
return 0.0, 0
|
|
535
|
+
|
|
536
|
+
def _check_pii_window(
|
|
537
|
+
self,
|
|
538
|
+
project_id: str,
|
|
539
|
+
window_hours: int,
|
|
540
|
+
) -> tuple[bool, int]:
|
|
541
|
+
"""Return (pii_detected, count) from sf-audit for last *window_hours*."""
|
|
542
|
+
try:
|
|
543
|
+
from datetime import datetime, timedelta
|
|
544
|
+
from datetime import timezone as _tz
|
|
545
|
+
|
|
546
|
+
from spanforge.sdk import sf_audit
|
|
547
|
+
|
|
548
|
+
since = datetime.now(_tz.utc) - timedelta(hours=window_hours)
|
|
549
|
+
records = sf_audit.export(
|
|
550
|
+
schema_key="halluccheck.pii.v1",
|
|
551
|
+
date_range=(since.isoformat(), datetime.now(_tz.utc).isoformat()),
|
|
552
|
+
limit=1000,
|
|
553
|
+
)
|
|
554
|
+
# Filter by project_id if non-empty
|
|
555
|
+
if project_id:
|
|
556
|
+
records = [
|
|
557
|
+
r
|
|
558
|
+
for r in records
|
|
559
|
+
if r.get("project_id") == project_id or not r.get("project_id")
|
|
560
|
+
]
|
|
561
|
+
count = sum(
|
|
562
|
+
1 for r in records if r.get("detected") is True or r.get("pii_detected") is True
|
|
563
|
+
)
|
|
564
|
+
return count > 0, count # noqa: TRY300
|
|
565
|
+
except Exception:
|
|
566
|
+
_log.debug("Could not query PII records from sf_audit")
|
|
567
|
+
return False, 0
|
|
568
|
+
|
|
569
|
+
def _check_secrets_window(
|
|
570
|
+
self,
|
|
571
|
+
project_id: str,
|
|
572
|
+
window_hours: int,
|
|
573
|
+
) -> tuple[bool, int]:
|
|
574
|
+
"""Return (secrets_detected, count) from sf-audit for last *window_hours*."""
|
|
575
|
+
try:
|
|
576
|
+
from datetime import datetime, timedelta
|
|
577
|
+
from datetime import timezone as _tz
|
|
578
|
+
|
|
579
|
+
from spanforge.sdk import sf_audit
|
|
580
|
+
|
|
581
|
+
since = datetime.now(_tz.utc) - timedelta(hours=window_hours)
|
|
582
|
+
records = sf_audit.export(
|
|
583
|
+
schema_key="halluccheck.secrets.v1",
|
|
584
|
+
date_range=(since.isoformat(), datetime.now(_tz.utc).isoformat()),
|
|
585
|
+
limit=1000,
|
|
586
|
+
)
|
|
587
|
+
if project_id:
|
|
588
|
+
records = [
|
|
589
|
+
r
|
|
590
|
+
for r in records
|
|
591
|
+
if r.get("project_id") == project_id or not r.get("project_id")
|
|
592
|
+
]
|
|
593
|
+
count = sum(
|
|
594
|
+
1
|
|
595
|
+
for r in records
|
|
596
|
+
if r.get("has_secrets") is True or r.get("secrets_detected") is True
|
|
597
|
+
)
|
|
598
|
+
return count > 0, count # noqa: TRY300
|
|
599
|
+
except Exception:
|
|
600
|
+
_log.debug("Could not query Secrets records from sf_audit")
|
|
601
|
+
return False, 0
|
|
602
|
+
|
|
603
|
+
def _send_trust_gate_alert(
|
|
604
|
+
self,
|
|
605
|
+
*,
|
|
606
|
+
project_id: str,
|
|
607
|
+
pipeline_id: str,
|
|
608
|
+
failures: list[str],
|
|
609
|
+
timestamp: str,
|
|
610
|
+
) -> None:
|
|
611
|
+
"""Publish halluccheck.trust_gate.failed alert via sf-alert (GAT-022).
|
|
612
|
+
|
|
613
|
+
Deduplicates by (project_id, pipeline_id) within 5 minutes.
|
|
614
|
+
"""
|
|
615
|
+
dedup_key = f"{project_id}:{pipeline_id}"
|
|
616
|
+
now = time.monotonic()
|
|
617
|
+
with self._lock:
|
|
618
|
+
last_sent = self._alerted_trust_gates.get(dedup_key)
|
|
619
|
+
if last_sent is not None and (now - last_sent) < _ALERT_DEDUP_WINDOW_SECONDS:
|
|
620
|
+
_log.debug("Trust gate alert suppressed (dedup): %s", dedup_key)
|
|
621
|
+
return
|
|
622
|
+
self._alerted_trust_gates[dedup_key] = now
|
|
623
|
+
|
|
624
|
+
try:
|
|
625
|
+
from spanforge.sdk import sf_alert
|
|
626
|
+
from spanforge.sdk._types import AlertSeverity
|
|
627
|
+
|
|
628
|
+
sf_alert.publish(
|
|
629
|
+
"halluccheck.trust_gate.failed",
|
|
630
|
+
{
|
|
631
|
+
"project_id": project_id,
|
|
632
|
+
"pipeline_id": pipeline_id,
|
|
633
|
+
"failures": failures,
|
|
634
|
+
"timestamp": timestamp,
|
|
635
|
+
"gate_id": "gate6_trust",
|
|
636
|
+
},
|
|
637
|
+
severity=AlertSeverity.CRITICAL.value,
|
|
638
|
+
project_id=project_id,
|
|
639
|
+
)
|
|
640
|
+
except Exception as exc:
|
|
641
|
+
_log.debug("sf_alert.publish failed for trust gate: %s", exc)
|
|
642
|
+
|
|
643
|
+
# ------------------------------------------------------------------
|
|
644
|
+
# Public API — evaluate_prri
|
|
645
|
+
# ------------------------------------------------------------------
|
|
646
|
+
|
|
647
|
+
def evaluate_prri( # noqa: PLR0913
|
|
648
|
+
self,
|
|
649
|
+
project_id: str,
|
|
650
|
+
*,
|
|
651
|
+
prri_score: int,
|
|
652
|
+
threshold: int = _PRRI_RED_THRESHOLD,
|
|
653
|
+
framework: str = "",
|
|
654
|
+
policy_file: str = "",
|
|
655
|
+
dimension_breakdown: dict[str, Any] | None = None,
|
|
656
|
+
) -> PRRIResult:
|
|
657
|
+
"""Score a PRRI payload and return a GREEN / AMBER / RED verdict (GAT-010).
|
|
658
|
+
|
|
659
|
+
Args:
|
|
660
|
+
project_id: Project being evaluated.
|
|
661
|
+
prri_score: Raw PRRI score (0-100, higher = more risk).
|
|
662
|
+
threshold: RED threshold. Scores >= threshold → RED.
|
|
663
|
+
Default: 70.
|
|
664
|
+
framework: Regulatory framework (e.g. ``"eu-ai-act"``).
|
|
665
|
+
policy_file: Path to the policy file used for scoring.
|
|
666
|
+
dimension_breakdown: Optional per-dimension breakdown dict.
|
|
667
|
+
|
|
668
|
+
Returns:
|
|
669
|
+
:class:`~spanforge.sdk._types.PRRIResult`
|
|
670
|
+
|
|
671
|
+
Raises:
|
|
672
|
+
:exc:`~spanforge.sdk._exceptions.SFGateEvaluationError`:
|
|
673
|
+
If *prri_score* is out of range.
|
|
674
|
+
"""
|
|
675
|
+
if not (0 <= prri_score <= 100): # noqa: PLR2004
|
|
676
|
+
raise SFGateEvaluationError(f"prri_score must be in [0, 100], got {prri_score}.")
|
|
677
|
+
|
|
678
|
+
timestamp = datetime.now(timezone.utc).isoformat()
|
|
679
|
+
amber_threshold = _PRRI_AMBER_THRESHOLD
|
|
680
|
+
|
|
681
|
+
if prri_score >= threshold:
|
|
682
|
+
verdict = PRRIVerdict.RED
|
|
683
|
+
allow = False
|
|
684
|
+
elif prri_score >= amber_threshold:
|
|
685
|
+
verdict = PRRIVerdict.AMBER
|
|
686
|
+
allow = True
|
|
687
|
+
else:
|
|
688
|
+
verdict = PRRIVerdict.GREEN
|
|
689
|
+
allow = True
|
|
690
|
+
|
|
691
|
+
result = PRRIResult(
|
|
692
|
+
gate_id="gate5_governance",
|
|
693
|
+
prri_score=prri_score,
|
|
694
|
+
verdict=verdict,
|
|
695
|
+
dimension_breakdown=dimension_breakdown or {},
|
|
696
|
+
framework=framework,
|
|
697
|
+
policy_file=policy_file,
|
|
698
|
+
timestamp=timestamp,
|
|
699
|
+
allow=allow,
|
|
700
|
+
project_id=project_id,
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
# Write artifact
|
|
704
|
+
self._write_artifact(
|
|
705
|
+
"gate5_governance",
|
|
706
|
+
{
|
|
707
|
+
"gate_id": "gate5_governance",
|
|
708
|
+
"prri_score": prri_score,
|
|
709
|
+
"verdict": verdict,
|
|
710
|
+
"dimension_breakdown": dimension_breakdown or {},
|
|
711
|
+
"framework": framework,
|
|
712
|
+
"policy_file": policy_file,
|
|
713
|
+
"timestamp": timestamp,
|
|
714
|
+
"allow": allow,
|
|
715
|
+
"project_id": project_id,
|
|
716
|
+
},
|
|
717
|
+
)
|
|
718
|
+
|
|
719
|
+
# Publish alert if RED or AMBER (GAT-011)
|
|
720
|
+
if verdict == PRRIVerdict.RED:
|
|
721
|
+
self._publish_prri_alert(
|
|
722
|
+
"halluccheck.prri.red", project_id, prri_score, verdict, timestamp
|
|
723
|
+
)
|
|
724
|
+
elif verdict == PRRIVerdict.AMBER:
|
|
725
|
+
self._publish_prri_alert(
|
|
726
|
+
"halluccheck.prri.amber", project_id, prri_score, verdict, timestamp
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
return result
|
|
730
|
+
|
|
731
|
+
def _publish_prri_alert(
|
|
732
|
+
self,
|
|
733
|
+
topic: str,
|
|
734
|
+
project_id: str,
|
|
735
|
+
prri_score: int,
|
|
736
|
+
verdict: str,
|
|
737
|
+
timestamp: str,
|
|
738
|
+
) -> None:
|
|
739
|
+
"""Publish a PRRI alert via sf-alert (best-effort)."""
|
|
740
|
+
try:
|
|
741
|
+
from spanforge.sdk import sf_alert
|
|
742
|
+
from spanforge.sdk._types import AlertSeverity
|
|
743
|
+
|
|
744
|
+
severity = AlertSeverity.HIGH if verdict == PRRIVerdict.RED else AlertSeverity.WARNING
|
|
745
|
+
sf_alert.publish(
|
|
746
|
+
topic,
|
|
747
|
+
{
|
|
748
|
+
"project_id": project_id,
|
|
749
|
+
"prri_score": prri_score,
|
|
750
|
+
"verdict": verdict,
|
|
751
|
+
"timestamp": timestamp,
|
|
752
|
+
},
|
|
753
|
+
severity=severity.value,
|
|
754
|
+
project_id=project_id,
|
|
755
|
+
)
|
|
756
|
+
except Exception:
|
|
757
|
+
_log.debug("sf_alert.publish failed for PRRI alert: %s", topic)
|
|
758
|
+
|
|
759
|
+
# ------------------------------------------------------------------
|
|
760
|
+
# Public API — list_artifacts
|
|
761
|
+
# ------------------------------------------------------------------
|
|
762
|
+
|
|
763
|
+
def list_artifacts(
|
|
764
|
+
self,
|
|
765
|
+
gate_id: str | None = None,
|
|
766
|
+
*,
|
|
767
|
+
limit: int = 50,
|
|
768
|
+
) -> list[GateArtifact]:
|
|
769
|
+
"""List gate artifacts in the artifact store (GAT-003).
|
|
770
|
+
|
|
771
|
+
Args:
|
|
772
|
+
gate_id: Filter to artifacts for a specific gate. ``None`` means
|
|
773
|
+
all gates.
|
|
774
|
+
limit: Maximum number of results to return (most-recent first).
|
|
775
|
+
|
|
776
|
+
Returns:
|
|
777
|
+
List of :class:`~spanforge.sdk._types.GateArtifact` objects.
|
|
778
|
+
"""
|
|
779
|
+
pattern = f"{gate_id}_result.json" if gate_id else "*_result.json"
|
|
780
|
+
paths = sorted(
|
|
781
|
+
self._artifact_dir.glob(pattern),
|
|
782
|
+
key=lambda p: p.stat().st_mtime,
|
|
783
|
+
reverse=True,
|
|
784
|
+
)[:limit]
|
|
785
|
+
|
|
786
|
+
artifacts: list[GateArtifact] = []
|
|
787
|
+
for path in paths:
|
|
788
|
+
try:
|
|
789
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
790
|
+
artifacts.append(
|
|
791
|
+
GateArtifact(
|
|
792
|
+
gate_id=data.get("gate_id", path.stem.replace("_result", "")),
|
|
793
|
+
name=data.get("name", data.get("gate_id", "")),
|
|
794
|
+
verdict=data.get("verdict", GateVerdict.PASS),
|
|
795
|
+
metrics=data.get("metrics", {}),
|
|
796
|
+
timestamp=data.get("timestamp", ""),
|
|
797
|
+
duration_ms=int(data.get("duration_ms", 0)),
|
|
798
|
+
artifact_path=str(path),
|
|
799
|
+
)
|
|
800
|
+
)
|
|
801
|
+
except (json.JSONDecodeError, OSError): # noqa: PERF203
|
|
802
|
+
continue
|
|
803
|
+
return artifacts
|
|
804
|
+
|
|
805
|
+
# ------------------------------------------------------------------
|
|
806
|
+
# evaluate_async (F-10)
|
|
807
|
+
# ------------------------------------------------------------------
|
|
808
|
+
|
|
809
|
+
async def evaluate_async(
|
|
810
|
+
self,
|
|
811
|
+
gate_id: str,
|
|
812
|
+
payload: dict,
|
|
813
|
+
*,
|
|
814
|
+
project_id: str = "",
|
|
815
|
+
pipeline_id: str = "",
|
|
816
|
+
):
|
|
817
|
+
"""Async variant of :meth:`evaluate` (F-10).
|
|
818
|
+
|
|
819
|
+
Runs :meth:`evaluate` in a thread-pool executor via
|
|
820
|
+
:func:`asyncio.run_in_executor`, making it safe to ``await``
|
|
821
|
+
from async code without blocking the event loop.
|
|
822
|
+
|
|
823
|
+
Args:
|
|
824
|
+
gate_id: Gate identifier.
|
|
825
|
+
payload: Evaluation payload dict.
|
|
826
|
+
project_id: Optional project scope.
|
|
827
|
+
pipeline_id: Optional pipeline scope.
|
|
828
|
+
|
|
829
|
+
Returns:
|
|
830
|
+
:class:`~spanforge.sdk._types.GateEvaluationResult` — same as
|
|
831
|
+
:meth:`evaluate`.
|
|
832
|
+
"""
|
|
833
|
+
import asyncio
|
|
834
|
+
import functools
|
|
835
|
+
|
|
836
|
+
loop = asyncio.get_event_loop()
|
|
837
|
+
return await loop.run_in_executor(
|
|
838
|
+
None,
|
|
839
|
+
functools.partial(
|
|
840
|
+
self.evaluate,
|
|
841
|
+
gate_id,
|
|
842
|
+
payload,
|
|
843
|
+
project_id=project_id,
|
|
844
|
+
pipeline_id=pipeline_id,
|
|
845
|
+
),
|
|
846
|
+
)
|
|
847
|
+
|
|
848
|
+
# ------------------------------------------------------------------
|
|
849
|
+
# Public API — get_status
|
|
850
|
+
# ------------------------------------------------------------------
|
|
851
|
+
|
|
852
|
+
def get_status(self) -> GateStatusInfo:
|
|
853
|
+
"""Return health and statistics for sf-gate.
|
|
854
|
+
|
|
855
|
+
Returns:
|
|
856
|
+
:class:`~spanforge.sdk._types.GateStatusInfo`
|
|
857
|
+
"""
|
|
858
|
+
with self._lock:
|
|
859
|
+
evaluate_count = self._evaluate_count
|
|
860
|
+
trust_gate_count = self._trust_gate_count
|
|
861
|
+
last_evaluate_at = self._last_evaluate_at
|
|
862
|
+
cb_open = [k for k, v in self._gate_circuit_breakers.items() if v.is_open()]
|
|
863
|
+
|
|
864
|
+
artifact_count = len(list(self._artifact_dir.glob("*_result.json")))
|
|
865
|
+
|
|
866
|
+
return GateStatusInfo(
|
|
867
|
+
status="degraded" if cb_open else "ok",
|
|
868
|
+
evaluate_count=evaluate_count,
|
|
869
|
+
trust_gate_count=trust_gate_count,
|
|
870
|
+
last_evaluate_at=last_evaluate_at,
|
|
871
|
+
artifact_count=artifact_count,
|
|
872
|
+
artifact_dir=str(self._artifact_dir),
|
|
873
|
+
open_circuit_breakers=cb_open,
|
|
874
|
+
)
|