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>
This commit is contained in:
Claude
2026-08-21 17:29:28 +00:00
parent 0ecd78f7e3
commit fbb251ea72
2 changed files with 246 additions and 0 deletions
+81
View File
@@ -0,0 +1,81 @@
/**
* Sätt locale-preferenser för ett testkonto (språk / region / valuta) för att
* simulera olika marknader utan psql. Skriver ut det sparade resultatet.
*
* pnpm --filter @app/worker exec tsx scripts/set-test-locale.ts <email> <languageTag> [regionCode] [currencyCode]
*
* Exempel:
* # Full italiensk marknad (italienska + EUR + inga SEK-priser + IT-högtider):
* ... set-test-locale.ts dev@hypbit.com it-IT IT EUR
* # Bara dölja SEK-priset men behålla svenska marknaden/högtiderna:
* ... set-test-locale.ts dev@hypbit.com it-IT SE EUR
* # Tillbaka till svenskt:
* ... set-test-locale.ts dev@hypbit.com sv-SE SE SEK
*
* region/valuta är valfria utelämnas de rörs de inte (bara språket byts).
* Byt sedan språk i appens profil (eller starta om appen) så cachen rensas.
*
* Körs i ett vanligt fönster (inte Fönster 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 { eq } 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;
}
}
async function main() {
if (!process.env.DATABASE_URL) throw new Error("Sätt DATABASE_URL.");
const [email, languageTag, regionCode, currencyCode] = process.argv.slice(2);
if (!email || !languageTag) {
console.error(
"Användning: set-test-locale.ts <email> <languageTag> [regionCode] [currencyCode]",
);
console.error("Exempel: set-test-locale.ts dev@hypbit.com it-IT IT EUR");
process.exit(1);
}
const { db, pool } = createDatabase(process.env.DATABASE_URL);
const [user] = await db
.select({ id: schema.users.id })
.from(schema.users)
.where(eq(schema.users.email, email))
.limit(1);
if (!user) throw new Error(`Hittar ingen användare med e-post "${email}".`);
const patch = {
languageTag,
...(regionCode ? { regionCode } : {}),
...(currencyCode ? { currencyCode } : {}),
};
const [row] = await db
.insert(schema.userLocalePreferences)
.values({ userId: user.id, ...patch })
.onConflictDoUpdate({
target: schema.userLocalePreferences.userId,
set: { ...patch, updatedAt: new Date() },
})
.returning();
console.log(`\n✓ Uppdaterade locale för ${email}:`);
console.log(` language_tag = ${row?.languageTag}`);
console.log(` region_code = ${row?.regionCode}`);
console.log(` currency_code = ${row?.currencyCode} ${row?.currencyCode === "SEK" ? "(SEK-pris VISAS)" : "(receptpris DÖLJS tills riktpriser finns)"}`);
console.log(`\nByt språk i appens profil (eller starta om appen) så cachen rensas och det slår igenom.`);
await pool.end();
}
main().then(
() => process.exit(0),
(e) => {
console.error(e);
process.exit(1);
},
);
+165
View File
@@ -0,0 +1,165 @@
/**
* 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);
},
);