mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 16:09:05 +00:00
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.
192 lines
6.7 KiB
Python
Executable File
192 lines
6.7 KiB
Python
Executable File
#!/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()
|