mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-08 11:46:35 +00:00
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:
1 parent
a490b28af6
commit
4f2c9140fa
6 files changed
+71
-11
No files matched your search
@@ -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,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
@@ -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
@@ -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("\\")
|
||||
|
||||
Reference in new issue
Block a user