fix(recipe-generation, database): allergen-blocker åtgärdad
- seed/run.ts uppdaterar nu ALLA canonical-ingrediensfält vid re-seed, inklusive allergens (tidigare uppdaterades bara nameSv/nutritionPer100). - seed/run.ts härleder allergener för seed-recept från ALLA ingredienser, inte bara obligatoriska. - verification.ts hård-avvisar inkompletta kandidater (saknar titel, ingredienser, steg, portioner eller tider) som rejected. - Lägger allergen-invariant-test i @app/recipe-generation som verifierar att seed-receptens lagrade allergens matchar färsk derivering. - Allergener omräknade för alla 246 recept; 0 mismatch återstår. - Raderade 4 trasiga recept + 1 ordningsdubblett tidigare.
This commit is contained in:
@@ -79,7 +79,25 @@ async function main() {
|
|||||||
target: schema.canonicalIngredients.id,
|
target: schema.canonicalIngredients.id,
|
||||||
set: {
|
set: {
|
||||||
nameSv: ing.nameSv,
|
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,
|
nutritionPer100: ing.nutritionPer100,
|
||||||
|
nutritionProvenance: ing.nutritionProvenance,
|
||||||
|
peakSeasons: ing.peakSeasons,
|
||||||
|
shelfLifeGuidance: ing.shelfLifeGuidance ?? null,
|
||||||
|
defaultPriceMinorPerKg: ing.defaultPriceMinorPerKg ?? null,
|
||||||
updatedAt: new Date(),
|
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<Allergen>();
|
const allergens = new Set<Allergen>();
|
||||||
for (const ri of recipe.ingredients) {
|
for (const ri of recipe.ingredients) {
|
||||||
if (ri.optional) continue;
|
|
||||||
for (const a of ingredientMap.get(ri.ing)?.allergens ?? []) allergens.add(a);
|
for (const a of ingredientMap.get(ri.ing)?.allergens ?? []) allergens.add(a);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run"
|
"test": "pnpm --filter=@app/database run db:test-setup && vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@app/ai-contracts": "workspace:*",
|
"@app/ai-contracts": "workspace:*",
|
||||||
|
|||||||
@@ -157,6 +157,23 @@ export async function verifyCandidate(
|
|||||||
const opts = { ...DEFAULT_OPTIONS, ...options };
|
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||||
const reasons: string[] = [];
|
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) ───────────────────────────────────
|
// ── 1. Ingrediensmappning (hård spärr) ───────────────────────────────────
|
||||||
const canonicalIds: string[] = [];
|
const canonicalIds: string[] = [];
|
||||||
const nutritionSources = new Map<string, IngredientNutritionSource>();
|
const nutritionSources = new Map<string, IngredientNutritionSource>();
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user