""" AI Assistant Service for ChefCode Handles intent detection, conversational AI, and action orchestration """ import os import json import logging from typing import Dict, Any, List, Optional from openai import OpenAI from pydantic import BaseModel logger = logging.getLogger(__name__) class IntentResult(BaseModel): """Structure for intent detection result""" intent: str confidence: float entities: Dict[str, Any] requires_confirmation: bool = False response_message: str = "" class AIAssistantService: """ AI Assistant for natural language command processing Uses GPT-4o-mini for intent detection and conversational responses """ # Supported intents INTENTS = { "add_inventory": "Add items to inventory", "update_inventory": "Update inventory quantities", "delete_inventory": "Remove items from inventory", "query_inventory": "Query inventory status", "add_recipe": "Add a new recipe manually", "edit_recipe": "Edit existing recipe", "delete_recipe": "Delete a recipe", "search_recipe_web": "Search recipes online", "show_recipe": "Display specific recipe", "import_recipe": "Import recipe from search results", "show_catalogue": "Show recipe catalogue", "filter_catalogue": "Filter recipes by category", "general_query": "General questions", "unknown": "Cannot determine intent" } def __init__(self): """Initialize OpenAI client""" api_key = os.getenv("OPENAI_API_KEY") if not api_key: raise ValueError("OPENAI_API_KEY environment variable not set") self.client = OpenAI(api_key=api_key) self.model = "gpt-4o-mini" self.conversation_context = [] # Store recent conversation for context async def detect_intent(self, user_input: str, context: Optional[Dict] = None) -> IntentResult: """ Detect user intent from natural language input Args: user_input: User's natural language command context: Optional conversation context Returns: IntentResult with detected intent and extracted entities """ try: system_prompt = self._build_intent_detection_prompt() # Add context if available context_info = "" if context: context_info = f"\n\nConversation context: {json.dumps(context)}" user_prompt = f"""Analyze this user command and return a structured JSON response: User Input: "{user_input}"{context_info} Return JSON with this structure: {{ "intent": "intent_name", "confidence": 0.95, "entities": {{ // Extracted entities based on intent }}, "requires_confirmation": true/false, "response_message": "Conversational response to user" }} IMPORTANT: Return ONLY the JSON, no markdown formatting.""" response = self.client.chat.completions.create( model=self.model, messages=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt} ], temperature=0.3, max_tokens=1000 ) result_text = response.choices[0].message.content.strip() # Clean markdown if present if result_text.startswith("```"): lines = result_text.split('\n') result_text = '\n'.join([l for l in lines if not l.startswith("```")]) result_dict = json.loads(result_text) return IntentResult(**result_dict) except Exception as e: logger.error(f"Intent detection error: {str(e)}") return IntentResult( intent="unknown", confidence=0.0, entities={}, response_message=f"I'm not sure what you mean. Could you rephrase that?" ) async def parse_recipe_from_text(self, user_input: str) -> Dict[str, Any]: """ Parse recipe details from natural language Example: "Add a recipe called Pizza with flour 100 kg and tomato sauce 200 ml" Returns: { "recipe_name": "Pizza", "ingredients": [ {"name": "flour", "quantity": 100, "unit": "kg"}, {"name": "tomato sauce", "quantity": 200, "unit": "ml"} ], "yield_qty": 1, "yield_unit": "piece" } """ try: prompt = f"""You are a recipe parsing expert. Extract ALL ingredient information from this command. User Input: "{user_input}" CRITICAL RULES: 1. Extract the recipe name 2. For EACH ingredient, extract: - name (ingredient name) - quantity (numeric value, if missing use null) - unit (measurement unit like kg, g, ml, liters, pieces, etc. If missing use null) 3. Extract yield if mentioned (default: null) EXAMPLES: "Add recipe Pizza with flour 500 grams and tomato 200 ml" → {{"recipe_name": "Pizza", "ingredients": [{{"name": "flour", "quantity": 500, "unit": "grams"}}, {{"name": "tomato", "quantity": 200, "unit": "ml"}}], "yield_qty": null, "yield_unit": null}} "Add recipe spaghetti with flour and salt" → {{"recipe_name": "spaghetti", "ingredients": [{{"name": "flour", "quantity": null, "unit": null}}, {{"name": "salt", "quantity": null, "unit": null}}], "yield_qty": null, "yield_unit": null}} Return ONLY valid JSON, no markdown, no explanation: {{ "recipe_name": "string", "ingredients": [ {{"name": "string", "quantity": number or null, "unit": "string or null"}}, ... ], "yield_qty": number or null, "yield_unit": "string or null", "instructions": "string or empty" }}""" response = self.client.chat.completions.create( model=self.model, messages=[ {"role": "system", "content": "You are a precise recipe data extractor. Always return valid JSON."}, {"role": "user", "content": prompt} ], temperature=0.1, max_tokens=1000 ) result = response.choices[0].message.content.strip() # Clean markdown if result.startswith("```"): lines = result.split('\n') result = '\n'.join([l for l in lines if not l.startswith("```")]) parsed = json.loads(result) # Convert null to proper values for ing in parsed.get('ingredients', []): if ing.get('quantity') is None: ing['quantity'] = None if ing.get('unit') is None: ing['unit'] = None return parsed except Exception as e: logger.error(f"Recipe parsing error: {str(e)}") raise ValueError(f"Could not parse recipe: {str(e)}") async def generate_response(self, intent: str, action_result: Dict[str, Any]) -> str: """ Generate a conversational response based on the action result Args: intent: The detected intent action_result: Result from the action handler Returns: Conversational response string """ try: prompt = f"""Generate a short, friendly response for this action: Intent: {intent} Action Result: {json.dumps(action_result)} Rules: - Be conversational and concise (max 2 sentences) - Use emojis sparingly for emphasis - Confirm what was done - If error, be helpful and suggest alternatives Return only the response text, nothing else.""" response = self.client.chat.completions.create( model=self.model, messages=[ {"role": "system", "content": "You are ChefCode's friendly AI assistant. Be concise and helpful."}, {"role": "user", "content": prompt} ], temperature=0.7, max_tokens=150 ) return response.choices[0].message.content.strip() except Exception as e: logger.error(f"Response generation error: {str(e)}") return "Action completed." if action_result.get("success") else "Something went wrong." def _build_intent_detection_prompt(self) -> str: """Build the system prompt for intent detection with examples""" return """You are ChefCode's intelligent assistant. Analyze user commands and detect their intent. SUPPORTED INTENTS: 📦 INVENTORY: - add_inventory: Add items to inventory Example: "Add 5 kg of rice at 2.50 euros" Entities: {"item_name": "rice", "quantity": 5, "unit": "kg", "price": 2.50} IMPORTANT: Always extract price if mentioned (at, for, cost, price, euro, dollar, etc.) - update_inventory: Update quantities Example: "Update flour to 10 kg" Entities: {"item_name": "flour", "quantity": 10, "unit": "kg"} - delete_inventory: Remove items Example: "Remove tomatoes from inventory" Entities: {"item_name": "tomatoes"} - query_inventory: Check stock Example: "How much rice do we have?" Entities: {"item_name": "rice"} 🍳 RECIPE MANAGEMENT: - add_recipe: Create new recipe manually Example: "Add a recipe called Pizza with flour 100 kg and cheese 50 kg" Entities: {"recipe_name": "Pizza", "raw_text": "...full input..."} - edit_recipe: Modify existing recipe (add/remove/change ingredient) Example: "Edit recipe Pizza by adding 2 grams of salt" Entities: {"recipe_name": "Pizza", "action": "adding", "ingredient_name": "salt", "quantity": "2", "unit": "grams"} Example: "Remove flour from Pizza recipe" Entities: {"recipe_name": "Pizza", "action": "remove", "ingredient_name": "flour"} Example: "Change tomatoes in Pizza to 500 grams" Entities: {"recipe_name": "Pizza", "action": "change", "ingredient_name": "tomatoes", "quantity": "500", "unit": "grams"} - delete_recipe: Remove recipe Example: "Delete the recipe Pasta" Entities: {"recipe_name": "Pasta"} - search_recipe_web: Search recipes online Example: "Search pasta recipes" or "Find Italian recipes" Entities: {"query": "pasta", "filters": {"cuisine": "Italian"}} - show_recipe: Display specific recipe Example: "Show me the Pizza recipe" Entities: {"recipe_name": "Pizza"} - import_recipe: Import from search results Example: "Import the second one" or "Import that recipe" Entities: {"index": 2, "recipe_id": "..."} 📚 CATALOGUE: - show_catalogue: Show all recipes Example: "Show all recipes" or "Open recipe catalogue" Entities: {} - filter_catalogue: Filter by category Example: "Show dessert recipes" Entities: {"category": "dessert"} ❓ OTHER: - general_query: General questions - unknown: Cannot determine RULES: 1. Set requires_confirmation=true for destructive actions (add, update, delete) 2. Extract ALL relevant entities from the input 3. Be conversational in response_message 4. If ambiguous, ask clarifying questions 5. For numbers, always extract both quantity and unit 6. For recipe commands, capture the full raw text for later parsing Return JSON only, no markdown.""" def add_to_context(self, role: str, content: str): """Add message to conversation context""" self.conversation_context.append({"role": role, "content": content}) # Keep only last 10 messages if len(self.conversation_context) > 10: self.conversation_context = self.conversation_context[-10:] def clear_context(self): """Clear conversation context""" self.conversation_context = []