Aggregate a node's translatable field-columns into a single chat request (opt-in batch translation)
---
## Problem/Motivation
`ai_translate` translates **one field-column per chat request**. `TextTranslator::translateContent()` is called once per column, and `ChatTranslationProvider::translateText()` rebuilds the full prompt (the configured translator prompt template + `setSystemPrompt('You are a helpful translator.')`) and calls `chat()` for each one.
For a node with N translatable field-columns, that is N requests, each re-sending the **same** instruction block. For a typical 3-field node, roughly 1,000 of \~1,500 input tokens are the identical instructions sent three times. The redundancy scales linearly with field count and node count.
**Prompt caching does not solve this.** The translator prompt is only \~500 tokens, well below the minimum cacheable prefix of common models (e.g. 4,096 tokens on Anthropic Haiku 4.5), so the prefix silently never caches (`cache_creation_input_tokens` stays 0). The fix is to send the fields together.
On a large backfill the cost is real: across \~35,000+ nodes this is on the order of tens of dollars of avoidable input cost, plus \~Nx the request count — which also increases latency and rate-limit pressure.
## Proposed resolution
Add an **opt-in, additive** batch path that packs all of a node's translatable field-columns into a **single** chat request, leaving the existing per-field path completely unchanged.
- New method `translateMultiple(array $texts, LanguageInterface $langTo, ?LanguageInterface $langFrom, array $context): array` on the ai_translate text translator (`TextTranslator` + `TextTranslatorInterface`), keyed by caller-supplied field id. It operates against the **`chat`** operation type directly (mirroring what `ChatTranslationProvider` already does internally), since batching many texts into one prompt is a chat concern — native `translate_text` providers (DeepL/Google-style) are single-text by design.
- `translateContent()` (the single-field path) is **unchanged**. When no usable chat provider is configured, `translateMultiple()` transparently loops `translateContent()` per field — so enabling the feature never changes behavior or adds a hard requirement.
- **Wire format: a robust delimiter, not structured JSON.** The `ai` module exposes no capability flag for structured output (the `AiProviderCapability` enum has only `StreamChatOutput` / `ChatFiberSupport`), so structured-output support cannot be feature-detected, and only OpenAI-based providers honor `ChatInput::setChatStructuredJsonSchema()`. JSON would also force every field's HTML through an escaping round-trip. Instead, each field is wrapped between unique sentinel markers (field id + a per-request random nonce); HTML rides raw between markers and is parsed back by id. This works on **every** chat provider.
- **Partial failure degrades gracefully:** a whole-request failure falls back to per-field for every field; a single field that doesn't round-trip (missing / duplicated / empty / broken sentinel boundary) falls back to per-field for just that field. No request is retried more than once. Each fallback is logged.
- **Large nodes are chunked** by a configurable character-count threshold (provider-agnostic; token counting isn't portable across `ai` providers). Fields are greedily bin-packed into batches under the threshold; a single oversized field is sent on its own. This also mitigates the large-content timeouts reported in #3545381.
- **Opt-in:** the batch path is exposed as a separate method (and/or config flag) so no consumer behavior changes until a caller opts in.
## Remaining tasks
- [ ] Implement `translateMultiple()` + interface addition (additive; per-field path untouched)
- [ ] Sentinel build/parse with per-request nonce; per-field fallback on any field that doesn't round-trip
- [ ] Character-based chunking / greedy bin-packing; oversized-field handling
- [ ] Carry the existing prompt-injection guard into the batch prompt + add data/instruction framing
- [ ] Tests: multi-field round-trip, HTML preservation, sentinel parse + per-field fallback, partial-failure behavior, oversized-field chunking
- [ ] Demonstrate token reduction with before/after token counts on a representative multi-field node
- [ ] phpcs (Drupal + DrupalPractice) clean; GitLab CI green
- [ ] Maintainer review of API placement and opt-in surface
## User interface changes
None by default. Optionally a config setting for the batch-chunk character threshold and/or a toggle to enable batching. No change to existing behavior unless enabled.
## API changes / Data model changes
- **Additive only.** New `translateMultiple()` on `TextTranslatorInterface` (a default implementation keeps the interface non-breaking for existing implementers). No change to `translateContent()` or to the `ai` module's `TranslateText` operation type.
---
issue