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>
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
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)),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user