1"""PromptService — centralised template management, rendering, and escaping.
2
3Design decisions
4----------------
5* Templates are loaded **at construction time** (boot phase). Hot-reload is
6 out of scope; the service is read-only at runtime.
7* Templates are addressed by ``(name, version)``. ``version="latest"`` resolves
8 to the most recently registered version for that name.
9* Variable substitution delegates to :class:`~lexigram.ai.prompt.rendering.engine.PromptRenderer`
10 so every :class:`~lexigram.ai.prompt.rendering.engine.RenderFormat` is
11 supported through the service.
12* Provider-specific escaping is applied to variable *values* **before**
13 substitution so that special characters in values do not break the template.
14 Full escaping rules are stubbed — see ``_apply_provider_escaping``.
15* The observability hook (``PromptObserverProtocol``) is called synchronously
16 after every successful render. Exceptions from the observer are caught and
17 logged as warnings so they never break the caller.
18"""
19
20from __future__ import annotations
21
22import asyncio
23from typing import Any, Protocol, runtime_checkable
24
25from lexigram.ai.prompt.constants import DEFAULT_RENDER_FORMAT
26from lexigram.ai.prompt.exceptions import (
27 PromptConfigError,
28 PromptNotFoundError,
29 PromptRenderError,
30)
31from lexigram.ai.prompt.hooks import (
32 PromptInputSanitizedHook,
33 PromptRenderedHook,
34 PromptTemplateResolvedHook,
35)
36from lexigram.ai.prompt.rendering.engine import PromptRenderer, RenderFormat
37from lexigram.ai.prompt.rendering.sanitizer import InputSanitizer
38from lexigram.ai.prompt.service.models import (
39 LLMProvider,
40 PromptRenderRequest,
41 PromptRenderResult,
42 PromptTemplate,
43)
44from lexigram.ai.prompt.service.observer import (
45 NoOpPromptObserver,
46 PromptObserverProtocol,
47)
48from lexigram.contracts.core.hooks import HookRegistryProtocol
49from lexigram.logging import get_logger
50
51logger = get_logger(__name__)
52
53# Sentinel used internally when resolving "latest".
54_LATEST = "latest"
55
56
57@runtime_checkable
58class PromptServiceProtocol(Protocol):
59 """Structural protocol for PromptService.
60
61 Inject this type in DI containers so consumers don't depend on the
62 concrete class.
63 """
64
65 def render(self, request: PromptRenderRequest) -> PromptRenderResult:
66 """Render a named prompt template.
67
68 Args:
69 request: Lookup key + variable values.
70
71 Returns:
72 The rendered result.
73
74 Raises:
75 :class:`~lexigram.ai.prompt.exceptions.PromptNotFoundError`:
76 No template with the given ``(name, version)`` is registered.
77 :class:`~lexigram.ai.prompt.exceptions.PromptRenderError`:
78 A required variable is missing from the request.
79 """
80 ...
81
82 def get_template(self, name: str, version: str = _LATEST) -> PromptTemplate:
83 """Return the :class:`~lexigram.ai.prompt.service.models.PromptTemplate`
84 for ``(name, version)`` without rendering it."""
85 ...
86
87 def list_templates(self) -> list[tuple[str, str]]:
88 """Return all registered ``(name, version)`` pairs."""
89 ...
90
91
92class PromptService:
93 """Injectable service for loading, versioning, and rendering prompt templates.
94
95 Instantiate at boot time (via the DI provider) with one or more
96 :class:`~lexigram.ai.prompt.service.loader.PromptLoaderProtocol` sources.
97
98 Args:
99 templates: Pre-built list of :class:`~lexigram.ai.prompt.service.models.PromptTemplate`
100 objects. Pass an empty list and call :meth:`_register` if
101 you are building the service manually.
102 observer: Optional observability hook. Defaults to
103 :class:`~lexigram.ai.prompt.service.observer.NoOpPromptObserver`.
104 sanitizer: Optional :class:`~lexigram.ai.prompt.rendering.sanitizer.InputSanitizer`
105 applied to resolved variables before substitution. When
106 attached, the ``prompt.input_sanitized`` hook fires after
107 every successful scan.
108
109 Example::
110
111 from lexigram.ai.prompt.service import (
112 DictPromptLoader, PromptService, PromptRenderRequest
113 )
114
115 loader = DictPromptLoader([
116 {
117 "name": "greeting",
118 "version": "v1",
119 "content": "Hello, {name}!",
120 "required_variables": ["name"],
121 "provider": "anthropic",
122 }
123 ])
124 service = PromptService(loader.load())
125 result = service.render(PromptRenderRequest(name="greeting", variables={"name": "Alice"}))
126 print(result.rendered) # Hello, Alice!
127 """
128
129 def __init__(
130 self,
131 templates: list[PromptTemplate],
132 observer: PromptObserverProtocol | None = None,
133 hook_registry: HookRegistryProtocol | None = None,
134 sanitizer: InputSanitizer | None = None,
135 ) -> None:
136 self._observer: PromptObserverProtocol = observer or NoOpPromptObserver()
137 self._hooks: HookRegistryProtocol | None = hook_registry
138 self._sanitizer = sanitizer
139 self._hook_tasks: set[asyncio.Task[None]] = set()
140 # _store: name → {version → PromptTemplate}
141 self._store: dict[str, dict[str, PromptTemplate]] = {}
142 # _latest: name → most-recently-registered version string
143 self._latest: dict[str, str] = {}
144
145 for tmpl in templates:
146 self._register(tmpl)
147
148 logger.debug(
149 "prompt_service_initialised",
150 template_count=sum(len(v) for v in self._store.values()),
151 )
152
153 def attach_hook_registry(self, hook_registry: HookRegistryProtocol | None) -> None:
154 """Attach (or detach, when ``None``) the framework hook registry.
155
156 Hook actions are emitted fire-and-forget (see
157 :meth:`~lexigram.ai.prompt.service.service.PromptService._emit_action`).
158 """
159 self._hooks = hook_registry
160
161 # ------------------------------------------------------------------
162 # Public API
163 # ------------------------------------------------------------------
164
165 def render(self, request: PromptRenderRequest) -> PromptRenderResult:
166 """Render a named prompt template.
167
168 Args:
169 request: Lookup key, variables, and optional pinned version.
170
171 Returns:
172 :class:`~lexigram.ai.prompt.service.models.PromptRenderResult`
173
174 Raises:
175 :class:`~lexigram.ai.prompt.exceptions.PromptNotFoundError`:
176 No template with the requested ``(name, version)``.
177 :class:`~lexigram.ai.prompt.exceptions.PromptRenderError`:
178 A required variable is missing or substitution fails.
179 """
180 tmpl = self.get_template(request.name, request.version)
181 resolved = self._resolve_variables(tmpl, request.variables)
182 self._sanitize_inputs(resolved)
183 escaped = self._apply_provider_escaping(tmpl.provider, resolved)
184 rendered = self._substitute(tmpl.content, escaped, tmpl.name, tmpl.format)
185
186 result = PromptRenderResult(
187 name=tmpl.name,
188 version=tmpl.version,
189 rendered=rendered,
190 provider=tmpl.provider,
191 )
192
193 self._notify_observer(tmpl.name, tmpl.version, resolved, rendered, tmpl.format)
194 return result
195
196 def get_template(self, name: str, version: str = _LATEST) -> PromptTemplate:
197 """Look up a template by name and optional version.
198
199 Args:
200 name: Template name.
201 version: Version string or ``"latest"`` (default).
202
203 Returns:
204 The matching :class:`~lexigram.ai.prompt.service.models.PromptTemplate`.
205
206 Raises:
207 :class:`~lexigram.ai.prompt.exceptions.PromptNotFoundError`:
208 Name or version is not registered.
209 """
210 versions = self._store.get(name)
211 if versions is None:
212 available = sorted(self._store.keys())
213 raise PromptNotFoundError(
214 f"No prompt template registered under '{name}'. "
215 f"Available names: {available!r}"
216 )
217
218 resolved_version = self._latest[name] if version == _LATEST else version
219 tmpl = versions.get(resolved_version)
220 if tmpl is None:
221 raise PromptNotFoundError(
222 f"Template '{name}' version '{resolved_version}' not found. "
223 f"Available versions: {sorted(versions.keys())!r}"
224 )
225 self._emit_action(
226 "prompt.template_resolved",
227 PromptTemplateResolvedHook(template_name=name),
228 )
229 return tmpl
230
231 def list_templates(self) -> list[tuple[str, str]]:
232 """Return all ``(name, version)`` pairs sorted by name then version."""
233 pairs: list[tuple[str, str]] = []
234 for name, versions in sorted(self._store.items()):
235 for version in sorted(versions.keys()):
236 pairs.append((name, version))
237 return pairs
238
239 # ------------------------------------------------------------------
240 # Internal helpers
241 # ------------------------------------------------------------------
242
243 def _register(self, tmpl: PromptTemplate) -> None:
244 """Store a template and update the 'latest' pointer."""
245 versions = self._store.setdefault(tmpl.name, {})
246 versions[tmpl.version] = tmpl
247 # Last-registered wins for "latest".
248 self._latest[tmpl.name] = tmpl.version
249
250 def _resolve_variables(
251 self,
252 tmpl: PromptTemplate,
253 supplied: dict[str, Any],
254 ) -> dict[str, Any]:
255 """Merge supplied variables with optional defaults and enforce required vars.
256
257 Raises:
258 :class:`~lexigram.ai.prompt.exceptions.PromptRenderError`:
259 A required variable was not supplied.
260 """
261 resolved: dict[str, Any] = dict(tmpl.optional_defaults)
262 resolved.update(supplied)
263
264 missing = [v for v in tmpl.required_variables if v not in resolved]
265 if missing:
266 raise PromptRenderError(
267 f"Template '{tmpl.name}' v{tmpl.version}: "
268 f"missing required variable(s): {missing!r}. "
269 f"Supplied: {sorted(supplied.keys())!r}"
270 )
271
272 return resolved
273
274 def _substitute(
275 self,
276 content: str,
277 variables: dict[str, Any],
278 template_name: str,
279 render_format: RenderFormat = DEFAULT_RENDER_FORMAT,
280 ) -> str:
281 """Perform variable substitution using the template's declared format.
282
283 Delegates to :class:`~lexigram.ai.prompt.rendering.engine.PromptRenderer`
284 so the service supports every :class:`RenderFormat` value:
285 ``f_string`` (``{variable}``), ``jinja2`` (``{{ variable }}``,
286 loops/filters/conditionals), ``dollar`` (``$variable``), and
287 ``simple`` (literal, no substitution).
288
289 Raises:
290 :class:`~lexigram.ai.prompt.exceptions.PromptRenderError`:
291 A placeholder in the template has no corresponding variable, or
292 the Jinja2 template contains a syntax/undefined-variable error.
293 :class:`~lexigram.ai.prompt.exceptions.PromptConfigError`:
294 ``render_format`` is not a valid
295 :class:`~lexigram.ai.prompt.rendering.engine.RenderFormat`.
296 """
297 if not isinstance(render_format, RenderFormat):
298 raise PromptConfigError(
299 f"Template '{template_name}': unknown render format {render_format!r}. "
300 f"Valid values: {[f.value for f in RenderFormat]!r}"
301 )
302
303 renderer = PromptRenderer(render_format)
304 try:
305 return renderer.render(content, variables)
306 except PromptRenderError:
307 raise
308 except Exception as exc:
309 raise PromptRenderError(
310 f"Template '{template_name}': {render_format.value} rendering error — {exc}"
311 ) from exc
312
313 @staticmethod
314 def _apply_provider_escaping(
315 provider: LLMProvider,
316 variables: dict[str, Any],
317 ) -> dict[str, Any]:
318 """Apply provider-specific escaping to variable *values* before substitution.
319
320 Status: scaffolded seam — minimal real escaping is in place; full
321 provider-specific rules are a TODO.
322
323 Anthropic:
324 Values are passed through as-is. A future implementation would
325 wrap values that contain ``{`` / ``}`` or XML-special characters in
326 ``<parameter name="…">…</parameter>`` tags to prevent prompt
327 injection. The seam is here; the full rule set is out of scope.
328
329 OpenAI / Azure:
330 Literal ``{`` and ``}`` characters inside values are escaped to
331 ``{{`` and ``}}`` to prevent them being interpreted as format
332 placeholders by downstream callers that re-template the output.
333
334 Generic:
335 No escaping.
336 """
337 if provider in (LLMProvider.GENERIC, LLMProvider.ANTHROPIC):
338 # TODO(LEX-009): Anthropic — wrap values in <parameter> XML tags
339 # when the value contains characters that could break the Claude
340 # system prompt (e.g. angle brackets or unbalanced braces).
341 return variables
342
343 if provider in (LLMProvider.OPENAI, LLMProvider.AZURE):
344 escaped: dict[str, Any] = {}
345 for key, val in variables.items():
346 if isinstance(val, str):
347 # Escape literal braces so they survive any subsequent
348 # str.format_map calls on the rendered output.
349 escaped[key] = val.replace("{", "{{").replace("}", "}}")
350 else:
351 escaped[key] = val
352 return escaped
353
354 # Unreachable with current enum, but safe fallback.
355 return variables # pragma: no cover
356
357 def _sanitize_inputs(self, variables: dict[str, Any]) -> None:
358 """Scan resolved variables for prompt-injection patterns when a sanitizer is attached.
359
360 Raises:
361 :class:`~lexigram.ai.prompt.exceptions.PromptValidationError`:
362 An injection pattern is detected and the sanitizer is strict.
363 """
364 if self._sanitizer is None:
365 return
366 self._sanitizer.sanitize_all(variables)
367 self._emit_action("prompt.input_sanitized", PromptInputSanitizedHook())
368
369 def _notify_observer(
370 self,
371 name: str,
372 version: str,
373 variables: dict[str, Any],
374 rendered_output: str,
375 render_format: RenderFormat,
376 ) -> None:
377 """Call the observer and emit the ``prompt.rendered`` hook, swallowing errors."""
378 try:
379 self._observer.on_render(
380 name,
381 version,
382 variables,
383 rendered_output,
384 render_format,
385 )
386 except Exception as exc: # noqa: BLE001
387 logger.warning(
388 "prompt_observer_error",
389 template=name,
390 version=version,
391 error=str(exc),
392 )
393 self._emit_action(
394 "prompt.rendered",
395 PromptRenderedHook(render_format=render_format),
396 )
397
398 def _emit_action(self, hook_name: str, payload: object) -> None:
399 """Fire a hook action fire-and-forget when a registry and running event loop exist.
400
401 Action hooks are fire-and-forget by design
402 (:class:`~lexigram.hooks.registry.HookRegistry`). When no registry is
403 attached, or no event loop is running (e.g. sync CLI code), the hook
404 is skipped — the observer remains the always-synchronous
405 observability surface.
406 """
407 if self._hooks is None:
408 return
409 try:
410 asyncio.get_running_loop()
411 except RuntimeError:
412 return
413 task = asyncio.create_task(self._hooks.call_action(hook_name, payload=payload))
414 self._hook_tasks.add(task)
415 task.add_done_callback(self._hook_tasks.discard)
416
417
418__all__ = ["PromptService", "PromptServiceProtocol"]