From 61a0645f31d8e0321ecebe55af1c74dbe5623137 Mon Sep 17 00:00:00 2001 From: Wails Doc Translator Date: Tue, 5 May 2026 09:20:27 +1000 Subject: [PATCH 1/5] chore(i18n): add translation automation scripts with 3-iter prompt improvement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three scripts to docs/scripts/ for automated Wails v3 doc translation: translate-docs.py — Ollama-based batch translator (qwen3.6:35b) - Updated system prompt (V3): explicit d2 string-label rule with example, stronger product-name preservation, completeness guarantee, terminology consistency, loanword guidance - post_process(): auto-fixes frontmatter artifacts, asset paths, link rewrites - Translation cache keyed on source MD5 hash translate-qa.py — Heuristic + optional AI quality scorer - 15 checks covering: length ratio, frontmatter, code blocks, imports, locale character sets, URLs, d2 string labels (new check 14), artifact detection, asset paths, internal link rewrites - --ai-verify flag: calls z.ai (ZAI_API_KEY env var) for prose quality scoring blended 60% heuristic / 40% AI improve-prompts.py — 3-iteration prompt improvement loop - Translates test files with V0 prompt, AI-evaluates results, generates improved prompt, repeats N times, outputs A/B comparison table - Applies winning prompt back to translate-docs.py Co-Authored-By: Claude Sonnet 4.6 --- docs/scripts/improve-prompts.py | 522 ++++++++++++++++++++++++++++++++ docs/scripts/translate-docs.py | 486 +++++++++++++++++++++++++++++ docs/scripts/translate-qa.py | 423 ++++++++++++++++++++++++++ 3 files changed, 1431 insertions(+) create mode 100644 docs/scripts/improve-prompts.py create mode 100644 docs/scripts/translate-docs.py create mode 100644 docs/scripts/translate-qa.py diff --git a/docs/scripts/improve-prompts.py b/docs/scripts/improve-prompts.py new file mode 100644 index 00000000000..37af170d2be --- /dev/null +++ b/docs/scripts/improve-prompts.py @@ -0,0 +1,522 @@ +#!/usr/bin/env python3 +""" +3-iteration prompt improvement loop for Wails v3 doc translation. + +Tests current prompt against AI-improved variants, A/B compares outputs, +and writes the winning prompt back to translate-docs.py. + +Usage: + python3 improve-prompts.py + python3 improve-prompts.py --locale de --iterations 3 + python3 improve-prompts.py --locale de,ja --dry-run +""" +import os, sys, json, re, time, argparse, textwrap +from pathlib import Path + +try: + import requests +except ImportError: + os.system("pip3 install requests -q") + import requests + +OLLAMA_BASE = "http://ai-master.taileaa27f.ts.net:11434" +TRANSLATE_MODEL = "qwen3.6:35b" +EVAL_MODEL = "qwen3.6:35b" # same model; evaluate mode via different prompt + +DOCS_ROOT = Path(__file__).parent.parent / "src" / "content" / "docs" +WORK_DIR = Path(__file__).parent.parent / ".prompt-improvement" + +LOCALE_NAMES = { + "de": "German", + "zh-cn": "Simplified Chinese", + "zh-tw": "Traditional Chinese", + "ja": "Japanese", + "ko": "Korean", + "ru": "Russian", + "fr": "French", + "pt": "Portuguese", +} + +# ── Test files ─────────────────────────────────────────────────────────────── +# why-wails.mdx: prose-heavy, d2 diagram, known issues from PR review +# installation.mdx: code-heavy, technical commands, frontmatter-rich +TEST_FILES = [ + "quick-start/why-wails.mdx", + "getting-started/installation.mdx", +] + +# ── Known issues from PR review (seed for evaluator context) ───────────────── +KNOWN_ISSUES = """ +From PR review of previous translations, these systemic issues were found: +- Product name "Wails" was altered in Portuguese (became "Wais") — proper nouns must be preserved exactly +- d2 diagram code block content was translated in German ("Your UI" → "Ihre UI") — ALL code block content must remain in English +- Korean milliseconds translated to wrong word ("하마초" instead of "밀리초") +- zh-tw: direction description error ("向後端" instead of "向前端" — "to the frontend") +- Frontmatter double-dash artifact (---\\n--- at start) appeared in multiple locales +- Trailing --- artifact appeared at end of files +These suggest the prompt needs stronger rules about: (1) product name preservation, +(2) ALL code block content being untranslatable including d2 diagrams, +(3) precision in technical direction terms. +""" + +# ── V0: Current production prompt ──────────────────────────────────────────── +PROMPT_V0 = { + "name": "V0 (baseline)", + "system": """You are a professional technical documentation translator. +Translate the given MDX/Markdown documentation accurately. + +STRICT RULES — violating any of these will cause the page to break: + +1. Translate ALL prose text naturally in {lang}. Do not be overly literal. + +2. PRESERVE frontmatter structure exactly: + - Keep all YAML keys in English (title:, description:, link:, icon:, etc.) + - Translate ONLY the string values of: title, description, tagline, text, label, alt, content (banner content) + - Copy all other frontmatter values (links, icons, variants, booleans) UNCHANGED + +3. NEVER translate ANY of the following — copy them character-for-character: + - Code blocks (``` ```) — including ALL content inside them + - Inline code (`code`) + - Code comments (// ..., # ..., /* ... */) inside code blocks + - JSX/MDX component names and props (e.g. , ) + - import statements + - URLs (http://, https://) + - File paths and CLI commands + - Variable names and function names + - d2 diagram definitions + +4. FRONTMATTER DELIMITERS — CRITICAL: + - The document MUST begin with exactly one line containing only: --- + - The frontmatter block MUST end with exactly one line containing only: --- + - Do NOT output two --- lines at the start + - Do NOT add any --- at the end of the document body + +5. Return ONLY the translated document. No preamble, no explanation, no markdown fences around the whole document. + +6. Preserve all blank lines, heading levels, and list structure exactly as in the source.""", + + "user": """Translate this Wails v3 documentation page to {lang}. +Apply all rules strictly. Return only the translated document. + +--- +{content} +---""", +} + +# ── Ollama call ─────────────────────────────────────────────────────────────── + +def ollama_call(system: str, user: str, model: str = TRANSLATE_MODEL, + temperature: float = 0.2, max_tokens: int = 8192, + think: bool = False) -> str | None: + payload = { + "model": model, + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ], + "stream": True, + "think": think, + "options": {"temperature": temperature, "top_p": 0.9, "num_predict": max_tokens}, + } + parts = [] + try: + resp = requests.post(f"{OLLAMA_BASE}/api/chat", json=payload, + stream=True, timeout=(10, 600)) + resp.raise_for_status() + for line in resp.iter_lines(): + if not line: + continue + try: + data = json.loads(line) + except json.JSONDecodeError: + continue + token = data.get("message", {}).get("content", "") + if token: + parts.append(token) + if data.get("done"): + break + except Exception as e: + print(f" Ollama error: {e}", flush=True) + return None + return "".join(parts).strip() or None + + +# ── Translation ─────────────────────────────────────────────────────────────── + +def translate(content: str, lang: str, prompt: dict, file_label: str) -> str | None: + system = prompt["system"].replace("{lang}", lang) + user = prompt["user"].replace("{lang}", lang).replace("{content}", content) + print(f" → translating {file_label}...", flush=True) + result = ollama_call(system, user, temperature=0.2) + if not result: + return None + # Strip wrapping markdown fences + if result.startswith("```") and result.endswith("```"): + result = "\n".join(result.split("\n")[1:-1]) + # Strip leading text before frontmatter + if not result.startswith("---") and "---" in result: + result = result[result.index("---"):] + return result + + +# ── Evaluation ──────────────────────────────────────────────────────────────── + +EVAL_SYSTEM = """You are a professional translation quality assessor and prompt engineer. +You evaluate machine translations of technical documentation and identify how the TRANSLATION PROMPT +should be improved to prevent recurring issues. + +Be precise, concise, and actionable. Focus on SYSTEMIC issues (patterns that would affect many files), +not one-off word choices.""" + +EVAL_USER = """Evaluate this {lang} translation of an English technical documentation page. + +## Known systemic issues from prior translation runs: +{known_issues} + +## English source: +``` +{source} +``` + +## {lang} translation: +``` +{translation} +``` + +Respond with JSON: +{{ + "score": <0.0-1.0>, + "accuracy_score": <0.0-1.0>, + "fluency_score": <0.0-1.0>, + "technical_score": <0.0-1.0>, + "issues": [ + {{"severity": "critical|major|minor", "description": "...", "example": "source snippet → translated snippet"}} + ], + "prompt_improvements": [ + "Specific rule or wording to add/change in the system prompt to prevent this issue" + ], + "summary": "One sentence overall assessment" +}}""" + + +def evaluate(source: str, translation: str, lang: str) -> dict: + user = EVAL_USER.format( + lang=lang, + known_issues=KNOWN_ISSUES, + source=source[:3000], + translation=translation[:3000], + ) + print(f" → evaluating...", flush=True) + result = ollama_call(EVAL_SYSTEM, user, model=EVAL_MODEL, temperature=0.1, max_tokens=2048) + if not result: + return {"score": 0.5, "issues": [], "prompt_improvements": [], "summary": "Evaluation failed"} + # Strip markdown fences + if result.startswith("```"): + result = "\n".join(result.split("\n")[1:-1]) + # Find JSON object + m = re.search(r'\{[\s\S]+\}', result) + if not m: + return {"score": 0.5, "issues": [], "prompt_improvements": [], "summary": result[:200]} + try: + return json.loads(m.group(0)) + except json.JSONDecodeError: + return {"score": 0.5, "issues": [], "prompt_improvements": [result[:500]], "summary": "Parse error"} + + +# ── Prompt evolution ────────────────────────────────────────────────────────── + +IMPROVE_SYSTEM = """You are a prompt engineer specializing in machine translation of technical documentation. +Given a current translation prompt and a list of issues found in its outputs, produce an IMPROVED version +of the system prompt that will prevent those issues while preserving what works well. + +Output ONLY the improved system prompt text — no explanation, no markdown fences, no commentary.""" + +IMPROVE_USER = """Current system prompt: +--- +{current_prompt} +--- + +Issues found across translated files (most critical first): +{issues} + +Produce an improved version of the system prompt that directly addresses these issues. +The prompt must still cover all the original rules but should be clearer and more specific +where the issues indicate ambiguity.""" + + +def generate_improved_prompt(current_prompt: dict, all_issues: list[str]) -> dict: + issues_text = "\n".join(f"- {i}" for i in all_issues) + user = IMPROVE_USER.format( + current_prompt=current_prompt["system"], + issues=issues_text, + ) + print(f" → generating improved prompt...", flush=True) + improved_system = ollama_call(IMPROVE_SYSTEM, user, temperature=0.3, max_tokens=4096) + if not improved_system: + return current_prompt + + return { + "name": f"V{current_prompt['name'][1]} improved", + "system": improved_system, + "user": current_prompt["user"], # user template stays same + } + + +# ── Heuristic checks ────────────────────────────────────────────────────────── + +def heuristic_score(src: str, tgt: str, locale: str) -> float: + score = 1.0 + if len(tgt.strip()) < 50: + return 0.0 + if src.strip() == tgt.strip(): + return 0.0 + ratio = len(tgt) / max(len(src), 1) + if ratio < (0.3 if locale in ["zh-cn", "zh-tw", "ja", "ko"] else 0.5): + score -= 0.3 + if tgt.startswith("---\n---\n"): + score -= 0.3 + if tgt.rstrip().endswith("\n---"): + score -= 0.1 + src_blocks = re.findall(r'```[\s\S]*?```', src) + tgt_blocks = re.findall(r'```[\s\S]*?```', tgt) + if len(src_blocks) != len(tgt_blocks): + score -= 0.15 + for sc, tc in zip(src_blocks, tgt_blocks): + sc_c = re.sub(r'^```\w*\n?', '', sc).rstrip('`').strip() + tc_c = re.sub(r'^```\w*\n?', '', tc).rstrip('`').strip() + if sc_c != tc_c: + score -= 0.1 + break + if re.search(r'(? dict: + lang = LOCALE_NAMES[locale] + iter_dir = WORK_DIR / locale / f"iter{iteration}" + iter_dir.mkdir(parents=True, exist_ok=True) + + results = {} + for src_path in test_files: + rel = str(src_path.relative_to(DOCS_ROOT)) + source = src_path.read_text(encoding="utf-8") + + out_path = iter_dir / src_path.name + # Re-use cached if already translated (supports --dry-run reruns) + if out_path.exists(): + print(f" [cached] {rel}", flush=True) + translation = out_path.read_text(encoding="utf-8") + else: + translation = translate(source, lang, prompt, rel) + if not translation: + results[rel] = {"error": "translation failed"} + continue + out_path.write_text(translation, encoding="utf-8") + + h_score = heuristic_score(source, translation, locale) + eval_result = evaluate(source, translation, lang) + + combined = h_score * 0.4 + eval_result.get("score", 0.5) * 0.6 + results[rel] = { + "heuristic": round(h_score, 3), + "ai_score": round(eval_result.get("score", 0.5), 3), + "accuracy": round(eval_result.get("accuracy_score", 0.5), 3), + "fluency": round(eval_result.get("fluency_score", 0.5), 3), + "technical": round(eval_result.get("technical_score", 0.5), 3), + "combined": round(combined, 3), + "issues": eval_result.get("issues", []), + "prompt_improvements": eval_result.get("prompt_improvements", []), + "summary": eval_result.get("summary", ""), + } + print(f" scores: heuristic={h_score:.3f} ai={eval_result.get('score',0):.3f} combined={combined:.3f}", flush=True) + print(f" summary: {eval_result.get('summary','')[:120]}", flush=True) + + return results + + +def extract_all_improvements(iter_results: dict) -> list[str]: + """Collect all unique prompt improvement suggestions from an iteration.""" + seen = set() + improvements = [] + for file_result in iter_results.values(): + if "error" in file_result: + continue + for imp in file_result.get("prompt_improvements", []): + if imp not in seen: + seen.add(imp) + improvements.append(imp) + for issue in file_result.get("issues", []): + if issue.get("severity") == "critical": + desc = issue.get("description", "") + ex = issue.get("example", "") + key = desc[:80] + if key not in seen: + seen.add(key) + improvements.append(f"Critical: {desc}" + (f" (e.g. {ex})" if ex else "")) + return improvements + + +def avg_combined(iter_results: dict) -> float: + scores = [r["combined"] for r in iter_results.values() if "combined" in r] + return sum(scores) / len(scores) if scores else 0.0 + + +def print_comparison_table(all_iters: list[tuple[str, dict]]): + print("\n" + "="*80, flush=True) + print("A/B COMPARISON TABLE", flush=True) + print("="*80, flush=True) + # Collect all file names + files = [] + for _, results in all_iters: + for f in results: + if f not in files: + files.append(f) + + header = f"{'File':<40}" + "".join(f" {name:>12}" for name, _ in all_iters) + print(header, flush=True) + print("-"*80, flush=True) + for f in files: + row = f"{f[:38]:<40}" + for _, results in all_iters: + r = results.get(f, {}) + val = f"{r.get('combined', 0):.3f}" if "combined" in r else " N/A" + row += f" {val:>12}" + print(row, flush=True) + + print("-"*80, flush=True) + avg_row = f"{'AVERAGE':<40}" + for _, results in all_iters: + avg_row += f" {avg_combined(results):>12.3f}" + print(avg_row, flush=True) + print("="*80, flush=True) + + +def apply_winning_prompt(winning_prompt: dict): + """Patch translate-docs.py with the winning system prompt.""" + script_path = Path(__file__).parent / "translate-docs.py" + content = script_path.read_text(encoding="utf-8") + + # Find existing SYSTEM_PROMPT assignment and replace it + pattern = r'(SYSTEM_PROMPT\s*=\s*""")([\s\S]*?)(""")' + m = re.search(pattern, content) + if not m: + print(" ✗ Could not find SYSTEM_PROMPT in translate-docs.py — manual update needed", flush=True) + return False + + new_content = content[:m.start()] + f'SYSTEM_PROMPT = """{winning_prompt["system"]}"""' + content[m.end():] + script_path.write_text(new_content, encoding="utf-8") + print(f" ✓ Patched translate-docs.py with winning prompt", flush=True) + return True + + +def main(): + parser = argparse.ArgumentParser(description="3-iteration translation prompt improvement loop") + parser.add_argument("--locale", default="de", help="Comma-separated locales to test (default: de)") + parser.add_argument("--iterations", type=int, default=3, help="Number of iterations (default: 3)") + parser.add_argument("--dry-run", action="store_true", help="Use cached translations if available") + parser.add_argument("--no-patch", action="store_true", help="Don't write winning prompt to translate-docs.py") + args = parser.parse_args() + + locales = [l.strip() for l in args.locale.split(",") if l.strip() in LOCALE_NAMES] + if not locales: + print(f"Unknown locale(s). Valid: {', '.join(LOCALE_NAMES)}") + sys.exit(1) + + test_file_paths = [DOCS_ROOT / f for f in TEST_FILES if (DOCS_ROOT / f).exists()] + WORK_DIR.mkdir(parents=True, exist_ok=True) + + print(f"\n{'='*80}", flush=True) + print(f"TRANSLATION PROMPT IMPROVEMENT LOOP", flush=True) + print(f"Locales: {', '.join(locales)} | Files: {len(test_file_paths)} | Iterations: {args.iterations}", flush=True) + print(f"{'='*80}\n", flush=True) + + all_locale_results = {} + + for locale in locales: + lang = LOCALE_NAMES[locale] + print(f"\n{'─'*60}", flush=True) + print(f"Locale: {locale} ({lang})", flush=True) + print(f"{'─'*60}", flush=True) + + current_prompt = PROMPT_V0.copy() + current_prompt["name"] = "V0" + iteration_history = [] # [(prompt_name, results)] + + for iteration in range(args.iterations): + print(f"\n [Iteration {iteration+1}/{args.iterations}] Prompt: {current_prompt['name']}", flush=True) + + results = run_iteration(current_prompt, test_file_paths, locale, iteration) + iteration_history.append((current_prompt["name"], results)) + + avg = avg_combined(results) + print(f" Average combined score: {avg:.3f}", flush=True) + + if iteration < args.iterations - 1: + # Collect issues and generate improved prompt for next iteration + improvements = extract_all_improvements(results) + print(f" Found {len(improvements)} improvement suggestions:", flush=True) + for imp in improvements[:5]: + print(f" · {textwrap.shorten(imp, 100)}", flush=True) + + next_prompt = generate_improved_prompt(current_prompt, improvements) + next_prompt["name"] = f"V{iteration+1}" + current_prompt = next_prompt + + # Save improved prompt to disk for inspection + prompt_path = WORK_DIR / locale / f"prompt_v{iteration+1}.txt" + prompt_path.write_text(next_prompt["system"], encoding="utf-8") + print(f" Saved improved prompt to: {prompt_path}", flush=True) + + # Comparison + print_comparison_table(iteration_history) + + # Pick winning prompt (highest avg combined score) + best_idx = max(range(len(iteration_history)), key=lambda i: avg_combined(iteration_history[i][1])) + best_name, best_results = iteration_history[best_idx] + print(f"\n Winner: {best_name} (avg combined = {avg_combined(best_results):.3f})", flush=True) + + # Save full report + report = { + "locale": locale, + "test_files": [str(f.relative_to(DOCS_ROOT)) for f in test_file_paths], + "iterations": [ + {"prompt": name, "avg_combined": round(avg_combined(r), 3), "results": r} + for name, r in iteration_history + ], + "winner": best_name, + } + report_path = WORK_DIR / f"{locale}-report.json" + report_path.write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8") + print(f" Report saved to: {report_path}", flush=True) + all_locale_results[locale] = report + + # Apply winning prompt to translate-docs.py (use last iteration's prompt as winner) + # The winning prompt is the one from the best scoring iteration + # We need to find which prompt object corresponds to best_idx + # Prompts are: iter 0 = V0, iter 1 = V1, iter 2 = V2 + # The prompt used in iteration i is saved at prompt_v{i}.txt (for i>0) or is PROMPT_V0 + if not args.no_patch and best_idx > 0: + winning_prompt_path = WORK_DIR / locale / f"prompt_v{best_idx}.txt" + if winning_prompt_path.exists(): + winning_prompt = { + "system": winning_prompt_path.read_text(encoding="utf-8"), + "user": PROMPT_V0["user"], + } + print(f"\nApplying winning prompt ({best_name}) to translate-docs.py...", flush=True) + apply_winning_prompt(winning_prompt) + elif not args.no_patch and best_idx == 0: + print(f"\n V0 (baseline) is already the best — no changes to translate-docs.py needed.", flush=True) + + print(f"\n{'='*80}", flush=True) + print("IMPROVEMENT LOOP COMPLETE", flush=True) + print(f"Results saved to: {WORK_DIR}/", flush=True) + print(f"{'='*80}\n", flush=True) + + return all_locale_results + + +if __name__ == "__main__": + main() diff --git a/docs/scripts/translate-docs.py b/docs/scripts/translate-docs.py new file mode 100644 index 00000000000..43955b01dc3 --- /dev/null +++ b/docs/scripts/translate-docs.py @@ -0,0 +1,486 @@ +#!/usr/bin/env python3 +""" +Wails v3 documentation translator using Ollama streaming API. +Usage: + python3 translate-docs.py --locale all + python3 translate-docs.py --locale zh-cn + python3 translate-docs.py --locale ja,ko +""" +import os +import sys +import json +import argparse +import hashlib +import re +import time +from pathlib import Path + +try: + import requests +except ImportError: + print("Installing requests...") + os.system("pip3 install requests -q") + import requests + +OLLAMA_BASE = "http://ai-master.taileaa27f.ts.net:11434" +MODEL = "qwen3.6:35b" + +DOCS_ROOT = Path(__file__).parent.parent / "src" / "content" / "docs" +CACHE_DIR = Path(__file__).parent.parent / ".translation-cache" + +LOCALE_NAMES = { + "zh-cn": "Simplified Chinese (Mandarin)", + "zh-tw": "Traditional Chinese (Taiwan)", + "ja": "Japanese", + "ko": "Korean", + "ru": "Russian", + "fr": "French", + "pt": "Portuguese (Brazilian)", + "de": "German", +} + +ALL_LOCALES = list(LOCALE_NAMES.keys()) + +# Priority files to translate - shorter files first for broader coverage +PRIORITY_FILES = [ + "index.mdx", + "quick-start/why-wails.mdx", + "quick-start/next-steps.mdx", # 34 lines + "status.mdx", # 33 lines + "getting-started/installation.mdx", # 114 lines + "feedback.mdx", # 87 lines + "credits.mdx", # 63 lines + "community/links.md", + "community/templates.md", + "faq.mdx", # 260 lines + "quick-start/first-app.mdx", # 270 lines + "getting-started/your-first-app.mdx", # 273 lines + "quick-start/installation.mdx", # 430 lines - large + "concepts/architecture.mdx", + "concepts/bridge.mdx", + "concepts/lifecycle.mdx", + "concepts/manager-api.mdx", + "concepts/build-system.mdx", + "contributing.mdx", +] + +# Paths within the docs that have locale-specific translations. +# When a file is translated, its path is added here so that internal +# links in OTHER translated files can be rewritten to the locale-prefixed version. +# Keys are source-relative paths (no leading /), values are the locale-relative path +# (same value, since translated files mirror the source structure). +# This set is updated dynamically as files are translated during a run. +ALWAYS_LOCALIZE_PATHS = { + # These core pages are always translated first; links to them should always + # be locale-prefixed. Updated at runtime via build_translated_paths(). +} + +# Paths in source docs that map to a DIFFERENT target path in locale docs. +# e.g. the primary installation CTA links to quick-start/installation but we +# translate getting-started/installation, so the link should be rewritten. +PATH_REMAP = { + "/quick-start/installation": "/getting-started/installation", +} + +SYSTEM_PROMPT = """You are a professional technical documentation translator. +Translate the given MDX/Markdown documentation accurately, completely, and naturally in {lang}. + +CRITICAL INSTRUCTION: Ensure the translation covers the ENTIRE source text. Do not truncate output. +Every heading, paragraph, list item, and sentence in the source must appear in the translation. + +STRICT RULES — violating any of these will cause the page to break or render incorrectly: + +1. TRANSLATION QUALITY: + - Translate ALL prose text naturally in {lang}. Do not be overly literal. + - Maintain consistent terminology for technical concepts throughout the document. + - PRESERVE ALL product names, proper nouns, and brand names exactly as written in the source. + These must NEVER be translated, transliterated, altered, or "corrected": + Wails, Electron, Go, npm, Xcode, WebView2, React, Vue, Svelte, macOS, Windows, Linux, + Discord, TypeScript, JavaScript — and any other brand names or tool names. + - Keep technical loanwords in their original English form unless a well-established {lang} + translation exists (e.g. "hot reload", "WebView", "binary" may stay as-is). + +2. PRESERVE frontmatter structure exactly: + - Keep all YAML keys in English (title:, description:, link:, icon:, etc.) + - Translate ONLY the string values of: title, description, tagline, text, label, alt, content (banner content) + - Copy all other frontmatter values (links, icons, variants, booleans) UNCHANGED + +3. CODE BLOCKS — COPY VERBATIM, CHARACTER FOR CHARACTER: + Everything between ``` fences is CODE and must be copied without any change whatsoever. + This rule has NO exceptions: + - Shell commands, Go code, configuration files — do NOT translate + - Code comments (// ..., # ..., /* ... */) — do NOT translate + - d2 diagram definitions — the ENTIRE block including quoted string labels like + "Your UI\\n(React/Vue/etc)" must be copied exactly. + WRONG: "Ihre UI\\n(React/Vue/etc)" CORRECT: "Your UI\\n(React/Vue/etc)" + String labels inside d2 blocks are diagram code, not prose — never translate them. + - JSX/MDX component names and props (e.g. , ) — do NOT translate + - import statements — do NOT translate + - Inline code (`backtick content`) — copy exactly, including CLI commands and paths + +4. URLs AND PATHS — copy unchanged: + - URLs (http://, https://) — copy exactly + - File paths and directory names — copy exactly + +5. FRONTMATTER DELIMITERS — CRITICAL: + - The document MUST begin with exactly one line containing only: --- + - The frontmatter block MUST end with exactly one line containing only: --- + - Do NOT output two --- lines at the start (wrong: ---\\n---\\ntitle:) + - Do NOT add any --- at the end of the document body + +6. Return ONLY the translated document. No preamble, no explanation, no markdown fences around the whole document. + +7. Preserve all blank lines, heading levels (#, ##, ###), list structure (-, *), indentation, + line breaks, and MDX component structure exactly as in the source.""" + +TRANSLATE_PROMPT = """Translate this Wails v3 documentation page to {lang}. +Apply all rules strictly. Return only the translated document. + +--- +{content} +---""" + + +def file_hash(content: str) -> str: + return hashlib.md5(content.encode()).hexdigest()[:12] + + +def load_cache(locale: str) -> dict: + cache_file = CACHE_DIR / f"{locale}.json" + if cache_file.exists(): + try: + return json.loads(cache_file.read_text()) + except Exception: + return {} + return {} + + +def save_cache(locale: str, cache: dict): + CACHE_DIR.mkdir(exist_ok=True) + cache_file = CACHE_DIR / f"{locale}.json" + cache_file.write_text(json.dumps(cache, indent=2, ensure_ascii=False)) + + +def build_translated_paths(locale: str, files: list) -> set: + """ + Return the set of root-relative paths (e.g. '/quick-start/next-steps') + that have translated versions for this locale, based on the files list. + This drives automatic link rewriting in post_process(). + """ + paths = set() + for src_path in files: + rel = src_path.relative_to(DOCS_ROOT) + # Strip extension to get the URL path + url_path = "/" + str(rel.with_suffix("")) + # Also check if the locale file actually exists (cached from prior run) + out_path = DOCS_ROOT / locale / rel + if out_path.exists() or True: # optimistic: assume we'll translate it + paths.add(url_path) + return paths + + +def post_process(content: str, locale: str, rel_path: Path, translated_paths: set) -> str: + """ + Apply deterministic post-processing to every translated file: + + 1. Fix malformed double---- frontmatter (model artifact) + 2. Remove trailing --- artifact (model artifact) + 3. Fix relative asset paths (locale files are one dir deeper than source root) + 4. Rewrite internal links for translated pages to locale-prefixed versions + 5. Remap CTA paths that point to a different-named translated file + """ + # 1. Fix double---- frontmatter: model sometimes outputs "---\n---\ntitle:" + if content.startswith("---\n---\n"): + content = content[4:] # strip the spurious leading "---\n" + + # 2. Remove trailing --- artifact + stripped = content.rstrip() + if stripped.endswith("\n---"): + content = stripped[:-4].rstrip() + "\n" + + # 3. Fix relative asset paths + # Source root: docs/src/content/docs/index.mdx → ../../assets = docs/src/assets ✓ + # Locale root: docs/src/content/docs/de/index.mdx → ../../assets = docs/src/content/assets ✗ + # Fix: ../../assets/ → ../../../assets/ + content = content.replace("../../assets/", "../../../assets/") + + # 4 & 5. Link rewriting + # + # For each translated path, rewrite: + # - Markdown links: [text](/path) → [text](//path) + # - Frontmatter link: values: link: /path → link: //path + # - href="/path" in JSX + # + # Also apply PATH_REMAP before prefixing: + # /quick-start/installation → //getting-started/installation + # (because we translate getting-started/installation, not quick-start/installation) + # + # ONLY rewrite paths that have a translated version. Leave other root-relative + # links as-is so they fall back to the English page via Starlight locale fallback. + + def make_locale_path(src_path: str) -> str | None: + """Return locale-prefixed path if src_path should be rewritten, else None.""" + # Apply remap first + remapped = PATH_REMAP.get(src_path, src_path) + # Check if the remapped path (without extension) is in translated_paths + # translated_paths contains paths like /quick-start/next-steps (no extension) + if remapped in translated_paths: + return f"/{locale}{remapped}" + return None + + def rewrite_markdown_link(m: re.Match) -> str: + text, path, suffix = m.group(1), m.group(2), m.group(3) + new_path = make_locale_path(path) + if new_path: + return f"[{text}]({new_path}{suffix if suffix != ')' else ''})" + return m.group(0) + + # Markdown links: [text](/path) or [text](/path/sub) + content = re.sub( + r'\[([^\]]*)\]\((/[a-zA-Z0-9/_-]+)(/?)\)', + rewrite_markdown_link, + content + ) + + # Frontmatter link: /path + def rewrite_fm_link(m: re.Match) -> str: + key, path, tail = m.group(1), m.group(2), m.group(3) + new_path = make_locale_path(path) + if new_path: + return f"{key}{new_path}{tail}" + return m.group(0) + + content = re.sub( + r'(link:\s*)(/[a-zA-Z0-9/_-]+)(\s*$)', + rewrite_fm_link, + content, + flags=re.MULTILINE + ) + + # href="/path" in JSX + def rewrite_href(m: re.Match) -> str: + path = m.group(1) + new_path = make_locale_path(path) + if new_path: + return f'href="{new_path}"' + return m.group(0) + + content = re.sub(r'href="(/[a-zA-Z0-9/_-]+)"', rewrite_href, content) + + return content + + +def translate_with_ollama(content: str, lang: str, file_path: str) -> str: + """Translate content using Ollama chat API (thinking disabled).""" + system = SYSTEM_PROMPT.format(lang=lang) + user_msg = TRANSLATE_PROMPT.format(lang=lang, content=content) + + payload = { + "model": MODEL, + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": user_msg}, + ], + "stream": True, + "think": False, + "options": { + "temperature": 0.2, + "top_p": 0.9, + "num_predict": 16384, + } + } + + response_parts = [] + try: + resp = requests.post( + f"{OLLAMA_BASE}/api/chat", + json=payload, + stream=True, + timeout=(10, 600) + ) + resp.raise_for_status() + + for line in resp.iter_lines(): + if not line: + continue + try: + data = json.loads(line) + except json.JSONDecodeError: + continue + + msg = data.get("message", {}) + token = msg.get("content", "") + if token: + response_parts.append(token) + + if data.get("done"): + break + + except requests.exceptions.Timeout: + print(f" TIMEOUT translating {file_path}", flush=True) + return None + except Exception as e: + print(f" ERROR translating {file_path}: {e}", flush=True) + return None + + result = "".join(response_parts).strip() + + # Strip any leading/trailing markdown code fences the model might add + if result.startswith("```") and result.endswith("```"): + lines = result.split("\n") + result = "\n".join(lines[1:-1]) + + # Strip any leading "---" explanatory text before the frontmatter + if not result.startswith("---") and "---" in result: + idx = result.index("---") + result = result[idx:] + + return result if result else None + + +def translate_file( + src_path: Path, + locale: str, + lang: str, + cache: dict, + translated_paths: set, +) -> tuple[bool, str]: + """ + Translate a single file and apply post-processing. + Returns (success, status) where status is 'translated', 'cached', or 'failed'. + """ + content = src_path.read_text(encoding="utf-8") + h = file_hash(content) + cache_key = str(src_path.relative_to(DOCS_ROOT)) + + # Check cache + if cache_key in cache and cache[cache_key].get("hash") == h: + rel = src_path.relative_to(DOCS_ROOT) + out_path = DOCS_ROOT / locale / rel + if out_path.exists(): + return True, "cached" + + print(f" Translating {src_path.relative_to(DOCS_ROOT)} → {locale}...", flush=True) + + translated = translate_with_ollama(content, lang, str(src_path)) + if not translated: + return False, "failed" + + rel = src_path.relative_to(DOCS_ROOT) + out_path = DOCS_ROOT / locale / rel + out_path.parent.mkdir(parents=True, exist_ok=True) + + # Apply all post-processing fixes + translated = post_process(translated, locale, rel, translated_paths) + + out_path.write_text(translated, encoding="utf-8") + + cache[cache_key] = {"hash": h, "translated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())} + return True, "translated" + + +def get_source_files() -> list[Path]: + """Get priority docs files that exist.""" + files = [] + for rel in PRIORITY_FILES: + p = DOCS_ROOT / rel + if p.exists(): + files.append(p) + + # Add any remaining files not in priority list + all_files = set( + p for p in DOCS_ROOT.rglob("*.mdx") + if not any(part in str(p) for part in ["blog", "changelog", "whats-new", "showcase"]) + and p.parts[len(DOCS_ROOT.parts)] not in ALL_LOCALES + ) | set( + p for p in DOCS_ROOT.rglob("*.md") + if not any(part in str(p) for part in ["blog", "changelog", "whats-new", "showcase"]) + and p.parts[len(DOCS_ROOT.parts)] not in ALL_LOCALES + ) + + # Add non-priority files at the end + for p in sorted(all_files): + if p not in files: + files.append(p) + + return files + + +def run_locale(locale: str, files: list[Path], max_files: int = None): + lang = LOCALE_NAMES[locale] + cache = load_cache(locale) + + # Build the set of paths that will be translated in this run (for link rewriting) + files_to_process = files[:max_files] if max_files else files + translated_paths = build_translated_paths(locale, files_to_process) + + print(f"\n{'='*60}", flush=True) + print(f"Translating to {locale} ({lang})", flush=True) + print(f"{'='*60}", flush=True) + + translated = 0 + cached = 0 + failed = 0 + count = 0 + + for src_path in files: + if max_files and count >= max_files: + break + count += 1 + + success, status = translate_file(src_path, locale, lang, cache, translated_paths) + if success: + if status == "translated": + translated += 1 + print(f" ✓ {src_path.relative_to(DOCS_ROOT)}", flush=True) + else: + cached += 1 + print(f" (cached) {src_path.relative_to(DOCS_ROOT)}", flush=True) + else: + failed += 1 + print(f" ✗ FAILED: {src_path.relative_to(DOCS_ROOT)}", flush=True) + + # Save cache after each file + save_cache(locale, cache) + + print(f"\nLocale {locale}: {translated} translated, {cached} cached, {failed} failed", flush=True) + return {"locale": locale, "translated": translated, "cached": cached, "failed": failed} + + +def main(): + parser = argparse.ArgumentParser(description="Translate Wails v3 docs") + parser.add_argument("--locale", default="all", help="Locale(s) to translate (comma-separated or 'all')") + parser.add_argument("--max-files", type=int, default=None, help="Max files per locale") + args = parser.parse_args() + + if args.locale == "all": + locales = ALL_LOCALES + else: + locales = [l.strip() for l in args.locale.split(",")] + for l in locales: + if l not in LOCALE_NAMES: + print(f"Unknown locale: {l}. Valid: {', '.join(ALL_LOCALES)}") + sys.exit(1) + + files = get_source_files() + print(f"Found {len(files)} source files to translate", flush=True) + print(f"Locales: {', '.join(locales)}", flush=True) + + results = [] + for locale in locales: + result = run_locale(locale, files, args.max_files) + results.append(result) + + print("\n" + "="*60, flush=True) + print("TRANSLATION SUMMARY", flush=True) + print("="*60, flush=True) + print(f"{'Locale':<10} {'Translated':>12} {'Cached':>8} {'Failed':>8}", flush=True) + for r in results: + print(f"{r['locale']:<10} {r['translated']:>12} {r['cached']:>8} {r['failed']:>8}", flush=True) + + total_files = sum(r['translated'] + r['cached'] for r in results) + print(f"\nTotal files processed: {total_files}", flush=True) + + +if __name__ == "__main__": + main() diff --git a/docs/scripts/translate-qa.py b/docs/scripts/translate-qa.py new file mode 100644 index 00000000000..31068eb7b3c --- /dev/null +++ b/docs/scripts/translate-qa.py @@ -0,0 +1,423 @@ +#!/usr/bin/env python3 +""" +Wails v3 translation QA scorer. +Usage: + python3 translate-qa.py --locale all + python3 translate-qa.py --locale zh-cn + python3 translate-qa.py --locale zh-cn --ai-verify + python3 translate-qa.py --locale all --ai-verify --ai-model z1-mini +""" +import os +import sys +import json +import argparse +import re +from pathlib import Path + +try: + import requests +except ImportError: + os.system("pip3 install requests -q") + import requests + +DOCS_ROOT = Path(__file__).parent.parent / "src" / "content" / "docs" +QA_DIR = Path(__file__).parent.parent / ".translation-qa" + +LOCALE_NAMES = { + "zh-cn": "Simplified Chinese", + "zh-tw": "Traditional Chinese", + "ja": "Japanese", + "ko": "Korean", + "ru": "Russian", + "fr": "French", + "pt": "Portuguese", + "de": "German", +} + +ALL_LOCALES = list(LOCALE_NAMES.keys()) + +# Characters expected in each locale (basic sanity check) +LOCALE_CHAR_RANGES = { + "zh-cn": (r'[一-鿿]', "Chinese characters"), + "zh-tw": (r'[一-鿿]', "Chinese characters"), + "ja": (r'[぀-ヿ一-鿿]', "Japanese characters"), + "ko": (r'[가-힯]', "Korean characters"), + "ru": (r'[Ѐ-ӿ]', "Cyrillic characters"), + "fr": (r'[a-zA-ZÀ-ÿ]', None), # Latin, harder to distinguish + "pt": (r'[a-zA-ZÀ-ÿ]', None), + "de": (r'[a-zA-ZÄÖÜäöüß]', None), +} + +# z.ai API (OpenAI-compatible) +ZAI_BASE = os.environ.get("ZAI_BASE", "https://api.z.ai/v1") +ZAI_API_KEY = os.environ.get("ZAI_API_KEY", "") +ZAI_DEFAULT_MODEL = "z1-mini" + +# Max characters of body text to send to AI verifier (keeps cost low) +AI_VERIFY_MAX_CHARS = 3000 + + +def extract_frontmatter(content: str) -> tuple[str, str]: + """Split content into frontmatter and body.""" + if not content.startswith("---"): + return "", content + end = content.find("---", 3) + if end == -1: + return "", content + return content[3:end].strip(), content[end+3:].strip() + + +def count_code_blocks(content: str) -> int: + return len(re.findall(r'```[\s\S]*?```', content)) + + +def count_inline_code(content: str) -> int: + return len(re.findall(r'`[^`]+`', content)) + + +def strip_code_blocks(text: str) -> str: + """Remove code blocks from text (for AI sampling — we don't want it scoring code).""" + text = re.sub(r'```[\s\S]*?```', '', text) + text = re.sub(r'`[^`\n]+`', '', text) + return text.strip() + + +def ai_verify_translation(src_body: str, tgt_body: str, locale: str, lang_name: str, model: str) -> dict: + """ + Call z.ai to verify translation quality. + Returns {"score": float|None, "issues": list[str], "ai_used": bool}. + Score is None if the API call failed or key is missing. + """ + if not ZAI_API_KEY: + return {"score": None, "issues": ["ZAI_API_KEY not set — skipping AI verification"], "ai_used": False} + + # Strip code blocks so the AI focuses on prose quality + src_sample = strip_code_blocks(src_body)[:AI_VERIFY_MAX_CHARS] + tgt_sample = strip_code_blocks(tgt_body)[:AI_VERIFY_MAX_CHARS] + + if len(src_sample) < 100 or len(tgt_sample) < 100: + return {"score": None, "issues": ["Not enough prose to AI-verify"], "ai_used": False} + + prompt = f"""You are a professional translation quality assessor. Rate this {lang_name} translation of English technical documentation. + +Score 0.0–1.0 based on: +- Accuracy: same meaning conveyed? +- Fluency: reads naturally in {lang_name}? +- Completeness: nothing missing or spuriously added? +- Technical terms: product names, UI labels, and technical jargon handled correctly? + +English source (prose excerpt, code removed): +--- +{src_sample} +--- + +{lang_name} translation (prose excerpt, code removed): +--- +{tgt_sample} +--- + +Reply with JSON only — no prose, no markdown fences: +{{"score": <0.0-1.0>, "issues": ["specific issue 1", "specific issue 2"]}} + +If the translation is good, return an empty issues list.""" + + try: + resp = requests.post( + f"{ZAI_BASE}/chat/completions", + headers={ + "Authorization": f"Bearer {ZAI_API_KEY}", + "Content-Type": "application/json", + }, + json={ + "model": model, + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.1, + "max_tokens": 512, + }, + timeout=30, + ) + resp.raise_for_status() + + raw = resp.json()["choices"][0]["message"]["content"].strip() + # Strip markdown fences if model wraps in ```json ... ``` + if raw.startswith("```"): + lines = raw.split("\n") + raw = "\n".join(lines[1:-1]) + result = json.loads(raw) + ai_score = float(result.get("score", 0.5)) + ai_issues = [f"[AI] {i}" for i in result.get("issues", [])] + return {"score": ai_score, "issues": ai_issues, "ai_used": True} + + except Exception as e: + return {"score": None, "issues": [f"[AI] Verification error: {e}"], "ai_used": False} + + +def check_file_pair(src_path: Path, tgt_path: Path, locale: str, + ai_verify: bool = False, ai_model: str = ZAI_DEFAULT_MODEL) -> dict: + """Score a translated file against its source.""" + src = src_path.read_text(encoding="utf-8") + tgt = tgt_path.read_text(encoding="utf-8") + + issues = [] + score = 1.0 + + # 1. Check file is not empty + if len(tgt.strip()) < 50: + return {"score": 0.0, "issues": ["File is empty or too short"]} + + # 2. Check file is not just a copy of source (not translated) + if src.strip() == tgt.strip(): + return {"score": 0.0, "issues": ["File appears to be an untranslated copy of source"]} + + # 3. Check translated file has content (not much shorter than source) + src_len = len(src) + tgt_len = len(tgt) + ratio = tgt_len / src_len if src_len > 0 else 0 + # CJK languages can be shorter, European languages similar length + min_ratio = 0.3 if locale in ["zh-cn", "zh-tw", "ja", "ko"] else 0.5 + max_ratio = 3.0 + if ratio < min_ratio: + issues.append(f"Translated file is suspiciously short (ratio: {ratio:.2f})") + score -= 0.3 + elif ratio > max_ratio: + issues.append(f"Translated file is suspiciously long (ratio: {ratio:.2f})") + score -= 0.1 + + # 4. Check frontmatter is preserved + src_fm, src_body = extract_frontmatter(src) + tgt_fm, tgt_body = extract_frontmatter(tgt) + + if src_fm and not tgt_fm: + issues.append("Frontmatter missing in translation") + score -= 0.2 + + if src_fm and tgt_fm: + # Check YAML keys are preserved + src_keys = set(re.findall(r'^(\w+):', src_fm, re.MULTILINE)) + tgt_keys = set(re.findall(r'^(\w+):', tgt_fm, re.MULTILINE)) + missing_keys = src_keys - tgt_keys + if missing_keys: + issues.append(f"Missing frontmatter keys: {missing_keys}") + score -= 0.15 + + # 5. Check code blocks are preserved + src_code_blocks = re.findall(r'```[\s\S]*?```', src) + tgt_code_blocks = re.findall(r'```[\s\S]*?```', tgt) + if len(src_code_blocks) != len(tgt_code_blocks): + issues.append(f"Code block count mismatch: src={len(src_code_blocks)}, tgt={len(tgt_code_blocks)}") + score -= 0.15 + + # 6. Check code block contents are not translated + for i, (sc, tc) in enumerate(zip(src_code_blocks, tgt_code_blocks)): + # Extract the code content (strip language marker) + sc_content = re.sub(r'^```\w*\n?', '', sc).rstrip('`').strip() + tc_content = re.sub(r'^```\w*\n?', '', tc).rstrip('`').strip() + if sc_content != tc_content: + issues.append(f"Code block {i+1} content was modified") + score -= 0.1 + break # Only report once + + # 7. Check MDX imports are preserved + src_imports = re.findall(r'^import\s+.+$', src, re.MULTILINE) + tgt_imports = re.findall(r'^import\s+.+$', tgt, re.MULTILINE) + if len(src_imports) != len(tgt_imports): + issues.append(f"Import count mismatch: src={len(src_imports)}, tgt={len(tgt_imports)}") + score -= 0.1 + + # 8. Check locale-specific characters appear (for non-Latin locales) + char_pattern, char_desc = LOCALE_CHAR_RANGES.get(locale, (None, None)) + if char_pattern and char_desc: + if not re.search(char_pattern, tgt_body): + issues.append(f"No {char_desc} found in translation body") + score -= 0.4 + + # 9. Check URLs are preserved + src_urls = re.findall(r'https?://\S+', src) + tgt_urls = re.findall(r'https?://\S+', tgt) + if src_urls and len(tgt_urls) < len(src_urls) * 0.7: + issues.append(f"URL count mismatch: src={len(src_urls)}, tgt={len(tgt_urls)}") + score -= 0.1 + + # 10. Check for malformed double-frontmatter artifact (model bug) + if tgt.startswith("---\n---\n"): + issues.append("Malformed double frontmatter (---\\n---\\n) — post-processing should have fixed this") + score -= 0.3 + + # 11. Check for trailing --- artifact (model bug) + if tgt.rstrip().endswith("\n---"): + issues.append("Trailing --- artifact at end of file — post-processing should have fixed this") + score -= 0.1 + + # 12. Check relative asset paths are correct for locale depth + # Source uses ../../assets/; locale files should use ../../../assets/ + if re.search(r'(? dict: + locale_dir = DOCS_ROOT / locale + if not locale_dir.exists(): + return {"locale": locale, "error": "Locale directory not found", "avg_score": 0.0, "files": []} + + files = list(locale_dir.rglob("*.mdx")) + list(locale_dir.rglob("*.md")) + if not files: + return {"locale": locale, "error": "No translated files found", "avg_score": 0.0, "files": []} + + results = [] + for tgt_path in sorted(files): + rel = tgt_path.relative_to(locale_dir) + src_path = DOCS_ROOT / rel + if not src_path.exists(): + continue + result = check_file_pair(src_path, tgt_path, locale, ai_verify=ai_verify, ai_model=ai_model) + result["file"] = str(rel) + results.append(result) + + if not results: + return {"locale": locale, "error": "No matching source files found", "avg_score": 0.0, "files": []} + + avg = sum(r["score"] for r in results) / len(results) + low_quality = [r for r in results if r["score"] < 0.75] + + return { + "locale": locale, + "avg_score": round(avg, 3), + "files_scored": len(results), + "low_quality_count": len(low_quality), + "low_quality": low_quality, + "files": results, + } + + +def main(): + parser = argparse.ArgumentParser(description="QA score Wails v3 translations") + parser.add_argument("--locale", default="all") + parser.add_argument("--json", action="store_true", help="Output full JSON") + parser.add_argument("--ai-verify", action="store_true", + help="Use z.ai LLM to verify translation accuracy (requires ZAI_API_KEY env var)") + parser.add_argument("--ai-model", default=ZAI_DEFAULT_MODEL, + help=f"z.ai model to use for verification (default: {ZAI_DEFAULT_MODEL})") + args = parser.parse_args() + + if args.ai_verify and not ZAI_API_KEY: + print("⚠ --ai-verify requires ZAI_API_KEY environment variable to be set.", flush=True) + print(f" Set it with: export ZAI_API_KEY=", flush=True) + print(f" Using heuristic scoring only.", flush=True) + + if args.locale == "all": + locales = ALL_LOCALES + else: + locales = [l.strip() for l in args.locale.split(",")] + + all_results = {} + ai_label = f" (+ z.ai/{args.ai_model})" if args.ai_verify and ZAI_API_KEY else "" + print(f"\nQA Scoring Results{ai_label}", flush=True) + print("="*70, flush=True) + print(f"{'Locale':<10} {'Avg Score':>10} {'Files':>7} {'Low Quality':>12}", flush=True) + print("-"*70, flush=True) + + for locale in locales: + result = score_locale(locale, ai_verify=args.ai_verify, ai_model=args.ai_model) + all_results[locale] = result + if "error" in result: + print(f"{locale:<10} {'ERROR':>10} {result['error']}", flush=True) + else: + flag = " ⚠" if result["low_quality_count"] > 0 else " ✓" + print( + f"{locale:<10} {result['avg_score']:>10.3f} {result['files_scored']:>7} {result['low_quality_count']:>12}{flag}", + flush=True + ) + if result["low_quality"]: + for lq in result["low_quality"]: + ai_note = f" [AI: {lq['ai_score']:.3f}]" if lq.get("ai_score") is not None else "" + print(f" → LOW ({lq['score']:.3f}{ai_note}): {lq['file']}", flush=True) + for issue in lq["issues"]: + print(f" - {issue}", flush=True) + + print("="*70, flush=True) + + if args.json: + print(json.dumps(all_results, indent=2)) + + import datetime + today = datetime.date.today().strftime("%Y-%m-%d") + + # Save per-locale reports to .translation-qa/ + QA_DIR.mkdir(exist_ok=True) + for locale, result in all_results.items(): + per_locale_path = QA_DIR / f"{locale}-{today}.json" + per_locale_path.write_text(json.dumps(result, indent=2, ensure_ascii=False)) + + # Save combined results + combined_path = QA_DIR / f"all-{today}.json" + combined_path.write_text(json.dumps(all_results, indent=2, ensure_ascii=False)) + print(f"\nQA results saved to: {QA_DIR}/", flush=True) + + return all_results + + +if __name__ == "__main__": + main() From 73eccc8ade2badc748a09df85ee2a826eca43421 Mon Sep 17 00:00:00 2001 From: Wails Doc Translator Date: Tue, 5 May 2026 09:39:48 +1000 Subject: [PATCH 2/5] fix(i18n): correct z.ai base URL to api.z.ai/api/paas/v4 and default model to GLM-5-Turbo The ZAI_BASE default was https://api.z.ai/v1 (returns 404). Correct endpoint per docs.z.ai is https://api.z.ai/api/paas/v4. Also update default model from z1-mini to GLM-5-Turbo. Co-Authored-By: Claude Sonnet 4.6 --- docs/scripts/translate-qa.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/scripts/translate-qa.py b/docs/scripts/translate-qa.py index 31068eb7b3c..620500c9c3b 100644 --- a/docs/scripts/translate-qa.py +++ b/docs/scripts/translate-qa.py @@ -48,10 +48,11 @@ "de": (r'[a-zA-ZÄÖÜäöüß]', None), } -# z.ai API (OpenAI-compatible) -ZAI_BASE = os.environ.get("ZAI_BASE", "https://api.z.ai/v1") +# z.ai API — correct base URL from https://docs.z.ai/ +# Endpoint: https://api.z.ai/api/paas/v4 (raw Bearer key, not JWT) +ZAI_BASE = os.environ.get("ZAI_BASE", "https://api.z.ai/api/paas/v4") ZAI_API_KEY = os.environ.get("ZAI_API_KEY", "") -ZAI_DEFAULT_MODEL = "z1-mini" +ZAI_DEFAULT_MODEL = "GLM-5-Turbo" # Max characters of body text to send to AI verifier (keeps cost low) AI_VERIFY_MAX_CHARS = 3000 From d54b5b5f61feb11bea67bd1a7c349d16ccced1c7 Mon Sep 17 00:00:00 2001 From: Wails Doc Translator Date: Tue, 5 May 2026 10:01:09 +1000 Subject: [PATCH 3/5] fix(translate-qa): use z.ai Coding Plan endpoint and increase max_tokens for GLM-5-Turbo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Switch ZAI_BASE from general paas/v4 to the dedicated coding plan endpoint (https://api.z.ai/api/coding/paas/v4) as required by the Coding Plan subscription - Increase max_tokens from 512 to 2000 — GLM-5-Turbo is a thinking model that consumes ~300 reasoning tokens internally before producing output, causing empty responses with the previous limit Co-Authored-By: Claude Sonnet 4.6 --- docs/scripts/translate-qa.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/scripts/translate-qa.py b/docs/scripts/translate-qa.py index 620500c9c3b..b686eb765e9 100644 --- a/docs/scripts/translate-qa.py +++ b/docs/scripts/translate-qa.py @@ -48,9 +48,9 @@ "de": (r'[a-zA-ZÄÖÜäöüß]', None), } -# z.ai API — correct base URL from https://docs.z.ai/ -# Endpoint: https://api.z.ai/api/paas/v4 (raw Bearer key, not JWT) -ZAI_BASE = os.environ.get("ZAI_BASE", "https://api.z.ai/api/paas/v4") +# z.ai Coding Plan API — dedicated coding endpoint per https://docs.z.ai/devpack/overview +# GLM Coding Plan requires https://api.z.ai/api/coding/paas/v4 (NOT the general paas/v4) +ZAI_BASE = os.environ.get("ZAI_BASE", "https://api.z.ai/api/coding/paas/v4") ZAI_API_KEY = os.environ.get("ZAI_API_KEY", "") ZAI_DEFAULT_MODEL = "GLM-5-Turbo" @@ -133,9 +133,9 @@ def ai_verify_translation(src_body: str, tgt_body: str, locale: str, lang_name: "model": model, "messages": [{"role": "user", "content": prompt}], "temperature": 0.1, - "max_tokens": 512, + "max_tokens": 2000, # GLM-5-Turbo uses ~300 reasoning tokens before output }, - timeout=30, + timeout=45, ) resp.raise_for_status() From 42e1304506ae3c5f261b9aa41c1fb9fbc5440aab Mon Sep 17 00:00:00 2001 From: Wails Doc Translator Date: Tue, 5 May 2026 10:21:00 +1000 Subject: [PATCH 4/5] feat(translate): chunked translation with context passing for large files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split documents > 8000 chars at paragraph/heading boundaries and translate each chunk independently. After each chunk, ask the model for a 2-3 sentence context summary (topics covered, terminology choices) and inject it into the next chunk's prompt, keeping terminology consistent across boundaries. This addresses two issues: - Hallucination at truncated boundaries: models were completing sentences/ paragraphs not in the source when they received a truncated view - Terminology drift: without context, later chunks sometimes used different terms for the same concept (e.g. alternating synonyms) Changes: - split_into_chunks(): split at \n\n## headings or \n\n paragraph breaks, never inside code blocks (tracks ``` fence parity) - summarize_chunk(): non-streaming Ollama call for a 2-3 sentence context note - strip_frontmatter(): removes spurious --- blocks from continuation chunks - translate_doc(): orchestrates split → translate → summarize → reassemble - Prompt: removed --- content delimiters (collided with MDX frontmatter syntax, causing model to include the closing --- in its output) - System prompt: added WRONG/CORRECT counter-example for bash comment translation - QA: fixed false positive in asset path check (../../../assets/ substring matched ../../assets/ pattern — now uses string replacement before checking) Co-Authored-By: Claude Sonnet 4.6 --- docs/scripts/translate-docs.py | 149 +++++++++++++++++++++++++++++++-- docs/scripts/translate-qa.py | 4 +- 2 files changed, 146 insertions(+), 7 deletions(-) diff --git a/docs/scripts/translate-docs.py b/docs/scripts/translate-docs.py index 43955b01dc3..a516fda26e7 100644 --- a/docs/scripts/translate-docs.py +++ b/docs/scripts/translate-docs.py @@ -110,6 +110,8 @@ This rule has NO exceptions: - Shell commands, Go code, configuration files — do NOT translate - Code comments (// ..., # ..., /* ... */) — do NOT translate + WRONG: `# Wails installieren` CORRECT: `# Install Wails` + Comments inside code blocks are part of the code — never translate them. - d2 diagram definitions — the ENTIRE block including quoted string labels like "Your UI\\n(React/Vue/etc)" must be copied exactly. WRONG: "Ihre UI\\n(React/Vue/etc)" CORRECT: "Your UI\\n(React/Vue/etc)" @@ -133,18 +135,101 @@ 7. Preserve all blank lines, heading levels (#, ##, ###), list structure (-, *), indentation, line breaks, and MDX component structure exactly as in the source.""" -TRANSLATE_PROMPT = """Translate this Wails v3 documentation page to {lang}. -Apply all rules strictly. Return only the translated document. +CHUNK_SIZE = 8000 # chars; files larger than this are translated in chunks + +SUMMARIZE_PROMPT = """The following is a translated excerpt from Wails v3 documentation. +Write a 2-3 sentence context note for the translator handling the next excerpt: +- What topics/sections were covered +- Key terminology choices (how specific technical terms were rendered in {lang}) +Keep it concise — it will be injected into the next translation prompt. --- -{content} +{translated} ---""" +CHUNK_TRANSLATE_PROMPT = """Translate the following {chunk_label} of a Wails v3 documentation page to {lang}. +Apply all rules strictly. Return only the translated text. +{context_block} +{content}""" + def file_hash(content: str) -> str: return hashlib.md5(content.encode()).hexdigest()[:12] +def split_into_chunks(content: str, max_size: int = CHUNK_SIZE) -> list[str]: + """Split MDX content at paragraph/heading boundaries, never inside code blocks.""" + if len(content) <= max_size: + return [content] + + chunks = [] + start = 0 + + while start < len(content): + if len(content) - start <= max_size: + chunks.append(content[start:]) + break + + lo = start + max_size // 2 + hi = min(len(content), start + max_size) + window = content[lo:hi] + + # Rough check: odd number of ``` lines before lo means we're inside a code block + pre = content[start:lo] + in_code = pre.count('\n```') % 2 == 1 + + split_pos = None + if not in_code: + # Prefer last heading boundary (\n\n## ...) in window + for m in reversed(list(re.finditer(r'\n\n(?=#+\s)', window))): + split_pos = lo + m.end() + break + + if split_pos is None: + # Fall back to last paragraph break + last = window.rfind('\n\n') + if last >= 0: + split_pos = lo + last + 2 + + if split_pos is None: + # Last resort: last newline before hi + last_nl = content.rfind('\n', start, hi) + split_pos = (last_nl + 1) if last_nl > start else hi + + chunks.append(content[start:split_pos]) + start = split_pos + + return chunks + + +def strip_frontmatter(content: str) -> str: + """Remove a leading frontmatter block if the model added one to a continuation chunk.""" + content = content.lstrip("\n") + if content.startswith("---"): + end = content.find("\n---", 3) + if end >= 0: + return content[end + 4:].lstrip("\n") + return content + + +def summarize_chunk(translated: str, lang: str) -> str: + """Ask the model for a brief context summary of a translated chunk.""" + prompt = SUMMARIZE_PROMPT.format(translated=translated[:4000], lang=lang) + payload = { + "model": MODEL, + "messages": [{"role": "user", "content": prompt}], + "stream": False, + "think": False, + "options": {"temperature": 0.1, "num_predict": 300}, + } + try: + resp = requests.post(f"{OLLAMA_BASE}/api/chat", json=payload, timeout=(10, 60)) + resp.raise_for_status() + return resp.json().get("message", {}).get("content", "").strip() + except Exception: + return "" + + def load_cache(locale: str) -> dict: cache_file = CACHE_DIR / f"{locale}.json" if cache_file.exists(): @@ -270,10 +355,25 @@ def rewrite_href(m: re.Match) -> str: return content -def translate_with_ollama(content: str, lang: str, file_path: str) -> str: +def translate_with_ollama(content: str, lang: str, file_path: str, + context_summary: str = "", is_continuation: bool = False) -> str: """Translate content using Ollama chat API (thinking disabled).""" system = SYSTEM_PROMPT.format(lang=lang) - user_msg = TRANSLATE_PROMPT.format(lang=lang, content=content) + + context_block = "" + if context_summary: + context_block = ( + "Previous document context (already translated — maintain consistent terminology):\n" + f"{context_summary}\n" + ) + + chunk_label = "continuation" if is_continuation else "page" + user_msg = CHUNK_TRANSLATE_PROMPT.format( + chunk_label=chunk_label, + lang=lang, + context_block=context_block, + content=content, + ) payload = { "model": MODEL, @@ -338,6 +438,43 @@ def translate_with_ollama(content: str, lang: str, file_path: str) -> str: return result if result else None +def translate_doc(content: str, lang: str, file_path: str) -> str | None: + """Translate a document, splitting into chunks with context passing for large files.""" + chunks = split_into_chunks(content) + + if len(chunks) == 1: + return translate_with_ollama(content, lang, file_path) + + print(f" Splitting into {len(chunks)} chunks", flush=True) + parts = [] + context = "" + + for i, chunk in enumerate(chunks): + is_last = i == len(chunks) - 1 + translated_chunk = translate_with_ollama( + chunk, lang, file_path, + context_summary=context, + is_continuation=(i > 0), + ) + if translated_chunk is None: + return None + + # Strip any spurious frontmatter the model adds to continuation chunks + if i > 0: + translated_chunk = strip_frontmatter(translated_chunk) + + parts.append(translated_chunk) + + if not is_last: + context = summarize_chunk(translated_chunk, lang) + preview = context[:80].replace('\n', ' ') + print(f" Chunk {i+1}/{len(chunks)} done → context: {preview}...", flush=True) + else: + print(f" Chunk {i+1}/{len(chunks)} done", flush=True) + + return "".join(parts) + + def translate_file( src_path: Path, locale: str, @@ -362,7 +499,7 @@ def translate_file( print(f" Translating {src_path.relative_to(DOCS_ROOT)} → {locale}...", flush=True) - translated = translate_with_ollama(content, lang, str(src_path)) + translated = translate_doc(content, lang, str(src_path)) if not translated: return False, "failed" diff --git a/docs/scripts/translate-qa.py b/docs/scripts/translate-qa.py index b686eb765e9..092a2f4dcd3 100644 --- a/docs/scripts/translate-qa.py +++ b/docs/scripts/translate-qa.py @@ -251,7 +251,9 @@ def check_file_pair(src_path: Path, tgt_path: Path, locale: str, # 12. Check relative asset paths are correct for locale depth # Source uses ../../assets/; locale files should use ../../../assets/ - if re.search(r'(? Date: Tue, 5 May 2026 11:07:56 +1000 Subject: [PATCH 5/5] feat(translate): add z.ai backend support via --zai-model flag Adds an alternative translation backend using the z.ai OpenAI-compatible API alongside the existing Ollama/qwen3.6:35b default. Usage: python3 translate-docs.py --locale de --zai-model glm-5.1 python3 translate-docs.py --locale all --zai-model glm-4.7 The z.ai backend reads ZAI_API_KEY and ZAI_BASE from the environment (defaults to the Coding Plan endpoint). GLM-5.1 and GLM-4.7 are both thinking models and work correctly with max_tokens=8000. The same chunking and context-passing logic applies to both backends. Comparison result (2 German test files): both qwen3.6:35b and GLM-5.1 score 1.000 on heuristic checks. GLM-5.1 is ~2x faster (cloud vs local 35B). Minor style difference: GLM-5.1 uses fully-localized German terms while qwen mixes in English loanwords more freely. Co-Authored-By: Claude Sonnet 4.6 --- docs/scripts/translate-docs.py | 100 ++++++++++++++++++++++++++++++++- 1 file changed, 98 insertions(+), 2 deletions(-) diff --git a/docs/scripts/translate-docs.py b/docs/scripts/translate-docs.py index a516fda26e7..2257e635a73 100644 --- a/docs/scripts/translate-docs.py +++ b/docs/scripts/translate-docs.py @@ -25,6 +25,13 @@ OLLAMA_BASE = "http://ai-master.taileaa27f.ts.net:11434" MODEL = "qwen3.6:35b" +ZAI_BASE = os.environ.get("ZAI_BASE", "https://api.z.ai/api/coding/paas/v4") +ZAI_API_KEY = os.environ.get("ZAI_API_KEY", "") +ZAI_MODEL = os.environ.get("ZAI_MODEL", "glm-5.1") + +# Active backend — overridden by --zai-model CLI flag +TRANSLATE_BACKEND = "ollama" + DOCS_ROOT = Path(__file__).parent.parent / "src" / "content" / "docs" CACHE_DIR = Path(__file__).parent.parent / ".translation-cache" @@ -438,12 +445,90 @@ def translate_with_ollama(content: str, lang: str, file_path: str, return result if result else None +def translate_with_zai(content: str, lang: str, file_path: str, + context_summary: str = "", is_continuation: bool = False) -> str | None: + """Translate content using z.ai OpenAI-compatible API.""" + if not ZAI_API_KEY: + print(" ERROR: ZAI_API_KEY not set", flush=True) + return None + + system = SYSTEM_PROMPT.format(lang=lang) + context_block = "" + if context_summary: + context_block = ( + "Previous document context (already translated — maintain consistent terminology):\n" + f"{context_summary}\n" + ) + chunk_label = "continuation" if is_continuation else "page" + user_msg = CHUNK_TRANSLATE_PROMPT.format( + chunk_label=chunk_label, + lang=lang, + context_block=context_block, + content=content, + ) + + try: + resp = requests.post( + f"{ZAI_BASE}/chat/completions", + headers={ + "Authorization": f"Bearer {ZAI_API_KEY}", + "Content-Type": "application/json", + }, + json={ + "model": ZAI_MODEL, + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": user_msg}, + ], + "temperature": 0.2, + "max_tokens": 8000, + }, + timeout=180, + ) + resp.raise_for_status() + data = resp.json() + except requests.exceptions.Timeout: + print(f" TIMEOUT (z.ai) translating {file_path}", flush=True) + return None + except Exception as e: + print(f" ERROR (z.ai) translating {file_path}: {e}", flush=True) + return None + + choice = data.get("choices", [{}])[0] + result = choice.get("message", {}).get("content", "").strip() + + if not result: + usage = data.get("usage", {}) + print(f" WARNING: empty content from {ZAI_MODEL} (usage={usage}) for {file_path}", flush=True) + return None + + # Strip any wrapping markdown fences + if result.startswith("```") and result.endswith("```"): + lines = result.split("\n") + result = "\n".join(lines[1:-1]) + + # Strip any preamble before frontmatter + if not result.startswith("---") and "---" in result: + idx = result.index("---") + result = result[idx:] + + return result if result else None + + +def _translate_chunk(content: str, lang: str, file_path: str, + context_summary: str = "", is_continuation: bool = False) -> str | None: + """Dispatch to the active translation backend.""" + if TRANSLATE_BACKEND == "zai": + return translate_with_zai(content, lang, file_path, context_summary, is_continuation) + return translate_with_ollama(content, lang, file_path, context_summary, is_continuation) + + def translate_doc(content: str, lang: str, file_path: str) -> str | None: """Translate a document, splitting into chunks with context passing for large files.""" chunks = split_into_chunks(content) if len(chunks) == 1: - return translate_with_ollama(content, lang, file_path) + return _translate_chunk(content, lang, file_path) print(f" Splitting into {len(chunks)} chunks", flush=True) parts = [] @@ -451,7 +536,7 @@ def translate_doc(content: str, lang: str, file_path: str) -> str | None: for i, chunk in enumerate(chunks): is_last = i == len(chunks) - 1 - translated_chunk = translate_with_ollama( + translated_chunk = _translate_chunk( chunk, lang, file_path, context_summary=context, is_continuation=(i > 0), @@ -585,11 +670,22 @@ def run_locale(locale: str, files: list[Path], max_files: int = None): def main(): + global TRANSLATE_BACKEND, ZAI_MODEL + parser = argparse.ArgumentParser(description="Translate Wails v3 docs") parser.add_argument("--locale", default="all", help="Locale(s) to translate (comma-separated or 'all')") parser.add_argument("--max-files", type=int, default=None, help="Max files per locale") + parser.add_argument("--zai-model", default=None, + help="Use z.ai instead of Ollama; specify model name (e.g. glm-5.1, glm-4.7)") args = parser.parse_args() + if args.zai_model: + TRANSLATE_BACKEND = "zai" + ZAI_MODEL = args.zai_model + print(f"Backend: z.ai ({ZAI_MODEL})", flush=True) + else: + print(f"Backend: Ollama ({MODEL})", flush=True) + if args.locale == "all": locales = ALL_LOCALES else: