import { generateObject } from "ai"; import { z } from "zod"; import { resolveModel, type AiConfig } from "../factory"; import { dietaryTagsSchema, ingredientSchema, stepSchema } from "./recipe-schema"; export const MEAL_COURSES = ["starter", "main", "side", "dessert", "drink"] as const; export type MealCourse = (typeof MEAL_COURSES)[number]; const MealOutputSchema = z.object({ recipes: z.array(z.object({ course: z.enum(MEAL_COURSES), recipeType: z.enum(["dish", "drink"]).describe("\"drink\" only for a beverage/cocktail with no cooking step."), title: z.string().max(150), description: z.string().max(300), baseServings: z.number().int().min(1).max(100), prepMins: z.number().int().min(0).max(360).optional(), cookMins: z.number().int().min(0).max(360).optional(), difficulty: z.enum(["easy", "medium", "hard"]).optional(), dietaryTags: dietaryTagsSchema, ingredients: z.array(ingredientSchema(z.number())).max(30), steps: z.array(stepSchema).max(20), })).min(1).max(6), }); export type GeneratedMeal = z.infer; const LANG: Record = { en: "English", fr: "French" }; /** Generates a themed, coherent set of recipes in one call — one per * requested course — so they read as a matching meal (shared cuisine, * complementary flavors) rather than N unrelated recipes. */ export async function generateMeal( options: { theme: string; courses: MealCourse[]; servings?: number; dietaryPrefs?: string; difficulty?: "easy" | "medium" | "hard"; }, config?: AiConfig & { userContext?: string }, locale?: string ): Promise { const model = resolveModel(config); const lang = LANG[locale ?? "en"] ?? "English"; const servings = options.servings ?? 4; const dietaryClause = options.dietaryPrefs?.trim() ? `Dietary requirements: ${options.dietaryPrefs.trim()}.` : ""; const difficultyClause = options.difficulty ? `All recipes must be ${options.difficulty} difficulty: ${{ easy: "simple techniques, few steps, everyday ingredients", medium: "moderate skill, standard techniques", hard: "advanced techniques, multiple components" }[options.difficulty]}.` : ""; const systemPrompt = `You are a professional chef planning a complete, coherent meal — not a list of unrelated recipes. ` + `Generate exactly one recipe per requested course, all sharing a consistent cuisine/style that fits the theme, ` + `with flavors and ingredients that complement each other across courses (e.g. a light starter before a rich main, ` + `a dessert or drink that fits the same culinary tradition). For ingredients: quantity must be a number only ` + `(e.g. 0.25, 1.5, 2), unit is a separate string (e.g. 'cup', 'tbsp', 'g', 'ml') — never combine them. ` + `Set recipeType to "drink" only for a beverage/cocktail with no cooking step; for a drink, omit cookMins and ` + `default baseServings to 1 unless the theme implies otherwise. Respond in ${lang}.` + (config?.userContext ? `\n\nUser preferences and context:\n${config.userContext}` : ""); const { object } = await generateObject({ model, schema: MealOutputSchema, system: systemPrompt, prompt: [ `Theme: ${options.theme.trim()}.`, `Generate these courses, in this order: ${options.courses.join(", ")}.`, `Plan for ${servings} servings per course (except drinks, which default to 1 unless stated otherwise).`, dietaryClause, difficultyClause, ].filter(Boolean).join(" "), }); // Belt-and-suspenders in case the model ignores the drink-specific // instructions above — same rationale as generateRecipe(). return { recipes: object.recipes.map((r) => (r.recipeType === "drink" ? { ...r, cookMins: undefined } : r)), }; }