import json import os import gradio as gr import pandas as pd import re from openai import OpenAI import requests import sys from typing import List, Dict, Any, Tuple import base64 # Add this function at the top of your file def get_image_base64(image_path): with open(image_path, "rb") as img_file: return base64.b64encode(img_file.read()).decode() # Get the base64 string for your logo logo_base64 = get_image_base64("Logo.png") # Load the JSON data with open('premium_collections.json', 'r') as f: premium_collections = json.load(f) with open('clothing.json', 'r') as f: clothing = json.load(f) # Combine both datasets and tag them with their source for item in premium_collections: item['source'] = 'premium_collections' for item in clothing: item['source'] = 'clothing' all_items = premium_collections + clothing # Function to normalize price strings to float def normalize_price(price_str): if not price_str: return None # Handle ranges like "$8.50 – $28.00" if '–' in price_str or '-' in price_str: parts = re.split(r'–|-', price_str) # Take the lower price for calculation price_str = parts[0].strip() # Extract the numeric value match = re.search(r'(\d+\.\d+|\d+)', price_str) if match: return float(match.group(1)) return None # Process items to have normalized prices for item in all_items: if item.get('price'): item['normalized_price'] = normalize_price(item['price']) # Define dropdown options (simplified) GIFT_OCCASIONS = [ "Choose an option", "Festive Celebration", "Long Service Award", "Corporate Milestones", "Onboarding", "Christmas/Year-End Celebration", "Annual Dinner & Dance", "All The Best!", "Others" ] # Budget options for the new interface BUDGET_RANGES = [ "Below S$10", "S$10 to S$20", "S$20 to S$35", "S$35 to S$55", "S$55 to S$80" ] # Configure API keys OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "") OLLAMA_API_URL = "http://localhost:11434/api/generate" # Default Ollama URL class BudgetAgent: def __init__(self, items, model="deepseek-r1:32b"): self.items = items self.model = model def calculate_bundle(self, min_budget: float, max_budget: float, selected_items: list) -> tuple: """ Calculate if the selected items fit within the budget range. Returns: (fits_budget, total_cost, explanation) """ # Filter out items without valid prices valid_items = [item for item in selected_items if item.get('normalized_price') is not None] if not valid_items: return False, 0, "No items with valid prices were selected." total_cost = sum(item['normalized_price'] for item in valid_items) # Check if total fits within budget range fits_budget = min_budget <= total_cost <= max_budget # Create explanation item_details = [f"{item['name']} (S${item['normalized_price']:.2f})" for item in valid_items] explanation = f"Total cost: S${total_cost:.2f} for items: {', '.join(item_details)}. " if fits_budget: explanation += f"This bundle is within your budget range of S${min_budget:.2f} to S${max_budget:.2f}." else: if total_cost < min_budget: explanation += f"This bundle is below your minimum budget of S${min_budget:.2f} by S${min_budget - total_cost:.2f}." else: explanation += f"This bundle exceeds your maximum budget of S${max_budget:.2f} by S${total_cost - max_budget:.2f}." return fits_budget, total_cost, explanation def filter_items_by_budget(self, min_budget: float, max_budget: float) -> list: """ Filter all items that fall within the budget range per item. Returns: list of items within budget """ valid_items = [] for item in self.items: if item.get('normalized_price') is not None: price = item['normalized_price'] if min_budget <= price <= max_budget: valid_items.append(item) # Sort by price (ascending) valid_items.sort(key=lambda x: x.get('normalized_price', 0)) return valid_items class SelectionAgent: def __init__(self, items): self.items = items self.client = OpenAI(api_key=OPENAI_API_KEY) def select_items(self, criteria: str, min_budget: float, max_budget: float, gift_occasion: str = "", quantity: int = 1) -> List[Dict]: """ Use OpenAI to select items based on the criteria. Returns a list of items that fit the criteria. """ # First filter items by budget budget_agent = BudgetAgent(self.items) budget_filtered_items = budget_agent.filter_items_by_budget(min_budget, max_budget) if not budget_filtered_items: return [] # Prepare the data for OpenAI (limit the number of items to avoid token limits) items_sample = budget_filtered_items[:100] # Take more items since we're showing all # Extract discount information for each item for item in items_sample: has_discount, discount_info, _ = extract_discount_info(item) if has_discount: item['has_bulk_discount'] = True item['discount_info'] = discount_info items_data = json.dumps([{ "name": item['name'], "type": item['type'], "price": item.get('normalized_price'), "description": item.get('short_description', '')[:100], "labels": item.get('labels', []), "has_bulk_discount": item.get('has_bulk_discount', False), "discount_info": item.get('discount_info', ''), "url": item.get('url', ''), "images": item.get('images', '') } for item in items_sample]) # Create the system prompt system_prompt = """ You are a gift selection expert. Your task is to select items that best match the user's criteria. Focus primarily on: 1. The user's specific requirements and description (50%) 2. Gift occasion appropriateness (30%) 3. Value for money and bulk discount opportunities (20%) Return your selections as a JSON array of item names that meet the criteria. Prioritize items with bulk discounts when quantity is high. """ budget_text = f"budget range of S${min_budget:.2f} to S${max_budget:.2f} per item" # Build user prompt user_prompt = f""" I need {quantity} items with a {budget_text} that match these criteria: PRIMARY REQUIREMENT: {criteria} OCCASION: {gift_occasion if gift_occasion and gift_occasion != "Choose an option" else "General purpose"} QUANTITY NEEDED: {quantity} Here are the available items within my budget: {items_data} Please select the best items that match my criteria. If quantity is high (>10), prioritize items with bulk discounts. Return a JSON object with a key called "items" that contains an array of item names, like this: {{"items": ["Item 1", "Item 2", "Item 3"]}} """ try: response = self.client.chat.completions.create( model="gpt-4o", messages=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt} ], response_format={"type": "json_object"}, temperature=0.1 ) # Extract the selected item names result = json.loads(response.choices[0].message.content) selected_item_names = result.get("items", []) if not isinstance(selected_item_names, list): # Try to handle different response formats if isinstance(result, list): selected_item_names = result else: # Look for any list in the response for value in result.values(): if isinstance(value, list): selected_item_names = value break # Find the corresponding items from our pool selected_items = [] for name in selected_item_names: for item in budget_filtered_items: if name.lower() in item['name'].lower() or item['name'].lower() in name.lower(): # Add discount info to the item has_discount, discount_info, formatted_discount = extract_discount_info(item) if has_discount: item['has_bulk_discount'] = True item['discount_info'] = discount_info item['formatted_discount'] = formatted_discount selected_items.append(item) break # If no specific selection was made, return all budget-filtered items if not selected_items: selected_items = budget_filtered_items return selected_items except Exception as e: print(f"Error calling OpenAI API: {str(e)}") # Return all budget-filtered items as fallback return budget_filtered_items class GiftBundleChatbot: def __init__(self, items): self.items = items self.budget_agent = BudgetAgent(items) self.selection_agent = SelectionAgent(items) def create_combination_options(self, items: List[Dict], quantity: int, min_budget: float, max_budget: float) -> List[Dict]: """ Create three different combination options from the available items. Returns: List of combinations with different themes/strategies """ if len(items) < quantity: return [] combinations = [] # Strategy 1: Best Value - Mix of price ranges to maximize value best_value_items = [] sorted_by_value = sorted(items, key=lambda x: x.get('normalized_price', 0)) # Take mix of low, mid, and high priced items low_third = len(sorted_by_value) // 3 mid_third = 2 * len(sorted_by_value) // 3 low_items = sorted_by_value[:low_third] mid_items = sorted_by_value[low_third:mid_third] high_items = sorted_by_value[mid_third:] # Create balanced selection items_needed = quantity while items_needed > 0 and (low_items or mid_items or high_items): if items_needed >= 3 and low_items and mid_items and high_items: best_value_items.extend([low_items.pop(0), mid_items.pop(0), high_items.pop(0)]) items_needed -= 3 elif items_needed >= 2 and low_items and mid_items: best_value_items.extend([low_items.pop(0), mid_items.pop(0)]) items_needed -= 2 elif low_items: best_value_items.append(low_items.pop(0)) items_needed -= 1 elif mid_items: best_value_items.append(mid_items.pop(0)) items_needed -= 1 elif high_items: best_value_items.append(high_items.pop(0)) items_needed -= 1 else: break if len(best_value_items) == quantity: total_cost = sum(item['normalized_price'] for item in best_value_items if item['normalized_price']) combinations.append({ "name": "Best Value Mix", "description": "Balanced selection across different price ranges for maximum value", "items": best_value_items[:quantity], "total_cost": total_cost, "strategy": "value" }) # Strategy 2: Premium Selection - Higher-end items premium_items = sorted(items, key=lambda x: x.get('normalized_price', 0), reverse=True)[:quantity] if len(premium_items) == quantity: total_cost = sum(item['normalized_price'] for item in premium_items if item['normalized_price']) combinations.append({ "name": "Premium Selection", "description": "Higher-end products for a premium gifting experience", "items": premium_items, "total_cost": total_cost, "strategy": "premium" }) # Strategy 3: Budget-Friendly or Bulk Discount Focus if quantity > 5: # For larger quantities, focus on bulk discount items bulk_items = [item for item in items if item.get('has_bulk_discount', False)] if len(bulk_items) >= quantity: bulk_selection = bulk_items[:quantity] total_cost = sum(item['normalized_price'] for item in bulk_selection if item['normalized_price']) combinations.append({ "name": "Bulk Discount Special", "description": "Items with bulk discounts - perfect for larger quantities", "items": bulk_selection, "total_cost": total_cost, "strategy": "bulk" }) else: # Budget-friendly selection budget_items = sorted(items, key=lambda x: x.get('normalized_price', 0))[:quantity] total_cost = sum(item['normalized_price'] for item in budget_items if item['normalized_price']) combinations.append({ "name": "Budget-Friendly", "description": "Most economical selection within your budget range", "items": budget_items, "total_cost": total_cost, "strategy": "budget" }) else: # For smaller quantities, create a curated selection # Random sampling for variety import random varied_items = random.sample(items, min(quantity, len(items))) total_cost = sum(item['normalized_price'] for item in varied_items if item['normalized_price']) combinations.append({ "name": "Curated Selection", "description": "Carefully selected variety for a unique gift combination", "items": varied_items, "total_cost": total_cost, "strategy": "curated" }) return combinations[:3] # Return maximum 3 combinations def process_query(self, query: str, min_budget: float = 0, max_budget: float = 500, quantity: int = 1, gift_occasion: str = "") -> Tuple[str, List[Dict], List[Dict]]: """ Process a user query and return both individual products and combination options. Returns: (response_text, all_items, combinations) """ # Get all items within budget range all_budget_items = self.budget_agent.filter_items_by_budget(min_budget, max_budget) if not all_budget_items: return f"No items found within your budget range of S${min_budget:.2f} to S${max_budget:.2f} per item.", [], [] # If there's a specific query or occasion, use AI to filter/rank if query and query.strip() and query.lower() not in ["find me gift items", ""]: selected_items = self.selection_agent.select_items( criteria=query, min_budget=min_budget, max_budget=max_budget, gift_occasion=gift_occasion, quantity=quantity ) else: # Return all items within budget selected_items = all_budget_items # Create combination options combinations = self.create_combination_options(selected_items, quantity, min_budget, max_budget) # Calculate totals total_items = len(selected_items) total_cost_per_item_range = f"S${min_budget:.2f} - S${max_budget:.2f}" # Format the response response = f"Found {total_items} products within your budget range of {total_cost_per_item_range} per item" if query and query.strip(): response += f" matching: '{query}'" if gift_occasion and gift_occasion != "Choose an option": response += f" for {gift_occasion}" response += f"\nQuantity needed: {quantity}" if combinations: response += f"\n\nšŸŽ THREE CURATED COMBINATIONS:" for i, combo in enumerate(combinations, 1): response += f"\n{i}. {combo['name']}: S${combo['total_cost']:.2f} total" response += f"\n\nView the 'Combination Options' tab to see detailed combinations, or browse all {total_items} individual products below." # Check for items with bulk discounts discount_items = [item for item in selected_items if item.get('has_bulk_discount', False)] if discount_items and quantity > 5: response += f"\n\nšŸ’° BULK DISCOUNT OPPORTUNITY: {len(discount_items)} items offer bulk discounts for larger quantities!" return response, selected_items, combinations def parse_budget_range(budget_range): """Parse a budget range string into min and max values""" if budget_range == "Below S$10": return 0, 10 elif budget_range == "S$10 to S$20": return 10, 20 elif budget_range == "S$20 to S$35": return 20, 35 elif budget_range == "S$35 to S$55": return 35, 55 elif budget_range == "S$55 to S$80": return 55, 80 else: # Default range if no match return 0, 500 def extract_discount_info(item): """ Extract bulk discount information from item description. Returns: (has_discount, discount_info, formatted_info) """ has_discount = False discount_info = None formatted_info = "" # Check if the item has a description description = item.get('short_description', '') or item.get('description', '') if not description: return has_discount, discount_info, formatted_info # Keywords that might indicate a bulk discount discount_keywords = [ 'bulk discount', 'volume discount', 'quantity discount', 'bulk pricing', 'buy more save more', 'discount for quantities', 'bulk purchase', 'special pricing', 'wholesale price', 'bulk orders', 'quantity pricing', 'discount for bulk' ] description_lower = description.lower() # Check for discount keywords for keyword in discount_keywords: if keyword in description_lower: has_discount = True break if has_discount: # Try to extract sentences containing discount information sentences = description.split('.') discount_sentences = [] for sentence in sentences: sentence = sentence.strip() sentence_lower = sentence.lower() for keyword in discount_keywords: if keyword in sentence_lower and sentence: discount_sentences.append(sentence) break if discount_sentences: discount_info = '. '.join(discount_sentences) + '.' formatted_info = f"Bulk Discount: {discount_info}" else: # If we can't extract specific sentences, use entire description discount_info = description formatted_info = f"Bulk Discount Available (see description for details)" return has_discount, discount_info, formatted_info def create_combination_display_html(combinations): """ Create HTML display for the three combination options """ if not combinations: return "

