/** * 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 * pnpm --filter @app/worker exec tsx scripts/translate-recipes.ts all --limit=5 # PILOT: 5 recept × alla språk * * Kräver DATABASE_URL och REDIS_URL i miljön (samma som API/worker använder). */ import { config as loadDotenv } from "dotenv"; import { existsSync } from "node:fs"; import path from "node:path"; 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"; // Ladda .env (samma sökning som worker/seed) så DATABASE_URL/REDIS_URL finns // även när skriptet körs fristående via `pnpm exec tsx`. for (const candidate of [".env", "../.env", "../../.env"]) { const p = path.resolve(process.cwd(), candidate); if (existsSync(p)) { loadDotenv({ path: p }); break; } } 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 positional = process.argv.slice(2).filter((a) => !a.startsWith("--")); const arg = (positional[0] ?? "all").toLowerCase(); const targets = arg === "all" ? LANGS : arg.split(",").map((s) => s.trim()).filter(Boolean); // Valfri pilotgräns: --limit=N översätter bara de N första publicerade recepten // (alfabetiskt) × målspråk – billig kvalitetskoll innan hela svepet. const limitFlag = process.argv.find((a) => a.startsWith("--limit=")); const limit = limitFlag ? Number(limitFlag.split("=")[1]) : null; if (limitFlag && (!Number.isInteger(limit) || (limit ?? 0) <= 0)) { throw new Error("--limit=N kräver ett positivt heltal, t.ex. --limit=5"); } 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 baseQuery = db .select({ id: schema.recipes.id, title: schema.recipes.titleSv }) .from(schema.recipes) .where(eq(schema.recipes.status, "published")) .orderBy(schema.recipes.titleSv); const recipes = limit ? await baseQuery.limit(limit) : await baseQuery; if (limit) { console.log( `PILOT-läge: ${recipes.length} recept (alfabetiskt först) × ${targets.length} språk:`, ); for (const r of recipes) console.log(` • ${r.title}`); } 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); }, );