Files
openobserve/scripts/translations/translator.py
T
Bhargav 3fb35f2dc1 ci: fix weekly npm update type-check failure and rename branch/PR (#13671)
Restrict npm-check-updates to in-major upgrades (--target minor --peer).
Unrestricted major bumps pushed typescript from 5.9.x to 7.0.x, which
drops the ./lib/tsc export that vue-tsc still resolves, failing the type
check with ERR_PACKAGE_PATH_NOT_EXPORTED. Majors held back are now
listed in the PR body so they stay visible for a deliberate manual
upgrade.

Rename the branch to chore/npm-update-<date> and the PR title to
chore(deps): update web npm dependencies, matching the repo convention
used by update-translations.yml instead of the automated/ prefix.

Also fix along the way:
- npm ci before checking for updates, so reported current versions are
real
- gate has_updates on ncu output instead of npm outdated, so the job no
longer runs a full build cycle only to find nothing to commit
- write scratch files to RUNNER_TEMP so an untracked outdated.json
cannot make the commit step think dependencies changed
- scope the change check to web/package.json and web/package-lock.json
- split the commit message into separate -m flags to drop YAML
indentation
2026-08-06 13:45:57 +00:00

527 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
from openai import OpenAI
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 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:
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