336 lines
11 KiB
TypeScript
336 lines
11 KiB
TypeScript
/**
|
||
* Verifieringspipeline för AI-genererade receptkandidater (docs/32 §3).
|
||
*
|
||
* Obligatorisk grind — inget blir verified utan att passera alla steg:
|
||
* 1. Ingrediensmappning mot canonical_ingredients (hård spärr).
|
||
* 2. Allergener HÄRLEDDA ur ingredienserna (aldrig AI-påstådda).
|
||
* 3. Näring BERÄKNAD via nutrition-engine (aldrig AI-siffra).
|
||
* 4. Språk-/kvalitetskontroll (rimliga steg/tider).
|
||
* 5. Dedup mot recipe_similarities.
|
||
*
|
||
* Först då verificationStatus=verified; annars unverified & osynlig.
|
||
*/
|
||
|
||
import {
|
||
computeRecipeNutrition,
|
||
type RecipeIngredientForCalc,
|
||
type IngredientNutritionSource,
|
||
} from "@app/nutrition-engine";
|
||
import { deriveRecipeAllergens, type IngredientSafetyInfo } from "@app/recipe-engine";
|
||
import type { NutritionValues, Allergen } from "@app/shared-types";
|
||
import type { RecipeCandidate, VerificationResult } from "./types.js";
|
||
|
||
export interface CanonicalIngredientLookup {
|
||
getById(id: string):
|
||
| {
|
||
id: string;
|
||
nutritionPer100: import("@app/shared-types").NutritionDeclaration;
|
||
defaultUnit: import("@app/shared-types").Unit;
|
||
densityGPerMl?: number | null;
|
||
gramsPerPiece?: number | null;
|
||
allergens: Allergen[];
|
||
isVegan: boolean;
|
||
isVegetarian: boolean;
|
||
containsGluten: boolean;
|
||
containsLactose: boolean;
|
||
isPork: boolean;
|
||
isBeef: boolean;
|
||
isAlcohol: boolean;
|
||
}
|
||
| undefined;
|
||
}
|
||
|
||
export interface SimilarityLookup {
|
||
hasSimilarity(recipeTitle: string): Promise<boolean>;
|
||
}
|
||
|
||
export interface VerificationOptions {
|
||
/** Max tillåten förberedelsetid (minuter). */
|
||
maxPrepTimeMinutes?: number;
|
||
/** Max tillåten koktid (minuter). */
|
||
maxCookTimeMinutes?: number;
|
||
/** Max total tid (minuter). */
|
||
maxTotalTimeMinutes?: number;
|
||
/** Minsta antal steg. */
|
||
minSteps?: number;
|
||
/** Minsta antal ingredienser. */
|
||
minIngredients?: number;
|
||
/** Om true, krävs att receptet har minst en icke-valfri protein-källa. */
|
||
requireProtein?: boolean;
|
||
}
|
||
|
||
const DEFAULT_OPTIONS: Required<VerificationOptions> = {
|
||
maxPrepTimeMinutes: 120,
|
||
maxCookTimeMinutes: 180,
|
||
maxTotalTimeMinutes: 240,
|
||
minSteps: 2,
|
||
minIngredients: 3,
|
||
requireProtein: false,
|
||
};
|
||
|
||
/** Ingredienser som kräver ett säkerhetssteg med genomstekning/temperatur (spec §61.3). */
|
||
const RAW_PROTEIN_REQUIRING_SAFE_COOKING = new Set([
|
||
// Fågel
|
||
"chicken_breast",
|
||
"chicken_thigh",
|
||
// Fläsk/nöt/köttfärs
|
||
"pork_loin",
|
||
"minced_beef",
|
||
"minced_mixed",
|
||
"meatball_pork_beef",
|
||
"falukorv",
|
||
// Ägg
|
||
"egg",
|
||
// Fisk/skaldjur
|
||
"cod",
|
||
"salmon",
|
||
"shrimp",
|
||
"anchovy_swedish",
|
||
"pickled_herring",
|
||
]);
|
||
|
||
const SAFE_COOKING_KEYWORDS_SV = [
|
||
/\bgenomstekt\b/i,
|
||
/\bgenomkokt\b/i,
|
||
/\bgenomgrillad\b/i,
|
||
/\bgenomv\w+\b/i,
|
||
/\binte längre rosa\b/i,
|
||
/\binte rosa\b/i,
|
||
/\bflagnar\b/i,
|
||
/\bgenomkokt\b/i,
|
||
/\bkärntemperatur\b/i,
|
||
/\binnertemperatur\b/i,
|
||
/\btemperatur\b/i,
|
||
/\b°\s*c\b/i,
|
||
/\bgrader\b/i,
|
||
/\btill(?:s)? den är klar\b/i,
|
||
/\btill(?:s)? köttet släpper vätskan\b/i,
|
||
/\b72\s*c?\b/i,
|
||
/\b74\s*c?\b/i,
|
||
/\b75\s*c?\b/i,
|
||
/\b63\s*c?\b/i,
|
||
/\b65\s*c?\b/i,
|
||
/\b70\s*c?\b/i,
|
||
];
|
||
|
||
/** Fraser som INTE räcker som säkerhetsbevis – de beskriver bara utseende/tid. */
|
||
const UNSAFE_APPEARANCE_ONLY_SV = [
|
||
/\bgyllenbrun\b/i,
|
||
/\bgyllene\b/i,
|
||
/\bkrispig\b/i,
|
||
/\bkrispiga\b/i,
|
||
/\bfint färg\b/i,
|
||
/\bfint färgade\b/i,
|
||
/\bfärgad\b/i,
|
||
/\bfräsch\b/i,
|
||
/\bfräscha\b/i,
|
||
];
|
||
|
||
function requiresSafeCookingStep(ingredientIds: string[]): boolean {
|
||
return ingredientIds.some((id) => RAW_PROTEIN_REQUIRING_SAFE_COOKING.has(id));
|
||
}
|
||
|
||
function hasSafeCookingStep(steps: RecipeCandidate["steps"]): boolean {
|
||
return steps.some((s) => {
|
||
const instruction = s.instructionSv;
|
||
const hasPositive = SAFE_COOKING_KEYWORDS_SV.some((re) => re.test(instruction));
|
||
const onlyAppearance =
|
||
UNSAFE_APPEARANCE_ONLY_SV.some((re) => re.test(instruction)) &&
|
||
!SAFE_COOKING_KEYWORDS_SV.some((re) => re.test(instruction));
|
||
return hasPositive && !onlyAppearance;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Kör en kandidat genom verifieringspipelinen.
|
||
* Returnerar alltid ett VerificationResult; status sätts beroende på om
|
||
* alla grindar passerades.
|
||
*/
|
||
export async function verifyCandidate(
|
||
candidate: RecipeCandidate,
|
||
ingredients: CanonicalIngredientLookup,
|
||
similarity: SimilarityLookup | null,
|
||
options: VerificationOptions = {},
|
||
): Promise<VerificationResult> {
|
||
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||
const reasons: string[] = [];
|
||
|
||
// ── 0. Hård avvisning av inkompletta kandidater ──────────────────────────
|
||
if (!candidate.titleSv || candidate.titleSv.trim().length === 0) {
|
||
reasons.push("Receptet saknar titel.");
|
||
}
|
||
if (!Array.isArray(candidate.ingredients) || candidate.ingredients.length === 0) {
|
||
reasons.push("Receptet saknar ingredienser.");
|
||
}
|
||
if (!Array.isArray(candidate.steps) || candidate.steps.length === 0) {
|
||
reasons.push("Receptet saknar tillagningssteg.");
|
||
}
|
||
if (candidate.portions == null || candidate.portions <= 0) {
|
||
reasons.push("Receptet saknar giltigt antal portioner.");
|
||
}
|
||
if (
|
||
candidate.prepTimeMinutes == null ||
|
||
candidate.cookTimeMinutes == null ||
|
||
candidate.totalTimeMinutes == null
|
||
) {
|
||
reasons.push("Receptet saknar tidsangivelser.");
|
||
}
|
||
|
||
// ── 1. Ingrediensmappning (hård spärr) ───────────────────────────────────
|
||
const canonicalIds: string[] = [];
|
||
const nutritionSources = new Map<string, IngredientNutritionSource>();
|
||
const safetyInfos = new Map<string, IngredientSafetyInfo>();
|
||
|
||
for (const ing of candidate.ingredients) {
|
||
const canonical = ingredients.getById(ing.canonicalIngredientId);
|
||
if (!canonical) {
|
||
reasons.push(
|
||
`Ingrediens "${ing.canonicalIngredientId}" finns inte i canonical_ingredients — hård spärr.`,
|
||
);
|
||
continue;
|
||
}
|
||
canonicalIds.push(ing.canonicalIngredientId);
|
||
|
||
// Bygg nutrition source för steg 3
|
||
nutritionSources.set(ing.canonicalIngredientId, {
|
||
densityGPerMl: canonical.densityGPerMl ?? undefined,
|
||
gramsPerPiece: canonical.gramsPerPiece ?? undefined,
|
||
nutritionPer100: canonical.nutritionPer100,
|
||
});
|
||
|
||
// Bygg safety info för steg 2
|
||
safetyInfos.set(ing.canonicalIngredientId, {
|
||
id: canonical.id,
|
||
allergens: canonical.allergens,
|
||
isVegan: canonical.isVegan,
|
||
isVegetarian: canonical.isVegetarian,
|
||
containsGluten: canonical.containsGluten,
|
||
containsLactose: canonical.containsLactose,
|
||
isPork: canonical.isPork,
|
||
isBeef: canonical.isBeef,
|
||
isAlcohol: canonical.isAlcohol,
|
||
dataVerified: true,
|
||
});
|
||
}
|
||
|
||
// Om någon ingrediens saknas → avvisat direkt
|
||
if (reasons.length > 0) {
|
||
return {
|
||
candidate,
|
||
status: "rejected",
|
||
reasons,
|
||
nutritionPerPortion: null,
|
||
allergens: [],
|
||
canonicalIngredientIds: canonicalIds,
|
||
};
|
||
}
|
||
|
||
// ── 2. Allergener (härledda, aldrig AI) ──────────────────────────────────
|
||
// AI:s eventuella påståenden om allergener ignoreras fullständigt.
|
||
// Säkerheten härleds alltid deterministiskt ur canonical_ingredients.
|
||
const aiClaims = candidate.aiClaimedAllergens ?? [];
|
||
const derivedAllergens = deriveRecipeAllergens(canonicalIds, safetyInfos);
|
||
const allergens = derivedAllergens;
|
||
|
||
// ── 3. Näring (beräknad via nutrition-engine) ────────────────────────────
|
||
const calcIngredients: RecipeIngredientForCalc[] = candidate.ingredients.map((ing) => ({
|
||
canonicalIngredientId: ing.canonicalIngredientId,
|
||
quantity: ing.quantity,
|
||
unit: ing.unit as import("@app/shared-types").Unit,
|
||
optional: ing.optional,
|
||
}));
|
||
|
||
const nutritionResult = computeRecipeNutrition(
|
||
calcIngredients,
|
||
candidate.portions,
|
||
nutritionSources,
|
||
);
|
||
|
||
if (nutritionResult.uncomputableIngredientIds.length > 0) {
|
||
reasons.push(
|
||
`Näring kunde inte beräknas för: ${nutritionResult.uncomputableIngredientIds.join(", ")}`,
|
||
);
|
||
}
|
||
|
||
// ── 4. Språk-/kvalitetskontroll ──────────────────────────────────────────
|
||
if (candidate.prepTimeMinutes > opts.maxPrepTimeMinutes) {
|
||
reasons.push(
|
||
`Förberedelsetid ${candidate.prepTimeMinutes} min överstiger max ${opts.maxPrepTimeMinutes} min.`,
|
||
);
|
||
}
|
||
if (candidate.cookTimeMinutes > opts.maxCookTimeMinutes) {
|
||
reasons.push(
|
||
`Koktid ${candidate.cookTimeMinutes} min överstiger max ${opts.maxCookTimeMinutes} min.`,
|
||
);
|
||
}
|
||
const totalTime = candidate.prepTimeMinutes + candidate.cookTimeMinutes;
|
||
if (totalTime > opts.maxTotalTimeMinutes) {
|
||
reasons.push(`Total tid ${totalTime} min överstiger max ${opts.maxTotalTimeMinutes} min.`);
|
||
}
|
||
if (candidate.steps.length < opts.minSteps) {
|
||
reasons.push(`Endast ${candidate.steps.length} steg — minst ${opts.minSteps} krävs.`);
|
||
}
|
||
if (candidate.ingredients.length < opts.minIngredients) {
|
||
reasons.push(
|
||
`Endast ${candidate.ingredients.length} ingredienser — minst ${opts.minIngredients} krävs.`,
|
||
);
|
||
}
|
||
if (candidate.totalTimeMinutes !== totalTime) {
|
||
reasons.push(
|
||
`totalTimeMinutes (${candidate.totalTimeMinutes}) matchar inte prep+cook (${totalTime}).`,
|
||
);
|
||
}
|
||
|
||
// Kontrollera att stegen är rimliga
|
||
for (const step of candidate.steps) {
|
||
if (!step.instructionSv || step.instructionSv.length < 10) {
|
||
reasons.push(`Steg ${step.stepNumber} har för kort instruktion.`);
|
||
}
|
||
if (step.timerSeconds != null && step.timerSeconds < 0) {
|
||
reasons.push(`Steg ${step.stepNumber} har negativ timer.`);
|
||
}
|
||
if (step.temperatureC != null && (step.temperatureC < 0 || step.temperatureC > 350)) {
|
||
reasons.push(`Steg ${step.stepNumber} har orimlig temperatur (${step.temperatureC}°C).`);
|
||
}
|
||
}
|
||
|
||
// ── 4b. Matsäkerhetslint: rå fågel/fläsk/ägg/fisk kräver genomstekningssteg ──
|
||
if (requiresSafeCookingStep(canonicalIds) && !hasSafeCookingStep(candidate.steps)) {
|
||
reasons.push(
|
||
"Receptet innehåller rått kött/fågel/ägg/fisk men saknar steg för genomstekning/temperatur.",
|
||
);
|
||
}
|
||
|
||
// ── 5. Dedup mot befintliga recept ────────────────────────────────────────
|
||
if (similarity) {
|
||
const isDup = await similarity.hasSimilarity(candidate.titleSv);
|
||
if (isDup) {
|
||
reasons.push(`Titel "${candidate.titleSv}" flaggad som potentiell dubblett.`);
|
||
}
|
||
}
|
||
|
||
// ── Resultat ─────────────────────────────────────────────────────────────
|
||
const status: VerificationResult["status"] = reasons.length === 0 ? "verified" : "unverified";
|
||
|
||
return {
|
||
candidate,
|
||
status,
|
||
reasons,
|
||
nutritionPerPortion: nutritionResult.perPortion,
|
||
allergens,
|
||
canonicalIngredientIds: canonicalIds,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Kör en batch kandidater genom pipelinen.
|
||
*/
|
||
export async function verifyBatch(
|
||
candidates: RecipeCandidate[],
|
||
ingredients: CanonicalIngredientLookup,
|
||
similarity: SimilarityLookup | null,
|
||
options?: VerificationOptions,
|
||
): Promise<VerificationResult[]> {
|
||
return Promise.all(candidates.map((c) => verifyCandidate(c, ingredients, similarity, options)));
|
||
}
|