fix(i18n): translate against the models the run actually asked for

A batch that named its languages still deployed a server that knew none of
them. The role renders load_only from the inventory, the tracked i18n.yml
leaves it empty, and an empty load_only makes the image skip
prefetch_models.py. Nothing was baked behind the package cache, so the
server reached for the whole argos set at runtime over a path no cache
serves. The readiness wait then sat for its full hour and gave up with
"does not offer", which reads like a server that refuses the language
rather than one still downloading it. The lane now renders its own host
vars with load_only pinned to the pivot plus the batch, beside the tracked
file so the container sees it, and deploy and server carry the codes that
decide it.

The span guard could not see a hyphen. Its dotted-name alternative matched
[A-Za-z]\w*(\.[A-Za-z]\w+)+, and \w excludes -, so a host like argos-net.com
was never a protected span: a translation was free to mangle it and the
comparison still balanced. Widening the head to [\w-]* brings those names
under the same guard the undotted ones already had.

A refusal named its criterion and nothing else. "protected-span" says an
entry was turned down without saying which span moved, which is the one
fact that separates a bad translation from a criterion that is too strict,
so the marker now carries lost= and gained= and the per-catalog summary
counts the criteria it saw instead of reporting every rejection as a
damaged span.

Verified in the container against babel 2.18.0: 171 unit tests pass, and
the catalog lint holds across all 218 catalogs under the widened regex,
which is the gate that would have caught a span the old pattern let through.
The rendered vars file pins de, el, en, et, hi and lv, and those catalogs
carry their translations.

make i18n-tune still calls server with one argument and is fixed separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-09-27 17:25:46 +02:00
1 parent a490b28af6
commit 4f2c9140fa
6 files changed
+71 -11

No files matched your search

