516 lines
14 KiB
Go
516 lines
14 KiB
Go
// translate: MiniMax M2.5 draft → GLM 5 review → write
|
|
//
|
|
// translate --lang de --what faq
|
|
// translate --lang all --what all
|
|
// translate --lang de --what yaml --no-review
|
|
|
|
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const apiURL = "https://openrouter.ai/api/v1/chat/completions"
|
|
|
|
var (
|
|
translateModel = "openai/gpt-oss-20b:nitro"
|
|
reviewModel = "z-ai/glm-5"
|
|
apiKey string
|
|
)
|
|
|
|
var langName = map[string]string{
|
|
"da": "Danish", "de": "German", "es": "Spanish", "fi": "Finnish",
|
|
"fr": "French", "hi": "Hindi", "id": "Indonesian", "it": "Italian",
|
|
"ja": "Japanese", "ko": "Korean", "nl": "Dutch", "no": "Norwegian",
|
|
"pl": "Polish", "pt": "Portuguese", "ru": "Russian", "sv": "Swedish",
|
|
"th": "Thai", "tr": "Turkish", "uk": "Ukrainian", "vi": "Vietnamese",
|
|
"zh": "Chinese (Simplified)",
|
|
}
|
|
|
|
var langNative = map[string]string{
|
|
"da": "Dansk", "de": "Deutsch", "es": "Español", "fi": "Suomi",
|
|
"fr": "Français", "hi": "हिन्दी", "id": "Bahasa Indonesia", "it": "Italiano",
|
|
"ja": "日本語", "ko": "한국어", "nl": "Nederlands", "no": "Norsk",
|
|
"pl": "Polski", "pt": "Português", "ru": "Русский", "sv": "Svenska",
|
|
"th": "ไทย", "tr": "Türkçe", "uk": "Українська", "vi": "Tiếng Việt",
|
|
"zh": "中文",
|
|
}
|
|
|
|
var langAddress = map[string]string{
|
|
"da": "du/din", "de": "du/dein (informal)", "es": "tú (informal)",
|
|
"fr": "tu/ton (informal)", "hi": "तुम (informal)", "id": "kamu (informal)",
|
|
"it": "tu (informal)", "ja": "polite form (です/ます)", "ko": "존댓말 (polite)",
|
|
"nl": "je/jouw (informal)", "no": "du/din", "pl": "ty (informal)",
|
|
"pt": "você", "ru": "ты (informal)", "sv": "du/din", "th": "คุณ (polite)",
|
|
"tr": "sen (informal)", "uk": "ти (informal)", "vi": "bạn (informal)",
|
|
"zh": "你 (informal)",
|
|
}
|
|
|
|
var knownTemplates = []string{"landing", "faq", "pricing", "privacy", "security", "dpa", "terms", "connect", "docs", "consent"}
|
|
|
|
func main() {
|
|
lang := flag.String("lang", "", "language(s) or 'all'")
|
|
what := flag.String("what", "", "template name(s), 'yaml', or 'all'")
|
|
key := flag.String("key", "", "OpenRouter API key")
|
|
portal := flag.String("portal", "", "portal directory")
|
|
noReview := flag.Bool("no-review", false, "skip review pass")
|
|
flag.Parse()
|
|
|
|
apiKey = pick(*key, os.Getenv("OPENROUTER_API_KEY"), envKey("OPENROUTER_API_KEY"))
|
|
if apiKey == "" {
|
|
die("no API key")
|
|
}
|
|
portalDir := *portal
|
|
if portalDir == "" {
|
|
portalDir = findPortalDir()
|
|
}
|
|
if *lang == "" || *what == "" {
|
|
die("Usage: translate --lang <code|all> --what <name|yaml|all>")
|
|
}
|
|
|
|
langs := resolveLangs(*lang)
|
|
tmpls, doYAML := resolveWhat(*what)
|
|
review := !*noReview
|
|
total := len(langs) * (len(tmpls) + b2i(doYAML))
|
|
n, errors := 0, 0
|
|
|
|
for _, l := range langs {
|
|
for _, t := range tmpls {
|
|
n++
|
|
fmt.Printf("[%d/%d] %s/%s ", n, total, l, t)
|
|
if err := doTemplate(portalDir, l, t, review); err != nil {
|
|
fmt.Printf("ERROR: %v\n", err)
|
|
errors++
|
|
}
|
|
}
|
|
if doYAML {
|
|
n++
|
|
fmt.Printf("[%d/%d] %s/yaml ", n, total, l)
|
|
if err := doYAML_(portalDir, l, review); err != nil {
|
|
fmt.Printf("ERROR: %v\n", err)
|
|
errors++
|
|
}
|
|
}
|
|
}
|
|
fmt.Printf("\nDone: %d/%d succeeded\n", total-errors, total)
|
|
if errors > 0 {
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// --- Template ---
|
|
|
|
func doTemplate(portalDir, lang, name string, review bool) error {
|
|
src, err := os.ReadFile(filepath.Join(portalDir, "templates", name+".tmpl"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ln, addr := langName[lang], langAddress[lang]
|
|
|
|
prompt := fmt.Sprintf(`Translate this web page template from English to %s.
|
|
RULES:
|
|
- Preserve ALL HTML tags, attributes, classes, IDs exactly
|
|
- Preserve ALL Go template syntax ({{.T.key}}, {{range}}, {{end}}, {{if}}, etc.)
|
|
- Preserve ALL JavaScript and CSS untranslated
|
|
- Never translate "inou"
|
|
- Keep English loanwords %s speakers actually use
|
|
- Use %s address forms
|
|
- Keep technical terms: DICOM, MRI, CT, FLAIR, MCP, API, JSON, CSV, HIPAA, GDPR, OCR, PDF
|
|
- Rename {{define "X"}} to {{define "X_%s"}}
|
|
- Output ONLY the translated template`, ln, ln, addr, lang)
|
|
|
|
fmt.Printf("draft... ")
|
|
translated, err := callLLM(translateModel, prompt, string(src), 0)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
translated = stripFences(translated)
|
|
translated = ensureDefine(translated, name, lang)
|
|
|
|
if review {
|
|
fmt.Printf("GLM review... ")
|
|
translated = reviewContent(translated, ln, "template")
|
|
}
|
|
|
|
os.WriteFile(filepath.Join(portalDir, "templates", name+"_"+lang+".tmpl"), []byte(translated), 0644)
|
|
fmt.Println("ok")
|
|
return nil
|
|
}
|
|
|
|
// --- YAML ---
|
|
|
|
func doYAML_(portalDir, lang string, review bool) error {
|
|
src, err := os.ReadFile(filepath.Join(portalDir, "lang", "en.yaml"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ln, addr := langName[lang], langAddress[lang]
|
|
|
|
allKeys := extractKeys(string(src))
|
|
keys := make(map[string]string)
|
|
passthrough := make(map[string]string)
|
|
for k, v := range allKeys {
|
|
switch {
|
|
case strings.HasPrefix(k, "lang_"):
|
|
passthrough[k] = v
|
|
case k == "language_name":
|
|
passthrough[k] = langNative[lang]
|
|
default:
|
|
keys[k] = v
|
|
}
|
|
}
|
|
keysJSON, _ := json.Marshal(keys)
|
|
|
|
prompt := fmt.Sprintf(`Translate these UI strings from English to %s.
|
|
Return a JSON object with same keys and translated values.
|
|
RULES: keep format placeholders (%%s %%d), never translate "inou", use %s address, keep technical terms.
|
|
Return ONLY valid JSON.`, ln, addr)
|
|
|
|
fmt.Printf("draft... ")
|
|
result, err := callLLM(translateModel, prompt, string(keysJSON), 0)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
result = stripFences(result)
|
|
|
|
var translated map[string]string
|
|
if err := json.Unmarshal([]byte(result), &translated); err != nil {
|
|
return fmt.Errorf("bad JSON: %w", err)
|
|
}
|
|
|
|
if review {
|
|
fmt.Printf("GLM review... ")
|
|
reviewYAML_(keys, translated, ln)
|
|
}
|
|
|
|
for k, v := range passthrough {
|
|
translated[k] = v
|
|
}
|
|
os.WriteFile(filepath.Join(portalDir, "lang", lang+".yaml"), []byte(rebuildYAML(string(src), translated)), 0644)
|
|
fmt.Println("ok")
|
|
return nil
|
|
}
|
|
|
|
// --- LLM call ---
|
|
|
|
func callLLM(model, system, user string, maxTokHint int) (string, error) {
|
|
body := map[string]any{
|
|
"model": model,
|
|
"messages": []map[string]string{{"role": "system", "content": system}, {"role": "user", "content": user}},
|
|
"temperature": 0.1,
|
|
"provider": map[string]any{"order": []string{"Groq"}, "allow_fallbacks": true},
|
|
}
|
|
if maxTokHint > 0 {
|
|
body["max_tokens"] = maxTokHint
|
|
}
|
|
|
|
data, _ := json.Marshal(body)
|
|
req, _ := http.NewRequest("POST", apiURL, bytes.NewReader(data))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
|
|
|
// MiniMax M2.5 reasons before generating — needs generous timeout
|
|
// pricing (8KB) = 40s, landing (16KB) ~80s, faq (51KB) ~200s
|
|
timeout := time.Duration(len(user)/80+60) * time.Second
|
|
if timeout < 3*time.Minute {
|
|
timeout = 3 * time.Minute
|
|
}
|
|
t0 := time.Now()
|
|
resp, err := (&http.Client{Timeout: timeout}).Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("[%.0fs] %v", time.Since(t0).Seconds(), err)
|
|
}
|
|
defer resp.Body.Close()
|
|
respData, _ := io.ReadAll(resp.Body)
|
|
fmt.Printf("[%.0fs %dKB] ", time.Since(t0).Seconds(), len(respData)/1024)
|
|
|
|
if resp.StatusCode != 200 {
|
|
return "", fmt.Errorf("HTTP %d: %.300s", resp.StatusCode, respData)
|
|
}
|
|
|
|
var r struct {
|
|
Choices []struct {
|
|
Message struct {
|
|
Content string `json:"content"`
|
|
Reasoning string `json:"reasoning"`
|
|
} `json:"message"`
|
|
FinishReason string `json:"finish_reason"`
|
|
} `json:"choices"`
|
|
Error *struct {
|
|
Message string `json:"message"`
|
|
} `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(respData, &r); err != nil {
|
|
return "", fmt.Errorf("bad JSON: %.300s", respData)
|
|
}
|
|
if r.Error != nil {
|
|
return "", fmt.Errorf("API: %s", r.Error.Message)
|
|
}
|
|
if len(r.Choices) == 0 {
|
|
return "", fmt.Errorf("no choices: %.300s", respData)
|
|
}
|
|
|
|
content := r.Choices[0].Message.Content
|
|
if content == "" {
|
|
content = r.Choices[0].Message.Reasoning
|
|
}
|
|
if content == "" {
|
|
return "", fmt.Errorf("empty content: %.500s", respData)
|
|
}
|
|
if r.Choices[0].FinishReason == "length" {
|
|
return "", fmt.Errorf("truncated (output hit max_tokens)")
|
|
}
|
|
return content, nil
|
|
}
|
|
|
|
// --- Review ---
|
|
|
|
type fix struct {
|
|
Old string `json:"old"`
|
|
New string `json:"new"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
|
|
func reviewContent(content, ln, kind string) string {
|
|
prompt := fmt.Sprintf(`Review this %s translation to %s. Check: wrong words, grammar, broken HTML tags, broken Go template tags, stray whitespace.
|
|
Return JSON array: [{"old":"exact string","new":"fix","reason":"why"}]. If perfect return [].`, ln, kind)
|
|
result, err := callLLM(reviewModel, prompt, content, 16384)
|
|
if err != nil {
|
|
fmt.Printf("(review error: %v) ", err)
|
|
return content
|
|
}
|
|
fixes := parseFixes(result)
|
|
applied := 0
|
|
for _, f := range fixes {
|
|
if f.Old != "" && f.New != "" && strings.Contains(content, f.Old) {
|
|
content = strings.Replace(content, f.Old, f.New, 1)
|
|
applied++
|
|
}
|
|
}
|
|
if applied > 0 {
|
|
fmt.Printf("(%d fixes) ", applied)
|
|
}
|
|
return content
|
|
}
|
|
|
|
func reviewYAML_(english, translated map[string]string, ln string) {
|
|
type pair struct {
|
|
EN string `json:"en"`
|
|
TR string `json:"tr"`
|
|
}
|
|
combined := make(map[string]pair)
|
|
for k, v := range english {
|
|
combined[k] = pair{EN: v, TR: translated[k]}
|
|
}
|
|
data, _ := json.Marshal(combined)
|
|
|
|
prompt := fmt.Sprintf(`Review these %s UI string translations. "en" is original, "tr" is translation.
|
|
Check: wrong translations, grammar, broken format placeholders.
|
|
Return JSON: [{"old":"yaml_key","new":"corrected value","reason":"why"}]. If perfect return [].`, ln)
|
|
|
|
result, err := callLLM(reviewModel, prompt, string(data), 16384)
|
|
if err != nil {
|
|
fmt.Printf("(review error: %v) ", err)
|
|
return
|
|
}
|
|
fixes := parseFixes(result)
|
|
for _, f := range fixes {
|
|
if _, ok := translated[f.Old]; ok {
|
|
translated[f.Old] = f.New
|
|
}
|
|
}
|
|
if len(fixes) > 0 {
|
|
fmt.Printf("(%d fixes) ", len(fixes))
|
|
}
|
|
}
|
|
|
|
func parseFixes(raw string) []fix {
|
|
raw = stripFences(strings.TrimSpace(raw))
|
|
var fixes []fix
|
|
if json.Unmarshal([]byte(raw), &fixes) != nil {
|
|
if s := strings.Index(raw, "["); s >= 0 {
|
|
if e := strings.LastIndex(raw, "]"); e > s {
|
|
json.Unmarshal([]byte(raw[s:e+1]), &fixes)
|
|
}
|
|
}
|
|
}
|
|
return fixes
|
|
}
|
|
|
|
// --- Helpers ---
|
|
|
|
func ensureDefine(content, name, lang string) string {
|
|
want := fmt.Sprintf(`{{define "%s_%s"}}`, name, lang)
|
|
if strings.Contains(content, want) {
|
|
return content
|
|
}
|
|
if i := strings.Index(content, `{{define "`); i >= 0 {
|
|
if j := strings.Index(content[i:], `"}}`); j > 0 {
|
|
return content[:i] + want + content[i+j+3:]
|
|
}
|
|
}
|
|
return want + "\n" + content
|
|
}
|
|
|
|
func extractKeys(src string) map[string]string {
|
|
keys := make(map[string]string)
|
|
scanner := bufio.NewScanner(strings.NewReader(src))
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if t := strings.TrimSpace(line); strings.HasPrefix(t, "#") || t == "" {
|
|
continue
|
|
}
|
|
if idx := strings.Index(line, ":"); idx > 0 {
|
|
k := strings.TrimSpace(line[:idx])
|
|
v := strings.TrimSpace(line[idx+1:])
|
|
if len(v) >= 2 && v[0] == '"' && v[len(v)-1] == '"' {
|
|
v = v[1 : len(v)-1]
|
|
}
|
|
if k != "" && v != "" {
|
|
keys[k] = v
|
|
}
|
|
}
|
|
}
|
|
return keys
|
|
}
|
|
|
|
func rebuildYAML(src string, translated map[string]string) string {
|
|
var out strings.Builder
|
|
scanner := bufio.NewScanner(strings.NewReader(src))
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
t := strings.TrimSpace(line)
|
|
if strings.HasPrefix(t, "#") || t == "" {
|
|
out.WriteString(line + "\n")
|
|
continue
|
|
}
|
|
idx := strings.Index(line, ":")
|
|
if idx < 0 {
|
|
out.WriteString(line + "\n")
|
|
continue
|
|
}
|
|
key := strings.TrimSpace(line[:idx])
|
|
if val, ok := translated[key]; ok {
|
|
if needsQuote(val) {
|
|
val = strings.ReplaceAll(val, `\`, `\\`)
|
|
val = strings.ReplaceAll(val, `"`, `\"`)
|
|
out.WriteString(key + ": \"" + val + "\"\n")
|
|
} else {
|
|
out.WriteString(key + ": " + val + "\n")
|
|
}
|
|
} else {
|
|
out.WriteString(line + "\n")
|
|
}
|
|
}
|
|
return out.String()
|
|
}
|
|
|
|
func needsQuote(s string) bool {
|
|
return s == "" || strings.ContainsAny(s, `:{}[]&*?|>!%@`+"`\"'#") ||
|
|
s[0] == ' ' || s[len(s)-1] == ' ' || s[0] == '-' || s[0] == '{' ||
|
|
s == "true" || s == "false" || s == "null" || s == "yes" || s == "no"
|
|
}
|
|
|
|
func stripFences(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
for _, p := range []string{"```json", "```yaml", "```html", "```"} {
|
|
if strings.HasPrefix(s, p) {
|
|
s = s[len(p):]
|
|
break
|
|
}
|
|
}
|
|
if strings.HasSuffix(s, "```") {
|
|
s = s[:len(s)-3]
|
|
}
|
|
return strings.TrimSpace(s)
|
|
}
|
|
|
|
func envKey(name string) string {
|
|
for _, p := range []string{"anthropic.env", "/tank/inou/anthropic.env"} {
|
|
if data, err := os.ReadFile(p); err == nil {
|
|
for _, line := range strings.Split(string(data), "\n") {
|
|
if strings.HasPrefix(strings.TrimSpace(line), name+"=") {
|
|
return strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), name+"="))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func findPortalDir() string {
|
|
dir, _ := os.Getwd()
|
|
for {
|
|
if _, err := os.Stat(filepath.Join(dir, "portal", "templates")); err == nil {
|
|
return filepath.Join(dir, "portal")
|
|
}
|
|
p := filepath.Dir(dir)
|
|
if p == dir {
|
|
break
|
|
}
|
|
dir = p
|
|
}
|
|
die("cannot find portal/")
|
|
return ""
|
|
}
|
|
|
|
func resolveLangs(spec string) []string {
|
|
if spec == "all" {
|
|
var out []string
|
|
for l := range langName {
|
|
out = append(out, l)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
return strings.Split(spec, ",")
|
|
}
|
|
|
|
func resolveWhat(spec string) ([]string, bool) {
|
|
if spec == "all" {
|
|
return knownTemplates, true
|
|
}
|
|
var t []string
|
|
yaml := false
|
|
for _, p := range strings.Split(spec, ",") {
|
|
if p == "yaml" {
|
|
yaml = true
|
|
} else {
|
|
t = append(t, p)
|
|
}
|
|
}
|
|
return t, yaml
|
|
}
|
|
|
|
func pick(ss ...string) string {
|
|
for _, s := range ss {
|
|
if s != "" {
|
|
return s
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func b2i(b bool) int {
|
|
if b {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func die(msg string) {
|
|
fmt.Fprintln(os.Stderr, msg)
|
|
os.Exit(1)
|
|
}
|