fix(ci): retry locale placeholder mismatches

This commit is contained in:
Vincent Koc
2026-07-12 15:09:23 +08:00
parent 87c8bdfc81
commit b66929152c
4 changed files with 92 additions and 15 deletions
+28 -15
View File
@@ -12,6 +12,7 @@ import {
compareStringArrays,
createControlUiLocaleSyncPlan,
flattenTranslations,
resolveLocaleMetaProvenance,
type GlossaryEntry,
type LocaleEntry,
type LocaleMeta,
@@ -1317,6 +1318,24 @@ function parseTranslationReply(raw: string): Record<string, unknown> {
return JSON.parse(fenced ? fenced[1] : trimmed) as Record<string, unknown>;
}
export function parseTranslationBatchReply(
raw: string,
items: readonly TranslationBatchItem[],
locale: string,
): Map<string, string> {
const parsed = parseTranslationReply(raw);
const translated = new Map<string, string>();
for (const item of items) {
const value = parsed[item.key];
if (typeof value !== "string" || !value.trim()) {
throw new Error(`missing translation for ${item.key}`);
}
translated.set(item.key, value);
}
assertPlaceholderParity(new Map(items.map((item) => [item.key, item.text])), translated, locale);
return translated;
}
async function translateBatch(
clientAccess: ClientAccess,
items: readonly TranslationBatchItem[],
@@ -1334,15 +1353,7 @@ async function translateBatch(
const raw = await (
await clientAccess.getClient()
).prompt(buildBatchPrompt(items), attemptLabel);
const parsed = parseTranslationReply(raw);
const translated = new Map<string, string>();
for (const item of items) {
const value = parsed[item.key];
if (typeof value !== "string" || !value.trim()) {
throw new Error(`missing translation for ${item.key}`);
}
translated.set(item.key, value);
}
const translated = parseTranslationBatchReply(raw, items, context.locale);
logProgress(`${attemptLabel}: done (${formatDuration(Date.now() - startedAt)})`);
return translated;
} catch (error) {
@@ -1528,16 +1539,18 @@ async function syncLocale(
// legitimately stay identical to English. Track fallback keys from actual
// fallback decisions and previous fallback metadata instead.
const nextProvider = allowTranslate
? resolveConfiguredProvider()
: (previousMeta?.provider ?? "");
const nextModel = allowTranslate ? resolveConfiguredModel() : (previousMeta?.model ?? "");
const provenance = resolveLocaleMetaProvenance({
didTranslate: allowTranslate && plan.pending.length > 0,
model: allowTranslate ? resolveConfiguredModel() : "",
previousMeta,
provider: allowTranslate ? resolveConfiguredProvider() : "",
});
const artifacts = plan.render({
defaultGlossary: DEFAULT_GLOSSARY,
generatedAt: new Date().toISOString(),
glossary,
model: nextModel,
provider: nextProvider,
model: provenance.model,
provider: provenance.provider,
workflow: CONTROL_UI_I18N_WORKFLOW,
});
assertPlaceholderParity(sourceFlat, artifacts.nextFlat, entry.locale);
+15
View File
@@ -71,6 +71,21 @@ export function shouldReuseExistingTranslation(options: {
return !options.isFallback || (!options.allowTranslate && !options.force);
}
export function resolveLocaleMetaProvenance(options: {
didTranslate: boolean;
model: string;
previousMeta: LocaleMeta | null;
provider: string;
}): { model: string; provider: string } {
if (options.didTranslate) {
return { model: options.model, provider: options.provider };
}
return {
model: options.previousMeta?.model ?? options.model,
provider: options.previousMeta?.provider ?? options.provider,
};
}
export function createControlUiLocaleSyncPlan(input: {
allowTranslate: boolean;
cacheKeyFor: (key: string, textHash: string) => string;
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
createControlUiLocaleSyncPlan,
flattenTranslations,
resolveLocaleMetaProvenance,
type LocaleEntry,
type LocaleMeta,
type TranslationMemoryEntry,
@@ -50,6 +51,27 @@ function localeMeta(overrides: Partial<LocaleMeta> = {}): LocaleMeta {
}
describe("createControlUiLocaleSyncPlan", () => {
it("preserves provenance when a configured provider performs no translation", () => {
const previousMeta = localeMeta();
expect(
resolveLocaleMetaProvenance({
didTranslate: false,
model: "next-model",
previousMeta,
provider: "next-provider",
}),
).toEqual({ model: previousMeta.model, provider: previousMeta.provider });
expect(
resolveLocaleMetaProvenance({
didTranslate: true,
model: "next-model",
previousMeta,
provider: "next-provider",
}),
).toEqual({ model: "next-model", provider: "next-provider" });
});
it("plans reuse and renders deterministic locale artifacts", () => {
const sourceFlat = flattenTranslations({
group: {
+27
View File
@@ -7,6 +7,7 @@ import { pathToFileURL } from "node:url";
import { describe, expect, it } from "vitest";
import {
appendBoundedProcessOutput,
parseTranslationBatchReply,
runProcess,
shouldReuseExistingTranslation,
} from "../../scripts/control-ui-i18n.ts";
@@ -50,6 +51,32 @@ async function waitForChildClose(
}
describe("control-ui-i18n process runner", () => {
it("rejects placeholder-corrupt batch replies before they leave the retry loop", () => {
const items = [
{
cacheKey: "cache-key",
key: "configView.viewPendingChange",
text: "View pending change ({count})",
textHash: "text-hash",
},
];
expect(() =>
parseTranslationBatchReply(
JSON.stringify({ "configView.viewPendingChange": "Pending change" }),
items,
"ar",
),
).toThrow("ar:configView.viewPendingChange expected {count} got {}");
expect(
parseTranslationBatchReply(
JSON.stringify({ "configView.viewPendingChange": "Pending change ({count})" }),
items,
"ar",
),
).toEqual(new Map([["configView.viewPendingChange", "Pending change ({count})"]]));
});
it("ships no recorded English fallbacks", () => {
const metaDir = path.resolve("ui/src/i18n/.i18n");
const fallbacks = readdirSync(metaDir)