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:
Arnaud
2026-07-14 13:45:52 +02:00
parent 21c541e115
commit d8bc077f47
11 changed files with 40 additions and 8 deletions
+8
View File
@@ -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}`);
}
+7 -2
View File
@@ -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",
+11 -1
View File
@@ -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",
+1 -1
View File
@@ -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 } } } } });
+1
View File
@@ -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.",
+1
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@epicure/web",
"version": "0.28.3",
"version": "0.29.0",
"private": true,
"scripts": {
"dev": "next dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "epicure",
"version": "0.28.3",
"version": "0.29.0",
"private": true,
"scripts": {
"dev": "pnpm --filter web dev",