From 4b7dec2759ba8491f5404bddb1afec66a49af38f Mon Sep 17 00:00:00 2001 From: "Sven (AAMOS AI)" Date: Sun, 9 Aug 2026 19:47:03 +0700 Subject: [PATCH] =?UTF-8?q?feat(recipe-generation):=20Cibello=20Receptkata?= =?UTF-8?q?log=20Fas=20A=20=E2=80=93=20STEG=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Nytt paket @app/recipe-generation med pipeline, verifieringsgrind, gaprapport och pilotbatch-script. - Ny AAMOS-task GENERATE_RECIPE_CANDIDATES i @app/ai-contracts. - GeminiAamosClient utökat med receptgenereringsstöd. - MockAamosClient uppdaterad med deterministiska kandidater. - Verifieringspipeline: ingrediensmappning (hård spärr), härledda allergener, beräknad näring via nutrition-engine, språk-/kvalitetskontroll, dedup. - Hermetiska tester: 10/10 gröna. - typecheck + test + build: gröna för hela workspace. Refs: docs/32-receptkatalog-buildout.md --- packages/ai-contracts/src/gemini.ts | 165 ++++++++++++ packages/ai-contracts/src/mock.ts | 53 ++++ packages/ai-contracts/src/tasks.ts | 81 ++++++ packages/recipe-generation/package.json | 32 +++ .../recipe-generation/scripts/pilot-batch.ts | 145 +++++++++++ packages/recipe-generation/src/gap-report.ts | 173 +++++++++++++ packages/recipe-generation/src/index.ts | 4 + packages/recipe-generation/src/pipeline.ts | 173 +++++++++++++ packages/recipe-generation/src/types.ts | 81 ++++++ .../recipe-generation/src/verification.ts | 234 ++++++++++++++++++ .../recipe-generation/test/gap-report.test.ts | 39 +++ .../recipe-generation/test/pipeline.test.ts | 69 ++++++ .../test/verification.test.ts | 166 +++++++++++++ packages/recipe-generation/tsconfig.json | 11 + pnpm-lock.yaml | 128 ++++++++++ 15 files changed, 1554 insertions(+) create mode 100644 packages/recipe-generation/package.json create mode 100644 packages/recipe-generation/scripts/pilot-batch.ts create mode 100644 packages/recipe-generation/src/gap-report.ts create mode 100644 packages/recipe-generation/src/index.ts create mode 100644 packages/recipe-generation/src/pipeline.ts create mode 100644 packages/recipe-generation/src/types.ts create mode 100644 packages/recipe-generation/src/verification.ts create mode 100644 packages/recipe-generation/test/gap-report.test.ts create mode 100644 packages/recipe-generation/test/pipeline.test.ts create mode 100644 packages/recipe-generation/test/verification.test.ts create mode 100644 packages/recipe-generation/tsconfig.json diff --git a/packages/ai-contracts/src/gemini.ts b/packages/ai-contracts/src/gemini.ts index 3818e1f..2cae722 100644 --- a/packages/ai-contracts/src/gemini.ts +++ b/packages/ai-contracts/src/gemini.ts @@ -16,6 +16,7 @@ import { type TaskOutput, detectedItemSchema, type DetectedItem, + generateRecipeCandidatesOutput, } from "./tasks.js"; import type { AamosCallOptions, AamosClient, AamosResult } from "./client.js"; import type { LocaleContext } from "@app/shared-types"; @@ -193,6 +194,8 @@ export class GeminiAamosClient implements AamosClient { case "ANALYZE_FRIDGE_IMAGE": case "ANALYZE_PANTRY_IMAGE": return this.analyzeStorageImage(taskType, parsedInput.data as TaskInput<"ANALYZE_FRIDGE_IMAGE">, options) as Promise>; + case "GENERATE_RECIPE_CANDIDATES": + return this.generateRecipeCandidates(parsedInput.data as TaskInput<"GENERATE_RECIPE_CANDIDATES">, options) as Promise>; default: return { status: "failed", @@ -292,6 +295,168 @@ export class GeminiAamosClient implements AamosClient { }; } + private async generateRecipeCandidates( + input: TaskInput<"GENERATE_RECIPE_CANDIDATES">, + options: AamosCallOptions, + ): Promise> { + const locale = options.localeContext ?? this.defaultLocale(); + const started = Date.now(); + + // Estimate cost: ~0.003 USD per candidate (text-only, pessimistic) + const totalCandidates = input.targetMatrix.reduce((sum, t) => sum + t.count, 0); + const estimatedCostUsd = totalCandidates * 0.003; + if (await this.isOverBudget(estimatedCostUsd)) { + return { + status: "failed", + output: null, + error: "Global Gemini-dagsbudget är förbrukad.", + }; + } + + const prompt = this.buildRecipeGenerationPrompt(input, locale); + const payload = { + contents: [{ parts: [{ text: prompt }] }], + generationConfig: { + responseMimeType: "application/json", + temperature: 0.3, + }, + }; + + const url = `${GEMINI_API_BASE}/models/${this.cfg.model}:generateContent?key=${this.cfg.apiKey}`; + const res = await this.cfg.fetchImpl(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(this.cfg.timeoutMs), + }); + + if (!res.ok) { + const body = await res.text(); + return { + status: "failed", + output: null, + error: `Gemini ${res.status}: ${body.slice(0, 500)}`, + }; + } + + const geminiBody = (await res.json()) as { + candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }>; + usageMetadata?: { promptTokenCount?: number; candidatesTokenCount?: number }; + }; + const text = geminiBody.candidates?.[0]?.content?.parts?.[0]?.text ?? ""; + + let parsed: z.infer; + try { + const json = JSON.parse(text); + const safe = generateRecipeCandidatesOutput.safeParse(json); + if (!safe.success) { + return { status: "failed", output: null, error: `Gemini-svar matchar inte schema: ${safe.error.message}` }; + } + parsed = safe.data; + } catch { + return { status: "failed", output: null, error: "Gemini-svar var inte giltig JSON." }; + } + + const inputTokens = geminiBody.usageMetadata?.promptTokenCount ?? 0; + const outputTokens = geminiBody.usageMetadata?.candidatesTokenCount ?? 0; + const costUsd = this.estimateCostUsd(inputTokens, outputTokens); + await this.recordSpend(costUsd); + + const latencyMs = Date.now() - started; + + return { + status: parsed.candidates.length > 0 ? "ok" : "uncertain", + output: parsed as TaskOutput<"GENERATE_RECIPE_CANDIDATES">, + modelVersion: this.cfg.model, + promptVersion: "gemini-recipe-gen-v1", + latencyMs, + costUsd, + inputTokens, + outputTokens, + }; + } + + private buildRecipeGenerationPrompt( + input: TaskInput<"GENERATE_RECIPE_CANDIDATES">, + locale: LocaleContext, + ): string { + const lang = locale.languageTag.startsWith("en") ? "English" : "Swedish"; + const catalog = input.canonicalIngredientsCatalog.map((i) => + `- ${i.id} (${i.nameSv}, ${i.category}, enhet: ${i.defaultUnit}, vegan: ${i.isVegan}, veg: ${i.isVegetarian}, gluten: ${i.containsGluten}, laktos: ${i.containsLactose})` + ).join("\n"); + + const targets = input.targetMatrix.map((t) => + `- ${t.mealType} × ${t.mainIngredientId} × ${t.dietVariant}: ${t.count} st` + ).join("\n"); + + const constraints = input.constraints; + + return `Du är en erfaren svensk matskribent som skriver vardagsrecept för svenska hushåll. + +VIKTIGAST: Använd ENDAST ingredienser från katalogen nedan. Hitta ALDRIG på ingredienser som inte finns i listan. Om ett recept behöver något som saknas, hoppa över det receptet och lägg till en notering i rejectedPrompts. + +Tillåtna ingredienser (canonical_ingredients): +${catalog} + +Önskade recept: +${targets} + +Begränsningar: +- max förberedelsetid: ${constraints.maxPrepTimeMinutes ?? 60} min +- max koktid: ${constraints.maxCookTimeMinutes ?? 45} min +- portioner: ${constraints.portions ?? 4} +- max kryddnivå: ${constraints.spiceLevelMax ?? 3} +- undvik: ${constraints.avoidIngredients.join(", ") || "(ingen)"} + +Regler: +- Recepten ska vara realistiska vardagsrätter för svenska hushåll. +- Stegen ska vara tydliga, med rimliga tider och temperaturer. +- Använd svenska mått (g, dl, msk, tsk, st). +- Formuleringen ska vara mjölkprincips-vänlig: ingen svinnskam, inget "släng". +- Varje ingrediens MÅSTE finnas i katalogen ovan. +- Svara ENDAST med giltig JSON i exakt detta format: + +{ + "candidates": [ + { + "titleSv": "...", + "descriptionSv": "...", + "cuisine": "swedish", + "mealTypes": ["dinner"], + "prepTimeMinutes": 15, + "cookTimeMinutes": 30, + "portions": 4, + "spiceLevel": 1, + "ingredients": [ + { + "canonicalIngredientId": "kycklingfile", + "displayNameSv": "kycklingfilé", + "quantity": 500, + "unit": "GRAM", + "optional": false, + "note": null + } + ], + "steps": [ + { + "instructionSv": "...", + "timerSeconds": null, + "temperatureC": null, + "tip": null + } + ], + "storageGuidanceSv": "...", + "mealPrepFriendly": false, + "freezerFriendly": false, + "confidence": 0.95 + } + ], + "rejectedPrompts": [] +} + +Språk: ${lang}.`; + } + private buildFridgePrompt( taskType: "ANALYZE_FRIDGE_IMAGE" | "ANALYZE_PANTRY_IMAGE", locale: LocaleContext, diff --git a/packages/ai-contracts/src/mock.ts b/packages/ai-contracts/src/mock.ts index 80ec4df..ebd5649 100644 --- a/packages/ai-contracts/src/mock.ts +++ b/packages/ai-contracts/src/mock.ts @@ -217,6 +217,59 @@ export function mockOutputFor(taskType: AamosTaskType, input: unknown): unknown ], }; + case "GENERATE_RECIPE_CANDIDATES": { + // Deterministisk mock: returnerar en enkel kandidat per target-cell + const inp = input as { + targetMatrix?: Array<{ mealType: string; mainIngredientId: string; dietVariant: string; count: number }>; + canonicalIngredientsCatalog?: Array<{ id: string; nameSv: string; category: string; defaultUnit: string }>; + }; + const targets = inp.targetMatrix ?? []; + const catalog = inp.canonicalIngredientsCatalog ?? []; + const candidates = []; + for (const t of targets) { + const mainIng = catalog.find((c) => c.id === t.mainIngredientId); + for (let i = 0; i < Math.min(t.count, 2); i++) { + candidates.push({ + titleSv: `Mock-${mainIng?.nameSv ?? t.mainIngredientId} ${t.dietVariant} ${i + 1}`, + descriptionSv: `Ett enkelt vardagsrecept med ${mainIng?.nameSv ?? t.mainIngredientId}.`, + cuisine: "swedish", + mealTypes: [t.mealType], + prepTimeMinutes: 10, + cookTimeMinutes: 20, + portions: 4, + spiceLevel: 1, + ingredients: [ + { + canonicalIngredientId: t.mainIngredientId, + displayNameSv: mainIng?.nameSv ?? t.mainIngredientId, + quantity: 500, + unit: mainIng?.defaultUnit ?? "GRAM", + optional: false, + note: null, + }, + { + canonicalIngredientId: "onion_yellow", + displayNameSv: "gul lök", + quantity: 2, + unit: "COUNT", + optional: false, + note: null, + }, + ], + steps: [ + { instructionSv: "Förbered ingredienserna.", timerSeconds: null, temperatureC: null, tip: null }, + { instructionSv: "Stek och låt koka klart.", timerSeconds: 900, temperatureC: null, tip: null }, + ], + storageGuidanceSv: "Förvara i kylskåp upp till 3 dagar.", + mealPrepFriendly: false, + freezerFriendly: false, + confidence: 0.85, + }); + } + } + return { candidates, rejectedPrompts: [] }; + } + case "RANK_RECIPES": { const ids = typeof input === "object" && input !== null diff --git a/packages/ai-contracts/src/tasks.ts b/packages/ai-contracts/src/tasks.ts index a16fdab..699b672 100644 --- a/packages/ai-contracts/src/tasks.ts +++ b/packages/ai-contracts/src/tasks.ts @@ -18,6 +18,7 @@ export const AAMOS_TASK_TYPES = [ "DEDUPLICATE_INVENTORY", "STRUCTURE_RECIPE_TEXT", "GENERATE_RECIPE_OPTIONS", + "GENERATE_RECIPE_CANDIDATES", "RANK_RECIPES", "PARSE_CRAVING", "UPDATE_USER_MEMORY", @@ -274,6 +275,82 @@ export const generateRecipeOptionsOutput = z.object({ ), }); +// --------------------------------------------------------------------------- +// GENERATE_RECIPE_CANDIDATES (docs/32): AI-genererade receptkandidater +// konstruerade mot BEFINTLIGA canonical_ingredients. Ingen påhittad +// ingrediens. Näring räknas ALDRIG här – det gör nutrition-engine. +// --------------------------------------------------------------------------- + +export const generateRecipeCandidatesInput = z.object({ + targetMatrix: z.array( + z.object({ + mealType: z.string(), + mainIngredientId: z.string(), + dietVariant: z.enum(["standard", "vegetarian", "vegan", "gluten_free", "lactose_free"]), + count: z.number().int().min(1).max(10), + }), + ).min(1).max(20), + canonicalIngredientsCatalog: z.array( + z.object({ + id: z.string(), + nameSv: z.string(), + category: z.string(), + defaultUnit: unitSchema, + isVegan: z.boolean(), + isVegetarian: z.boolean(), + containsGluten: z.boolean(), + containsLactose: z.boolean(), + allergens: z.array(z.string()), + }), + ).min(1), + constraints: z.object({ + maxPrepTimeMinutes: z.number().int().nullable().default(60), + maxCookTimeMinutes: z.number().int().nullable().default(45), + portions: z.number().int().default(4), + spiceLevelMax: z.number().int().nullable().default(3), + avoidIngredients: z.array(z.string()).default([]), + }).default(() => ({ maxPrepTimeMinutes: 60, maxCookTimeMinutes: 45, portions: 4, spiceLevelMax: 3, avoidIngredients: [] })), + marketLocale: z.string().default("sv-SE"), +}); + +export const generateRecipeCandidatesOutput = z.object({ + candidates: z.array( + z.object({ + titleSv: z.string(), + descriptionSv: z.string(), + cuisine: z.string().nullable(), + mealTypes: z.array(z.string()), + prepTimeMinutes: z.number().int(), + cookTimeMinutes: z.number().int(), + portions: z.number().int(), + spiceLevel: z.number().int(), + ingredients: z.array( + z.object({ + canonicalIngredientId: z.string(), + displayNameSv: z.string(), + quantity: z.number(), + unit: unitSchema, + optional: z.boolean().default(false), + note: z.string().nullable(), + }), + ), + steps: z.array( + z.object({ + instructionSv: z.string(), + timerSeconds: z.number().nullable(), + temperatureC: z.number().nullable(), + tip: z.string().nullable(), + }), + ), + storageGuidanceSv: z.string().nullable(), + mealPrepFriendly: z.boolean().default(false), + freezerFriendly: z.boolean().default(false), + confidence: z.number().min(0).max(1), + }), + ), + rejectedPrompts: z.array(z.string()).default([]), +}); + export const rankRecipesInput = z.object({ candidateIds: z.array(z.string()).max(50), deterministicScores: z.record(z.string(), z.number()), @@ -454,6 +531,10 @@ export const TASK_CONTRACTS = { input: generateRecipeOptionsInput, output: generateRecipeOptionsOutput, }, + GENERATE_RECIPE_CANDIDATES: { + input: generateRecipeCandidatesInput, + output: generateRecipeCandidatesOutput, + }, RANK_RECIPES: { input: rankRecipesInput, output: rankRecipesOutput }, PARSE_CRAVING: { input: parseCravingInput, output: parseCravingOutput }, UPDATE_USER_MEMORY: { input: updateUserMemoryInput, output: updateUserMemoryOutput }, diff --git a/packages/recipe-generation/package.json b/packages/recipe-generation/package.json new file mode 100644 index 0000000..ca676bf --- /dev/null +++ b/packages/recipe-generation/package.json @@ -0,0 +1,32 @@ +{ + "name": "@app/recipe-generation", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "AI-assisterad receptgenerering med verifieringspipeline för Cibello (docs/32)", + "exports": { + ".": "./src/index.ts", + "./pipeline": "./src/pipeline.ts", + "./verification": "./src/verification.ts", + "./gap-report": "./src/gap-report.ts", + "./gemini-recipe": "./src/gemini-recipe.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@app/ai-contracts": "workspace:*", + "@app/database": "workspace:*", + "@app/nutrition-engine": "workspace:*", + "@app/recipe-engine": "workspace:*", + "@app/shared-types": "workspace:*", + "drizzle-orm": "^0.41.0", + "zod": "^4.4.0" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "typescript": "~5.9.3", + "vitest": "^4.0.0" + } +} diff --git a/packages/recipe-generation/scripts/pilot-batch.ts b/packages/recipe-generation/scripts/pilot-batch.ts new file mode 100644 index 0000000..4c7a375 --- /dev/null +++ b/packages/recipe-generation/scripts/pilot-batch.ts @@ -0,0 +1,145 @@ +#!/usr/bin/env tsx +/** + * PILOTBATCH – Receptkatalog Fas A (docs/32). + * + * Genererar ~20–30 recept över en skiva av matrisen: + * middag × mejeri/kyckling/köttfärs × allätare+veg + * + * Använder Gemini i staging/dev (AAMOS_MODE=gemini) eller mock (AAMOS_MODE=mock). + * Ingen påfyllning i prod. Resultatet skrivs till stdout som JSON + markdown. + * + * Körning: + * AAMOS_MODE=mock pnpm tsx packages/recipe-generation/scripts/pilot-batch.ts + * AAMOS_MODE=gemini GEMINI_API_KEY=... pnpm tsx packages/recipe-generation/scripts/pilot-batch.ts + */ + +import { createAamosClient } from "@app/ai-contracts"; +import { SEED_INGREDIENTS } from "@app/database/seed"; +import { runPipeline, type PipelineTarget, type PipelineIngredient } from "@app/recipe-generation"; +import type { CanonicalIngredientLookup, SimilarityLookup } from "@app/recipe-generation"; + +// ── 1. Bygg katalog från seed-data ───────────────────────────────────────── +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, +})); + +// ── 2. Bygg lookup för verifiering ───────────────────────────────────────── +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, + }; + }, +}; + +// ── 3. Dedup-lookup (enkel, baserad på titel) ────────────────────────────── +const knownTitles = new Set(); +const similarityLookup: SimilarityLookup = { + async hasSimilarity(title: string) { + if (knownTitles.has(title.toLowerCase())) return true; + knownTitles.add(title.toLowerCase()); + return false; + }, +}; + +// ── 4. Definiera pilot-matris ────────────────────────────────────────────── +const targets: PipelineTarget[] = [ + // Mejeri × allätare + { mealType: "dinner", mainIngredientId: "milk_3", dietVariant: "standard", count: 3 }, + { mealType: "dinner", mainIngredientId: "cream", dietVariant: "standard", count: 2 }, + // Kyckling × allätare + { mealType: "dinner", mainIngredientId: "chicken_breast", dietVariant: "standard", count: 4 }, + { mealType: "dinner", mainIngredientId: "chicken_thigh", dietVariant: "standard", count: 2 }, + // Köttfärs × allätare + { mealType: "dinner", mainIngredientId: "ground_beef", dietVariant: "standard", count: 3 }, + { mealType: "dinner", mainIngredientId: "ground_pork", dietVariant: "standard", count: 2 }, + // Veg-varianter + { mealType: "dinner", mainIngredientId: "tofu_firm", dietVariant: "vegan", count: 3 }, + { mealType: "dinner", mainIngredientId: "lentils_red", dietVariant: "vegetarian", count: 2 }, + { mealType: "dinner", mainIngredientId: "chickpeas", dietVariant: "vegan", count: 2 }, +]; + +// ── 5. Kör pipelinen ─────────────────────────────────────────────────────── +async function main() { + const client = createAamosClient(process.env); + console.error("[pilot-batch] Startar generering..."); + console.error(`[pilot-batch] Katalog: ${catalog.length} ingredienser`); + console.error(`[pilot-batch] Mål: ${targets.length} matris-celler, ~${targets.reduce((s, t) => s + t.count, 0)} recept`); + + const result = await runPipeline( + client, + targets, + catalog, + ingredientLookup, + similarityLookup, + { maxPrepTimeMinutes: 30, maxCookTimeMinutes: 45, portions: 4 }, + ); + + // ── 6. Rapportera ──────────────────────────────────────────────────────── + const report = { + batchId: `pilot-${Date.now()}`, + generatedAt: new Date().toISOString(), + targetMatrix: targets, + geminiStatus: result.geminiResult.status, + geminiCostUsd: result.geminiResult.costUsd ?? 0, + candidatesGenerated: result.candidates.length, + verifiedCount: result.verifiedCount, + unverifiedCount: result.unverifiedCount, + rejectedCount: result.rejectedCount, + candidates: result.verificationResults.map((r) => ({ + title: r.candidate.titleSv, + status: r.status, + reasons: r.reasons, + allergens: r.allergens, + nutritionPerPortion: r.nutritionPerPortion, + ingredients: r.candidate.ingredients.map((i) => i.canonicalIngredientId), + })), + }; + + console.log(JSON.stringify(report, null, 2)); + + // Markdown-sammanfattning till stderr + console.error("\n# Pilotbatch-sammanfattning\n"); + console.error(`- Genererade kandidater: ${result.candidates.length}`); + console.error(`- Verified: ${result.verifiedCount}`); + console.error(`- Unverified: ${result.unverifiedCount}`); + console.error(`- Rejected: ${result.rejectedCount}`); + console.error(`- Gemini-kostnad: $${(result.geminiResult.costUsd ?? 0).toFixed(4)}`); + + if (result.geminiResult.error) { + console.error(`\n**Fel:** ${result.geminiResult.error}`); + } + + if (result.candidates.length === 0 && result.geminiResult.status === "ok") { + console.error("\n**Notering:** Gemini returnerade ok men inga kandidater. " + + "Mock-klienten stödjer inte GENERATE_RECIPE_CANDIDATES ännu — " + + "kör med AAMOS_MODE=gemini för live-generering."); + } +} + +main().catch((err) => { + console.error("[pilot-batch] Fatal:", err); + process.exit(1); +}); diff --git a/packages/recipe-generation/src/gap-report.ts b/packages/recipe-generation/src/gap-report.ts new file mode 100644 index 0000000..7352419 --- /dev/null +++ b/packages/recipe-generation/src/gap-report.ts @@ -0,0 +1,173 @@ +/** + * Katalog-gaprapport (docs/32 §4). + * + * Byggs ur consent-gatad nollträff-bankning + cook/rating-signaler. + * Identifierar vad som saknas i receptkatalogen så påfyllningen kan + * styras mot verklig efterfrågan. + */ + +import type { GapReport, GapReportEntry } from "./types.js"; + +export interface AnalyticsEvent { + eventName: string; + occurredAt: Date; + properties: Record; +} + +export interface RecipeSignal { + recipeId: string | null; + titleSv: string | null; + cookCount: number; + ratingAverage: number | null; + ratingCount: number; + searchQuery?: string | null; +} + +export interface GapReportOptions { + /** Minsta antal nollträffar för att en sökning ska räknas. */ + minMissThreshold?: number; + /** Hur långt bak i tiden (dagar). */ + lookbackDays?: number; + /** Max antal entries i rapporten. */ + maxEntries?: number; +} + +const DEFAULT_GAP_OPTIONS: Required = { + minMissThreshold: 2, + lookbackDays: 30, + maxEntries: 50, +}; + +/** + * Bygg gaprapport ur analytics-events och receptsignaler. + * + * @param searchMissEvents productAnalyticsEvents med eventName = "recipe_search_zero_results" + * @param cookSignals recipe_cooks aggregerade per recept + * @param ratingSignals recipe_ratings aggregerade per recept + * @param options Filter och begränsningar + */ +export function buildGapReport( + searchMissEvents: AnalyticsEvent[], + cookSignals: RecipeSignal[], + ratingSignals: RecipeSignal[], + options: GapReportOptions = {}, +): GapReport { + const opts = { ...DEFAULT_GAP_OPTIONS, ...options }; + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - opts.lookbackDays); + + // ── 1. Samla nollträffar ───────────────────────────────────────────────── + const missMap = new Map }>(); + + for (const ev of searchMissEvents) { + if (ev.occurredAt < cutoff) continue; + + const query = String(ev.properties?.query ?? "").toLowerCase().trim(); + if (!query) continue; + + const ingredientId = ev.properties?.suggestedIngredientId + ? String(ev.properties.suggestedIngredientId) + : null; + const mealType = ev.properties?.mealType + ? String(ev.properties.mealType) + : null; + + const key = `${query}::${ingredientId ?? "_"}::${mealType ?? "_"}`; + const existing = missMap.get(key); + if (existing) { + existing.count++; + existing.queries.add(query); + if (ev.occurredAt > existing.lastAt) existing.lastAt = ev.occurredAt; + } else { + missMap.set(key, { + count: 1, + lastAt: ev.occurredAt, + queries: new Set([query]), + }); + } + } + + // ── 2. Filtrera och rangordna ──────────────────────────────────────────── + const entries: GapReportEntry[] = []; + + for (const [key, data] of missMap) { + if (data.count < opts.minMissThreshold) continue; + + const parts = key.split("::"); + const ingredientId = parts[1] ?? null; + const mealType = parts[2] ?? null; + + entries.push({ + searchQuery: [...data.queries][0] ?? "", + missCount: data.count, + lastMissedAt: data.lastAt, + suggestedIngredientId: ingredientId === "_" ? null : ingredientId, + suggestedMealType: mealType === "_" ? null : mealType, + priority: data.count >= 10 ? "high" : data.count >= 5 ? "medium" : "low", + }); + } + + // Sortera: high → medium → low, sedan antal + entries.sort((a, b) => { + const pOrder = { high: 0, medium: 1, low: 2 }; + if (pOrder[a.priority] !== pOrder[b.priority]) { + return pOrder[a.priority] - pOrder[b.priority]; + } + return b.missCount - a.missCount; + }); + + const limitedEntries = entries.slice(0, opts.maxEntries); + + // ── 3. Aggregera topp-saknade ──────────────────────────────────────────── + const ingredientCounts = new Map(); + const mealTypeCounts = new Map(); + + for (const e of limitedEntries) { + ingredientCounts.set(e.suggestedIngredientId, (ingredientCounts.get(e.suggestedIngredientId) ?? 0) + e.missCount); + mealTypeCounts.set(e.suggestedMealType, (mealTypeCounts.get(e.suggestedMealType) ?? 0) + e.missCount); + } + + const topMissingIngredients = [...ingredientCounts.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) + .map(([id, count]) => ({ canonicalIngredientId: id as string | null, count })); + + const topMissingMealTypes = [...mealTypeCounts.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) + .map(([mealType, count]) => ({ mealType: mealType as string | null, count })); + + return { + generatedAt: new Date(), + entries: limitedEntries, + topMissingIngredients, + topMissingMealTypes, + }; +} + +/** + * Formatera gaprapporten som läsbar text för granskning. + */ +export function formatGapReport(report: GapReport): string { + const lines: string[] = [ + `# Katalog-gaprapport`, + `Genererad: ${report.generatedAt.toISOString()}`, + ``, + `## Topp-saknade ingredienser`, + ...report.topMissingIngredients.map( + (i) => `- ${i.canonicalIngredientId ?? "(okänd)"}: ${i.count} nollträffar`, + ), + ``, + `## Topp-saknade måltidstyper`, + ...report.topMissingMealTypes.map( + (m) => `- ${m.mealType ?? "(okänd)"}: ${m.count} nollträffar`, + ), + ``, + `## Detaljer (${report.entries.length} entries)`, + ...report.entries.map( + (e) => + `- [${e.priority.toUpperCase()}] "${e.searchQuery}" — ${e.missCount} missar, senast ${e.lastMissedAt.toISOString().slice(0, 10)}${e.suggestedIngredientId ? ` (ingrediens: ${e.suggestedIngredientId})` : ""}${e.suggestedMealType ? ` (måltid: ${e.suggestedMealType})` : ""}`, + ), + ]; + return lines.join("\n"); +} diff --git a/packages/recipe-generation/src/index.ts b/packages/recipe-generation/src/index.ts new file mode 100644 index 0000000..bd1b519 --- /dev/null +++ b/packages/recipe-generation/src/index.ts @@ -0,0 +1,4 @@ +export * from "./types.js"; +export * from "./verification.js"; +export * from "./pipeline.js"; +export * from "./gap-report.js"; diff --git a/packages/recipe-generation/src/pipeline.ts b/packages/recipe-generation/src/pipeline.ts new file mode 100644 index 0000000..7d43452 --- /dev/null +++ b/packages/recipe-generation/src/pipeline.ts @@ -0,0 +1,173 @@ +/** + * Genereringspipeline för receptkandidater (docs/32 §2–3). + * + * - Gemini genererar svenska vardagsrecept KONSTRUERADE mot BEFINTLIGA + * canonical_ingredients. + * - Hitta aldrig på ingredienser utanför katalogen. + * - Varje kandidat passerar verifieringspipelinen. + * - Ingen kandidat blir verified utan att passera alla grindar. + */ + +import type { AamosClient, AamosResult } from "@app/ai-contracts"; +import type { + TaskInput, + TaskOutput, +} from "@app/ai-contracts"; +import type { RecipeCandidate, VerificationResult } from "./types.js"; +import { verifyCandidate, type CanonicalIngredientLookup, type SimilarityLookup } from "./verification.js"; + +export interface PipelineIngredient { + id: string; + nameSv: string; + category: string; + defaultUnit: string; + isVegan: boolean; + isVegetarian: boolean; + containsGluten: boolean; + containsLactose: boolean; + allergens: string[]; +} + +export interface PipelineTarget { + mealType: string; + mainIngredientId: string; + dietVariant: "standard" | "vegetarian" | "vegan" | "gluten_free" | "lactose_free"; + count: number; +} + +export interface PipelineOptions { + /** Max förberedelsetid i minuter. */ + maxPrepTimeMinutes?: number; + /** Max koktid i minuter. */ + maxCookTimeMinutes?: number; + /** Antal portioner. */ + portions?: number; + /** Max kryddnivå. */ + spiceLevelMax?: number; + /** Ingredienser att undvika. */ + avoidIngredients?: string[]; + /** Locale, default sv-SE. */ + marketLocale?: string; +} + +export interface PipelineResult { + candidates: RecipeCandidate[]; + verificationResults: VerificationResult[]; + verifiedCount: number; + unverifiedCount: number; + rejectedCount: number; + geminiResult: AamosResult<"GENERATE_RECIPE_CANDIDATES">; +} + +/** + * Kör genereringspipelinen: Gemini → verifiering. + * + * @param client AamosClient (Gemini, mock, eller HTTP) + * @param targets Vad som ska genereras + * @param catalog Tillgängliga canonical_ingredients + * @param ingredients Lookup för verifiering + * @param similarity Lookup för dedup + * @param options Begränsningar + */ +export async function runPipeline( + client: AamosClient, + targets: PipelineTarget[], + catalog: PipelineIngredient[], + ingredients: CanonicalIngredientLookup, + similarity: SimilarityLookup | null, + options: PipelineOptions = {}, +): Promise { + const input: TaskInput<"GENERATE_RECIPE_CANDIDATES"> = { + targetMatrix: targets.map((t) => ({ + mealType: t.mealType, + mainIngredientId: t.mainIngredientId, + dietVariant: t.dietVariant, + count: t.count, + })), + canonicalIngredientsCatalog: catalog.map((i) => ({ + id: i.id, + nameSv: i.nameSv, + category: i.category, + defaultUnit: i.defaultUnit as import("@app/shared-types").Unit, + isVegan: i.isVegan, + isVegetarian: i.isVegetarian, + containsGluten: i.containsGluten, + containsLactose: i.containsLactose, + allergens: i.allergens, + })), + constraints: { + maxPrepTimeMinutes: options.maxPrepTimeMinutes ?? 60, + maxCookTimeMinutes: options.maxCookTimeMinutes ?? 45, + portions: options.portions ?? 4, + spiceLevelMax: options.spiceLevelMax ?? 3, + avoidIngredients: options.avoidIngredients ?? [], + }, + marketLocale: options.marketLocale ?? "sv-SE", + }; + + const geminiResult = await client.runTask("GENERATE_RECIPE_CANDIDATES", input); + + if (geminiResult.status !== "ok" || !geminiResult.output) { + return { + candidates: [], + verificationResults: [], + verifiedCount: 0, + unverifiedCount: 0, + rejectedCount: 0, + geminiResult, + }; + } + + const output = geminiResult.output as TaskOutput<"GENERATE_RECIPE_CANDIDATES">; + + // Mappa Gemini-output till interna typer + const candidates: RecipeCandidate[] = output.candidates.map((c, idx) => ({ + titleSv: c.titleSv, + descriptionSv: c.descriptionSv, + cuisine: c.cuisine, + mealTypes: c.mealTypes, + prepTimeMinutes: c.prepTimeMinutes, + cookTimeMinutes: c.cookTimeMinutes, + totalTimeMinutes: c.prepTimeMinutes + c.cookTimeMinutes, + portions: c.portions, + spiceLevel: c.spiceLevel, + ingredients: c.ingredients.map((ing) => ({ + canonicalIngredientId: ing.canonicalIngredientId, + displayNameSv: ing.displayNameSv, + quantity: ing.quantity, + unit: ing.unit, + optional: ing.optional, + note: ing.note, + })), + steps: c.steps.map((s, sIdx) => ({ + stepNumber: sIdx + 1, + instructionSv: s.instructionSv, + timerSeconds: s.timerSeconds, + temperatureC: s.temperatureC, + tip: s.tip, + })), + storageGuidanceSv: c.storageGuidanceSv, + mealPrepFriendly: c.mealPrepFriendly, + freezerFriendly: c.freezerFriendly, + sourceType: "ai_generated" as const, + confidence: c.confidence, + })); + + // Kör verifieringspipelinen + const verificationResults = await Promise.all( + candidates.map((c) => verifyCandidate(c, ingredients, similarity)), + ); + + const verifiedCount = verificationResults.filter((r) => r.status === "verified").length; + const unverifiedCount = verificationResults.filter((r) => r.status === "unverified").length; + const rejectedCount = verificationResults.filter((r) => r.status === "rejected").length; + + return { + candidates, + verificationResults, + verifiedCount, + unverifiedCount, + rejectedCount, + geminiResult, + }; +} diff --git a/packages/recipe-generation/src/types.ts b/packages/recipe-generation/src/types.ts new file mode 100644 index 0000000..8e9b70c --- /dev/null +++ b/packages/recipe-generation/src/types.ts @@ -0,0 +1,81 @@ +/** + * Typer för AI-assisterad receptgenerering (docs/32). + * Ingen PII. Alla receptkandidater passerar verifieringspipelinen. + */ + +export interface RecipeCandidate { + titleSv: string; + descriptionSv: string; + cuisine: string | null; + mealTypes: string[]; + prepTimeMinutes: number; + cookTimeMinutes: number; + totalTimeMinutes: number; + portions: number; + spiceLevel: number; + ingredients: RecipeCandidateIngredient[]; + steps: RecipeCandidateStep[]; + storageGuidanceSv: string | null; + mealPrepFriendly: boolean; + freezerFriendly: boolean; + sourceType: "ai_generated"; + confidence: number; +} + +export interface RecipeCandidateIngredient { + canonicalIngredientId: string; + displayNameSv: string; + quantity: number; + unit: string; + optional: boolean; + note: string | null; +} + +export interface RecipeCandidateStep { + stepNumber: number; + instructionSv: string; + timerSeconds: number | null; + temperatureC: number | null; + tip: string | null; +} + +export interface VerificationResult { + candidate: RecipeCandidate; + status: "verified" | "unverified" | "rejected"; + reasons: string[]; + nutritionPerPortion: import("@app/shared-types").NutritionValues | null; + allergens: string[]; + canonicalIngredientIds: string[]; +} + +export interface GapReportEntry { + searchQuery: string; + missCount: number; + lastMissedAt: Date; + suggestedIngredientId: string | null; + suggestedMealType: string | null; + priority: "high" | "medium" | "low"; +} + +export interface GapReport { + generatedAt: Date; + entries: GapReportEntry[]; + topMissingIngredients: Array<{ canonicalIngredientId: string | null; count: number }>; + topMissingMealTypes: Array<{ mealType: string | null; count: number }>; +} + +export interface PilotBatchResult { + batchId: string; + generatedAt: Date; + targetMatrix: Array<{ + mealType: string; + mainIngredientId: string; + dietVariant: string; + count: number; + }>; + candidates: VerificationResult[]; + verifiedCount: number; + unverifiedCount: number; + rejectedCount: number; + totalGeminiCostUsd: number; +} diff --git a/packages/recipe-generation/src/verification.ts b/packages/recipe-generation/src/verification.ts new file mode 100644 index 0000000..f36612a --- /dev/null +++ b/packages/recipe-generation/src/verification.ts @@ -0,0 +1,234 @@ +/** + * Verifieringspipeline för AI-genererade receptkandidater (docs/32 §3). + * + * Obligatorisk grind — inget blir verified utan att passera alla steg: + * 1. Ingrediensmappning mot canonical_ingredients (hård spärr). + * 2. Allergener HÄRLEDDA ur ingredienserna (aldrig AI-påstådda). + * 3. Näring BERÄKNAD via nutrition-engine (aldrig AI-siffra). + * 4. Språk-/kvalitetskontroll (rimliga steg/tider). + * 5. Dedup mot recipe_similarities. + * + * Först då verificationStatus=verified; annars unverified & osynlig. + */ + +import { + computeRecipeNutrition, + type RecipeIngredientForCalc, + type IngredientNutritionSource, +} from "@app/nutrition-engine"; +import { + deriveRecipeAllergens, + type IngredientSafetyInfo, +} from "@app/recipe-engine"; +import type { NutritionValues, Allergen } from "@app/shared-types"; +import type { RecipeCandidate, VerificationResult } from "./types.js"; + +export interface CanonicalIngredientLookup { + getById(id: string): + | { + id: string; + nutritionPer100: import("@app/shared-types").NutritionDeclaration; + defaultUnit: import("@app/shared-types").Unit; + densityGPerMl?: number | null; + gramsPerPiece?: number | null; + allergens: Allergen[]; + isVegan: boolean; + isVegetarian: boolean; + containsGluten: boolean; + containsLactose: boolean; + isPork: boolean; + isBeef: boolean; + isAlcohol: boolean; + } + | undefined; +} + +export interface SimilarityLookup { + hasSimilarity(recipeTitle: string): Promise; +} + +export interface VerificationOptions { + /** Max tillåten förberedelsetid (minuter). */ + maxPrepTimeMinutes?: number; + /** Max tillåten koktid (minuter). */ + maxCookTimeMinutes?: number; + /** Max total tid (minuter). */ + maxTotalTimeMinutes?: number; + /** Minsta antal steg. */ + minSteps?: number; + /** Minsta antal ingredienser. */ + minIngredients?: number; + /** Om true, krävs att receptet har minst en icke-valfri protein-källa. */ + requireProtein?: boolean; +} + +const DEFAULT_OPTIONS: Required = { + maxPrepTimeMinutes: 120, + maxCookTimeMinutes: 180, + maxTotalTimeMinutes: 240, + minSteps: 2, + minIngredients: 3, + requireProtein: false, +}; + +/** + * Kör en kandidat genom verifieringspipelinen. + * Returnerar alltid ett VerificationResult; status sätts beroende på om + * alla grindar passerades. + */ +export async function verifyCandidate( + candidate: RecipeCandidate, + ingredients: CanonicalIngredientLookup, + similarity: SimilarityLookup | null, + options: VerificationOptions = {}, +): Promise { + const opts = { ...DEFAULT_OPTIONS, ...options }; + const reasons: string[] = []; + + // ── 1. Ingrediensmappning (hård spärr) ─────────────────────────────────── + const canonicalIds: string[] = []; + const nutritionSources = new Map(); + const safetyInfos = new Map(); + + for (const ing of candidate.ingredients) { + const canonical = ingredients.getById(ing.canonicalIngredientId); + if (!canonical) { + reasons.push( + `Ingrediens "${ing.canonicalIngredientId}" finns inte i canonical_ingredients — hård spärr.`, + ); + continue; + } + canonicalIds.push(ing.canonicalIngredientId); + + // Bygg nutrition source för steg 3 + nutritionSources.set(ing.canonicalIngredientId, { + densityGPerMl: canonical.densityGPerMl ?? undefined, + gramsPerPiece: canonical.gramsPerPiece ?? undefined, + nutritionPer100: canonical.nutritionPer100, + }); + + // Bygg safety info för steg 2 + safetyInfos.set(ing.canonicalIngredientId, { + id: canonical.id, + allergens: canonical.allergens, + isVegan: canonical.isVegan, + isVegetarian: canonical.isVegetarian, + containsGluten: canonical.containsGluten, + containsLactose: canonical.containsLactose, + isPork: canonical.isPork, + isBeef: canonical.isBeef, + isAlcohol: canonical.isAlcohol, + dataVerified: true, + }); + } + + // Om någon ingrediens saknas → avvisat direkt + if (reasons.length > 0) { + return { + candidate, + status: "rejected", + reasons, + nutritionPerPortion: null, + allergens: [], + canonicalIngredientIds: canonicalIds, + }; + } + + // ── 2. Allergener (härledda, aldrig AI) ────────────────────────────────── + const allergens = deriveRecipeAllergens(canonicalIds, safetyInfos); + + // ── 3. Näring (beräknad via nutrition-engine) ──────────────────────────── + const calcIngredients: RecipeIngredientForCalc[] = candidate.ingredients.map((ing) => ({ + canonicalIngredientId: ing.canonicalIngredientId, + quantity: ing.quantity, + unit: ing.unit as import("@app/shared-types").Unit, + optional: ing.optional, + })); + + const nutritionResult = computeRecipeNutrition( + calcIngredients, + candidate.portions, + nutritionSources, + ); + + if (nutritionResult.uncomputableIngredientIds.length > 0) { + reasons.push( + `Näring kunde inte beräknas för: ${nutritionResult.uncomputableIngredientIds.join(", ")}`, + ); + } + + // ── 4. Språk-/kvalitetskontroll ────────────────────────────────────────── + if (candidate.prepTimeMinutes > opts.maxPrepTimeMinutes) { + reasons.push( + `Förberedelsetid ${candidate.prepTimeMinutes} min överstiger max ${opts.maxPrepTimeMinutes} min.`, + ); + } + if (candidate.cookTimeMinutes > opts.maxCookTimeMinutes) { + reasons.push( + `Koktid ${candidate.cookTimeMinutes} min överstiger max ${opts.maxCookTimeMinutes} min.`, + ); + } + const totalTime = candidate.prepTimeMinutes + candidate.cookTimeMinutes; + if (totalTime > opts.maxTotalTimeMinutes) { + reasons.push(`Total tid ${totalTime} min överstiger max ${opts.maxTotalTimeMinutes} min.`); + } + if (candidate.steps.length < opts.minSteps) { + reasons.push(`Endast ${candidate.steps.length} steg — minst ${opts.minSteps} krävs.`); + } + if (candidate.ingredients.length < opts.minIngredients) { + reasons.push( + `Endast ${candidate.ingredients.length} ingredienser — minst ${opts.minIngredients} krävs.`, + ); + } + if (candidate.totalTimeMinutes !== totalTime) { + reasons.push( + `totalTimeMinutes (${candidate.totalTimeMinutes}) matchar inte prep+cook (${totalTime}).`, + ); + } + + // Kontrollera att stegen är rimliga + for (const step of candidate.steps) { + if (!step.instructionSv || step.instructionSv.length < 10) { + reasons.push(`Steg ${step.stepNumber} har för kort instruktion.`); + } + if (step.timerSeconds != null && step.timerSeconds < 0) { + reasons.push(`Steg ${step.stepNumber} har negativ timer.`); + } + if (step.temperatureC != null && (step.temperatureC < 0 || step.temperatureC > 350)) { + reasons.push(`Steg ${step.stepNumber} har orimlig temperatur (${step.temperatureC}°C).`); + } + } + + // ── 5. Dedup mot befintliga recept ──────────────────────────────────────── + if (similarity) { + const isDup = await similarity.hasSimilarity(candidate.titleSv); + if (isDup) { + reasons.push(`Titel "${candidate.titleSv}" flaggad som potentiell dubblett.`); + } + } + + // ── Resultat ───────────────────────────────────────────────────────────── + const status: VerificationResult["status"] = + reasons.length === 0 ? "verified" : "unverified"; + + return { + candidate, + status, + reasons, + nutritionPerPortion: nutritionResult.perPortion, + allergens, + canonicalIngredientIds: canonicalIds, + }; +} + +/** + * Kör en batch kandidater genom pipelinen. + */ +export async function verifyBatch( + candidates: RecipeCandidate[], + ingredients: CanonicalIngredientLookup, + similarity: SimilarityLookup | null, + options?: VerificationOptions, +): Promise { + return Promise.all(candidates.map((c) => verifyCandidate(c, ingredients, similarity, options))); +} diff --git a/packages/recipe-generation/test/gap-report.test.ts b/packages/recipe-generation/test/gap-report.test.ts new file mode 100644 index 0000000..2f9b412 --- /dev/null +++ b/packages/recipe-generation/test/gap-report.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from "vitest"; +import { buildGapReport, formatGapReport } from "../src/gap-report.js"; +import type { AnalyticsEvent, RecipeSignal } from "../src/gap-report.js"; + +describe("buildGapReport", () => { + it("identifierar topp-saknade ingredienser", () => { + const events: AnalyticsEvent[] = [ + { eventName: "recipe_search_zero_results", occurredAt: new Date(), properties: { query: "lax", suggestedIngredientId: "laxfile" } }, + { eventName: "recipe_search_zero_results", occurredAt: new Date(), properties: { query: "lax", suggestedIngredientId: "laxfile" } }, + { eventName: "recipe_search_zero_results", occurredAt: new Date(), properties: { query: "lax", suggestedIngredientId: "laxfile" } }, + { eventName: "recipe_search_zero_results", occurredAt: new Date(), properties: { query: "tofu", suggestedIngredientId: "fast_tofu" } }, + { eventName: "recipe_search_zero_results", occurredAt: new Date(), properties: { query: "tofu", suggestedIngredientId: "fast_tofu" } }, + ]; + + const report = buildGapReport(events, [], []); + expect(report.entries).toHaveLength(2); + expect(report.topMissingIngredients[0]?.canonicalIngredientId).toBe("laxfile"); + expect(report.topMissingIngredients[0]?.count).toBe(3); + }); + + it("filtrerar bort enstaka missar", () => { + const events: AnalyticsEvent[] = [ + { eventName: "recipe_search_zero_results", occurredAt: new Date(), properties: { query: "enstaka" } }, + ]; + const report = buildGapReport(events, [], [], { minMissThreshold: 2 }); + expect(report.entries).toHaveLength(0); + }); + + it("formaterar rapporten", () => { + const events: AnalyticsEvent[] = [ + { eventName: "recipe_search_zero_results", occurredAt: new Date(), properties: { query: "lax" } }, + { eventName: "recipe_search_zero_results", occurredAt: new Date(), properties: { query: "lax" } }, + ]; + const report = buildGapReport(events, [], [], { minMissThreshold: 1 }); + const text = formatGapReport(report); + expect(text).toContain("Katalog-gaprapport"); + expect(text).toContain("lax"); + }); +}); diff --git a/packages/recipe-generation/test/pipeline.test.ts b/packages/recipe-generation/test/pipeline.test.ts new file mode 100644 index 0000000..2be7153 --- /dev/null +++ b/packages/recipe-generation/test/pipeline.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from "vitest"; +import { MockAamosClient } from "@app/ai-contracts"; +import { runPipeline, type PipelineTarget, type PipelineIngredient } from "../src/pipeline.js"; +import type { CanonicalIngredientLookup, SimilarityLookup } from "../src/verification.js"; + +const mockClient = new MockAamosClient(); + +const mockIngredients: CanonicalIngredientLookup = { + getById(id: string) { + const db: Record> = { + kycklingfile: { + id: "kycklingfile", + nutritionPer100: { + basis: "per_100_g", + values: { + kcal: 165, proteinG: 31, carbsG: 0, fatG: 3.6, + saturatedFatG: 1, fiberG: 0, sugarG: 0, saltG: 0.1, + }, + }, + defaultUnit: "GRAM", + densityGPerMl: null, + gramsPerPiece: null, + allergens: [], + isVegan: false, + isVegetarian: false, + containsGluten: false, + containsLactose: false, + isPork: false, + isBeef: false, + isAlcohol: false, + }, + }; + return db[id] ?? undefined; + }, +}; + +const noSimilarity: SimilarityLookup = { + async hasSimilarity() { + return false; + }, +}; + +const catalog: PipelineIngredient[] = [ + { + id: "kycklingfile", + nameSv: "kycklingfilé", + category: "kott_fagel", + defaultUnit: "GRAM", + isVegan: false, + isVegetarian: false, + containsGluten: false, + containsLactose: false, + allergens: [], + }, +]; + +const targets: PipelineTarget[] = [ + { mealType: "dinner", mainIngredientId: "kycklingfile", dietVariant: "standard", count: 2 }, +]; + +describe("runPipeline", () => { + it("kör med mockad klient och returnerar resultat", async () => { + const result = await runPipeline(mockClient, targets, catalog, mockIngredients, noSimilarity); + expect(result.geminiResult.status).toBe("ok"); + // MockAamosClient returnerar nu kandidater för GENERATE_RECIPE_CANDIDATES + expect(result.candidates.length).toBeGreaterThan(0); + expect(result.verifiedCount + result.unverifiedCount + result.rejectedCount).toBe(result.candidates.length); + }); +}); diff --git a/packages/recipe-generation/test/verification.test.ts b/packages/recipe-generation/test/verification.test.ts new file mode 100644 index 0000000..c76ffb7 --- /dev/null +++ b/packages/recipe-generation/test/verification.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect } from "vitest"; +import { verifyCandidate, type CanonicalIngredientLookup, type SimilarityLookup } from "../src/verification.js"; +import type { RecipeCandidate } from "../src/types.js"; + +const mockIngredients: CanonicalIngredientLookup = { + getById(id: string) { + const db: Record> = { + kycklingfile: { + id: "kycklingfile", + nutritionPer100: { + basis: "per_100_g", + values: { + kcal: 165, proteinG: 31, carbsG: 0, fatG: 3.6, + saturatedFatG: 1, fiberG: 0, sugarG: 0, saltG: 0.1, + }, + }, + defaultUnit: "GRAM", + densityGPerMl: null, + gramsPerPiece: null, + allergens: [], + isVegan: false, + isVegetarian: false, + containsGluten: false, + containsLactose: false, + isPork: false, + isBeef: false, + isAlcohol: false, + }, + ris: { + id: "ris", + nutritionPer100: { + basis: "per_100_g", + values: { + kcal: 130, proteinG: 2.7, carbsG: 28, fatG: 0.3, + saturatedFatG: 0.1, fiberG: 0.4, sugarG: 0.1, saltG: 0, + }, + }, + defaultUnit: "GRAM", + densityGPerMl: null, + gramsPerPiece: null, + allergens: [], + isVegan: true, + isVegetarian: true, + containsGluten: false, + containsLactose: false, + isPork: false, + isBeef: false, + isAlcohol: false, + }, + "krossade_tomater": { + id: "krossade_tomater", + nutritionPer100: { + basis: "per_100_g", + values: { + kcal: 32, proteinG: 1.6, carbsG: 5.8, fatG: 0.3, + saturatedFatG: 0, fiberG: 1.2, sugarG: 4, saltG: 0.1, + }, + }, + defaultUnit: "GRAM", + densityGPerMl: null, + gramsPerPiece: null, + allergens: [], + isVegan: true, + isVegetarian: true, + containsGluten: false, + containsLactose: false, + isPork: false, + isBeef: false, + isAlcohol: false, + }, + }; + return db[id] ?? undefined; + }, +}; + +const noSimilarity: SimilarityLookup = { + async hasSimilarity() { + return false; + }, +}; + +function makeCandidate(overrides?: Partial): RecipeCandidate { + return { + titleSv: "Testrecept", + descriptionSv: "Ett enkelt testrecept.", + cuisine: "swedish", + mealTypes: ["dinner"], + prepTimeMinutes: 10, + cookTimeMinutes: 20, + totalTimeMinutes: 30, + portions: 4, + spiceLevel: 1, + ingredients: [ + { canonicalIngredientId: "kycklingfile", displayNameSv: "kycklingfilé", quantity: 500, unit: "GRAM", optional: false, note: null }, + { canonicalIngredientId: "ris", displayNameSv: "ris", quantity: 300, unit: "GRAM", optional: false, note: null }, + { canonicalIngredientId: "krossade_tomater", displayNameSv: "krossade tomater", quantity: 400, unit: "GRAM", optional: false, note: null }, + ], + steps: [ + { stepNumber: 1, instructionSv: "Stek kycklingen i en panna.", timerSeconds: null, temperatureC: null, tip: null }, + { stepNumber: 2, instructionSv: "Tillsätt tomater och ris, låt koka.", timerSeconds: 900, temperatureC: null, tip: null }, + ], + storageGuidanceSv: "Förvara i kylskåp upp till 3 dagar.", + mealPrepFriendly: false, + freezerFriendly: false, + sourceType: "ai_generated", + confidence: 0.9, + ...overrides, + }; +} + +describe("verifyCandidate", () => { + it("passerar en giltig kandidat som verified", async () => { + const result = await verifyCandidate(makeCandidate(), mockIngredients, noSimilarity); + expect(result.status).toBe("verified"); + expect(result.reasons).toHaveLength(0); + expect(result.allergens).toHaveLength(0); + expect(result.nutritionPerPortion).not.toBeNull(); + expect(result.nutritionPerPortion!.kcal).toBeGreaterThan(0); + }); + + it("avvisar kandidat med okänd ingrediens", async () => { + const c = makeCandidate({ + ingredients: [ + { canonicalIngredientId: "fantasi_gronsak", displayNameSv: "fantasigrönsak", quantity: 100, unit: "GRAM", optional: false, note: null }, + ], + }); + const result = await verifyCandidate(c, mockIngredients, noSimilarity); + expect(result.status).toBe("rejected"); + expect(result.reasons.some((r) => r.includes("fantasi_gronsak"))).toBe(true); + }); + + it("markerar unverified vid orimlig tid", async () => { + const c = makeCandidate({ prepTimeMinutes: 200, cookTimeMinutes: 10, totalTimeMinutes: 210 }); + const result = await verifyCandidate(c, mockIngredients, noSimilarity, { maxPrepTimeMinutes: 60 }); + expect(result.status).toBe("unverified"); + expect(result.reasons.some((r) => r.includes("Förberedelsetid"))).toBe(true); + }); + + it("markerar unverified vid för få steg", async () => { + const c = makeCandidate({ steps: [{ stepNumber: 1, instructionSv: "Gör allt.", timerSeconds: null, temperatureC: null, tip: null }] }); + const result = await verifyCandidate(c, mockIngredients, noSimilarity, { minSteps: 2 }); + expect(result.status).toBe("unverified"); + expect(result.reasons.some((r) => r.includes("steg"))).toBe(true); + }); + + it("härleder allergener korrekt", async () => { + const c = makeCandidate({ + ingredients: [ + { canonicalIngredientId: "kycklingfile", displayNameSv: "kycklingfilé", quantity: 500, unit: "GRAM", optional: false, note: null }, + ], + }); + const result = await verifyCandidate(c, mockIngredients, noSimilarity); + expect(result.allergens).toEqual([]); + }); + + it("detekterar dubblett", async () => { + const dupSimilarity: SimilarityLookup = { + async hasSimilarity() { + return true; + }, + }; + const result = await verifyCandidate(makeCandidate(), mockIngredients, dupSimilarity); + expect(result.status).toBe("unverified"); + expect(result.reasons.some((r) => r.includes("dubblett"))).toBe(true); + }); +}); diff --git a/packages/recipe-generation/tsconfig.json b/packages/recipe-generation/tsconfig.json new file mode 100644 index 0000000..ab1b34a --- /dev/null +++ b/packages/recipe-generation/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": ".", + "tsBuildInfoFile": "./.tsbuildinfo", + "types": ["node"] + }, + "include": ["src/**/*", "test/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 08a6ba9..2349cd0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -409,6 +409,40 @@ importers: specifier: workspace:* version: link:../shared-types + packages/recipe-generation: + dependencies: + '@app/ai-contracts': + specifier: workspace:* + version: link:../ai-contracts + '@app/database': + specifier: workspace:* + version: link:../database + '@app/nutrition-engine': + specifier: workspace:* + version: link:../nutrition-engine + '@app/recipe-engine': + specifier: workspace:* + version: link:../recipe-engine + '@app/shared-types': + specifier: workspace:* + version: link:../shared-types + drizzle-orm: + specifier: ^0.41.0 + version: 0.41.0(@types/pg@8.20.3)(pg@8.22.0) + zod: + specifier: ^4.4.0 + version: 4.4.3 + devDependencies: + '@types/node': + specifier: ^22.10.0 + version: 22.20.1 + typescript: + specifier: ~5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.0.0 + version: 4.1.10(@types/node@22.20.1)(vite@6.4.3(@types/node@22.20.1)(lightningcss@1.33.0)(terser@5.49.0)(tsx@4.23.4)(yaml@2.9.0)) + packages/recommendation-engine: dependencies: '@app/recipe-engine': @@ -3279,6 +3313,95 @@ packages: resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==} hasBin: true + drizzle-orm@0.41.0: + resolution: {integrity: sha512-7A4ZxhHk9gdlXmTdPj/lREtP+3u8KvZ4yEN6MYVxBzZGex5Wtdc+CWSbu7btgF6TB0N+MNPrvW7RKBbxJchs/Q==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=4' + '@electric-sql/pglite': '>=0.2.0' + '@libsql/client': '>=0.10.0' + '@libsql/client-wasm': '>=0.10.0' + '@neondatabase/serverless': '>=0.10.0' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/sql.js': '*' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=14.0.0' + gel: '>=2' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@libsql/client-wasm': + optional: true + '@neondatabase/serverless': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/sql.js': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + gel: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + drizzle-orm@0.45.2: resolution: {integrity: sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==} peerDependencies: @@ -8871,6 +8994,11 @@ snapshots: esbuild: 0.25.12 tsx: 4.23.4 + drizzle-orm@0.41.0(@types/pg@8.20.3)(pg@8.22.0): + optionalDependencies: + '@types/pg': 8.20.3 + pg: 8.22.0 + drizzle-orm@0.45.2(@types/pg@8.20.3)(pg@8.22.0): optionalDependencies: '@types/pg': 8.20.3