Files
Epicure/apps/web/lib/ai/features/adapt-recipe.ts
T
Arnaud d9d58fd01a feat(ai): Vercel AI SDK provider factory with BYOK and per-use-case model prefs
Provider factory (OpenAI/Anthropic/OpenRouter/Ollama). generateObject for all outputs.
Features: recipe generation, photo-to-recipe vision, variations, drink/meal pairings,
ingredient substitution, recipe adaptation, nutrition analysis, URL import, meal plan gen.
User BYOK keys (AES-256-GCM). Per-use-case model preferences (text/vision/mealPlan).
Site-level key override via admin settings.
2026-07-01 08:10:19 +02:00

83 lines
3.0 KiB
TypeScript

import { generateObject } from "ai";
import { z } from "zod";
import { resolveModel, type AiConfig } from "../factory";
const AdaptedRecipeSchema = z.object({
title: z.string(),
description: z.string(),
baseServings: z.number().int().min(1).max(100),
prepMins: z.number().int().min(0).optional(),
cookMins: z.number().int().min(0).optional(),
difficulty: z.enum(["easy", "medium", "hard"]),
dietaryTags: z.object({
vegan: z.boolean().optional(),
vegetarian: z.boolean().optional(),
glutenFree: z.boolean().optional(),
dairyFree: z.boolean().optional(),
nutFree: z.boolean().optional(),
halal: z.boolean().optional(),
kosher: z.boolean().optional(),
}),
ingredients: z.array(z.object({
rawName: z.string(),
quantity: z.number().optional(),
unit: z.string().optional(),
note: z.string().optional(),
})),
steps: z.array(z.object({
instruction: z.string(),
timerSeconds: z.number().int().optional(),
})),
adaptationNotes: z.string(),
});
export type AdaptedRecipe = z.infer<typeof AdaptedRecipeSchema>;
const LANG: Record<string, string> = { en: "English", fr: "French" };
export async function adaptRecipe(
recipe: {
title: string;
description?: string | null;
baseServings: number;
ingredients: Array<{ rawName: string; quantity?: string | number | null; unit?: string | null }>;
steps: Array<{ instruction: string }>;
},
constraints: {
excludeIngredients: string[];
extraConstraints?: string;
},
config?: AiConfig,
locale?: string
): Promise<AdaptedRecipe> {
const model = resolveModel(config);
const lang = LANG[locale ?? "en"] ?? "English";
const recipeText = [
`Title: ${recipe.title}`,
recipe.description ? `Description: ${recipe.description}` : "",
`Servings: ${recipe.baseServings}`,
`Ingredients:\n${recipe.ingredients.map((i) => ` - ${i.quantity ? `${i.quantity} ` : ""}${i.unit ? `${i.unit} ` : ""}${i.rawName}`).join("\n")}`,
`Steps:\n${recipe.steps.map((s, i) => ` ${i + 1}. ${s.instruction}`).join("\n")}`,
].filter(Boolean).join("\n");
const constraintText = [
constraints.excludeIngredients.length > 0
? `MUST NOT contain or use: ${constraints.excludeIngredients.join(", ")}. Find suitable substitutes that preserve the dish character.`
: "",
constraints.extraConstraints?.trim()
? `Additional constraints: ${constraints.extraConstraints}`
: "",
].filter(Boolean).join("\n");
const { object } = await generateObject({
model,
schema: AdaptedRecipeSchema,
system:
`You are a professional chef specializing in recipe adaptation. When adapting recipes, find the best substitutes that preserve the spirit, flavor profile, and texture of the original. Always explain what changed and why in adaptationNotes. For ingredient quantities: use numbers only, units separately. Respond in ${lang}.`,
prompt: `Adapt the following recipe with these constraints:\n\n${constraintText}\n\nOriginal recipe:\n${recipeText}`,
});
return object;
}