131 lines
3.6 KiB
TypeScript
131 lines
3.6 KiB
TypeScript
import { createLLM } from './llm';
|
||
import { zodResponseFormat } from 'openai/helpers/zod';
|
||
import { z } from 'zod';
|
||
|
||
const RecipeSchema = z.object({
|
||
name: z.string(),
|
||
servings: z.number().nullable(),
|
||
description: z.string().nullable(),
|
||
ingredients: z.array(
|
||
z.object({
|
||
item: z.string(),
|
||
amount: z.string(),
|
||
unit: z.string()
|
||
})
|
||
).nullable(),
|
||
steps: z.array(z.string()).nullable(),
|
||
image: z.string().nullable().optional()
|
||
});
|
||
|
||
export type Recipe = z.infer<typeof RecipeSchema>;
|
||
|
||
/**
|
||
* Detect if the text contains a recipe using binary classification
|
||
* @param text - The text to analyze
|
||
* @returns True if a recipe is detected, false otherwise
|
||
*/
|
||
export async function detectRecipe(text: string): Promise<boolean> {
|
||
try {
|
||
const { client, model } = createLLM();
|
||
|
||
const detectionResponse = await client.chat.completions.create({
|
||
model,
|
||
messages: [
|
||
{
|
||
role: 'system',
|
||
content:
|
||
"You are a recipe detector. Answer with ONLY 'yes' or 'no' - nothing else. A recipe MUST have: (1) name/title, (2) ingredients with quantities, (3) numbered cooking steps. If ANY are missing, answer 'no'."
|
||
},
|
||
{
|
||
role: 'user',
|
||
content: `Does this text contain a recipe?\n\n${text}`
|
||
}
|
||
],
|
||
max_tokens: 10
|
||
});
|
||
|
||
const detectionResult = detectionResponse.choices[0].message.content?.toLowerCase() ?? '';
|
||
return detectionResult.includes('yes');
|
||
} catch (e) {
|
||
console.error('Recipe detection error:', e);
|
||
throw new Error('Failed to detect recipe');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Extract recipe data from text using LLM structured output
|
||
* @param text - The text containing the recipe
|
||
* @returns Parsed recipe object
|
||
*/
|
||
export async function parseRecipe(text: string): Promise<Recipe> {
|
||
try {
|
||
const { client, model } = createLLM();
|
||
|
||
const completion = await client.beta.chat.completions.parse({
|
||
model,
|
||
messages: [
|
||
{
|
||
role: 'system',
|
||
content: `You are a RECIPE EXTRACTOR. Extract the recipe from the provided text.
|
||
|
||
✅ REQUIREMENTS:
|
||
1. Extract the exact recipe name from the text
|
||
2. List all ingredients with their quantities and units
|
||
3. List all cooking steps in order
|
||
4. Translate everything to Italian
|
||
5. Convert measurements to SI units (g, mL, °C)
|
||
|
||
📋 CONVERSION TABLE:
|
||
- 1 cup = 240 mL, 1 tbsp = 15 mL, 1 tsp = 5 mL
|
||
- 1 oz = 28.35 g, 1 lb = 453.59 g
|
||
- 1 stick butter = 113 g
|
||
- °F→°C: (°F–32)×5/9
|
||
|
||
🔄 OUTPUT FORMAT:
|
||
{
|
||
"name": "recipe name in Italian",
|
||
"servings": number or null,
|
||
"description": "description in Italian or null",
|
||
"ingredients": [{"item": "ingredient name", "amount": "quantity", "unit": "SI unit"}],
|
||
"steps": ["1. First step", "2. Second step", ...]
|
||
}
|
||
|
||
Extract ONLY what's explicitly in the text. Be accurate and literal.
|
||
`
|
||
},
|
||
{
|
||
role: 'user',
|
||
content: `Extract the recipe from this text:\n\n${text}`
|
||
}
|
||
],
|
||
response_format: zodResponseFormat(RecipeSchema, 'recipe')
|
||
});
|
||
|
||
const recipe = completion.choices[0].message.parsed;
|
||
|
||
if (!recipe || !recipe.name) {
|
||
throw new Error('Failed to extract recipe - missing name');
|
||
}
|
||
|
||
return recipe;
|
||
} catch (e) {
|
||
console.error('Recipe parsing error:', e);
|
||
throw new Error('Failed to parse recipe');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Complete workflow: detect recipe and parse if found
|
||
* @param text - The text to analyze
|
||
* @returns Parsed recipe object if detected, null otherwise
|
||
*/
|
||
export async function extractRecipe(text: string): Promise<Recipe | null> {
|
||
const isRecipe = await detectRecipe(text);
|
||
|
||
if (!isRecipe) {
|
||
return null;
|
||
}
|
||
|
||
return parseRecipe(text);
|
||
}
|