Skip to content

Commit 8b4d7fe

Browse files
B2JK-Industryclaude
andcommitted
feat(i18n): PR-P G-35 follow-through — translate AI envelope on publish
User feedback: PR-P G-35 schema landed but the visible behaviour didn't change yet — `titleLocalized` was still always undefined, so the consumer kept hitting the PL canonical fallback. Adding the real generator hook now. `lib/ai-pipeline/generate.ts`: - new `translateEnvelopeFields(plPayload, targetLang)` helper. One Haiku call per non-pl lang; tight system prompt locks the same proper-noun list as the spec translator (BLIK, PKO, NBP, Tauron, IKE, IKZE, RRSO, WIBOR, WIRON, Katowice, Warszawa, Śląsk, Nikiszowiec, Varso Tower, ETF, S&P 500, WIG20). Returns PL fallback on parse failure (the consumer then falls back to `lang="pl"` rendering — no observable regression). `lib/ai-pipeline/publish.ts` `runPipeline`: - PL envelope strings extracted into `plTitle`/`plTagline`/ `plDescription` so the canonical fields and the localized maps use the same source string. - When `ANTHROPIC_API_KEY` is set, fan out 3 parallel `translateEnvelopeFields` calls (uk/cs/en). Each `.catch(() => plPayload)` so a single-language outage doesn't kill the whole publish. Aggregated into `titleLocalized` / `taglineLocalized` / `descriptionLocalized` Records and stamped onto the envelope. - When the API key is absent (mock-fallback dev path) the localized maps stay undefined; the consumer keeps showing PL with `lang="pl"`. Cost estimate: 3 extra Haiku calls per publish × 3 envelope strings ≈ 60 input + 200 output tokens each = ~$0.001 per rotation (negligible vs the existing spec translation). Existing live envelopes (3 currently in Redis: fast/medium/slow) were published before this commit so they have no localized maps. They'll naturally rotate on schedule (1 h / 6 h / 12 h cycles) and the next-generation replacements WILL be translated. An admin backfill endpoint that re-translates currently-live envelopes is documented as a Sprint H follow-up. Validation: - pnpm typecheck → 0 errors - pnpm test → 719/719 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 73edf39 commit 8b4d7fe

2 files changed

Lines changed: 106 additions & 4 deletions

File tree

