feat: add batch-cooking sessions (single "mega recipe" with dish sections)
New AI-generated batch-cooking flow: pick N dinners/lunches + servings, get one unified recipe covering a full prep session — merged shopping list, steps tagged per-dish (a step can advance several dishes at once, e.g. a shared oven bake), and per-dish storage (fridge/freezer) + exact day-of reheat/finishing instructions. Schema: recipes.isBatchCook, recipeSteps.appliesTo (dish names), new recipeBatchDishes table. Batch recipes are excluded from the normal /recipes list and live under their own /batch-cooking section. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
import { generateObject } from "ai";
|
||||
import { z } from "zod";
|
||||
import { resolveModel, type AiConfig } from "../factory";
|
||||
|
||||
const BatchCookSchema = z.object({
|
||||
title: z.string().max(150),
|
||||
description: z.string().max(300),
|
||||
dishes: z.array(z.object({
|
||||
name: z.string().max(100),
|
||||
description: z.string().max(200),
|
||||
fridgeDays: z.number().int().min(1).max(7),
|
||||
freezerFriendly: z.boolean(),
|
||||
freezerNote: z.string().max(150).optional(),
|
||||
dayOfInstructions: z.string().max(300),
|
||||
})).min(1).max(6),
|
||||
ingredients: z.array(z.object({
|
||||
rawName: z.string().describe("Ingredient name only — never include the quantity or unit here."),
|
||||
quantity: z.number().optional().describe("A number only — never combined with the unit."),
|
||||
unit: z.string().optional().describe("The unit only, e.g. 'cup', 'g' — never combined with the quantity or name."),
|
||||
})).max(50),
|
||||
steps: z.array(z.object({
|
||||
appliesTo: z.array(z.string()).describe("Which dish(es) this step contributes to, using exact names from dishes[]. Empty array = shared mise-en-place step for everyone (Préparation phase). One name = a step specific to that dish's assembly. Two or more names = a step that simultaneously advances multiple dishes."),
|
||||
instruction: z.string(),
|
||||
})).max(50),
|
||||
});
|
||||
|
||||
export type GeneratedBatchCook = z.infer<typeof BatchCookSchema>;
|
||||
|
||||
const LANG: Record<string, string> = { en: "English", fr: "French" };
|
||||
|
||||
export async function generateBatchCook(
|
||||
options: {
|
||||
dinners?: number;
|
||||
lunches?: number;
|
||||
servings?: number;
|
||||
dietaryPrefs?: string;
|
||||
difficulty?: "easy" | "medium" | "hard";
|
||||
},
|
||||
config?: AiConfig & { userContext?: string },
|
||||
locale?: string
|
||||
): Promise<GeneratedBatchCook> {
|
||||
const model = resolveModel(config);
|
||||
const lang = LANG[locale ?? "en"] ?? "English";
|
||||
const dinners = options.dinners ?? 4;
|
||||
const lunches = options.lunches ?? 0;
|
||||
const servings = options.servings ?? 4;
|
||||
const difficulty = options.difficulty;
|
||||
|
||||
const mealParts: string[] = [];
|
||||
if (dinners > 0) mealParts.push(`${dinners} dinner(s)`);
|
||||
if (lunches > 0) mealParts.push(`${lunches} lunch(es)`);
|
||||
const totalDishes = Math.max(1, Math.min(6, dinners + lunches > 0 ? dinners + lunches : 4));
|
||||
|
||||
const dietaryClause = options.dietaryPrefs?.trim()
|
||||
? `Dietary requirements: ${options.dietaryPrefs.trim()}.`
|
||||
: "";
|
||||
const difficultyClause = difficulty
|
||||
? `Keep techniques at a ${difficulty} skill level.`
|
||||
: "";
|
||||
|
||||
const systemPrompt = `You are a professional meal-prep chef writing ONE unified "batch cooking" recipe page — not several separate recipes. This mirrors real French batch-cooking guides (cuisine-addict.com style): one shopping list, then a single prep session structured in two phases, covering multiple meals for the days ahead.
|
||||
|
||||
Structure:
|
||||
1. A merged, deduplicated ingredient list across all dishes (identical rawName spelling for anything shared).
|
||||
2. Steps, tagged individually with which dish(es) they belong to (appliesTo — empty for shared prep, one name for a dish-specific step, several names when a step genuinely advances multiple dishes at once, e.g. two things share the same oven bake, or a base is cooked once then split):
|
||||
- Phase "Préparation": shared mise-en-place done first — peeling, chopping, pre-cooking grains/proteins, etc. Group by task; tag with dish names only if a prep step is specific to one dish.
|
||||
- Phase "Assemblage": the remaining steps that turn prepped components into finished dishes. Actively look for steps that serve multiple dishes at once (shared oven timing, a base split several ways) and tag those with multiple names instead of duplicating the step.
|
||||
|
||||
Before designing the dishes, pick 2-3 "anchor" ingredients for the week — substantial vegetables, fruits, or proteins bought in bulk. Design each dish around reusing these anchors, prepared in a genuinely different form each time (e.g. a vegetable roasted in one dish, pureed into a soup in another, shredded raw in a salad in a third). Every dish must share at least one anchor ingredient with another dish — this is a structural requirement, not incidental. Beyond the anchors, pantry staples (oil, garlic, onion, salt) can repeat freely.
|
||||
|
||||
Dishes must be genuinely distinct — different techniques and flavor profiles (a tart, a braise, a soup, a stuffed vegetable, a grain salad, a curry), never the same base dish renamed. Favor formats that store well for several days (braises, soups, tarts, grain salads, stuffed vegetables, curries) over ones that degrade fast, unless dayOfInstructions explicitly compensates.
|
||||
|
||||
Every dish needs: fridgeDays, freezerFriendly, and a freezerNote if applicable (duration + thawing tip). dayOfInstructions must name the exact reheat method and time, AND a concrete fresh finishing touch (stir in cream, cook fresh grain, add a fresh side, sear a protein, grate cheese) — never generic ("reheat and enjoy" is unacceptable).
|
||||
|
||||
For ingredients: quantity is a number only, unit is a separate string. Never combine them. Respond in ${lang}.`;
|
||||
|
||||
const systemPromptWithContext = systemPrompt + (config?.userContext ? `\n\nUser preferences and context:\n${config.userContext}` : "");
|
||||
|
||||
const { object } = await generateObject({
|
||||
model,
|
||||
schema: BatchCookSchema,
|
||||
system: systemPromptWithContext,
|
||||
prompt: [
|
||||
`Generate a batch-cooking session covering ${mealParts.join(" and ") || `${totalDishes} meals`}.`,
|
||||
`Design exactly ${totalDishes} distinct dishes, each for ${servings} servings.`,
|
||||
dietaryClause,
|
||||
difficultyClause,
|
||||
].filter(Boolean).join(" "),
|
||||
});
|
||||
|
||||
return object;
|
||||
}
|
||||
Reference in New Issue
Block a user