fix: photo import language + no-recipe-recognized handling (v0.29.0)
importFromPhoto() accepted a locale param but silently discarded it (named _locale, never read) — now builds a language instruction the same way generate-recipe.ts does for text generation. The AI output schema had no way to signal "couldn't find a recipe in this image" — generateObject would always fabricate a title/fields. Added a `found` boolean the model sets to false in that case; the route now returns 422 instead of creating a garbage recipe and sending the user into the editor with it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,14 @@
|
||||
|
||||
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
|
||||
|
||||
## 0.29.0 — 2026-07-14 15:20
|
||||
|
||||
### Added
|
||||
- **Photo recipe import now respects your app language** — previously it defaulted to English (or whatever language was in the photo) instead of matching your locale like other AI generation features.
|
||||
|
||||
### Fixed
|
||||
- Importing a photo with no recognizable recipe used to open the recipe editor anyway with empty/garbage fields — it now shows a clear error instead.
|
||||
|
||||
## 0.28.3 — 2026-07-14 14:55
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -44,6 +44,10 @@ export async function POST(req: NextRequest) {
|
||||
if (!result.ok) return result.response;
|
||||
const recipe = result.data;
|
||||
|
||||
if (!recipe.found) {
|
||||
return NextResponse.json({ error: "No recipe recognized in photo" }, { status: 422 });
|
||||
}
|
||||
|
||||
const newRecipeId = crypto.randomUUID();
|
||||
const now = new Date();
|
||||
|
||||
|
||||
@@ -158,8 +158,10 @@ export function AiGenerateDialog({
|
||||
body: JSON.stringify({ imageBase64: photoBase64, mimeType: photoMime }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
let msg = t("photoError");
|
||||
try { msg = ((await res.json()) as { error?: string }).error ?? msg; } catch { /* non-JSON body */ }
|
||||
let msg = res.status === 422 ? tRecipe("photoNoRecipeFound") : t("photoError");
|
||||
if (res.status !== 422) {
|
||||
try { msg = ((await res.json()) as { error?: string }).error ?? msg; } catch { /* non-JSON body */ }
|
||||
}
|
||||
toast.error(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ export function PhotoImportButton() {
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 422) throw new Error(t("photoNoRecipeFound"));
|
||||
const data = await res.json().catch(() => ({})) as { error?: string };
|
||||
throw new Error(data.error ?? `Request failed: ${res.status}`);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { resolveModel, type AiConfig } from "../factory";
|
||||
import { dietaryTagsSchema, ingredientSchema, stepSchema } from "./recipe-schema";
|
||||
|
||||
const ImportedRecipeSchema = z.object({
|
||||
found: z.boolean().describe("false if the image does not contain a recognizable recipe (e.g. random photo, no visible ingredients/instructions)"),
|
||||
title: z.string(),
|
||||
description: z.string().optional(),
|
||||
baseServings: z.number().int().min(1).optional(),
|
||||
@@ -17,19 +18,23 @@ const ImportedRecipeSchema = z.object({
|
||||
|
||||
export type ImportedRecipe = z.infer<typeof ImportedRecipeSchema>;
|
||||
|
||||
const LANG: Record<string, string> = { en: "English", fr: "French" };
|
||||
|
||||
export async function importFromPhoto(
|
||||
imageBase64: string,
|
||||
mimeType: "image/jpeg" | "image/png" | "image/webp",
|
||||
config?: AiConfig,
|
||||
_locale?: string,
|
||||
locale?: string,
|
||||
): Promise<ImportedRecipe> {
|
||||
const model = resolveModel(config);
|
||||
const lang = LANG[locale ?? "en"] ?? "English";
|
||||
const langInstruction = lang !== "English" ? ` Write the entire recipe (title, description, ingredient names, step instructions) in ${lang}, regardless of the language used in the image.` : "";
|
||||
|
||||
const { object } = await generateObject({
|
||||
model,
|
||||
schema: ImportedRecipeSchema,
|
||||
system:
|
||||
"You are a recipe extraction specialist. Extract the complete recipe from the provided image. Be precise with ingredient quantities and cooking instructions.",
|
||||
`You are a recipe extraction specialist. Extract the complete recipe from the provided image. Be precise with ingredient quantities and cooking instructions. If the image does not contain a recognizable recipe (no visible ingredients or cooking instructions), set found to false and leave the other fields minimal/empty.${langInstruction}`,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Mirrors CHANGELOG.md at the repo root — update both together.
|
||||
export const APP_VERSION = "0.28.3";
|
||||
export const APP_VERSION = "0.29.0";
|
||||
|
||||
export type ChangelogEntry = {
|
||||
version: string;
|
||||
@@ -11,6 +11,16 @@ export type ChangelogEntry = {
|
||||
};
|
||||
|
||||
export const CHANGELOG: ChangelogEntry[] = [
|
||||
{
|
||||
version: "0.29.0",
|
||||
date: "2026-07-14 15:20",
|
||||
added: [
|
||||
"**Photo recipe import now respects your app language** — previously it defaulted to English (or whatever language was in the photo) instead of matching your locale like other AI generation features.",
|
||||
],
|
||||
fixed: [
|
||||
"Importing a photo with no recognizable recipe used to open the recipe editor anyway with empty/garbage fields — it now shows a clear error instead.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.28.3",
|
||||
date: "2026-07-14 14:55",
|
||||
|
||||
@@ -286,7 +286,7 @@ export function generateOpenApiSpec(): object {
|
||||
}));
|
||||
|
||||
registry.registerPath({ method: "post", path: "/api/v1/ai/generate-from-idea", summary: "Generate a full recipe from a short title/idea", description: "Rate-limited: 10 req/min. Consumes AI quota.", security, request: { body: { content: { "application/json": { schema: z.object({ title: z.string().min(1).max(200), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } }, required: true } }, responses: { 200: { description: "Created recipe id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 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-photo", summary: "Import a recipe from a photo using AI vision", description: "Rate-limited: 5 req/min. Consumes AI quota.", security, request: { body: { content: { "application/json": { schema: z.object({ imageBase64: z.string().max(14_000_000), mimeType: z.enum(["image/jpeg", "image/png", "image/webp"]) }) } }, required: true } }, responses: { 200: { description: "Created recipe id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 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-photo", summary: "Import a recipe from a photo using AI vision", description: "Rate-limited: 5 req/min. Consumes AI quota. Written in the caller's app language, regardless of the language shown in the photo.", security, request: { body: { content: { "application/json": { schema: z.object({ imageBase64: z.string().max(14_000_000), mimeType: z.enum(["image/jpeg", "image/png", "image/webp"]) }) } }, required: true } }, responses: { 200: { description: "Created recipe id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 422: { description: "No recipe recognized in the photo", 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/substitute", summary: "Suggest ingredient substitutions", description: "Rate-limited: 10 req/min. Consumes AI quota.", security, request: { body: { content: { "application/json": { schema: z.object({ ingredient: z.string().min(1).max(200), recipeTitle: z.string().max(200).optional(), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } }, required: true } }, responses: { 200: { description: "Substitutions", content: { "application/json": { schema: z.object({ substitutions: z.array(z.record(z.string(), z.unknown())) }) } } }, 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/scale", summary: "Scale a recipe's ingredients to a target serving count", description: "Rate-limited: 20 req/min. Consumes AI quota.", security, request: { body: { content: { "application/json": { schema: z.object({ recipeId: z.string(), targetServings: z.number().int().min(1).max(100) }) } }, required: true } }, responses: { 200: { description: "Scaled ingredients", content: { "application/json": { schema: z.object({ ingredients: 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: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "post", path: "/api/v1/ai/adapt/{id}", summary: "Adapt a recipe to exclude ingredients or meet extra constraints", description: "Consumes AI quota. Creates a new private draft recipe.", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ excludeIngredients: z.array(z.string()).default([]), extraConstraints: z.string().max(500).optional(), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } }, required: true } }, responses: { 200: { description: "New recipe id and adaptation notes", content: { "application/json": { schema: z.object({ id: z.string(), adaptationNotes: z.string().optional() }) } } }, 400: { description: "Validation error or no constraint provided", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
|
||||
@@ -152,6 +152,7 @@
|
||||
"cookAction": "Cook",
|
||||
"adaptConstraintPlaceholder": "e.g. Make it vegan, lower the calories, use pantry staples only, gluten-free…",
|
||||
"photoImportFailed": "Failed to import recipe from photo.",
|
||||
"photoNoRecipeFound": "No recipe could be recognized in that photo. Try a clearer picture of the ingredients or instructions.",
|
||||
"importFromPhoto": "Import from Photo",
|
||||
"urlImportTitle": "Import recipe from URL",
|
||||
"urlImportDescription": "Paste a recipe URL and AI will extract the ingredients and instructions.",
|
||||
|
||||
@@ -152,6 +152,7 @@
|
||||
"drinksTypeHot": "Chaud",
|
||||
"cookAction": "Cuisiner",
|
||||
"photoImportFailed": "Échec de l'importation de la recette depuis la photo.",
|
||||
"photoNoRecipeFound": "Aucune recette reconnue sur cette photo. Essayez une image plus nette des ingrédients ou des instructions.",
|
||||
"importFromPhoto": "Importer depuis une photo",
|
||||
"urlImportTitle": "Importer une recette depuis une URL",
|
||||
"urlImportDescription": "Collez l'URL d'une recette et l'IA en extraira les ingrédients et les instructions.",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@epicure/web",
|
||||
"version": "0.28.3",
|
||||
"version": "0.29.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "epicure",
|
||||
"version": "0.28.3",
|
||||
"version": "0.29.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "pnpm --filter web dev",
|
||||
|
||||
Reference in New Issue
Block a user