Issue #3585529: Add translation result caching and cross-field deduplication.

Description

Adds a translation result cache so the same text is not sent to an LLM twice.

TextTranslator::translateContent() is the single point where text reaches a provider — src/TextTranslator.php:54 holds the only translateText() call, and all five call sites route through it (the "Translate" tab batch, both interface translation paths, and both Drush commands). This MR puts a cache in front of that one seam.

It does so with a decorator, CachedTextTranslator, registered against ai_translate.text_translator. TextTranslator and TextTranslatorInterface are untouched, and the only existing file in src/ that changes is the settings form, which gains one checkbox. That keeps the diff clear of the code the other open MRs are working in.

Per @svendecabooter's scoping comment: based on 1.3.x, entry point contained to translateContent(), a dedicated cache bin rather than cache.default, and on for new installs, off for existing sites.

What is added

File Change
src/CachedTextTranslator.php new — the decorator
ai_translate.services.yml the cache.ai_translate bin and the decoration
config/schema/ai_translate.schema.yml cache_translations boolean
config/install/ai_translate.settings.yml cache_translations: true
ai_translate.post_update.php sets it FALSE on existing sites
src/Form/AiTranslateSettingsForm.php the checkbox
.cspell-project-words.txt 3 German words used in test fixtures
tests/src/Unit/CachedTextTranslatorTest.php new — 9 tests
tests/src/Kernel/TranslationCacheServicesTest.php new — 5 tests
tests/modules/ai_translate_test/ new — a test module holding one stub translator the kernel tests count calls on
tests/src/Functional/AiTranslateSettingsFormTest.php 4 lines: 3 assertions for the checkbox and one $edit value
docs/configuration.md a section for the new setting
docs/index.md a Features bullet

Against 1.3.x: 14 files changed, 696 insertions, 0 deletions. Nine existing files touched, five new ones, and not a single line removed anywhere in the module.

Cache key and invalidation

Key: ai_translate:{from}:{to}:{sha256(text)}, with auto for an unknown source language so it cannot collide with an explicit one.

Editing the source text changes the hash, so the stale entry is simply never asked for again — cross-run invalidation falls out of the key.

Output also depends on the model and the prompt, both editable in the UI. Keyed on text alone, an admin who tuned the prompt would keep receiving the old translations forever — a correctness bug, not a missed optimisation. Entries therefore carry:

  • config:ai.settings — the default translate_text and chat provider and model
  • config:ai_translate.settings — which prompt is selected
  • config:ai.ai_prompt.<id> — the prompt body, for both the language-specific prompt and the default, because ChatTranslationProvider falls back to the default when the language-specific entity is empty

Config::save() invalidates config:<name> unconditionally (core/lib/Drupal/Core/Config/Config.php:231), and config entities do the same via ConfigEntityBase::getCacheTagsToInvalidate(), so saving any of those drops the affected entries.

Points reviewers should know

1. Deduplication is the cache, not a second mechanism. The issue asks for two things; they turn out to be one. Identical strings hash identically, and the batch translates one field at a time, so the first occurrence's write lands before the second occurrence's lookup. testRepeatedFieldsInOneRunCostOneProviderCall drives this through the real batch callback and asserts the repeated string reaches the provider once. The consequence is worth stating plainly: dedup only works when caching is enabled. This is not the standalone in-memory grouping layer the issue description sketches — building that would mean restructuring the batch so something holds all the strings at once, which is the area #3585527 is working in.

2. Correction to an earlier comment on this issue. The per-language model field in AiTranslateSettingsForm does not select the model. Its only reader is src/Controller/ContentTranslationControllerOverride.php:173, where it builds the label of the "Translate using @ai" link. The model that actually runs comes from ai.settings via getDefaultProviderForOperationType(). That is why config:ai.settings is in the tag set. The conclusion in that comment was right; the reason given was not.

3. Coordination with #3585527. If translateMultiple() joins TextTranslatorInterface, CachedTextTranslator stops being a valid implementation and the container will fatal. It is a one-method passthrough to fix, but somebody has to notice. Flagging it rather than pre-empting it, since that interface change is not written yet.

4. hook_ai_translate_translation_alter() is invisible to this cache. That hook (ChatTranslationProvider::loadTranslator()) lets any module rewrite the messages or swap the provider at call time. A cache in front of translateContent() cannot see it. A site whose hook varies per request should leave the feature off — which the opt-out setting allows.