No combination options available.

" html_content = """
""" for i, combo in enumerate(combinations, 1): strategy_icons = { "value": "āš–ļø", "premium": "šŸ‘‘", "bulk": "šŸ’°", "budget": "šŸ’µ", "curated": "šŸŽÆ" } icon = strategy_icons.get(combo['strategy'], "šŸŽ") html_content += f"""
{icon} Option {i}: {combo['name']}
Total: S${combo['total_cost']:.2f}
{combo['description']}
""" for item in combo['items']: # Get image image_html = "" if 'images' in item and item['images']: image_url = None if isinstance(item['images'], str): image_url = item['images'] elif isinstance(item['images'], list) and len(item['images']) > 0: image_url = item['images'][0] elif isinstance(item['images'], dict) and len(item['images']) > 0: image_url = list(item['images'].values())[0] if image_url: if image_url.startswith('/'): image_url = f"https://yourdomain.com{image_url}" image_html = f'{item[' image_html += '' else: image_html = '
No Image
' else: image_html = '
No Image
' # Price and bulk discount indicator price_display = f"S${item['normalized_price']:.2f}" if item.get('normalized_price') is not None else "N/A" bulk_indicator = "" if item.get('has_bulk_discount', False): bulk_indicator = 'BULK' html_content += f"""
{image_html}
{item['name'][:50]}{"..." if len(item['name']) > 50 else ""}
{price_display}{bulk_indicator}
""" html_content += f"""
""" html_content += "
" return html_content def create_product_grid_html(items): """ Create HTML grid displaying all products with images, titles, and URLs """ if not items: return "

No products found matching your criteria.

" html_content = """
""" for item in items: # Get image URL image_html = "" if 'images' in item and item['images']: image_url = None if isinstance(item['images'], str): image_url = item['images'] elif isinstance(item['images'], list) and len(item['images']) > 0: image_url = item['images'][0] elif isinstance(item['images'], dict) and len(item['images']) > 0: image_url = list(item['images'].values())[0] if image_url: # Handle relative URLs if needed if image_url.startswith('/'): image_url = f"https://yourdomain.com{image_url}" image_html = f'{item[' image_html += '' else: image_html = '
No Image Available
' else: image_html = '
No Image Available
' # Get price price_display = f"S${item['normalized_price']:.2f}" if item.get('normalized_price') is not None else "Price on request" # Get URL product_url = item.get('url', '#') if not product_url or product_url == '#': url_html = 'URL not available' else: url_html = f'
View Product
' # Check for bulk discount bulk_discount_badge = "" if item.get('has_bulk_discount', False): bulk_discount_badge = '
šŸ’° BULK DISCOUNT AVAILABLE
' # Create product card html_content += f"""
{bulk_discount_badge} {image_html}
{item['name']}
{price_display}
{url_html}
""" html_content += "
" return html_content """ Create HTML grid displaying all products with images, titles, and URLs """ if not items: return "

No products found matching your criteria.

" html_content = """
""" for item in items: # Get image URL image_html = "" if 'images' in item and item['images']: image_url = None if isinstance(item['images'], str): image_url = item['images'] elif isinstance(item['images'], list) and len(item['images']) > 0: image_url = item['images'][0] elif isinstance(item['images'], dict) and len(item['images']) > 0: image_url = list(item['images'].values())[0] if image_url: # Handle relative URLs if needed if image_url.startswith('/'): image_url = f"https://yourdomain.com{image_url}" image_html = f'{item[' image_html += '' else: image_html = '
No Image Available
' else: image_html = '
No Image Available
' # Get price price_display = f"S${item['normalized_price']:.2f}" if item.get('normalized_price') is not None else "Price on request" # Get URL product_url = item.get('url', '#') if not product_url or product_url == '#': url_html = 'URL not available' else: url_html = f'
View Product
' # Check for bulk discount bulk_discount_badge = "" if item.get('has_bulk_discount', False): bulk_discount_badge = '
šŸ’° BULK DISCOUNT
' # Create product card html_content += f"""
{bulk_discount_badge} {image_html}
{item['name']}
{price_display}
{url_html}
""" html_content += "
" return html_content # Custom CSS to match the Gift Market homepage style css = """ :root { --primary-color: #87CEEB; --secondary-color: #3C3B6E; --background-color: #f0f2f5; --border-color: #ddd; } body { font-family: 'Arial', sans-serif; background-color: var(--background-color); } .main-container { max-width: 1200px; margin: 0 auto; } .header { background-color: white; padding: 10px 0; border-bottom: 1px solid var(--border-color); } h1.title { color: var(--primary-color); font-weight: bold; font-size: 2.5em; margin: 0; padding: 10px 0; } .section-header { background-color: var(--background-color); padding: 8px; margin-top: 10px; border-radius: 5px; font-size: 1.2em; color: #333; font-weight: bold; } .section-number { display: inline-block; width: 24px; height: 24px; background-color: var(--secondary-color); color: white; border-radius: 50%; text-align: center; margin-right: 10px; } .btn-primary { background-color: var(--primary-color); border-color: var(--primary-color); } .btn-primary:hover { background-color: #8f1c2a; border-color: #8f1c2a; } .filter-row { display: flex; flex-direction: row; flex-wrap: nowrap; gap: 10px; margin-bottom: 8px; padding: 6px; background-color: white; border-radius: 5px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); } .filter-row .gr-column { flex: 1; min-width: 250px; } .results-container { background-color: white; border-radius: 5px; padding: 15px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); } .footer { text-align: center; padding: 20px 0; margin-top: 30px; font-size: 0.9em; color: #87CEEB; } .search-box { display: flex; margin: 15px 0; } .search-box input { flex-grow: 1; padding: 8px 15px; border: 1px solid var(--border-color); border-radius: 4px 0 0 4px; } .search-box button { background-color: var(--secondary-color); color: white; border: none; padding: 8px 15px; border-radius: 0 4px 4px 0; cursor: pointer; } @media (max-width: 768px) { .filter-row { flex-wrap: wrap; } .filter-row .gr-column { min-width: 100%; } } """ # Define the Gradio interface with gr.Blocks(css=css, title="Gift Finder") as demo: # Header header_styles = { "container": """ display: flex; align-items: center; justify-content: space-between; padding: 0 20px; width: 100%; flex-wrap: wrap; gap: 20px; """, "logo_section": """ display: flex; align-items: center; gap: 15px; """, "logo": """ height: 50px; width: auto; object-fit: contain; """, "title": """ color: #87CEEB; font-weight: bold; margin: 0; font-size: clamp(1.5rem, 2vw, 2rem); """, "nav": """ display: flex; gap: 20px; align-items: center; """, "nav_item": """ display: flex; align-items: center; cursor: pointer; """ } with gr.Row(elem_classes=["header"]): gr.HTML(f"""
PrintNGift Logo

Your Gift Finder

""") # Search bar gr.HTML("""
1 Describe what you're looking for (optional)
""") with gr.Row(elem_classes=["search-box"]): query = gr.Textbox( placeholder="Example: office supplies, premium drinkware, tech gadgets (leave empty to see all products)", label="Requirements", value="" ) # Budget section gr.HTML("""
2 Budget per item (S$) + Quantity needed*
""") with gr.Row(elem_classes=["filter-row"]): with gr.Column(scale=3): budget_range = gr.Radio( choices=BUDGET_RANGES, label="Budget Per Item", value=BUDGET_RANGES[0] ) with gr.Column(scale=1): quantity = gr.Number( label="Quantity", minimum=1, value=1, info="How many items needed" ) # Gift Occasion section gr.HTML("""
3 Gift Occasion (optional)
""") with gr.Row(elem_classes=["filter-row"]): gift_occasion = gr.Dropdown( choices=GIFT_OCCASIONS, label="Occasion", value="Choose an option" ) # Search button search_btn = gr.Button("Find Products", variant="primary") # Results section with gr.Tabs(): with gr.TabItem("šŸŽ Combination Options"): combinations_html = gr.HTML(label="Three Curated Combinations") with gr.TabItem("šŸ“‹ All Products"): response = gr.Textbox(label="Search Summary", lines=3) products_html = gr.HTML(label="Individual Products") with gr.TabItem("šŸ“Š Product List"): products_table = gr.DataFrame(label="Product Details") def find_products(budget_range_val, quantity_val, gift_occasion_val, query_val): """ Main function to find and display products with combination options """ # Parse budget range min_budget, max_budget = parse_budget_range(budget_range_val) # Get quantity try: qty = int(quantity_val) if quantity_val else 1 except (ValueError, TypeError): qty = 1 # Initialize chatbot chatbot = GiftBundleChatbot(all_items) # Process query (now returns combinations too) response_text, selected_items, combinations = chatbot.process_query( query=query_val, min_budget=min_budget, max_budget=max_budget, quantity=qty, gift_occasion=gift_occasion_val ) # Create combination display combinations_display = create_combination_display_html(combinations) # Create HTML grid for all products products_grid = create_product_grid_html(selected_items) # Create DataFrame for table view if selected_items: table_data = [] for item in selected_items: price_display = f"S${item['normalized_price']:.2f}" if item.get('normalized_price') is not None else "Price on request" url_display = item.get('url', 'Not available') discount_status = "Yes" if item.get('has_bulk_discount', False) else "No" table_data.append({ "Name": item['name'], "Price": price_display, "Type": item.get('type', 'N/A'), "Bulk Discount": discount_status, "URL": url_display, "Description": item.get('short_description', 'No description')[:100] + "..." }) products_df = pd.DataFrame(table_data) else: products_df = pd.DataFrame(columns=["Name", "Price", "Type", "Bulk Discount", "URL", "Description"]) return combinations_display, response_text, products_grid, products_df # Connect the search button search_btn.click( fn=find_products, inputs=[budget_range, quantity, gift_occasion, query], outputs=[combinations_html, response, products_html, products_table] ) # Examples section gr.Examples( examples=[ ["S$10 to S$20", 5, "Corporate Milestones", "office supplies"], ["S$35 to S$55", 10, "Festive Celebration", "premium drinkware"], ["S$20 to S$35", 3, "Long Service Award", "tech gadgets"], ["S$55 to S$80", 1, "All The Best!", "luxury items"], ["Below S$10", 20, "Choose an option", ""] # Show all cheap items ], inputs=[budget_range, quantity, gift_occasion, query] ) # Footer gr.HTML(""" """) # Launch the app if __name__ == "__main__": # Launch Gradio interface demo.launch()