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:
@@ -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;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// Mirrors CHANGELOG.md at the repo root — update both together.
|
||||
export const APP_VERSION = "0.41.0";
|
||||
export const APP_VERSION = "0.42.0";
|
||||
|
||||
export type ChangelogEntry = {
|
||||
version: string;
|
||||
@@ -11,6 +11,13 @@ export type ChangelogEntry = {
|
||||
};
|
||||
|
||||
export const CHANGELOG: ChangelogEntry[] = [
|
||||
{
|
||||
version: "0.42.0",
|
||||
date: "2026-07-17 15:15",
|
||||
added: [
|
||||
"Recipe editor now has \"Regenerate with AI\" — describe what to change (e.g. \"make it spicier\", \"swap in chicken\") and the draft you're editing updates in place, no new recipe created. You still need to save.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.41.0",
|
||||
date: "2026-07-17 14:45",
|
||||
|
||||
@@ -292,6 +292,7 @@ export function generateOpenApiSpec(): object {
|
||||
registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/comments/{commentId}/reactions", summary: "Get reaction counts (+ your own reactions, if authenticated)", security: [], request: { params: z.object({ id: z.string(), commentId: z.string() }) }, responses: { 200: { description: "Counts", content: { "application/json": { schema: z.object({ counts: z.record(z.string(), z.number()), userReactions: z.array(z.string()) }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/comments/{commentId}/reactions", summary: "Toggle a reaction on a comment", description: "Rate-limited: 60 req/min.", security, request: { params: z.object({ id: z.string(), commentId: z.string() }), body: { content: { "application/json": { schema: z.object({ type: z.enum(["like", "love", "laugh", "wow", "sad", "fire"]) }) } }, required: true } }, responses: { 200: { description: "Updated counts", content: { "application/json": { schema: z.object({ counts: z.record(z.string(), z.number()), added: z.boolean() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "post", path: "/api/v1/ai/generate", summary: "Generate recipe from prompt", description: "Rate-limited: 10 req/min.", security, request: { body: { content: { "application/json": { schema: z.object({ prompt: z.string().min(3).max(500), language: z.string().max(10).default("en"), difficulty: z.enum(["easy", "medium", "hard"]).optional(), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } }, required: true } }, responses: { 200: { description: "Generated", content: { "application/json": { schema: AiGeneratedRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "post", path: "/api/v1/ai/regenerate", summary: "Revise a recipe draft per a free-text instruction (recipe editor's \"Regenerate with AI\")", description: "Rate-limited: 10 req/min. Consumes AI quota. Stateless — takes the current draft's fields directly (not a recipeId), so it reflects unsaved editor changes; does not write to the database.", security, request: { body: { content: { "application/json": { schema: z.object({ title: z.string().min(1).max(200), description: z.string().max(2000).optional(), baseServings: z.number().int().min(1).max(100), difficulty: z.enum(["easy", "medium", "hard"]).optional(), ingredients: z.array(z.object({ rawName: z.string().min(1).max(200), quantity: z.union([z.string(), z.number()]).optional(), unit: z.string().max(50).optional() })).max(100), steps: z.array(z.object({ instruction: z.string().min(1).max(2000) })).max(100), instruction: z.string().min(1).max(500), language: z.string().max(10).default("en"), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } }, required: true } }, responses: { 200: { description: "Revised draft", content: { "application/json": { schema: AiGeneratedRef } } }, 400: { description: "Validation error", 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/import-url", summary: "Import recipe from URL", description: "Rate-limited: 10 req/min.", security, request: { body: { content: { "application/json": { schema: z.object({ url: z.string().url(), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } }, required: true } }, responses: { 200: { description: "Imported", content: { "application/json": { schema: AiGeneratedRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
|
||||
// --- AI (Phase 3): recipe generation, transforms, chat ---
|
||||
|
||||
Reference in New Issue
Block a user