Files
Cibello-app/apps/worker/scripts/translate-recipes.ts
T
Claude b5bfb7cb8b feat(worker): --limit=N i translate-recipes för billig pilot över alla språk
- translate-recipes.ts får en valfri --limit=N som bara köar de N första
  (alfabetiskt) publicerade recepten × målspråk. Syfte: kvalitetskolla en
  handfull recept på ALLA språk innan hela svepet (~496 × 11) körs och betalas.
- Skriver ut vilka recept som valts i pilot-läge så man vet vad man ska granska.
- Oförändrat i övrigt: idempotent, resumbar, draft_ai -> verifiering ->
  publish-translations.ts publicerar bara det som klarat verifieringen.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-21 15:42:51 +00:00

116 lines
4.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
},
);