Files
openobserve/scripts/translations/translator.py
T

858 lines
35 KiB
Python

# Copyright 2026 OpenObserve Inc.
"""
Incremental translation engine for OpenObserve (DeepSeek-backed).
Cost model: translation is done by an LLM (DeepSeek) via its OpenAI-compatible
API. To keep cost and latency minimal we only ever send a string for translation
when it is genuinely new or its English source has changed. This is tracked with
a committed state file (`.translation_state.json`) that mirrors en-US.json and
stores, for each key, a hash of the English source that has been successfully
propagated to every supported language.
Decision per key (per locale):
* source unchanged AND already translated -> keep existing value (no API call)
* English source changed since last run -> re-translate (label edits covered)
* key new / missing in target -> translate
* key removed from en-US.json -> pruned from the target file
Pending leaves are translated in batches (many strings per API call) to keep the
request count and cost low, and several batches are in flight at once so a large
backlog finishes in minutes rather than hours. Each string travels with its i18n
key path, and the prompt carries the TERMS glossary (English senses plus optional
per-locale pinned renderings) and the locale's own module names (mined from its
committed `menu.*` translations), so short ambiguous labels resolve to the right
sense and reuse the established vocabulary instead of being translated blind.
Each item is validated
independently — a string whose translation drops/alters an interpolation
placeholder (e.g. `{count}`) is rejected and left un-advanced so it retries on the
next run, exactly like a hard API failure.
Environment:
DEEPSEEK_API_KEY Required. API key for https://api.deepseek.com.
DEEPSEEK_MODEL Model id (default "deepseek-v4-flash").
DEEPSEEK_BASE_URL API base URL (default "https://api.deepseek.com").
TRANSLATION_BATCH_SIZE Strings per API call (default 50).
TRANSLATION_CONCURRENCY Batches in flight per locale (default 4; 1 = serial).
"""
import json
import os
import re
import hashlib
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
# `openai` is imported inside _get_client(), not here: the change-detection half of
# this module (collect_pending_leaves, load_state, …) is pure file comparison, and
# `main.py --check` runs it with no dependencies installed and no API key — which is
# what lets the merge-queue gate be free.
STATE_FILENAME = ".translation_state.json"
# Source locale: filename stem of the English source (web/.../languages/en-US.json).
SOURCE_LOCALE = "en-US"
# Human-readable target names used in the translation prompt, keyed by the target
# locale's filename stem (BCP-47 style, matching web/src/locales/languages/*.json).
LANGUAGE_NAMES = {
"ar-SA": "Arabic",
"tr-TR": "Turkish",
"zh-CN": "Simplified Chinese",
"fr-FR": "French",
"es-ES": "Spanish",
"de-DE": "German",
"it-IT": "Italian",
"pt-PT": "Portuguese",
"ja-JP": "Japanese",
"ko-KR": "Korean",
"nl-NL": "Dutch",
"zh-TW": "Traditional Chinese",
"ru-RU": "Russian",
"pl-PL": "Polish",
"vi-VN": "Vietnamese",
}
# The term table injected into every prompt. Strings reach the model as isolated
# UI fragments, so without the `sense` lines an "event" is a party, a "stream" is
# a brook and "live" is a broadcast — exactly the mistranslations a zh-CN audit
# found. A sense steers every target language at once; an optional per-locale key
# pins one language's rendering, because the same audit found the same word coined
# differently across batches (stream = 流/数据流, trace = 追踪/跟踪). Keeping both
# in one table means a pinned rendering can never lose its sense definition.
TERMS = {
"stream": {
"sense": "a named dataset of ingested telemetry (logs, metrics, or traces) "
"— the data-stream sense, never a brook/creek and never audio/video streaming",
"zh-CN": "数据流",
"zh-TW": "串流",
},
"alert": {
"sense": "a monitoring alert: an alerting rule or the notification it "
"sends — the alarm sense, not a browser dialog",
"zh-CN": "告警",
"zh-TW": "警示",
},
"filter": {
"sense": "a condition that narrows query or list results",
"zh-CN": "过滤器",
},
"event": {
"sense": "a recorded occurrence in the system (log event, alert event) — "
"the record/incident sense, never a social activity or gathering",
"zh-CN": "事件",
"zh-TW": "事件",
},
"live": {
"sense": "updating in real time (live tail, live data) — the real-time "
"sense, never a live broadcast or livestream",
"zh-CN": "实时",
"zh-TW": "即時",
},
"trace": {
"sense": "a distributed trace: one request's recorded path through services",
"zh-CN": "追踪",
"zh-TW": "追蹤",
},
"span": {
"sense": "a single operation inside a distributed trace — the APM term of "
"art; keep 'Span' when the language has no established equivalent",
"zh-CN": "Span",
"zh-TW": "Span",
},
"function": {
"sense": "a user-defined data-transformation function (VRL) — the "
"programming sense",
},
"pipeline": {
"sense": "a data-processing pipeline that routes and transforms ingested "
"records",
"zh-CN": "流水线",
"zh-TW": "管線",
"fr-FR": "pipeline",
},
"downtime": {
"sense": "a scheduled window that mutes alert notifications (a maintenance "
"window) — never an outage or unavailability",
"fr-FR": "période de maintenance",
},
"burn rate": {
"sense": "how fast an SLO's error budget is being spent — never combustion",
"fr-FR": "taux d'épuisement",
},
"page": {
"sense": "in on-call: a notification that summons a responder, and the "
"record it opens — not a web page",
"fr-FR": "notification",
},
"scorer": {
"sense": "an evaluator that grades LLM output — never a sports scorer",
"fr-FR": "évaluateur",
},
"RUM": {
"sense": "Real User Monitoring, a product module name — keep the "
"acronym 'RUM' wherever the source says RUM",
"fr-FR": "RUM",
},
"destination": {
"sense": "a configured endpoint that alerts or pipeline output are "
"delivered to",
"zh-CN": "目标",
"zh-TW": "目標",
},
"organization": {
"sense": "a tenant/workspace that groups users and data — the account sense",
},
"firing": {
"sense": "the state of an alert that is currently triggered",
"zh-CN": "触发中",
"zh-TW": "觸發中",
},
"open": {
"sense": "as an incident/issue status: still unresolved — not the verb "
"'to open'",
},
"top": {
"sense": "in rankings (Top N, Top Queries): highest-ranked; in layout "
"and position labels it is the ordinary spatial top",
},
"value": {
"sense": "the data value of a field or metric — never worth/merit",
"zh-CN": "值",
"zh-TW": "值",
},
"breakdown": {
"sense": "splitting a chart or metric by a dimension — never a failure "
"or collapse",
},
"report": {
"sense": "a scheduled or exported dashboard report document",
"zh-CN": "报表",
"zh-TW": "報表",
},
}
# Subtrees of en-US.json whose leaves are canonical module names; their committed
# translations are mined per locale into the prompt's fixed-renderings table.
MODULE_NAME_SUBTREES = ("menu",)
# printf / linked-message tokens that MUST survive translation unchanged.
# Brace tokens are handled by _scan_tokens, which a regex cannot do: `{'}'}` is a
# single literal-escape token whose body contains the very character a regex like
# `{[^{}]*}` stops at.
_PRINTF = re.compile(r"%[sd]")
# `@` always starts a linked message in vue-i18n — `@:key` or `@.modifier:key`.
# A bare `@` (even inside a word, e.g. an email address) is a compile error; the
# locale files spell it `{'@'}` for that reason.
_LINKED = re.compile(r"@(?:\.[A-Za-z_$][A-Za-z0-9_$]*)?:[\w.]+")
# A vue-i18n named placeholder is a JS-style identifier; a list placeholder is an
# index. Anything else — most importantly a name the model helpfully translated,
# `{标识符}` for `{identifier}` — is INVALID_TOKEN_IN_PLACEHOLDER, and vue-i18n
# compiles messages just-in-time, so it *throws* at render time and blanks the
# page that used the string.
_NAMED = re.compile(r"[A-Za-z_$][A-Za-z0-9_$]*\Z")
_LIST = re.compile(r"\d+\Z")
_client = None
# Batches are translated concurrently (see translate_pending), so the lazy client
# construction below must be race-free.
_client_lock = threading.Lock()
class TranslationError(Exception):
"""Raised when the translation backend fails for a batch."""
def _script_dir():
return os.path.dirname(os.path.abspath(__file__))
def get_language_file_path(locale):
"""Absolute path to a language file (e.g. fr-FR.json)."""
languages_dir = os.path.join(
_script_dir(), "..", "..", "web", "src", "locales", "languages"
)
return os.path.join(languages_dir, f"{locale}.json")
def get_state_file_path():
"""Absolute path to the translation state (source-hash) file."""
return os.path.join(_script_dir(), STATE_FILENAME)
def get_supported_languages():
"""Returns the list of auto-translated language codes.
Derived from LANGUAGE_NAMES so the code list and the prompt's language names
are a single source of truth (dict preserves insertion order).
"""
return list(LANGUAGE_NAMES.keys())
def _hash(text):
"""Short, stable hash of an English source string."""
return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
def load_json(path, default=None):
if os.path.exists(path):
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
return {} if default is None else default
def find_duplicate_keys(path):
"""Return the key names duplicated within a single object in `path`.
json.load keeps the LAST of a duplicated pair, so a locale file carrying two
"announcements" blocks parses, validates and compares as up-to-date while the
earlier block is silently dead. Nothing this module writes can produce one —
it dumps Python dicts — but git can: line-merging two locale files that each
added the same block at a different offset keeps both and reports no conflict.
Only a pairs hook sees them, which is why this is checked rather than assumed.
"""
found = []
def hook(pairs):
seen = set()
for key, _ in pairs:
if key in seen:
found.append(key)
seen.add(key)
return dict(pairs)
with open(path, "r", encoding="utf-8") as f:
json.load(f, object_pairs_hook=hook)
return sorted(set(found))
def load_source():
"""Load the English source (en-US.json)."""
return load_json(get_language_file_path(SOURCE_LOCALE), {})
def load_state():
"""Load the shared source-hash state tree."""
return load_json(get_state_file_path(), {})
def write_json(path, data, sort_keys=False):
"""Write `data` as pretty JSON to `path` atomically.
Written to a sibling temp file and renamed, so an interrupted run (the job is
cancellable mid-write, and CI now commits whatever progress exists) can never
leave a truncated / half-written locale file behind to be committed.
"""
tmp = f"{path}.tmp"
with open(tmp, "w", encoding="utf-8") as f:
f.write(json.dumps(data, indent=2, ensure_ascii=False, sort_keys=sort_keys) + "\n")
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
def save_state(state):
write_json(get_state_file_path(), state, sort_keys=True)
def new_counters():
return {"pending": 0, "kept": 0, "translated": 0, "failed": 0, "failed_paths": set()}
def _scan_tokens(text):
"""
Return (tokens, compilable) for one message.
`tokens` is the multiset of interpolation tokens vue-i18n would see —
placeholders (`{count}`, `{0}`), literal escapes (`{'{'}`, `{'@'}`), printf
tokens and linked-message references — normalised so that incidental
whitespace inside braces does not count as a difference.
`compilable` is False when the message is something vue-i18n's compiler would
reject outright: a placeholder whose name is not an identifier, an unclosed
or unbalanced brace, or an unterminated literal escape. Those are what turn a
translation into a thrown SyntaxError at render time instead of a wrong-looking
label, so they must never be written to a locale file.
"""
tokens = []
i = 0
n = len(text)
while i < n:
ch = text[i]
if ch == "%":
m = _PRINTF.match(text, i)
if m:
tokens.append(m.group(0))
i = m.end()
continue
if ch == "@":
m = _LINKED.match(text, i)
if not m:
return tokens, False # bare @ — must be written {'@'}
tokens.append(m.group(0))
i = m.end()
continue
if ch == "}":
return tokens, False # closing brace with nothing open
if ch != "{":
i += 1
continue
j = i + 1
while j < n and text[j].isspace():
j += 1
if j < n and text[j] == "'":
# Literal escape: {'{'}, {'}'}, {'@'}, {'|'}. The body is quoted, so
# scan to the closing quote rather than to the next brace.
end = text.find("'", j + 1)
if end == -1:
return tokens, False
k = end + 1
while k < n and text[k].isspace():
k += 1
if k >= n or text[k] != "}":
return tokens, False
tokens.append("{'" + text[j + 1 : end] + "'}")
i = k + 1
continue
end = text.find("}", j)
if end == -1:
return tokens, False
name = text[j:end].strip()
if not (_NAMED.match(name) or _LIST.match(name)):
return tokens, False
tokens.append("{" + name + "}")
i = end + 1
return tokens, True
def _placeholders(text):
"""Multiset of interpolation tokens in a string (order-independent)."""
return sorted(_scan_tokens(text)[0])
def _get_client():
"""Lazily construct the DeepSeek (OpenAI-compatible) client.
Constructed on first use so that dry passes / imports don't require the API
key — or the `openai` package — to be present (e.g. when only counting pending
work). The client itself is thread-safe and shared by every worker; only its
construction is locked.
"""
global _client
if _client is None:
with _client_lock:
if _client is None:
from openai import OpenAI
api_key = os.environ.get("DEEPSEEK_API_KEY")
if not api_key:
raise TranslationError(
"DEEPSEEK_API_KEY is not set — cannot reach the translation service."
)
_client = OpenAI(
api_key=api_key,
base_url=os.environ.get("DEEPSEEK_BASE_URL", "https://api.deepseek.com"),
# Cap per-request time so a single hung/slow call can't stall the
# whole run for the SDK's 600s default. Our own retry/split loop
# then recovers.
timeout=float(os.environ.get("DEEPSEEK_TIMEOUT", "60")),
max_retries=2,
)
return _client
def _model():
return os.environ.get("DEEPSEEK_MODEL", "deepseek-v4-flash")
def build_module_glossary(source, existing):
"""(English, translated) pairs for the app's module names.
The MODULE_NAME_SUBTREES trees are where every major module's user-facing
name lives (Alerts, Pipelines, Dashboards, Streams, …). Pulling the pairs
from the locale's own committed translations means new strings reuse the
established vocabulary (zh-CN: Alerts -> 告警, Pipelines -> 流水线) instead of
each batch coining its own, with no per-language table to maintain. A pair
kept in English (RUM -> RUM) is included on purpose: it tells the model
"leave this as is". Sorted so the prompt is byte-stable per locale, which
keeps provider-side prompt caching effective across batches.
"""
terms = {}
for subtree in MODULE_NAME_SUBTREES:
src = source.get(subtree) if isinstance(source.get(subtree), dict) else {}
dst = existing.get(subtree) if isinstance(existing.get(subtree), dict) else {}
for key, en in src.items():
tr = dst.get(key)
if not isinstance(en, str) or not isinstance(tr, str):
continue
# Token-bearing entries (aria templates) are messages, not names.
if _placeholders(en) or not en.strip() or not tr.strip():
continue
terms.setdefault(en, tr)
return sorted(terms.items())
def _system_prompt(locale, module_terms):
lang = LANGUAGE_NAMES.get(locale, locale)
pinned = {term: entry[locale] for term, entry in TERMS.items() if locale in entry}
# One renderings table, curated pinned terms winning over mined module names
# (case/plural-insensitively), so the prompt can never mandate two different
# renderings for the same word.
renderings = {
en: tr
for en, tr in module_terms
if en.lower() not in pinned and en.lower().removesuffix("s") not in pinned
}
renderings.update(pinned)
parts = [
f"You are a professional software localization engine. Translate UI strings "
f"for OpenObserve from English into {lang}.\n"
"Context: OpenObserve is an observability platform. Users ingest logs, "
"metrics, and traces into streams, search them, chart them on dashboards, "
"alert on them, and transform them with functions and pipelines. Every "
"string is UI text from this product (buttons, labels, menu items, table "
"headers, messages), so always prefer a word's technical/monitoring sense "
"over its everyday sense.\n"
'Each input item has "key" and "text". The key is the i18n key path — '
"context that tells you where in the UI the text appears (for key "
"alerts.incidents.statusOpen, 'Open' is an incident status meaning "
"unresolved, not the verb). Use it to resolve ambiguity; translate only "
"the text, and never echo the key in the output.\n"
"Glossary — the sense these terms always carry in OpenObserve:\n"
]
parts.extend(f"- {term}: {entry['sense']}\n" for term, entry in TERMS.items())
if renderings:
parts.append(
f"Fixed {lang} renderings — always render these terms and module "
"names exactly as shown:\n"
)
parts.extend(
f"- {en} -> {tr}\n"
for en, tr in sorted(renderings.items(), key=lambda kv: kv[0].lower())
)
parts.append(
"Rules:\n"
'- Reply with ONLY a JSON object of the form {\"translations\": [...]} '
"containing exactly one translated string per input item, in the same order.\n"
"- Preserve every interpolation placeholder EXACTLY as written and untranslated: "
"curly-brace tokens like {count}, {name}, {0}; printf tokens like %s, %d; and "
"linked-message tokens like @:common.name. Never translate, reorder, or remove them.\n"
"- Preserve the EXACT number of '|' pipe separators — they are vue-i18n plural "
"forms. Translate each segment. If the target language has no plural distinction, "
"repeat the same translated form for every segment, keeping the '|' separators "
"(e.g. '{n} Day | {n} Days' -> '{n} 天 | {n} 天').\n"
"- Copy vue-i18n literal escapes verbatim: {'@'}, {'{'}, {'}'}, {'|'} — keep the "
"quotes and braces exactly, never translate or unescape them.\n"
"- Keep any HTML tags, markdown, and URLs intact.\n"
"- Keep quoted code literals verbatim — field names, enum values and query "
"values in quotes ('metrics', 'role', level = 'error') are typed by users "
"and must not be translated.\n"
"- A short label that starts with a verb ('Copy Key', 'Import dashboard', "
"'Search Role') is an action: translate it as a command, never as a noun "
"phrase.\n"
"- Do not translate the product name 'OpenObserve' or other proper nouns/brand names.\n"
"- Preserve leading/trailing whitespace and terminal punctuation.\n"
"- Produce natural, concise wording appropriate for buttons, labels, and short UI messages.\n"
"- Do not add explanations, notes, or any text outside the JSON object."
)
return "".join(parts)
def translate_batch(items, locale, prompt, max_retries=3, _depth=0):
"""
Translate a list of (key, text) pairs via DeepSeek, index-aligned to `items`.
`key` is the string's dotted i18n path, sent purely as disambiguating
context. `prompt` is the locale's system prompt, built once per locale by
translate_pending.
Returns a list the same length as `items`; each element is the translated
string, or None if that specific item could not be translated. A None item is
left un-advanced by the caller so it retries on the next run.
Robustness:
* Length/shape mismatches do NOT discard the whole batch. If the model
returns the wrong number of items, the batch is split in half and each
half retried, isolating the single bad string as None while its neighbours
still translate.
* Retries perturb the temperature. The first request runs at temperature 0
for determinism; because a deterministic retry of a deterministic failure
is pointless, subsequent attempts raise the temperature so the model can
actually produce a different (valid) response.
"""
if not items:
return []
payload = json.dumps(
{"strings": [{"key": key, "text": text} for key, text in items]},
ensure_ascii=False,
)
temperature = 0.0
last_err = None
for attempt in range(max_retries):
try:
resp = _get_client().chat.completions.create(
model=_model(),
messages=[
{"role": "system", "content": prompt},
{"role": "user", "content": payload},
],
stream=False,
temperature=temperature,
response_format={"type": "json_object"},
)
content = resp.choices[0].message.content
data = json.loads(content) if content else {}
out = data.get("translations") if isinstance(data, dict) else None
if not isinstance(out, list) or len(out) != len(items):
raise TranslationError(
f"expected {len(items)} translations, got "
f"{len(out) if isinstance(out, list) else type(out).__name__}"
)
# An empty/whitespace/non-string element is a failed item, not a valid
# translation — never write a blank label. Treat it like a shape
# mismatch so it flows through perturb-retry and, if it persists, the
# split below isolates just that index as None (retried next run).
cleaned = [x if isinstance(x, str) and x.strip() else None for x in out]
if all(c is not None for c in cleaned):
return cleaned
raise TranslationError(
f"{cleaned.count(None)} of {len(items)} translations were empty/invalid"
)
except (json.JSONDecodeError, TranslationError, KeyError, IndexError, TypeError) as e:
# Malformed / wrong-length response — perturb temperature and retry.
last_err = e
temperature = 0.3 if temperature == 0.0 else min(temperature + 0.2, 1.0)
except Exception as e: # noqa: BLE001 — network / API / rate-limit errors
last_err = e
time.sleep(2 * (attempt + 1))
# Retries exhausted. Split to isolate the offending element so its neighbours
# aren't lost with it. Recursion bottoms out naturally at a single string
# (which returns [None]); the depth guard is only a stack backstop and must be
# large enough to reach singletons for any batch size (log2(4096) = 12).
if len(items) > 1 and _depth < 32:
mid = len(items) // 2
left = translate_batch(items[:mid], locale, prompt, max_retries, _depth + 1)
right = translate_batch(items[mid:], locale, prompt, max_retries, _depth + 1)
return left + right
print(f" ! translation failed for [{locale}] ({len(items)} item(s)): {last_err}")
return [None] * len(items)
def _src_text(value):
return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
def _needs_translation(has_existing, prev_hash, cur_hash):
"""
Whether a leaf must be (re)translated.
Bootstrap rule: when there is no recorded state for a key (prev_hash is None)
but a translation already exists, we trust the existing translation and only
record its hash — this avoids a one-time full re-translation (and overwriting
manual fixes) the first time the state file is introduced.
"""
if not has_existing:
return True
if prev_hash is None:
return False # bootstrap: adopt existing translation as up to date
return prev_hash != cur_hash # re-translate only when the English source changed
def collect_pending_leaves(source, existing, state, path=()):
"""Return (path_tuple, value) for every leaf needing translation.
`value` is the raw source value — a string, or a list of strings for an array
leaf — so translate_pending can translate array elements and preserve the type.
"""
pending = []
for key, value in source.items():
cur_path = path + (key,)
if isinstance(value, dict):
child_existing = existing.get(key) if isinstance(existing.get(key), dict) else {}
child_state = state.get(key) if isinstance(state.get(key), dict) else {}
pending.extend(
collect_pending_leaves(value, child_existing, child_state, cur_path)
)
continue
text = _src_text(value)
if not text.strip():
continue # empty source, never translated
cur_hash = _hash(text)
prev_hash = state.get(key) if isinstance(state.get(key), str) else None
has_existing = key in existing and not isinstance(existing.get(key), dict)
if _needs_translation(has_existing, prev_hash, cur_hash):
# Keep the raw value (str or list) so translate_pending can handle
# array leaves element-wise and preserve their type.
pending.append((cur_path, value))
return pending
def build_locale(source, existing, state, translated, counters, path=()):
"""
Build the target subtree for one locale from precomputed translations.
`translated` maps a leaf's path-tuple to its translated string (only pending
leaves that succeeded are present). Keys absent from `source` are pruned. A
pending leaf with no successful translation is counted as failed and its path
recorded in `counters['failed_paths']` so shared state is not advanced for it.
"""
target = {}
for key, value in source.items():
cur_path = path + (key,)
if isinstance(value, dict):
child_existing = existing.get(key) if isinstance(existing.get(key), dict) else {}
child_state = state.get(key) if isinstance(state.get(key), dict) else {}
target[key] = build_locale(
value, child_existing, child_state, translated, counters, cur_path
)
continue
text = _src_text(value)
cur_hash = _hash(text)
prev_hash = state.get(key) if isinstance(state.get(key), str) else None
has_existing = key in existing and not isinstance(existing.get(key), dict)
if not text.strip():
target[key] = value # empty source, never translated
counters["kept"] += 1
continue
if not _needs_translation(has_existing, prev_hash, cur_hash):
target[key] = existing[key]
counters["kept"] += 1
continue
counters["pending"] += 1
if cur_path in translated:
target[key] = translated[cur_path]
counters["translated"] += 1
else:
# No successful translation this run — keep a stale value if we have
# one, otherwise leave English as a visible placeholder. State is NOT
# advanced -> retried next run.
counters["failed"] += 1
counters["failed_paths"].add(cur_path)
target[key] = existing[key] if has_existing else value
return target
def _leaf_present(tree, path):
node = tree
for part in path:
if not isinstance(node, dict) or part not in node:
return False
node = node[part]
return not isinstance(node, dict)
def build_state(source, locale_targets, failed_paths, path=()):
"""
Rebuild the shared state tree after a full run.
A key's hash is recorded only when its translation is present in *every*
supported locale and it did not fail this run — guaranteeing the next run
treats it as done across all languages.
"""
state = {}
for key, value in source.items():
cur_path = path + (key,)
if isinstance(value, dict):
sub = build_state(value, locale_targets, failed_paths, cur_path)
if sub:
state[key] = sub
continue
text = _src_text(value)
if not text.strip():
state[key] = _hash(text)
continue
if cur_path in failed_paths:
continue # leave unset -> retry next run
if all(_leaf_present(t, cur_path) for t in locale_targets.values()):
state[key] = _hash(text)
return state
def _validate(src, out):
"""
Return `out` if it safely preserves `src`'s structure, else None.
Rejects a translation that is empty/whitespace-only (which would blank the
label), that vue-i18n could not compile at all, that changes the
interpolation-token multiset ({count}, {'{'}, %s, @:key), or that changes the
vue-i18n pluralization pipe count (`|`).
The compilability check is the one that matters most: a translated placeholder
name or a mangled literal escape makes vue-i18n throw while rendering, which
blanks every component on the page rather than just spoiling one label.
"""
if not isinstance(out, str) or not out.strip():
return None
out_tokens, compilable = _scan_tokens(out)
if not compilable:
return None
if _placeholders(src) != sorted(out_tokens):
return None
if src.count("|") != out.count("|"):
return None
return out
def translate_pending(pending, locale, module_terms):
"""
Translate all pending leaves for a locale in batches.
`pending` is a list of (path_tuple, value) where value is a string or a list
of strings; `module_terms` is the locale's module-name glossary from
build_module_glossary. Returns {path_tuple: translated_value} for leaves that
translated successfully AND passed `_validate` for every string. String
leaves map to a string; array leaves map to a list (translated element-wise,
type preserved). A leaf with any failed/invalid string is omitted entirely
(left to retry).
"""
batch_size = int(os.environ.get("TRANSLATION_BATCH_SIZE", "50"))
prompt = _system_prompt(locale, module_terms)
# Flatten every translatable string across all leaves into one work list so
# array elements share batches with plain strings. `slots` holds the eventual
# per-leaf output (translated strings, kept non-str/empty elements, or None).
units = [] # [path, kind, slots]
flat = [] # [(unit_idx, elem_idx, (dotted_key, source_string))]
for path, value in pending:
dotted = ".".join(path)
if isinstance(value, list):
slots = [None] * len(value)
uidx = len(units)
units.append([path, "list", slots])
for i, el in enumerate(value):
if isinstance(el, str) and el.strip():
flat.append((uidx, i, (f"{dotted}.{i}", el)))
else:
slots[i] = el # non-string / empty — keep as-is, never billed
else:
uidx = len(units)
units.append([path, "str", [None]])
flat.append((uidx, 0, (dotted, value)))
total = len(flat)
chunks = [flat[start : start + batch_size] for start in range(0, total, batch_size)]
# Batches are independent, so translate several concurrently. Sequentially a
# backlog run is ~880 API calls and takes hours — longer than the gap between
# two en-US.json merges to main, so the run was always superseded before it
# could open a PR. Concurrency is what lets a full run actually land.
workers = max(1, int(os.environ.get("TRANSLATION_CONCURRENCY", "4")))
batch_results = [None] * len(chunks)
done = 0
if workers == 1 or len(chunks) <= 1:
for i, chunk in enumerate(chunks):
batch_results[i] = translate_batch([item for _, _, item in chunk], locale, prompt)
done += len(chunk)
print(f" {locale}: {done}/{total} strings translated", flush=True)
else:
with ThreadPoolExecutor(max_workers=workers) as pool:
futures = {
pool.submit(
translate_batch, [item for _, _, item in chunk], locale, prompt
): i
for i, chunk in enumerate(chunks)
}
# Results are stored by index, so out-of-order completion cannot
# misalign a translation with its source string.
for fut in as_completed(futures):
i = futures[fut]
batch_results[i] = fut.result()
done += len(chunks[i])
print(f" {locale}: {done}/{total} strings translated", flush=True)
for chunk, results in zip(chunks, batch_results):
for (uidx, eidx, (_key, src)), out in zip(chunk, results):
units[uidx][2][eidx] = _validate(src, out) # None on failure/mismatch
translated = {}
for path, kind, slots in units:
if any(s is None for s in slots):
# At least one string failed or changed structure — skip the whole
# leaf so its state hash is not advanced and it retries next run.
print(f" ! skipping {'/'.join(path)} [{locale}] — unsafe/failed translation")
continue
translated[path] = slots[0] if kind == "str" else slots
return translated