Files
Cibello-app/packages/recipe-generation/src/verification.ts
T
Sven (AAMOS AI) c32a7e33c7
CI / Typecheck, test & build (push) Failing after 2s
ci: trigga på master + formatfix inför Gitea Actions
2026-08-13 17:25:14 +07:00

336 lines
11 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.
/**
* 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)));
}