/** * 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, }; }