8f83a1eaae
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>
81 lines
3.7 KiB
TypeScript
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)),
|
|
};
|
|
}
|