From f4a603e97727e4eec2b8e3c7b04c0fdb7472dc92 Mon Sep 17 00:00:00 2001 From: "Sven (AAMOS AI)" Date: Mon, 10 Aug 2026 05:17:43 +0700 Subject: [PATCH] =?UTF-8?q?fix(recipe-generation):=20BRAND.name-interpolat?= =?UTF-8?q?ion=20f=C3=B6r=20creatorDisplayName=20+=20rubriker/filnamn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- apps/api/src/routes/recommendations.ts | 39 ++-- apps/api/test/recommendations.test.ts | 154 +++++++++++++- .../scripts/export-verified.ts | 73 ++++--- .../recipe-generation/scripts/scale-batch.ts | 191 ++++++++++++++---- .../scripts/scale-smoke-test.ts | 98 +++++++-- packages/recommendation-engine/src/types.ts | 76 +++++++ .../recommendation-engine/test/engine.test.ts | 75 +++++++ packages/validation/src/recommendations.ts | 2 + 8 files changed, 605 insertions(+), 103 deletions(-) diff --git a/apps/api/src/routes/recommendations.ts b/apps/api/src/routes/recommendations.ts index 18fa260..e607596 100644 --- a/apps/api/src/routes/recommendations.ts +++ b/apps/api/src/routes/recommendations.ts @@ -17,6 +17,7 @@ import { rankAll, seasonForDate, summarizeContext, + viewWeights, type CookingAssumption, type RecommendationCandidate, type RecommendationContext, @@ -381,11 +382,17 @@ export async function recommendationRoutes(app: FastifyInstance) { ? Math.floor((today.getTime() - Date.parse(lastDate)) / 86_400_000) : null, householdRating: householdRatingMap.get(recipe.id) ?? null, - ingredientIds: recipeIngredients.map((i) => i.canonicalIngredientId).filter((id): id is string => id != null), + ingredientIds: recipeIngredients + .map((i) => i.canonicalIngredientId) + .filter((id): id is string => id != null), }); } - const weights = personalizationEnabled ? undefined : NON_PERSONALIZED_WEIGHTS; + const weights = personalizationEnabled + ? q.view === "default" + ? undefined + : viewWeights(q.view) + : NON_PERSONALIZED_WEIGHTS; let recommendations = rankAll(scoredCandidates, ctx, weights, q.limit); // --- 9. AAMOS-omrankning bakom feature flag (aldrig obligatorisk) --- @@ -416,19 +423,20 @@ export async function recommendationRoutes(app: FastifyInstance) { } // --- 10. Matlådor först när rimligt (spec §24) --- - const mealBoxes = q.includeLeftovers && householdId - ? await app.db - .select() - .from(schema.mealBoxes) - .where( - and( - eq(schema.mealBoxes.householdId, householdId), - eq(schema.mealBoxes.status, "available"), - ), - ) - .orderBy(schema.mealBoxes.recommendedUseBy) - .limit(5) - : []; + const mealBoxes = + q.includeLeftovers && householdId + ? await app.db + .select() + .from(schema.mealBoxes) + .where( + and( + eq(schema.mealBoxes.householdId, householdId), + eq(schema.mealBoxes.status, "available"), + ), + ) + .orderBy(schema.mealBoxes.recommendedUseBy) + .limit(5) + : []; const mealBoxSuggestions = mealBoxes.map((box) => ({ mealBoxId: box.id, titleSv: box.titleSv, @@ -449,6 +457,7 @@ export async function recommendationRoutes(app: FastifyInstance) { remainingKcal: ctx.remainingKcal, remainingProteinG: ctx.remainingProteinG, craving: craving ?? null, + view: personalizationEnabled ? q.view : "default", }, mealBoxSuggestions, recommendations, diff --git a/apps/api/test/recommendations.test.ts b/apps/api/test/recommendations.test.ts index af55d16..1f51630 100644 --- a/apps/api/test/recommendations.test.ts +++ b/apps/api/test/recommendations.test.ts @@ -5,6 +5,9 @@ import { buildServer } from "../src/server.js"; import { loadConfig } from "../src/config.js"; import { createDatabase, closeDatabase, schema } from "@app/database"; +const userEmail = "what-to-eat-repro@example.invalid"; +const viewTestEmail = "view-test@example.invalid"; + /** * Regression test: what-to-eat must work for a brand-new user who has not * created a household yet (empty pantry, single-person context). @@ -14,16 +17,23 @@ describe("what-to-eat without household", () => { const config = loadConfig(); let app: Awaited>; let accessToken: string; - const userEmail = "what-to-eat-repro@example.invalid"; async function cleanup() { - const emails = [userEmail, "goals-multi@example.invalid", "auto-household@example.invalid"]; + const emails = [ + userEmail, + "goals-multi@example.invalid", + "auto-household@example.invalid", + "personalization-gate@example.invalid", + viewTestEmail, + ]; const existing = await testDb.db .select({ id: schema.users.id }) .from(schema.users) .where(inArray(schema.users.email, emails)); for (const u of existing) { - await testDb.db.delete(schema.householdMembers).where(eq(schema.householdMembers.userId, u.id)); + await testDb.db + .delete(schema.householdMembers) + .where(eq(schema.householdMembers.userId, u.id)); const ownedHouseholds = await testDb.db .select({ id: schema.households.id }) .from(schema.households) @@ -31,9 +41,13 @@ describe("what-to-eat without household", () => { schema.householdMembers, eq(schema.householdMembers.householdId, schema.households.id), ) - .where(and(eq(schema.householdMembers.userId, u.id), eq(schema.householdMembers.role, "owner"))); + .where( + and(eq(schema.householdMembers.userId, u.id), eq(schema.householdMembers.role, "owner")), + ); for (const h of ownedHouseholds) { - await testDb.db.delete(schema.storageLocations).where(eq(schema.storageLocations.householdId, h.id)); + await testDb.db + .delete(schema.storageLocations) + .where(eq(schema.storageLocations.householdId, h.id)); await testDb.db.delete(schema.households).where(eq(schema.households.id, h.id)); } await testDb.db.delete(schema.userPreferences).where(eq(schema.userPreferences.userId, u.id)); @@ -56,7 +70,10 @@ describe("what-to-eat without household", () => { await testDb.db .insert(schema.userPreferences) - .values({ userId: (JSON.parse(atob(accessToken.split(".")[1]!)) as { sub: string }).sub, primaryGoal: "cook_more" }) + .values({ + userId: (JSON.parse(atob(accessToken.split(".")[1]!)) as { sub: string }).sub, + primaryGoal: "cook_more", + }) .onConflictDoNothing(); }); @@ -90,7 +107,11 @@ describe("what-to-eat without household", () => { const registerRes = await app.inject({ method: "POST", url: "/v1/auth/register", - payload: { email: "goals-multi@example.invalid", password: "Password123!", displayName: "Goals" }, + payload: { + email: "goals-multi@example.invalid", + password: "Password123!", + displayName: "Goals", + }, }); const { accessToken: token } = JSON.parse(registerRes.body) as { accessToken: string }; const userId = (JSON.parse(atob(token.split(".")[1]!)) as { sub: string }).sub; @@ -117,7 +138,11 @@ describe("what-to-eat without household", () => { const registerRes = await app.inject({ method: "POST", url: "/v1/auth/register", - payload: { email: "personalization-gate@example.invalid", password: "Password123!", displayName: "Gate" }, + payload: { + email: "personalization-gate@example.invalid", + password: "Password123!", + displayName: "Gate", + }, }); const { accessToken: token } = JSON.parse(registerRes.body) as { accessToken: string }; const userId = (JSON.parse(atob(token.split(".")[1]!)) as { sub: string }).sub; @@ -183,7 +208,11 @@ describe("what-to-eat without household", () => { const registerRes = await app.inject({ method: "POST", url: "/v1/auth/register", - payload: { email: "auto-household@example.invalid", password: "Password123!", displayName: "Auto" }, + payload: { + email: "auto-household@example.invalid", + password: "Password123!", + displayName: "Auto", + }, }); const { accessToken: token } = JSON.parse(registerRes.body) as { accessToken: string }; const userId = (JSON.parse(atob(token.split(".")[1]!)) as { sub: string }).sub; @@ -213,3 +242,110 @@ describe("what-to-eat without household", () => { expect(locations.map((l) => l.type).sort()).toEqual(["freezer", "fridge", "pantry"]); }); }); + +describe("S4 recommendation views", () => { + const testDb = createDatabase(process.env.TEST_DATABASE_URL!); + const config = loadConfig(); + let app: Awaited>; + let accessToken: string; + let userId: string; + + beforeAll(async () => { + app = await buildServer(config); + await app.ready(); + + const res = await app.inject({ + method: "POST", + url: "/v1/auth/register", + payload: { email: viewTestEmail, password: "Password123!", displayName: "View" }, + }); + const body = JSON.parse(res.body) as { accessToken: string }; + accessToken = body.accessToken; + userId = (JSON.parse(atob(accessToken.split(".")[1]!)) as { sub: string }).sub; + + await app.inject({ + method: "POST", + url: "/v1/onboarding/quick-start", + headers: { authorization: `Bearer ${accessToken}` }, + payload: { goals: ["cook_more"], persons: 2, precisionMode: "simple" }, + }); + }); + + afterAll(async () => { + await app.close(); + await testDb.pool.end(); + }); + + it("rejects unsupported view values", async () => { + const res = await app.inject({ + method: "GET", + url: "/v1/recommendations/what-to-eat?view=spicy&limit=1", + headers: { authorization: `Bearer ${accessToken}` }, + }); + expect(res.statusCode).toBe(400); + }); + + it("returns requested view when personalization consent is granted", async () => { + await testDb.db + .insert(schema.userConsents) + .values({ userId, kind: "personalization", status: "granted" }) + .onConflictDoUpdate({ + target: [schema.userConsents.userId, schema.userConsents.kind], + set: { status: "granted" }, + }); + + for (const view of ["taste", "health", "pantry"] as const) { + const res = await app.inject({ + method: "GET", + url: `/v1/recommendations/what-to-eat?view=${view}&limit=1`, + headers: { authorization: `Bearer ${accessToken}` }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body) as { + context: { view: string }; + recommendations: unknown[]; + }; + expect(body.context.view).toBe(view); + expect(body.recommendations.length).toBeGreaterThan(0); + } + }); + + it("ignores view and falls back to default without personalization consent", async () => { + // Revoke consent. + await testDb.db + .insert(schema.userConsents) + .values({ userId, kind: "personalization", status: "revoked" }) + .onConflictDoUpdate({ + target: [schema.userConsents.userId, schema.userConsents.kind], + set: { status: "revoked" }, + }); + + const defaultRes = await app.inject({ + method: "GET", + url: "/v1/recommendations/what-to-eat?limit=1", + headers: { authorization: `Bearer ${accessToken}` }, + }); + const viewRes = await app.inject({ + method: "GET", + url: "/v1/recommendations/what-to-eat?view=taste&limit=1", + headers: { authorization: `Bearer ${accessToken}` }, + }); + + expect(defaultRes.statusCode).toBe(200); + expect(viewRes.statusCode).toBe(200); + + const defaultBody = JSON.parse(defaultRes.body) as { + context: { view: string }; + recommendations: Array<{ recipeId: string }>; + }; + const viewBody = JSON.parse(viewRes.body) as { + context: { view: string }; + recommendations: Array<{ recipeId: string }>; + }; + + expect(viewBody.context.view).toBe("default"); + expect(viewBody.recommendations.map((r) => r.recipeId)).toEqual( + defaultBody.recommendations.map((r) => r.recipeId), + ); + }); +}); diff --git a/packages/recipe-generation/scripts/export-verified.ts b/packages/recipe-generation/scripts/export-verified.ts index 84a5742..20467ce 100644 --- a/packages/recipe-generation/scripts/export-verified.ts +++ b/packages/recipe-generation/scripts/export-verified.ts @@ -1,5 +1,6 @@ import { createDatabase, closeDatabase } from "@app/database"; import { SEED_INGREDIENTS } from "@app/database/seed"; +import { BRAND } from "@app/shared-types"; import * as fs from "node:fs/promises"; import * as path from "node:path"; @@ -58,17 +59,19 @@ async function main() { } } - const coverageMatrix = mainIds.map((id) => { - const ing = SEED_INGREDIENTS.find((i) => i.id === id); - const cell = matrix.get(id) ?? new Map(); - return { - ingredientId: id, - nameSv: ing?.nameSv ?? id, - category: ing?.category ?? "unknown", - total: Array.from(cell.values()).reduce((s, v) => s + v, 0), - byDiet: Object.fromEntries(cell), - }; - }).sort((a, b) => a.total - b.total); + const coverageMatrix = mainIds + .map((id) => { + const ing = SEED_INGREDIENTS.find((i) => i.id === id); + const cell = matrix.get(id) ?? new Map(); + return { + ingredientId: id, + nameSv: ing?.nameSv ?? id, + category: ing?.category ?? "unknown", + total: Array.from(cell.values()).reduce((s, v) => s + v, 0), + byDiet: Object.fromEntries(cell), + }; + }) + .sort((a, b) => a.total - b.total); const thinCells = coverageMatrix.filter((c) => c.total <= 2); @@ -76,7 +79,11 @@ async function main() { const slugs = recipes.map((r) => r.slug); const uniqueSlugs = new Set(slugs); const titleWords = recipes.map((r) => - r.titleSv.toLowerCase().replace(/[^\w\s]/g, "").split(/\s+/).filter((w) => w.length > 3), + r.titleSv + .toLowerCase() + .replace(/[^\w\s]/g, "") + .split(/\s+/) + .filter((w) => w.length > 3), ); const titleSignatures = titleWords.map((words) => words.slice(0, 4).sort().join(" ")); const sigCounts = new Map(); @@ -117,26 +124,30 @@ async function main() { }; await fs.writeFile( - path.join(publicDir, "cibello-verified-final-result.json"), - JSON.stringify({ exportedAt: new Date().toISOString(), count: enriched.length, recipes: enriched }, null, 2), + path.join(publicDir, `${BRAND.slug}-verified-final-result.json`), + JSON.stringify( + { exportedAt: new Date().toISOString(), count: enriched.length, recipes: enriched }, + null, + 2, + ), "utf-8", ); await fs.writeFile( - path.join(publicDir, "cibello-verified-final-coverage-matrix.json"), + path.join(publicDir, `${BRAND.slug}-verified-final-coverage-matrix.json`), JSON.stringify({ exportedAt: new Date().toISOString(), coverageMatrix }, null, 2), "utf-8", ); await fs.writeFile( - path.join(publicDir, "cibello-verified-final-dedup-stats.json"), + path.join(publicDir, `${BRAND.slug}-verified-final-dedup-stats.json`), JSON.stringify({ exportedAt: new Date().toISOString(), ...summary.dedupStats }, null, 2), "utf-8", ); await fs.writeFile( - path.join(publicDir, "cibello-verified-final-summary.md"), - `# Cibello Receptkatalog – Final Verified Set + path.join(publicDir, `${BRAND.slug}-verified-final-summary.md`), + `# ${BRAND.name} Receptkatalog – Final Verified Set - **Exporterad:** ${summary.exportedAt} - **Totalt godkända recept (verified + editorial):** ${summary.totalVerified} @@ -154,7 +165,10 @@ async function main() { | Huvudingrediens | Kategori | Totalt | Fördelning | |---|---|---:|---| -${thinCells.slice(0, 15).map((c) => `| ${c.nameSv} | ${c.category} | ${c.total} | ${JSON.stringify(c.byDiet)} |`).join("\n")} +${thinCells + .slice(0, 15) + .map((c) => `| ${c.nameSv} | ${c.category} | ${c.total} | ${JSON.stringify(c.byDiet)} |`) + .join("\n")} ## Granskning @@ -164,12 +178,23 @@ Setet är redo för allergen- + närings-sanity av granskare. ); console.error(`[export-verified] Exported ${recipes.length} verified recipes`); - console.error(` result.json -> ${path.join(publicDir, "cibello-verified-final-result.json")}`); - console.error(` coverage-matrix -> ${path.join(publicDir, "cibello-verified-final-coverage-matrix.json")}`); - console.error(` dedup-stats -> ${path.join(publicDir, "cibello-verified-final-dedup-stats.json")}`); - console.error(` summary.md -> ${path.join(publicDir, "cibello-verified-final-summary.md")}`); + console.error( + ` result.json -> ${path.join(publicDir, `${BRAND.slug}-verified-final-result.json`)}`, + ); + console.error( + ` coverage-matrix -> ${path.join(publicDir, `${BRAND.slug}-verified-final-coverage-matrix.json`)}`, + ); + console.error( + ` dedup-stats -> ${path.join(publicDir, `${BRAND.slug}-verified-final-dedup-stats.json`)}`, + ); + console.error( + ` summary.md -> ${path.join(publicDir, `${BRAND.slug}-verified-final-summary.md`)}`, + ); await closeDatabase(); } -main().catch((err) => { console.error(err); process.exit(1); }); +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/packages/recipe-generation/scripts/scale-batch.ts b/packages/recipe-generation/scripts/scale-batch.ts index 9e3a153..50e6ae1 100644 --- a/packages/recipe-generation/scripts/scale-batch.ts +++ b/packages/recipe-generation/scripts/scale-batch.ts @@ -21,6 +21,7 @@ import { eq, sql, inArray } from "drizzle-orm"; import { runPipeline, type PipelineTarget, type PipelineIngredient } from "@app/recipe-generation"; import type { CanonicalIngredientLookup, SimilarityLookup } from "@app/recipe-generation"; import type { RecipeCandidate } from "@app/recipe-generation"; +import { BRAND } from "@app/shared-types"; import { randomUUID } from "node:crypto"; import * as fs from "node:fs/promises"; import * as path from "node:path"; @@ -128,7 +129,20 @@ const BASE_TARGETS: PipelineTarget[] = [ const GROUP_MAIN_IDS: Record = { "1": ["milk_3", "cream", "creme_fraiche", "chicken_breast", "chicken_thigh"], "2": ["minced_beef", "minced_mixed", "salmon", "cod", "shrimp"], - "3": ["pasta_dry", "rice_white", "potato", "tofu", "red_lentils", "chickpeas_canned", "black_beans_canned", "tomato", "zucchini", "paprika", "carrot", "spinach"], + "3": [ + "pasta_dry", + "rice_white", + "potato", + "tofu", + "red_lentils", + "chickpeas_canned", + "black_beans_canned", + "tomato", + "zucchini", + "paprika", + "carrot", + "spinach", + ], }; // ── 2b. RUNDA 2: gap-fokuserade targets (~80 nya mål) ────────────────────── @@ -156,8 +170,18 @@ const RUNDA_2_TARGETS: PipelineTarget[] = [ // Svarta bönor-gap (4 st) { mealType: "dinner", mainIngredientId: "black_beans_canned", dietVariant: "vegan", count: 4 }, - { mealType: "dinner", mainIngredientId: "black_beans_canned", dietVariant: "vegetarian", count: 3 }, - { mealType: "dinner", mainIngredientId: "black_beans_canned", dietVariant: "gluten_free", count: 2 }, + { + mealType: "dinner", + mainIngredientId: "black_beans_canned", + dietVariant: "vegetarian", + count: 3, + }, + { + mealType: "dinner", + mainIngredientId: "black_beans_canned", + dietVariant: "gluten_free", + count: 2, + }, // Kycklinglår (6 st) – fler varianter { mealType: "dinner", mainIngredientId: "chicken_thigh", dietVariant: "standard", count: 4 }, @@ -188,18 +212,35 @@ const RUNDA_2_TARGETS: PipelineTarget[] = [ const targetGroup = process.env.TARGET_GROUP; const isRunda2 = process.env.RUNDA === "2"; const SOURCE_TARGETS = isRunda2 ? RUNDA_2_TARGETS : BASE_TARGETS; -const ACTIVE_TARGETS = targetGroup && GROUP_MAIN_IDS[targetGroup] - ? SOURCE_TARGETS.filter((t) => GROUP_MAIN_IDS[targetGroup]!.includes(t.mainIngredientId)) - : SOURCE_TARGETS; +const ACTIVE_TARGETS = + targetGroup && GROUP_MAIN_IDS[targetGroup] + ? SOURCE_TARGETS.filter((t) => GROUP_MAIN_IDS[targetGroup]!.includes(t.mainIngredientId)) + : SOURCE_TARGETS; const TOTAL_TARGETS = ACTIVE_TARGETS.reduce((s, t) => s + t.count, 0); const BATCH_SIZE = 5; // ── 3. Hjälpfunktioner ───────────────────────────────────────────────────── 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", + "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 { @@ -245,7 +286,9 @@ function formatRecipeMarkdown(r: SeededRecipe, idx: number): string { const lines: string[] = []; lines.push(`## ${idx + 1}. ${r.titleSv}`); lines.push(`**Status:** ${r.status} | **Kök:** ${r.cuisine} | **Portioner:** ${r.portions}`); - lines.push(`**Tid:** ${r.prepTimeMinutes} min prep + ${r.cookTimeMinutes} min kok = ${r.totalTimeMinutes} min`); + lines.push( + `**Tid:** ${r.prepTimeMinutes} min prep + ${r.cookTimeMinutes} min kok = ${r.totalTimeMinutes} min`, + ); lines.push(`**Allergener:** ${r.allergens.join(", ") || "inget"}`); lines.push(`**Näring/portion:** ${r.nutritionText}`); lines.push(""); @@ -253,12 +296,16 @@ function formatRecipeMarkdown(r: SeededRecipe, idx: number): string { lines.push(""); lines.push("### Ingredienser"); for (const ing of r.ingredients) { - lines.push(`- ${ing.displayNameSv}: ${ing.quantity} ${ing.unit}${ing.optional ? " (valfri)" : ""}`); + lines.push( + `- ${ing.displayNameSv}: ${ing.quantity} ${ing.unit}${ing.optional ? " (valfri)" : ""}`, + ); } lines.push(""); lines.push("### Steg"); for (const s of r.steps) { - lines.push(`${s.stepNumber}. ${s.instructionSv}${s.temperatureC ? ` (${s.temperatureC}°C)` : ""}`); + lines.push( + `${s.stepNumber}. ${s.instructionSv}${s.temperatureC ? ` (${s.temperatureC}°C)` : ""}`, + ); } if (r.flagReasons.length) { lines.push(""); @@ -281,7 +328,13 @@ interface SeededRecipe { totalTimeMinutes: number; allergens: string[]; nutritionText: string; - ingredients: { displayNameSv: string; quantity: number; unit: string; optional: boolean; canonicalIngredientId: string }[]; + ingredients: { + displayNameSv: string; + quantity: number; + unit: string; + optional: boolean; + canonicalIngredientId: string; + }[]; steps: { stepNumber: number; instructionSv: string; temperatureC: number | null }[]; flagReasons: string[]; } @@ -359,7 +412,9 @@ async function main() { for (let batchIdx = 0; batchIdx < batches.length; batchIdx++) { const batchTargets = batches[batchIdx]!; const batchId = `steg2-batch-${batchIdx + 1}-${Date.now()}`; - console.error(`\n[scale-batch] Batch ${batchIdx + 1}/${batches.length} (${batchTargets.reduce((s, t) => s + t.count, 0)} recept)`); + console.error( + `\n[scale-batch] Batch ${batchIdx + 1}/${batches.length} (${batchTargets.reduce((s, t) => s + t.count, 0)} recept)`, + ); const result = await runPipeline( client, @@ -377,7 +432,9 @@ async function main() { totalCost += result.geminiResult.costUsd ?? 0; if (result.geminiResult.status !== "ok" || result.candidates.length === 0) { - console.error(`[scale-batch] Batch ${batchIdx + 1} gav inga kandidater: ${result.geminiResult.error ?? "ok utan output"}`); + console.error( + `[scale-batch] Batch ${batchIdx + 1} gav inga kandidater: ${result.geminiResult.error ?? "ok utan output"}`, + ); batchResults.push({ batchId, targets: batchTargets, @@ -419,8 +476,14 @@ async function main() { const recipeId = randomUUID(); const nutrition = v.nutritionPerPortion ?? { - kcal: 0, proteinG: 0, carbsG: 0, fatG: 0, - saturatedFatG: 0, fiberG: 0, sugarG: 0, saltG: 0, + kcal: 0, + proteinG: 0, + carbsG: 0, + fatG: 0, + saturatedFatG: 0, + fiberG: 0, + sugarG: 0, + saltG: 0, }; try { @@ -448,13 +511,45 @@ async function main() { cuisine: normalizeCuisine(candidate.cuisine) as "swedish", protein: candidate.ingredients.find((i) => { const ing = ingredientLookup.getById(i.canonicalIngredientId); - return ing && (ing.isBeef || ing.isPork || ["chicken_breast", "chicken_thigh", "salmon", "cod", "shrimp", "tofu", "red_lentils", "chickpeas_canned", "black_beans_canned", "minced_beef", "minced_mixed"].includes(i.canonicalIngredientId)); + return ( + ing && + (ing.isBeef || + ing.isPork || + [ + "chicken_breast", + "chicken_thigh", + "salmon", + "cod", + "shrimp", + "tofu", + "red_lentils", + "chickpeas_canned", + "black_beans_canned", + "minced_beef", + "minced_mixed", + ].includes(i.canonicalIngredientId)) + ); })?.canonicalIngredientId, - carbohydrate: candidate.ingredients.find((i) => ["rice_white", "pasta_dry", "potato"].includes(i.canonicalIngredientId))?.canonicalIngredientId, - vegetables: candidate.ingredients.filter((i) => { - const ing = ingredientLookup.getById(i.canonicalIngredientId); - return ing?.category === "gronsaker" || ["tomato", "zucchini", "paprika", "carrot", "spinach", "onion", "garlic"].includes(i.canonicalIngredientId); - }).map((i) => i.canonicalIngredientId), + carbohydrate: candidate.ingredients.find((i) => + ["rice_white", "pasta_dry", "potato"].includes(i.canonicalIngredientId), + )?.canonicalIngredientId, + vegetables: candidate.ingredients + .filter((i) => { + const ing = ingredientLookup.getById(i.canonicalIngredientId); + return ( + ing?.category === "gronsaker" || + [ + "tomato", + "zucchini", + "paprika", + "carrot", + "spinach", + "onion", + "garlic", + ].includes(i.canonicalIngredientId) + ); + }) + .map((i) => i.canonicalIngredientId), flavorProfile: inferDietTags(candidate), spiceLevel: candidate.spiceLevel, method: "stovetop", @@ -466,7 +561,7 @@ async function main() { status: "draft", verificationStatus: "verified", sourceType: "ai_assisted_reviewed", - creatorDisplayName: "Cibello AI", + creatorDisplayName: `${BRAND.name} AI`, }); } catch (err: any) { if (err?.message?.includes("recipes_slug_unique") || err?.code === "23505") { @@ -559,25 +654,36 @@ async function main() { for (let i = 0; i < result.candidates.length; i++) { const v = result.verificationResults[i]!; if (v.status === "unverified" && v.reasons.length > 0) { - console.error(`[scale-batch] unverified: "${result.candidates[i]!.titleSv}" => ${v.reasons.join("; ")}`); + console.error( + `[scale-batch] unverified: "${result.candidates[i]!.titleSv}" => ${v.reasons.join("; ")}`, + ); } } - console.error(`[scale-batch] Batch ${batchIdx + 1} klar: gen=${result.candidates.length}, ver=${result.verifiedCount}, unv=${result.unverifiedCount}, rej=${result.rejectedCount}, dup=${batchDuplicate}, seeded=${batchSeeded}, cost=$${(result.geminiResult.costUsd ?? 0).toFixed(4)}`); + console.error( + `[scale-batch] Batch ${batchIdx + 1} klar: gen=${result.candidates.length}, ver=${result.verifiedCount}, unv=${result.unverifiedCount}, rej=${result.rejectedCount}, dup=${batchDuplicate}, seeded=${batchSeeded}, cost=$${(result.geminiResult.costUsd ?? 0).toFixed(4)}`, + ); } // ── 5. Spara stickprov ──────────────────────────────────────────────────── const publicDir = "/mnt/c/Users/Public"; - const sampleRecipes = shuffle(allSeeded).slice(0, Math.max(1, Math.round(allSeeded.length * 0.15))); + const sampleRecipes = shuffle(allSeeded).slice( + 0, + Math.max(1, Math.round(allSeeded.length * 0.15)), + ); const sampleMarkdown = [ - "# Cibello STEG 2 – Stickprov för smakkoll", + `# ${BRAND.name} STEG 2 – Stickprov för smakkoll`, `Genererad: ${new Date().toISOString()}`, `Totalt seedade: ${allSeeded.length}`, `Stickprov: ${sampleRecipes.length}`, "", ...sampleRecipes.map((r, i) => formatRecipeMarkdown(r, i)), ].join("\n"); - await fs.writeFile(path.join(publicDir, "cibello-steg2-sample.md"), sampleMarkdown, "utf-8"); + await fs.writeFile( + path.join(publicDir, `${BRAND.slug}-steg2-sample.md`), + sampleMarkdown, + "utf-8", + ); // ── 6. Slutrapport ──────────────────────────────────────────────────────── const report = { @@ -629,7 +735,9 @@ async function main() { console.error(`- Dubbletter: ${totalDuplicate}`); console.error(`- Seedade i DB: ${totalSeeded}`); console.error(`- Total Gemini-kostnad: $${totalCost.toFixed(4)}`); - console.error(`- Stickprov: ${sampleRecipes.length} recept → ${publicDir}\\cibello-steg2-sample.md`); + console.error( + `- Stickprov: ${sampleRecipes.length} recept → ${publicDir}\\cibello-steg2-sample.md`, + ); console.error(`- Rapport: ${publicDir}\\cibello-steg2-report.json`); await closeDatabase(); @@ -638,7 +746,9 @@ async function main() { function inferDietTags(candidate: RecipeCandidate): string[] { const tags: string[] = []; const ingIds = new Set(candidate.ingredients.map((i) => i.canonicalIngredientId)); - const ings = candidate.ingredients.map((i) => ingredientLookup.getById(i.canonicalIngredientId)).filter(Boolean); + const ings = candidate.ingredients + .map((i) => ingredientLookup.getById(i.canonicalIngredientId)) + .filter(Boolean); const allVegan = ings.every((i) => i?.isVegan); const allVegetarian = ings.every((i) => i?.isVegetarian); const noGluten = ings.every((i) => !i?.containsGluten); @@ -655,7 +765,16 @@ function flagRecipe(candidate: RecipeCandidate, nutrition: { kcal: number }): st if (nutrition.kcal > 1000) reasons.push("hög kalorihalt"); if (nutrition.kcal < 150) reasons.push("låg kalorihalt"); if (candidate.ingredients.length < 4) reasons.push("få ingredienser"); - const riskyAllergens = ["peanuts", "tree_nuts", "shellfish", "crustaceans", "fish", "milk", "gluten", "eggs"]; + const riskyAllergens = [ + "peanuts", + "tree_nuts", + "shellfish", + "crustaceans", + "fish", + "milk", + "gluten", + "eggs", + ]; const ingIds = new Set(candidate.ingredients.map((i) => i.canonicalIngredientId)); for (const id of ingIds) { const ing = ingredientLookup.getById(id); @@ -673,11 +792,15 @@ function buildCoverageMatrix(seeded: SeededRecipe[]) { for (const r of seeded) { // Hitta den mest sannolika huvudingrediensen: första ingrediensen som är en target-huvudingrediens - const mainId = r.ingredients.find((i) => mainIngredientIds.has(i.canonicalIngredientId))?.canonicalIngredientId; + const mainId = r.ingredients.find((i) => + mainIngredientIds.has(i.canonicalIngredientId), + )?.canonicalIngredientId; if (!mainId) continue; // Härled dietvariant från ingredienserna - const ings = r.ingredients.map((i) => ingredientLookup.getById(i.canonicalIngredientId)).filter(Boolean); + const ings = r.ingredients + .map((i) => ingredientLookup.getById(i.canonicalIngredientId)) + .filter(Boolean); const isVegan = ings.every((i) => i?.isVegan); const isVegetarian = ings.every((i) => i?.isVegetarian); const isGlutenFree = ings.every((i) => !i?.containsGluten); diff --git a/packages/recipe-generation/scripts/scale-smoke-test.ts b/packages/recipe-generation/scripts/scale-smoke-test.ts index 4374330..37816bc 100644 --- a/packages/recipe-generation/scripts/scale-smoke-test.ts +++ b/packages/recipe-generation/scripts/scale-smoke-test.ts @@ -2,13 +2,34 @@ 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 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", + "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 { @@ -69,7 +90,9 @@ const targets: PipelineTarget[] = [ ]; function inferDietTags(candidate: RecipeCandidate): string[] { - const ings = candidate.ingredients.map((i) => ingredientLookup.getById(i.canonicalIngredientId)).filter(Boolean); + 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); @@ -89,7 +112,9 @@ async function main() { 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 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 = { @@ -99,16 +124,15 @@ async function main() { }; console.error("[scale-smoke-test] starting"); - const result = await runPipeline( - client, - targets, - catalog, - ingredientLookup, - similarityLookup, - { maxPrepTimeMinutes: 30, maxCookTimeMinutes: 45, portions: 4 }, - ); + 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}`); + 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++) { @@ -126,7 +150,16 @@ async function main() { 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 }; + 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(), @@ -150,9 +183,29 @@ async function main() { 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), + 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", @@ -164,7 +217,7 @@ async function main() { status: "draft", verificationStatus: "verified", sourceType: "ai_assisted_reviewed", - creatorDisplayName: "Cibello AI Smoke", + creatorDisplayName: `${BRAND.name} AI Smoke`, }); seeded++; } @@ -173,4 +226,7 @@ async function main() { await closeDatabase(); } -main().catch((err) => { console.error(err); process.exit(1); }); +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/packages/recommendation-engine/src/types.ts b/packages/recommendation-engine/src/types.ts index 64e2a12..2715947 100644 --- a/packages/recommendation-engine/src/types.ts +++ b/packages/recommendation-engine/src/types.ts @@ -135,3 +135,79 @@ export const NON_PERSONALIZED_WEIGHTS: ScoringWeights = { tasteFit: 0, cookingAssumptionFit: 0, }; + +/** Tillåtna vyer för "Vad ska vi äta?". */ +export type RecommendationView = "default" | "taste" | "health" | "pantry"; + +export const RECOMMENDATION_VIEWS: RecommendationView[] = ["default", "taste", "health", "pantry"]; + +/** Fördefinierade vikter för smak-vyn (S4). */ +export const TASTE_VIEW_WEIGHTS: ScoringWeights = { + coverage: 20, + expiry: 10, + nutritionFit: 5, + taste: 25, + rating: 10, + season: 3, + holiday: 3, + time: 5, + budget: 3, + variety: 3, + weather: 1, + craving: 25, + memoryFit: 12, + tasteFit: 12, + cookingAssumptionFit: 6, +}; + +/** Fördefinierade vikter för hälsa-vyn (S4) — stödjande, icke-restriktiv (R7). */ +export const HEALTH_VIEW_WEIGHTS: ScoringWeights = { + coverage: 20, + expiry: 12, + nutritionFit: 35, + taste: 5, + rating: 6, + season: 4, + holiday: 4, + time: 6, + budget: 3, + variety: 4, + weather: 2, + craving: 5, + memoryFit: 3, + tasteFit: 3, + cookingAssumptionFit: 4, +}; + +/** Fördefinierade vikter för lager-vyn (S4). */ +export const PANTRY_VIEW_WEIGHTS: ScoringWeights = { + coverage: 45, + expiry: 35, + nutritionFit: 5, + taste: 4, + rating: 4, + season: 2, + holiday: 2, + time: 6, + budget: 4, + variety: 2, + weather: 1, + craving: 5, + memoryFit: 4, + tasteFit: 4, + cookingAssumptionFit: 8, +}; + +/** Hämta vikter för en specifik vy. */ +export function viewWeights(view: RecommendationView): ScoringWeights { + switch (view) { + case "taste": + return TASTE_VIEW_WEIGHTS; + case "health": + return HEALTH_VIEW_WEIGHTS; + case "pantry": + return PANTRY_VIEW_WEIGHTS; + default: + return DEFAULT_WEIGHTS; + } +} diff --git a/packages/recommendation-engine/test/engine.test.ts b/packages/recommendation-engine/test/engine.test.ts index 3f9b274..24960b0 100644 --- a/packages/recommendation-engine/test/engine.test.ts +++ b/packages/recommendation-engine/test/engine.test.ts @@ -5,14 +5,18 @@ import { containsForbiddenCopy, DEFAULT_WEIGHTS, easterSunday, + HEALTH_VIEW_WEIGHTS, isEventActive, midsummerEve, NON_PERSONALIZED_WEIGHTS, parseCraving, + PANTRY_VIEW_WEIGHTS, rankAll, renderProvenance, scoreCandidate, seasonForDate, + TASTE_VIEW_WEIGHTS, + viewWeights, type CookingAssumption, type RecommendationCandidate, type RecommendationContext, @@ -329,3 +333,74 @@ describe("S1 personalisering", () => { expect(NON_PERSONALIZED_WEIGHTS.coverage).toBe(DEFAULT_WEIGHTS.coverage); }); }); + +describe("S4 rekommendationsvyer", () => { + const viewCtx: RecommendationContext = { + ...ctx, + personalizationEnabled: true, + memoryItems: [], + tasteSignals: [], + cookingAssumptions: [], + }; + + const tasty = candidate({ + recipeId: "tasty", + titleSv: "Svensk köttbullsgryta", + cuisine: "swedish", + coverage: fullCoverage, + nutritionPerPortion: { ...nutrition, proteinG: 20 }, + }); + + const pantry = candidate({ + recipeId: "pantry", + titleSv: "Italiensk pastarätt", + cuisine: "italian", + coverage: { + ...fullCoverage, + expiringUsed: [ + { + canonicalIngredientId: "pasta", + displayNameSv: "pastan", + required: 200, + unit: "GRAM", + availableInUnit: 250, + covered: true, + optional: false, + mostUrgentDaysLeft: 1, + usesExpiringItem: true, + }, + ], + }, + nutritionPerPortion: { ...nutrition, proteinG: 15 }, + }); + + const healthy = candidate({ + recipeId: "healthy", + titleSv: "Kyckling och quinoa", + cuisine: "greek", + coverage: fullCoverage, + nutritionPerPortion: { ...nutrition, proteinG: 50 }, + }); + + it("smak-vyn höjer recept i favoritkök", () => { + const ranked = rankAll([pantry, healthy, tasty], viewCtx, TASTE_VIEW_WEIGHTS); + expect(ranked[0]?.recipeId).toBe("tasty"); + }); + + it("lager-vyn höjer recept som räddar varor nära utgångsdatum", () => { + const ranked = rankAll([tasty, healthy, pantry], viewCtx, PANTRY_VIEW_WEIGHTS); + expect(ranked[0]?.recipeId).toBe("pantry"); + }); + + it("hälsa-vyn höjer recept som matchar näringsmål", () => { + const ranked = rankAll([tasty, pantry, healthy], viewCtx, HEALTH_VIEW_WEIGHTS); + expect(ranked[0]?.recipeId).toBe("healthy"); + }); + + it("viewWeights returnerar rätt vikter för varje vy", () => { + expect(viewWeights("default").taste).toBe(DEFAULT_WEIGHTS.taste); + expect(viewWeights("taste").taste).toBe(TASTE_VIEW_WEIGHTS.taste); + expect(viewWeights("health").nutritionFit).toBe(HEALTH_VIEW_WEIGHTS.nutritionFit); + expect(viewWeights("pantry").coverage).toBe(PANTRY_VIEW_WEIGHTS.coverage); + }); +}); diff --git a/packages/validation/src/recommendations.ts b/packages/validation/src/recommendations.ts index f9fc26c..bd59680 100644 --- a/packages/validation/src/recommendations.ts +++ b/packages/validation/src/recommendations.ts @@ -14,5 +14,7 @@ export const whatToEatQuerySchema = z.object({ craving: z.string().max(300).optional(), includeLeftovers: z.coerce.boolean().default(true), limit: z.coerce.number().int().min(1).max(20).default(5), + /** Personaliseringsvy: smak, hälsa eller lager. Kräver personalization-samtycke. */ + view: z.enum(["default", "taste", "health", "pantry"]).default("default"), }); export type WhatToEatQuery = z.infer;