af92b8cc71
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>
54 lines
2.2 KiB
TypeScript
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;
|
|
}
|