import { and, eq } from "drizzle-orm"; import { schema } from "@app/database"; import type { WorkerContext } from "../context.js"; /** * Receptöversättning (i18n-spec §13–14, M3): * TRANSLATE_RECIPE-jobb → AAMOS översätter TEXT → deterministisk verifiering * → sparas som draft_ai → människa publicerar i admin. * * AI kan aldrig ändra mängder, ingredient-IDs, tider eller allergener – * de bor i strukturerade fält utanför översättningen. Verifieringen här * kontrollerar det som ÄNDÅ kan gå fel i text: stegantal, bevarade tal * (temperaturer/mängder inbakade i löptext) och tomma fält. */ export async function processTranslateRecipe( ctx: WorkerContext, data: { recipeId: string; targetLanguageTag: string }, ): Promise { const { recipeId, targetLanguageTag } = data; const [recipe] = await ctx.db .select() .from(schema.recipes) .where(eq(schema.recipes.id, recipeId)) .limit(1); if (!recipe) return; const steps = await ctx.db .select() .from(schema.recipeSteps) .where(eq(schema.recipeSteps.recipeId, recipeId)) .orderBy(schema.recipeSteps.stepNumber); const input = { sourceLanguageTag: "sv", targetLanguageTag, title: recipe.titleSv, description: recipe.descriptionSv ?? null, storageGuidance: recipe.storageGuidanceSv ?? null, steps: steps.map((s) => ({ stepNumber: s.stepNumber, instruction: s.instructionSv, tip: s.tip ?? null, })), }; const result = await ctx.aamos.runTask("TRANSLATE_RECIPE", input); if (result.status !== "ok" || !result.output) { throw new Error(`TRANSLATE_RECIPE misslyckades: ${result.status}`); } const out = result.output; // --- Deterministisk verifiering (spec §61.1: AI:s svar litas aldrig på rakt av) --- const checks: Record = { stepCountMatches: out.steps.length === input.steps.length, stepNumbersMatch: out.steps.every((s, i) => s.stepNumber === input.steps[i]?.stepNumber), titleNonEmpty: out.title.trim().length > 0, numbersPreserved: numbersPreserved( [input.title, input.description ?? "", ...input.steps.map((s) => s.instruction)], [out.title, out.description ?? "", ...out.steps.map((s) => s.instruction)], ), confidenceAcceptable: out.confidence >= 0.5, }; const ok = Object.values(checks).every(Boolean); const notes = Object.entries(checks) .filter(([, v]) => !v) .map(([k]) => `Verifiering föll: ${k}`); // --- Spara utkast (upsert per språk) --- const [existing] = await ctx.db .select({ id: schema.recipeTranslations.id }) .from(schema.recipeTranslations) .where( and( eq(schema.recipeTranslations.recipeId, recipeId), eq(schema.recipeTranslations.languageTag, targetLanguageTag), ), ) .limit(1); const row = { title: out.title, description: out.description, storageGuidance: out.storageGuidance, status: "draft_ai" as const, source: "ai" as const, verification: { ok, checks, ...(notes.length ? { notes } : {}) }, updatedAt: new Date(), }; if (existing) { await ctx.db .update(schema.recipeTranslations) .set(row) .where(eq(schema.recipeTranslations.id, existing.id)); } else { await ctx.db .insert(schema.recipeTranslations) .values({ recipeId, languageTag: targetLanguageTag, ...row }); } // Stegtexter: ersätt hela uppsättningen för språket (idempotent). await ctx.db .delete(schema.recipeStepTranslations) .where( and( eq(schema.recipeStepTranslations.recipeId, recipeId), eq(schema.recipeStepTranslations.languageTag, targetLanguageTag), ), ); if (out.steps.length > 0) { await ctx.db.insert(schema.recipeStepTranslations).values( out.steps.map((s) => ({ recipeId, languageTag: targetLanguageTag, stepNumber: s.stepNumber, instruction: s.instruction, tip: s.tip, })), ); } } /** * Alla tal i källtexten ska finnas kvar i måltexten (multiset-jämförelse). * Fångar när en modell "översätter om" 225°C till 437°F eller tappar en mängd – * enhetskonvertering är visningslagrets jobb, aldrig översättningens. */ export function numbersPreserved(sourceTexts: string[], targetTexts: string[]): boolean { const extract = (texts: string[]) => { const counts = new Map(); for (const m of texts.join(" ").matchAll(/\d+(?:[.,]\d+)?/g)) { const key = m[0].replace(",", "."); counts.set(key, (counts.get(key) ?? 0) + 1); } return counts; }; const src = extract(sourceTexts); const tgt = extract(targetTexts); for (const [num, count] of src) { if ((tgt.get(num) ?? 0) < count) return false; } return true; }