Translations¶
The backend translates its user-facing strings (API error details, bot
messages, emails) with gettext; the frontend translates its static UI with
next-intl. This guide covers how the locale is
resolved on each side, how to mark a string, and how the catalog workflows
run. The backend public API lives in sparkth.lib.i18n (see the
Python API reference); the frontend helpers live in
frontend/lib/i18n/.
Two languages, resolved separately¶
Sparkth resolves two different languages and they are deliberately unrelated. Conflating them is the single most common mistake when working on this code.
Interface language — Sparkth's own text: labels, buttons, API error messages, and any
fixed string the backend authors. It is resolved server-side, from the signed-in user's
stored preference, then the Accept-Language header, then DEFAULT_LANGUAGE. It is
limited to the languages the platform ships catalogs for, because a translation either
exists or it does not. Everything else in this guide is about this language.
Generated language — what the model writes: chat replies, course titles, lesson text,
assessment questions, answer options, feedback, and conversation titles. It is not
resolved from a stored preference or a negotiated header, the way interface language is.
In chat, the prompts instruct the model to write in the language of the user's most recent
message and to switch when the user switches; a conversation title instead follows the
language of the message the conversation opened with, and the MCP course-generation tool
takes an explicit tag from its caller rather than reading one off a message. Either way,
there is no tag to store and no allowlist to check — any language the model handles works.
The one setting that can decide a generated language is DEFAULT_LANGUAGE: the MCP tool
falls back to it when its caller omits the tag or sends an unusable one.
The practical consequences worth knowing:
- A user whose interface is English can hold an entire conversation in Japanese and get a Japanese course. Neither setting affects the other.
- Nothing on the server records which language a course was generated in. A surface that needs the tag as data has to be handed it: the MCP course-generation tool takes it as a parameter from its caller, and the Open edX publishing path asks the model for the same tag again when it creates a course run, since the model is the only thing that knows which language it wrote the course in.
- Internal prompts stay English on purpose: the scope classifier, the retrieval intent router and the document search agent reason in English while handling input in any language. Nothing they produce is shown to a user.
- Output quality varies by language and Sparkth does not restrict which languages it will generate in, so output in a language nobody has reviewed is possible.
The out-of-scope chat refusal is the one string that touches both. When the assistant model itself judges a request out of scope, it is handed the English source and told to send it in the conversation's language. A faster check can also catch the request first; a classification step may run there, but it only decides whether the request is in scope — the backend writes the refusal sentence itself rather than a model, so that refusal follows the interface language instead.
Backend¶
How the locale is resolved¶
Any response rendered at or after authentication resolves its locale in this order:
- A signed-in user's stored
User.languagepreference, when set and still one of the supported languages, is bound byget_current_useronce the user is resolved — the first point in the request at which the locale is bound to that preference — and replaces whatever the middleware negotiated from the header. - The
Accept-Languageheader, negotiated byLocaleMiddlewareagainst the platform allowlist (SUPPORTED_LANGUAGES, exposed atGET /api/v1/languages). Entries are taken in listing order, with anything after a;discarded: browsers list languages by descending preference, so the order alone carries the preference. Matching is exact and case-sensitive, soen-USdoes not matchen, but a browser sendingen-US,enstill lands onenthrough its next entry. - When neither of the above offers a supported tag — an anonymous request, an unset or
no-longer-supported stored preference, or a header offering nothing supported — the
platform
DEFAULT_LANGUAGEapplies.
A gate that rejects a request outright, before routing, renders its response without the
authentication dependency ever running — so nothing binds the stored preference on its
behalf. Where such a gate has resolved the user itself, it binds the preference before
rendering (PluginAccessMiddleware does). A gate that has no user to bind — one rejecting
a request it never authenticated — follows the negotiated header, or DEFAULT_LANGUAGE.
Code that runs outside a request (background tasks, CLI) sees
DEFAULT_LANGUAGE, or installs a specific locale with
sparkth.core.i18n.locale_context (tests do this too).
Marking strings¶
Import the marking functions from sparkth.lib.i18n, never from
sparkth.core.*:
from sparkth.lib.i18n import _, lazy_gettext
There are four cases:
- A literal evaluated during a request: wrap it in
_()at the point of use.
raise HTTPException(status_code=401, detail=_("Incorrect username or password"))
- An f-string:
pybabel extractcannot see inside f-strings. Convert tostr.formaton the translated template:
# before: f"Role not found: {role_name}"
_("Role not found: {role_name}").format(role_name=role_name)
- A module-level constant: module bodies run at import, before any request
locale exists. Mark with
lazy_gettext(), which defers translation until the value is rendered, and callstr()on it at the boundary (response serialization, message dispatch):
GREETING_MESSAGE = lazy_gettext("Hello! How can I help you?")
...
await post_message(str(GREETING_MESSAGE))
- A constant stored in a plain-
strfield (a dataclass a response model embeds, where aLazyStringcannot go, e.g. theDisplayInfo/SidebarEntryfrontend metadata): mark the literal withgettext_noop(), which returns it unchanged, and translate the stored value by passing it throughgettext()at the rendering boundary (UserPluginResponse.for_plugindoes this for the frontend metadata):
DISPLAY_INFO.add_item(self, DisplayInfo(gettext_noop("Create Course"), gettext_noop("Build courses with AI")))
Only user-facing text is marked. Log lines, LLM prompt templates, MCP tool
descriptions, and OpenAPI field descriptions stay English, as do machine-read
error codes ("expired_token"), operator-facing configuration errors
("Slack credentials not configured."), and hand-rolled request-parameter
validation messages, which follow the untranslated Pydantic 422s.
The HTTPException slice of this rule is enforced by the suite:
tests/core/i18n/test_marking_enforcement.py
fails on any literal or f-string detail that is neither _()-wrapped nor
annotated with an # i18n-exempt: <reason> comment on one of the call's
lines. Misuse inside the marking calls themselves (an f-string passed to
_()) is caught by ruff's INT rules. A gettext_noop-marked constant has
no runtime effect to check this way — gettext() stays content-keyed
whether or not the wrapper is present — so that same file also runs
pybabel's extractor directly and asserts the constant is still among the
extracted messages, the only view where the marking is visible at all.
Catalog workflow¶
Translations are looked up across every directory registered on the
LOCALE_DIRS hook (sparkth.lib.i18n). Core registers sparkth/locale/ (its
README recaps this table), one directory per language. Each catalog carries
only its own package's strings: core extraction ignores sparkth/plugins/
(see babel.cfg), and a plugin owns the catalogs under its own locale/
directory, registered on LOCALE_DIRS from its __init__ (see the
plugin guide), so its translations travel with it
when it moves to its own repository.
tests/core/i18n/test_catalog_containment.py enforces the boundary. .po
files are committed source; the .pot template and compiled .mo files are
git-ignored build artifacts.
| Command | What it does |
|---|---|
make i18n.extract |
Scan for marked strings into the core and per-plugin messages.pot |
make i18n.init -- <lang> |
Create the core and per-plugin catalogs for a new language |
make i18n.update |
Re-extract and merge changes into every catalog |
make i18n.compile |
Compile .po to the .mo files loaded at runtime |
Every target covers the core catalog and each sparkth/plugins/*/locale/
directory that exists, so the day-to-day loop after marking or changing
strings is unchanged wherever the string lives: make i18n.update, fill in
the new msgstr entries in the affected
<catalog dir>/<lang>/LC_MESSAGES/messages.po, then make i18n.compile
(required before the app can serve the translations).
The i18n-translator
Claude Code agent automates this loop: it runs the extraction, fills the
missing msgstr entries with LLM-authored translations (pending
native-speaker review), and validates the result. It covers the frontend
catalogs too (see Catalog drift guard).
The production image compiles the catalogs at build time: the catalog-builder
stage in the Dockerfile runs pybabel compile over the
core and per-plugin catalogs with the lockfile-pinned dev dependencies and
hands sparkth/ (with the compiled .mo files) to the runtime stage. A
deployment that does not use the image must run make i18n.compile itself
before starting the server.
Adding a language¶
- Add the BCP 47 tag to
SUPPORTED_LANGUAGESinsparkth/core/config.py(a language is added only once a full interface translation for it exists and has been reviewed; see the comment there). make i18n.init -- <lang>, translate the core and per-plugin catalogs it creates,make i18n.compile.- The
/api/v1/languagesendpoint andDEFAULT_LANGUAGEvalidation pick up the new tag automatically.
Testing translated code¶
The suite detaches the shipped catalog directories for the whole session
(the shipped_locale_dirs fixture in sparkth.lib.testing), so locally
compiled .mo files never leak into tests: every lookup falls back to the
English source string, and assertions against English text keep working. To
assert on an actual translation, inject a catalog with the
translation_catalog fixture and render under the target locale, via
locale_context("<lang>") or an Accept-Language header:
async def test_detail_is_translated(client, translation_catalog):
translation_catalog("Incorrect username or password", "Usuario o contraseña incorrectos")
response = await client.post(..., headers={"Accept-Language": "es"})
Frontend¶
The frontend uses next-intl in cookie-based mode:
production is a static export served by FastAPI (output: "export" in
frontend/next.config.ts), so there is no Next.js server, no middleware, and
no locale URL prefix. The locale is resolved on the client.
How the locale is resolved¶
LocaleProvider(frontend/app/LocaleProvider.tsx, mounted at the root of the layout) reads theNEXT_LOCALEcookie viareadLocaleCookie()(frontend/lib/i18n/config.ts). An absent or unsupported value falls back toen.- Rendering starts immediately on the bundled core English catalog, so the
prerendered static-export HTML keeps its content. The provider then loads
the full catalog for the cookie locale:
frontend/messages/<locale>.jsonmerged with the catalog of every registered plugin that ships one (see plugin catalogs below). Once the chunks arrive it setsdocument.documentElement.langand re-renders with the merged catalog (for a non-default locale, the brief English flash before that is inherent to client-side locale resolution). A chunk that fails to load logs and falls back to its English counterpart. - The provider records the locale it actually renders in
frontend/lib/i18n/active-locale.ts. The cookie stores the preference; the active locale tracks what is on screen, and the two diverge when the catalog load fails outright and the UI stays on English. - The API client echoes the active locale as an
Accept-Languageheader on every request (localeMiddlewareinfrontend/lib/api/middleware.ts), so backend responses arrive in the language the frontend renders, cookie or not. A caller-suppliedAccept-Languageheader wins over the default.
The catalog allowlist lives in locales in frontend/lib/i18n/config.ts and
names the bundled messages/*.json files. The user-facing language picker
reads the authoritative platform list from GET /api/v1/languages.
Marking strings¶
Components read messages with useTranslations, one namespace per route or
feature (and one per plugin, so plugin messages stay portable):
const t = useTranslations("home");
<p>{t("tagline")}</p>
<h1>{t.rich("title", { brand: (chunks) => <span className="text-primary-500">{chunks}</span> })}</h1>
Messages use ICU syntax for interpolation and plurals
("lastDays": "last {days} days"). Keys are written by hand into
frontend/messages/en.json first; es.json and fr.json must carry the same
keys. Unknown keys are a TypeScript error: the catalogs are bound to
next-intl's types via the Messages type in frontend/lib/i18n/messages.ts
(referenced from frontend/lib/i18n/next-intl.d.ts).
Plugin catalogs¶
A frontend plugin owns its catalogs, mirroring the backend containment rule:
the core frontend/messages/ files carry no plugin namespace, and a plugin
that has user-facing strings ships messages/<locale>.json files in its own
directory, scoped to a single top-level namespace equal to the plugin name.
The plugin declares a loadMessages loader on its PluginDefinition, and
loadMessages in frontend/lib/i18n/messages.ts merges every registered
plugin's catalog into the core one at load time (a plugin catalog that fails
to load falls back to that plugin's English catalog). See the
frontend plugin guide for the
step-by-step wiring; the containment is guarded by
frontend/tests/lib/i18n/messages.test.ts.
Catalog drift guard¶
make test.frontend.i18n (part of make test.frontend, so it runs in CI)
runs i18n-check once per catalog
(frontend/scripts/i18n-check.mjs): the core frontend/messages/ catalog is
checked against the core sources, and each frontend/plugins/*/messages/
catalog against that plugin's own sources. Each run fails on keys missing
from any locale file, keys whose ICU placeholders diverge from the source,
catalog entries no component uses, and keys used in code but defined in no
catalog. A plugin opts in simply by having a messages/ directory; the
script picks it up automatically. The
i18n-translator
Claude Code agent fills in the missing or invalid keys the check reports.
Testing translated components¶
Wrap the component in the English catalog with renderWithIntl from
frontend/tests/intl-test-utils.tsx; assertions against English text keep
working unchanged:
import { renderWithIntl } from "../intl-test-utils";
renderWithIntl(<HomeClient />);
expect(screen.getByRole("link", { name: "Get Started" })).toBeInTheDocument();
A plugin component test passes the plugin's English catalog as the second argument, since the helper's default carries only the core catalog:
import chatEn from "@/plugins/chat/messages/en.json";
renderWithIntl(<ChatInput ... />, chatEn);
Adding a language¶
- Add the tag to
SUPPORTED_LANGUAGESinsparkth/core/config.py(see the backend steps above). - Add the tag to
localesinfrontend/lib/i18n/config.tsand createfrontend/messages/<lang>.jsonwith every key fromen.json(make test.frontend.i18nverifies completeness).