+9 -2
View File
@@ -16,6 +16,7 @@ import json
import os
import sys
import tempfile
from collections import Counter
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
from pathlib import Path
@@ -42,6 +43,7 @@ from utils.i18n.libretranslate import (
download_status,
server,
)
from utils.i18n.placeholders import Rejected
from utils.i18n.translate import apply, damaged, discard, pending, retry
CHUNK_SIZE = 500
@@ -152,10 +154,14 @@ def one_catalog(client: LibreTranslate, domain: str, code: str) -> None:
catalog = read_catalog(path)
todo = pending(catalog)
discarded = refused = damaged = 0
criteria: Counter[str] = Counter()
refusal = ""
for start in range(0, len(todo), CHUNK_SIZE):
chunk = todo[start : start + CHUNK_SIZE]
outcome = client.translate([m.id for m in chunk], code)
criteria.update(
value.reason for value in outcome.values if isinstance(value, Rejected)
)
discarded += apply(chunk, outcome.values)
refused += outcome.refused
damaged += outcome.damaged
@@ -163,9 +169,10 @@ def one_catalog(client: LibreTranslate, domain: str, code: str) -> None:
write_catalog(path, catalog)
print(f"{domain}/{code}: {start + len(chunk)}/{len(todo)}", flush=True)
tail = f", last refusal {refusal}" if refused else ""
broke = ", ".join(f"{count} {name}" for name, count in criteria.most_common())
print(
f"{domain}/{code}: {len(todo) - discarded} translated, {discarded} discarded "
f"({refused} requests refused, {damaged} damaged a protected span){tail}",
f"({refused} requests refused, {damaged} rejected: {broke or 'none'}){tail}",
flush=True,
)
@@ -193,7 +200,7 @@ def translate(domains: list[str], requested: list[str]) -> int:
tuned = int(os.environ.get("INFINITO_I18N_LANES") or 0)
lanes = min(len(work), tuned or cpus)
batch = int(os.environ.get("INFINITO_I18N_BATCH_SIZE") or BATCH_SIZE)
with server(PROJECT_ROOT) as url:
with server(PROJECT_ROOT, codes) as url:
client = LibreTranslate(url, max(cpus // lanes, 1), batch_size=batch)
client.wait(
codes,
@@ -20,7 +20,7 @@ class TestParallelDeployIsRefused(unittest.TestCase):
mock.patch("utils.i18n.libretranslate.deploying", return_value=True),
mock.patch("utils.i18n.libretranslate.deploy") as started,
self.assertRaises(RuntimeError),
server(Path("/repo")),
server(Path("/repo"), ["de"]),
):
pass
started.assert_not_called()
@@ -199,7 +199,10 @@ class TestRefusalIsRemembered(unittest.TestCase):
self.assertEqual(
message.user_comments,
[
f"{REFUSAL_PREFIX} {ENGINE} protected-span",
(
f"{REFUSAL_PREFIX} {ENGINE} protected-span "
"lost=['{count}'] gained=[]"
),
URL_SUPPRESSION,
f"{REJECTED_PREFIX} verlustig Einträge",
],
+32 -4
View File
@@ -32,6 +32,8 @@ DEPLOY_PID_FILE = Path("build") / "deploy.pid"
DEPLOY_ROUTER = "deploy/main.sh"
GPU_STAMP = Path("build") / "i18n-gpu.stamp"
INVENTORY_VARS = Path("inventories") / "development" / "i18n.yml"
INVENTORY_VARS_RENDERED = Path("inventories") / "development" / "i18n.rendered.yml"
PIVOT_LANGUAGE = "en"
def accelerated() -> bool:
@@ -186,11 +188,36 @@ def await_runner(name: str, env: dict[str, str]) -> None:
raise RuntimeError(f"The i18n runner {name} did not reach the running state.")
def deploy(root: Path) -> str:
def _vars_file(root: Path, codes: list[str]) -> Path:
"""Write the lane's host vars with ``load_only`` pinned to *codes*.
An empty ``load_only`` makes the image skip ``prefetch_models.py``, so the
server installs the whole argos set at runtime, where no cache reaches it.
Args:
root: repository root.
codes: the target languages this run translates into.
Returns:
The rendered file, beside the tracked one so the container sees it.
"""
from utils.cache.yaml import dump_yaml, load_yaml
loaded = load_yaml(str(root / INVENTORY_VARS))
service = loaded["applications"][ROLE]["services"]["libretranslate"]
service["load_only"] = sorted({PIVOT_LANGUAGE, *codes})
rendered = root / INVENTORY_VARS_RENDERED
dump_yaml(rendered, loaded)
return rendered
def deploy(root: Path, codes: list[str]) -> str:
"""Deploy web-svc-libretranslate alone into its own inventory and return its URL.
Args:
root: repository root.
codes: the target languages, pinned into the role's ``load_only`` so
the image bakes exactly those models through the cache.
Returns:
The base URL, empty when the service stays unreachable.
@@ -232,7 +259,7 @@ def deploy(root: Path) -> str:
"--include",
ROLE,
"--vars-file",
f"{loaded['INFINITO_SRC_DIR']}/{INVENTORY_VARS}",
f"{loaded['INFINITO_SRC_DIR']}/{_vars_file(root, codes).relative_to(root)}",
],
[
*inside,
@@ -268,11 +295,12 @@ def deploy(root: Path) -> str:
@contextmanager
def server(root: Path) -> Iterator[str]:
def server(root: Path, codes: list[str]) -> Iterator[str]:
"""Yield the URL of the dedicated LibreTranslate, deploying it when absent.
Args:
root: repository root.
codes: the target languages, which decide the models the deploy bakes.
Yields:
The base URL to translate against. The service answers only once it
@@ -289,7 +317,7 @@ def server(root: Path) -> Iterator[str]:
"left untouched."
)
running = "" if unaccelerated(root) else deployed(root)
yield running or deploy(root)
yield running or deploy(root, codes)
def lane_container(env: dict[str, str]) -> str:
+1 -1
View File
@@ -31,7 +31,7 @@ PROTECTED = re.compile(
r"|(?<=\])\((?:[^\s()]|\([^\s()]*\))+\)"
r"|(?<![\w/@.-])[\w.-]+/[\w.@+-]+(?:/[\w.@+-]+)+"
r"|(?<![\w/@.-])[\w.-]+/[\w.@+-]*\.[A-Za-z]\w{0,7}\b"
r"|\b[A-Za-z]\w*(?:\.[A-Za-z]\w+)+"
r"|\b[A-Za-z][\w-]*(?:\.[A-Za-z]\w+)+"
r"|\b[A-Za-z]\w*_\w+"
r"|\"[\w.-]*(?:<[^>\s]*>[\w.-]*)*\"|'[\w.-]*(?:<[^>\s]*>[\w.-]*)*'"
r"|\d+(?:[.,]\d+)*"
+24 -2
View File
@@ -11,7 +11,7 @@ from utils.i18n.catalog import (
REJECTED_PREFIX,
TRANSLATION_REFUSED,
)
from utils.i18n.placeholders import Rejected, harms
from utils.i18n.placeholders import Rejected, harms, protected_spans
if TYPE_CHECKING:
from babel.messages.catalog import Catalog, Message
@@ -144,6 +144,26 @@ def retry(catalog: Catalog) -> int:
return sum(1 for message in catalog if refused(message) and clear(message))
SPAN_CRITERION = "protected-span"
DETAIL_SPANS = 6
def _detail(message: Message, rejected: Rejected) -> str:
"""Return the spans that made a rejection, for the marker to carry.
Args:
message: the entry being annotated.
rejected: what came back and why it was turned down.
"""
if rejected.reason != SPAN_CRITERION or not isinstance(message.id, str):
return ""
source = protected_spans(message.id)
answer = protected_spans(rejected.text)
lost = sorted(source - answer)[:DETAIL_SPANS]
gained = sorted(answer - source)[:DETAIL_SPANS]
return f" lost={lost} gained={gained}".replace("\n", "\\n")
def record(message: Message, rejected: Rejected | None) -> None:
"""Annotate ``message`` as unanswerable, quoting what was turned down.
@@ -164,7 +184,9 @@ def record(message: Message, rejected: Rejected | None) -> None:
if rejected is None:
message.user_comments.append(TRANSLATION_REFUSED)
return
message.user_comments.append(f"{REFUSAL_PREFIX} {ENGINE} {rejected.reason}")
message.user_comments.append(
f"{REFUSAL_PREFIX} {ENGINE} {rejected.reason}{_detail(message, rejected)}"
)
message.user_comments.append(URL_SUPPRESSION)
message.user_comments.append(
f"{REJECTED_PREFIX} {rejected.text}".replace("\n", "\\n").rstrip("\\")