File size: 11,398 Bytes
9aaec2c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
"""
AI Service Module
Handles interactions with OpenAI models (GPT-4o-mini and GPT-o3 reasoning)
"""

import os
import json
from typing import Dict, List, Any, Optional
from openai import OpenAI
import logging

logger = logging.getLogger(__name__)

class AIService:
    """Service for AI-powered recipe interpretation and ingredient mapping"""
    
    def __init__(self):
        """Initialize OpenAI client with API key from environment"""
        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.gpt_4o_mini_model = "gpt-4o-mini"
        # Use o3 reasoning model for ingredient mapping
        self.gpt_o3_model = "o3"
    
    async def interpret_query(self, user_query: str) -> Dict[str, Any]:
        """
        Use GPT-4o-mini to interpret natural language recipe search query
        
        Args:
            user_query: Natural language search query (e.g., "quick Italian pasta without cheese")
        
        Returns:
            Structured filters dictionary with:
            - keywords: List of search keywords
            - cuisine: Cuisine type (optional)
            - restrictions: List of dietary restrictions (optional)
            - max_time: Maximum cooking time in minutes (optional)
        
        Example response:
        {
            "keywords": ["pasta"],
            "cuisine": "Italian",
            "restrictions": ["no cheese"],
            "max_time": 30
        }
        """
        try:
            system_prompt = """You are a culinary assistant that interprets recipe search queries.
Extract structured information from natural language queries.

Return ONLY valid JSON with these fields:
- keywords: array of strings (main ingredients or dish types to search)
- cuisine: string or null (e.g., "Italian", "Chinese", "Mexican")
- restrictions: array of strings (dietary restrictions like "no cheese", "vegetarian", "vegan")
- max_time: number or null (maximum cooking time in minutes)

Examples:
Query: "quick Italian pasta without cheese"
Response: {"keywords": ["pasta"], "cuisine": "Italian", "restrictions": ["no cheese"], "max_time": 30}

Query: "spicy chicken curry"
Response: {"keywords": ["chicken", "curry"], "cuisine": null, "restrictions": ["spicy"], "max_time": null}

Query: "easy vegetarian soup under 20 minutes"
Response: {"keywords": ["soup"], "cuisine": null, "restrictions": ["vegetarian"], "max_time": 20}
"""
            
            response = self.client.chat.completions.create(
                model=self.gpt_4o_mini_model,
                messages=[
                    {"role": "system", "content": system_prompt},
                    {"role": "user", "content": user_query}
                ],
                temperature=0.3,
                max_tokens=500,
                response_format={"type": "json_object"}
            )
            
            result = json.loads(response.choices[0].message.content)
            
            # Ensure all required fields exist with defaults
            return {
                "keywords": result.get("keywords", []),
                "cuisine": result.get("cuisine"),
                "restrictions": result.get("restrictions", []),
                "max_time": result.get("max_time")
            }
            
        except Exception as e:
            logger.error(f"Error interpreting query: {str(e)}")
            # Fallback: return simple keyword extraction
            return {
                "keywords": [user_query],
                "cuisine": None,
                "restrictions": [],
                "max_time": None
            }
    
    async def map_ingredients(
        self, 
        recipe_ingredients: List[Dict[str, str]], 
        inventory_items: List[Dict[str, str]]
    ) -> List[Dict[str, Any]]:
        """
        Use AI reasoning to semantically map recipe ingredients to inventory
        Tries o3 first, falls back to gpt-4 if needed
        
        Args:
            recipe_ingredients: List of recipe ingredients [{"name": "...", "quantity": "...", "unit": "..."}]
            inventory_items: List of inventory items [{"name": "...", "unit": "..."}]
        
        Returns:
            List of mapping results:
            [{
                "recipe_ingredient": str,
                "recipe_quantity": str,
                "recipe_unit": str,
                "mapped_to": str or null,
                "match_confidence": float (0.0-1.0),
                "match_type": "exact" | "substitute" | "missing",
                "note": str (explanation or suggestion)
            }]
        """
        # Try o3 first, fallback to gpt-4 if it fails
        models_to_try = ["o3", "gpt-4"]
        
        for model_name in models_to_try:
            try:
                logger.info(f"Attempting ingredient mapping with model: {model_name}")
                return await self._map_with_model(model_name, recipe_ingredients, inventory_items)
            except Exception as e:
                logger.warning(f"Model {model_name} failed: {str(e)}")
                if model_name == models_to_try[-1]:  # Last model
                    raise
                else:
                    logger.info(f"Falling back to next model...")
                    continue
        
        # This should never be reached, but just in case
        raise Exception("All AI models failed")
    
    async def _map_with_model(
        self,
        model_name: str,
        recipe_ingredients: List[Dict[str, str]], 
        inventory_items: List[Dict[str, str]]
    ) -> List[Dict[str, Any]]:
        """Internal method to map ingredients using a specific model"""
        try:
            # Prepare data for the AI
            recipe_list = "\n".join([
                f"- {ing.get('name', 'Unknown')} ({ing.get('quantity', '')} {ing.get('unit', '')})"
                for ing in recipe_ingredients
            ])
            
            inventory_list = "\n".join([
                f"- {item.get('name', 'Unknown')} ({item.get('unit', 'pz')})"
                for item in inventory_items
            ])
            
            system_prompt = """You are an expert AI sous-chef specializing in ingredient matching and substitutions.

Your task: Match recipe ingredients to the restaurant's inventory items semantically.

Rules:
1. EXACT MATCH: Same ingredient or very similar (e.g., "tomatoes" → "San Marzano tomatoes")
2. SUBSTITUTE: Compatible replacement (e.g., "butter" → "margarine", "basil" → "dried basil")
3. MISSING: No suitable match exists in inventory

For each recipe ingredient, analyze and return:
- recipe_ingredient: exact name from recipe
- recipe_quantity: amount needed
- recipe_unit: unit of measurement
- mapped_to: inventory item name (or null if missing)
- match_confidence: 0.0 to 1.0 (1.0 = perfect match)
- match_type: "exact" | "substitute" | "missing"
- note: Brief explanation or substitution advice

Return ONLY valid JSON array. No markdown, no extra text."""
            
            user_prompt = f"""Recipe Ingredients:
{recipe_list}

Inventory Available:
{inventory_list}

Match each recipe ingredient to inventory. Return JSON array of mappings."""
            
            # Use different parameters based on model
            if model_name == "o3":
                response = self.client.chat.completions.create(
                    model=model_name,
                    messages=[
                        {"role": "user", "content": f"{system_prompt}\n\n{user_prompt}"}
                    ],
                    max_completion_tokens=4000
                )
            else:  # gpt-4 or other models
                response = self.client.chat.completions.create(
                    model=model_name,
                    messages=[
                        {"role": "system", "content": system_prompt},
                        {"role": "user", "content": user_prompt}
                    ],
                    max_tokens=2000,
                    temperature=0.2,
                    response_format={"type": "json_object"}
                )
            
            result = response.choices[0].message.content
            
            # Log raw response for debugging
            logger.debug(f"Raw AI response: {result[:500] if result else 'EMPTY'}")
            
            if not result or not result.strip():
                logger.error("AI returned empty response")
                raise ValueError("AI returned empty response")
            
            # Try to extract JSON if wrapped in markdown code blocks
            if result.startswith("```"):
                # Extract JSON from markdown code block
                lines = result.split('\n')
                json_lines = []
                in_code_block = False
                for line in lines:
                    if line.startswith("```"):
                        in_code_block = not in_code_block
                        continue
                    if in_code_block or (not line.startswith("```") and json_lines):
                        json_lines.append(line)
                result = '\n'.join(json_lines).strip()
            
            # Parse response - handle if it's wrapped in a root key
            parsed = json.loads(result)
            if isinstance(parsed, dict):
                # If response has a wrapper key like "mappings", extract it
                if "mappings" in parsed:
                    mappings = parsed["mappings"]
                elif "ingredients" in parsed:
                    mappings = parsed["ingredients"]
                else:
                    # Try to find the first list value
                    mappings = None
                    for value in parsed.values():
                        if isinstance(value, list):
                            mappings = value
                            break
                    if not mappings:
                        mappings = []
            else:
                mappings = parsed if isinstance(parsed, list) else []
            
            # Ensure all recipe_quantity values are strings
            for mapping in mappings:
                if 'recipe_quantity' in mapping and not isinstance(mapping['recipe_quantity'], str):
                    mapping['recipe_quantity'] = str(mapping['recipe_quantity'])
            
            return mappings
            
        except Exception as e:
            logger.error(f"Error mapping ingredients: {str(e)}", exc_info=True)
            # Fallback: return basic structure marking all as missing
            return [
                {
                    "recipe_ingredient": ing.get("name", "Unknown"),
                    "recipe_quantity": ing.get("quantity", ""),
                    "recipe_unit": ing.get("unit", ""),
                    "mapped_to": None,
                    "match_confidence": 0.0,
                    "match_type": "missing",
                    "note": f"Unable to perform AI matching: {str(e)}"
                }
                for ing in recipe_ingredients
            ]


# Singleton instance
_ai_service: Optional[AIService] = None

def get_ai_service() -> AIService:
    """Get or create the AI service singleton"""
    global _ai_service
    if _ai_service is None:
        _ai_service = AIService()
    return _ai_service