Files
Epicure/apps/web/lib/ai/features/import-url.ts
T
Arnaud af92b8cc71 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>
2026-07-14 15:17:33 +02:00

54 lines
2.2 KiB
TypeScript

import { generateObject } from "ai";
import { z } from "zod";
import { resolveModel, type AiConfig } from "../factory";
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(),
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.string())),
steps: z.array(stepSchema),
});
export type ImportedRecipe = z.infer<typeof ImportedRecipeSchema>;
export async function importFromUrl(url: string, config?: AiConfig): Promise<ImportedRecipe> {
const res = await safeFetch(url, {
headers: { "User-Agent": "Mozilla/5.0 (compatible; Epicure/1.0; recipe importer)" },
signal: AbortSignal.timeout(10000),
});
if (!res.ok) throw new Error(`Failed to fetch URL: ${res.status}`);
const html = await res.text();
// strip scripts/styles, take first 30k chars to stay within context
const cleaned = html
.replace(/<script[\s\S]*?<\/script>/gi, "")
.replace(/<style[\s\S]*?<\/style>/gi, "")
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.slice(0, 30000);
const model = resolveModel(config);
const { object } = await generateObject({
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. 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;
}