diff --git a/scripts/import_translation.py b/scripts/import_translation.py old mode 100644 new mode 100755 index 21b3d1d..e6ae291 --- a/scripts/import_translation.py +++ b/scripts/import_translation.py @@ -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. diff --git a/scripts/llm_translator.py b/scripts/llm_translator.py old mode 100644 new mode 100755 index ea33267..9052aa6 --- a/scripts/llm_translator.py +++ b/scripts/llm_translator.py @@ -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: