"""
Model-Facing Prompt Templates, in Several Languages
The third localization shape the suite needs, and the one that is **not**
translation. :class:`~os_helper.Catalog` serves interface copy keyed on its
English source; :class:`~os_helper.i18n_utils.Locales` serves any text keyed
on meaning; :class:`Prompts` serves the text a *model* reads, which looks like
the second but is consumed like neither.
Why prompts are not gettext
---------------------------
A prompt's language versions are **independently authored, not translated**.
Measured in standpoint on 2026-10-22, one prompt ran 1345 characters in
English, 1532 in French and 1479 in Spanish: nobody translated that: somebody
wrote the instruction three times, because what makes a model behave in French
is not the French words of the English sentence.
Two consequences follow, and they are the whole reason this module exists
rather than a second catalogue:
* **The key is the stable thing.** Prompt engineering reworks the wording
constantly. Key on the English text, as gettext does, and every tweak to the
English orphans the other languages — which then fall back to English
silently, exactly where a wrong language is hardest to notice. Key on
``axis_prompt`` and the wording is free to move.
* **A 1300-character msgid is not a catalogue entry.** It is unreadable in a
``.po``, unusable in a translation tool, and meaningless to a translator who
is not a prompt engineer.
So prompts stay keyed on meaning, in YAML, read through :class:`Locales` —
which already parses, validates and falls back. What this class adds is the
four things every caller was otherwise writing by hand:
1. the **whole-language view** (a caller wants every prompt for one language,
not one prompt at a time), cached;
2. the **field shape** ``key.system`` / ``key.user``, for prompts that come in
parts;
3. **language resolution** for a requested tag, so ``fr-CA`` finds ``fr``;
4. a **placeholder check across languages** — the failure specific to this
shape, where a translated prompt drops a ``{field}`` the code fills and the
render raises in production rather than in a test.
Layout
------
::
prompts:
axis_prompt: # simple: key -> language -> text
en: You are naming the four poles…
fr: Vous nommez les quatre pôles…
eyeball_critique: # fielded: key -> field -> language -> text
system:
en: You are a strict visual-quality reviewer…
user:
en: Examine the image and write…
Usage example
-------------
>>> # p = Prompts.from_path("standpoint/locales/i18n.yaml", section="bundle")
>>> # p.text("axis_prompt", "fr")
>>> # p.render("title_template", "fr", domain="LLM")
>>> # p.bundle("fr")["axis_prompt"]
Author
------
[Warith HARCHAOUI](https://linkedin.com/in/warith-harchaoui/)
"""
from __future__ import annotations
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from typing import Any
from .i18n_utils import Locales, _placeholders
__all__ = ["Prompts"]
[docs]
@dataclass(frozen=True, eq=False)
class Prompts:
"""One section of a locale file, read as model-facing templates.
Build one with :meth:`from_path`; the constructor takes an already-parsed
:class:`Locales` for callers that have one.
Attributes
----------
locales : os_helper.Locales
The parsed file. Reused rather than re-read, so a package that also
has a ``gui`` section pays for one parse.
section : str
Which section holds the prompts. Named by the caller because os-helper
has no business knowing that prompts are a thing — it is the member
that decides, and members differ (``prompts`` here, ``bundle`` there).
"""
locales: Locales
section: str
# -- construction ----------------------------------------------------
[docs]
@classmethod
def from_path(cls, path: str | Path, section: str = "prompts") -> Prompts:
"""Read a locale file and take one section of it as prompts.
Parameters
----------
path : str or pathlib.Path
The ``locales/i18n.yaml`` to read. Must live **inside** the
importable package directory, or it is not package data and never
reaches the wheel.
section : str
The section holding the templates.
Returns
-------
Prompts
A handle. The file is parsed and validated now, by `Locales`.
"""
return cls(Locales.from_path(path), section)
# -- interrogation ---------------------------------------------------
@property
def keys(self) -> tuple[str, ...]:
"""Every prompt key in this section, sorted."""
return tuple(sorted(self.locales.section(self.section)))
@property
def languages(self) -> tuple[str, ...]:
"""Every language any prompt in this section is written in, sorted.
Taken from the prompts themselves rather than from ``meta``: a package
whose interface is bilingual may well have prompts in three languages,
or in one, and the file should not have to lie about either.
"""
declared = set(self.locales.supported_locales)
found: set[str] = set()
for value in self.locales.section(self.section).values():
found |= _languages_of(value, declared)
return tuple(sorted(found))
@property
def default_language(self) -> str:
"""The language served when the request names one we do not have."""
return self.locales.default_locale
[docs]
def resolve(self, language: str | None) -> str:
"""The language that will actually be served for a request.
Parameters
----------
language : str or None
A language code, possibly regional (``fr-CA``) or absent.
Returns
-------
str
*language* when prompts exist in it, its base language when only
that has them, otherwise :attr:`default_language`.
"""
from .gettext_utils import language_candidates
available = self.languages
for candidate in language_candidates(language):
if candidate in available:
return candidate
return self.default_language
# -- lookup ----------------------------------------------------------
[docs]
def text(self, key: str, language: str | None = None, *, field: str | None = None) -> str:
"""One prompt, in the best available language.
Parameters
----------
key : str
The semantic key, e.g. ``axis_prompt``.
language : str or None
Wanted language; resolved through :meth:`resolve` first, so a
regional tag or an unknown one never reaches the file.
field : str, optional
For prompts that come in parts, which part — typically
``"system"`` or ``"user"``. Omit for a single-text prompt.
Returns
-------
str
The template, unformatted. Use :meth:`render` to fill it.
Raises
------
KeyError
If the key (or the field) does not exist, or carries neither the
wanted language nor the default one. Raising beats returning the
key: a prompt that is silently the string ``axis_prompt`` would
reach a model and produce confident nonsense.
"""
entry = self._entry(key, field)
if isinstance(entry, str):
return entry
wanted = self.resolve(language)
for candidate in (wanted, self.default_language):
if candidate in entry:
found = entry[candidate]
if not isinstance(found, str):
# A locale file may hold a whole structure under a
# language — standpoint keeps its GUI strings that way.
# That is legitimate data, but it is not a template, and
# `str()`-ing a dict into a prompt would send a model the
# repr of a mapping. Point the caller at the view that
# returns it intact.
where = f"{self.section}.{key}" + (f".{field}" if field else "")
raise KeyError(
f"{self.locales.path}: {where} holds a "
f"{type(found).__name__} in {candidate!r}, not a template; "
f"read it with bundle({candidate!r}) instead"
)
return found
fallback = self.default_language
where = f"{self.section}.{key}" + (f".{field}" if field else "")
raise KeyError(
f"{self.locales.path}: {where} carries neither {wanted!r} nor the default "
f"{fallback!r}; it has {sorted(entry)}"
)
[docs]
def render(
self,
key: str,
language: str | None = None,
*,
field: str | None = None,
**values: Any,
) -> str:
"""One prompt with its ``{placeholder}`` fields filled in.
Templates are data, never code: this formats, it never evaluates. The
placeholder names are checked first, so a caller learns what it forgot
instead of sending a half-filled instruction to a model — which would
not fail, it would just answer the wrong question.
Parameters
----------
key : str
The semantic key.
language : str or None
Wanted language, resolved through :meth:`resolve`.
field : str, optional
Which part, for a fielded prompt.
**values
One value per placeholder in the template.
Returns
-------
str
The formatted prompt.
Raises
------
KeyError
If the template needs a placeholder no value was given for.
"""
template = self.text(key, language, field=field)
missing = _placeholders(template) - set(values)
if missing:
where = f"{self.section}.{key}" + (f".{field}" if field else "")
raise KeyError(
f"{self.locales.path}: {where} needs {sorted(missing)}, "
f"which render() was not given"
)
return template.format(**values)
[docs]
@lru_cache(maxsize=16) # noqa: B019
def bundle(self, language: str | None = None) -> dict[str, Any]:
"""Every prompt for one language at once.
This is how prompts are actually consumed: a caller resolves the
language once, then reads several templates. Asking key by key means
resolving the language again for each, which is both slower and an
invitation to resolve it inconsistently.
Parameters
----------
language : str or None
Wanted language, resolved through :meth:`resolve`.
Returns
-------
dict
``{key: text}`` for simple prompts, ``{key: {field: text}}`` for
fielded ones. Values come back **as stored**, so a key holding a
structure for that language (a nested sub-tree, not a template)
survives intact. A key with nothing for this language and nothing
for the default is omitted rather than raising: a bundle is a
view, and a caller that needs one key should ask for it by name
and get the error.
"""
wanted = self.resolve(language)
declared = set(self.locales.supported_locales)
out: dict[str, Any] = {}
for key, value in self.locales.section(self.section).items():
if _is_fielded(value, declared):
fields = {}
for field, inner in value.items():
leaf = _leaf(inner, wanted, self.default_language)
if leaf is not _ABSENT:
fields[field] = leaf
if fields:
out[key] = fields
else:
leaf = _leaf(value, wanted, self.default_language)
if leaf is not _ABSENT:
out[key] = leaf
return out
# -- validation ------------------------------------------------------
[docs]
def problems(self) -> list[str]:
"""Everything wrong with this section, as sentences.
Checks, in order:
* a prompt missing a language that the others have — the reader gets
a different language than the one they asked for, which for a model
means a different answer;
* a prompt whose text is empty;
* **a language whose template does not carry the same placeholders as
the default** — the failure specific to this shape. A French prompt
that drops the ``{criteria}`` the English one has will raise at
render time, in production, on whichever request first asked for
French. An *extra* placeholder is just as bad: it reaches
``format()`` with no value and raises there instead.
Returns
-------
list of str
One sentence per fault, empty when the section is sound.
"""
faults: list[str] = []
entries = self.locales.section(self.section)
declared = set(self.locales.supported_locales)
everywhere = set(self.languages)
default = self.default_language
for key in sorted(entries):
value = entries[key]
parts = value if _is_fielded(value, declared) else {None: value}
for field, texts in parts.items():
where = f"{self.section}.{key}" + (f".{field}" if field else "")
if isinstance(texts, str):
# One text for every language, deliberately. Nothing to
# compare, and nothing to be missing.
if not texts.strip():
faults.append(f"{where} is empty")
continue
if not isinstance(texts, dict):
faults.append(
f"{where} must be a string or a mapping of languages, "
f"got {type(texts).__name__}"
)
continue
for absent in sorted(everywhere - set(texts)):
faults.append(
f"{where} has no {absent!r} text, though other prompts here do — "
f"a caller asking for {absent!r} silently gets {default!r}"
)
for language, text in sorted(texts.items()):
if not str(text).strip():
faults.append(f"{where} is empty in {language!r}")
if default in texts:
expected = _placeholders(str(texts[default]))
for language, text in sorted(texts.items()):
if language == default:
continue
got = _placeholders(str(text))
if got != expected:
faults.append(
f"{where} in {language!r} has placeholders {sorted(got)} "
f"but {default!r} has {sorted(expected)} — render() will "
f"raise for whoever asks for {language!r}"
)
return faults
# -- internals -------------------------------------------------------
def _entry(self, key: str, field: str | None) -> Any:
"""The raw node for a key (and field), with a message naming the file."""
entries = self.locales.section(self.section)
if key not in entries:
raise KeyError(
f"{self.locales.path}: section {self.section!r} has no prompt {key!r}; "
f"it has {sorted(entries)}"
)
value = entries[key]
declared = set(self.locales.supported_locales)
if field is None:
if _is_fielded(value, declared):
raise KeyError(
f"{self.locales.path}: {self.section}.{key} comes in parts "
f"{sorted(value)}; ask for one with field="
)
return value
if not isinstance(value, dict) or field not in value:
raise KeyError(
f"{self.locales.path}: {self.section}.{key} has no field {field!r}; "
f"it has {sorted(value) if isinstance(value, dict) else type(value).__name__}"
)
return value[field]
#: Sentinel for "this node carries nothing for that language", distinct from a
#: node that legitimately carries ``None`` or an empty string.
_ABSENT = object()
def _leaf(node: Any, wanted: str, default: str) -> Any:
"""The value a node holds for *wanted*, else for *default*, else absent.
Returned **as it is stored**: a template comes back a string, and a
structure comes back a structure. `bundle` is a view of the file, so it
must not reshape what it finds.
"""
if not isinstance(node, dict):
return node
for candidate in (wanted, default):
if candidate in node:
return node[candidate]
return _ABSENT
def _is_fielded(value: Any, locales: set[str]) -> bool:
"""Whether a prompt node is ``{field: {language: text}}`` rather than flat.
Decided by asking the file which codes are locales, not by looking at the
nesting. Guessing from nesting is what a first attempt did, and it was
wrong on real data: standpoint keeps a whole ``gui`` sub-tree inside its
prompt section as ``{en: {...}, fr: {...}}``, whose values *are* mappings
while its keys are plainly languages. The declared locales settle it —
outer keys that are locales mean language-first, anything else means
fields.
"""
if not isinstance(value, dict) or not value:
return False
return not (set(value) & locales)
def _languages_of(value: Any, locales: set[str]) -> set[str]:
"""Every language code appearing under one prompt node."""
if not isinstance(value, dict):
return set()
if _is_fielded(value, locales):
found: set[str] = set()
for inner in value.values():
if isinstance(inner, dict):
found |= set(inner) & locales
return found
return set(value) & locales