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,351 @@
1
+ """spanforge.sdk.feedback — SpanForge sf-feedback User Feedback client (Phase 13).
2
+
3
+ Implements FB-001 through FB-006: collecting, storing, and surfacing user
4
+ feedback for LLM responses, with optional T.R.U.S.T. dimension linkage.
5
+
6
+ Architecture
7
+ ------------
8
+ * :meth:`submit` records a ``llm.feedback.submitted`` event.
9
+ * :meth:`get_feedback` returns feedback records for a given session.
10
+ * :meth:`get_summary` returns an aggregated :class:`FeedbackSummaryPayload`.
11
+ * :meth:`link_to_trust` links feedback to a T.R.U.S.T. dimension score
12
+ adjustment.
13
+ * :meth:`get_status` returns service health statistics.
14
+
15
+ Raw user text (free-text comments) is **never stored**. Only SHA-256 hashes
16
+ are retained. User identifiers are similarly hashed before storage.
17
+
18
+ Security requirements
19
+ ---------------------
20
+ * All plaintext comment and user ID values are hashed with SHA-256 before
21
+ recording.
22
+ * Thread-safety: all in-process state uses locks.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import hashlib
28
+ import logging
29
+ import threading
30
+ from dataclasses import dataclass
31
+ from typing import Any
32
+
33
+ from spanforge.namespaces.feedback import (
34
+ FeedbackRating,
35
+ FeedbackSubmittedPayload,
36
+ FeedbackSummaryPayload,
37
+ )
38
+ from spanforge.sdk._base import SFClientConfig, SFServiceClient
39
+
40
+ __all__ = ["SFFeedbackClient"]
41
+
42
+ _log = logging.getLogger(__name__)
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Status dataclass
47
+ # ---------------------------------------------------------------------------
48
+
49
+
50
+ @dataclass
51
+ class FeedbackStatusInfo:
52
+ """sf-feedback service status.
53
+
54
+ Returned by :meth:`SFFeedbackClient.get_status`.
55
+
56
+ Attributes:
57
+ status: ``"ok"`` or ``"degraded"``.
58
+ total_submitted: Total feedback submissions recorded in this process.
59
+ sessions_tracked: Number of distinct session IDs tracked.
60
+ """
61
+
62
+ status: str
63
+ total_submitted: int
64
+ sessions_tracked: int
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Client
69
+ # ---------------------------------------------------------------------------
70
+
71
+
72
+ class SFFeedbackClient(SFServiceClient):
73
+ """SpanForge User Feedback service client.
74
+
75
+ Provides structured user feedback collection for LLM interactions,
76
+ supporting thumbs, star, Likert, and free-text modalities.
77
+
78
+ Example usage::
79
+
80
+ from spanforge.sdk import sf_feedback
81
+
82
+ # Submit thumbs-up feedback
83
+ fb_id = sf_feedback.submit(
84
+ session_id="sess-abc",
85
+ trace_id="trace-xyz",
86
+ rating="thumbs_up",
87
+ )
88
+
89
+ # Submit a free-text comment (text is hashed, never stored)
90
+ fb_id = sf_feedback.submit(
91
+ session_id="sess-abc",
92
+ trace_id="trace-xyz",
93
+ rating="free_text",
94
+ comment="The answer was very helpful.",
95
+ user_id="user-42",
96
+ )
97
+
98
+ # Get session summary
99
+ summary = sf_feedback.get_summary("sess-abc")
100
+ """
101
+
102
+ def __init__(self, config: SFClientConfig) -> None:
103
+ super().__init__(config, service_name="feedback")
104
+ self._lock = threading.Lock()
105
+ # session_id → list of FeedbackSubmittedPayload dicts
106
+ self._store: dict[str, list[dict[str, Any]]] = {}
107
+ self._total_submitted: int = 0
108
+
109
+ # ------------------------------------------------------------------
110
+ # FB-001: submit
111
+ # ------------------------------------------------------------------
112
+
113
+ def submit(
114
+ self,
115
+ session_id: str,
116
+ trace_id: str,
117
+ rating: str | FeedbackRating,
118
+ *,
119
+ comment: str | None = None,
120
+ user_id: str | None = None,
121
+ source: str = "api",
122
+ metadata: dict[str, Any] | None = None,
123
+ linked_trust_dimension: str | None = None,
124
+ ) -> str:
125
+ """Submit user feedback for an LLM response.
126
+
127
+ Args:
128
+ session_id: Session or conversation the feedback applies to.
129
+ trace_id: Trace ID of the specific LLM call being rated.
130
+ rating: A :class:`~spanforge.namespaces.feedback.FeedbackRating`
131
+ value or its string equivalent (e.g. ``"thumbs_up"``).
132
+ comment: Optional free-text comment. The text is
133
+ hashed with SHA-256; raw text is NOT stored.
134
+ user_id: Optional user identifier. Hashed with
135
+ SHA-256 before storage.
136
+ source: Feedback channel label (default ``"api"``).
137
+ metadata: Arbitrary key-value metadata.
138
+ linked_trust_dimension: Optional T.R.U.S.T. dimension to link
139
+ this feedback to (e.g. ``"reliability"``).
140
+
141
+ Returns:
142
+ The unique ``feedback_id`` (ULID) for the submitted record.
143
+ """
144
+ from spanforge.ulid import generate as _ulid
145
+
146
+ rating_enum = FeedbackRating(rating) if isinstance(rating, str) else rating
147
+
148
+ comment_hash = ""
149
+ if comment:
150
+ comment_hash = hashlib.sha256(comment.encode("utf-8")).hexdigest()
151
+
152
+ user_id_hash = ""
153
+ if user_id:
154
+ user_id_hash = hashlib.sha256(user_id.encode("utf-8")).hexdigest()
155
+
156
+ feedback_id = _ulid()
157
+ payload = FeedbackSubmittedPayload(
158
+ feedback_id=feedback_id,
159
+ session_id=session_id,
160
+ trace_id=trace_id,
161
+ rating=rating_enum,
162
+ comment_hash=comment_hash,
163
+ user_id_hash=user_id_hash,
164
+ source=source,
165
+ metadata=metadata or {},
166
+ linked_trust_dimension=linked_trust_dimension,
167
+ )
168
+
169
+ record = payload.to_dict()
170
+
171
+ with self._lock:
172
+ if session_id not in self._store:
173
+ self._store[session_id] = []
174
+ self._store[session_id].append(record)
175
+ self._total_submitted += 1
176
+
177
+ self._emit_local("llm.feedback.submitted", record, session_id=session_id)
178
+ return feedback_id
179
+
180
+ # ------------------------------------------------------------------
181
+ # FB-002: get_feedback
182
+ # ------------------------------------------------------------------
183
+
184
+ def get_feedback(
185
+ self,
186
+ session_id: str,
187
+ *,
188
+ rating_filter: str | FeedbackRating | None = None,
189
+ ) -> list[dict[str, Any]]:
190
+ """Return feedback records for *session_id*.
191
+
192
+ Args:
193
+ session_id: Session to query.
194
+ rating_filter: Optional rating type to filter by.
195
+
196
+ Returns:
197
+ List of feedback record dicts (in submission order).
198
+ """
199
+ with self._lock:
200
+ records = list(self._store.get(session_id, []))
201
+
202
+ if rating_filter is not None:
203
+ rf = FeedbackRating(rating_filter) if isinstance(rating_filter, str) else rating_filter
204
+ records = [r for r in records if r.get("rating") == rf.value]
205
+
206
+ return records
207
+
208
+ # ------------------------------------------------------------------
209
+ # FB-003: get_summary
210
+ # ------------------------------------------------------------------
211
+
212
+ def get_summary(self, session_id: str) -> FeedbackSummaryPayload:
213
+ """Return an aggregated feedback summary for *session_id*.
214
+
215
+ Args:
216
+ session_id: Session to summarise.
217
+
218
+ Returns:
219
+ A :class:`~spanforge.namespaces.feedback.FeedbackSummaryPayload`.
220
+ """
221
+ records = self.get_feedback(session_id)
222
+
223
+ thumbs_up = sum(1 for r in records if r.get("rating") == FeedbackRating.THUMBS_UP.value)
224
+ thumbs_down = sum(1 for r in records if r.get("rating") == FeedbackRating.THUMBS_DOWN.value)
225
+ free_text = sum(1 for r in records if r.get("rating") == FeedbackRating.FREE_TEXT.value)
226
+
227
+ star_values = [
228
+ FeedbackRating(r["rating"]).numeric_value()
229
+ for r in records
230
+ if r.get("rating", "").startswith("star_")
231
+ ]
232
+ star_values_clean = [v for v in star_values if v is not None]
233
+
234
+ likert_values = [
235
+ FeedbackRating(r["rating"]).numeric_value()
236
+ for r in records
237
+ if r.get("rating", "").startswith("likert_")
238
+ ]
239
+ likert_values_clean = [v for v in likert_values if v is not None]
240
+
241
+ # Numeric values are on [0, 1]. Convert star/likert back to 1–5 scale.
242
+ avg_star = (
243
+ round(sum(star_values_clean) / len(star_values_clean) * 4 + 1, 2)
244
+ if star_values_clean
245
+ else None
246
+ )
247
+ avg_likert = (
248
+ round(sum(likert_values_clean) / len(likert_values_clean) * 4 + 1, 2)
249
+ if likert_values_clean
250
+ else None
251
+ )
252
+
253
+ # Positive rate: numeric ratings above 0.5 are "positive"
254
+ all_numeric: list[float] = []
255
+ for r in records:
256
+ try:
257
+ v = FeedbackRating(r["rating"]).numeric_value()
258
+ if v is not None:
259
+ all_numeric.append(v)
260
+ except ValueError:
261
+ continue
262
+
263
+ positive_rate = (
264
+ sum(1 for v in all_numeric if v >= 0.5) / len(all_numeric) if all_numeric else 0.0
265
+ )
266
+
267
+ return FeedbackSummaryPayload(
268
+ session_id=session_id,
269
+ total_feedback=len(records),
270
+ thumbs_up_count=thumbs_up,
271
+ thumbs_down_count=thumbs_down,
272
+ avg_star_rating=avg_star,
273
+ avg_likert_score=avg_likert,
274
+ free_text_count=free_text,
275
+ positive_rate=round(positive_rate, 4),
276
+ )
277
+
278
+ # ------------------------------------------------------------------
279
+ # FB-004: link_to_trust
280
+ # ------------------------------------------------------------------
281
+
282
+ def link_to_trust(
283
+ self,
284
+ feedback_id: str,
285
+ trust_dimension: str,
286
+ *,
287
+ weight: float = 0.1,
288
+ ) -> bool:
289
+ """Link a feedback record to a T.R.U.S.T. dimension score adjustment.
290
+
291
+ This emits a ``llm.feedback.trust_linked`` event that the T.R.U.S.T.
292
+ service can consume to adjust dimension scores based on explicit user
293
+ signal.
294
+
295
+ Args:
296
+ feedback_id: ULID of the feedback record to link.
297
+ trust_dimension: T.R.U.S.T. dimension to adjust
298
+ (``"transparency"``, ``"reliability"``,
299
+ ``"user_trust"``, ``"security"``,
300
+ ``"traceability"``).
301
+ weight: Adjustment weight in [0.0, 1.0] (default 0.1).
302
+
303
+ Returns:
304
+ ``True`` if the link event was emitted successfully.
305
+ """
306
+ _VALID_DIMENSIONS = frozenset(
307
+ {"transparency", "reliability", "user_trust", "security", "traceability"}
308
+ )
309
+ if trust_dimension not in _VALID_DIMENSIONS:
310
+ raise ValueError(
311
+ f"link_to_trust: trust_dimension must be one of {sorted(_VALID_DIMENSIONS)}"
312
+ )
313
+ if not (0.0 <= weight <= 1.0):
314
+ raise ValueError(f"link_to_trust: weight must be in [0, 1]; got {weight}")
315
+
316
+ self._emit_local(
317
+ "llm.feedback.trust_linked",
318
+ {
319
+ "feedback_id": feedback_id,
320
+ "trust_dimension": trust_dimension,
321
+ "weight": weight,
322
+ },
323
+ )
324
+ return True
325
+
326
+ # ------------------------------------------------------------------
327
+ # FB-005: get_status
328
+ # ------------------------------------------------------------------
329
+
330
+ def get_status(self) -> FeedbackStatusInfo:
331
+ """Return service health and submission statistics."""
332
+ with self._lock:
333
+ return FeedbackStatusInfo(
334
+ status="ok",
335
+ total_submitted=self._total_submitted,
336
+ sessions_tracked=len(self._store),
337
+ )
338
+
339
+ # ------------------------------------------------------------------
340
+ # Internal helpers
341
+ # ------------------------------------------------------------------
342
+
343
+ def _emit_local(
344
+ self,
345
+ event_type: str,
346
+ payload: dict[str, Any],
347
+ *,
348
+ session_id: str = "",
349
+ ) -> None:
350
+ """Emit a feedback event locally or forward to remote endpoint."""
351
+ _log.debug("sf-feedback emit %s session=%s", event_type, session_id)