Improved auto translation support #2
Regular → Executable
+4
-2
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env python3
|
||||
#!/usr/bin/env -S uv run
|
||||
"""Import a human-made whole-article translation into the fragment store.
|
||||
|
||||
A full translation produced outside the pipeline (e.g. by ChatGPT, pasted
|
||||
@@ -13,7 +13,9 @@ database). Blocks that fail validation stay untranslated — the translator
|
||||
service picks them up as scoped jobs on the next run.
|
||||
|
||||
Usage:
|
||||
uv run python scripts/import_translation.py PATH LANG FILE.md [--db DB]
|
||||
scripts/import_translation.py PATH LANG FILE.md [--db DB]
|
||||
|
||||
Run from the repository root (the script runs in the project environment).
|
||||
|
||||
PATH is the page path without leading slash ("" = front page), LANG the
|
||||
target language base tag (e.g. fi), FILE.md the translated Markdown.
|
||||
|
||||
Regular → Executable
+92
-25
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env python3
|
||||
#!/usr/bin/env -S uv run
|
||||
# /// script
|
||||
# requires-python = ">=3.14"
|
||||
# dependencies = [
|
||||
@@ -16,15 +16,17 @@ answer one job at a time — but speaks the "markdown" and "article" job
|
||||
modes: fragments and whole pages cross as Markdown, and the server
|
||||
validates structure (blocks, fences, URLs, placeholders) before storing.
|
||||
|
||||
The LLM is reached via an OpenAI Chat Completions endpoint
|
||||
(base_url + /v1/chat/completions: llama.cpp, hosted APIs) or ollama's
|
||||
native /api/chat (api="ollama") — ollama's OpenAI endpoint ignores
|
||||
think:false, which hybrid models need off. Backend quirks (sampling,
|
||||
num_predict cap, think) live in the config, not in the protocol.
|
||||
The script figures out the LLM-side details itself: the endpoint shape is
|
||||
autodetected (an ollama server answers /api/version and gets its native
|
||||
/api/chat — its OpenAI-compatible /v1 ignores think:false, which hybrid
|
||||
models need off; anything else gets /v1/chat/completions), and the
|
||||
announced language capabilities follow the model family unless overridden
|
||||
(--langs or config). Backend quirks (sampling, num_predict cap, think)
|
||||
live in the config, not in the protocol.
|
||||
|
||||
Usage:
|
||||
uv run scripts/llm_translator.py ws://localhost:8210/_translate/KEY
|
||||
uv run scripts/llm_translator.py wss://example.com/_translate/KEY --config my.json
|
||||
scripts/llm_translator.py ws://localhost:8210/_translate/KEY
|
||||
scripts/llm_translator.py wss://example.com/_translate/KEY --model qwen3.8:27b
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -40,13 +42,14 @@ import websockets
|
||||
|
||||
#: Shipped defaults, aimed at a local ollama running the structure-proven
|
||||
#: qwen3.8:27b (docs/llm-translation.md trial evidence). A --config JSON
|
||||
#: overrides per key, CLI flags override the config.
|
||||
#: overrides per key, CLI flags override the config. "api" and "langs" are
|
||||
#: autodetected when unset (detect_api / model_langs).
|
||||
DEFAULT_CONFIG = {
|
||||
"api": "ollama", # "ollama" (native /api/chat) | "openai" (/v1/chat/completions)
|
||||
"api": "", # "" = autodetect; "ollama" (native /api/chat) | "openai" (/v1)
|
||||
"base_url": "http://127.0.0.1:11434",
|
||||
"model": "qwen3.8:27b",
|
||||
"api_key": "", # openai api only
|
||||
"langs": ["de", "es", "fi", "pt", "zh"], # announced capabilities
|
||||
"langs": [], # announced capabilities; empty = autodetect from the model
|
||||
"modes": ["markdown", "article"],
|
||||
"temperature": 0.2,
|
||||
"top_p": 0.8,
|
||||
@@ -61,22 +64,81 @@ DEFAULT_CONFIG = {
|
||||
"timeout": 10800,
|
||||
}
|
||||
|
||||
#: Language code -> English name (for the prompts). Broad by design:
|
||||
#: the announced capabilities default to a per-model subset of this table.
|
||||
LANG_NAMES = {
|
||||
"ar": "Arabic",
|
||||
"bg": "Bulgarian",
|
||||
"bn": "Bengali",
|
||||
"ca": "Catalan",
|
||||
"cs": "Czech",
|
||||
"da": "Danish",
|
||||
"de": "German",
|
||||
"el": "Greek",
|
||||
"es": "Spanish",
|
||||
"et": "Estonian",
|
||||
"fa": "Persian",
|
||||
"fi": "Finnish",
|
||||
"fr": "French",
|
||||
"he": "Hebrew",
|
||||
"hi": "Hindi",
|
||||
"hr": "Croatian",
|
||||
"hu": "Hungarian",
|
||||
"id": "Indonesian",
|
||||
"it": "Italian",
|
||||
"ja": "Japanese",
|
||||
"ko": "Korean",
|
||||
"lt": "Lithuanian",
|
||||
"lv": "Latvian",
|
||||
"ms": "Malay",
|
||||
"nl": "Dutch",
|
||||
"no": "Norwegian",
|
||||
"pl": "Polish",
|
||||
"pt": "Portuguese",
|
||||
"ro": "Romanian",
|
||||
"ru": "Russian",
|
||||
"sk": "Slovak",
|
||||
"sl": "Slovenian",
|
||||
"sr": "Serbian",
|
||||
"sv": "Swedish",
|
||||
"th": "Thai",
|
||||
"tr": "Turkish",
|
||||
"uk": "Ukrainian",
|
||||
"vi": "Vietnamese",
|
||||
"zh": "Simplified Chinese",
|
||||
}
|
||||
|
||||
#: Announced capabilities by model family (substring match on the model
|
||||
#: string, first hit wins; None = the full LANG_NAMES table). Qwen3 models
|
||||
#: officially cover 100+ languages, so they announce everything; anything
|
||||
#: unknown gets the conservative major-language set below. --langs or the
|
||||
#: config's "langs" override the detection.
|
||||
_MODEL_LANGS = [("qwen", None)]
|
||||
_MAJOR_LANGS = ["de", "es", "fr", "it", "ja", "ko", "nl", "pl", "pt", "ru", "sv", "zh"]
|
||||
|
||||
|
||||
def model_langs(model: str) -> list[str]:
|
||||
"""The language capabilities to announce for a model string."""
|
||||
for pattern, langs in _MODEL_LANGS:
|
||||
if pattern in model.lower():
|
||||
return sorted(LANG_NAMES if langs is None else langs)
|
||||
return list(_MAJOR_LANGS)
|
||||
|
||||
|
||||
async def detect_api(cfg: dict, http: httpx.AsyncClient) -> str:
|
||||
"""The endpoint shape to use: an ollama server answers /api/version and
|
||||
gets its native /api/chat (its OpenAI-compatible /v1 silently ignores
|
||||
think:false); anything else gets the OpenAI Chat Completions shape."""
|
||||
if cfg["api"]:
|
||||
return cfg["api"]
|
||||
try:
|
||||
r = await http.get(f"{cfg['base_url']}/api/version", timeout=5)
|
||||
if r.status_code == 200:
|
||||
return "ollama"
|
||||
except httpx.HTTPError:
|
||||
pass
|
||||
return "openai"
|
||||
|
||||
RULES = """\
|
||||
Rules:
|
||||
- Output ONLY the translation, no commentary, no preamble.
|
||||
@@ -245,6 +307,11 @@ async def serve(cfg: dict) -> None:
|
||||
url, backoff = cfg["url"], 1
|
||||
limits = httpx.Timeout(cfg["timeout"])
|
||||
async with httpx.AsyncClient(timeout=limits) as http:
|
||||
cfg["api"] = await detect_api(cfg, http)
|
||||
print(
|
||||
f"[llm backend: {cfg['api']} api at {cfg['base_url']}, model={cfg['model']}]",
|
||||
file=sys.stderr,
|
||||
)
|
||||
while True:
|
||||
try:
|
||||
async with websockets.connect(url) as ws:
|
||||
@@ -287,32 +354,30 @@ def main() -> None:
|
||||
"this script: api, base_url, model, langs, modes, temperature, "
|
||||
"predict_ratio/cap, think, ...); CLI flags win over the file",
|
||||
)
|
||||
p.add_argument(
|
||||
"--api",
|
||||
choices=["ollama", "openai"],
|
||||
help="LLM endpoint shape: 'ollama' = native /api/chat (needed for "
|
||||
"think:false), 'openai' = /v1/chat/completions (llama.cpp, hosted "
|
||||
"APIs) (default: ollama)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--base-url",
|
||||
help="LLM server root without path, e.g. http://127.0.0.1:11434 "
|
||||
"(default) or https://api.openai.com",
|
||||
"(default) or https://api.openai.com; the endpoint shape is "
|
||||
"autodetected",
|
||||
)
|
||||
p.add_argument(
|
||||
"--model",
|
||||
help="model string to serve, e.g. qwen3.8:27b (default; the "
|
||||
"structure-proven reference) — announced to the server in Hello",
|
||||
"structure-proven reference) — selects the announced languages "
|
||||
"unless --langs overrides",
|
||||
)
|
||||
p.add_argument(
|
||||
"--api-key",
|
||||
help="bearer key for --api openai backends (ollama ignores it)",
|
||||
help="bearer key for hosted OpenAI-compatible backends (ollama "
|
||||
"ignores it)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--langs",
|
||||
help="comma-separated language capabilities announced to the server, "
|
||||
"e.g. de,es,fi,pt,zh (default); jobs come only from the "
|
||||
"intersection with the site's configured target languages",
|
||||
help="comma-separated language capabilities to announce, overriding "
|
||||
"the model-based autodetection (qwen models announce all "
|
||||
f"{len(LANG_NAMES)} known languages, others a conservative set); "
|
||||
"jobs come only from the intersection with the site's configured "
|
||||
"target languages",
|
||||
)
|
||||
p.add_argument(
|
||||
"--modes",
|
||||
@@ -327,13 +392,15 @@ def main() -> None:
|
||||
cfg = dict(DEFAULT_CONFIG)
|
||||
if args.config:
|
||||
cfg.update(json.loads(Path(args.config).read_text()))
|
||||
for key in ("api", "base_url", "model", "api_key"):
|
||||
for key in ("base_url", "model", "api_key"):
|
||||
if getattr(args, key):
|
||||
cfg[key] = getattr(args, key)
|
||||
if args.langs:
|
||||
cfg["langs"] = args.langs.split(",")
|
||||
if args.modes:
|
||||
cfg["modes"] = args.modes.split(",")
|
||||
if not cfg["langs"]:
|
||||
cfg["langs"] = model_langs(cfg["model"])
|
||||
cfg["url"] = args.url
|
||||
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user