AI-assisted i18n translation, drift detection, and quality linting on top of i18n-tasks — packaged as a gem so the same toolchain works across every app instead of being copy-pasted (and forking) per repo.
Locallingo:
- Translates missing/changed keys through RubyLLM (OpenAI, Anthropic, Google, … — you pick the provider/model).
- Detects drift with per-key source hashes, so only genuinely missing/outdated keys are re-translated.
- Validates translation completeness for CI (missing, outdated, and optional duplicate-value / manual-edit checks).
- Lints quality — static rules, terminology lists, optional British-spelling drift, plus an optional AI review.
- Ships RuboCop cops that enforce fully-qualified i18n keys and locale-aware date formatting.
Everything app-specific lives in .locallingo.yml — target locales, provider and
model, prompt context/glossary, per-language style guides, which validators and
quality rules run, and post-translate hooks — with a default block plus optional
per-package overrides.
# Gemfile
group :development do
gem "locallingo"
endProvider credentials come from ENV (e.g. OPENAI_API_KEY, ANTHROPIC_API_KEY)
or from Ruby, for apps whose keys live elsewhere (Rails credentials, an app
config object, a vault):
Locallingo.configure do |config|
config.anthropic_api_key = "sk-ant-..." # a String…
config.openai_api_key = -> { AppConf.openai_key } # …or a lazy callable
endPut that in a .locallingo.rb file next to .locallingo.yml and the lingo
CLI loads it on start — no Rails boot required. A key set via
Locallingo.configure wins over one set through RubyLLM.configure, which
wins over ENV. Locallingo never stores keys itself. configure publishes the
settings frozen: change them by calling it again, never by writing to
Locallingo.settings. Nothing else needs calling at boot. With String keys
the settings are Ractor-shareable; a callable key is kept as it is (resolved on
every use, so it may close over a logger or a vault client), which keeps the
settings readable on the main Ractor only.
Create .locallingo.yml at your app root:
defaults:
source_locale: en
target_locales: [de, sv]
provider: openai # any RubyLLM provider
translate:
model: gpt-5-mini
quality:
model: gpt-4o-mini
terminology: business # "business" | "banking" | path/to/terms.yml
british_spellings: false
context: "Cosmos, a business application"
glossary:
entity: "business/company account holder"
member: "user belonging to an entity"
language_guides:
de: config/locales/.guides/de.md # inline text or `file:` path
validators:
missing: true
outdated: true
duplicate_values: true # flags non-AR keys duplicating an AR attribute value
manual_edits: false # protects hand-edited target values
strict:
strict: [missing, outdated]
strict_all: [missing, outdated, duplicate_value]
after_translate:
- "bundle exec i18n-tasks normalize -p"
# Optional per-location overrides (each inherits `defaults`):
packages: []
# - path: engines/billing
# context: "the billing engine"
# target_locales: [de]With no packages:, the whole app uses defaults — the common case.
lingo status # translation status per locale
lingo translate --locale de # translate missing/changed keys
lingo translate --force # re-translate everything
lingo translate --force-key a.b.c # re-translate one key (skips manual; add --include-manual)
lingo validate --strict # CI gate (exit 1 on strict-tier issues)
lingo validate --strict-all # stricter CI gate
lingo quality --ai # quality linting (+ optional AI pass)
lingo quality --ai --prefix yoga. # lint one namespace
lingo fix-quality --locale en # auto-fix fixable issues
lingo accept-edits --locale de # protect hand-edited translations (drifted keys only)
lingo accept-edits --key a.b.c # protect a specific key
lingo accept-edits --all # protect everything (initial adoption)
lingo sync # refresh drift state (preserves manual flags)
lingo hash # source-translation fingerprintAdd --package engines/billing to any command to scope it to a package.
The legacy flag forms (lingo --translate, lingo --validate, …) still work and
print a one-line deprecation notice, so you can migrate CI and docs incrementally.
# .rubocop.yml
require:
- locallingo/rubocop
inherit_gem:
locallingo: config/default.ymlLocallingo/RelativeI18nKey— flags relativet(".key")lookups and autocorrects them to fully-qualified keys derived from the file path and enclosing method. ConfigureScopedDirectoriesfor your layout.Locallingo/StrftimeInView— flags.strftime(...)in views; useI18n.l(value, format: :name)instead.value:-pair inputs (HTML datetime) are exempt.
Locallingo recommends disabling Rails' Rails/I18nLazyLookup and
Rails/I18nLocaleTexts, which fight the fully-qualified-key convention.
config = Locallingo.configuration(root_path: Dir.pwd)
Locallingo::Manager.new(config:).validate # => [violations]
Locallingo::QualityChecker.new(config:).check(locale: "en")QualityChecker#check_texts(texts, locale:, use_ai: false) runs the same
static checks, terminology, British spellings and optional AI review over a
{ key => text } hash instead of the locale files, and returns the same
suggestion hashes. With use_ai: true every given key is reviewed (no
sampling). QualityChecker.new(…, context:) takes a RubyLLM::Context for
the AI pass, as Translator.new does:
checker = Locallingo::QualityChecker.new(config:, context: RubyLLM.context { |c| c.anthropic_api_key = key })
checker.check_texts(result.translations, locale: "de", use_ai: true)
# => [{ key:, text:, locale:, category:, issue:, match:, severity:, source: :static | :ai }, …]Locallingo::Configuration.parse(yaml, root_path:, package:) resolves the same
config from YAML text (ERB included) instead of reading .locallingo.yml. A
configuration loaded from the file reads it when it is built, never cached.
Locallingo::Translator runs the same prompt (rules, glossary, language guide),
provider, model and batching over a plain { key => text } hash:
require "ruby_llm" # for RubyLLM.context (Bundler.require already loads it in Rails)
translator = Locallingo::Translator.new(
config: Locallingo.configuration(root_path: Dir.pwd),
context: RubyLLM.context { |c| c.anthropic_api_key = tenant_key }, # optional, per-tenant key
pause: 0 # no sleep between batches
)
result = translator.translate(
{ "title" => "Hello", "intro" => "Some **markdown**." },
from: "en", to: "de",
style: :prose, # :ui (default) or :prose (keeps Markdown, no brevity rule)
instructions: "Write in a warm, plain voice." # appended after the language guide
)
result.translations # => { "title" => "Hallo", "intro" => "Etwas **Markdown**." }
result.failed # => keys that failed after retries- Every call runs on a
RubyLLM::Context: thecontext:given, else one built for that call from the resolved key. The globalRubyLLM.configis never written, so tenants can translate concurrently. MissingCredentialsErroris raised before any call when no key is found.translate.max_batch_chars(ormax_batch_chars:) limits the source characters per batch (a single longer value is sent alone, not split);translate.pause(orpause:) is the sleep after each batch. Retries of failed or omitted keys keep their own backoff.Locallingo::Prompt.translation(config:, from:, to:, style:, instructions:, placeholders:)returns the prompt itself.
Texts with their own token syntax (a template language, an interpolation
format other than placeholder_style) pass placeholders:, a list of Regexps
(default: translate.placeholders, regex strings compiled with Regexp::MULTILINE, []). The prompt names the
patterns, and each reply is checked: the tokens every pattern matches in a
value must equal its source's, compared on the exact text and by count (order
may change). A value that differs is re-requested in the omitted-key rounds;
one that still differs is never returned, but listed in failed and in
rejections:
LIQUID = [/\{\{.*?\}\}/m, /\{%.*?%\}/m]
result = translator.translate(texts, from: "en", to: "de", style: :prose, placeholders: LIQUID)
result.translations # only values whose tokens match their source
result.failed # => ["body"]
result.rejections # => { "body" => { "missing" => ["{% paragraph %}"], "extra" => [] } }Locallingo::Placeholders.compare(source, translated, patterns) is the check
itself (nil when the tokens match). RecordTranslator#call takes
placeholders: too; a record with a rejected segment is not written. The
lingo CLI does not use translate.placeholders.
Locallingo::Drift is the missing / outdated / hand-edited rule over the state
entry shape (source_hash, target_hash, manual), for strings or nested
values:
drift = Locallingo::Drift.new # hasher: any callable(String) -> String
drift.status(source:, target:, entry:) # => :missing | :outdated | :current
drift.due?(source:, target:, entry:, force: false, include_manual: false)
drift.hand_edited?(target:, entry:)
drift.record(source:, target:, manual: false) # => a new entryrequire "locallingo/active_record" (ActiveRecord >= 7.1; nothing loads
ActiveRecord otherwise) adds a concern for a JSON(B) column keyed by locale
plus a JSON(B) state column:
class Article < ApplicationRecord
include Locallingo::Translatable
translates_content :body, # { "en" => { … }, "de" => { … } }
state: :translation_state, # { "de" => { "source_hash", "target_hash", "manual" } }
source_locale: -> { "en" }, # String/Symbol, or a callable run on the record
target_locales: -> { %w[de sv] },
translatable: ->(path, value) { true } # optional filter on flattened String leaves
end
article.translation_status(:de) # => :missing | :outdated | :current
article.translation_manual?(:de)
article.translation_due?(:de, force: false)
article.record_translation(:de, manual: false)
Locallingo::RecordTranslator.new(translator:)
.call(Article.where(id: ids), to: "de", style: :prose)
# => { translated: 3, failed: { article_id => [paths] }, skipped: [ids] }- Saving a record runs
mark_hand_edits: a target locale a person changed is flaggedmanualand never machine-overwritten (include_manual: trueoverrides, keeping the flag). That includes a target locale saved for the first time, e.g. a source copy used as a placeholder atcreate!: leave untranslated locales out of the column instead. A cleared locale is not flagged and is due again, unless it was alreadymanual: the flag stays, so retranslating it needsinclude_manual: true. RecordTranslatorsends one batched translation per source locale across all due records, then writes each record underrecord.with_lock. A record whose source changed, or whose target became manual, while the model answered is skipped (skipped:) and left for the next run, as is a due record with no translatable text.- Nested values keep their shape; leaves the
translatable:filter rejects are copied from the source. A record with any segment that failed after retries is not written at all: its paths are reported infailed:, and it stays due for the next run (your fallback to the source locale applies meanwhile). - Writes use
update_columns; passwrite: ->(record, attributes) { … }to bump a lock version or run validations. translates_contentfreezes the declaration as it is made, at boot: the column and state names, a String or Array locale option (as a frozen copy) and the drift. Callables are kept as they are, so they may close over app state; like the model itself, a declaration is read on the main Ractor.
MIT © Mikael Henriksson