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
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""spanforge.namespaces.consent — Consent namespace payload types (RFC-0001 SPANFORGE).
|
|
2
|
+
|
|
3
|
+
Classes
|
|
4
|
+
-------
|
|
5
|
+
ConsentPayload consent.granted / consent.revoked / consent.violation
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any, Literal
|
|
12
|
+
|
|
13
|
+
__all__ = ["ConsentPayload"]
|
|
14
|
+
|
|
15
|
+
_VALID_STATUSES = frozenset({"granted", "revoked", "violation"})
|
|
16
|
+
_VALID_LEGAL_BASES = frozenset(
|
|
17
|
+
{
|
|
18
|
+
"consent",
|
|
19
|
+
"contract",
|
|
20
|
+
"legal_obligation",
|
|
21
|
+
"vital_interest",
|
|
22
|
+
"public_task",
|
|
23
|
+
"legitimate_interest",
|
|
24
|
+
}
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class ConsentPayload:
|
|
30
|
+
"""RFC-0001 SPANFORGE — payload for consent.* events.
|
|
31
|
+
|
|
32
|
+
Tracks data-subject consent grants, revocations, and boundary violations
|
|
33
|
+
for GDPR Art. 6/7 and EU AI Act compliance (U — User Rights).
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
subject_id: str
|
|
37
|
+
scope: str
|
|
38
|
+
purpose: str
|
|
39
|
+
status: Literal["granted", "revoked", "violation"]
|
|
40
|
+
legal_basis: str = "consent"
|
|
41
|
+
expiry: str | None = None # ISO 8601 timestamp
|
|
42
|
+
agent_id: str | None = None
|
|
43
|
+
violation_detail: str | None = None
|
|
44
|
+
data_categories: list[str] = field(default_factory=list)
|
|
45
|
+
|
|
46
|
+
def __post_init__(self) -> None:
|
|
47
|
+
if not self.subject_id:
|
|
48
|
+
raise ValueError("ConsentPayload.subject_id must be non-empty")
|
|
49
|
+
if not self.scope:
|
|
50
|
+
raise ValueError("ConsentPayload.scope must be non-empty")
|
|
51
|
+
if not self.purpose:
|
|
52
|
+
raise ValueError("ConsentPayload.purpose must be non-empty")
|
|
53
|
+
if self.status not in _VALID_STATUSES:
|
|
54
|
+
raise ValueError(f"ConsentPayload.status must be one of {sorted(_VALID_STATUSES)}")
|
|
55
|
+
if self.legal_basis not in _VALID_LEGAL_BASES:
|
|
56
|
+
raise ValueError(
|
|
57
|
+
f"ConsentPayload.legal_basis must be one of {sorted(_VALID_LEGAL_BASES)}"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def to_dict(self) -> dict[str, Any]:
|
|
61
|
+
"""Serialise to a plain dict."""
|
|
62
|
+
d: dict[str, Any] = {
|
|
63
|
+
"subject_id": self.subject_id,
|
|
64
|
+
"scope": self.scope,
|
|
65
|
+
"purpose": self.purpose,
|
|
66
|
+
"status": self.status,
|
|
67
|
+
"legal_basis": self.legal_basis,
|
|
68
|
+
}
|
|
69
|
+
if self.expiry is not None:
|
|
70
|
+
d["expiry"] = self.expiry
|
|
71
|
+
if self.agent_id is not None:
|
|
72
|
+
d["agent_id"] = self.agent_id
|
|
73
|
+
if self.violation_detail is not None:
|
|
74
|
+
d["violation_detail"] = self.violation_detail
|
|
75
|
+
if self.data_categories:
|
|
76
|
+
d["data_categories"] = list(self.data_categories)
|
|
77
|
+
return d
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def from_dict(cls, data: dict[str, Any]) -> ConsentPayload:
|
|
81
|
+
"""Deserialise from a plain dict."""
|
|
82
|
+
return cls(
|
|
83
|
+
subject_id=data["subject_id"],
|
|
84
|
+
scope=data["scope"],
|
|
85
|
+
purpose=data["purpose"],
|
|
86
|
+
status=data["status"],
|
|
87
|
+
legal_basis=data.get("legal_basis", "consent"),
|
|
88
|
+
expiry=data.get("expiry"),
|
|
89
|
+
agent_id=data.get("agent_id"),
|
|
90
|
+
violation_detail=data.get("violation_detail"),
|
|
91
|
+
data_categories=list(data.get("data_categories", [])),
|
|
92
|
+
)
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""spanforge.namespaces.cost — Cost payload types (RFC-0001 §9).
|
|
2
|
+
|
|
3
|
+
Classes
|
|
4
|
+
-------
|
|
5
|
+
CostTokenRecordedPayload
|
|
6
|
+
RFC §9.1 — cost recorded for a single model call.
|
|
7
|
+
CostSessionRecordedPayload
|
|
8
|
+
RFC §9.2 — aggregate cost across a session.
|
|
9
|
+
CostAttributedPayload
|
|
10
|
+
RFC §9.3 — cost attributed to a specific target.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from spanforge.namespaces.trace import (
|
|
19
|
+
CostBreakdown,
|
|
20
|
+
ModelInfo,
|
|
21
|
+
PricingTier,
|
|
22
|
+
TokenUsage,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"CostAttributedPayload",
|
|
27
|
+
"CostSessionRecordedPayload",
|
|
28
|
+
"CostTokenRecordedPayload",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
_VALID_ATTRIBUTION_TYPES = frozenset({"direct", "proportional", "estimated", "manual"})
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class CostTokenRecordedPayload:
|
|
36
|
+
"""RFC-0001 §9.1 — Cost recorded for a single model call (one span).
|
|
37
|
+
|
|
38
|
+
Used with event type: ``llm.cost.token.recorded``.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
cost: CostBreakdown
|
|
42
|
+
token_usage: TokenUsage
|
|
43
|
+
model: ModelInfo
|
|
44
|
+
pricing_tier: PricingTier | None = None
|
|
45
|
+
span_id: str | None = None
|
|
46
|
+
agent_run_id: str | None = None
|
|
47
|
+
|
|
48
|
+
def __post_init__(self) -> None:
|
|
49
|
+
if not isinstance(self.cost, CostBreakdown):
|
|
50
|
+
raise TypeError("CostTokenRecordedPayload.cost must be a CostBreakdown")
|
|
51
|
+
if not isinstance(self.token_usage, TokenUsage):
|
|
52
|
+
raise TypeError("CostTokenRecordedPayload.token_usage must be a TokenUsage")
|
|
53
|
+
if not isinstance(self.model, ModelInfo):
|
|
54
|
+
raise TypeError("CostTokenRecordedPayload.model must be a ModelInfo")
|
|
55
|
+
|
|
56
|
+
def to_dict(self) -> dict[str, Any]:
|
|
57
|
+
"""Serialise the payload to a plain ``dict``."""
|
|
58
|
+
d: dict[str, Any] = {
|
|
59
|
+
"cost": self.cost.to_dict(),
|
|
60
|
+
"token_usage": self.token_usage.to_dict(),
|
|
61
|
+
"model": self.model.to_dict(),
|
|
62
|
+
}
|
|
63
|
+
if self.pricing_tier is not None:
|
|
64
|
+
d["pricing_tier"] = self.pricing_tier.to_dict()
|
|
65
|
+
if self.span_id is not None:
|
|
66
|
+
d["span_id"] = self.span_id
|
|
67
|
+
if self.agent_run_id is not None:
|
|
68
|
+
d["agent_run_id"] = self.agent_run_id
|
|
69
|
+
return d
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def from_dict(cls, data: dict[str, Any]) -> CostTokenRecordedPayload:
|
|
73
|
+
"""Deserialise from a plain ``dict``."""
|
|
74
|
+
return cls(
|
|
75
|
+
cost=CostBreakdown.from_dict(data["cost"]),
|
|
76
|
+
token_usage=TokenUsage.from_dict(data["token_usage"]),
|
|
77
|
+
model=ModelInfo.from_dict(data["model"]),
|
|
78
|
+
pricing_tier=PricingTier.from_dict(data["pricing_tier"])
|
|
79
|
+
if "pricing_tier" in data
|
|
80
|
+
else None,
|
|
81
|
+
span_id=data.get("span_id"),
|
|
82
|
+
agent_run_id=data.get("agent_run_id"),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class CostSessionRecordedPayload:
|
|
88
|
+
"""RFC-0001 §9.2 — Aggregate cost across a session.
|
|
89
|
+
|
|
90
|
+
Used with event type: ``llm.cost.session.recorded``.
|
|
91
|
+
A session is any arbitrary grouping (user session, request batch, experiment run).
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
total_cost: CostBreakdown
|
|
95
|
+
total_token_usage: TokenUsage
|
|
96
|
+
call_count: int
|
|
97
|
+
session_duration_ms: float | None = None
|
|
98
|
+
models_used: list[str] = field(default_factory=list)
|
|
99
|
+
|
|
100
|
+
def __post_init__(self) -> None:
|
|
101
|
+
if not isinstance(self.total_cost, CostBreakdown):
|
|
102
|
+
raise TypeError("CostSessionRecordedPayload.total_cost must be a CostBreakdown")
|
|
103
|
+
if not isinstance(self.total_token_usage, TokenUsage):
|
|
104
|
+
raise TypeError("CostSessionRecordedPayload.total_token_usage must be a TokenUsage")
|
|
105
|
+
if not isinstance(self.call_count, int) or self.call_count < 0:
|
|
106
|
+
raise ValueError("CostSessionRecordedPayload.call_count must be a non-negative int")
|
|
107
|
+
if self.session_duration_ms is not None and self.session_duration_ms < 0:
|
|
108
|
+
raise ValueError("CostSessionRecordedPayload.session_duration_ms must be non-negative")
|
|
109
|
+
|
|
110
|
+
def to_dict(self) -> dict[str, Any]:
|
|
111
|
+
"""Serialise the payload to a plain ``dict``."""
|
|
112
|
+
d: dict[str, Any] = {
|
|
113
|
+
"total_cost": self.total_cost.to_dict(),
|
|
114
|
+
"total_token_usage": self.total_token_usage.to_dict(),
|
|
115
|
+
"call_count": self.call_count,
|
|
116
|
+
}
|
|
117
|
+
if self.session_duration_ms is not None:
|
|
118
|
+
d["session_duration_ms"] = self.session_duration_ms
|
|
119
|
+
if self.models_used:
|
|
120
|
+
d["models_used"] = list(self.models_used)
|
|
121
|
+
return d
|
|
122
|
+
|
|
123
|
+
@classmethod
|
|
124
|
+
def from_dict(cls, data: dict[str, Any]) -> CostSessionRecordedPayload:
|
|
125
|
+
"""Deserialise from a plain ``dict``."""
|
|
126
|
+
return cls(
|
|
127
|
+
total_cost=CostBreakdown.from_dict(data["total_cost"]),
|
|
128
|
+
total_token_usage=TokenUsage.from_dict(data["total_token_usage"]),
|
|
129
|
+
call_count=int(data["call_count"]),
|
|
130
|
+
session_duration_ms=float(data["session_duration_ms"])
|
|
131
|
+
if "session_duration_ms" in data
|
|
132
|
+
else None,
|
|
133
|
+
models_used=list(data.get("models_used", [])),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass
|
|
138
|
+
class CostAttributedPayload:
|
|
139
|
+
"""RFC-0001 §9.3 — Cost attributed to a specific target.
|
|
140
|
+
|
|
141
|
+
Used with event type: ``llm.cost.attributed``.
|
|
142
|
+
``attribution_type`` describes how the cost share was computed.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
cost: CostBreakdown
|
|
146
|
+
attribution_target: str
|
|
147
|
+
attribution_type: str # "direct"|"proportional"|"estimated"|"manual"
|
|
148
|
+
source_event_ids: list[str] = field(default_factory=list)
|
|
149
|
+
|
|
150
|
+
def __post_init__(self) -> None:
|
|
151
|
+
if not isinstance(self.cost, CostBreakdown):
|
|
152
|
+
raise TypeError("CostAttributedPayload.cost must be a CostBreakdown")
|
|
153
|
+
if not isinstance(self.attribution_target, str) or not self.attribution_target:
|
|
154
|
+
raise ValueError("CostAttributedPayload.attribution_target must be a non-empty string")
|
|
155
|
+
if self.attribution_type not in _VALID_ATTRIBUTION_TYPES:
|
|
156
|
+
raise ValueError(
|
|
157
|
+
f"CostAttributedPayload.attribution_type must be one of {sorted(_VALID_ATTRIBUTION_TYPES)}"
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
def to_dict(self) -> dict[str, Any]:
|
|
161
|
+
"""Serialise the payload to a plain ``dict``."""
|
|
162
|
+
d: dict[str, Any] = {
|
|
163
|
+
"cost": self.cost.to_dict(),
|
|
164
|
+
"attribution_target": self.attribution_target,
|
|
165
|
+
"attribution_type": self.attribution_type,
|
|
166
|
+
}
|
|
167
|
+
if self.source_event_ids:
|
|
168
|
+
d["source_event_ids"] = list(self.source_event_ids)
|
|
169
|
+
return d
|
|
170
|
+
|
|
171
|
+
@classmethod
|
|
172
|
+
def from_dict(cls, data: dict[str, Any]) -> CostAttributedPayload:
|
|
173
|
+
"""Deserialise from a plain ``dict``."""
|
|
174
|
+
return cls(
|
|
175
|
+
cost=CostBreakdown.from_dict(data["cost"]),
|
|
176
|
+
attribution_target=data["attribution_target"],
|
|
177
|
+
attribution_type=data["attribution_type"],
|
|
178
|
+
source_event_ids=list(data.get("source_event_ids", [])),
|
|
179
|
+
)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""spanforge.namespaces.decision — Decision namespace payload types (RFC-0001 SPANFORGE).
|
|
2
|
+
|
|
3
|
+
Classes
|
|
4
|
+
-------
|
|
5
|
+
DecisionDriver Factor contributing to a decision (T \u2014 Transparency)
|
|
6
|
+
DecisionPayload decision.made / decision.revised / decision.rejected
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"DecisionDriver",
|
|
16
|
+
"DecisionPayload",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
_VALID_DECISION_TYPES = frozenset(
|
|
20
|
+
{
|
|
21
|
+
"classification",
|
|
22
|
+
"routing",
|
|
23
|
+
"generation",
|
|
24
|
+
"tool_selection",
|
|
25
|
+
"other",
|
|
26
|
+
}
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class DecisionDriver:
|
|
32
|
+
"""A single factor that contributed to an agent decision (T \u2014 Transparency).
|
|
33
|
+
|
|
34
|
+
``weight`` and ``confidence`` must be in the range [0.0, 1.0].
|
|
35
|
+
The sum of all ``weight`` values in a list should equal 1.0 but is
|
|
36
|
+
not enforced here (enforcement is the caller's responsibility).
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
factor_name: str
|
|
40
|
+
weight: float # 0.0\u20131.0; fractional contribution to the overall decision
|
|
41
|
+
contribution: float # signed contribution to the final decision score
|
|
42
|
+
evidence: str # human-readable evidence string
|
|
43
|
+
confidence: float # 0.0\u20131.0
|
|
44
|
+
|
|
45
|
+
def __post_init__(self) -> None:
|
|
46
|
+
if not self.factor_name:
|
|
47
|
+
raise ValueError("DecisionDriver.factor_name must be non-empty")
|
|
48
|
+
if not (0.0 <= self.weight <= 1.0):
|
|
49
|
+
raise ValueError("DecisionDriver.weight must be in [0.0, 1.0]")
|
|
50
|
+
if not (0.0 <= self.confidence <= 1.0):
|
|
51
|
+
raise ValueError("DecisionDriver.confidence must be in [0.0, 1.0]")
|
|
52
|
+
|
|
53
|
+
def to_dict(self) -> dict[str, Any]:
|
|
54
|
+
"""Serialise to a plain dict."""
|
|
55
|
+
return {
|
|
56
|
+
"factor_name": self.factor_name,
|
|
57
|
+
"weight": self.weight,
|
|
58
|
+
"contribution": self.contribution,
|
|
59
|
+
"evidence": self.evidence,
|
|
60
|
+
"confidence": self.confidence,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def from_dict(cls, data: dict[str, Any]) -> DecisionDriver:
|
|
65
|
+
"""Deserialise from a plain dict."""
|
|
66
|
+
return cls(
|
|
67
|
+
factor_name=data["factor_name"],
|
|
68
|
+
weight=float(data["weight"]),
|
|
69
|
+
contribution=float(data["contribution"]),
|
|
70
|
+
evidence=data["evidence"],
|
|
71
|
+
confidence=float(data["confidence"]),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class DecisionPayload:
|
|
77
|
+
"""RFC-0001 SPANFORGE \u2014 payload for decision.* events.
|
|
78
|
+
|
|
79
|
+
Captures every individual agent decision at inference time, including
|
|
80
|
+
the full set of contributing decision drivers for T \u2014 Transparency.
|
|
81
|
+
|
|
82
|
+
``actor`` is an optional dict representation of an ActorContext and is
|
|
83
|
+
intentionally typed as ``dict | None`` to avoid a circular import.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
decision_id: str # ULID
|
|
87
|
+
agent_id: str
|
|
88
|
+
decision_type: str # classification | routing | generation | tool_selection | other
|
|
89
|
+
input_summary: str
|
|
90
|
+
output_summary: str
|
|
91
|
+
confidence: float # 0.0\u20131.0
|
|
92
|
+
latency_ms: float
|
|
93
|
+
rationale_hash: str # SHA-256 of the full rationale text
|
|
94
|
+
decision_drivers: list[DecisionDriver] = field(default_factory=list)
|
|
95
|
+
actor: dict[str, Any] | None = None
|
|
96
|
+
|
|
97
|
+
def __post_init__(self) -> None:
|
|
98
|
+
if not self.decision_id:
|
|
99
|
+
raise ValueError("DecisionPayload.decision_id must be non-empty")
|
|
100
|
+
if not self.agent_id:
|
|
101
|
+
raise ValueError("DecisionPayload.agent_id must be non-empty")
|
|
102
|
+
if self.decision_type not in _VALID_DECISION_TYPES:
|
|
103
|
+
raise ValueError(
|
|
104
|
+
f"DecisionPayload.decision_type must be one of {sorted(_VALID_DECISION_TYPES)}"
|
|
105
|
+
)
|
|
106
|
+
if not (0.0 <= self.confidence <= 1.0):
|
|
107
|
+
raise ValueError("DecisionPayload.confidence must be in [0.0, 1.0]")
|
|
108
|
+
if self.latency_ms < 0:
|
|
109
|
+
raise ValueError("DecisionPayload.latency_ms must be >= 0")
|
|
110
|
+
|
|
111
|
+
def to_dict(self) -> dict[str, Any]:
|
|
112
|
+
"""Serialise to a plain dict."""
|
|
113
|
+
d: dict[str, Any] = {
|
|
114
|
+
"decision_id": self.decision_id,
|
|
115
|
+
"agent_id": self.agent_id,
|
|
116
|
+
"decision_type": self.decision_type,
|
|
117
|
+
"input_summary": self.input_summary,
|
|
118
|
+
"output_summary": self.output_summary,
|
|
119
|
+
"confidence": self.confidence,
|
|
120
|
+
"latency_ms": self.latency_ms,
|
|
121
|
+
"rationale_hash": self.rationale_hash,
|
|
122
|
+
"decision_drivers": [d.to_dict() for d in self.decision_drivers],
|
|
123
|
+
}
|
|
124
|
+
if self.actor is not None:
|
|
125
|
+
d["actor"] = self.actor
|
|
126
|
+
return d
|
|
127
|
+
|
|
128
|
+
@classmethod
|
|
129
|
+
def from_dict(cls, data: dict[str, Any]) -> DecisionPayload:
|
|
130
|
+
"""Deserialise from a plain dict."""
|
|
131
|
+
drivers = [DecisionDriver.from_dict(dd) for dd in data.get("decision_drivers", [])]
|
|
132
|
+
return cls(
|
|
133
|
+
decision_id=data["decision_id"],
|
|
134
|
+
agent_id=data["agent_id"],
|
|
135
|
+
decision_type=data["decision_type"],
|
|
136
|
+
input_summary=data["input_summary"],
|
|
137
|
+
output_summary=data["output_summary"],
|
|
138
|
+
confidence=float(data["confidence"]),
|
|
139
|
+
latency_ms=float(data["latency_ms"]),
|
|
140
|
+
rationale_hash=data["rationale_hash"],
|
|
141
|
+
decision_drivers=drivers,
|
|
142
|
+
actor=data.get("actor"),
|
|
143
|
+
)
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""spanforge.namespaces.diff — Diff payload types (RFC-0001).
|
|
2
|
+
|
|
3
|
+
Classes
|
|
4
|
+
-------
|
|
5
|
+
DiffComputedPayload llm.diff.computed
|
|
6
|
+
DiffRegressionFlaggedPayload llm.diff.regression.flagged
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"DiffComputedPayload",
|
|
16
|
+
"DiffRegressionFlaggedPayload",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
_VALID_DIFF_TYPES = frozenset({"prompt", "response", "template", "token_usage", "cost"})
|
|
20
|
+
_VALID_ALGORITHMS = frozenset(
|
|
21
|
+
{"embedding_cosine", "levenshtein", "token_edit", "lcs", "semantic_embedding"}
|
|
22
|
+
)
|
|
23
|
+
_VALID_SEVERITIES = frozenset({"low", "medium", "high", "critical"})
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class DiffComputedPayload:
|
|
28
|
+
"""RFC-0001 — A diff was computed between two events."""
|
|
29
|
+
|
|
30
|
+
ref_event_id: str
|
|
31
|
+
target_event_id: str
|
|
32
|
+
diff_type: str # "prompt"|"response"|"template"|"token_usage"|"cost"
|
|
33
|
+
similarity_score: float
|
|
34
|
+
added_tokens: int | None = None
|
|
35
|
+
removed_tokens: int | None = None
|
|
36
|
+
diff_algorithm: str | None = None
|
|
37
|
+
ref_content_hash: str | None = None # 64 hex chars
|
|
38
|
+
target_content_hash: str | None = None # 64 hex chars
|
|
39
|
+
computation_duration_ms: float | None = None
|
|
40
|
+
|
|
41
|
+
def __post_init__(self) -> None:
|
|
42
|
+
if not self.ref_event_id:
|
|
43
|
+
raise ValueError("DiffComputedPayload.ref_event_id must be non-empty")
|
|
44
|
+
if not self.target_event_id:
|
|
45
|
+
raise ValueError("DiffComputedPayload.target_event_id must be non-empty")
|
|
46
|
+
if self.diff_type not in _VALID_DIFF_TYPES:
|
|
47
|
+
raise ValueError(
|
|
48
|
+
f"DiffComputedPayload.diff_type must be one of {sorted(_VALID_DIFF_TYPES)}"
|
|
49
|
+
)
|
|
50
|
+
if not (0.0 <= self.similarity_score <= 1.0):
|
|
51
|
+
raise ValueError("DiffComputedPayload.similarity_score must be in [0,1]")
|
|
52
|
+
if self.diff_algorithm is not None and self.diff_algorithm not in _VALID_ALGORITHMS:
|
|
53
|
+
raise ValueError(
|
|
54
|
+
f"DiffComputedPayload.diff_algorithm must be one of {sorted(_VALID_ALGORITHMS)}"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def to_dict(self) -> dict[str, Any]:
|
|
58
|
+
"""Serialise the payload to a plain ``dict``."""
|
|
59
|
+
d: dict[str, Any] = {
|
|
60
|
+
"ref_event_id": self.ref_event_id,
|
|
61
|
+
"target_event_id": self.target_event_id,
|
|
62
|
+
"diff_type": self.diff_type,
|
|
63
|
+
"similarity_score": self.similarity_score,
|
|
64
|
+
}
|
|
65
|
+
if self.added_tokens is not None:
|
|
66
|
+
d["added_tokens"] = self.added_tokens
|
|
67
|
+
if self.removed_tokens is not None:
|
|
68
|
+
d["removed_tokens"] = self.removed_tokens
|
|
69
|
+
if self.diff_algorithm is not None:
|
|
70
|
+
d["diff_algorithm"] = self.diff_algorithm
|
|
71
|
+
if self.ref_content_hash is not None:
|
|
72
|
+
d["ref_content_hash"] = self.ref_content_hash
|
|
73
|
+
if self.target_content_hash is not None:
|
|
74
|
+
d["target_content_hash"] = self.target_content_hash
|
|
75
|
+
if self.computation_duration_ms is not None:
|
|
76
|
+
d["computation_duration_ms"] = self.computation_duration_ms
|
|
77
|
+
return d
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def from_dict(cls, data: dict[str, Any]) -> DiffComputedPayload:
|
|
81
|
+
"""Deserialise from a plain ``dict``."""
|
|
82
|
+
return cls(
|
|
83
|
+
ref_event_id=data["ref_event_id"],
|
|
84
|
+
target_event_id=data["target_event_id"],
|
|
85
|
+
diff_type=data["diff_type"],
|
|
86
|
+
similarity_score=float(data["similarity_score"]),
|
|
87
|
+
added_tokens=int(data["added_tokens"]) if "added_tokens" in data else None,
|
|
88
|
+
removed_tokens=int(data["removed_tokens"]) if "removed_tokens" in data else None,
|
|
89
|
+
diff_algorithm=data.get("diff_algorithm"),
|
|
90
|
+
ref_content_hash=data.get("ref_content_hash"),
|
|
91
|
+
target_content_hash=data.get("target_content_hash"),
|
|
92
|
+
computation_duration_ms=float(data["computation_duration_ms"])
|
|
93
|
+
if "computation_duration_ms" in data
|
|
94
|
+
else None,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class DiffRegressionFlaggedPayload:
|
|
100
|
+
"""RFC-0001 — A diff score fell below the similarity threshold."""
|
|
101
|
+
|
|
102
|
+
ref_event_id: str
|
|
103
|
+
target_event_id: str
|
|
104
|
+
diff_type: str
|
|
105
|
+
similarity_score: float
|
|
106
|
+
threshold: float
|
|
107
|
+
severity: str # "low"|"medium"|"high"|"critical"
|
|
108
|
+
diff_event_id: str | None = None
|
|
109
|
+
alert_target: str | None = None
|
|
110
|
+
|
|
111
|
+
def __post_init__(self) -> None:
|
|
112
|
+
if not self.ref_event_id:
|
|
113
|
+
raise ValueError("DiffRegressionFlaggedPayload.ref_event_id must be non-empty")
|
|
114
|
+
if not self.target_event_id:
|
|
115
|
+
raise ValueError("DiffRegressionFlaggedPayload.target_event_id must be non-empty")
|
|
116
|
+
if self.diff_type not in _VALID_DIFF_TYPES:
|
|
117
|
+
raise ValueError(
|
|
118
|
+
f"DiffRegressionFlaggedPayload.diff_type must be one of {sorted(_VALID_DIFF_TYPES)}"
|
|
119
|
+
)
|
|
120
|
+
if not (0.0 <= self.similarity_score <= 1.0):
|
|
121
|
+
raise ValueError("DiffRegressionFlaggedPayload.similarity_score must be in [0,1]")
|
|
122
|
+
if not (0.0 <= self.threshold <= 1.0):
|
|
123
|
+
raise ValueError("DiffRegressionFlaggedPayload.threshold must be in [0,1]")
|
|
124
|
+
if self.severity not in _VALID_SEVERITIES:
|
|
125
|
+
raise ValueError(
|
|
126
|
+
f"DiffRegressionFlaggedPayload.severity must be one of {sorted(_VALID_SEVERITIES)}"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def to_dict(self) -> dict[str, Any]:
|
|
130
|
+
"""Serialise the payload to a plain ``dict``."""
|
|
131
|
+
d: dict[str, Any] = {
|
|
132
|
+
"ref_event_id": self.ref_event_id,
|
|
133
|
+
"target_event_id": self.target_event_id,
|
|
134
|
+
"diff_type": self.diff_type,
|
|
135
|
+
"similarity_score": self.similarity_score,
|
|
136
|
+
"threshold": self.threshold,
|
|
137
|
+
"severity": self.severity,
|
|
138
|
+
}
|
|
139
|
+
if self.diff_event_id is not None:
|
|
140
|
+
d["diff_event_id"] = self.diff_event_id
|
|
141
|
+
if self.alert_target is not None:
|
|
142
|
+
d["alert_target"] = self.alert_target
|
|
143
|
+
return d
|
|
144
|
+
|
|
145
|
+
@classmethod
|
|
146
|
+
def from_dict(cls, data: dict[str, Any]) -> DiffRegressionFlaggedPayload:
|
|
147
|
+
"""Deserialise from a plain ``dict``."""
|
|
148
|
+
return cls(
|
|
149
|
+
ref_event_id=data["ref_event_id"],
|
|
150
|
+
target_event_id=data["target_event_id"],
|
|
151
|
+
diff_type=data["diff_type"],
|
|
152
|
+
similarity_score=float(data["similarity_score"]),
|
|
153
|
+
threshold=float(data["threshold"]),
|
|
154
|
+
severity=data["severity"],
|
|
155
|
+
diff_event_id=data.get("diff_event_id"),
|
|
156
|
+
alert_target=data.get("alert_target"),
|
|
157
|
+
)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""spanforge.namespaces.drift \u2014 Drift namespace payload types (RFC-0001 SPANFORGE).
|
|
2
|
+
|
|
3
|
+
Classes
|
|
4
|
+
-------
|
|
5
|
+
DriftPayload drift.detected / drift.threshold_breach / drift.resolved
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any, Literal
|
|
12
|
+
|
|
13
|
+
__all__ = ["DriftPayload"]
|
|
14
|
+
|
|
15
|
+
_VALID_STATUSES = frozenset({"detected", "threshold_breach", "resolved"})
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class DriftPayload:
|
|
20
|
+
"""RFC-0001 SPANFORGE \u2014 payload for drift.* events.
|
|
21
|
+
|
|
22
|
+
Captures Z-score and KL-divergence statistical drift signals against the
|
|
23
|
+
deployment baseline (T \u2014 Traceability).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
metric_name: str
|
|
27
|
+
agent_id: str
|
|
28
|
+
current_value: float
|
|
29
|
+
baseline_mean: float
|
|
30
|
+
baseline_stddev: float
|
|
31
|
+
z_score: float
|
|
32
|
+
threshold: float
|
|
33
|
+
window_seconds: int
|
|
34
|
+
status: Literal["detected", "threshold_breach", "resolved"]
|
|
35
|
+
kl_divergence: float | None = None
|
|
36
|
+
|
|
37
|
+
def __post_init__(self) -> None:
|
|
38
|
+
if not self.metric_name:
|
|
39
|
+
raise ValueError("DriftPayload.metric_name must be non-empty")
|
|
40
|
+
if not self.agent_id:
|
|
41
|
+
raise ValueError("DriftPayload.agent_id must be non-empty")
|
|
42
|
+
if self.status not in _VALID_STATUSES:
|
|
43
|
+
raise ValueError(f"DriftPayload.status must be one of {sorted(_VALID_STATUSES)}")
|
|
44
|
+
if self.window_seconds <= 0:
|
|
45
|
+
raise ValueError("DriftPayload.window_seconds must be > 0")
|
|
46
|
+
if self.baseline_stddev < 0:
|
|
47
|
+
raise ValueError("DriftPayload.baseline_stddev must be >= 0")
|
|
48
|
+
|
|
49
|
+
def to_dict(self) -> dict[str, Any]:
|
|
50
|
+
"""Serialise to a plain dict."""
|
|
51
|
+
d: dict[str, Any] = {
|
|
52
|
+
"metric_name": self.metric_name,
|
|
53
|
+
"agent_id": self.agent_id,
|
|
54
|
+
"current_value": self.current_value,
|
|
55
|
+
"baseline_mean": self.baseline_mean,
|
|
56
|
+
"baseline_stddev": self.baseline_stddev,
|
|
57
|
+
"z_score": self.z_score,
|
|
58
|
+
"threshold": self.threshold,
|
|
59
|
+
"window_seconds": self.window_seconds,
|
|
60
|
+
"status": self.status,
|
|
61
|
+
}
|
|
62
|
+
if self.kl_divergence is not None:
|
|
63
|
+
d["kl_divergence"] = self.kl_divergence
|
|
64
|
+
return d
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def from_dict(cls, data: dict[str, Any]) -> DriftPayload:
|
|
68
|
+
"""Deserialise from a plain dict."""
|
|
69
|
+
return cls(
|
|
70
|
+
metric_name=data["metric_name"],
|
|
71
|
+
agent_id=data["agent_id"],
|
|
72
|
+
current_value=float(data["current_value"]),
|
|
73
|
+
baseline_mean=float(data["baseline_mean"]),
|
|
74
|
+
baseline_stddev=float(data["baseline_stddev"]),
|
|
75
|
+
z_score=float(data["z_score"]),
|
|
76
|
+
threshold=float(data["threshold"]),
|
|
77
|
+
window_seconds=int(data["window_seconds"]),
|
|
78
|
+
status=data["status"],
|
|
79
|
+
kl_divergence=data.get("kl_divergence"),
|
|
80
|
+
)
|