Files
openobserve/scripts/translations/main.py
T
Hengfei Yang 7789e90118 fix(i18n): send key-path context and a term glossary to the translation model (#13967)
Addresses the mechanism half of #13966 — auto-translations were
context-blind because `translate_batch()` sent bare string arrays with a
one-line system prompt.

## What changed

- **Key-path context**: each string is now sent as `{"key":
"alerts.incidents.statusOpen", "text": "Open"}` and the prompt tells the
model to use the key to disambiguate ("Open" there is an incident
status, not the verb). Keys are context only and never echoed into the
output; the response format is unchanged.
- **Product background + `TERMS` glossary** (`translator.py`): the
prompt now introduces OpenObserve and carries one term table — an
English `sense` line per ambiguous term that steers all 14 languages at
once (an "event" is a record, not a party; a "stream" is a data stream,
not a brook; "live" is real-time, not a broadcast), plus optional
per-locale pinned renderings (zh-CN/zh-TW seeded: event → 事件, stream →
数据流, live → 实时, trace → 追踪, report → 报表, …).
- **Module vocabulary mined from the locale itself**:
`build_module_glossary()` extracts the committed `menu.*` translations
(Alerts → 告警, Pipelines → 流水线, Dashboards → 仪表盘) and merges them with
the pinned terms into a single conflict-free renderings table — pinned
terms win, so the prompt can never mandate two renderings for one word.
Works for every locale with no hand-maintained tables.
- The system prompt is built once per locale and is byte-stable (sorted
renderings), so DeepSeek's prefix caching keeps the added prompt tokens
cheap. Change detection, validation, batching/split-retry, and the state
file are untouched.

## Testing

- Stubbed the API client and verified: payload shape, batch/index
alignment (string + array leaves), failed-item isolation via split
retry, prompt assembly for locales with and without pinned terms,
byte-stability of the prompt, and the pinned-vs-module dedup (`Streams`
module entry absorbed by pinned `stream`).
- `python3 main.py --check` (the pure comparison the merge-queue gate
runs) is unaffected.

## Not in this PR

Existing wrong translations are not re-sent (state hashes unchanged);
the backfill pass and the bootstrap-run gap are tracked as follow-ups in
#13966.
2026-08-26 10:20:34 +00:00

192 lines
6.7 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# Copyright 2026 OpenObserve Inc.
"""
Translation Generator for OpenObserve.
Generates translation files from en-US.json using DeepSeek (an LLM) via its
OpenAI-compatible API. Only newly added or modified English strings are
translated; already-translated, unchanged text is never re-sent (see
translator.py for the change-detection model).
Usage:
python main.py # translate all supported languages
python main.py fr-FR es-ES de-DE # translate specific languages (filename stems)
python main.py --check # report what is pending; translate nothing
Exit codes:
0 nothing to do, or everything translated
1 bad usage / missing source
2 --check only: translations are pending
3 at least one string failed validation (retried on the next run)
Environment:
DEEPSEEK_API_KEY Required. API key for https://api.deepseek.com.
DEEPSEEK_MODEL Model id (default "deepseek-v4-flash").
TRANSLATION_BATCH_SIZE Strings per API call (default 50).
TRANSLATION_CONCURRENCY Batches in flight per locale (default 4; 1 = serial).
"""
import os
import sys
from translator import (
SOURCE_LOCALE,
build_locale,
build_module_glossary,
build_state,
collect_pending_leaves,
find_duplicate_keys,
get_language_file_path,
get_state_file_path,
get_supported_languages,
load_json,
load_source,
load_state,
new_counters,
save_state,
translate_pending,
write_json,
)
def run_check(locales):
"""Report every key still needing translation, without touching the API.
Pure comparison of en-US.json against the locale files and
.translation_state.json, so it costs nothing, needs no API key, and writes
nothing. This is what the merge-queue gate runs to prove a merge is not about
to land English-only strings on main.
"""
source = load_source()
if not source:
print("ERROR: en-US.json source is empty or missing.")
sys.exit(1)
# Before comparing anything: a duplicated key makes every comparison below
# read the last block and ignore the first, so a file looks up to date while
# half of it is dead. Structural, so it fails like a bad source.
duplicated = False
for path in [get_language_file_path(SOURCE_LOCALE), get_state_file_path()] + [
get_language_file_path(locale) for locale in locales
]:
if not os.path.exists(path):
continue
dupes = find_duplicate_keys(path)
if dupes:
duplicated = True
print(f"ERROR: duplicate keys in {path}: {', '.join(dupes)}")
if duplicated:
print("\nA duplicated key hides the earlier block from every reader. Merge them.")
sys.exit(1)
state = load_state()
total = 0
for locale in locales:
existing = load_json(get_language_file_path(locale), {})
pending = collect_pending_leaves(source, existing, state)
if not pending:
continue
total += len(pending)
shown = [".".join(path) for path, _ in pending[:5]]
more = f" (+{len(pending) - len(shown)} more)" if len(pending) > len(shown) else ""
print(f" {locale}: {len(pending)} pending — {', '.join(shown)}{more}")
if total:
print(
f"\n{total} translation(s) pending across {len(locales)} language(s)."
)
sys.exit(2)
print(f"All {len(locales)} language(s) are up to date with en-US.json.")
def main():
supported = get_supported_languages()
args = sys.argv[1:]
# `--force` is accepted for backward compatibility but is now a no-op: there
# is no safety cap to bypass.
check_only = "--check" in args
requested = [a for a in args if not a.startswith("--")]
if requested:
invalid = [lang for lang in requested if lang not in supported]
if invalid:
print(f"WARNING: Unsupported language codes: {', '.join(invalid)}")
print(f"Supported languages: {', '.join(supported)}")
locales = [lang for lang in requested if lang in supported]
else:
locales = supported
if not locales:
print("ERROR: No valid languages to translate.")
sys.exit(1)
if check_only:
run_check(locales)
return
source = load_source()
if not source:
print("ERROR: en-US.json source is empty or missing.")
sys.exit(1)
state = load_state()
full_run = set(locales) == set(supported)
counters = new_counters()
locale_targets = {}
for locale in locales:
existing = load_json(get_language_file_path(locale), {})
pending = collect_pending_leaves(source, existing, state)
print(f"\nTranslating: {locale} ({len(pending)} strings pending)")
translated = (
translate_pending(pending, locale, build_module_glossary(source, existing))
if pending
else {}
)
target = build_locale(source, existing, state, translated, counters)
locale_targets[locale] = target
# Only rewrite the file when it actually changed. `json.dumps(indent=2)`
# does not match prettier's formatting (short arrays are written
# multi-line), so an unconditional write would reformat an otherwise
# unchanged locale file and break the `format:check` gate. Skipping the
# write keeps the committed prettier formatting intact. A file that *did*
# change is rewritten in json.dumps style throughout, so callers must run
# prettier over whatever this rewrites — update-translations.yml does that
# before it commits.
if target != existing:
# Flushed per locale (atomically), so a run that is cancelled or dies
# part way through still leaves every completed locale on disk for CI
# to commit.
write_json(get_language_file_path(locale), target)
# Advance shared state only on a full run, where every supported locale was
# processed and "present in all locales" is meaningful. Subset runs translate
# what they need but leave state untouched (safe: at worst a later full run
# re-checks those keys).
if full_run:
state = build_state(source, locale_targets, counters["failed_paths"])
save_state(state)
else:
print("\nℹ️ Subset run — state file not advanced (run all languages to persist state).")
print("\n" + "-" * 60)
print(
f"Done. translated={counters['translated']}, kept={counters['kept']}, "
f"failed={counters['failed']} across {len(locales)} language(s)."
)
if counters["failed"]:
print(
f"ERROR: {counters['failed']} translation(s) failed — they will retry "
f"next run."
)
sys.exit(3)
if __name__ == "__main__":
main()