i18n(innehåll): komplett ingrediensöversättning (362×11) + receptöversättnings-verktyg

Ingredienser: fyllde luckan från 101 till 362 för da/de/es/fi/fr/it/nb/nl/pl/pt
(engelska hade redan alla 362). Nu 3982 översättningar, 11 språk kompletta – en
spansk/tysk/fransk användare ser ingrediensnamn på sitt språk, inte svenska.
Författat av språkexperter per språk, verifierat mot 362-listan + kvalitetskoll.

Recept: 407 recept har 0 översättningar (svensk källa visas). Lade till två
körbara skript för AI-pipelinen (kräver din Gemini-nyckel i workern):
- scripts/translate-recipes.ts [språk|all]  – köar TRANSLATE_RECIPE för alla
  publicerade recept × språk, idempotent (hoppar redan gjorda).
- scripts/publish-translations.ts [språk|all] – publicerar de utkast som klarat
  den inbyggda verifieringen (numbersPreserved/stegantal/confidence); övriga
  stannar som utkast för granskning.

Verifierat mot testdatabas: ingredient_translations 3982 rader/11 språk kompletta;
enqueue köar 405 jobb; processor skapar draft_ai+ok; publish publicerar; steg-
översättningar följer med och serveras.
This commit is contained in:
Claude
2026-08-20 18:35:59 +00:00
parent ac87bd8d77
commit 16fb29cc12
3 changed files with 2755 additions and 10 deletions
@@ -0,0 +1,50 @@
/**
* Publicera AI-receptöversättningar som klarat den inbyggda verifieringen
* (verification.ok = true). Endast PUBLICERADE översättningar visas för
* användare. Utkast som föll på verifieringen lämnas kvar för manuell
* granskning i admin (så inget felöversatt går live automatiskt).
*
* Kör EFTER att translate-recipes.ts-kön har tömts:
* pnpm --filter @app/worker exec tsx scripts/publish-translations.ts es
* pnpm --filter @app/worker exec tsx scripts/publish-translations.ts all
*
* Kräver DATABASE_URL i miljön.
*/
import { and, eq, sql } from "drizzle-orm";
import { createDatabase, schema } from "@app/database";
async function main() {
if (!process.env.DATABASE_URL) throw new Error("Sätt DATABASE_URL.");
const arg = (process.argv[2] ?? "all").toLowerCase();
const { db, pool } = createDatabase(process.env.DATABASE_URL);
const cond = [
eq(schema.recipeTranslations.status, "draft_ai"),
sql`${schema.recipeTranslations.verification}->>'ok' = 'true'`,
];
if (arg !== "all") cond.push(eq(schema.recipeTranslations.languageTag, arg));
const published = await db
.update(schema.recipeTranslations)
.set({ status: "published", updatedAt: new Date() })
.where(and(...cond))
.returning({ id: schema.recipeTranslations.id });
const [remaining] = await db
.select({ n: sql<number>`count(*)::int` })
.from(schema.recipeTranslations)
.where(eq(schema.recipeTranslations.status, "draft_ai"));
console.log(`Publicerade ${published.length} verifierade översättningar (${arg}).`);
console.log(
`Kvar som utkast (föll på verifieringen granska i admin vid behov): ${remaining?.n ?? 0}.`,
);
await pool.end();
}
main().then(
() => process.exit(0),
(e) => {
console.error(e);
process.exit(1);
},
);
+85
View File
@@ -0,0 +1,85 @@
/**
* Köa AI-översättning (TRANSLATE_RECIPE) för ALLA publicerade recept × målspråk.
* Hoppar över (recept, språk) som redan har en översättningsrad idempotent och
* resumbar, så du kan köra om utan att betala för samma jobb två gånger.
*
* Workern (Fönster C) MÅSTE köra med en RIKTIG AI-nyckel (inte mock) för att
* jobben ska bli riktiga översättningar. Jobben sparas som utkast (draft_ai)
* publicera dem sedan med publish-translations.ts.
*
* Kör från repo-roten:
* pnpm --filter @app/worker exec tsx scripts/translate-recipes.ts es # bara spanska (testa först!)
* pnpm --filter @app/worker exec tsx scripts/translate-recipes.ts es,de # flera
* pnpm --filter @app/worker exec tsx scripts/translate-recipes.ts all # alla 11 språk
*
* Kräver DATABASE_URL och REDIS_URL i miljön (samma som API/worker använder).
*/
import IORedis from "ioredis";
import { Queue } from "bullmq";
import { eq } from "drizzle-orm";
import { createDatabase, schema } from "@app/database";
import { JOB_QUEUE_NAME } from "@app/shared-types";
const LANGS = ["en", "da", "de", "es", "fi", "fr", "it", "nb", "nl", "pl", "pt"];
async function main() {
if (!process.env.DATABASE_URL) throw new Error("Sätt DATABASE_URL.");
const arg = (process.argv[2] ?? "all").toLowerCase();
const targets =
arg === "all" ? LANGS : arg.split(",").map((s) => s.trim()).filter(Boolean);
for (const l of targets) {
if (!LANGS.includes(l)) throw new Error(`Okänt språk: ${l}. Giltiga: ${LANGS.join(", ")}, all`);
}
const { db, pool } = createDatabase(process.env.DATABASE_URL);
const connection = new IORedis(process.env.REDIS_URL ?? "redis://localhost:6379", {
maxRetriesPerRequest: null,
});
const queue = new Queue(JOB_QUEUE_NAME, { connection });
const recipes = await db
.select({ id: schema.recipes.id })
.from(schema.recipes)
.where(eq(schema.recipes.status, "published"));
const existing = await db
.select({ r: schema.recipeTranslations.recipeId, l: schema.recipeTranslations.languageTag })
.from(schema.recipeTranslations);
const have = new Set(existing.map((e) => `${e.r}|${e.l}`));
const jobs: { name: string; data: unknown }[] = [];
let skip = 0;
for (const r of recipes) {
for (const lang of targets) {
if (have.has(`${r.id}|${lang}`)) {
skip++;
continue;
}
jobs.push({
name: "TRANSLATE_RECIPE",
data: { jobType: "TRANSLATE_RECIPE", recipeId: r.id, targetLanguageTag: lang },
});
}
}
// Köa i klumpar (snabbare än en add per jobb).
for (let i = 0; i < jobs.length; i += 500) {
await queue.addBulk(jobs.slice(i, i + 500) as never);
}
console.log(
`Köade ${jobs.length} översättningsjobb (hoppade ${skip} som redan finns). ` +
`Recept: ${recipes.length}, språk: ${targets.join(", ")}.`,
);
console.log(
"Workern (med riktig AI-nyckel) processar dem nu. När kön tömts: kör publish-translations.ts.",
);
await queue.close();
await connection.quit();
await pool.end();
}
main().then(
() => process.exit(0),
(e) => {
console.error(e);
process.exit(1);
},
);