The same applies to $context, which TextTranslatorInterface documents as carrying preferred_model, preferred_version and preferred_provider. It is deliberately not part of the cache key: no caller in this project passes it and TextTranslator ignores it, so it cannot affect output today. A future caller that used it to select a model would be served a translation made with different settings. Both limitations are stated in the class docblock. Happy to fold $context into the key instead if you would rather the key be exhaustive.

5. Neither failures nor empty results are cached. $this->cache->set() sits after the inner translateContent() call, so a TranslationException propagates before anything is written — a rate-limit or outage cannot be stored as if it were a translation.

An empty string needed handling separately, because it is a successful return as far as the decorator is concerned. ChatTranslationProvider::translateText() returns new TranslateTextOutput('', '', '') when the target language entity cannot be loaded and again on a Guzzle failure, and both interface-translation call sites already initialise $translation = '' and carry on — so it is a live path, not a hypothetical. Cached at CACHE_PERMANENT it would pin the failure and make "translate it again", the existing remedy for a bad translation, silently return the same empty string. The write is therefore guarded with if ($translation !== ''), and testEmptyTranslationIsNotCached() asserts the provider is called again.

6. Caching also makes translations reproducible. During testing the same source string produced a 223-byte German translation on one run and 222 bytes on another — the model simply varies. Without a cache, re-running a translation can silently reword published copy.

7. A named post_update hook, not ai_translate_update_10005(). Several MRs are open against this module and any of them could claim 10005, at which point one silently never runs on sites that installed the other first. A named post-update cannot collide, and the module already has a post_update.php.

8. The issue description says "User interface changes: None"; this MR adds one. A checkbox on the AI Translate settings form, because @svendecabooter asked for the feature to be toggleable and for existing sites to be able to opt in. Flagging it so it does not read as scope creep against the description, which predates that comment.

9. A duplicate module in the test environment, and the workaround it forced. The AI module still bundles the deprecated ai_translate submodule this project replaced. core/tests/bootstrap.php registers one PSR-4 path per extension name and the last one discovered wins — which is the bundled copy, deterministically, both locally and in CI. Classes present in both copies still resolve, which is why this has been invisible for years. CachedTextTranslator exists only here, so all nine unit tests failed with Class "Drupal\ai_translate\CachedTextTranslator" not found. Kernel and functional tests are unaffected: they resolve through the container, which uses Drupal's own module list.

This is not caused by this MR — any MR adding a class to this project hits it, and this is simply the first one to do so.

CachedTextTranslatorTest::setUpBeforeClass() works around it by putting this project's src/ first in the Composer autoloader. Five lines, reproduced and verified with the bundled copy both present and absent.

Three alternatives, happy to take any:

  • delete the bundled copy in .gitlab-ci.yml's existing .phpunit-base before_script — fixes the cause rather than the symptom, but it is your CI file;
  • convert the nine unit tests to kernel tests — no workaround at all, roughly 12s instead of 0.03s;
  • drop the workaround entirely once AI 2.0 removes the bundled submodule.

Worth knowing regardless of what this MR does, since it will affect every future class added here.

Open questions

These are my calls, not the issue's. Happy to change any of them.

  1. Decorator vs. editing TextTranslator directly. A decorator keeps the diff off every existing src/ file, which keeps this MR conflict-free against the others in flight. It is still "the cache in the TextTranslator service" from every caller's point of view. Would you rather it went inline?
  2. Cache tags vs. putting provider/model/prompt in the key. Tags are simpler and match the issue's "use standard cache tag infrastructure". The trade-off: config:ai.settings changes for reasons unrelated to translation, so it will sometimes over-invalidate. Putting them in the key would be precise and reversible — switch model, switch back, old entries still valid — at the cost of a longer key and no automatic invalidation when a prompt body is edited.
  3. CACHE_PERMANENT vs. a configurable max-age. Entries currently live until a tag invalidates them. Nobody specified a lifetime. Should this be a setting?

Testing instructions

Automated

Tests: 16, Assertions: 66, PHPUnit Deprecations: 4
exit=0

