210 lines
6.2 KiB
TypeScript
210 lines
6.2 KiB
TypeScript
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<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();
|
|
|
|
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<Recipe> {
|
|
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<Recipe | null> {
|
|
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<Recipe> {
|
|
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;
|
|
}
|