Files
Cibello-app/apps/worker/src/processors/translation.ts
T
2026-08-05 19:21:11 +07:00

143 lines
4.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { and, eq } from "drizzle-orm";
import { schema } from "@app/database";
import type { WorkerContext } from "../context.js";
/**
* Receptöversättning (i18n-spec §1314, 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;
}