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:
Sven (AAMOS AI)
2026-08-10 01:52:57 +07:00
parent 9c5adaa921
commit b40e1f9d90
4 changed files with 131 additions and 3 deletions
+1 -1
View File
@@ -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([]);
});
});