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
spanforge/auto.py ADDED
@@ -0,0 +1,464 @@
1
+ """spanforge.auto — Automatic integration discovery and patching.
2
+
3
+ Call :func:`setup` to automatically detect and patch all SpanForge-supported
4
+ LLM libraries that are installed in the current environment. This eliminates
5
+ the need to manually import each integration module.
6
+
7
+ Usage \u2014 fastest path to value::
8
+
9
+ import spanforge.auto
10
+ spanforge.auto.setup() # patches everything installed
11
+
12
+ Or call explicitly for programmatic control::
13
+
14
+ from spanforge.auto import setup
15
+ patched = setup(verbose=True)
16
+ # patched = {"openai", "anthropic"}
17
+
18
+ Note:
19
+ ----
20
+ :func:`setup` is **not** called automatically on import. You must call it
21
+ explicitly so that importing :mod:`spanforge` never silently monkey-patches
22
+ third-party libraries without your consent.
23
+
24
+ Supported libraries (patched when installed):
25
+ * **openai** — :mod:`spanforge.integrations.openai`
26
+ * **anthropic** — :mod:`spanforge.integrations.anthropic`
27
+ * **groq** — :mod:`spanforge.integrations.groq`
28
+ * **ollama** — :mod:`spanforge.integrations.ollama`
29
+ * **together** — :mod:`spanforge.integrations.together`
30
+
31
+ Callback-based integrations (register manually):
32
+ * **LangChain** — use :class:`~spanforge.integrations.langchain.LLMSchemaCallbackHandler`
33
+ * **LlamaIndex** — use :class:`~spanforge.integrations.llamaindex.LLMSchemaEventHandler`
34
+ * **CrewAI** — use :func:`~spanforge.integrations.crewai.patch`
35
+
36
+ Security note
37
+ -------------
38
+ Monkey-patching is only applied when the target library is already installed.
39
+ The patching flag ``_spanforge_patched`` prevents double-patching. Each
40
+ integration is wrapped in a ``try/except`` so a broken integration never
41
+ prevents the others from loading.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import importlib.util
47
+ import threading
48
+ import warnings
49
+
50
+ __all__ = ["patched_integrations", "setup", "teardown", "trace_rag"]
51
+
52
+ # Internal registry of successfully patched integrations (module name → patch fn).
53
+ _PATCHED: set[str] = set()
54
+ _PATCHED_LOCK = threading.Lock()
55
+
56
+ # Map of library import name → (integration module path, patch fn name, unpatch fn name)
57
+ _INTEGRATIONS: list[tuple[str, str, str, str]] = [
58
+ ("openai", "spanforge.integrations.openai", "patch", "unpatch"),
59
+ ("anthropic", "spanforge.integrations.anthropic", "patch", "unpatch"),
60
+ ("groq", "spanforge.integrations.groq", "patch", "unpatch"),
61
+ ("ollama", "spanforge.integrations.ollama", "patch", "unpatch"),
62
+ ("together", "spanforge.integrations.together", "patch", "unpatch"),
63
+ ]
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # RAG auto-patch state (F-20)
67
+ # ---------------------------------------------------------------------------
68
+
69
+ # Stores original methods keyed by "<module>.<Class>.<method>" so teardown
70
+ # can restore them precisely.
71
+ _RAG_PATCHED: set[str] = set()
72
+ _RAG_PATCHED_LOCK = threading.Lock()
73
+ _RAG_ORIGINALS: dict[str, object] = {}
74
+
75
+
76
+ def _patch_rag_llama_index() -> bool:
77
+ """Monkey-patch LlamaIndex ``VectorIndexRetriever.retrieve`` (F-20)."""
78
+ _key = "llama_index.VectorIndexRetriever.retrieve"
79
+ try:
80
+ with warnings.catch_warnings():
81
+ warnings.simplefilter("ignore")
82
+ from llama_index.core.retrievers import VectorIndexRetriever # type: ignore[import]
83
+ except ImportError:
84
+ return False
85
+
86
+ with _RAG_PATCHED_LOCK:
87
+ if "llama_index" in _RAG_PATCHED:
88
+ return False
89
+ original = VectorIndexRetriever.retrieve
90
+ _RAG_ORIGINALS[_key] = original
91
+
92
+ def _sf_retrieve(self_, str_or_query_bundle, **kw): # type: ignore[override]
93
+ import time as _t
94
+
95
+ query_text = str(str_or_query_bundle)
96
+ session_id: str | None = None
97
+ try:
98
+ from spanforge.sdk import sf_rag
99
+
100
+ session_id = sf_rag.trace_query(
101
+ query=query_text,
102
+ retriever_name="VectorIndexRetriever",
103
+ )
104
+ except Exception: # nosec B110
105
+ pass
106
+ t0 = _t.monotonic()
107
+ result = original(self_, str_or_query_bundle, **kw)
108
+ latency_ms = (_t.monotonic() - t0) * 1000
109
+ if session_id is not None:
110
+ try:
111
+ from spanforge.sdk import sf_rag
112
+
113
+ chunk_count = len(result) if isinstance(result, (list, tuple)) else 0
114
+ sf_rag.trace_retrieval(
115
+ session_id=session_id,
116
+ chunks=[
117
+ {"chunk_id": str(i), "score": 0.0}
118
+ for i in range(chunk_count)
119
+ ],
120
+ latency_ms=float(latency_ms),
121
+ )
122
+ except Exception: # nosec B110
123
+ pass
124
+ return result
125
+
126
+ VectorIndexRetriever.retrieve = _sf_retrieve # type: ignore[method-assign]
127
+ _RAG_PATCHED.add("llama_index")
128
+ return True
129
+
130
+
131
+ def _unpatch_rag_llama_index() -> bool:
132
+ """Restore LlamaIndex ``VectorIndexRetriever.retrieve`` to original."""
133
+ _key = "llama_index.VectorIndexRetriever.retrieve"
134
+ try:
135
+ with warnings.catch_warnings():
136
+ warnings.simplefilter("ignore")
137
+ from llama_index.core.retrievers import VectorIndexRetriever # type: ignore[import]
138
+ except ImportError:
139
+ return False
140
+ with _RAG_PATCHED_LOCK:
141
+ if "llama_index" not in _RAG_PATCHED:
142
+ return False
143
+ original = _RAG_ORIGINALS.pop(_key, None)
144
+ if original is not None:
145
+ VectorIndexRetriever.retrieve = original # type: ignore[method-assign]
146
+ _RAG_PATCHED.discard("llama_index")
147
+ return True
148
+
149
+
150
+ def _patch_rag_langchain() -> bool:
151
+ """Monkey-patch LangChain ``BaseRetriever.invoke`` (F-20)."""
152
+ _key = "langchain_core.BaseRetriever.invoke"
153
+ try:
154
+ with warnings.catch_warnings():
155
+ warnings.simplefilter("ignore")
156
+ from langchain_core.retrievers import BaseRetriever # type: ignore[import]
157
+ except ImportError:
158
+ return False
159
+
160
+ with _RAG_PATCHED_LOCK:
161
+ if "langchain" in _RAG_PATCHED:
162
+ return False
163
+ original = BaseRetriever.invoke
164
+ _RAG_ORIGINALS[_key] = original
165
+
166
+ def _sf_invoke(self_, input, config=None, **kw): # type: ignore[override]
167
+ import time as _t
168
+
169
+ query_text = str(input)
170
+ session_id: str | None = None
171
+ try:
172
+ from spanforge.sdk import sf_rag
173
+
174
+ session_id = sf_rag.trace_query(
175
+ query=query_text,
176
+ retriever_name=type(self_).__name__,
177
+ )
178
+ except Exception: # nosec B110
179
+ pass
180
+ t0 = _t.monotonic()
181
+ result = original(self_, input, config, **kw)
182
+ latency_ms = (_t.monotonic() - t0) * 1000
183
+ if session_id is not None:
184
+ try:
185
+ from spanforge.sdk import sf_rag
186
+
187
+ chunk_count = len(result) if isinstance(result, (list, tuple)) else 0
188
+ sf_rag.trace_retrieval(
189
+ session_id=session_id,
190
+ chunks=[
191
+ {"chunk_id": str(i), "score": 0.0}
192
+ for i in range(chunk_count)
193
+ ],
194
+ latency_ms=float(latency_ms),
195
+ )
196
+ except Exception: # nosec B110
197
+ pass
198
+ return result
199
+
200
+ BaseRetriever.invoke = _sf_invoke # type: ignore[method-assign]
201
+ _RAG_PATCHED.add("langchain")
202
+ return True
203
+
204
+
205
+ def _unpatch_rag_langchain() -> bool:
206
+ """Restore LangChain ``BaseRetriever.invoke`` to original."""
207
+ _key = "langchain_core.BaseRetriever.invoke"
208
+ try:
209
+ with warnings.catch_warnings():
210
+ warnings.simplefilter("ignore")
211
+ from langchain_core.retrievers import BaseRetriever # type: ignore[import]
212
+ except ImportError:
213
+ return False
214
+ with _RAG_PATCHED_LOCK:
215
+ if "langchain" not in _RAG_PATCHED:
216
+ return False
217
+ original = _RAG_ORIGINALS.pop(_key, None)
218
+ if original is not None:
219
+ BaseRetriever.invoke = original # type: ignore[method-assign]
220
+ _RAG_PATCHED.discard("langchain")
221
+ return True
222
+
223
+
224
+ def _try_patch_integration(
225
+ lib_name: str, integration_module: str, patch_fn: str, verbose: bool
226
+ ) -> bool:
227
+ """Attempt to patch one integration; returns True if newly patched."""
228
+ try:
229
+ mod = importlib.import_module(integration_module)
230
+ getattr(mod, patch_fn)()
231
+ _PATCHED.add(lib_name)
232
+ if verbose:
233
+ print(f" {lib_name}: patched \u2713")
234
+ except Exception as exc:
235
+ warnings.warn(
236
+ f"spanforge.auto: failed to patch {lib_name!r}: {exc}",
237
+ UserWarning,
238
+ stacklevel=3,
239
+ )
240
+ if verbose:
241
+ print(f" {lib_name}: patch failed — {exc}")
242
+ return False
243
+ else:
244
+ return True
245
+
246
+
247
+ def setup(*, verbose: bool = False) -> set[str]:
248
+ """Detect and patch all installed SpanForge-supported LLM libraries.
249
+
250
+ Iterates over supported integrations and calls their ``patch()`` function
251
+ if the underlying library is installed. Already-patched integrations are
252
+ skipped silently (idempotent).
253
+
254
+ Args:
255
+ verbose: When ``True``, print a status line for each integration
256
+ attempted.
257
+
258
+ Returns:
259
+ Set of library names that were newly patched in this call (does not
260
+ include libraries already patched in previous calls).
261
+
262
+ Example::
263
+
264
+ from spanforge.auto import setup
265
+ patched = setup(verbose=True)
266
+ # openai patched ✓
267
+ # anthropic not installed, skipped
268
+
269
+ Note:
270
+ Callback-based integrations (LangChain, LlamaIndex, CrewAI) are not
271
+ auto-patched because they require manual handler registration. See
272
+ their respective integration guides.
273
+ """
274
+ newly_patched: set[str] = set()
275
+
276
+ for lib_name, integration_module, patch_fn, _unpatch_fn in _INTEGRATIONS:
277
+ if lib_name in _PATCHED:
278
+ if verbose:
279
+ print(f" {lib_name}: already patched, skipped")
280
+ continue
281
+
282
+ if importlib.util.find_spec(lib_name) is None:
283
+ if verbose:
284
+ print(f" {lib_name}: not installed, skipped")
285
+ continue
286
+
287
+ if _try_patch_integration(lib_name, integration_module, patch_fn, verbose):
288
+ newly_patched.add(lib_name)
289
+
290
+ # Attempt RAG auto-patches (F-20) — best-effort, never raise.
291
+ for _rag_lib, _rag_patch_fn in [
292
+ ("llama_index", _patch_rag_llama_index),
293
+ ("langchain_core", _patch_rag_langchain),
294
+ ]:
295
+ if importlib.util.find_spec(_rag_lib) is None:
296
+ if verbose:
297
+ print(f" {_rag_lib} (rag): not installed, skipped")
298
+ continue
299
+ try:
300
+ patched = _rag_patch_fn()
301
+ if patched:
302
+ newly_patched.add(_rag_lib + ":rag")
303
+ if verbose:
304
+ print(f" {_rag_lib} (rag): patched \u2713")
305
+ elif verbose:
306
+ print(f" {_rag_lib} (rag): already patched, skipped")
307
+ except Exception as exc:
308
+ warnings.warn(
309
+ f"spanforge.auto: failed to patch {_rag_lib!r} RAG: {exc}",
310
+ UserWarning,
311
+ stacklevel=2,
312
+ )
313
+
314
+ return newly_patched
315
+
316
+
317
+ def teardown(*, verbose: bool = False) -> set[str]:
318
+ """Unpatch all auto-patched integrations and reset the auto-patch registry.
319
+
320
+ Calls ``unpatch()`` on every integration that was patched via
321
+ :func:`setup`. Safe to call even if :func:`setup` was never called.
322
+
323
+ Args:
324
+ verbose: When ``True``, print a status line for each integration.
325
+
326
+ Returns:
327
+ Set of library names that were unpatched.
328
+ """
329
+ unpatched: set[str] = set()
330
+
331
+ for lib_name, integration_module, _patch_fn, unpatch_fn in _INTEGRATIONS:
332
+ with _PATCHED_LOCK:
333
+ if lib_name not in _PATCHED:
334
+ continue
335
+ try:
336
+ mod = importlib.import_module(integration_module)
337
+ getattr(mod, unpatch_fn)()
338
+ with _PATCHED_LOCK:
339
+ _PATCHED.discard(lib_name)
340
+ unpatched.add(lib_name)
341
+ if verbose:
342
+ print(f" {lib_name}: unpatched \u2713")
343
+ except Exception as exc:
344
+ warnings.warn(
345
+ f"spanforge.auto: failed to unpatch {lib_name!r}: {exc}",
346
+ UserWarning,
347
+ stacklevel=2,
348
+ )
349
+
350
+ # Unpatch RAG integrations (F-20).
351
+ for _rag_lib, _rag_unpatch_fn in [
352
+ ("llama_index", _unpatch_rag_llama_index),
353
+ ("langchain_core", _unpatch_rag_langchain),
354
+ ]:
355
+ try:
356
+ if _rag_unpatch_fn():
357
+ unpatched.add(_rag_lib + ":rag")
358
+ if verbose:
359
+ print(f" {_rag_lib} (rag): unpatched \u2713")
360
+ except Exception as exc:
361
+ warnings.warn(
362
+ f"spanforge.auto: failed to unpatch {_rag_lib!r} RAG: {exc}",
363
+ UserWarning,
364
+ stacklevel=2,
365
+ )
366
+
367
+ return unpatched
368
+
369
+
370
+ def patched_integrations() -> set[str]:
371
+ """Return the set of library names currently patched via :func:`setup`.
372
+
373
+ Returns:
374
+ Snapshot of the currently patched integration names.
375
+ """
376
+ with _PATCHED_LOCK:
377
+ return set(_PATCHED)
378
+
379
+
380
+ # NOTE: setup() is NOT called automatically on import.
381
+ # Call spanforge.auto.setup() explicitly to patch installed integrations.
382
+ # This is intentional: importing spanforge should never monkey-patch
383
+ # third-party libraries without explicit user consent.
384
+
385
+
386
+ # ---------------------------------------------------------------------------
387
+ # trace_rag — decorator for manual RAG tracing (F-20)
388
+ # ---------------------------------------------------------------------------
389
+
390
+
391
+ def trace_rag(func):
392
+ """Decorator that automatically traces a RAG retrieval function via sf-rag.
393
+
394
+ Wraps any callable that accepts a query string as its first argument and
395
+ emits ``llm.rag.query`` + ``llm.rag.retrieved`` spans via
396
+ :mod:`spanforge.sdk.rag`. The raw query text is **never stored**; only
397
+ its SHA-256 hash is recorded.
398
+
399
+ Usage::
400
+
401
+ from spanforge.auto import trace_rag
402
+
403
+ @trace_rag
404
+ def my_retriever(query: str) -> list[dict]:
405
+ return vector_db.search(query)
406
+
407
+ # The decorator emits RAG spans on every call.
408
+ results = my_retriever("What is SpanForge?")
409
+
410
+ The decorator is fail-safe: if sf-rag is unavailable (e.g. in tests),
411
+ the wrapped function executes normally without raising.
412
+
413
+ Args:
414
+ func: The retrieval callable to decorate. Its first positional
415
+ argument must be the query string.
416
+
417
+ Returns:
418
+ The wrapped function with identical signature and return type.
419
+ """
420
+ import functools
421
+ import time as _time
422
+
423
+ @functools.wraps(func)
424
+ def _wrapper(*args, **kwargs):
425
+ # Treat the first positional arg (or 'query' kwarg) as the query text.
426
+ query_text = ""
427
+ if args:
428
+ query_text = str(args[0])
429
+ query_text = str(kwargs.get("query", query_text)) or query_text
430
+
431
+ session_id: str | None = None
432
+ try:
433
+ from spanforge.sdk import sf_rag # type: ignore[import]
434
+
435
+ session_id = sf_rag.trace_query(
436
+ query=query_text,
437
+ retriever_name=func.__qualname__,
438
+ )
439
+ except Exception: # nosec B110 — never let tracing break the application
440
+ pass
441
+
442
+ t0 = _time.monotonic()
443
+ result = func(*args, **kwargs)
444
+ latency_ms = (_time.monotonic() - t0) * 1000
445
+
446
+ if session_id is not None:
447
+ try:
448
+ from spanforge.sdk import sf_rag # type: ignore[import]
449
+
450
+ chunk_count = len(result) if isinstance(result, (list, tuple)) else 0
451
+ sf_rag.trace_retrieval(
452
+ session_id=session_id,
453
+ chunks=[
454
+ {"chunk_id": str(i), "score": 0.0}
455
+ for i in range(chunk_count)
456
+ ],
457
+ latency_ms=float(latency_ms),
458
+ )
459
+ except Exception: # nosec B110
460
+ pass
461
+
462
+ return result
463
+
464
+ return _wrapper