feat: regenerate recipe with modifications from the editor

Every existing AI entry point (generate, generate-from-idea, adapt,
variations) either creates a new recipe or a saved variation — none
let you revise the draft you're currently editing in place. Adds a
stateless /api/v1/ai/regenerate endpoint that takes the editor's
current in-progress fields (not a recipeId, so unsaved edits are
included) plus a free-text instruction, and returns a full revised
draft the editor merges into its own state. No DB write happens;
the user still saves normally.

v0.42.0
This commit is contained in:
Arnaud
2026-07-17 17:11:37 +02:00
parent 77c739960d
commit 25e624f618
11 changed files with 314 additions and 3 deletions
@@ -0,0 +1,61 @@
import { generateObject } from "ai";
import { z } from "zod";
import { resolveModel, type AiConfig } from "../factory";
import { dietaryTagsSchema, ingredientSchema, stepSchema } from "./recipe-schema";
const RegeneratedRecipeSchema = z.object({
title: z.string(),
description: z.string().optional(),
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"]).optional(),
dietaryTags: dietaryTagsSchema.optional(),
ingredients: z.array(ingredientSchema(z.number())),
steps: z.array(stepSchema),
});
export type RegeneratedRecipe = z.infer<typeof RegeneratedRecipeSchema>;
const LANG: Record<string, string> = { en: "English", fr: "French" };
/** Revises a recipe draft already open in the editor per a free-text
* instruction — "add more spice", "make it vegetarian", "double the yield".
* Unlike adaptRecipe/suggestVariations, this never creates a new recipe or
* variation record; it returns a full replacement draft for the caller to
* merge into the in-progress form state, which the user still has to save. */
export async function regenerateRecipe(
current: {
title: string;
description?: string | null;
baseServings: number;
difficulty?: string | null;
ingredients: Array<{ rawName: string; quantity?: string | number | null; unit?: string | null }>;
steps: Array<{ instruction: string }>;
},
instruction: string,
config?: AiConfig & { language?: string }
): Promise<RegeneratedRecipe> {
const model = resolveModel(config);
const lang = LANG[config?.language ?? "en"] ?? "English";
const langInstruction = lang !== "English" ? ` Write the entire recipe (title, description, ingredient names, step instructions) in ${lang}.` : "";
const currentText = [
`Title: ${current.title}`,
current.description ? `Description: ${current.description}` : "",
`Servings: ${current.baseServings}`,
current.difficulty ? `Difficulty: ${current.difficulty}` : "",
`Ingredients:\n${current.ingredients.map((i) => ` - ${i.quantity ? `${i.quantity} ` : ""}${i.unit ? `${i.unit} ` : ""}${i.rawName}`).join("\n")}`,
`Steps:\n${current.steps.map((s, i) => ` ${i + 1}. ${s.instruction}`).join("\n")}`,
].filter(Boolean).join("\n");
const { object } = await generateObject({
model,
schema: RegeneratedRecipeSchema,
system:
`You are revising an existing recipe draft per the user's instruction. Keep everything unchanged except what the instruction requires you to change — this is an edit, not a fresh rewrite. Return the complete updated recipe, not just the changed parts. For ingredient quantities: use numbers only, units separately.${langInstruction}`,
prompt: `Current recipe draft:\n${currentText}\n\nInstruction: ${instruction}`,
});
return object;
}