import { createLLM, checkModelAvailability } from './llm'; import { zodResponseFormat } from 'openai/helpers/zod'; import { z } from 'zod'; import { RECIPE_DETECTION_PROMPT, RECIPE_EXTRACTION_PROMPT } from './prompts/recipe-extraction'; 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; /** * 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 { try { const { client, model } = createLLM(); console.log('[LLM] Starting recipe detection...'); console.log('[LLM] Model:', model); console.log('[LLM] Text length:', text.length); const detectionResponse = await client.chat.completions.create({ model, messages: [ { role: 'system', content: RECIPE_DETECTION_PROMPT }, { role: 'user', content: `Does this text contain a recipe?\n\n${text}` } ], max_tokens: 10, temperature: 0 }); const detectionResult = detectionResponse.choices[0].message.content?.toLowerCase() ?? ''; console.log('[LLM] Detection response:', detectionResult); return detectionResult.includes('yes'); } catch (e) { console.error('[LLM] Recipe detection error:', e); console.error('[LLM] Stack trace:', (e as Error).stack); // Check if this is a model-related error const errorMessage = (e as Error).message || ''; const isModelError = errorMessage.includes('400') && (errorMessage.toLowerCase().includes('model') || errorMessage.toLowerCase().includes('load')); if (isModelError) { const { model } = createLLM(); const modelCheck = await checkModelAvailability(model); if (!modelCheck.available) { throw new Error(modelCheck.message || `Model "${model}" is not available`); } } throw new Error(`Failed to detect recipe: ${(e as Error).message}`); } } /** * 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 { try { const { client, model } = createLLM(); console.log('[LLM] Starting recipe parsing...'); console.log('[LLM] Model:', model); const completion = await client.beta.chat.completions.parse({ model, messages: [ { role: 'system', content: RECIPE_EXTRACTION_PROMPT }, { role: 'user', content: `Extract the recipe from this text:\n\n${text}` } ], response_format: zodResponseFormat(RecipeSchema, 'recipe'), temperature: 0.3 }); const recipe = completion.choices[0].message.parsed; console.log('[LLM] Parse response:', recipe?.name); if (!recipe || !recipe.name) { throw new Error('Failed to extract recipe - missing name'); } return recipe; } catch (e) { console.error('[LLM] Recipe parsing error:', e); console.error('[LLM] Stack trace:', (e as Error).stack); // Check if this is a model-related error const errorMessage = (e as Error).message || ''; const isModelError = errorMessage.includes('400') && (errorMessage.toLowerCase().includes('model') || errorMessage.toLowerCase().includes('load')); if (isModelError) { const { model } = createLLM(); const modelCheck = await checkModelAvailability(model); if (!modelCheck.available) { throw new Error(modelCheck.message || `Model "${model}" is not available`); } } // If structured output fails, try standard completion if ((e as any).message?.includes('response_format') || (e as any).message?.includes('structured output')) { console.warn('[LLM] Falling back to standard completion'); return await parseRecipeWithStandardCompletion(text); } throw new Error(`Failed to parse recipe: ${(e as Error).message}`); } } /** * 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 { const isRecipe = await detectRecipe(text); if (!isRecipe) { return null; } return parseRecipe(text); } /** * Fallback parser using standard completion (no structured output) * Used when the model doesn't support beta.chat.completions.parse() */ async function parseRecipeWithStandardCompletion(text: string): Promise { const { client, model } = createLLM(); console.log('[LLM] Using standard completion fallback'); const completion = await client.chat.completions.create({ model, messages: [ { role: 'system', content: `You are a recipe extractor. Return ONLY valid JSON matching this schema: { "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": ["First step", "Second step", ...] } Convert all measurements to SI units (g, mL, °C). Translate everything to Italian. Extract ONLY what's in the text.` }, { role: 'user', content: `Extract the recipe from this text:\n\n${text}` } ], max_tokens: 2000, temperature: 0.3 }); const jsonResponse = completion.choices[0].message.content; if (!jsonResponse) { throw new Error('Empty response from LLM'); } console.log('[LLM] Standard completion raw response:', jsonResponse.substring(0, 200)); // Parse and validate JSON (remove code fences if present) const cleanedJson = jsonResponse.replace(/```json\n?|```\n?/g, '').trim(); const parsedData = JSON.parse(cleanedJson); const recipe = RecipeSchema.parse(parsedData); console.log('[LLM] Standard completion parsed recipe:', recipe.name); return recipe; }