f4a603e977
feat(recommendation-engine,api): S4 Smak/Hälsa/Lager-vyer för 'Vad ska vi äta?' - Ersätter hårdkodat 'Cibello' i scale-batch, scale-smoke-test och export-verified. - DB-backfill: 224 recept hade 'Cibello AI' i creator_display_name (värdena motsvarar nuvarande BRAND.name, ingen rad ändrades men kontrollen är gjord). - Lägger till view-query-param (default|taste|health|pantry) med fördefinierade ScoringWeights och samtyckesgrind. - Unit-tester för vyer; integrationstester för vy-param, validering och fallback utan personalization-samtycke. - brand-guard grön; pnpm typecheck 19/19; pnpm test --force x2 grönt (34 tasks, 275 tester).
233 lines
7.4 KiB
TypeScript
233 lines
7.4 KiB
TypeScript
import { createAamosClient } from "@app/ai-contracts";
|
|
import { SEED_INGREDIENTS } from "@app/database/seed";
|
|
import { createDatabase, closeDatabase, schema } from "@app/database";
|
|
import { runPipeline, type PipelineTarget, type PipelineIngredient } from "@app/recipe-generation";
|
|
import type {
|
|
CanonicalIngredientLookup,
|
|
SimilarityLookup,
|
|
RecipeCandidate,
|
|
} from "@app/recipe-generation";
|
|
import { BRAND } from "@app/shared-types";
|
|
import { randomUUID } from "node:crypto";
|
|
|
|
const VALID_CUISINES = new Set([
|
|
"swedish",
|
|
"nordic",
|
|
"italian",
|
|
"french",
|
|
"spanish",
|
|
"greek",
|
|
"thai",
|
|
"chinese",
|
|
"japanese",
|
|
"korean",
|
|
"vietnamese",
|
|
"indian",
|
|
"mexican",
|
|
"american",
|
|
"turkish",
|
|
"lebanese",
|
|
"moroccan",
|
|
"middle_eastern",
|
|
"international",
|
|
]);
|
|
|
|
function normalizeCuisine(raw: string | undefined): string {
|
|
const c = (raw ?? "swedish").toLowerCase().replace(/[^a-z]/g, "_");
|
|
if (VALID_CUISINES.has(c)) return c;
|
|
if (c === "asian") return "international";
|
|
if (c.includes("swedish") || c.includes("nordic")) return "swedish";
|
|
if (c.includes("italian")) return "italian";
|
|
if (c.includes("asian")) return "international";
|
|
if (c.includes("japan")) return "japanese";
|
|
if (c.includes("china")) return "chinese";
|
|
if (c.includes("thai")) return "thai";
|
|
if (c.includes("indian")) return "indian";
|
|
if (c.includes("mexican")) return "mexican";
|
|
if (c.includes("american")) return "american";
|
|
if (c.includes("mediterranean")) return "greek";
|
|
return "international";
|
|
}
|
|
|
|
const catalog: PipelineIngredient[] = SEED_INGREDIENTS.map((i) => ({
|
|
id: i.id,
|
|
nameSv: i.nameSv,
|
|
category: i.category,
|
|
defaultUnit: i.defaultUnit,
|
|
isVegan: i.isVegan,
|
|
isVegetarian: i.isVegetarian,
|
|
containsGluten: i.containsGluten,
|
|
containsLactose: i.containsLactose,
|
|
allergens: i.allergens,
|
|
}));
|
|
|
|
const ingredientLookup: CanonicalIngredientLookup = {
|
|
getById(id: string) {
|
|
const ing = SEED_INGREDIENTS.find((i) => i.id === id);
|
|
if (!ing) return undefined;
|
|
return {
|
|
id: ing.id,
|
|
nutritionPer100: ing.nutritionPer100,
|
|
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,
|
|
};
|
|
},
|
|
};
|
|
|
|
const targets: PipelineTarget[] = [
|
|
{ mealType: "dinner", mainIngredientId: "chicken_breast", dietVariant: "standard", count: 2 },
|
|
{ mealType: "dinner", mainIngredientId: "minced_beef", dietVariant: "standard", count: 2 },
|
|
{ mealType: "dinner", mainIngredientId: "tofu", dietVariant: "vegan", count: 1 },
|
|
];
|
|
|
|
function inferDietTags(candidate: RecipeCandidate): string[] {
|
|
const ings = candidate.ingredients
|
|
.map((i) => ingredientLookup.getById(i.canonicalIngredientId))
|
|
.filter(Boolean);
|
|
const tags: string[] = [];
|
|
const allVegan = ings.every((i) => i?.isVegan);
|
|
const allVegetarian = ings.every((i) => i?.isVegetarian);
|
|
const noGluten = ings.every((i) => !i?.containsGluten);
|
|
const noLactose = ings.every((i) => !i?.containsLactose);
|
|
if (allVegan) tags.push("vegan");
|
|
else if (allVegetarian) tags.push("vegetarian");
|
|
if (noGluten) tags.push("gluten_free");
|
|
if (noLactose) tags.push("lactose_free");
|
|
return tags;
|
|
}
|
|
|
|
async function main() {
|
|
const dbUrl = process.env.DATABASE_URL;
|
|
if (!dbUrl) throw new Error("DATABASE_URL saknas");
|
|
const { db } = createDatabase(dbUrl);
|
|
const client = createAamosClient(process.env);
|
|
|
|
const existing = await db.query.recipes.findMany({ columns: { titleSv: true, slug: true } });
|
|
const knownTitles = new Set(
|
|
existing.map((r) => r.titleSv.toLowerCase().replace(/[^a-z0-9åäö]/g, " ")),
|
|
);
|
|
const knownSlugs = new Set(existing.map((r) => r.slug));
|
|
|
|
const similarityLookup: SimilarityLookup = {
|
|
async hasSimilarity(title: string) {
|
|
return knownTitles.has(title.toLowerCase().replace(/[^a-z0-9åäö]/g, " "));
|
|
},
|
|
};
|
|
|
|
console.error("[scale-smoke-test] starting");
|
|
const result = await runPipeline(client, targets, catalog, ingredientLookup, similarityLookup, {
|
|
maxPrepTimeMinutes: 30,
|
|
maxCookTimeMinutes: 45,
|
|
portions: 4,
|
|
});
|
|
|
|
console.error(
|
|
`[scale-smoke-test] generated=${result.candidates.length} verified=${result.verifiedCount} unverified=${result.unverifiedCount} rejected=${result.rejectedCount}`,
|
|
);
|
|
|
|
let seeded = 0;
|
|
for (let i = 0; i < result.candidates.length; i++) {
|
|
const candidate = result.candidates[i]!;
|
|
const v = result.verificationResults[i]!;
|
|
if (v.status !== "verified") continue;
|
|
|
|
const slugBase = candidate.titleSv
|
|
.toLowerCase()
|
|
.normalize("NFD")
|
|
.replace(/[\u0300-\u036f]/g, "")
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-+|-+$/g, "");
|
|
let slug = slugBase;
|
|
if (knownSlugs.has(slug)) slug = `${slug}-${randomUUID().slice(0, 8)}`;
|
|
knownSlugs.add(slug);
|
|
|
|
const nutrition = v.nutritionPerPortion ?? {
|
|
kcal: 0,
|
|
proteinG: 0,
|
|
carbsG: 0,
|
|
fatG: 0,
|
|
saturatedFatG: 0,
|
|
fiberG: 0,
|
|
sugarG: 0,
|
|
saltG: 0,
|
|
};
|
|
|
|
await db.insert(schema.recipes).values({
|
|
id: randomUUID(),
|
|
slug,
|
|
titleSv: candidate.titleSv,
|
|
descriptionSv: candidate.descriptionSv,
|
|
cuisine: normalizeCuisine(candidate.cuisine),
|
|
mealTypes: candidate.mealTypes,
|
|
tags: [],
|
|
methods: [],
|
|
equipment: [],
|
|
prepTimeMinutes: candidate.prepTimeMinutes,
|
|
cookTimeMinutes: candidate.cookTimeMinutes,
|
|
totalTimeMinutes: candidate.totalTimeMinutes,
|
|
portions: candidate.portions,
|
|
nutritionPerPortion: nutrition,
|
|
allergens: v.allergens,
|
|
spiceLevel: candidate.spiceLevel,
|
|
storageGuidanceSv: candidate.storageGuidanceSv ?? null,
|
|
mealPrepFriendly: candidate.mealPrepFriendly,
|
|
freezerFriendly: candidate.freezerFriendly,
|
|
dna: {
|
|
cuisine: normalizeCuisine(candidate.cuisine),
|
|
protein: candidate.ingredients.find((i) =>
|
|
[
|
|
"chicken_breast",
|
|
"chicken_thigh",
|
|
"minced_beef",
|
|
"minced_mixed",
|
|
"salmon",
|
|
"cod",
|
|
"shrimp",
|
|
"tofu",
|
|
"red_lentils",
|
|
].includes(i.canonicalIngredientId),
|
|
)?.canonicalIngredientId,
|
|
carbohydrate: candidate.ingredients.find((i) =>
|
|
["rice_white", "pasta_dry", "potato"].includes(i.canonicalIngredientId),
|
|
)?.canonicalIngredientId,
|
|
vegetables: candidate.ingredients
|
|
.filter((i) =>
|
|
["tomato", "zucchini", "paprika", "carrot", "spinach", "onion", "garlic"].includes(
|
|
i.canonicalIngredientId,
|
|
),
|
|
)
|
|
.map((i) => i.canonicalIngredientId),
|
|
flavorProfile: inferDietTags(candidate),
|
|
spiceLevel: candidate.spiceLevel,
|
|
method: "stovetop",
|
|
timeMinutes: candidate.totalTimeMinutes,
|
|
calories: Math.round(nutrition.kcal),
|
|
proteinGrams: Math.round(nutrition.proteinG),
|
|
},
|
|
variantType: "standard",
|
|
status: "draft",
|
|
verificationStatus: "verified",
|
|
sourceType: "ai_assisted_reviewed",
|
|
creatorDisplayName: `${BRAND.name} AI Smoke`,
|
|
});
|
|
seeded++;
|
|
}
|
|
|
|
console.error(`[scale-smoke-test] seeded=${seeded}`);
|
|
await closeDatabase();
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error(err);
|
|
process.exit(1);
|
|
});
|