b5bfb7cb8b
- 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>
116 lines
4.5 KiB
TypeScript
116 lines
4.5 KiB
TypeScript
/**
|
||
* 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);
|
||
},
|
||
);
|