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,
|
||||
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<Allergen>();
|
||||
for (const ri of recipe.ingredients) {
|
||||
if (ri.optional) continue;
|
||||
for (const a of ingredientMap.get(ri.ing)?.allergens ?? []) allergens.add(a);
|
||||
}
|
||||
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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<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