spanforge 2.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 (101) hide show
  1. spanforge/__init__.py +695 -0
  2. spanforge/_batch_exporter.py +322 -0
  3. spanforge/_cli.py +3081 -0
  4. spanforge/_hooks.py +340 -0
  5. spanforge/_server.py +953 -0
  6. spanforge/_span.py +1015 -0
  7. spanforge/_store.py +287 -0
  8. spanforge/_stream.py +654 -0
  9. spanforge/_trace.py +334 -0
  10. spanforge/_tracer.py +253 -0
  11. spanforge/actor.py +141 -0
  12. spanforge/alerts.py +464 -0
  13. spanforge/auto.py +181 -0
  14. spanforge/baseline.py +336 -0
  15. spanforge/config.py +460 -0
  16. spanforge/consent.py +227 -0
  17. spanforge/consumer.py +379 -0
  18. spanforge/core/__init__.py +5 -0
  19. spanforge/core/compliance_mapping.py +1060 -0
  20. spanforge/cost.py +597 -0
  21. spanforge/debug.py +514 -0
  22. spanforge/drift.py +488 -0
  23. spanforge/egress.py +63 -0
  24. spanforge/eval.py +575 -0
  25. spanforge/event.py +1052 -0
  26. spanforge/exceptions.py +246 -0
  27. spanforge/explain.py +181 -0
  28. spanforge/export/__init__.py +50 -0
  29. spanforge/export/append_only.py +342 -0
  30. spanforge/export/cloud.py +349 -0
  31. spanforge/export/datadog.py +495 -0
  32. spanforge/export/grafana.py +331 -0
  33. spanforge/export/jsonl.py +198 -0
  34. spanforge/export/otel_bridge.py +291 -0
  35. spanforge/export/otlp.py +817 -0
  36. spanforge/export/otlp_bridge.py +231 -0
  37. spanforge/export/redis_backend.py +282 -0
  38. spanforge/export/webhook.py +302 -0
  39. spanforge/exporters/__init__.py +29 -0
  40. spanforge/exporters/console.py +271 -0
  41. spanforge/exporters/jsonl.py +144 -0
  42. spanforge/hitl.py +297 -0
  43. spanforge/inspect.py +429 -0
  44. spanforge/integrations/__init__.py +39 -0
  45. spanforge/integrations/_pricing.py +277 -0
  46. spanforge/integrations/anthropic.py +388 -0
  47. spanforge/integrations/bedrock.py +306 -0
  48. spanforge/integrations/crewai.py +251 -0
  49. spanforge/integrations/gemini.py +349 -0
  50. spanforge/integrations/groq.py +444 -0
  51. spanforge/integrations/langchain.py +349 -0
  52. spanforge/integrations/llamaindex.py +370 -0
  53. spanforge/integrations/ollama.py +286 -0
  54. spanforge/integrations/openai.py +370 -0
  55. spanforge/integrations/together.py +485 -0
  56. spanforge/metrics.py +393 -0
  57. spanforge/metrics_export.py +342 -0
  58. spanforge/migrate.py +278 -0
  59. spanforge/model_registry.py +282 -0
  60. spanforge/models.py +407 -0
  61. spanforge/namespaces/__init__.py +215 -0
  62. spanforge/namespaces/audit.py +253 -0
  63. spanforge/namespaces/cache.py +209 -0
  64. spanforge/namespaces/chain.py +74 -0
  65. spanforge/namespaces/confidence.py +69 -0
  66. spanforge/namespaces/consent.py +85 -0
  67. spanforge/namespaces/cost.py +175 -0
  68. spanforge/namespaces/decision.py +135 -0
  69. spanforge/namespaces/diff.py +146 -0
  70. spanforge/namespaces/drift.py +79 -0
  71. spanforge/namespaces/eval_.py +232 -0
  72. spanforge/namespaces/fence.py +180 -0
  73. spanforge/namespaces/guard.py +104 -0
  74. spanforge/namespaces/hitl.py +92 -0
  75. spanforge/namespaces/latency.py +69 -0
  76. spanforge/namespaces/prompt.py +185 -0
  77. spanforge/namespaces/redact.py +172 -0
  78. spanforge/namespaces/template.py +197 -0
  79. spanforge/namespaces/tool_call.py +76 -0
  80. spanforge/namespaces/trace.py +1006 -0
  81. spanforge/normalizer.py +183 -0
  82. spanforge/presidio_backend.py +149 -0
  83. spanforge/processor.py +258 -0
  84. spanforge/prompt_registry.py +415 -0
  85. spanforge/py.typed +0 -0
  86. spanforge/redact.py +780 -0
  87. spanforge/sampling.py +500 -0
  88. spanforge/schemas/v1.0/schema.json +170 -0
  89. spanforge/schemas/v2.0/schema.json +536 -0
  90. spanforge/signing.py +1152 -0
  91. spanforge/stream.py +559 -0
  92. spanforge/testing.py +376 -0
  93. spanforge/trace.py +199 -0
  94. spanforge/types.py +696 -0
  95. spanforge/ulid.py +304 -0
  96. spanforge/validate.py +383 -0
  97. spanforge-2.0.0.dist-info/METADATA +1777 -0
  98. spanforge-2.0.0.dist-info/RECORD +101 -0
  99. spanforge-2.0.0.dist-info/WHEEL +4 -0
  100. spanforge-2.0.0.dist-info/entry_points.txt +5 -0
  101. spanforge-2.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,415 @@
