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 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-14 15:17:33 +02:00
parent 3042d289a0
commit af92b8cc71
16 changed files with 80 additions and 17 deletions
+7
View File
@@ -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
@@ -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,
@@ -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,
+6
View File
@@ -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}`
+2 -1
View File
@@ -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 }) {
</div>
) : (
<div className="aspect-video bg-gradient-to-br from-muted to-muted/50 flex items-center justify-center text-4xl">
🍽
{recipe.recipeType === "drink" ? "🍹" : "🍽️"}
</div>
)}
<CardHeader className="pb-2">
+2 -1
View File
@@ -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
/>
) : (
<div className={cn("w-full h-full flex items-center justify-center text-2xl", selectMode && !selected && "opacity-60")}>
🍽
{recipe.recipeType === "drink" ? "🍹" : "🍽️"}
</div>
)}
{selectMode && selected && <div className="absolute inset-0 bg-primary/20" />}
@@ -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<Set<string>>(new Set());
const [results, setResults] = useState<SearchRecipeResult[]>([]);
@@ -115,7 +116,7 @@ export function ExplorePageContent({ trending, recent, initialQuery, popularTags
const debounceRef = useRef<ReturnType<typeof setTimeout> | 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
<SelectItem value="hard">{tRecipe("difficulty.hard")}</SelectItem>
</SelectContent>
</Select>
<Select value={recipeType} onValueChange={handleRecipeTypeChange}>
<SelectTrigger className="w-40">
<SelectValue placeholder={t("anyType")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="any">{t("anyType")}</SelectItem>
<SelectItem value="dish">{tRecipe("recipeType.dish")}</SelectItem>
<SelectItem value="drink">{tRecipe("recipeType.drink")}</SelectItem>
</SelectContent>
</Select>
<Input
type="number"
min={1}
@@ -392,7 +411,7 @@ export function ExplorePageContent({ trending, recent, initialQuery, popularTags
<div className="flex justify-center pt-4">
<Button
variant="outline"
onClick={() => fetchResults(query, difficulty, maxMins, offset, [...selectedTags], true)}
onClick={() => fetchResults(query, difficulty, maxMins, offset, [...selectedTags], recipeType, true)}
disabled={loadingMore}
className="min-w-32"
>
+9 -1
View File
@@ -4,6 +4,7 @@ import { resolveModel, type AiConfig } from "../factory";
import { dietaryTagsSchema, ingredientSchema, stepSchema } from "./recipe-schema";
const RecipeOutputSchema = z.object({
recipeType: z.enum(["dish", "drink"]).describe("\"drink\" only for a beverage/cocktail (no cooking step) — \"dish\" for anything else, including if unsure."),
title: z.string(),
description: z.string(),
baseServings: z.number().int().min(1).max(100),
@@ -31,9 +32,16 @@ export async function generateRecipe(
model,
schema: RecipeOutputSchema,
system:
`You are a professional chef and culinary writer. Generate detailed, accurate, and delicious recipes. For ingredients: quantity must be a number only (e.g. 0.25, 1.5, 2), unit is a separate string (e.g. 'cup', 'tbsp', 'g', 'ml'). Never combine quantity and unit into one string. Include helpful notes for ingredients when relevant.${langInstruction}${difficultyInstruction}${userInstruction}`,
`You are a professional chef and culinary writer. Generate detailed, accurate, and delicious recipes. For ingredients: quantity must be a number only (e.g. 0.25, 1.5, 2), unit is a separate string (e.g. 'cup', 'tbsp', 'g', 'ml'). Never combine quantity and unit into one string. Include helpful notes for ingredients when relevant. Set recipeType to "drink" only for a beverage/cocktail with no cooking involved (mixing, shaking, stirring, blending) — default to "dish" whenever it's ambiguous. For a drink, never set cookMins, and default baseServings to 1 unless the request specifically asks for more than one serving/portion.${langInstruction}${difficultyInstruction}${userInstruction}`,
prompt: `Create a recipe for: ${prompt}`,
});
// Belt-and-suspenders in case the model doesn't follow the drink-specific
// instructions above — a drink has no "cook" step by definition, and a
// model that ignores the 1-serving-by-default guidance would otherwise
// silently produce a batch-sized drink recipe.
if (object.recipeType === "drink") {
return { ...object, cookMins: undefined };
}
return object;
}
+5 -1
View File
@@ -5,6 +5,7 @@ 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)"),
recipeType: z.enum(["dish", "drink"]).describe("\"drink\" only for a beverage/cocktail (no cooking step) — \"dish\" for anything else, including if unsure."),
title: z.string(),
description: z.string().optional(),
baseServings: z.number().int().min(1).optional(),
@@ -34,7 +35,7 @@ export async function importFromPhoto(
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. 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}`,
`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. Set recipeType to "drink" only for a beverage/cocktail with no cooking involved — never set cookMins for a drink.${langInstruction}`,
messages: [
{
role: "user",
@@ -46,5 +47,8 @@ export async function importFromPhoto(
],
});
if (object.recipeType === "drink") {
return { ...object, cookMins: undefined };
}
return object;
}
+5 -1
View File
@@ -5,6 +5,7 @@ import { safeFetch } from "@/lib/validate-webhook-url";
import { dietaryTagsSchema, ingredientSchema, stepSchema } from "./recipe-schema";
const ImportedRecipeSchema = z.object({
recipeType: z.enum(["dish", "drink"]).describe("\"drink\" only for a beverage/cocktail (no cooking step) — \"dish\" for anything else, including if unsure."),
title: z.string(),
description: z.string().optional(),
baseServings: z.number().int().min(1).optional(),
@@ -41,9 +42,12 @@ export async function importFromUrl(url: string, config?: AiConfig): Promise<Imp
model,
schema: ImportedRecipeSchema,
system:
"You are an expert at extracting structured recipe data from web page text. Extract all recipe information accurately. For ingredients, separate quantity, unit, and name. For steps, extract timer durations in seconds when mentioned.",
"You are an expert at extracting structured recipe data from web page text. Extract all recipe information accurately. For ingredients, separate quantity, unit, and name. For steps, extract timer durations in seconds when mentioned. Set recipeType to \"drink\" only for a beverage/cocktail with no cooking involved — never set cookMins for a drink.",
prompt: `Extract the recipe from this web page:\n\nURL: ${url}\n\nContent:\n${cleaned}`,
});
if (object.recipeType === "drink") {
return { ...object, cookMins: undefined };
}
return object;
}
+10 -1
View File
@@ -1,5 +1,5 @@
// Mirrors CHANGELOG.md at the repo root — update both together.
export const APP_VERSION = "0.32.0";
export const APP_VERSION = "0.33.0";
export type ChangelogEntry = {
version: string;
@@ -11,6 +11,15 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.33.0",
date: "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.",
],
},
{
version: "0.32.0",
date: "2026-07-14 17:10",
+1 -1
View File
@@ -605,7 +605,7 @@ export function generateOpenApiSpec(): object {
avgRating: z.number().nullable(), ratingCount: z.number().int(),
}));
registry.registerPath({ method: "get", path: "/api/v1/search", summary: "Full-text search across public recipes — title, description, ingredient names, and tags", description: "Public — no auth required. Matches only public recipes by non-private authors.", security: [], request: { query: z.object({ q: z.string().min(1).max(200), difficulty: z.enum(["easy", "medium", "hard"]).optional(), maxMins: z.coerce.number().optional().describe("Max prepMins + cookMins combined"), dietary: z.string().optional().describe("Comma-separated: vegan,vegetarian,glutenFree,dairyFree"), tags: z.string().optional().describe("Comma-separated exact-tag filter chips, up to 5"), limit: z.coerce.number().min(1).max(50).default(20), offset: z.coerce.number().min(0).default(0) }) }, responses: { 200: { description: "Paginated results", content: { "application/json": { schema: z.object({ data: z.array(SearchResultRef), total: z.number(), limit: z.number(), offset: z.number() }) } } }, 400: { description: "Missing or empty 'q'", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/search", summary: "Full-text search across public recipes — title, description, ingredient names, and tags", description: "Public — no auth required. Matches only public recipes by non-private authors.", security: [], request: { query: z.object({ q: z.string().min(1).max(200), difficulty: z.enum(["easy", "medium", "hard"]).optional(), recipeType: z.enum(["dish", "drink"]).optional(), maxMins: z.coerce.number().optional().describe("Max prepMins + cookMins combined"), dietary: z.string().optional().describe("Comma-separated: vegan,vegetarian,glutenFree,dairyFree"), tags: z.string().optional().describe("Comma-separated exact-tag filter chips, up to 5"), limit: z.coerce.number().min(1).max(50).default(20), offset: z.coerce.number().min(0).default(0) }) }, responses: { 200: { description: "Paginated results", content: { "application/json": { schema: z.object({ data: z.array(SearchResultRef), total: z.number(), limit: z.number(), offset: z.number() }) } } }, 400: { description: "Missing or empty 'q'", content: { "application/json": { schema: ApiErrorRef } } } } });
// --- Invites ---
registry.registerPath({ method: "get", path: "/api/v1/invites/{token}", summary: "Check whether an invite token is valid", description: "Public — no auth required.", security: [], request: { params: z.object({ token: z.string() }) }, responses: { 200: { description: "Validity (and the invited email, if valid)", content: { "application/json": { schema: z.object({ valid: z.boolean(), email: z.string().optional() }) } } } } });
+1
View File
@@ -508,6 +508,7 @@
"maxMinutes": "Max minutes",
"aiSearchPlaceholder": "e.g. quick weeknight dinners, Italian comfort food…",
"anyDifficulty": "Any difficulty",
"anyType": "Any type",
"resultsFoundSingular": "{count} recipe found",
"resultsFoundPlural": "{count} recipes found",
"noResultsTitle": "No results for “{query}”",
+1
View File
@@ -508,6 +508,7 @@
"maxMinutes": "Minutes max",
"aiSearchPlaceholder": "ex. dîners rapides en semaine, cuisine réconfortante italienne…",
"anyDifficulty": "Toute difficulté",
"anyType": "Tout type",
"resultsFoundSingular": "{count} recette trouvée",
"resultsFoundPlural": "{count} recettes trouvées",
"noResultsTitle": "Aucun résultat pour « {query} »",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@epicure/web",
"version": "0.32.0",
"version": "0.33.0",
"private": true,
"scripts": {
"dev": "next dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "epicure",
"version": "0.32.0",
"version": "0.33.0",
"private": true,
"scripts": {
"dev": "pnpm --filter web dev",