feat: retry-failed-translations + kok-läget på rätt språk (titel, ingredienser, valfritt)
- retry-failed-translations.ts: kör om de receptöversättningar som föll på verifieringen (skriver ut vilka + varför, köar TRANSLATE_RECIPE på nytt). Idempotent; kör tills listan är tom, publicera sedan. - Kok-läget (cooking/[id].tsx) visade svensk titel + svenska ingrediensnamn fast detalj-API:t redan levererar översatta fält. Nu title/displayName med svensk fallback (stegen var redan översatta). - "(valfritt)" var hårdkodat i kok-läget + recept-detaljen -> ny nyckel recipe.optional i alla 12 språk. Vakt + typecheck gröna. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,7 @@ import { formatQuantity } from "@/lib/units";
|
||||
interface RecipeForCooking {
|
||||
id: string;
|
||||
titleSv: string;
|
||||
title?: string;
|
||||
portions: number;
|
||||
steps: Array<{
|
||||
id: string;
|
||||
@@ -42,6 +43,7 @@ interface RecipeForCooking {
|
||||
ingredients: Array<{
|
||||
id: string;
|
||||
displayNameSv: string;
|
||||
displayName?: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
optional: boolean;
|
||||
@@ -110,7 +112,7 @@ export default function CookingScreen() {
|
||||
<Screen>
|
||||
<Card>
|
||||
<Heading>✅ {t("cooked.title")}</Heading>
|
||||
<Body>{recipe.titleSv}</Body>
|
||||
<Body>{recipe.title ?? recipe.titleSv}</Body>
|
||||
<Small>
|
||||
{t("cooked.portionsCooked")}: {portionsCooked}
|
||||
</Small>
|
||||
@@ -140,7 +142,7 @@ export default function CookingScreen() {
|
||||
<Screen>
|
||||
<Heading>{t("cooked.title")}</Heading>
|
||||
<Card>
|
||||
<Body>{recipe.titleSv}</Body>
|
||||
<Body>{recipe.title ?? recipe.titleSv}</Body>
|
||||
<Small>
|
||||
{t("cooked.portionsCooked")}: {portionsCooked}
|
||||
</Small>
|
||||
@@ -242,7 +244,7 @@ export default function CookingScreen() {
|
||||
<Screen scroll={false} style={{ justifyContent: "space-between" }}>
|
||||
<View style={{ gap: spacing.md }}>
|
||||
<Button label={t("common.cancel")} variant="ghost" onPress={() => router.dismissAll()} />
|
||||
<Small>{recipe.titleSv}</Small>
|
||||
<Small>{recipe.title ?? recipe.titleSv}</Small>
|
||||
<Heading>
|
||||
{t("cooking.step", { current: stepIndex + 1, total: recipe.steps.length })}
|
||||
</Heading>
|
||||
@@ -275,8 +277,8 @@ export default function CookingScreen() {
|
||||
return (
|
||||
<Row key={ing.id} style={{ justifyContent: "space-between" }}>
|
||||
<Body>
|
||||
{ing.displayNameSv}
|
||||
{ing.optional ? " (valfritt)" : ""}
|
||||
{ing.displayName ?? ing.displayNameSv}
|
||||
{ing.optional ? ` ${t("recipe.optional")}` : ""}
|
||||
</Body>
|
||||
<Small>{formatQuantity(scaledQty, ing.unit)}</Small>
|
||||
</Row>
|
||||
|
||||
@@ -316,7 +316,7 @@ export default function RecipeScreen() {
|
||||
>
|
||||
<Body>
|
||||
{ing.displayName ?? ing.displayNameSv}
|
||||
{ing.optional ? " (valfritt)" : ""}
|
||||
{ing.optional ? ` ${t("recipe.optional")}` : ""}
|
||||
</Body>
|
||||
<Small>{formatQuantity(ing.quantity, ing.unit)}</Small>
|
||||
</Row>
|
||||
|
||||
@@ -345,6 +345,7 @@
|
||||
"recipe.coverage.addedBody": "Tilføjet: {count}",
|
||||
"recipe.iCookedThis": "Jeg har lavet denne",
|
||||
"recipe.ingredients": "Ingredienser",
|
||||
"recipe.optional": "(valgfri)",
|
||||
"recipe.notSafe": "Passer ikke til dine kostindstillinger",
|
||||
"recipe.perPortion": "pr. portion",
|
||||
"recipe.portions_one": "1 portion",
|
||||
|
||||
@@ -345,6 +345,7 @@
|
||||
"recipe.coverage.addedBody": "Hinzugefügt: {count}",
|
||||
"recipe.iCookedThis": "Habe ich gekocht",
|
||||
"recipe.ingredients": "Zutaten",
|
||||
"recipe.optional": "(optional)",
|
||||
"recipe.notSafe": "Passt nicht zu deinen Ernährungseinstellungen",
|
||||
"recipe.perPortion": "pro Portion",
|
||||
"recipe.portions_one": "1 Portion",
|
||||
|
||||
@@ -345,6 +345,7 @@
|
||||
"recipe.coverage.addedBody": "Added: {count}",
|
||||
"recipe.iCookedThis": "I cooked this",
|
||||
"recipe.ingredients": "Ingredients",
|
||||
"recipe.optional": "(optional)",
|
||||
"recipe.notSafe": "Doesn't match your dietary settings",
|
||||
"recipe.perPortion": "per serving",
|
||||
"recipe.portions_one": "1 serving",
|
||||
|
||||
@@ -345,6 +345,7 @@
|
||||
"recipe.coverage.addedBody": "Añadidos: {count}",
|
||||
"recipe.iCookedThis": "Lo he cocinado",
|
||||
"recipe.ingredients": "Ingredientes",
|
||||
"recipe.optional": "(opcional)",
|
||||
"recipe.notSafe": "No encaja con tu configuración dietética",
|
||||
"recipe.perPortion": "por ración",
|
||||
"recipe.portions_one": "1 ración",
|
||||
|
||||
@@ -345,6 +345,7 @@
|
||||
"recipe.coverage.addedBody": "Lisätty: {count}",
|
||||
"recipe.iCookedThis": "Tein tämän",
|
||||
"recipe.ingredients": "Ainekset",
|
||||
"recipe.optional": "(valinnainen)",
|
||||
"recipe.notSafe": "Ei sovi ruokavalioasetuksiisi",
|
||||
"recipe.perPortion": "per annos",
|
||||
"recipe.portions_one": "1 annos",
|
||||
|
||||
@@ -345,6 +345,7 @@
|
||||
"recipe.coverage.addedBody": "Ajoutés : {count}",
|
||||
"recipe.iCookedThis": "Je l'ai cuisiné",
|
||||
"recipe.ingredients": "Ingrédients",
|
||||
"recipe.optional": "(facultatif)",
|
||||
"recipe.notSafe": "Incompatible avec vos réglages alimentaires",
|
||||
"recipe.perPortion": "par portion",
|
||||
"recipe.portions_one": "1 portion",
|
||||
|
||||
@@ -345,6 +345,7 @@
|
||||
"recipe.coverage.addedBody": "Aggiunti: {count}",
|
||||
"recipe.iCookedThis": "L'ho cucinato",
|
||||
"recipe.ingredients": "Ingredienti",
|
||||
"recipe.optional": "(facoltativo)",
|
||||
"recipe.notSafe": "Non adatto alle tue impostazioni alimentari",
|
||||
"recipe.perPortion": "a porzione",
|
||||
"recipe.portions_one": "1 porzione",
|
||||
|
||||
@@ -345,6 +345,7 @@
|
||||
"recipe.coverage.addedBody": "Lagt til: {count}",
|
||||
"recipe.iCookedThis": "Jeg har laget denne",
|
||||
"recipe.ingredients": "Ingredienser",
|
||||
"recipe.optional": "(valgfritt)",
|
||||
"recipe.notSafe": "Passer ikke kostinnstillingene dine",
|
||||
"recipe.perPortion": "per porsjon",
|
||||
"recipe.portions_one": "1 porsjon",
|
||||
|
||||
@@ -345,6 +345,7 @@
|
||||
"recipe.coverage.addedBody": "Toegevoegd: {count}",
|
||||
"recipe.iCookedThis": "Dit heb ik gekookt",
|
||||
"recipe.ingredients": "Ingrediënten",
|
||||
"recipe.optional": "(optioneel)",
|
||||
"recipe.notSafe": "Past niet bij je voedingsinstellingen",
|
||||
"recipe.perPortion": "per portie",
|
||||
"recipe.portions_one": "1 portie",
|
||||
|
||||
@@ -353,6 +353,7 @@
|
||||
"recipe.coverage.addedBody": "Dodano: {count}",
|
||||
"recipe.iCookedThis": "Ugotowałem/-am to",
|
||||
"recipe.ingredients": "Składniki",
|
||||
"recipe.optional": "(opcjonalne)",
|
||||
"recipe.notSafe": "Nie pasuje do Twoich ustawień żywieniowych",
|
||||
"recipe.perPortion": "na porcję",
|
||||
"recipe.portions_few": "{count} porcje",
|
||||
|
||||
@@ -345,6 +345,7 @@
|
||||
"recipe.coverage.addedBody": "Adicionados: {count}",
|
||||
"recipe.iCookedThis": "Cozinhei isto",
|
||||
"recipe.ingredients": "Ingredientes",
|
||||
"recipe.optional": "(opcional)",
|
||||
"recipe.notSafe": "Não se adequa às suas definições alimentares",
|
||||
"recipe.perPortion": "por dose",
|
||||
"recipe.portions_one": "1 dose",
|
||||
|
||||
@@ -353,6 +353,7 @@
|
||||
"recipe.coverage.addedBody": "Tillagda: {count}",
|
||||
"recipe.iCookedThis": "Jag lagade detta",
|
||||
"recipe.ingredients": "Ingredienser",
|
||||
"recipe.optional": "(valfritt)",
|
||||
"recipe.notSafe": "Passar inte dina kostinställningar",
|
||||
"recipe.perPortion": "per portion",
|
||||
"recipe.portions_one": "1 portion",
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Kör om de receptöversättningar som föll på den deterministiska verifieringen
|
||||
* (verification.ok = false) och därför ligger kvar som svenska. Skriver ut vilka
|
||||
* + varför, och köar TRANSLATE_RECIPE på nytt. Workern översätter om (upsert med
|
||||
* ny verifiering) → kör sedan publish-translations.ts all för att publicera de
|
||||
* som nu klarar. Idempotent: kör om tills listan är tom.
|
||||
*
|
||||
* pnpm --filter @app/worker exec tsx scripts/retry-failed-translations.ts # kör om
|
||||
* pnpm --filter @app/worker exec tsx scripts/retry-failed-translations.ts --dry-run # visa bara
|
||||
*
|
||||
* Fönster C (workern) måste vara igång med riktig AI-nyckel.
|
||||
* Kräver DATABASE_URL + REDIS_URL (laddas från .env).
|
||||
*/
|
||||
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 { and, eq, sql } from "drizzle-orm";
|
||||
import { createDatabase, schema } from "@app/database";
|
||||
import { JOB_QUEUE_NAME } from "@app/shared-types";
|
||||
|
||||
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 dryRun = process.argv.includes("--dry-run");
|
||||
const { db, pool } = createDatabase(process.env.DATABASE_URL);
|
||||
|
||||
const failed = await db
|
||||
.select({
|
||||
recipeId: schema.recipeTranslations.recipeId,
|
||||
lang: schema.recipeTranslations.languageTag,
|
||||
title: schema.recipes.titleSv,
|
||||
verification: schema.recipeTranslations.verification,
|
||||
})
|
||||
.from(schema.recipeTranslations)
|
||||
.innerJoin(schema.recipes, eq(schema.recipes.id, schema.recipeTranslations.recipeId))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.recipeTranslations.status, "draft_ai"),
|
||||
sql`${schema.recipeTranslations.verification}->>'ok' = 'false'`,
|
||||
),
|
||||
)
|
||||
.orderBy(schema.recipes.titleSv);
|
||||
|
||||
if (failed.length === 0) {
|
||||
console.log("✓ Inga misslyckade översättningar – allt är verifierat och klart.");
|
||||
await pool.end();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`${failed.length} översättning(ar) föll på verifieringen:\n`);
|
||||
for (const f of failed) {
|
||||
const notes = f.verification?.notes?.length ? f.verification.notes.join("; ") : "okänd orsak";
|
||||
console.log(` [${f.lang}] ${f.title}`);
|
||||
console.log(` → ${notes}`);
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
console.log("\n(dry-run: köade inget. Kör utan --dry-run för att översätta om.)");
|
||||
await pool.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const connection = new IORedis(process.env.REDIS_URL ?? "redis://localhost:6379", {
|
||||
maxRetriesPerRequest: null,
|
||||
});
|
||||
const queue = new Queue(JOB_QUEUE_NAME, { connection });
|
||||
await queue.addBulk(
|
||||
failed.map((f) => ({
|
||||
name: "TRANSLATE_RECIPE",
|
||||
data: { jobType: "TRANSLATE_RECIPE", recipeId: f.recipeId, targetLanguageTag: f.lang },
|
||||
})) as never,
|
||||
);
|
||||
console.log(`\nKöade ${failed.length} omförsök. Workern (Fönster C) översätter om nu.`);
|
||||
console.log(
|
||||
"När kön tömts: pnpm --filter @app/worker exec tsx scripts/publish-translations.ts all",
|
||||
);
|
||||
console.log("Kör det här scriptet igen för att se om något fortfarande faller (då kan det vara receptet självt som behöver en manuell titt).");
|
||||
await queue.close();
|
||||
await connection.quit();
|
||||
await pool.end();
|
||||
}
|
||||
main().then(
|
||||
() => process.exit(0),
|
||||
(e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user