Baseline on 1.3.x before this MR was 2 tests / 17 assertions / 4 deprecations. The deprecation count is unchanged: the new test classes use PHP attributes (#[Group], #[CoversClass]) rather than doc-comment metadata, which PHPUnit 11 deprecates.

  • phpcs --standard=Drupal — clean, exit 0. Also clean under --standard=Drupal,DrupalPractice, which is what the issue's task list asks for, though CI only runs Drupal (the project ships no phpcs.xml, so CI uses gitlab_templates/assets/phpcs.xml.dist, where the DrupalPractice rule is commented out)
  • phpstan with CI's configuration (gitlab_templates/assets/phpstan.neon: level: 0 plus the #^Unsafe usage of new static# ignore) — 0 errors
  • cspell with CI's configuration — 0 issues in 57 files

The 9 unit tests cover: miss calls the provider and stores the result; hit does not call the provider; a repeated string costs one provider call; edited source text misses; a different target language misses; a NULL source language is keyed separately; invalidating config:ai.settings forces re-translation; the setting off bypasses the cache entirely; and an empty result is not cached.

The 5 kernel tests cover the container wiring (ai_translate.text_translator really is the decorator, cache.ai_translate really exists); both halves of the default — TRUE on a fresh install, FALSE after the post-update runs; that the decorator reads from the dedicated bin rather than cache.default; that three fields carrying two distinct strings through the real batch callback (AiTranslateController::translateSingleField()) cost two provider calls, not three; and that saving ai_translate.settings drops stored translations, rather than a test invalidating the tag by hand.

The counting stub lives in tests/modules/ai_translate_test/ rather than under tests/src/, because it is not a test. Drupal 10's TestDiscovery::scanDirectory() collects every .php file under tests/src/ that is not a TestBase, Trait or Interface and then rejects any class without a group — and CI runs core/scripts/run-tests.sh, not vendor/bin/phpunit, because .gitlab-ci.yml sets _PHPUNIT_CONCURRENT: 1. Drupal 11.4 uses PhpUnitTestDiscovery and is unaffected, but the OPT_IN_TEST_PREVIOUS_MAJOR job would have failed discovery. Same layout core uses for its own test doubles.

Those last three are deliberately mutation-tested. Repointing the decorator at @cache.default fails the dedicated-bin test; making the decorator never read the cache fails the repeated-fields test; dropping the config:ai_translate.settings tag fails the settings-save test. Each was applied, run and reversed.

Manual, against a live provider

Verified with gemini_provider and models/gemini-flash-lite-latest.

Same string three times through ai_translate.text_translator:

call 1:   806.4 ms -> Erfahren Sie mehr über unsere Dienstleistungen
call 2:     0.2 ms -> Erfahren Sie mehr über unsere Dienstleistungen
call 3:     0.3 ms -> Erfahren Sie mehr über unsere Dienstleistungen

Cross-field deduplication — a node whose title and body summary are the same string, so three extracted values become two provider calls:

ai_translate:en:de:090aee4cb58becb346a5ef95d5b9890a5befe564e89fd78cfc9a30dc3822e58a   23
ai_translate:en:de:a373c460c20a2303feec37e8319dc97344f8db4aefb343c47fa913df691103f8  222

Deleting the German translation and re-translating the whole node, with the bin left populated:

cache rows: 2  (unchanged - zero provider calls for the entire node)
german title: Unsere Dienstleistungen

Tag invalidation and the opt-out were not measured by hand on a live site; both are covered deterministically by testSavingSettingsDropsStoredTranslations and testDisabledSettingBypassesTheCache above.

Reproducing

  1. Configure any chat provider and set Translate text to Chat proxy to LLM.
  2. Add a second language, create a translatable node.
  3. Translate it; note the duration. Translate it again; it is near-instant.
  4. SELECT cid, LENGTH(data) FROM cache_ai_translate; shows one row per distinct source string, keyed ai_translate:{from}:{to}:{sha256}.
  5. Save the settings form; the rows expire.
  6. Untick the setting; nothing is cached.

Release note

Translation results are now cached, so the same text is not sent to the AI provider twice. New installs have this enabled. Existing sites keep it disabled and can opt in at Configuration → AI → AI Translate → Cache translation results. Cached results are discarded automatically when the translation prompt or the default AI provider changes.

AI compliance

This code was generated by Claude Code, then reviewed, applied, run and verified by me. All tests and lint runs above were executed locally.

Closes #3585529

Edited by Tekla Aivazashvili

Merge request reports

Loading