/** * Kvalitetsrapport för receptöversättningarna – kör EFTER svepet så du slipper * ögna igenom alla recept i appen. Läser bara (ändrar inget). * * pnpm --filter @app/worker exec tsx scripts/verify-translations.ts * * Visar per språk: * • täckning – antal recept med PUBLICERAD översättning / totalt, * • utkast – översättningar som inte är publicerade, * • föll_verif – översättningar som föll på den deterministiska verifieringen * (de publiceras aldrig automatiskt och faller tillbaka på svenska), * • titel=sv – heuristik: publicerad titel identisk med svenska källan * (ofta egennamn som Tiramisu/Pizza, men värt att titta på), * och några stickprov (titel + steg 1, svenska → översatt) att läsa direkt. * * Körs i ett vanligt fönster (inte A/B/C). Kräver DATABASE_URL (.env). */ import { config as loadDotenv } from "dotenv"; import { existsSync } from "node:fs"; import path from "node:path"; import { and, eq, sql } from "drizzle-orm"; import { createDatabase, schema } from "@app/database"; 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"]; const short = (s: string, n = 88) => (s.length > n ? s.slice(0, n - 1) + "…" : s); async function main() { if (!process.env.DATABASE_URL) throw new Error("Sätt DATABASE_URL."); const { db, pool } = createDatabase(process.env.DATABASE_URL); const [tot] = await db .select({ n: sql`count(*)::int` }) .from(schema.recipes) .where(eq(schema.recipes.status, "published")); const total = tot?.n ?? 0; const agg = (rows: { lang: string; n: number }[]) => new Map(rows.map((r) => [r.lang, r.n])); const published = agg( await db .select({ lang: schema.recipeTranslations.languageTag, n: sql`count(*)::int` }) .from(schema.recipeTranslations) .innerJoin(schema.recipes, eq(schema.recipes.id, schema.recipeTranslations.recipeId)) .where( and( eq(schema.recipeTranslations.status, "published"), eq(schema.recipes.status, "published"), ), ) .groupBy(schema.recipeTranslations.languageTag), ); const drafts = agg( await db .select({ lang: schema.recipeTranslations.languageTag, n: sql`count(*)::int` }) .from(schema.recipeTranslations) .where(eq(schema.recipeTranslations.status, "draft_ai")) .groupBy(schema.recipeTranslations.languageTag), ); const failed = agg( await db .select({ lang: schema.recipeTranslations.languageTag, n: sql`count(*)::int` }) .from(schema.recipeTranslations) .where(sql`${schema.recipeTranslations.verification}->>'ok' = 'false'`) .groupBy(schema.recipeTranslations.languageTag), ); const identical = agg( await db .select({ lang: schema.recipeTranslations.languageTag, n: sql`count(*)::int` }) .from(schema.recipeTranslations) .innerJoin(schema.recipes, eq(schema.recipes.id, schema.recipeTranslations.recipeId)) .where( and( eq(schema.recipeTranslations.status, "published"), eq(schema.recipes.status, "published"), sql`${schema.recipeTranslations.title} = ${schema.recipes.titleSv}`, ), ) .groupBy(schema.recipeTranslations.languageTag), ); console.log(`\nPublicerade recept totalt: ${total}\n`); console.log("=== Täckning & flaggor per språk ==="); console.log(" språk publicerade utkast föll_verif titel=sv"); for (const lang of LANGS) { const p = published.get(lang) ?? 0; const pct = total ? Math.round((p / total) * 100) : 0; const cov = `${p}/${total} (${pct}%)`; console.log( ` ${lang.padEnd(6)} ${cov.padEnd(15)} ${String(drafts.get(lang) ?? 0).padStart(5)} ${String( failed.get(lang) ?? 0, ).padStart(9)} ${String(identical.get(lang) ?? 0).padStart(8)}`, ); } console.log("\n=== Stickprov (svenska → översatt) ==="); for (const lang of LANGS) { const titles = await db .select({ id: schema.recipes.id, sv: schema.recipes.titleSv, tr: schema.recipeTranslations.title, }) .from(schema.recipeTranslations) .innerJoin(schema.recipes, eq(schema.recipes.id, schema.recipeTranslations.recipeId)) .where( and( eq(schema.recipeTranslations.languageTag, lang), eq(schema.recipeTranslations.status, "published"), eq(schema.recipes.status, "published"), ), ) .orderBy(schema.recipes.titleSv) .limit(2); if (titles.length === 0) { console.log(` ${lang}: (inga publicerade översättningar)`); continue; } console.log(` ${lang}:`); for (const t of titles) console.log(` titel: ${t.sv} → ${t.tr}`); const [step] = await db .select({ sv: schema.recipeSteps.instructionSv, tr: schema.recipeStepTranslations.instruction }) .from(schema.recipeStepTranslations) .innerJoin( schema.recipeSteps, and( eq(schema.recipeSteps.recipeId, schema.recipeStepTranslations.recipeId), eq(schema.recipeSteps.stepNumber, schema.recipeStepTranslations.stepNumber), ), ) .where( and( eq(schema.recipeStepTranslations.recipeId, titles[0]!.id), eq(schema.recipeStepTranslations.languageTag, lang), eq(schema.recipeStepTranslations.stepNumber, 1), ), ) .limit(1); if (step) { console.log(` steg 1: ${short(step.sv)}`); console.log(` → ${short(step.tr)}`); } } console.log( "\nTolkning: 'föll_verif' går aldrig live (faller på svenska) – granska i admin vid behov. " + "'titel=sv' är en heuristik, ofta egennamn. Stämmer stickproven och täckningen är hög är svepet friskt.", ); await pool.end(); } main().then( () => process.exit(0), (e) => { console.error(e); process.exit(1); }, );