1
+ """spanforge.prompt_registry — Prompt registry with versioning and W3C-event emission.
2
+
3
+ The prompt registry provides a centralised store for prompt templates so that
4
+ every rendered prompt is linked to the exact version that produced it. This
5
+ enables:
6
+
7
+ * **Reproducibility** — re-run any historical span with the same prompt.
8
+ * **A/B testing** — route traffic between prompt versions and compare results.
9
+ * **Audit trail** — the RFC-0001 ``llm.prompt.*`` events capture template
10
+ load, version change, and render events.
11
+
12
+ Quick start
13
+ -----------
14
+ ::
15
+
16
+ from spanforge.prompt_registry import PromptRegistry
17
+
18
+ registry = PromptRegistry()
19
+ registry.register(
20
+ name="rag_system",
21
+ template="You are {role}. Answer only from: {context}",
22
+ version="1.0.0",
23
+ )
24
+
25
+ rendered = registry.render("rag_system", {"role": "expert", "context": "...docs..."})
26
+ print(rendered)
27
+ # You are expert. Answer only from: ...docs...
28
+
29
+ # Later, update the template — version change event is emitted automatically.
30
+ registry.register(
31
+ name="rag_system",
32
+ template="You are {role}. Use ONLY these documents: {context}",
33
+ version="1.1.0",
34
+ )
35
+
36
+ Module-level singleton
37
+ ----------------------
38
+ A module-level ``_DEFAULT_REGISTRY`` is provided. Helper functions
39
+ :func:`register_prompt`, :func:`get_prompt_version`, and :func:`render_prompt`
40
+ delegate to it for convenience::
41
+
42
+ from spanforge.prompt_registry import register_prompt, render_prompt
43
+
44
+ register_prompt("greet", "Hello, {name}!", version="1.0.0")
45
+ text = render_prompt("greet", {"name": "world"})
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ import logging
51
+ import re
52
+ import time
53
+ from dataclasses import dataclass, field
54
+ from typing import Any
55
+
56
+ __all__ = [
57
+ "PromptRegistry",
58
+ "PromptVersion",
59
+ "get_prompt_version",
60
+ "register_prompt",
61
+ "render_prompt",
62
+ ]
63
+
64
+ _log = logging.getLogger("spanforge.prompt_registry")
65
+
66
+ # Simple {placeholder} pattern (not Jinja — zero runtime dependencies).
67
+ _PLACEHOLDER_RE = re.compile(r"\{(\w+)\}")
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # PromptVersion dataclass
72
+ # ---------------------------------------------------------------------------
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class PromptVersion:
77
+ """An immutable snapshot of a versioned prompt template.
78
+
79
+ Args:
80
+ name: Registry name (e.g. ``"rag_system"``).
81
+ template: Raw template string with ``{variable}`` placeholders.
82
+ version: Semantic version string (e.g. ``"1.0.0"``).
83
+ variables: List of placeholder names extracted from *template*.
84
+ created_at: Unix timestamp when this version was registered.
85
+ metadata: Free-form metadata dict (author, model hint, etc.).
86
+ """
87
+
88
+ name: str
89
+ template: str
90
+ version: str
91
+ variables: list[str] = field(default_factory=list)
92
+ created_at: float = field(default_factory=time.time)
93
+ metadata: dict[str, Any] | None = None
94
+
95
+ def render(self, variables: dict[str, Any]) -> str:
96
+ """Render the template by substituting *variables*.
97
+
98
+ Args:
99
+ variables: Dict of ``{placeholder: value}`` pairs.
100
+
101
+ Returns:
102
+ The rendered string.
103
+
104
+ Raises:
105
+ KeyError: If a required placeholder is missing from *variables*.
106
+
107
+ Example::
108
+
109
+ pv = PromptVersion("greet", "Hello, {name}!", "1.0.0", ["name"])
110
+ pv.render({"name": "Alice"})
111
+ # "Hello, Alice!"
112
+ """
113
+ missing = [v for v in self.variables if v not in variables]
114
+ if missing:
115
+ raise KeyError(
116
+ f"PromptVersion '{self.name}@{self.version}' requires variables "
117
+ f"{missing!r} but they were not supplied."
118
+ )
119
+ return self.template.format(**variables)
120
+
121
+ def to_dict(self) -> dict[str, Any]:
122
+ d: dict[str, Any] = {
123
+ "name": self.name,
124
+ "template": self.template,
125
+ "version": self.version,
126
+ "variables": self.variables,
127
+ "created_at": self.created_at,
128
+ }
129
+ if self.metadata is not None:
130
+ d["metadata"] = self.metadata
131
+ return d
132
+
133
+ @classmethod
134
+ def from_dict(cls, data: dict[str, Any]) -> "PromptVersion":
135
+ return cls(
136
+ name=data["name"],
137
+ template=data["template"],
138
+ version=data["version"],
139
+ variables=list(data.get("variables", [])),
140
+ created_at=float(data.get("created_at", time.time())),
141
+ metadata=data.get("metadata"),
142
+ )
143
+
144
+
145
+ # ---------------------------------------------------------------------------
146
+ # PromptRegistry
147
+ # ---------------------------------------------------------------------------
148
+
149
+
150
+ class PromptRegistry:
151
+ """Thread-safe registry of versioned prompt templates.
152
+
153
+ Multiple versions of the same template name are stored independently.
154
+ The *latest* version (most recently registered) is used by default when
155
+ calling :meth:`render`.
156
+
157
+ Example::
158
+
159
+ registry = PromptRegistry()
160
+ registry.register("system", "You are {role}.", version="1.0.0")
161
+ registry.register("system", "You are a helpful {role}.", version="2.0.0")
162
+
163
+ # Uses version 2.0.0 (latest).
164
+ registry.render("system", {"role": "assistant"})
165
+ """
166
+
167
+ def __init__(self) -> None:
168
+ import threading # noqa: PLC0415
169
+ self._lock = threading.RLock()
170
+ # {name: {version: PromptVersion}}
171
+ self._store: dict[str, dict[str, PromptVersion]] = {}
172
+ # {name: version_string} — last registered version = default
173
+ self._latest: dict[str, str] = {}
174
+
175
+ # ------------------------------------------------------------------
176
+ # Registration
177
+ # ------------------------------------------------------------------
178
+
179
+ def register(
180
+ self,
181
+ name: str,
182
+ template: str,
183
+ *,
184
+ version: str = "1.0.0",
185
+ metadata: dict[str, Any] | None = None,
186
+ ) -> PromptVersion:
187
+ """Register (or update) a prompt template.
188
+
189
+ Emits:
190
+ * ``llm.prompt.template.loaded`` on first registration.
191
+ * ``llm.prompt.version.changed`` when a *name* already exists
192
+ (even if the version string is the same).
193
+
194
+ Args:
195
+ name: Unique prompt name within this registry.
196
+ template: Template string with ``{variable}`` placeholders.
197
+ version: Semantic version string.
198
+ metadata: Optional free-form metadata.
199
+
200
+ Returns:
201
+ The newly created :class:`PromptVersion`.
202
+ """
203
+ variables = _PLACEHOLDER_RE.findall(template)
204
+ pv = PromptVersion(
205
+ name=name,
206
+ template=template,
207
+ version=version,
208
+ variables=variables,
209
+ metadata=metadata,
210
+ )
211
+ with self._lock:
212
+ existing = self._store.get(name)
213
+ is_new = existing is None
214
+ self._store.setdefault(name, {})[version] = pv
215
+ previous_version = self._latest.get(name)
216
+ self._latest[name] = version
217
+
218
+ # Emit RFC-0001 events outside the lock.
219
+ self._emit_register_events(pv, is_new=is_new, previous_version=previous_version)
220
+ return pv
221
+
222
+ # ------------------------------------------------------------------
223
+ # Retrieval
224
+ # ------------------------------------------------------------------
225
+
226
+ def get(self, name: str, version: str | None = None) -> PromptVersion:
227
+ """Return the :class:`PromptVersion` for *name*.
228
+
229
+ Args:
230
+ name: Prompt name.
231
+ version: Explicit version string, or ``None`` for the latest.
232
+
233
+ Raises:
234
+ KeyError: If *name* or *version* is not found.
235
+ """
236
+ with self._lock:
237
+ versions = self._store.get(name)
238
+ if versions is None:
239
+ raise KeyError(f"No prompt registered with name={name!r}")
240
+ if version is None:
241
+ version = self._latest[name]
242
+ pv = versions.get(version)
243
+ if pv is None:
244
+ raise KeyError(
245
+ f"Prompt {name!r} has no version {version!r}. "
246
+ f"Available: {sorted(versions)!r}"
247
+ )
248
+ return pv
249
+
250
+ def list_versions(self, name: str) -> list[str]:
251
+ """Return all registered version strings for *name*, sorted ascending."""
252
+ with self._lock:
253
+ versions = self._store.get(name)
254
+ if versions is None:
255
+ raise KeyError(f"No prompt registered with name={name!r}")
256
+ return sorted(versions.keys())
257
+
258
+ def list_names(self) -> list[str]:
259
+ """Return all registered prompt names, sorted."""
260
+ with self._lock:
261
+ return sorted(self._store.keys())
262
+
263
+ # ------------------------------------------------------------------
264
+ # Rendering
265
+ # ------------------------------------------------------------------
266
+
267
+ def render(
268
+ self,
269
+ name: str,
270
+ variables: dict[str, Any],
271
+ *,
272
+ version: str | None = None,
273
+ span_id: str | None = None,
274
+ trace_id: str | None = None,
275
+ ) -> str:
276
+ """Render a prompt template, emitting a ``llm.prompt.rendered`` event.
277
+
278
+ Args:
279
+ name: Prompt name.
280
+ variables: Substitution variables.
281
+ version: Optional version string; defaults to latest.
282
+ span_id: Optional parent span ID for event correlation.
283
+ trace_id: Optional trace ID for event correlation.
284
+
285
+ Returns:
286
+ The rendered template string.
287
+
288
+ Raises:
289
+ KeyError: If the prompt name or version is not found, or if a
290
+ required variable is missing.
291
+ """
292
+ pv = self.get(name, version)
293
+ rendered = pv.render(variables)
294
+ self._emit_rendered_event(pv, rendered, span_id=span_id, trace_id=trace_id)
295
+ return rendered
296
+
297
+ # ------------------------------------------------------------------
298
+ # Serialisation
299
+ # ------------------------------------------------------------------
300
+
301
+ def export_all(self) -> list[dict[str, Any]]:
302
+ """Return a list of ``to_dict()`` dicts for all registered prompt versions."""
303
+ with self._lock:
304
+ result = []
305
+ for versions in self._store.values():
306
+ for pv in versions.values():
307
+ result.append(pv.to_dict())
308
+ return result
309
+
310
+ def import_all(self, records: list[dict[str, Any]]) -> None:
311
+ """Bulk-import prompt versions from a list of dicts (no events emitted)."""
312
+ with self._lock:
313
+ for rec in records:
314
+ pv = PromptVersion.from_dict(rec)
315
+ self._store.setdefault(pv.name, {})[pv.version] = pv
316
+ self._latest[pv.name] = pv.version
317
+
318
+ # ------------------------------------------------------------------
319
+ # Internal event helpers
320
+ # ------------------------------------------------------------------
321
+
322
+ def _emit_register_events(
323
+ self,
324
+ pv: PromptVersion,
325
+ *,
326
+ is_new: bool,
327
+ previous_version: str | None,
328
+ ) -> None:
329
+ try:
330
+ from spanforge._stream import emit_rfc_event # noqa: PLC0415
331
+ from spanforge.types import EventType # noqa: PLC0415
332
+ if is_new:
333
+ emit_rfc_event(
334
+ EventType.PROMPT_TEMPLATE_LOADED,
335
+ payload=pv.to_dict(),
336
+ )
337
+ else:
338
+ emit_rfc_event(
339
+ EventType.PROMPT_VERSION_CHANGED,
340
+ payload={
341
+ **pv.to_dict(),
342
+ "previous_version": previous_version,
343
+ },
344
+ )
345
+ except Exception as exc: # NOSONAR
346
+ _log.debug("prompt_registry: failed to emit register event: %s", exc)
347
+
348
+ def _emit_rendered_event(
349
+ self,
350
+ pv: PromptVersion,
351
+ rendered: str,
352
+ *,
353
+ span_id: str | None,
354
+ trace_id: str | None,
355
+ ) -> None:
356
+ try:
357
+ from spanforge._stream import emit_rfc_event # noqa: PLC0415
358
+ from spanforge.types import EventType # noqa: PLC0415
359
+ emit_rfc_event(
360
+ EventType.PROMPT_RENDERED,
361
+ payload={
362
+ "name": pv.name,
363
+ "version": pv.version,
364
+ # Omit the rendered text to avoid leaking PII; include
365
+ # only the prompt name/version for correlation.
366
+ "rendered_length": len(rendered),
367
+ },
368
+ span_id=span_id,
369
+ trace_id=trace_id,
370
+ )
371
+ except Exception as exc: # NOSONAR
372
+ _log.debug("prompt_registry: failed to emit rendered event: %s", exc)
373
+
374
+
375
+ # ---------------------------------------------------------------------------
376
+ # Module-level singleton + helpers
377
+ # ---------------------------------------------------------------------------
378
+
379
+ _DEFAULT_REGISTRY = PromptRegistry()
380
+
381
+
382
+ def register_prompt(
383
+ name: str,
384
+ template: str,
385
+ *,
386
+ version: str = "1.0.0",
387
+ metadata: dict[str, Any] | None = None,
388
+ ) -> PromptVersion:
389
+ """Register a prompt in the module-level default registry.
390
+
391
+ Convenience wrapper around :meth:`PromptRegistry.register`.
392
+ """
393
+ return _DEFAULT_REGISTRY.register(name, template, version=version, metadata=metadata)
394
+
395
+
396
+ def get_prompt_version(name: str, version: str | None = None) -> PromptVersion:
397
+ """Get a :class:`PromptVersion` from the module-level default registry."""
398
+ return _DEFAULT_REGISTRY.get(name, version)
399
+
400
+
401
+ def render_prompt(
402
+ name: str,
403
+ variables: dict[str, Any],
404
+ *,
405
+ version: str | None = None,
406
+ span_id: str | None = None,
407
+ trace_id: str | None = None,
408
+ ) -> str:
409
+ """Render a prompt from the module-level default registry.
410
+
411
+ Convenience wrapper around :meth:`PromptRegistry.render`.
412
+ """
413
+ return _DEFAULT_REGISTRY.render(
414
+ name, variables, version=version, span_id=span_id, trace_id=trace_id
415
+ )
spanforge/py.typed ADDED
File without changes