lib/ai-pipeline/generate.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import Anthropic from "@anthropic-ai/sdk";
22
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
3+
import { z } from "zod";
34
import {
45
QuizSpecSchema,
56
ScrambleSpecSchema,
@@ -332,6 +333,59 @@ async function translateSpec(
332333
return mergeStructure(plSpec, response.parsed_output as GameSpec);
333334
}
334335

336+
/* G-35 — translate envelope strings (title/tagline/description).
337+
*
338+
* Separate from translateSpec because the envelope fields are short,
339+
* derived from the static seed catalog (not from the AI generator),
340+
* and don't need the structural-invariant baby-sitting that spec
341+
* translation requires. One Haiku call per non-pl lang; fallback to
342+
* the PL canonical strings if the call fails so the page never
343+
* blocks publish on a translation error. */
344+
const EnvelopeTranslationSchema = z.object({
345+
title: z.string().min(1).max(60),
346+
tagline: z.string().max(140),
347+
description: z.string().max(600),
348+
});
349+
350+
export async function translateEnvelopeFields(
351+
pl: { title: string; tagline: string; description: string },
352+
targetLang: Exclude<Lang, "pl">,
353+
): Promise<{ title: string; tagline: string; description: string }> {
354+
const response = await client().messages.parse({
355+
model: TRANSLATION_MODEL,
356+
max_tokens: 600,
357+
system: [
358+
{
359+
type: "text",
360+
text: [
361+
`Translate the JSON below from Polish to ${TARGET_LANG_LABEL[targetLang]}.`,
362+
"Keep these proper nouns untranslated: BLIK, PKO, NBP, Tauron, IKE, IKZE, RRSO, WIBOR, WIRON, Katowice, Warszawa, Śląsk, Nikiszowiec, Varso Tower, ETF, S&P 500, WIG20.",
363+
"Currency `zł` stays as `zł`.",
364+
"Tone: neutral, educational, Gen Z friendly. Same length envelope (title ≤ 60, tagline ≤ 140, description ≤ 600).",
365+
"Return EXACTLY the same JSON keys (title, tagline, description) with translated values.",
366+
].join("\n"),
367+
cache_control: { type: "ephemeral" },
368+
},
369+
],
370+
messages: [
371+
{
372+
role: "user",
373+
content: [
374+
"SOURCE (Polish):",
375+
"```json",
376+
JSON.stringify(pl, null, 2),
377+
"```",
378+
"",
379+
`Translate to ${TARGET_LANG_LABEL[targetLang]}.`,
380+
].join("\n"),
381+
},
382+
],
383+
output_config: { format: zodOutputFormat(EnvelopeTranslationSchema) },
384+
});
385+
if (!response.parsed_output) return pl;
386+
return response.parsed_output;
387+
}
388+
335389
function mergeStructure(pl: GameSpec, translated: GameSpec): GameSpec {
336390
if (pl.kind !== translated.kind) return pl; // last-ditch fallback
337391
if (pl.kind === "quiz" && translated.kind === "quiz") {

lib/ai-pipeline/publish.ts

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
type RotationSlot,
1212
} from "./types";
1313
import { pickResearchSeed, type ResearchSeed } from "./research";
14-
import { generateGameSpec } from "./generate";
14+
import { generateGameSpec, translateEnvelopeFields } from "./generate";
1515
import { moderateSpec, contentHash } from "./moderation";
1616

1717
// Single union index of live AI game ids across all slots (unchanged key so
@@ -230,11 +230,59 @@ export async function runPipeline(
230230

231231
// 5) Shape envelope + validate envelope too
232232
const id = `ai-${Math.floor(now / 1000).toString(36)}${slot === "fast" ? "" : slot[0]}`;
233+
const plTitle = seed.theme.split(" — ")[0];
234+
const plTagline = seed.notes.slice(0, 120);
235+
const plDescription = `${seed.theme}. AI-generowane wyzwanie co godzinę. Top 3 graczy dostanie permanentny medal.`;
236+
237+
// G-35 — translate envelope strings (title/tagline/description) into
238+
// the 3 non-pl locales alongside the spec translation. One Haiku
239+
// call per lang (parallel); failure falls back to PL canonical so a
240+
// translation outage never blocks publish. Skipped entirely when
241+
// ANTHROPIC_API_KEY is missing (mock-fallback pipeline path).
242+
let titleLocalized: AiGame["titleLocalized"] | undefined;
243+
let taglineLocalized: AiGame["taglineLocalized"] | undefined;
244+
let descriptionLocalized: AiGame["descriptionLocalized"] | undefined;
245+
if (process.env.ANTHROPIC_API_KEY) {
246+
try {
247+
const plPayload = {
248+
title: plTitle,
249+
tagline: plTagline,
250+
description: plDescription,
251+
};
252+
const [uk, cs, en] = await Promise.all([
253+
translateEnvelopeFields(plPayload, "uk").catch(() => plPayload),
254+
translateEnvelopeFields(plPayload, "cs").catch(() => plPayload),
255+
translateEnvelopeFields(plPayload, "en").catch(() => plPayload),
256+
]);
257+
titleLocalized = { pl: plTitle, uk: uk.title, cs: cs.title, en: en.title };
258+
taglineLocalized = {
259+
pl: plTagline,
260+
uk: uk.tagline,
261+
cs: cs.tagline,
262+
en: en.tagline,
263+
};
264+
descriptionLocalized = {
265+
pl: plDescription,
266+
uk: uk.description,
267+
cs: cs.description,
268+
en: en.description,
269+
};
270+
} catch (e) {
271+
console.warn(
272+
"[publish] envelope translation failed, falling back to PL",
273+
(e as Error).message,
274+
);
275+
}
276+
}
277+
233278
const game: AiGame = {
234279
id,
235-
title: seed.theme.split(" — ")[0],
236-
tagline: seed.notes.slice(0, 120),
237-
description: `${seed.theme}. AI-generowane wyzwanie co godzinę. Top 3 graczy dostanie permanentny medal.`,
280+
title: plTitle,
281+
tagline: plTagline,
282+
description: plDescription,
283+
titleLocalized,
284+
taglineLocalized,
285+
descriptionLocalized,
238286
theme: seed.theme,
239287
source: seed.source,
240288
buildingName: seed.buildingName,

0 commit comments

Comments
 (0)