mirror of
https://github.com/imputnet/helium.git
synced 2026-10-19 01:39:56 -05:00
devutils/i18n: add an option to translate using local CLI tools (#1942)
allows for translations using CLI tools like codex, opencode, claude, and etc. these CLI tools are run in a temporary directory to prevent agentic tool usage. you must pass restrictive arguments if you use this option yourself.
This commit is contained in:
1 parent
d820c3a286
commit
11d1698665
2 files changed
+67
-8
No files matched your search
@@ -47,6 +47,11 @@ def parse_args():
|
||||
type=Path,
|
||||
help='Import translations from a JSON file '
|
||||
'instead of calling the LLM.')
|
||||
translate.add_argument('--cmd',
|
||||
help='Run translations through this command instead of '
|
||||
'the LLM completions API. The filled prompt is appended '
|
||||
'as the final argument and the JSON payload is passed '
|
||||
'on stdin.')
|
||||
|
||||
subparsers.add_parser('clean', help='Clean up stale translation strings')
|
||||
|
||||
|
||||
@@ -2,17 +2,21 @@
|
||||
# You can use, redistribute, and/or modify this source code under
|
||||
# the terms of the GPL-3.0 license that can be found in the LICENSE file.
|
||||
"""
|
||||
Translation of Helium strings using a completions API.
|
||||
Translation of Helium strings using an LLM backend.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
I18N_DIR = Path(__file__).resolve().parent.parent / 'i18n'
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
I18N_DIR = REPO_ROOT / 'i18n'
|
||||
SOURCE_PATH = I18N_DIR / 'source.gen.json'
|
||||
TRANSLATIONS_DIR = I18N_DIR / 'translations'
|
||||
|
||||
@@ -57,6 +61,49 @@ def llm_chat(prompt, data):
|
||||
return body['choices'][0]['message']['content']
|
||||
|
||||
|
||||
def run_command(cmd, data):
|
||||
"""Run a non-interactive CLI backend."""
|
||||
with tempfile.TemporaryDirectory(prefix='helium-i18n-cmd-') as cwd:
|
||||
try:
|
||||
proc = subprocess.run(cmd,
|
||||
input=data,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
cwd=cwd)
|
||||
except FileNotFoundError as exc:
|
||||
raise RuntimeError(f'{cmd[0]} executable not found') from exc
|
||||
except subprocess.CalledProcessError as exc:
|
||||
details = []
|
||||
if exc.stderr:
|
||||
details.append(exc.stderr.strip())
|
||||
if exc.stdout:
|
||||
details.append(f'stdout:\n{exc.stdout.strip()}')
|
||||
message = f'{cmd[0]} failed'
|
||||
if details:
|
||||
message += ':\n' + '\n'.join(details)
|
||||
raise RuntimeError(message) from exc
|
||||
|
||||
return proc.stdout
|
||||
|
||||
|
||||
def command_chat(prompt, data, command):
|
||||
"""Send a translation request through a provided command."""
|
||||
cmd = shlex.split(command)
|
||||
if not cmd:
|
||||
raise ValueError('--cmd must not be empty')
|
||||
|
||||
cmd.append(prompt)
|
||||
return run_command(cmd, data)
|
||||
|
||||
|
||||
def translate_with_backend(prompt, data, command=None):
|
||||
"""Send a translation request to the selected backend."""
|
||||
if command:
|
||||
return command_chat(prompt, data, command)
|
||||
return llm_chat(prompt, data)
|
||||
|
||||
|
||||
def load_existing(lang_code):
|
||||
"""Load existing translations as a dict keyed by (name, source)."""
|
||||
path = TRANSLATIONS_DIR / f'{lang_code}.json'
|
||||
@@ -247,8 +294,10 @@ def save_translations(lang_code, source, existing, response, dedup_map):
|
||||
file.write('\n')
|
||||
|
||||
|
||||
def translate_language(lang_code, lang_name, source, prompt_template, from_file=None):
|
||||
# pylint: disable-next=too-many-locals
|
||||
def translate_language(language, source, prompt_template, from_file=None, command=None):
|
||||
"""Run translation for a single language."""
|
||||
lang_code, lang_name = language
|
||||
existing = load_existing(lang_code)
|
||||
untranslated = find_untranslated(source, existing)
|
||||
|
||||
@@ -269,7 +318,7 @@ def translate_language(lang_code, lang_name, source, prompt_template, from_file=
|
||||
else:
|
||||
prompt = fill_prompt(prompt_template, lang_name, lang_code)
|
||||
data = json.dumps(payload, ensure_ascii=False)
|
||||
raw = llm_chat(prompt, data)
|
||||
raw = translate_with_backend(prompt, data, command)
|
||||
|
||||
response = parse_response(raw, expected_names)
|
||||
|
||||
@@ -287,15 +336,20 @@ def run(args):
|
||||
prompt_template = (I18N_DIR / 'prompt.md').read_text(encoding='utf-8')
|
||||
TRANSLATIONS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
language = getattr(args, 'language', None)
|
||||
from_file = getattr(args, 'from_file', None)
|
||||
command = getattr(args, 'cmd', None)
|
||||
|
||||
if args.language:
|
||||
for code in args.language:
|
||||
if not command:
|
||||
command = os.environ.get('I18N_TRANSLATE_CMD')
|
||||
|
||||
if language:
|
||||
for code in language:
|
||||
if code not in languages:
|
||||
raise ValueError(f'unknown language code: {code}')
|
||||
translate_language(code, languages[code], source, prompt_template, from_file)
|
||||
translate_language((code, languages[code]), source, prompt_template, from_file, command)
|
||||
else:
|
||||
if from_file:
|
||||
raise ValueError('--from-file requires --language')
|
||||
for code, name in languages.items():
|
||||
translate_language(code, name, source, prompt_template)
|
||||
translate_language((code, name), source, prompt_template, command=command)
|
||||
Reference in new issue
Block a user