Files
openobserve/scripts/translations/translator.py
T
Bhargav a9dce87071 ci(i18n): generate translations in the PR that changes en-US.json (#13741)
The translation job ran on `push: main` and opened a separate
`chore/update-translations` PR. That PR had no owner — it was never
assigned to or reviewed by a human — and it left a window where main
carried English strings with no translations.

Move the trigger to `pull_request` and commit the generated locale files
back onto the PR's own head branch, so the author who added the English
strings also owns their translations and both land in one merge.

Notes on the mechanics:

- The commit is pushed with TRANSLATIONS_APPROVE_TOKEN, not
GITHUB_TOKEN. GitHub does not trigger workflows for GITHUB_TOKEN pushes,
so the new head SHA would carry no status checks and required checks
could never pass — the PR would be wedged. Falls back to GITHUB_TOKEN if
unset.
- A guard step skips the run when the head commit is already the
generated translation commit, so the PAT push cannot ping-pong.
- Fork PRs are skipped: their branch lives in the contributor's repo and
no token here can push to it.
- concurrency flips to cancel-in-progress: true. The `false` was
main-specific (merges landed faster than a run finished); on a PR branch
nothing is committed until a run completes and the surviving run
re-derives the pending set from .translation_state.json, so a cancel
costs spend, never progress.
- The push retries with a rebase in case the author pushes concurrently.

Cost does not regress: re-translation across branches is prevented by
the committed per-key hashes in .translation_state.json, not by the
trigger, so each string is still translated once — on whichever branch
first introduces it.
2026-08-11 06:36:04 +00:00

533 lines
22 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 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 = {
"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",
# NOTE: RTL languages (ar Arabic, fa Persian) are intentionally NOT listed
# yet — the web app has no dir="rtl" support, and the locale registry's
# strict on-disk/localeFileMap sync would force them to be user-selectable
# (and auto-served to ar/fa browsers) the moment their files are generated.
# Add them here together with RTL layout support and their web wiring.
}
# vue-i18n / printf style interpolation tokens that MUST survive translation
# unchanged: {count}, {name}, {0}, %s, %d, @:linked.key
_PLACEHOLDER = re.compile(r"{[^{}]*}|%[sd]|@:[\w.]+")
_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 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 _placeholders(text):
"""Multiset of interpolation tokens in a string (order-independent)."""
return sorted(_PLACEHOLDER.findall(text))
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 _system_prompt(locale):
lang = LANGUAGE_NAMES.get(locale, locale)
return (
f"You are a professional software localization engine. Translate UI strings "
f"for OpenObserve, an observability platform, from English into {lang}.\n"
"Rules:\n"
'- Reply with ONLY a JSON object of the form {\"translations\": [...]} '
"containing exactly one translated string per input string, 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"
"- 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."
)
def translate_batch(texts, locale, max_retries=3, _depth=0):
"""
Translate a list of strings via DeepSeek, index-aligned to `texts`.
Returns a list the same length as `texts`; 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 texts:
return []
payload = json.dumps({"strings": texts}, 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": _system_prompt(locale)},
{"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(texts):
raise TranslationError(
f"expected {len(texts)} 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(texts)} 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(texts) > 1 and _depth < 32:
mid = len(texts) // 2
left = translate_batch(texts[:mid], locale, max_retries, _depth + 1)
right = translate_batch(texts[mid:], locale, max_retries, _depth + 1)
return left + right
print(f" ! translation failed for [{locale}] ({len(texts)} item(s)): {last_err}")
return [None] * len(texts)
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), changes the interpolation-placeholder multiset ({count}, %s, @:key),
or changes the vue-i18n pluralization pipe count (`|`) — each silently breaks
runtime rendering.
"""
if not isinstance(out, str) or not out.strip():
return None
if _placeholders(src) != _placeholders(out):
return None
if src.count("|") != out.count("|"):
return None
return out
def translate_pending(pending, locale):
"""
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. 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"))
# 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, source_string)]
for path, value in pending:
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, 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, 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([s for _, _, s in chunk], locale)
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, [s for _, _, s in chunk], locale): 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, 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