diff --git a/packages/database/src/seed/run.ts b/packages/database/src/seed/run.ts index 9d385fd..8a5f245 100644 --- a/packages/database/src/seed/run.ts +++ b/packages/database/src/seed/run.ts @@ -79,7 +79,25 @@ async function main() { target: schema.canonicalIngredients.id, set: { nameSv: ing.nameSv, + nameEn: ing.nameEn, + aliases: ing.aliases, + category: ing.category, + defaultUnit: ing.defaultUnit, + densityGPerMl: ing.densityGPerMl ?? null, + gramsPerPiece: ing.gramsPerPiece ?? null, + allergens: ing.allergens, + isVegan: ing.isVegan, + isVegetarian: ing.isVegetarian, + containsGluten: ing.containsGluten, + containsLactose: ing.containsLactose, + isPork: ing.isPork, + isBeef: ing.isBeef, + isAlcohol: ing.isAlcohol, nutritionPer100: ing.nutritionPer100, + nutritionProvenance: ing.nutritionProvenance, + peakSeasons: ing.peakSeasons, + shelfLifeGuidance: ing.shelfLifeGuidance ?? null, + defaultPriceMinorPerKg: ing.defaultPriceMinorPerKg ?? null, updatedAt: new Date(), }, }); @@ -646,10 +664,10 @@ async function insertRecipe( ); } - // Deterministisk allergenhärledning + // Deterministisk allergenhärledning — ALLA ingredienser räknas, + // även valfria (garnering/servering kan innehålla allergener). const allergens = new Set(); for (const ri of recipe.ingredients) { - if (ri.optional) continue; for (const a of ingredientMap.get(ri.ing)?.allergens ?? []) allergens.add(a); } diff --git a/packages/recipe-generation/package.json b/packages/recipe-generation/package.json index e66eef9..6cf2af7 100644 --- a/packages/recipe-generation/package.json +++ b/packages/recipe-generation/package.json @@ -13,7 +13,7 @@ }, "scripts": { "typecheck": "tsc --noEmit", - "test": "vitest run" + "test": "pnpm --filter=@app/database run db:test-setup && vitest run" }, "dependencies": { "@app/ai-contracts": "workspace:*", diff --git a/packages/recipe-generation/src/verification.ts b/packages/recipe-generation/src/verification.ts index 07501cb..ae7884e 100644 --- a/packages/recipe-generation/src/verification.ts +++ b/packages/recipe-generation/src/verification.ts @@ -157,6 +157,23 @@ export async function verifyCandidate( 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(); diff --git a/packages/recipe-generation/test/allergen-invariant.test.ts b/packages/recipe-generation/test/allergen-invariant.test.ts new file mode 100644 index 0000000..9cbfcdf --- /dev/null +++ b/packages/recipe-generation/test/allergen-invariant.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, afterAll } from "vitest"; +import { config as loadDotenv } from "dotenv"; +import path from "node:path"; +import { createDatabase, closeDatabase } from "@app/database"; +import { SEED_RECIPES } from "@app/database/seed"; +import { verifyCandidate } from "../src/verification.js"; + +// Ladda monorepo-root .env (samma mönster som @app/database seed/tests) +loadDotenv({ path: path.resolve(process.cwd(), "../../.env") }); + +const { db } = createDatabase(); + +async function buildLookup() { + const allIngs = await db.query.canonicalIngredients.findMany(); + return { + getById(id: string) { + return allIngs.find((i) => i.id === id) ?? undefined; + }, + }; +} + +afterAll(async () => { + await closeDatabase(); +}); + +describe("allergen invariant", () => { + it("varje seedat recept har allergens som matchar färsk derivering från canonical_ingredients", async () => { + const lookup = await buildLookup(); + const seedTitles = new Set(SEED_RECIPES.map((r) => r.titleSv)); + const recipes = (await db.query.recipes.findMany()).filter((r) => seedTitles.has(r.titleSv)); + + const mismatches: string[] = []; + + for (const recipe of recipes) { + const ingredients = await db.query.recipeIngredients.findMany({ + where: (t, { eq }) => eq(t.recipeId, recipe.id), + }); + const steps = await db.query.recipeSteps.findMany({ + where: (t, { eq }) => eq(t.recipeId, recipe.id), + }); + + if (ingredients.length === 0 || steps.length === 0) { + // Inkompletta recept får aldrig lagras som verified i produktion. + continue; + } + + const candidate = { + titleSv: recipe.titleSv, + descriptionSv: recipe.descriptionSv, + cuisine: recipe.cuisine, + mealTypes: recipe.mealTypes, + prepTimeMinutes: recipe.prepTimeMinutes, + cookTimeMinutes: recipe.cookTimeMinutes, + totalTimeMinutes: recipe.totalTimeMinutes, + portions: recipe.portions, + spiceLevel: recipe.spiceLevel, + ingredients: ingredients.map((i) => ({ + canonicalIngredientId: i.canonicalIngredientId, + displayNameSv: i.displayNameSv, + quantity: i.quantity, + unit: i.unit, + optional: i.optional, + note: i.note, + })), + steps: steps.map((s) => ({ + stepNumber: s.stepNumber, + instructionSv: s.instructionSv, + timerSeconds: s.timerSeconds, + temperatureC: s.temperatureC, + tip: s.tip, + })), + storageGuidanceSv: recipe.storageGuidanceSv, + mealPrepFriendly: recipe.mealPrepFriendly, + freezerFriendly: recipe.freezerFriendly, + sourceType: recipe.sourceType, + confidence: 1, + }; + + const result = await verifyCandidate(candidate as any, lookup, null); + const derived = [...result.allergens].sort(); + const stored = [...(recipe.allergens ?? [])].sort(); + + if (JSON.stringify(derived) !== JSON.stringify(stored)) { + mismatches.push(`${recipe.titleSv}: stored=${JSON.stringify(stored)} derived=${JSON.stringify(derived)}`); + } + } + + if (mismatches.length > 0) { + console.error("Allergen mismatches:\n" + mismatches.join("\n")); + } + expect(mismatches).toEqual([]); + }); +});