fix(i18n): pt-BR review pass over the retranslated leaves (172 corrections) + review-locale script (#13885)

Reviewer pass (native-speaker prompt) over the 1,865 pt-BR leaves retranslated in #13782: 172 corrections applied; new scripts/i18n/review-locale.mjs with tests. ⚠️ base-red inherited: #12732
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-09-16 12:19:40 -03:00
committed by GitHub
parent 7f496c79d0
commit 060f331264
4 changed files with 364 additions and 170 deletions

View File

@@ -0,0 +1 @@
- **fix(i18n):** reviewer pass over the 1,865 pt-BR leaves retranslated in #13782 (172 corrections) via the new `review-locale` script. (#13885)

View File

@@ -0,0 +1,169 @@
#!/usr/bin/env node
/**
* OmniRoute — locale review pass. Sends the leaves a locale changed since a git ref to the
* translation backend with a *reviewer* prompt (native speaker, dashboard context, keep
* placeholders/ICU/markup) and applies only the corrections it returns. Produces a markdown
* report so the operator can read what was changed.
*
* Usage: node scripts/i18n/review-locale.mjs --locale=pt-BR --since=<ref> [--dry-run] [--batch-size=30]
*/
import { existsSync, readFileSync, promises as fs } from "node:fs";
import path from "node:path";
import process from "node:process";
import { execFileSync } from "node:child_process";
import { fileURLToPath, pathToFileURL } from "node:url";
import { backendConfig, callChat } from "./lib/translate-backend.mjs";
// ----- .env loader (same contract as sync-ui-keys.mjs: repo-root .env, already-set vars win)
(function loadDotEnv() {
const envPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", ".env");
if (!existsSync(envPath)) return;
try {
const raw = readFileSync(envPath, "utf8");
for (const rawLine of raw.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const eq = line.indexOf("=");
if (eq <= 0) continue;
const key = line.slice(0, eq).trim();
if (!key || process.env[key] !== undefined) continue;
let value = line.slice(eq + 1);
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
process.env[key] = value;
}
} catch {
// unreadable .env — the backend config will report the missing variables
}
})();
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(SCRIPT_DIR, "..", "..");
const MESSAGES_DIR = path.join(ROOT, "src", "i18n", "messages");
const flat = (o, p = "", out = {}) => {
for (const [k, v] of Object.entries(o)) {
const d = p ? `${p}.${k}` : k;
if (v && typeof v === "object" && !Array.isArray(v)) flat(v, d, out);
else out[d] = v;
}
return out;
};
const setDeep = (o, dotted, value) => {
const parts = dotted.split(".");
let n = o;
for (const p of parts.slice(0, -1)) n = n[p];
n[parts.at(-1)] = value;
};
export function changedLeaves(before, after) {
const out = {};
for (const [k, v] of Object.entries(after)) {
if (typeof v === "string" && before[k] !== v) out[k] = v;
}
return out;
}
export function parseReviewResponse(text, ids) {
const m = text.match(/\{[\s\S]*\}/);
if (!m) return {};
let parsed;
try {
parsed = JSON.parse(m[0]);
} catch {
return {};
}
const allowed = new Set(ids);
const out = {};
for (const [id, v] of Object.entries(parsed)) {
if (allowed.has(id) && typeof v === "string" && v.trim() && v.trim() !== "OK") {
out[id] = v.trim();
}
}
return out;
}
const REVIEW_SYSTEM = (english, native) =>
`You are a senior ${english} (${native}) localization reviewer for a developer-facing dashboard (an LLM proxy/router called OmniRoute). You receive a JSON object mapping ids to {en, current}. For every id, answer "OK" when the current translation is correct, natural and complete, or return the corrected ${english} string. Rules: keep every placeholder ({name}, {count, plural, …}), ICU syntax, HTML/markdown, product names (OmniRoute, Claude, Codex, MCP, A2A) and technical identifiers exactly as in the English; do not translate code; prefer the terminology already used in "current" when it is fine. Reply with ONLY a JSON object {id: "OK" | "corrected string"}.`;
function parseArgs(argv) {
const o = { locale: null, since: null, dryRun: false, batchSize: 30 };
for (const a of argv.slice(2)) {
if (a.startsWith("--locale=")) o.locale = a.slice(9);
else if (a.startsWith("--since=")) o.since = a.slice(8);
else if (a === "--dry-run") o.dryRun = true;
else if (a.startsWith("--batch-size=")) o.batchSize = Math.max(1, Number(a.slice(13)) || 30);
}
if (!o.locale || !o.since) throw new Error("--locale=<code> and --since=<git ref> are required");
return o;
}
async function main() {
const o = parseArgs(process.argv);
const file = path.join(MESSAGES_DIR, `${o.locale}.json`);
const rel = path.relative(ROOT, file);
const after = JSON.parse(await fs.readFile(file, "utf8"));
const before = JSON.parse(
execFileSync("git", ["show", `${o.since}:${rel}`], {
cwd: ROOT,
encoding: "utf8",
maxBuffer: 1 << 28,
})
);
const en = flat(JSON.parse(await fs.readFile(path.join(MESSAGES_DIR, "en.json"), "utf8")));
const changed = changedLeaves(flat(before), flat(after));
const ids = Object.keys(changed);
console.log(`[review] ${o.locale}: ${ids.length} leaves changed since ${o.since}`);
if (o.dryRun) return;
const config = JSON.parse(await fs.readFile(path.join(ROOT, "config", "i18n.json"), "utf8"));
const entry = config.locales.find((l) => l.code === o.locale);
const backend = backendConfig();
const fixes = {};
for (let i = 0; i < ids.length; i += o.batchSize) {
const slice = ids.slice(i, i + o.batchSize);
const payload = Object.fromEntries(
slice.map((id) => [id, { en: en[id], current: changed[id] }])
);
const text = await callChat(
[
{ role: "system", content: REVIEW_SYSTEM(entry.english ?? entry.name, entry.native) },
{ role: "user", content: JSON.stringify(payload) },
],
backend
);
Object.assign(fixes, parseReviewResponse(text, slice));
console.log(
`[review] ${Math.min(i + o.batchSize, ids.length)}/${ids.length} reviewed, ${Object.keys(fixes).length} corrections so far`
);
}
for (const [id, v] of Object.entries(fixes)) setDeep(after, id, v);
await fs.writeFile(file, JSON.stringify(after, null, 2) + "\n", "utf8");
const reportDir = path.join(ROOT, "_artifacts", "i18n-review");
await fs.mkdir(reportDir, { recursive: true });
const report = [
`# Review ${o.locale} since ${o.since}`,
"",
`${ids.length} leaves reviewed, ${Object.keys(fixes).length} corrected.`,
"",
"| key | en | before | after |",
"| --- | --- | --- | --- |",
...Object.entries(fixes).map(([id, v]) => `| \`${id}\` | ${en[id]} | ${changed[id]} | ${v} |`),
].join("\n");
await fs.writeFile(path.join(reportDir, `${o.locale}.md`), report + "\n", "utf8");
console.log(
`[review] ${Object.keys(fixes).length} corrections applied; report: _artifacts/i18n-review/${o.locale}.md`
);
}
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
if (isDirectRun) {
main().catch((e) => {
console.error(`[review] ${e.message}`);
process.exitCode = 1;
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,24 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { changedLeaves, parseReviewResponse } from "../../scripts/i18n/review-locale.mjs";
test("changedLeaves lists new and rewritten leaves only", () => {
const before = { "a.x": "Save", "a.y": "Salvar", "a.z": "Old" };
const after = { "a.x": "Salvar", "a.y": "Salvar", "a.z": "Novo", "a.w": "Novo também" };
assert.deepEqual(changedLeaves(before, after), {
"a.x": "Salvar",
"a.z": "Novo",
"a.w": "Novo também",
});
});
test("parseReviewResponse keeps only real corrections for known ids", () => {
const text = 'Here you go:\n{"a.x": "OK", "a.z": "Novo (corrigido)", "ghost": "x", "a.w": ""}';
assert.deepEqual(parseReviewResponse(text, ["a.x", "a.z", "a.w"]), {
"a.z": "Novo (corrigido)",
});
});
test("parseReviewResponse tolerates a fenced JSON block", () => {
assert.deepEqual(parseReviewResponse('```json\n{"k":"v"}\n```', ["k"]), { k: "v" });
});