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:
Arnaud
2026-07-19 12:16:28 +02:00
parent 1b72135958
commit 8f83a1eaae
11 changed files with 430 additions and 5 deletions
+80
View File
@@ -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)),
};
}
+8 -1
View File
@@ -1,5 +1,5 @@
// Mirrors CHANGELOG.md at the repo root — update both together.
export const APP_VERSION = "0.51.7";
export const APP_VERSION = "0.52.0";
export type ChangelogEntry = {
version: string;
@@ -11,6 +11,13 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.52.0",
date: "2026-07-19 13:55",
added: [
"Generate a complete meal from a collection — pick a theme and 2+ courses (starter, main, side, dessert, drink), AI generates one coherent, matching recipe per course and adds them all to that collection in one go.",
],
},
{
version: "0.51.7",
date: "2026-07-19 13:35",
+1
View File
@@ -319,6 +319,7 @@ export function generateOpenApiSpec(): object {
registry.registerPath({ method: "post", path: "/api/v1/ai/variations/{id}", summary: "Suggest creative variations on a recipe (author only)", description: "Consumes AI quota.", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ count: z.number().int().min(1).max(5).default(3), directions: z.string().max(500).optional(), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } } } }, responses: { 200: { description: "Variations", content: { "application/json": { schema: z.object({ variations: z.array(z.record(z.string(), z.unknown())) }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/ai/batch-cook/generate", summary: "Generate a batch-cooking plan (multiple dishes) as a new recipe", description: "Rate-limited: 3 req/min. Consumes AI quota.", security, request: { body: { content: { "application/json": { schema: z.object({ dinners: z.number().int().min(0).max(6).default(4), lunches: z.number().int().min(0).max(6).default(0), servings: z.number().int().min(1).max(12).default(4), dietaryPrefs: z.string().max(200).optional(), difficulty: z.enum(["easy", "medium", "hard"]).optional(), description: z.string().max(500).optional() }) } }, required: true } }, responses: { 200: { description: "New recipe id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error or no dinners/lunches chosen", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/ai/meal-plan/generate", summary: "Generate a week's meal plan (creates a draft recipe per entry)", description: "Rate-limited: 3 req/min. Consumes AI quota. Charges your tier's recipe limit for each generated entry (refunded if the limit is exceeded).", security, request: { body: { content: { "application/json": { schema: z.object({ weekStart: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), dietaryPrefs: z.string().max(200).optional(), servings: z.number().int().min(1).max(20).default(2), days: z.array(z.enum(["mon", "tue", "wed", "thu", "fri", "sat", "sun"])).min(1).max(7).default(["mon", "tue", "wed", "thu", "fri", "sat", "sun"]), usePantry: z.boolean().default(false), pantryMode: z.boolean().default(false), difficulty: z.enum(["easy", "medium", "hard"]).optional(), targetNutritionGoals: z.boolean().default(false) }) } }, required: true } }, responses: { 200: { description: "Created entries", content: { "application/json": { schema: z.object({ weekStart: z.string(), entries: z.array(z.object({ id: z.string(), day: z.string(), mealType: z.string(), recipeId: z.string(), recipeTitle: z.string() })) }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Recipe limit reached for your tier", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/ai/generate-meal", summary: "Generate a themed, coherent set of recipes (one per course) and add them to a collection", description: "Rate-limited: 3 req/min. Consumes AI quota (one call covers the whole meal). Charges your tier's recipe limit once per generated recipe (refunded if the limit is exceeded). The collection must be owned by the caller.", security, request: { body: { content: { "application/json": { schema: z.object({ collectionId: z.string(), theme: z.string().min(1).max(200), courses: z.array(z.enum(["starter", "main", "side", "dessert", "drink"])).min(2).max(6), servings: z.number().int().min(1).max(20).default(4), dietaryPrefs: z.string().max(200).optional(), difficulty: z.enum(["easy", "medium", "hard"]).optional() }) } }, required: true } }, responses: { 200: { description: "Created recipes, already added to the collection", content: { "application/json": { schema: z.object({ collectionId: z.string(), recipes: z.array(z.object({ id: z.string(), title: z.string(), course: z.string() })) }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Collection not found", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Recipe limit reached for your tier", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/ai/recipe-ideas", summary: "Generate 6 diverse recipe idea cards (not saved)", description: "Rate-limited: 10 req/min. Consumes AI quota.", security, request: { body: { content: { "application/json": { schema: z.object({ prompt: z.string().max(300).optional() }) } } } }, responses: { 200: { description: "Six recipe ideas", content: { "application/json": { schema: z.array(z.object({ title: z.string(), description: z.string(), tags: z.array(z.string()).max(4), difficulty: z.enum(["easy", "medium", "hard"]), totalMins: z.number().int().optional() })) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
const ProposedRecipeRef = registry.register("ProposedRecipe", z.object({
title: z.string(), description: z.string().optional(), baseServings: z.number().int(),