from langchain_google_genai import ChatGoogleGenerativeAI from langchain.agents import AgentExecutor, create_tool_calling_agent from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder from langchain.memory import ConversationBufferMemory from typing import Dict, Any, List, Optional import json import re from config import settings from tools import ALL_TOOLS from database import db, Platform SYSTEM_PROMPT = """You are a helpful E-commerce Product Suggestion Bot for users in India. CRITICAL FIRST STEP - USER PROFILE COLLECTION: 1. ALWAYS start by checking if user profile is complete (has name, age, location) 2. If ANY field is missing, collect it first before proceeding to product suggestions 3. Store each detail immediately after collection PROFILE COLLECTION FLOW: - If location is missing: Ask "Welcome! To provide personalized suggestions, what's your location? (e.g., Mumbai, Maharashtra or pincode)" - If name is missing: Ask "Great! What's your name?" - If age is missing: Ask "Thanks! What's your age?" - Once profile complete: Say "Perfect! Now you can ask for product suggestions. Example: 'I need a laptop under ₹50000'" PRODUCT SUGGESTION WORKFLOW (only after profile is complete): 1. Extract the product type/category and budget from user's message 2. Search for ANY products the user asks for - mobiles, laptops, cycles, books, electronics, appliances, clothing, etc. 3. Show top 3-5 products ranked by value-for-money, rating, and availability 4. Format response: "Top Picks: [name1], [name2], [name3]" as first line CONSTRAINTS: - NEVER proceed to product suggestions if profile is incomplete - Search for ANY product type the user requests - Always enforce price ≤ budget - Keep text short for low bandwidth""" class SuggestionAgent: def __init__(self): self.llm = ChatGoogleGenerativeAI( model="gemini-2.5-flash", api_key=settings.gemini_api_key, temperature=settings.temperature, ) def process_request( self, platform: str, platform_user_id: str, user_message: str, image_data: Optional[bytes] = None, audio_data: Optional[bytes] = None ) -> Dict[str, Any]: """Process user request and return suggestions.""" try: import logging logger = logging.getLogger(__name__) # Step 1: Get or create user user = db.get_user(Platform(platform), platform_user_id) if not user: user_id = db.upsert_user(Platform(platform), platform_user_id) user = db.get_user(Platform(platform), platform_user_id) # Step 2: Check if profile is complete needs_location = not user.get("location_str") needs_name = not user.get("name") needs_age = not user.get("age") # Step 3: Handle profile collection if needs_location: # Check if user is providing location if self._looks_like_location(user_message): db.upsert_user(Platform(platform), platform_user_id, location_str=user_message) return { "success": True, "response_text": "Great! Location saved. What's your name?", "picks": [] } else: return { "success": True, "response_text": "Welcome! To provide personalized suggestions, what's your location? (e.g., Mumbai, Maharashtra or pincode)", "picks": [] } if needs_name: # Check if this is a name (simple heuristic) if not any(char.isdigit() for char in user_message) and len(user_message.split()) <= 3: db.upsert_user(Platform(platform), platform_user_id, name=user_message.strip()) return { "success": True, "response_text": "Nice to meet you! What's your age?", "picks": [] } return { "success": True, "response_text": "Thanks! What's your name?", "picks": [] } if needs_age: # Check if this is an age try: age = int(user_message.strip()) if 5 <= age <= 120: db.upsert_user(Platform(platform), platform_user_id, age=age) return { "success": True, "response_text": "Perfect! Now you can ask for product suggestions.\n\nExamples:\n• 'I need a laptop under ₹50000'\n• 'Show me headphones under ₹3000'\n• 'Cycle under ₹8000'\n• 'Mobile under 15k'", "picks": [] } except: pass return { "success": True, "response_text": "Great! What's your age?", "picks": [] } # Step 4: Profile complete - handle product search enriched_message = user_message if image_data or audio_data: logger.info("Processing multimodal input...") media_context = self._process_multimodal(image_data, audio_data, user_message) logger.info(f"Media context extracted: {media_context}") # For multimodal inputs, let Gemini handle everything and pass the full context if media_context and len(media_context.strip()) > 0: enriched_message = media_context logger.info(f"Using multimodal context as search query: {enriched_message}") # For image data, we keep the original caption and add context if image_data and media_context and len(media_context.strip()) > 0: enriched_message = f"{user_message}\n\n{media_context}" # Generate search query using Gemini (supports all languages) search_query = self._generate_search_query(enriched_message, image_data, audio_data) if not search_query or len(search_query.strip()) == 0: return { "success": True, "response_text": "Please specify what product you're looking for and your budget.\n\nExamples:\n• 'I need a laptop under ₹50000'\n• 'Show me cycles under ₹7000'\n• 'Headphones under ₹2000'\n• 'Mobile under ₹15k'", "picks": [] } # Extract category and budget from the generated query for caching category, budget = self._extract_category_budget(search_query) if not category or not budget: return { "success": True, "response_text": "Please specify what product you're looking for and your budget.\n\nExamples:\n• 'I need a laptop under ₹50000'\n• 'Show me cycles under ₹7000'\n• 'Headphones under ₹2000'\n• 'Mobile under ₹15k'", "picks": [] } # Check cache location_key = db.make_location_key(user.get("location_str")) cached = db.cache_get(category, budget, location_key) if cached: logger.info("Using cached results") return { "success": True, "response_text": cached["response_text"], "picks": cached.get("picks_json", []) } # Search for products using the generated search query picks = self._search_products(search_query, budget, user.get("location_str", "India")) if not picks: return { "success": True, "response_text": f"I couldn't find {category} products under ₹{budget} right now. Try:\n• Increasing your budget (e.g., ₹{int(budget * 1.5)})\n• Using different keywords (e.g., 'smartphone' instead of 'mobile')\n• Trying a different product category", "picks": [] } # Pass products to LLM for natural formatting num_to_show = min(5, len(picks)) top_picks = picks[:num_to_show] # Prepare product data for LLM products_data = [] for i, pick in enumerate(top_picks, 1): product_info = { "number": i, "name": pick['name'], "price": f"₹{pick['price']:,}", "rating": f"{pick.get('rating', 0):.1f}/5" if pick.get('rating') and pick['rating'] > 0 else "N/A", "store": pick.get('source', 'Online'), "link": pick.get('link', '') } products_data.append(product_info) # Ask LLM to format the response response = self._format_products_with_llm(category, budget, products_data, user.get("name", "there")) # Cache the results picks_json = [{"name": p["name"], "rank": i+1, "price": p["price"], "link": p.get("link", "")} for i, p in enumerate(top_picks)] db.cache_put( user_id=user["id"], category=category, budget_inr=budget, location_key=location_key, response_text=response, picks_json=picks_json, ttl_hours=24 ) return { "success": True, "response_text": response, "picks": picks_json } except Exception as e: import logging logger = logging.getLogger(__name__) logger.error(f"Error in process_request: {str(e)}", exc_info=True) return { "success": False, "error": str(e), "response_text": "Sorry, I encountered an error. Please try again later.", "picks": [] } def _format_products_with_llm(self, category: str, budget: int, products: list, user_name: str) -> str: """Use LLM to format product suggestions naturally.""" try: import logging logger = logging.getLogger(__name__) # Create prompt for LLM products_json = json.dumps(products, indent=2) prompt = f"""You are a helpful shopping assistant. Format these products for the user. User: {user_name} Looking for: {category} under ₹{budget} Products data: {products_json} Format EXACTLY like this structure. DO NOT skip any fields, especially links: Top Picks: Here are {len(products)} great {category} options for you! . 💰 Price: ⭐ Rating: 🛒 Store: 🔗 Link: CRITICAL: You MUST include the "🔗 Link:" line with the actual URL from the data for EVERY product. Do not skip links! """ # Call LLM response = self.llm.invoke(prompt) if hasattr(response, 'content'): return response.content else: return str(response) except Exception as e: import logging logger = logging.getLogger(__name__) logger.error(f"LLM formatting error: {e}") # Fallback to simple formatting response = f"Top Picks: {', '.join([p['name'][:50] for p in products[:3]])}\n\n" response += f"Here are {len(products)} great {category} options under ₹{budget}:\n\n" for product in products: response += f"{product['number']}. {product['name']}\n" response += f" 💰 Price: {product['price']}\n" if product['rating'] != "N/A": response += f" ⭐ Rating: {product['rating']}\n" response += f" 🛒 Store: {product['store']}\n" if product['link']: response += f" 🔗 Link: {product['link']}\n" response += "\n" return response def _looks_like_location(self, message: str) -> bool: """Check if message looks like a location.""" message = message.strip() # Check if it's a 6-digit pincode if message.isdigit() and len(message) == 6: return True # Check if it's a city/location name (no numbers, reasonable length) if not any(char.isdigit() for char in message) and 3 <= len(message) <= 50: # Exclude common greetings greetings = ['hi', 'hello', 'hey', 'thanks', 'ok', 'yes', 'no', 'hii', 'hiii'] if message.lower() not in greetings: return True return False def _search_products(self, search_query: str, budget: int, location: str) -> List[Dict]: """Search for products using Google Shopping Light API - supports ANY product type.""" try: import serpapi import logging logger = logging.getLogger(__name__) # Use the search query directly (already processed by Gemini) query = search_query logger.info(f"Searching for: {query}") client = serpapi.Client(api_key=settings.serpapi_api_key) results = client.search({ "engine": "google_shopping_light", "q": query, "gl": "in", "hl": "en", "max_price": budget }) candidates = [] shopping_results = results.get("shopping_results", []) logger.info(f"Found {len(shopping_results)} shopping results from API") for item in shopping_results: try: # Try multiple price fields price_str = item.get("extracted_price", item.get("price", item.get("displayed_price", "0"))) if isinstance(price_str, str): # Remove currency symbols and commas price_clean = price_str.replace(",", "").replace("₹", "").replace("Rs", "").replace("INR", "").strip() # Extract just the number import re price_match = re.search(r'(\d+(?:\.\d+)?)', price_clean) if price_match: price = float(price_match.group(1)) else: continue else: price = float(price_str) # Be more lenient with budget (allow up to 10% over) if price > 0 and price <= budget * 1.1: # Extract rating safely rating = item.get("rating", 0) if rating and isinstance(rating, (int, float)): rating = float(rating) else: rating = 0.0 product_link = item.get("product_link", item.get("link", "")) # URL encode the link to handle spaces and special characters if product_link: from urllib.parse import quote # Only encode the query part and parameters, not the base URL if 'http' in product_link: try: # Parse the URL and encode the query string from urllib.parse import urlparse, parse_qs, urlencode, urlunparse parsed = urlparse(product_link) if parsed.query: # Re-encode the query string to handle spaces encoded_query = urlencode(parse_qs(parsed.query), doseq=True) product_link = urlunparse(parsed._replace(query=encoded_query)) except: # Fallback: URL encode the entire string if parsing fails product_link = quote(product_link, safe=':/?#[]@!$&\'()*+,;=') candidates.append({ "name": item.get("title", "Unknown Product"), "price": int(price), "rating": rating, "source": item.get("source", "Online"), "link": product_link }) except Exception as e: logger.debug(f"Skipping item due to error: {e}") continue logger.info(f"Found {len(candidates)} products within budget") # If we found very few results, try a broader search if len(candidates) < 3: logger.info("Few results found, trying broader search...") # Extract category from search query for broader search fallback fallback_category, _ = self._extract_category_budget(search_query) broad_query = f"{fallback_category or search_query} India" results = client.search({ "engine": "google_shopping_light", "q": broad_query, "gl": "in", "hl": "en", "max_price": budget }) for item in results.get("shopping_results", []): try: price_str = item.get("extracted_price", item.get("price", "0")) if isinstance(price_str, str): price_clean = price_str.replace(",", "").replace("₹", "").replace("Rs", "").replace("INR", "").strip() price_match = re.search(r'(\d+(?:\.\d+)?)', price_clean) if price_match: price = float(price_match.group(1)) else: continue else: price = float(price_str) if price > 0 and price <= budget * 1.1: rating = item.get("rating", 0) if rating and isinstance(rating, (int, float)): rating = float(rating) else: rating = 0.0 # Avoid duplicates title = item.get("title", "Unknown Product") if not any(c["name"] == title for c in candidates): product_link = item.get("product_link", item.get("link", "")) # URL encode the link to handle spaces and special characters if product_link: from urllib.parse import quote # Only encode the query part and parameters, not the base URL if 'http' in product_link: try: # Parse the URL and encode the query string from urllib.parse import urlparse, parse_qs, urlencode, urlunparse parsed = urlparse(product_link) if parsed.query: # Re-encode the query string to handle spaces encoded_query = urlencode(parse_qs(parsed.query), doseq=True) product_link = urlunparse(parsed._replace(query=encoded_query)) except: # Fallback: URL encode the entire string if parsing fails product_link = quote(product_link, safe=':/?#[]@!$&\'()*+,;=') candidates.append({ "name": title, "price": int(price), "rating": rating, "source": item.get("source", "Online"), "link": product_link }) except Exception as e: continue # Sort by: 1) Rating (highest first), 2) Price (lowest first) candidates.sort(key=lambda x: (-(x.get("rating") or 0), x["price"])) logger.info(f"Returning {len(candidates)} products") return candidates except Exception as e: import logging logging.getLogger(__name__).error(f"Search error: {e}", exc_info=True) return [] def _fallback_response(self, user_message: str, platform: str, platform_user_id: str) -> Dict[str, Any]: """Provide a fallback response when agent fails.""" import logging logger = logging.getLogger(__name__) logger.info("Using fallback response mechanism") # Check if user needs to set up profile user = db.get_user(Platform(platform), platform_user_id) if not user or not user.get("location_str"): return { "success": True, "response_text": "Welcome! To provide personalized suggestions, please share your location (e.g., 'Mumbai, Maharashtra' or a pincode).", "picks": [] } if not user.get("name"): return { "success": True, "response_text": "Great! Now, what's your name?", "picks": [] } if not user.get("age"): return { "success": True, "response_text": "Thanks! What's your age?", "picks": [] } # Try to extract category and budget category, budget = self._extract_category_budget(user_message) if not category: return { "success": True, "response_text": "I can help you find products! Please specify a category:\n- Mobile/Phone\n- Cycle/Bicycle\n- Books\n\nExample: 'I need a mobile under ₹5000'", "picks": [] } if not budget: return { "success": True, "response_text": f"What's your budget for {category}? (e.g., '₹5000' or '5000 rupees')", "picks": [] } # If we have all info, provide a generic response return { "success": True, "response_text": f"I'm currently processing your request for {category} under ₹{budget}. This might take a moment. If you don't see results, please try:\n\n1. Check your internet connection\n2. Try a different budget range\n3. Specify the category clearly\n\nExample: 'Show me mobiles under ₹6000'", "picks": [] } def _process_multimodal( self, image_data: Optional[bytes], audio_data: Optional[bytes], text_context: str ) -> str: """Use Gemini's multimodal capabilities to extract info from images/audio.""" try: from google import genai from google.genai import types client = genai.Client(api_key=settings.gemini_api_key) prompt = ( "You are a helpful assistant that extracts product search queries from any input type (text, voice, image).\n\n" "TASK: Convert the user's input into a clear product search query that can be used with Google Shopping.\n\n" "INSTRUCTIONS:\n" "- If this is VOICE: Transcribe the speech and extract the product request\n" "- If this is IMAGE: Analyze the image and extract product information\n" "- If this is TEXT: Use the text as-is\n" "- Return a SIMPLE, NATURAL search query like: 'mobile under 5000 rupees' or 'laptop under 40k'\n" "- Support ANY product type: mobiles, laptops, cycles, books, soap, clothes, etc.\n" "- Support ANY language: English, Hindi, or any other language\n" "- Include budget if mentioned, otherwise estimate based on context\n" "- Keep it concise but complete\n\n" f"User context: {text_context}\n\n" "IMPORTANT: Return ONLY the search query, nothing else. No explanations, no formatting." ) contents = [] # Add the text prompt contents.append(prompt) if image_data: contents.append( types.Part.from_bytes( data=image_data, mime_type='image/jpeg' ) ) if audio_data: # Telegram sends voice as OGG, but Gemini accepts various formats contents.append( types.Part.from_bytes( data=audio_data, mime_type='audio/ogg' # Telegram voice format ) ) response = client.models.generate_content( model='gemini-2.5-flash', contents=contents ) return response.text or "" except Exception as e: import logging logger = logging.getLogger(__name__) logger.error(f"Multimodal processing failed: {e}") return f"Could not process media: {str(e)}" def _generate_search_query( self, enriched_message: str, image_data: Optional[bytes] = None, audio_data: Optional[bytes] = None ) -> str: """Use Gemini to generate a search query that supports all languages.""" try: from google import genai from google.genai import types client = genai.Client(api_key=settings.gemini_api_key) prompt = ( "You are a helpful assistant that converts user messages into English Google Shopping search queries.\n\n" "TASK: Convert any language user input into a clean, searchable English query for Google Shopping.\n\n" "INSTRUCTIONS:\n" "- Convert from ANY language (Hindi, Tamil, Telugu, etc.) to English\n" "- Create a natural search query like: 'mobile phone under 5000 rupees'\n" "- Include budget if mentioned (convert to rupees)\n" "- Support ANY product type: mobiles, laptops, cycles, books, clothing, appliances, etc.\n" "- Keep it concise but complete\n" "- Use common English search terms that work well with Google Shopping\n" "- Convert currency mentions to rupees (₹)\n\n" f"User message: {enriched_message}\n\n" "Return ONLY the search query in English, nothing else. No explanations." ) contents = [] # Add the text prompt contents.append(prompt) # Add image if provided if image_data: contents.append( types.Part.from_bytes( data=image_data, mime_type='image/jpeg' ) ) # Add audio if provided if audio_data: contents.append( types.Part.from_bytes( data=audio_data, mime_type='audio/ogg' ) ) response = client.models.generate_content( model='gemini-2.5-flash', contents=contents ) search_query = response.text.strip() if response.text else "" # Fallback to original message if Gemini fails to generate if not search_query or len(search_query) < 5: return enriched_message return search_query except Exception as e: import logging logger = logging.getLogger(__name__) logger.error(f"Gemini search query generation failed: {e}") # Fallback to original message return enriched_message """Use Gemini to generate a search query that supports all languages.""" try: from google import genai from google.genai import types client = genai.Client(api_key=settings.gemini_api_key) prompt = ( "You are a helpful assistant that converts user messages into English Google Shopping search queries.\n\n" "TASK: Convert any language user input into a clean, searchable English query for Google Shopping.\n\n" "INSTRUCTIONS:\n" "- Convert from ANY language (Hindi, Tamil, Telugu, etc.) to English\n" "- Create a natural search query like: 'mobile phone under 5000 rupees'\n" "- Include budget if mentioned (convert to rupees)\n" "- Support ANY product type: mobiles, laptops, cycles, books, clothing, appliances, etc.\n" "- Keep it concise but complete\n" "- Use common English search terms that work well with Google Shopping\n" "- Convert currency mentions to rupees (₹)\n\n" f"User message: {enriched_message}\n\n" "Return ONLY the search query in English, nothing else. No explanations." ) contents = [prompt] # Add image if provided if image_data: contents.append( types.Part.from_bytes( data=image_data, mime_type='image/jpeg' ) ) # Add audio if provided if audio_data: contents.append( types.Part.from_bytes( data=audio_data, mime_type='audio/ogg' ) ) response = client.models.generate_content( model='gemini-2.5-flash', contents=contents ) search_query = response.text.strip() if response.text else "" # Fallback to original message if Gemini fails to generate if not search_query or len(search_query) < 5: return enriched_message return search_query except Exception as e: import logging logger = logging.getLogger(__name__) logger.error(f"Gemini search query generation failed: {e}") # Fallback to original message return enriched_message def _extract_picks(self, response_text: str) -> List[Dict[str, str]]: """Extract the top 3 picks from response.""" pattern = r"Top Picks:\s*(.+?)(?:\n|$)" match = re.search(pattern, response_text, re.IGNORECASE) if match: picks_str = match.group(1) picks_list = [p.strip() for p in picks_str.split(',')] return [{"name": pick, "rank": i+1} for i, pick in enumerate(picks_list[:3])] return [] def _extract_category_budget(self, message: str) -> tuple: """Extract category and budget from message - supports ANY product type.""" message_lower = message.lower() # Try to extract ANY product type mentioned category = None # Common product keywords product_keywords = [ 'mobile', 'phone', 'smartphone', 'iphone', 'android', 'laptop', 'computer', 'pc', 'macbook', 'notebook', 'cycle', 'bicycle', 'bike', 'sycle', 'book', 'books', 'novel', 'textbook', 'tv', 'television', 'smart tv', 'watch', 'smartwatch', 'wristwatch', 'headphone', 'earphone', 'earbuds', 'airpods', 'speaker', 'bluetooth speaker', 'camera', 'dslr', 'webcam', 'tablet', 'ipad', 'shoe', 'shoes', 'footwear', 'sneaker', 'shirt', 'tshirt', 't-shirt', 'clothing', 'dress', 'bag', 'backpack', 'handbag', 'refrigerator', 'fridge', 'washing machine', 'ac', 'air conditioner', 'fan', 'cooler', 'table', 'chair', 'furniture', 'toy', 'toys', 'game' ] # Find first product keyword in message for keyword in product_keywords: if keyword in message_lower: category = keyword break # If no specific keyword found, try to extract noun after "need", "want", "show", "find" if not category: action_patterns = [ r'(?:need|want|looking for|show|find|search)\s+(?:a|an|some)?\s*([a-zA-Z]+)', r'([a-zA-Z]+)\s+under', ] for pattern in action_patterns: match = re.search(pattern, message_lower) if match: potential_category = match.group(1).strip() # Filter out common words if potential_category not in ['the', 'a', 'an', 'me', 'you', 'it', 'is', 'are', 'under', 'below']: category = potential_category break # Extract budget with support for "k" notation (e.g., "50k") budget = None budget_patterns = [ r'₹\s*(\d+)k', # ₹50k r'(\d+)k\s*(?:rupees|inr|rs)?', # 50k rupees r'(?:rs|₹)\s*(\d+)', # Rs 5000 or ₹5000 r'(?:under|below|max|maximum|budget|upto|up to)\s*[:\s]*(?:rs|₹)?\s*(\d+)k?', # under Rs 5000 r'(\d+)\s*(?:rupees|inr)', # 5000 rupees ] for pattern in budget_patterns: match = re.search(pattern, message_lower) if match: budget_str = match.group(1) # Check if pattern includes 'k' suffix if 'k' in pattern and 'k' in match.group(0): budget = int(budget_str) * 1000 else: budget = int(budget_str) break return category, budget agent = SuggestionAgent()