143 lines
4.7 KiB
TypeScript
143 lines
4.7 KiB
TypeScript
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<void> {
|
||
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<string, boolean> = {
|
||
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<string, number>();
|
||
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;
|
||
}
|