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.
Files changed (174) hide show
  1. spanforge/__init__.py +815 -0
  2. spanforge/_ansi.py +93 -0
  3. spanforge/_batch_exporter.py +409 -0
  4. spanforge/_cli.py +2094 -0
  5. spanforge/_cli_audit.py +639 -0
  6. spanforge/_cli_compliance.py +711 -0
  7. spanforge/_cli_cost.py +243 -0
  8. spanforge/_cli_ops.py +791 -0
  9. spanforge/_cli_phase11.py +356 -0
  10. spanforge/_hooks.py +337 -0
  11. spanforge/_server.py +1708 -0
  12. spanforge/_span.py +1036 -0
  13. spanforge/_store.py +288 -0
  14. spanforge/_stream.py +664 -0
  15. spanforge/_trace.py +335 -0
  16. spanforge/_tracer.py +254 -0
  17. spanforge/actor.py +141 -0
  18. spanforge/alerts.py +469 -0
  19. spanforge/auto.py +464 -0
  20. spanforge/baseline.py +335 -0
  21. spanforge/cache.py +635 -0
  22. spanforge/compliance.py +325 -0
  23. spanforge/config.py +532 -0
  24. spanforge/consent.py +228 -0
  25. spanforge/consumer.py +377 -0
  26. spanforge/core/__init__.py +5 -0
  27. spanforge/core/compliance_mapping.py +1254 -0
  28. spanforge/cost.py +600 -0
  29. spanforge/debug.py +548 -0
  30. spanforge/deprecations.py +205 -0
  31. spanforge/drift.py +482 -0
  32. spanforge/egress.py +58 -0
  33. spanforge/eval.py +648 -0
  34. spanforge/event.py +1064 -0
  35. spanforge/exceptions.py +240 -0
  36. spanforge/explain.py +178 -0
  37. spanforge/export/__init__.py +69 -0
  38. spanforge/export/append_only.py +337 -0
  39. spanforge/export/cloud.py +357 -0
  40. spanforge/export/datadog.py +497 -0
  41. spanforge/export/grafana.py +320 -0
  42. spanforge/export/jsonl.py +195 -0
  43. spanforge/export/openinference.py +158 -0
  44. spanforge/export/otel_bridge.py +294 -0
  45. spanforge/export/otlp.py +811 -0
  46. spanforge/export/otlp_bridge.py +233 -0
  47. spanforge/export/redis_backend.py +282 -0
  48. spanforge/export/siem_schema.py +98 -0
  49. spanforge/export/siem_splunk.py +264 -0
  50. spanforge/export/siem_syslog.py +212 -0
  51. spanforge/export/webhook.py +299 -0
  52. spanforge/exporters/__init__.py +30 -0
  53. spanforge/exporters/console.py +271 -0
  54. spanforge/exporters/jsonl.py +144 -0
  55. spanforge/exporters/sqlite.py +142 -0
  56. spanforge/gate.py +1150 -0
  57. spanforge/governance.py +181 -0
  58. spanforge/hitl.py +295 -0
  59. spanforge/http.py +187 -0
  60. spanforge/inspect.py +427 -0
  61. spanforge/integrations/__init__.py +45 -0
  62. spanforge/integrations/_pricing.py +280 -0
  63. spanforge/integrations/anthropic.py +388 -0
  64. spanforge/integrations/azure_openai.py +133 -0
  65. spanforge/integrations/bedrock.py +292 -0
  66. spanforge/integrations/crewai.py +251 -0
  67. spanforge/integrations/gemini.py +351 -0
  68. spanforge/integrations/groq.py +442 -0
  69. spanforge/integrations/langchain.py +349 -0
  70. spanforge/integrations/langgraph.py +306 -0
  71. spanforge/integrations/llamaindex.py +373 -0
  72. spanforge/integrations/ollama.py +287 -0
  73. spanforge/integrations/openai.py +368 -0
  74. spanforge/integrations/together.py +483 -0
  75. spanforge/io.py +214 -0
  76. spanforge/lint.py +322 -0
  77. spanforge/metrics.py +417 -0
  78. spanforge/metrics_export.py +343 -0
  79. spanforge/migrate.py +402 -0
  80. spanforge/model_registry.py +278 -0
  81. spanforge/models.py +389 -0
  82. spanforge/namespaces/__init__.py +254 -0
  83. spanforge/namespaces/audit.py +256 -0
  84. spanforge/namespaces/cache.py +237 -0
  85. spanforge/namespaces/chain.py +77 -0
  86. spanforge/namespaces/confidence.py +72 -0
  87. spanforge/namespaces/consent.py +92 -0
  88. spanforge/namespaces/cost.py +179 -0
  89. spanforge/namespaces/decision.py +143 -0
  90. spanforge/namespaces/diff.py +157 -0
  91. spanforge/namespaces/drift.py +80 -0
  92. spanforge/namespaces/eval_.py +251 -0
  93. spanforge/namespaces/feedback.py +241 -0
  94. spanforge/namespaces/fence.py +193 -0
  95. spanforge/namespaces/guard.py +105 -0
  96. spanforge/namespaces/hitl.py +91 -0
  97. spanforge/namespaces/latency.py +72 -0
  98. spanforge/namespaces/prompt.py +190 -0
  99. spanforge/namespaces/redact.py +173 -0
  100. spanforge/namespaces/retrieval.py +379 -0
  101. spanforge/namespaces/runtime_governance.py +494 -0
  102. spanforge/namespaces/template.py +208 -0
  103. spanforge/namespaces/tool_call.py +77 -0
  104. spanforge/namespaces/trace.py +1029 -0
  105. spanforge/normalizer.py +171 -0
  106. spanforge/plugins.py +82 -0
  107. spanforge/presidio_backend.py +349 -0
  108. spanforge/processor.py +258 -0
  109. spanforge/prompt_registry.py +418 -0
  110. spanforge/py.typed +0 -0
  111. spanforge/redact.py +914 -0
  112. spanforge/regression.py +192 -0
  113. spanforge/runtime_policy.py +159 -0
  114. spanforge/sampling.py +511 -0
  115. spanforge/schema.py +183 -0
  116. spanforge/schemas/v1.0/schema.json +170 -0
  117. spanforge/schemas/v2.0/schema.json +536 -0
  118. spanforge/sdk/__init__.py +625 -0
  119. spanforge/sdk/_base.py +584 -0
  120. spanforge/sdk/_base.pyi +71 -0
  121. spanforge/sdk/_exceptions.py +1096 -0
  122. spanforge/sdk/_types.py +2184 -0
  123. spanforge/sdk/alert.py +1514 -0
  124. spanforge/sdk/alert.pyi +56 -0
  125. spanforge/sdk/audit.py +1196 -0
  126. spanforge/sdk/audit.pyi +67 -0
  127. spanforge/sdk/cec.py +1215 -0
  128. spanforge/sdk/cec.pyi +37 -0
  129. spanforge/sdk/config.py +641 -0
  130. spanforge/sdk/config.pyi +55 -0
  131. spanforge/sdk/enterprise.py +714 -0
  132. spanforge/sdk/enterprise.pyi +79 -0
  133. spanforge/sdk/explain.py +170 -0
  134. spanforge/sdk/fallback.py +432 -0
  135. spanforge/sdk/feedback.py +351 -0
  136. spanforge/sdk/gate.py +874 -0
  137. spanforge/sdk/gate.pyi +51 -0
  138. spanforge/sdk/identity.py +2114 -0
  139. spanforge/sdk/identity.pyi +47 -0
  140. spanforge/sdk/lineage.py +175 -0
  141. spanforge/sdk/observe.py +1065 -0
  142. spanforge/sdk/observe.pyi +50 -0
  143. spanforge/sdk/operator.py +338 -0
  144. spanforge/sdk/pii.py +1473 -0
  145. spanforge/sdk/pii.pyi +119 -0
  146. spanforge/sdk/pipelines.py +458 -0
  147. spanforge/sdk/pipelines.pyi +39 -0
  148. spanforge/sdk/policy.py +930 -0
  149. spanforge/sdk/rag.py +594 -0
  150. spanforge/sdk/rbac.py +280 -0
  151. spanforge/sdk/registry.py +430 -0
  152. spanforge/sdk/registry.pyi +46 -0
  153. spanforge/sdk/scope.py +279 -0
  154. spanforge/sdk/secrets.py +293 -0
  155. spanforge/sdk/secrets.pyi +25 -0
  156. spanforge/sdk/security.py +560 -0
  157. spanforge/sdk/security.pyi +57 -0
  158. spanforge/sdk/trust.py +472 -0
  159. spanforge/sdk/trust.pyi +41 -0
  160. spanforge/secrets.py +799 -0
  161. spanforge/signing.py +1179 -0
  162. spanforge/stats.py +100 -0
  163. spanforge/stream.py +560 -0
  164. spanforge/testing.py +378 -0
  165. spanforge/testing_mocks.py +1052 -0
  166. spanforge/trace.py +199 -0
  167. spanforge/types.py +696 -0
  168. spanforge/ulid.py +300 -0
  169. spanforge/validate.py +379 -0
  170. spanforge-1.0.0.dist-info/METADATA +1509 -0
  171. spanforge-1.0.0.dist-info/RECORD +174 -0
  172. spanforge-1.0.0.dist-info/WHEEL +4 -0
  173. spanforge-1.0.0.dist-info/entry_points.txt +5 -0
  174. spanforge-1.0.0.dist-info/licenses/LICENSE +128 -0
