Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/fixes/13885-pt-br-review.md
Original file line number Diff line number Diff line change
@@ -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)
169 changes: 169 additions & 0 deletions scripts/i18n/review-locale.mjs
Original file line number Diff line number Diff line change
@@ -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;
});
}
Loading
Loading