# 0009. AI translation through a queue, gated by an env flag

- **Status:** Accepted
- **Date:** 2026-06-20

## Context

Three languages, and editors work in Hungarian. Every article, content page, service and storage description
needs an English and a German version. Manual translation does not keep up, and half-translated content is
what visitors actually see.

## Decision

**Translate with the Anthropic API, asynchronously, through a queue.**

- `ActiveRecord::afterSave()` → `queueTranslations()` enqueues the record into `TranslationQueue` when any
  non-empty multilingual source attribute lacks a translation.
- Editing a Hungarian value **invalidates** the corresponding translations in every language, so they get
  regenerated rather than going stale.
- `./yii translate/auto`, running every minute, drains the queue. Time-boxed, and guarded by a `MysqlMutex`
  named `translate-auto` so overlapping cron runs exit immediately.
- `./yii translate/model <Model>` is the manual, whole-model equivalent.
- The whole mechanism is behind `APP_AUTO_TRANSLATE_ENABLED`, and additionally requires `ANTHROPIC_API_KEY`.

## Alternatives

- **Translate synchronously on save.** An editor pressing Save would wait on three API calls, and a failing
  API would fail the save.
- **A translation service (DeepL, Google).** Cheaper per word, but no control over tone — and the copy is
  deliberately informal ("te", "du"), which a generic engine does not preserve reliably.
- **Human translation only.** The status quo that did not keep up.
- **A generic job queue component.** One table and one cron entry did the job; a queue framework was not
  warranted for a single job type.

## Consequences

- **Saving stays fast** and never depends on an external service.
- **Editing the Hungarian source is enough.** The translations follow, and stale ones are dropped rather than
  left to rot.
- **Off by default.** A fresh environment does no API calls and costs nothing; the sequence is exercised
  locally by turning the flag on.
- Translations are **machine output that nobody reviewed** unless someone chooses to. That is an accepted
  quality trade; the manual command exists for content where it matters.
- `queueTranslations()` performs its own `updateAll()` for the invalidation, **outside the current save and
  bypassing `beforeSave`** — a later hook will not see it in `$changedAttributes`.
- **A translation run leaves the search index stale**, because it writes through `updateAll()`. Rebuild after
  a bulk run — see [0008](0008-own-full-text-search-index.md).
- `slug` is excluded from the "needs translating" check; slugs are derived, not translated.
