suggestion_bot / agent.py
Sanjay Ram A
Upload 52 files
75788a5 verified
Raw
History Blame Contribute Delete
36.8 kB
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: <first 3 product names, shortened>
Here are {len(products)} great {category} options for you!
<For each product in the list above>
<number>. <name>
💰 Price: <price from data>
⭐ Rating: <rating from data>
🛒 Store: <store from data>
🔗 Link: <link from data - COPY EXACT URL>
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()