@happyvertical/smrt-languages

Language string registry with a 5-layer cascade, locale fallback chains, and AI-driven auto-translation for missing locales with admin review.

v0.29.34i18n RegistryAI Auto-TranslationLocale Fallback

Overview

smrt-languages is the i18n companion to smrt-prompts. It uses the same 5-layer override cascade but is keyed by (key, locale) instead of just key. When a locale is missing, it walks a fallback chain (fr-CA to fr to default) and asynchronously enqueues an AI translation job so the next request returns the requested locale natively.

Installation

bash
npm install @happyvertical/smrt-languages

Quick Start

typescript
import {
  defineLanguageString,
  resolveLanguageString,
  LanguageOverride,
  LanguageOverrideCollection,
  clearLanguageCache,
} from '@happyvertical/smrt-languages';

// 1. Register code defaults (typically at module load time)
defineLanguageString({
  key: 'users.role.member',
  locale: 'en',
  template: 'Member',
});

defineLanguageString({
  key: 'commerce.invoice.dueText',
  locale: 'en',
  template: 'Due by {{ date }}',
});

// 2. Resolve a string for the current tenant + locale
const text = await resolveLanguageString('users.role.member', {
  db,
  tenantId: 'tenant-a',
  locale: 'es',                // not registered — walks fallback chain
  vars: { name: 'Will' },
});
// First call: returns 'Member' with source: 'fallback'
// Async: enqueues translation job; result becomes app-level override
// Next call: returns the Spanish translation

The 5-Layer Cascade

Identical to smrt-prompts, but keyed by (key, locale):

text
Priority (low to high)
  1. Code default       defineLanguageString({ key, locale, template })
  2. File / config      package config 'languages' overrides
  3. App-level stored   LanguageOverride row with tenantId = null
  4. Tenant-level       LanguageOverride row with current tenant
  5. Runtime override   resolveLanguageString({ overrides })

If no row exists for the exact (key, locale, tenantId), fall through to:
  - Same key with the requested locale's fallback chain (fr-CA -> fr -> default)
  - Return the first hit with source: 'fallback'
  - Fire-and-forget AI translation job for the missing target

Locale-Miss Flow

text
// When resolveLanguageString cannot find (key, locale, tenantId):

// 1. Walk locale fallback chain (buildLocaleFallbackChain)
//    fr-CA -> fr -> en (configured default)

// 2. Return the first hit immediately:
//    { text, source: 'fallback', resolvedFromLocale }

// 3. Fire-and-forget enqueueTranslationJob:
//    - Scoped to current tenant for glossary purposes
//    - Result is written app-level (tenantId: null) — reusable across tenants
//    - Job ID is deterministic, so concurrent misses collapse into one job

// 4. Subsequent requests hit the new app-level row at the requested locale.

AI Translation Pipeline

The translation worker honors several invariants to avoid runaway costs and bad overwrites:

  • Honors the smrt-languages.auto_translate feature flag (kill switch)
  • Skips locales outside supportedLocales when configured
  • Skips when a LanguageOverride already exists with a matching source_hash (hash-gated, never time-based)
  • Never overwrites rows with auto_generated: false — human edits win permanently
  • Pulls the tenant's existing overrides as a glossary so translations match tenant voice
typescript
// LanguageOverride model fields for the pipeline:
class LanguageOverride extends SmrtObject {
  key: string
  locale: string                  // BCP-47, normalized (lowercase lang / uppercase region)
  tenantId: string | null
  template: string
  auto_generated: boolean         // true if produced by translation worker
  source_hash: string | null      // sha256 of source template at translation time
  ai_model: string | null         // which model produced the translation
  reviewed_at: string | null      // ISO timestamp of admin review
  reviewed_by: string | null      // userId of reviewer (admin review queue)
  // Note: 'context' is inherited from SmrtObject and set in save() to
  // (tenantId ?? '__app__'); it backs the (key, locale, context) upsert key.
}

Conventions

  • Keys are namespaced by package: users.role.member, commerce.invoice.dueText
  • Locales follow BCP-47 (en, fr-CA, pt-BR) and are normalized on persistence
  • The translation prompt itself is registered with smrt-prompts under smrt-languages.translation — ops can tune wording without redeploying
  • The context column carries tenantId or '__app__' so the (key, locale, context) upsert key remains unique even with a nullable tenantId

Gotchas

DOs

  • Register every user-visible string with defineLanguageString at the default locale
  • Use the admin review queue to promote auto-generated translations to reviewed status
  • Manage the kill switch via the smrt-languages.auto_translate feature flag
  • Provide a tenant glossary by curating reviewed overrides — the translator uses them

DON'Ts

  • Don't translate user-supplied content with this package — it's for app strings only
  • Don't set auto_generated: false on auto-produced rows until a human reviews
  • Don't drop the context column — uniqueness depends on it
  • Don't enqueue translations synchronously — fire-and-forget is the contract

Related Modules