@@ -0,0 +1,379 @@
1
+ """spanforge.namespaces.retrieval — RAG retrieval namespace payload types.
2
+
3
+ Provides dataclasses for the ``llm.rag.*`` event namespace, covering all
4
+ phases of a Retrieval-Augmented Generation pipeline:
5
+
6
+ 1. **Query** — the user query forwarded to the retriever.
7
+ 2. **Retrieval** — the documents / chunks fetched from the vector store.
8
+ 3. **Generation** — the LLM generation that consumes retrieved context.
9
+ 4. **Session** — end-to-end RAG session summary.
10
+
11
+ Classes
12
+ -------
13
+ RetrievalQueryPayload
14
+ ``llm.rag.query`` events — user query + retriever config.
15
+ RetrievalResultPayload
16
+ ``llm.rag.retrieved`` events — retrieved chunks with scores.
17
+ RAGSpanPayload
18
+ ``llm.rag.generated`` events — LLM generation span over retrieved context.
19
+ RAGSessionPayload
20
+ ``llm.rag.session`` events — root summary for a complete RAG interaction.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from dataclasses import dataclass, field
26
+ from typing import Any, Literal
27
+
28
+ __all__ = [
29
+ "RAGSessionPayload",
30
+ "RAGSpanPayload",
31
+ "RetrievalQueryPayload",
32
+ "RetrievalResultPayload",
33
+ "RetrievedChunk",
34
+ ]
35
+
36
+ _VALID_STATUSES: frozenset[str] = frozenset({"ok", "error", "timeout", "partial"})
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Value objects
41
+ # ---------------------------------------------------------------------------
42
+
43
+
44
+ @dataclass
45
+ class RetrievedChunk:
46
+ """A single retrieved document chunk with its relevance score.
47
+
48
+ Attributes:
49
+ chunk_id: Unique identifier for the chunk within the document store.
50
+ content_hash: SHA-256 hex digest of the chunk text (raw text NOT stored).
51
+ score: Relevance / similarity score in [0.0, 1.0].
52
+ source: Document source identifier (e.g. URI, filename, database key).
53
+ metadata: Arbitrary key-value metadata attached to the chunk.
54
+ """
55
+
56
+ chunk_id: str
57
+ content_hash: str
58
+ score: float
59
+ source: str = ""
60
+ metadata: dict[str, Any] = field(default_factory=dict)
61
+
62
+ def __post_init__(self) -> None:
63
+ if not self.chunk_id:
64
+ raise ValueError("RetrievedChunk.chunk_id must be non-empty")
65
+ if not (0.0 <= self.score <= 1.0):
66
+ raise ValueError(f"RetrievedChunk.score must be in [0, 1]; got {self.score}")
67
+
68
+ def to_dict(self) -> dict[str, Any]:
69
+ """Serialise to a plain dict."""
70
+ return {
71
+ "chunk_id": self.chunk_id,
72
+ "content_hash": self.content_hash,
73
+ "score": self.score,
74
+ "source": self.source,
75
+ "metadata": self.metadata,
76
+ }
77
+
78
+ @classmethod
79
+ def from_dict(cls, data: dict[str, Any]) -> RetrievedChunk:
80
+ """Deserialise from a plain dict."""
81
+ return cls(
82
+ chunk_id=str(data["chunk_id"]),
83
+ content_hash=str(data.get("content_hash", "")),
84
+ score=float(data["score"]),
85
+ source=str(data.get("source", "")),
86
+ metadata=dict(data.get("metadata", {})),
87
+ )
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # Payload dataclasses
92
+ # ---------------------------------------------------------------------------
93
+
94
+
95
+ @dataclass
96
+ class RetrievalQueryPayload:
97
+ """Payload for ``llm.rag.query`` events.
98
+
99
+ Captures the user query and the retriever configuration at the time of
100
+ the query without storing raw query text.
101
+
102
+ Attributes:
103
+ session_id: RAG session this query belongs to.
104
+ query_hash: SHA-256 hex digest of the query text (text NOT stored).
105
+ top_k: Number of chunks requested from the retriever.
106
+ retriever_name: Name / identifier of the vector store or retriever.
107
+ embedding_model: Embedding model used to encode the query.
108
+ namespace: Optional vector store namespace / collection.
109
+ latency_ms: Time taken to submit the query (ms).
110
+ filters: Metadata filters applied to the retrieval query.
111
+ """
112
+
113
+ session_id: str
114
+ query_hash: str
115
+ top_k: int = 5
116
+ retriever_name: str = ""
117
+ embedding_model: str = ""
118
+ namespace: str = ""
119
+ latency_ms: float = 0.0
120
+ filters: dict[str, Any] = field(default_factory=dict)
121
+
122
+ def __post_init__(self) -> None:
123
+ if not self.session_id:
124
+ raise ValueError("RetrievalQueryPayload.session_id must be non-empty")
125
+ if self.top_k < 1:
126
+ raise ValueError(f"RetrievalQueryPayload.top_k must be >= 1; got {self.top_k}")
127
+ if self.latency_ms < 0:
128
+ raise ValueError("RetrievalQueryPayload.latency_ms must be >= 0")
129
+
130
+ def to_dict(self) -> dict[str, Any]:
131
+ """Serialise to a plain dict."""
132
+ return {
133
+ "session_id": self.session_id,
134
+ "query_hash": self.query_hash,
135
+ "top_k": self.top_k,
136
+ "retriever_name": self.retriever_name,
137
+ "embedding_model": self.embedding_model,
138
+ "namespace": self.namespace,
139
+ "latency_ms": self.latency_ms,
140
+ "filters": self.filters,
141
+ }
142
+
143
+ @classmethod
144
+ def from_dict(cls, data: dict[str, Any]) -> RetrievalQueryPayload:
145
+ """Deserialise from a plain dict."""
146
+ return cls(
147
+ session_id=str(data["session_id"]),
148
+ query_hash=str(data.get("query_hash", "")),
149
+ top_k=int(data.get("top_k", 5)),
150
+ retriever_name=str(data.get("retriever_name", "")),
151
+ embedding_model=str(data.get("embedding_model", "")),
152
+ namespace=str(data.get("namespace", "")),
153
+ latency_ms=float(data.get("latency_ms", 0.0)),
154
+ filters=dict(data.get("filters", {})),
155
+ )
156
+
157
+
158
+ @dataclass
159
+ class RetrievalResultPayload:
160
+ """Payload for ``llm.rag.retrieved`` events.
161
+
162
+ Attributes:
163
+ session_id: RAG session this result belongs to.
164
+ query_hash: SHA-256 hex digest of the triggering query.
165
+ chunks: Ordered list of retrieved chunks.
166
+ total_found: Total number of matching chunks before ``top_k`` truncation.
167
+ latency_ms: Time taken for the retrieval (ms).
168
+ status: Retrieval status: ``"ok"``, ``"partial"``, ``"error"``,
169
+ or ``"timeout"``.
170
+ error_message: Present when *status* is ``"error"`` or ``"timeout"``.
171
+ """
172
+
173
+ session_id: str
174
+ query_hash: str
175
+ chunks: list[RetrievedChunk] = field(default_factory=list)
176
+ total_found: int = 0
177
+ latency_ms: float = 0.0
178
+ status: Literal["ok", "partial", "error", "timeout"] = "ok"
179
+ error_message: str | None = None
180
+
181
+ def __post_init__(self) -> None:
182
+ if not self.session_id:
183
+ raise ValueError("RetrievalResultPayload.session_id must be non-empty")
184
+ if self.status not in _VALID_STATUSES:
185
+ raise ValueError(
186
+ f"RetrievalResultPayload.status must be one of {sorted(_VALID_STATUSES)}"
187
+ )
188
+ if self.latency_ms < 0:
189
+ raise ValueError("RetrievalResultPayload.latency_ms must be >= 0")
190
+
191
+ def to_dict(self) -> dict[str, Any]:
192
+ """Serialise to a plain dict."""
193
+ d: dict[str, Any] = {
194
+ "session_id": self.session_id,
195
+ "query_hash": self.query_hash,
196
+ "chunks": [c.to_dict() for c in self.chunks],
197
+ "total_found": self.total_found,
198
+ "latency_ms": self.latency_ms,
199
+ "status": self.status,
200
+ }
201
+ if self.error_message is not None:
202
+ d["error_message"] = self.error_message
203
+ return d
204
+
205
+ @classmethod
206
+ def from_dict(cls, data: dict[str, Any]) -> RetrievalResultPayload:
207
+ """Deserialise from a plain dict."""
208
+ return cls(
209
+ session_id=str(data["session_id"]),
210
+ query_hash=str(data.get("query_hash", "")),
211
+ chunks=[RetrievedChunk.from_dict(c) for c in data.get("chunks", [])],
212
+ total_found=int(data.get("total_found", 0)),
213
+ latency_ms=float(data.get("latency_ms", 0.0)),
214
+ status=data.get("status", "ok"),
215
+ error_message=data.get("error_message"),
216
+ )
217
+
218
+
219
+ @dataclass
220
+ class RAGSpanPayload:
221
+ """Payload for ``llm.rag.generated`` events.
222
+
223
+ Represents the LLM generation step that consumes retrieved context.
224
+
225
+ Attributes:
226
+ session_id: RAG session this span belongs to.
227
+ span_name: Human-readable label for the generation step.
228
+ model: Model identifier (e.g. ``"gpt-4o"``).
229
+ chunk_ids_used: Identifiers of the chunks included in the context window.
230
+ context_tokens: Number of tokens consumed by the retrieved context.
231
+ prompt_tokens: Number of tokens in the full prompt (context + instruction).
232
+ output_tokens: Number of tokens in the generated response.
233
+ latency_ms: Total generation latency in milliseconds.
234
+ status: Generation status.
235
+ grounding_score: Optional 0.0–1.0 score measuring how well the output
236
+ is grounded in the retrieved context.
237
+ error_message: Present when *status* is not ``"ok"``.
238
+ """
239
+
240
+ session_id: str
241
+ span_name: str
242
+ model: str
243
+ chunk_ids_used: list[str] = field(default_factory=list)
244
+ context_tokens: int = 0
245
+ prompt_tokens: int = 0
246
+ output_tokens: int = 0
247
+ latency_ms: float = 0.0
248
+ status: Literal["ok", "error", "timeout"] = "ok"
249
+ grounding_score: float | None = None
250
+ error_message: str | None = None
251
+
252
+ def __post_init__(self) -> None:
253
+ if not self.session_id:
254
+ raise ValueError("RAGSpanPayload.session_id must be non-empty")
255
+ if not self.model:
256
+ raise ValueError("RAGSpanPayload.model must be non-empty")
257
+ if self.status not in {"ok", "error", "timeout"}:
258
+ raise ValueError(
259
+ f"RAGSpanPayload.status must be 'ok', 'error', or 'timeout'; got {self.status!r}"
260
+ )
261
+ if self.latency_ms < 0:
262
+ raise ValueError("RAGSpanPayload.latency_ms must be >= 0")
263
+ if self.grounding_score is not None and not (0.0 <= self.grounding_score <= 1.0):
264
+ raise ValueError(
265
+ f"RAGSpanPayload.grounding_score must be in [0, 1]; got {self.grounding_score}"
266
+ )
267
+
268
+ def to_dict(self) -> dict[str, Any]:
269
+ """Serialise to a plain dict."""
270
+ d: dict[str, Any] = {
271
+ "session_id": self.session_id,
272
+ "span_name": self.span_name,
273
+ "model": self.model,
274
+ "chunk_ids_used": self.chunk_ids_used,
275
+ "context_tokens": self.context_tokens,
276
+ "prompt_tokens": self.prompt_tokens,
277
+ "output_tokens": self.output_tokens,
278
+ "latency_ms": self.latency_ms,
279
+ "status": self.status,
280
+ }
281
+ if self.grounding_score is not None:
282
+ d["grounding_score"] = self.grounding_score
283
+ if self.error_message is not None:
284
+ d["error_message"] = self.error_message
285
+ return d
286
+
287
+ @classmethod
288
+ def from_dict(cls, data: dict[str, Any]) -> RAGSpanPayload:
289
+ """Deserialise from a plain dict."""
290
+ gs = data.get("grounding_score")
291
+ return cls(
292
+ session_id=str(data["session_id"]),
293
+ span_name=str(data.get("span_name", "")),
294
+ model=str(data["model"]),
295
+ chunk_ids_used=list(data.get("chunk_ids_used", [])),
296
+ context_tokens=int(data.get("context_tokens", 0)),
297
+ prompt_tokens=int(data.get("prompt_tokens", 0)),
298
+ output_tokens=int(data.get("output_tokens", 0)),
299
+ latency_ms=float(data.get("latency_ms", 0.0)),
300
+ status=data.get("status", "ok"),
301
+ grounding_score=float(gs) if gs is not None else None,
302
+ error_message=data.get("error_message"),
303
+ )
304
+
305
+
306
+ @dataclass
307
+ class RAGSessionPayload:
308
+ """Payload for ``llm.rag.session`` events.
309
+
310
+ Root summary for a complete Retrieval-Augmented Generation interaction
311
+ from initial user query through to final generated response.
312
+
313
+ Attributes:
314
+ session_id: Unique identifier for this RAG session.
315
+ total_queries: Number of retrieval queries issued in the session.
316
+ total_chunks_used: Total distinct chunk IDs consumed across all generations.
317
+ total_input_tokens: Sum of all prompt tokens across generation spans.
318
+ total_output_tokens: Sum of all output tokens across generation spans.
319
+ avg_grounding_score: Mean grounding score across all ``llm.rag.generated``
320
+ spans; ``None`` if no grounding scores were recorded.
321
+ total_latency_ms: Total wall-clock time for the session (ms).
322
+ status: Overall session status.
323
+ retriever_name: Name of the primary retriever used in this session.
324
+ """
325
+
326
+ session_id: str
327
+ total_queries: int = 0
328
+ total_chunks_used: int = 0
329
+ total_input_tokens: int = 0
330
+ total_output_tokens: int = 0
331
+ avg_grounding_score: float | None = None
332
+ total_latency_ms: float = 0.0
333
+ status: Literal["ok", "partial", "error"] = "ok"
334
+ retriever_name: str = ""
335
+
336
+ def __post_init__(self) -> None:
337
+ if not self.session_id:
338
+ raise ValueError("RAGSessionPayload.session_id must be non-empty")
339
+ if self.status not in {"ok", "partial", "error"}:
340
+ raise ValueError(
341
+ f"RAGSessionPayload.status must be 'ok', 'partial', or 'error'; got {self.status!r}"
342
+ )
343
+ if self.avg_grounding_score is not None and not (0.0 <= self.avg_grounding_score <= 1.0):
344
+ raise ValueError(
345
+ f"RAGSessionPayload.avg_grounding_score must be in [0, 1]; "
346
+ f"got {self.avg_grounding_score}"
347
+ )
348
+
349
+ def to_dict(self) -> dict[str, Any]:
350
+ """Serialise to a plain dict."""
351
+ d: dict[str, Any] = {
352
+ "session_id": self.session_id,
353
+ "total_queries": self.total_queries,
354
+ "total_chunks_used": self.total_chunks_used,
355
+ "total_input_tokens": self.total_input_tokens,
356
+ "total_output_tokens": self.total_output_tokens,
357
+ "total_latency_ms": self.total_latency_ms,
358
+ "status": self.status,
359
+ "retriever_name": self.retriever_name,
360
+ }
361
+ if self.avg_grounding_score is not None:
362
+ d["avg_grounding_score"] = self.avg_grounding_score
363
+ return d
364
+
365
+ @classmethod
366
+ def from_dict(cls, data: dict[str, Any]) -> RAGSessionPayload:
367
+ """Deserialise from a plain dict."""
368
+ ags = data.get("avg_grounding_score")
369
+ return cls(
370
+ session_id=str(data["session_id"]),
371
+ total_queries=int(data.get("total_queries", 0)),
372
+ total_chunks_used=int(data.get("total_chunks_used", 0)),
373
+ total_input_tokens=int(data.get("total_input_tokens", 0)),
374
+ total_output_tokens=int(data.get("total_output_tokens", 0)),
375
+ avg_grounding_score=float(ags) if ags is not None else None,
376
+ total_latency_ms=float(data.get("total_latency_ms", 0.0)),
377
+ status=data.get("status", "ok"),
378
+ retriever_name=str(data.get("retriever_name", "")),
379
+ )