From af92b8cc71d6d9360dde4557b09fe1a43247596d Mon Sep 17 00:00:00 2001 From: Arnaud Date: Tue, 14 Jul 2026 15:17:33 +0200 Subject: [PATCH] feat: auto-classify AI recipes as dish/drink (v0.33.0) generate, generate-from-idea, URL import, and photo import all now have the AI set recipeType directly (defaulting to "dish" whenever ambiguous, per instruction), with cookMins force-cleared for drinks as a backstop in case the model doesn't follow the prompt guidance. 1-serving-by-default for unspecified drink quantities is left to the model's own judgment of the request text, since there's no reliable way to detect "was a serving count stated" without re-parsing intent. Drink recipes get a distinct default icon (no cover photo case), and Explore gains the same dish/drink Type filter My Recipes already had. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 7 ++++ .../app/api/v1/ai/generate-from-idea/route.ts | 1 + apps/web/app/api/v1/ai/import-photo/route.ts | 1 + apps/web/app/api/v1/search/route.ts | 6 ++++ apps/web/components/recipe/recipe-card.tsx | 3 +- apps/web/components/recipe/recipes-grid.tsx | 3 +- .../search/explore-page-content.tsx | 35 ++++++++++++++----- apps/web/lib/ai/features/generate-recipe.ts | 10 +++++- apps/web/lib/ai/features/import-photo.ts | 6 +++- apps/web/lib/ai/features/import-url.ts | 6 +++- apps/web/lib/changelog.ts | 11 +++++- apps/web/lib/openapi.ts | 2 +- apps/web/messages/en.json | 1 + apps/web/messages/fr.json | 1 + apps/web/package.json | 2 +- package.json | 2 +- 16 files changed, 80 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54efd8d..e552fc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ 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.33.0 — 2026-07-14 17:35 + +### Added +- **AI recipe generation/import now auto-detects drinks vs dishes** — generate, generate-from-idea, URL import, and photo import all classify the result and skip cook time for drinks, defaulting to dish whenever it's ambiguous. +- **Drink recipes get their own default icon** (🍹 instead of 🍽️) wherever a recipe has no cover photo. +- **Explore now has a Type filter** (dish/drink), alongside My Recipes' existing one. + ## 0.32.0 — 2026-07-14 17:10 ### Security diff --git a/apps/web/app/api/v1/ai/generate-from-idea/route.ts b/apps/web/app/api/v1/ai/generate-from-idea/route.ts index e74491b..e4940bb 100644 --- a/apps/web/app/api/v1/ai/generate-from-idea/route.ts +++ b/apps/web/app/api/v1/ai/generate-from-idea/route.ts @@ -57,6 +57,7 @@ export async function POST(req: NextRequest) { title: recipe.title, description: recipe.description ?? null, baseServings: recipe.baseServings, + recipeType: recipe.recipeType, prepMins: recipe.prepMins ?? null, cookMins: recipe.cookMins ?? null, difficulty: recipe.difficulty ?? null, diff --git a/apps/web/app/api/v1/ai/import-photo/route.ts b/apps/web/app/api/v1/ai/import-photo/route.ts index 3cac04a..8348840 100644 --- a/apps/web/app/api/v1/ai/import-photo/route.ts +++ b/apps/web/app/api/v1/ai/import-photo/route.ts @@ -59,6 +59,7 @@ export async function POST(req: NextRequest) { title: recipe.title, description: recipe.description, baseServings: recipe.baseServings ?? 4, + recipeType: recipe.recipeType, visibility: "private", difficulty: recipe.difficulty, prepMins: recipe.prepMins, diff --git a/apps/web/app/api/v1/search/route.ts b/apps/web/app/api/v1/search/route.ts index 0f97465..3975524 100644 --- a/apps/web/app/api/v1/search/route.ts +++ b/apps/web/app/api/v1/search/route.ts @@ -91,6 +91,12 @@ export async function GET(req: NextRequest) { conditions.push(eq(recipes.difficulty, difficulty)); } + const recipeTypeParam = searchParams.get("recipeType"); + const recipeType = recipeTypeParam === "dish" || recipeTypeParam === "drink" ? recipeTypeParam : undefined; + if (recipeType) { + conditions.push(eq(recipes.recipeType, recipeType)); + } + if (maxMins !== undefined) { conditions.push( sql`(${recipes.prepMins} + ${recipes.cookMins}) <= ${maxMins}` diff --git a/apps/web/components/recipe/recipe-card.tsx b/apps/web/components/recipe/recipe-card.tsx index be75b0e..d75bfc9 100644 --- a/apps/web/components/recipe/recipe-card.tsx +++ b/apps/web/components/recipe/recipe-card.tsx @@ -20,6 +20,7 @@ type Recipe = { updatedAt: Date; photos?: Array<{ storageKey: string; isCover: boolean }>; sourceUrl?: string | null; + recipeType?: "dish" | "drink"; }; const VISIBILITY_ICON = { @@ -56,7 +57,7 @@ export function RecipeCard({ recipe }: { recipe: Recipe }) { ) : (
- 🍽️ + {recipe.recipeType === "drink" ? "🍹" : "🍽️"}
)} diff --git a/apps/web/components/recipe/recipes-grid.tsx b/apps/web/components/recipe/recipes-grid.tsx index 7c465a8..49f4026 100644 --- a/apps/web/components/recipe/recipes-grid.tsx +++ b/apps/web/components/recipe/recipes-grid.tsx @@ -49,6 +49,7 @@ type Recipe = { isBatchCook?: boolean; dishCount?: number; sourceUrl?: string | null; + recipeType?: "dish" | "drink"; }; /** Stops the click from bubbling to the card's own Link/select handler. */ @@ -92,7 +93,7 @@ function RecipeThumb({ recipe, selectMode, selected, className }: { recipe: Reci /> ) : (
- 🍽️ + {recipe.recipeType === "drink" ? "🍹" : "🍽️"}
)} {selectMode && selected &&
} diff --git a/apps/web/components/search/explore-page-content.tsx b/apps/web/components/search/explore-page-content.tsx index d155c5a..cb188b4 100644 --- a/apps/web/components/search/explore-page-content.tsx +++ b/apps/web/components/search/explore-page-content.tsx @@ -95,6 +95,7 @@ export function ExplorePageContent({ trending, recent, initialQuery, popularTags const [inputValue, setInputValue] = useState(initialQuery); const [query, setQuery] = useState(initialQuery); const [difficulty, setDifficulty] = useState("any"); + const [recipeType, setRecipeType] = useState("any"); const [maxMins, setMaxMins] = useState(""); const [selectedTags, setSelectedTags] = useState>(new Set()); const [results, setResults] = useState([]); @@ -115,7 +116,7 @@ export function ExplorePageContent({ trending, recent, initialQuery, popularTags const debounceRef = useRef | null>(null); const fetchResults = useCallback( - async (q: string, diff: string, maxM: string, off: number, tags: string[], append = false) => { + async (q: string, diff: string, maxM: string, off: number, tags: string[], type = "any", append = false) => { if (!q.trim()) { setResults([]); setTotal(0); @@ -127,6 +128,7 @@ export function ExplorePageContent({ trending, recent, initialQuery, popularTags try { const params = new URLSearchParams({ q, limit: "20", offset: String(off) }); if (diff && diff !== "any") params.set("difficulty", diff); + if (type && type !== "any") params.set("recipeType", type); if (maxM) params.set("maxMins", maxM); if (tags.length > 0) params.set("tags", tags.join(",")); const res = await fetch(`/api/v1/search?${params}`); @@ -162,7 +164,7 @@ export function ExplorePageContent({ trending, recent, initialQuery, popularTags useEffect(() => { if (initialQuery) { - fetchResults(initialQuery, "any", "", 0, []); + fetchResults(initialQuery, "any", "", 0, [], "any"); fetchPeople(initialQuery); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -218,7 +220,7 @@ export function ExplorePageContent({ trending, recent, initialQuery, popularTags debounceRef.current = setTimeout(() => { setQuery(value); updateUrl(value); - fetchResults(value, difficulty, maxMins, 0, [...selectedTags]); + fetchResults(value, difficulty, maxMins, 0, [...selectedTags], recipeType); fetchPeople(value); }, 300); }; @@ -228,21 +230,27 @@ export function ExplorePageContent({ trending, recent, initialQuery, popularTags if (debounceRef.current) clearTimeout(debounceRef.current); setQuery(inputValue); updateUrl(inputValue); - fetchResults(inputValue, difficulty, maxMins, 0, [...selectedTags]); + fetchResults(inputValue, difficulty, maxMins, 0, [...selectedTags], recipeType); fetchPeople(inputValue); }; const handleDifficultyChange = (value: string | null) => { const v = value ?? "any"; setDifficulty(v); - fetchResults(query, v, maxMins, 0, [...selectedTags]); + fetchResults(query, v, maxMins, 0, [...selectedTags], recipeType); + }; + + const handleRecipeTypeChange = (value: string | null) => { + const v = value ?? "any"; + setRecipeType(v); + fetchResults(query, difficulty, maxMins, 0, [...selectedTags], v); }; const handleMaxMinsChange = (value: string) => { setMaxMins(value); if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { - fetchResults(query, difficulty, value, 0, [...selectedTags]); + fetchResults(query, difficulty, value, 0, [...selectedTags], recipeType); }, 300); }; @@ -251,7 +259,7 @@ export function ExplorePageContent({ trending, recent, initialQuery, popularTags const next = new Set(prev); if (next.has(tag)) next.delete(tag); else next.add(tag); - fetchResults(query, difficulty, maxMins, 0, [...next]); + fetchResults(query, difficulty, maxMins, 0, [...next], recipeType); return next; }); }; @@ -294,6 +302,17 @@ export function ExplorePageContent({ trending, recent, initialQuery, popularTags {tRecipe("difficulty.hard")} + +