diff --git a/app/api/recipes/generate/route.ts b/app/api/recipes/generate/route.ts index dd3a6b4..c228216 100644 --- a/app/api/recipes/generate/route.ts +++ b/app/api/recipes/generate/route.ts @@ -2,9 +2,10 @@ import { NextRequest, NextResponse } from "next/server"; import { getServerSessionAuth } from "@/lib/nextauth"; import { generateRecipes } from "@/lib/Recipe/generateRecipes"; import connectDB from "@/lib/connectToDatabase"; -import { extractIngredients } from "@/lib/langchain/extractIngredients"; +import { generateIngredient } from "@/lib/Ingredients/generateIngredeints"; import mongoose from "mongoose"; import RecipeModel from "@/models/Recipe"; + export async function POST(req: NextRequest) { const session = await getServerSessionAuth(); @@ -28,19 +29,31 @@ export async function POST(req: NextRequest) { } try { - const ingredients = await extractIngredients(ingredientsInput.toString()); + await connectDB(); - if (!ingredients.length) { + // Split ingredients string into array and process each ingredient + const ingredientsList = ingredientsInput + .toString() + .split(",") + .map((i: string) => i.trim()); + const generatedIngredients = await Promise.all( + ingredientsList.map(async (ingredientName: string) => { + const ingredient = await generateIngredient(ingredientName); + if (!ingredient) { + throw new Error(`Failed to generate ingredient: ${ingredientName}`); + } + return ingredient; + }) + ); + + if (!generatedIngredients.length) { return NextResponse.json( - { error: "No ingredients found" }, + { error: "No ingredients could be generated" }, { status: 400 } ); } - await connectDB(); - await Promise.all(ingredients.map((ing) => ing.save())); - - const recipes = await generateRecipes(ingredients, count); + const recipes = await generateRecipes(generatedIngredients, count); if (!recipes || !recipes.length) { return NextResponse.json( @@ -65,7 +78,9 @@ export async function POST(req: NextRequest) { } catch (error) { console.error("Error:", error); return NextResponse.json( - { error: "Server error occurred" }, + { + error: error instanceof Error ? error.message : "Server error occurred", + }, { status: 500 } ); } diff --git a/lib/Ingredients/generateIngredeints.ts b/lib/Ingredients/generateIngredeints.ts index 1ac9cdd..1fbfa21 100644 --- a/lib/Ingredients/generateIngredeints.ts +++ b/lib/Ingredients/generateIngredeints.ts @@ -1,5 +1,3 @@ -// lib/langchain/generateIngredient.ts - import { ChatOpenAI } from "@langchain/openai"; import { ChatPromptTemplate } from "@langchain/core/prompts"; import { StringOutputParser } from "@langchain/core/output_parsers"; @@ -8,16 +6,56 @@ import connectDB from "@/lib/connectToDatabase"; import Ingredient, { IngredientType } from "@/models/Ingredient"; const ingredientSchema = z.object({ - name: z.string(), - unit: z.enum(["g", "ml", "piece"]), + name: z.string({ + required_error: "Name is required", + }), + unit: z.enum(["g", "ml", "piece"], { + required_error: "Unit is required", + invalid_type_error: "Invalid unit type. Must be 'g', 'ml', or 'piece'", + }), nutrition: z.object({ - calories: z.number(), - protein: z.number(), - fats: z.number(), - carbs: z.number(), - fiber: z.number(), - sugar: z.number(), - sodium: z.number(), + calories: z + .number({ + required_error: "Calories value is required", + invalid_type_error: "Calories must be a number", + }) + .nonnegative("Calories cannot be negative"), + protein: z + .number({ + required_error: "Protein value is required", + invalid_type_error: "Protein must be a number", + }) + .nonnegative("Protein cannot be negative"), + fats: z + .number({ + required_error: "Fats value is required", + invalid_type_error: "Fats must be a number", + }) + .nonnegative("Fats cannot be negative"), + carbs: z + .number({ + required_error: "Carbs value is required", + invalid_type_error: "Carbs must be a number", + }) + .nonnegative("Carbs cannot be negative"), + fiber: z + .number({ + required_error: "Fiber value is required", + invalid_type_error: "Fiber must be a number", + }) + .nonnegative("Fiber cannot be negative"), + sugar: z + .number({ + required_error: "Sugar value is required", + invalid_type_error: "Sugar must be a number", + }) + .nonnegative("Sugar cannot be negative"), + sodium: z + .number({ + required_error: "Sodium value is required", + invalid_type_error: "Sodium must be a number", + }) + .nonnegative("Sodium cannot be negative"), }), }); @@ -32,9 +70,39 @@ const model = new ChatOpenAI({ const prompt = ChatPromptTemplate.fromTemplate(` Analyze the given food ingredient and provide its nutritional information. +Required format: +{{ + "name": "ingredient name", + "unit": "g" | "ml" | "piece", + "nutrition": {{ + "calories": number (per 100g/ml), + "protein": number (g per 100g/ml), + "fats": number (g per 100g/ml), + "carbs": number (g per 100g/ml), + "fiber": number (g per 100g/ml), + "sugar": number (g per 100g/ml), + "sodium": number (mg per 100g/ml) + }} +}} + +Example response: +{{ + "name": "apple", + "unit": "piece", + "nutrition": {{ + "calories": 52, + "protein": 0.3, + "fats": 0.2, + "carbs": 14, + "fiber": 2.4, + "sugar": 10.4, + "sodium": 1 + }} +}} + Ingredient to analyze: {ingredient} -{format_instructions} +Respond ONLY with a valid JSON object. `); const chain = prompt.pipe(model).pipe(parser); @@ -53,23 +121,41 @@ export async function generateIngredient( return existingIngredient; } + console.log("Generating nutrition info for:", ingredientName); + const result = await chain.invoke({ ingredient: ingredientName, - format_instructions: parser.getFormatInstructions(), }); - const parsedResult = ingredientSchema.parse(result); + console.log("AI Response:", result); + + const parsedResult = JSON.parse(result); + console.log("Parsed Result:", JSON.stringify(parsedResult, null, 2)); + + const validation = ingredientSchema.safeParse(parsedResult); + + if (!validation.success) { + console.error( + "Validation errors:", + validation.error.flatten().fieldErrors + ); + return null; + } const newIngredient = new Ingredient({ - name: parsedResult.name.toLowerCase(), - unit: parsedResult.unit, - nutrition: parsedResult.nutrition, + name: validation.data.name.toLowerCase(), + unit: validation.data.unit, + nutrition: validation.data.nutrition, }); await newIngredient.save(); return newIngredient; } catch (error) { - console.error("Error generating/saving ingredient:", error); + if (error instanceof z.ZodError) { + console.error("Validation errors:", error.flatten().fieldErrors); + } else { + console.error("Error generating/saving ingredient:", error); + } return null; } } diff --git a/lib/Recipe/generateRecipes.ts b/lib/Recipe/generateRecipes.ts index 05eb044..5fcc44b 100644 --- a/lib/Recipe/generateRecipes.ts +++ b/lib/Recipe/generateRecipes.ts @@ -124,18 +124,11 @@ export async function generateRecipes( .map((ing) => `- ${ing.name} (unit: ${ing.unit})`) .join("\n"); - console.log("Generating recipes with prompt:", { - ingredients: ingredientsString, - count, - }); - const results = await chain.invoke({ ingredients: ingredientsString, count, }); - console.log("AI Response:", results); - const parsedResults = JSON.parse(results); console.log("Parsed Results:", JSON.stringify(parsedResults, null, 2)); diff --git a/lib/cleanJsonString.ts b/lib/cleanJsonString.ts deleted file mode 100644 index 1572661..0000000 --- a/lib/cleanJsonString.ts +++ /dev/null @@ -1,5 +0,0 @@ -export default function cleanJsonString(str: string): string { - str = str.replace(/```json\n?|```\n?/g, ""); - str = str.trim(); - return str; -} diff --git a/lib/langchain/extractIngredients.ts b/lib/langchain/extractIngredients.ts deleted file mode 100644 index 9150d35..0000000 --- a/lib/langchain/extractIngredients.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { ChatOpenAI } from "@langchain/openai"; -import { ChatPromptTemplate } from "@langchain/core/prompts"; -import Ingredient, { IngredientType } from "@/models/Ingredient"; -import { isValidFood } from "./validateIngredient"; -import connectDB from "../connectToDatabase"; - -const model = new ChatOpenAI({ - modelName: "gpt-4o-mini", - temperature: 0, - openAIApiKey: process.env.OPENAI_API_KEY, - stop: ["\n\n"], // Prevent extra text after JSON -}); - -const prompt = ChatPromptTemplate.fromMessages([ - [ - "system", - ` -Analyze the given food ingredient(s) with their quantities. Multiple ingredients may be provided in a single string. -First split the input into individual ingredients and their amounts, then analyze each one. - -For each valid ingredient return: -1. The correct name in English -2. The original amount and unit from input (if provided) -3. The standard unit for this ingredient (g, ml, or piece) -4. Nutritional values per 100g, 100ml, or per piece of the product - -Example inputs: -"2 tomatoes, 500ml milk, 3 eggs" -"pomidor 2 szt, mleko 500ml, 3 jajka" - -Example output: -[ - {{ - "name": "tomato", - "unit": "piece", - "nutrition": {{ - "calories": 22, - "protein": 1.1, - "fats": 0.2, - "carbs": 4.8, - "fiber": 1.5, - "sugar": 3.2, - "sodium": 6 - }} - }}, - {{ - "name": "milk", - "unit": "ml", - "nutrition": {{ - "calories": 42, - "protein": 3.4, - "fats": 1.0, - "carbs": 5.0, - "fiber": 0, - "sugar": 5.0, - "sodium": 44 - }} - }}, - {{ - "name": "egg", - "unit": "piece", - "nutrition": {{ - "calories": 68, - "protein": 5.5, - "fats": 4.8, - "carbs": 0.6, - "fiber": 0, - "sugar": 0.6, - "sodium": 62 - }} - }} -] - -Notes: -- Things like fruits, vegetables should be in their natural unit, which is usually piece -- Things like bread, which are usually in slices should be named as "slice" like "bread slice" and piece unit -- Naming should be in english -- Names should be singular - -Respond ONLY with a valid JSON array string in this exact format (no other text): -[ - {{ - "name": "[english name]", - "unit": "[g/ml/piece]", - "nutrition": {{ - "calories": [number], - "protein": [number], - "fats": [number], - "carbs": [number], - "fiber": [number], - "sugar": [number], - "sodium": [number] - }} - }} -] - -You can not use markdown or HTML in your response, ONLY JSON. -`, - ], - ["user", "{ingredient}"], -]); - -const chain = prompt.pipe(model); - -async function validateIngredients(ing: IngredientType) { - await connectDB(); - - // Check if ingredient already exists - const existingIngredient = await Ingredient.findOne({ - name: { $regex: new RegExp(ing.name, "i") }, - }); - if (existingIngredient) return existingIngredient; - - // Validate new ingredient before saving - const isValid = await isValidFood(ing.name); - if (!isValid) return null; - - const ingredient = new Ingredient({ - name: ing.name, - unit: ing.unit, - nutrition: ing.nutrition, - }); - - return ingredient; -} - -export async function extractIngredients(input: string) { - try { - const analysis = await chain.invoke({ ingredient: input }); - let parsedIngredients; - - try { - parsedIngredients = JSON.parse(analysis.content.toString()); - } catch (error) { - if (error instanceof Error) { - throw new Error(`Failed to parse AI response: ${error.message}`); - } else { - throw new Error(`Failed to parse AI response: Unknown error`); - } - } - - const ingredients = ( - await Promise.all(parsedIngredients.map(validateIngredients)) - ).filter(Boolean); - - return ingredients; - } catch (error) { - if (error instanceof Error) { - throw new Error(`Ingredient analysis failed: ${error.message}`); - } else { - throw new Error("Ingredient analysis failed: Unknown error"); - } - } -} diff --git a/lib/langchain/generateRecipes.ts b/lib/langchain/generateRecipes.ts deleted file mode 100644 index 27bf9f9..0000000 --- a/lib/langchain/generateRecipes.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { ChatOpenAI } from "@langchain/openai"; -import { ChatPromptTemplate } from "@langchain/core/prompts"; -import connectDB from "@/lib/connectToDatabase"; -import Recipe from "@/models/Recipe"; -import Ingredient, { IngredientType } from "@/models/Ingredient"; -import { extractIngredients } from "./extractIngredients"; - -const model = new ChatOpenAI({ - modelName: "gpt-4o-mini", - temperature: 0, - openAIApiKey: process.env.OPENAI_API_KEY, -}); - -const prompt = ChatPromptTemplate.fromMessages([ - [ - "system", - ` -Create {count} recipe(s) using the provided ingredients as much as possible. - -Each recipe should: -- Use as many of the provided ingredients as possible. -- Require as few additional ingredients as possible. -- Conform to the Recipe model. - -Must conform with provided ingredients units. - -Notes for unknown ingredients: -- Things like fruits, vegetables should be in their natural unit, which is usually piece -- Things like bread, which are usually in slices should be named as "slice" like "bread slice" and piece unit -- Naming should be in english -- Names should be singular - -Respond ONLY with a valid JSON array string in this exact format (no other text): -[ - { - "name": "[Recipe name]", - "description": "[Description]", - "ingredients": [ - { - "name": "[Ingredient name]", - "amount": [number] - } - ], - "steps": ["[Step 1]", "[Step 2]", "..."], - "prepTime": [number], - "cookTime": [number], - "difficulty": "[Easy/Medium/Hard]", - "experience": [number] - } -] -`, - ], - ["user", "{ingredients}"], -]); - -const chain = prompt.pipe(model); - -async function translateUnit(unit: string) { - const units: { [key: string]: string } = { - g: "gram", - ml: "milliliter", - piece: "piece", - }; - - return units[unit] || unit; -} - -async function validateGeneratedIngredient(genIng: any) { - if (!genIng.name || !genIng.amount) return; - - if (genIng.amount <= 0) return; - - await connectDB(); - const ingredient = await Ingredient.findOne({ - name: genIng.name.toLowerCase(), - }); - - if (ingredient) return ingredient; - - const newIngredient = (await extractIngredients(genIng.name))[0]; - await newIngredient.save(); - - return newIngredient; -} - -async function validateRecipe(recipeData: any) { - if ( - !recipeData.name || - !recipeData.description || - !recipeData.ingredients || - !recipeData.steps || - !recipeData.prepTime || - !recipeData.cookTime || - !recipeData.difficulty || - !recipeData.experience - ) - return; - - if (!Array.isArray(recipeData.ingredients)) return; - - let ingredients; - - try { - ingredients = await Promise.all( - recipeData.ingredients.map(async (ing: any) => { - const ingredient = await validateGeneratedIngredient(ing); - - if (!ingredient) throw new Error("Invalid generated ingredient"); - - return { - ingredient: ingredient._id, - amount: ing.amount, - }; - }) - ); - } catch (error) { - console.error("Error validating generated ingredients:", error); - return; - } - - const recipe = new Recipe({ - name: recipeData.name, - description: recipeData.description, - ingredients, - steps: recipeData.steps, - prepTime: recipeData.prepTime, - cookTime: recipeData.cookTime, - difficulty: recipeData.difficulty, - experience: recipeData.experience, - }); - - return recipe; -} - -export async function generateRecipes( - ingredients: IngredientType[], - recipesCount: number = 5 -) { - await connectDB(); - - try { - const ingredientsAsString = ingredients - .map((ing) => `- ${ing.name}, provided in ${translateUnit(ing.unit)}s`) - .join("\n"); - - const response = await chain.invoke({ - ingredients: ingredientsAsString, - count: recipesCount, - }); - - const generatedRecipes = JSON.parse(response.content.toString()); - - const recipes = ( - await Promise.all(generatedRecipes.map(validateRecipe)) - ).filter(Boolean); - - return recipes; - } catch (error) { - console.error("Error:", error); - return []; - } -} diff --git a/lib/langchain/validateIngredient.ts b/lib/langchain/validateIngredient.ts deleted file mode 100644 index 4d08512..0000000 --- a/lib/langchain/validateIngredient.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { ChatOpenAI } from "@langchain/openai"; -import { ChatPromptTemplate } from "@langchain/core/prompts"; - -const model = new ChatOpenAI({ - modelName: "gpt-4o-mini", - temperature: 0, - openAIApiKey: process.env.OPENAI_API_KEY, - stop: ["\n", " "], -}); - -const prompt = ChatPromptTemplate.fromTemplate(` -Evaluate if the given ingredient is an edible food product. Answer only "true" or "false". - -Ingredient to evaluate: {ingredient} - -Evaluation rules: -- Answer "true" if the ingredient is: - * A fruit or vegetable - * A food product available in stores - * A spice or herb - * Meat or fish - * Dairy - * Grain or its derivative - * An ingredient used in cooking - -- Answer "false" if the ingredient is: - * Inedible or toxic - * A random string of characters - * A non-food item - * Does not exist as a food product - -Examples: -"banana": true -"apple": true -"salt": true -"chicken": true -"XKCD123": false -"stone": false -"dirt": false -"poison": false - -Answer only "true" or "false". -`); - -const chain = prompt.pipe(model); - -export async function isValidFood(ingredient: string): Promise { - try { - const result = await chain.invoke({ ingredient }); - - return result.content.toString().toLowerCase().includes("true"); - } catch (error) { - console.error(`Validation failed for ingredient "${ingredient}":`, error); - - return false; // Fail safe - if validation fails, assume ingredient is invalid - } -}