Files
Epicure/apps/web/lib/ai/features/generate-meal.ts
T
Arnaud 8f83a1eaae feat: generate a complete themed meal from a collection (v0.52.0)
New "Generate meal" button on any owned collection — pick a theme (free
text) and 2-6 courses (starter/main/side/dessert/drink), one generateObject
call produces a coherent recipe per course (shared cuisine/style, matching
flavors across courses) and all of them get saved as real recipes and
added to that collection in one action.

Follows the meal-plan generation route's established pattern: single
withAiQuota charge for the whole generation, then a pre-flight loop
charging the tier's recipe limit once per generated recipe (rolled back
on a partial breach) before the insert transaction runs. Recipes are
tagged with their course name for later filtering; visibility defaults to
private like every other AI-generated recipe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 12:16:28 +02:00

81 lines
3.7 KiB
TypeScript

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<typeof MealOutputSchema>;
const LANG: Record<string, string> = { 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<GeneratedMeal> {
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)),
};
}