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:
@@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user