Files
Cibello-app/apps/worker/scripts/verify-translations.ts
T
Claude fbb251ea72 feat(worker): verify-translations + set-test-locale (kvalitetsrapport & marknadstest)
- verify-translations.ts: läser DB och rapporterar täckning per språk, hur många
  som föll på verifieringen, titel=svenska-heuristik och stickprov (titel + steg 1).
  Kör efter svepet för att kvalitetskolla utan att ögna alla recept i appen.
- set-test-locale.ts: sätter språk/region/valuta för ett testkonto utan psql, för
  att simulera olika marknader (t.ex. EUR så SEK-receptpriset döljs).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-21 17:29:28 +00:00

166 lines
6.0 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.
/**
* 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<number>`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<number>`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<number>`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<number>`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<number>`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);
},
);