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
spanforge/processor.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""spanforge.processor — Span processor pipeline (RFC-0001 §18).
|
|
2
|
+
|
|
3
|
+
Span processors let users hook into the span lifecycle **before** and
|
|
4
|
+
**after** a span is exported. Common uses:
|
|
5
|
+
|
|
6
|
+
* Attribute enrichment (e.g. add ``k8s.pod_name`` to every span)
|
|
7
|
+
* Redaction of sensitive fields (complementing built-in :class:`~spanforge.redact.RedactionPolicy`)
|
|
8
|
+
* Custom metrics counters / latency histograms
|
|
9
|
+
* Distributed context propagation helpers
|
|
10
|
+
|
|
11
|
+
Usage::
|
|
12
|
+
|
|
13
|
+
from spanforge import configure
|
|
14
|
+
from spanforge.processor import SpanProcessor, ProcessorChain
|
|
15
|
+
|
|
16
|
+
class EnrichProcessor(SpanProcessor):
|
|
17
|
+
def on_start(self, span) -> None:
|
|
18
|
+
span.set_attribute("service.region", "us-east-1")
|
|
19
|
+
|
|
20
|
+
def on_end(self, span) -> None:
|
|
21
|
+
# span is already finalised with status / duration
|
|
22
|
+
if span.status == "error":
|
|
23
|
+
span.set_attribute("alert.triggered", True)
|
|
24
|
+
|
|
25
|
+
configure(span_processors=[EnrichProcessor()])
|
|
26
|
+
|
|
27
|
+
Processors receive the *live* :class:`~spanforge._span.Span` object.
|
|
28
|
+
Mutations made in ``on_start`` are visible to user code inside the ``with``
|
|
29
|
+
block. Mutations made in ``on_end`` appear in the exported payload.
|
|
30
|
+
|
|
31
|
+
Thread-safety
|
|
32
|
+
-------------
|
|
33
|
+
Processors are called from the thread that owns the span context manager, so
|
|
34
|
+
they run in the same thread/task as the user code. Processors MUST NOT
|
|
35
|
+
block the event loop; long-running work should be dispatched to a background
|
|
36
|
+
thread or asyncio task.
|
|
37
|
+
|
|
38
|
+
Error handling
|
|
39
|
+
--------------
|
|
40
|
+
Exceptions propagating from a processor are silently caught so that a buggy
|
|
41
|
+
processor never aborts user code. Errors are logged at ``WARNING`` level.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
import contextlib
|
|
47
|
+
import logging
|
|
48
|
+
import threading
|
|
49
|
+
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
|
50
|
+
|
|
51
|
+
if TYPE_CHECKING:
|
|
52
|
+
from spanforge._span import Span
|
|
53
|
+
|
|
54
|
+
__all__ = [
|
|
55
|
+
"NoopSpanProcessor",
|
|
56
|
+
"ProcessorChain",
|
|
57
|
+
"SpanProcessor",
|
|
58
|
+
"add_processor",
|
|
59
|
+
"clear_processors",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
_proc_logger = logging.getLogger("spanforge.processor")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
# Protocol
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@runtime_checkable
|
|
71
|
+
class SpanProcessor(Protocol):
|
|
72
|
+
"""Protocol implemented by all span processors.
|
|
73
|
+
|
|
74
|
+
Both methods are optional — a processor that only enriches on start can
|
|
75
|
+
omit ``on_end``, and vice-versa. The default no-op implementations
|
|
76
|
+
defined in this protocol mean partial implementations work correctly.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def on_start(self, span: Span) -> None:
|
|
80
|
+
"""Called synchronously immediately after the span is created.
|
|
81
|
+
|
|
82
|
+
The span has been pushed onto the context stack and its start time
|
|
83
|
+
recorded. Attributes may be freely added or mutated here.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
span: The newly created :class:`~spanforge._span.Span` (mutable).
|
|
87
|
+
"""
|
|
88
|
+
...
|
|
89
|
+
|
|
90
|
+
def on_end(self, span: Span) -> None:
|
|
91
|
+
"""Called synchronously after the span is finalised but before export.
|
|
92
|
+
|
|
93
|
+
``span.end_ns``, ``span.duration_ms``, and ``span.status`` are all
|
|
94
|
+
set by the time this method runs. Attributes may still be mutated
|
|
95
|
+
and will appear in the exported :class:`~spanforge.namespaces.trace.SpanPayload`.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
span: The finalised :class:`~spanforge._span.Span` (still mutable).
|
|
99
|
+
"""
|
|
100
|
+
...
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
# No-op implementation (default)
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class NoopSpanProcessor:
|
|
109
|
+
"""Span processor that does nothing. Used as the default."""
|
|
110
|
+
|
|
111
|
+
def on_start(self, span: Span) -> None:
|
|
112
|
+
"""No-op span start hook."""
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
def on_end(self, span: Span) -> None:
|
|
116
|
+
"""No-op span end hook."""
|
|
117
|
+
pass
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
# Processor chain
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class ProcessorChain:
|
|
126
|
+
"""An ordered chain of :class:`SpanProcessor` implementations.
|
|
127
|
+
|
|
128
|
+
Processors are called in insertion order for ``on_start`` and in the
|
|
129
|
+
**same** order for ``on_end``. Errors are caught per-processor so a
|
|
130
|
+
bug in one processor does not prevent subsequent processors from running.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
processors: Initial list of processors.
|
|
134
|
+
|
|
135
|
+
Example::
|
|
136
|
+
|
|
137
|
+
chain = ProcessorChain([EnrichProcessor(), RedactProcessor()])
|
|
138
|
+
chain.on_start(span)
|
|
139
|
+
# ... later ...
|
|
140
|
+
chain.on_end(span)
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
def __init__(self, processors: list[Any] | None = None) -> None:
|
|
144
|
+
self._processors: list[Any] = list(processors or [])
|
|
145
|
+
self._lock = threading.Lock()
|
|
146
|
+
|
|
147
|
+
def add(self, processor: Any) -> None:
|
|
148
|
+
"""Append *processor* to the chain."""
|
|
149
|
+
with self._lock:
|
|
150
|
+
self._processors.append(processor)
|
|
151
|
+
|
|
152
|
+
def remove(self, processor: Any) -> None:
|
|
153
|
+
"""Remove *processor* from the chain (no-op if not present)."""
|
|
154
|
+
with self._lock, contextlib.suppress(ValueError):
|
|
155
|
+
self._processors.remove(processor)
|
|
156
|
+
|
|
157
|
+
def clear(self) -> None:
|
|
158
|
+
"""Remove all processors from the chain."""
|
|
159
|
+
with self._lock:
|
|
160
|
+
self._processors.clear()
|
|
161
|
+
|
|
162
|
+
def on_start(self, span: Span) -> None:
|
|
163
|
+
"""Fire ``on_start`` on all processors in order."""
|
|
164
|
+
with self._lock:
|
|
165
|
+
procs = list(self._processors) # snapshot to avoid holding lock during callbacks
|
|
166
|
+
for proc in procs:
|
|
167
|
+
try:
|
|
168
|
+
proc.on_start(span)
|
|
169
|
+
except Exception as exc: # NOSONAR
|
|
170
|
+
_proc_logger.warning(
|
|
171
|
+
"SpanProcessor.on_start error in %r: %s", type(proc).__name__, exc
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
def on_end(self, span: Span) -> None:
|
|
175
|
+
"""Fire ``on_end`` on all processors in order."""
|
|
176
|
+
with self._lock:
|
|
177
|
+
procs = list(self._processors) # snapshot to avoid holding lock during callbacks
|
|
178
|
+
for proc in procs:
|
|
179
|
+
try:
|
|
180
|
+
proc.on_end(span)
|
|
181
|
+
except Exception as exc: # NOSONAR
|
|
182
|
+
_proc_logger.warning(
|
|
183
|
+
"SpanProcessor.on_end error in %r: %s", type(proc).__name__, exc
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
def __len__(self) -> int:
|
|
187
|
+
with self._lock:
|
|
188
|
+
return len(self._processors)
|
|
189
|
+
|
|
190
|
+
def __repr__(self) -> str:
|
|
191
|
+
with self._lock:
|
|
192
|
+
names = [type(p).__name__ for p in self._processors]
|
|
193
|
+
return f"ProcessorChain({names!r})"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ---------------------------------------------------------------------------
|
|
197
|
+
# Module-level helpers — called from _span.py
|
|
198
|
+
# ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _run_on_start(span: Span) -> None:
|
|
202
|
+
"""Fire ``on_start`` on all processors registered in the active config."""
|
|
203
|
+
try:
|
|
204
|
+
from spanforge.config import get_config
|
|
205
|
+
|
|
206
|
+
processors = get_config().span_processors
|
|
207
|
+
except Exception: # NOSONAR
|
|
208
|
+
return
|
|
209
|
+
for proc in processors:
|
|
210
|
+
try:
|
|
211
|
+
proc.on_start(span)
|
|
212
|
+
except Exception as exc: # NOSONAR
|
|
213
|
+
_proc_logger.warning("SpanProcessor.on_start error in %r: %s", type(proc).__name__, exc)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _run_on_end(span: Span) -> None:
|
|
217
|
+
"""Fire ``on_end`` on all processors registered in the active config."""
|
|
218
|
+
try:
|
|
219
|
+
from spanforge.config import get_config
|
|
220
|
+
|
|
221
|
+
processors = get_config().span_processors
|
|
222
|
+
except Exception: # NOSONAR
|
|
223
|
+
return
|
|
224
|
+
for proc in processors:
|
|
225
|
+
try:
|
|
226
|
+
proc.on_end(span)
|
|
227
|
+
except Exception as exc: # NOSONAR
|
|
228
|
+
_proc_logger.warning("SpanProcessor.on_end error in %r: %s", type(proc).__name__, exc)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def add_processor(processor: Any) -> None:
|
|
232
|
+
"""Append *processor* to the global span processor list in the active config.
|
|
233
|
+
|
|
234
|
+
Convenience wrapper around ``configure(span_processors=[...])``.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
processor: Any object implementing :class:`SpanProcessor` protocol.
|
|
238
|
+
|
|
239
|
+
Example::
|
|
240
|
+
|
|
241
|
+
from spanforge.processor import add_processor, SpanProcessor
|
|
242
|
+
|
|
243
|
+
class Enricher(SpanProcessor):
|
|
244
|
+
def on_start(self, span): span.set_attribute("region", "eu-west-1")
|
|
245
|
+
def on_end(self, span): pass
|
|
246
|
+
|
|
247
|
+
add_processor(Enricher())
|
|
248
|
+
"""
|
|
249
|
+
from spanforge.config import get_config
|
|
250
|
+
|
|
251
|
+
get_config().span_processors.append(processor)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def clear_processors() -> None:
|
|
255
|
+
"""Remove all span processors from the active config."""
|
|
256
|
+
from spanforge.config import get_config
|
|
257
|
+
|
|
258
|
+
get_config().span_processors.clear()
|
|
@@ -0,0 +1,418 @@
|
|
|
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
|
+
"""Serialise to a plain dict."""
|
|
123
|
+
d: dict[str, Any] = {
|
|
124
|
+
"name": self.name,
|
|
125
|
+
"template": self.template,
|
|
126
|
+
"version": self.version,
|
|
127
|
+
"variables": self.variables,
|
|
128
|
+
"created_at": self.created_at,
|
|
129
|
+
}
|
|
130
|
+
if self.metadata is not None:
|
|
131
|
+
d["metadata"] = self.metadata
|
|
132
|
+
return d
|
|
133
|
+
|
|
134
|
+
@classmethod
|
|
135
|
+
def from_dict(cls, data: dict[str, Any]) -> PromptVersion:
|
|
136
|
+
"""Deserialise from a plain dict."""
|
|
137
|
+
return cls(
|
|
138
|
+
name=data["name"],
|
|
139
|
+
template=data["template"],
|
|
140
|
+
version=data["version"],
|
|
141
|
+
variables=list(data.get("variables", [])),
|
|
142
|
+
created_at=float(data.get("created_at", time.time())),
|
|
143
|
+
metadata=data.get("metadata"),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ---------------------------------------------------------------------------
|
|
148
|
+
# PromptRegistry
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class PromptRegistry:
|
|
153
|
+
"""Thread-safe registry of versioned prompt templates.
|
|
154
|
+
|
|
155
|
+
Multiple versions of the same template name are stored independently.
|
|
156
|
+
The *latest* version (most recently registered) is used by default when
|
|
157
|
+
calling :meth:`render`.
|
|
158
|
+
|
|
159
|
+
Example::
|
|
160
|
+
|
|
161
|
+
registry = PromptRegistry()
|
|
162
|
+
registry.register("system", "You are {role}.", version="1.0.0")
|
|
163
|
+
registry.register("system", "You are a helpful {role}.", version="2.0.0")
|
|
164
|
+
|
|
165
|
+
# Uses version 2.0.0 (latest).
|
|
166
|
+
registry.render("system", {"role": "assistant"})
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
def __init__(self) -> None:
|
|
170
|
+
import threading
|
|
171
|
+
|
|
172
|
+
self._lock = threading.RLock()
|
|
173
|
+
# {name: {version: PromptVersion}}
|
|
174
|
+
self._store: dict[str, dict[str, PromptVersion]] = {}
|
|
175
|
+
# {name: version_string} — last registered version = default
|
|
176
|
+
self._latest: dict[str, str] = {}
|
|
177
|
+
|
|
178
|
+
# ------------------------------------------------------------------
|
|
179
|
+
# Registration
|
|
180
|
+
# ------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
def register(
|
|
183
|
+
self,
|
|
184
|
+
name: str,
|
|
185
|
+
template: str,
|
|
186
|
+
*,
|
|
187
|
+
version: str = "1.0.0",
|
|
188
|
+
metadata: dict[str, Any] | None = None,
|
|
189
|
+
) -> PromptVersion:
|
|
190
|
+
"""Register (or update) a prompt template.
|
|
191
|
+
|
|
192
|
+
Emits:
|
|
193
|
+
* ``llm.prompt.template.loaded`` on first registration.
|
|
194
|
+
* ``llm.prompt.version.changed`` when a *name* already exists
|
|
195
|
+
(even if the version string is the same).
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
name: Unique prompt name within this registry.
|
|
199
|
+
template: Template string with ``{variable}`` placeholders.
|
|
200
|
+
version: Semantic version string.
|
|
201
|
+
metadata: Optional free-form metadata.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
The newly created :class:`PromptVersion`.
|
|
205
|
+
"""
|
|
206
|
+
variables = _PLACEHOLDER_RE.findall(template)
|
|
207
|
+
pv = PromptVersion(
|
|
208
|
+
name=name,
|
|
209
|
+
template=template,
|
|
210
|
+
version=version,
|
|
211
|
+
variables=variables,
|
|
212
|
+
metadata=metadata,
|
|
213
|
+
)
|
|
214
|
+
with self._lock:
|
|
215
|
+
existing = self._store.get(name)
|
|
216
|
+
is_new = existing is None
|
|
217
|
+
self._store.setdefault(name, {})[version] = pv
|
|
218
|
+
previous_version = self._latest.get(name)
|
|
219
|
+
self._latest[name] = version
|
|
220
|
+
|
|
221
|
+
# Emit RFC-0001 events outside the lock.
|
|
222
|
+
self._emit_register_events(pv, is_new=is_new, previous_version=previous_version)
|
|
223
|
+
return pv
|
|
224
|
+
|
|
225
|
+
# ------------------------------------------------------------------
|
|
226
|
+
# Retrieval
|
|
227
|
+
# ------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
def get(self, name: str, version: str | None = None) -> PromptVersion:
|
|
230
|
+
"""Return the :class:`PromptVersion` for *name*.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
name: Prompt name.
|
|
234
|
+
version: Explicit version string, or ``None`` for the latest.
|
|
235
|
+
|
|
236
|
+
Raises:
|
|
237
|
+
KeyError: If *name* or *version* is not found.
|
|
238
|
+
"""
|
|
239
|
+
with self._lock:
|
|
240
|
+
versions = self._store.get(name)
|
|
241
|
+
if versions is None:
|
|
242
|
+
raise KeyError(f"No prompt registered with name={name!r}")
|
|
243
|
+
if version is None:
|
|
244
|
+
version = self._latest[name]
|
|
245
|
+
pv = versions.get(version)
|
|
246
|
+
if pv is None:
|
|
247
|
+
raise KeyError(
|
|
248
|
+
f"Prompt {name!r} has no version {version!r}. Available: {sorted(versions)!r}"
|
|
249
|
+
)
|
|
250
|
+
return pv
|
|
251
|
+
|
|
252
|
+
#: Alias for :meth:`get` (F-26).
|
|
253
|
+
get_version = get
|
|
254
|
+
|
|
255
|
+
def list_versions(self, name: str) -> list[str]:
|
|
256
|
+
"""Return all registered version strings for *name*, sorted ascending."""
|
|
257
|
+
with self._lock:
|
|
258
|
+
versions = self._store.get(name)
|
|
259
|
+
if versions is None:
|
|
260
|
+
raise KeyError(f"No prompt registered with name={name!r}")
|
|
261
|
+
return sorted(versions.keys())
|
|
262
|
+
|
|
263
|
+
def list_names(self) -> list[str]:
|
|
264
|
+
"""Return all registered prompt names, sorted."""
|
|
265
|
+
with self._lock:
|
|
266
|
+
return sorted(self._store.keys())
|
|
267
|
+
|
|
268
|
+
# ------------------------------------------------------------------
|
|
269
|
+
# Rendering
|
|
270
|
+
# ------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
def render(
|
|
273
|
+
self,
|
|
274
|
+
name: str,
|
|
275
|
+
variables: dict[str, Any],
|
|
276
|
+
*,
|
|
277
|
+
version: str | None = None,
|
|
278
|
+
span_id: str | None = None,
|
|
279
|
+
trace_id: str | None = None,
|
|
280
|
+
) -> str:
|
|
281
|
+
"""Render a prompt template, emitting a ``llm.prompt.rendered`` event.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
name: Prompt name.
|
|
285
|
+
variables: Substitution variables.
|
|
286
|
+
version: Optional version string; defaults to latest.
|
|
287
|
+
span_id: Optional parent span ID for event correlation.
|
|
288
|
+
trace_id: Optional trace ID for event correlation.
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
The rendered template string.
|
|
292
|
+
|
|
293
|
+
Raises:
|
|
294
|
+
KeyError: If the prompt name or version is not found, or if a
|
|
295
|
+
required variable is missing.
|
|
296
|
+
"""
|
|
297
|
+
pv = self.get(name, version)
|
|
298
|
+
rendered = pv.render(variables)
|
|
299
|
+
self._emit_rendered_event(pv, rendered, span_id=span_id, trace_id=trace_id)
|
|
300
|
+
return rendered
|
|
301
|
+
|
|
302
|
+
# ------------------------------------------------------------------
|
|
303
|
+
# Serialisation
|
|
304
|
+
# ------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
def export_all(self) -> list[dict[str, Any]]:
|
|
307
|
+
"""Return a list of ``to_dict()`` dicts for all registered prompt versions."""
|
|
308
|
+
with self._lock:
|
|
309
|
+
return [pv.to_dict() for versions in self._store.values() for pv in versions.values()]
|
|
310
|
+
|
|
311
|
+
def import_all(self, records: list[dict[str, Any]]) -> None:
|
|
312
|
+
"""Bulk-import prompt versions from a list of dicts (no events emitted)."""
|
|
313
|
+
with self._lock:
|
|
314
|
+
for rec in records:
|
|
315
|
+
pv = PromptVersion.from_dict(rec)
|
|
316
|
+
self._store.setdefault(pv.name, {})[pv.version] = pv
|
|
317
|
+
self._latest[pv.name] = pv.version
|
|
318
|
+
|
|
319
|
+
# ------------------------------------------------------------------
|
|
320
|
+
# Internal event helpers
|
|
321
|
+
# ------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
def _emit_register_events(
|
|
324
|
+
self,
|
|
325
|
+
pv: PromptVersion,
|
|
326
|
+
*,
|
|
327
|
+
is_new: bool,
|
|
328
|
+
previous_version: str | None,
|
|
329
|
+
) -> None:
|
|
330
|
+
try:
|
|
331
|
+
from spanforge._stream import emit_rfc_event
|
|
332
|
+
from spanforge.types import EventType
|
|
333
|
+
|
|
334
|
+
if is_new:
|
|
335
|
+
emit_rfc_event(
|
|
336
|
+
EventType.PROMPT_TEMPLATE_LOADED,
|
|
337
|
+
payload=pv.to_dict(),
|
|
338
|
+
)
|
|
339
|
+
else:
|
|
340
|
+
emit_rfc_event(
|
|
341
|
+
EventType.PROMPT_VERSION_CHANGED,
|
|
342
|
+
payload={
|
|
343
|
+
**pv.to_dict(),
|
|
344
|
+
"previous_version": previous_version,
|
|
345
|
+
},
|
|
346
|
+
)
|
|
347
|
+
except Exception as exc: # NOSONAR
|
|
348
|
+
_log.debug("prompt_registry: failed to emit register event: %s", exc)
|
|
349
|
+
|
|
350
|
+
def _emit_rendered_event(
|
|
351
|
+
self,
|
|
352
|
+
pv: PromptVersion,
|
|
353
|
+
rendered: str,
|
|
354
|
+
*,
|
|
355
|
+
span_id: str | None,
|
|
356
|
+
trace_id: str | None,
|
|
357
|
+
) -> None:
|
|
358
|
+
try:
|
|
359
|
+
from spanforge._stream import emit_rfc_event
|
|
360
|
+
from spanforge.types import EventType
|
|
361
|
+
|
|
362
|
+
emit_rfc_event(
|
|
363
|
+
EventType.PROMPT_RENDERED,
|
|
364
|
+
payload={
|
|
365
|
+
"name": pv.name,
|
|
366
|
+
"version": pv.version,
|
|
367
|
+
# Omit the rendered text to avoid leaking PII; include
|
|
368
|
+
# only the prompt name/version for correlation.
|
|
369
|
+
"rendered_length": len(rendered),
|
|
370
|
+
},
|
|
371
|
+
span_id=span_id,
|
|
372
|
+
trace_id=trace_id,
|
|
373
|
+
)
|
|
374
|
+
except Exception as exc: # NOSONAR
|
|
375
|
+
_log.debug("prompt_registry: failed to emit rendered event: %s", exc)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# ---------------------------------------------------------------------------
|
|
379
|
+
# Module-level singleton + helpers
|
|
380
|
+
# ---------------------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
_DEFAULT_REGISTRY = PromptRegistry()
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def register_prompt(
|
|
386
|
+
name: str,
|
|
387
|
+
template: str,
|
|
388
|
+
*,
|
|
389
|
+
version: str = "1.0.0",
|
|
390
|
+
metadata: dict[str, Any] | None = None,
|
|
391
|
+
) -> PromptVersion:
|
|
392
|
+
"""Register a prompt in the module-level default registry.
|
|
393
|
+
|
|
394
|
+
Convenience wrapper around :meth:`PromptRegistry.register`.
|
|
395
|
+
"""
|
|
396
|
+
return _DEFAULT_REGISTRY.register(name, template, version=version, metadata=metadata)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def get_prompt_version(name: str, version: str | None = None) -> PromptVersion:
|
|
400
|
+
"""Get a :class:`PromptVersion` from the module-level default registry."""
|
|
401
|
+
return _DEFAULT_REGISTRY.get(name, version)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def render_prompt(
|
|
405
|
+
name: str,
|
|
406
|
+
variables: dict[str, Any],
|
|
407
|
+
*,
|
|
408
|
+
version: str | None = None,
|
|
409
|
+
span_id: str | None = None,
|
|
410
|
+
trace_id: str | None = None,
|
|
411
|
+
) -> str:
|
|
412
|
+
"""Render a prompt from the module-level default registry.
|
|
413
|
+
|
|
414
|
+
Convenience wrapper around :meth:`PromptRegistry.render`.
|
|
415
|
+
"""
|
|
416
|
+
return _DEFAULT_REGISTRY.render(
|
|
417
|
+
name, variables, version=version, span_id=span_id, trace_id=trace_id
|
|
418
|
+
)
|
spanforge/py.typed
ADDED
|
File without changes
|