Spaces:
Running
Running
| 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"<strong>Bulk Discount:</strong> {discount_info}" | |
| else: | |
| # If we can't extract specific sentences, use entire description | |
| discount_info = description | |
| formatted_info = f"<strong>Bulk Discount Available</strong> (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 "<p>No combination options available.</p>" | |
| html_content = """ | |
| <style> | |
| .combinations-container { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 30px; | |
| padding: 20px 0; | |
| } | |
| .combination-card { | |
| border: 2px solid #87CEEB; | |
| border-radius: 12px; | |
| padding: 20px; | |
| background: linear-gradient(135deg, #f8fffe 0%, #f0f9ff 100%); | |
| box-shadow: 0 4px 6px rgba(135, 206, 235, 0.1); | |
| } | |
| .combination-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 15px; | |
| padding-bottom: 10px; | |
| border-bottom: 1px solid #87CEEB; | |
| } | |
| .combination-title { | |
| font-size: 20px; | |
| font-weight: bold; | |
| color: #2c5282; | |
| } | |
| .combination-cost { | |
| font-size: 18px; | |
| font-weight: bold; | |
| color: #87CEEB; | |
| background: white; | |
| padding: 8px 15px; | |
| border-radius: 20px; | |
| border: 1px solid #87CEEB; | |
| } | |
| .combination-description { | |
| color: #4a5568; | |
| margin-bottom: 20px; | |
| font-style: italic; | |
| } | |
| .combination-items { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); | |
| gap: 15px; | |
| } | |
| .combo-item { | |
| background: white; | |
| border: 1px solid #e2e8f0; | |
| border-radius: 8px; | |
| padding: 12px; | |
| text-align: center; | |
| box-shadow: 0 2px 4px rgba(0,0,0,0.05); | |
| } | |
| .combo-item-image { | |
| width: 100%; | |
| height: 120px; | |
| object-fit: contain; | |
| margin-bottom: 8px; | |
| border-radius: 4px; | |
| } | |
| .combo-item-name { | |
| font-weight: bold; | |
| font-size: 12px; | |
| color: #2d3748; | |
| margin-bottom: 5px; | |
| line-height: 1.2; | |
| } | |
| .combo-item-price { | |
| color: #87CEEB; | |
| font-weight: bold; | |
| font-size: 14px; | |
| } | |
| .combo-no-image { | |
| width: 100%; | |
| height: 120px; | |
| background-color: #f7fafc; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| color: #a0aec0; | |
| border-radius: 4px; | |
| margin-bottom: 8px; | |
| font-size: 11px; | |
| } | |
| .bulk-discount-indicator { | |
| background-color: #ff9800; | |
| color: white; | |
| padding: 2px 6px; | |
| border-radius: 10px; | |
| font-size: 10px; | |
| margin-left: 5px; | |
| } | |
| .select-combination-btn { | |
| background-color: #87CEEB; | |
| color: white; | |
| border: none; | |
| padding: 10px 20px; | |
| border-radius: 6px; | |
| cursor: pointer; | |
| font-weight: bold; | |
| margin-top: 15px; | |
| transition: background-color 0.2s; | |
| } | |
| .select-combination-btn:hover { | |
| background-color: #5F9EA0; | |
| } | |
| </style> | |
| <div class="combinations-container"> | |
| """ | |
| for i, combo in enumerate(combinations, 1): | |
| strategy_icons = { | |
| "value": "βοΈ", | |
| "premium": "π", | |
| "bulk": "π°", | |
| "budget": "π΅", | |
| "curated": "π―" | |
| } | |
| icon = strategy_icons.get(combo['strategy'], "π") | |
| html_content += f""" | |
| <div class="combination-card"> | |
| <div class="combination-header"> | |
| <div class="combination-title">{icon} Option {i}: {combo['name']}</div> | |
| <div class="combination-cost">Total: S${combo['total_cost']:.2f}</div> | |
| </div> | |
| <div class="combination-description">{combo['description']}</div> | |
| <div class="combination-items"> | |
| """ | |
| 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'<img src="{image_url}" alt="{item["name"]}" class="combo-item-image" onerror="this.style.display=\'none\'; this.nextElementSibling.style.display=\'flex\';">' | |
| image_html += '<div class="combo-no-image" style="display:none;">No Image</div>' | |
| else: | |
| image_html = '<div class="combo-no-image">No Image</div>' | |
| else: | |
| image_html = '<div class="combo-no-image">No Image</div>' | |
| # 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 = '<span class="bulk-discount-indicator">BULK</span>' | |
| html_content += f""" | |
| <div class="combo-item"> | |
| {image_html} | |
| <div class="combo-item-name">{item['name'][:50]}{"..." if len(item['name']) > 50 else ""}</div> | |
| <div class="combo-item-price">{price_display}{bulk_indicator}</div> | |
| </div> | |
| """ | |
| html_content += f""" | |
| </div> | |
| </div> | |
| """ | |
| html_content += "</div>" | |
| return html_content | |
| def create_product_grid_html(items): | |
| """ | |
| Create HTML grid displaying all products with images, titles, and URLs | |
| """ | |
| if not items: | |
| return "<p>No products found matching your criteria.</p>" | |
| html_content = """ | |
| <style> | |
| .product-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); | |
| gap: 20px; | |
| padding: 20px 0; | |
| } | |
| .product-card { | |
| border: 1px solid #ddd; | |
| border-radius: 8px; | |
| padding: 15px; | |
| text-align: center; | |
| background: white; | |
| box-shadow: 0 2px 4px rgba(0,0,0,0.1); | |
| transition: transform 0.2s, box-shadow 0.2s; | |
| } | |
| .product-card:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 4px 8px rgba(0,0,0,0.15); | |
| } | |
| .product-image { | |
| width: 100%; | |
| height: 200px; | |
| object-fit: contain; | |
| margin-bottom: 10px; | |
| border-radius: 4px; | |
| } | |
| .product-title { | |
| font-weight: bold; | |
| margin: 10px 0; | |
| color: #333; | |
| font-size: 14px; | |
| line-height: 1.3; | |
| } | |
| .product-price { | |
| color: #87CEEB; | |
| font-weight: bold; | |
| font-size: 16px; | |
| margin: 8px 0; | |
| } | |
| .product-url { | |
| margin-top: 10px; | |
| } | |
| .product-url a { | |
| background-color: #87CEEB; | |
| color: white; | |
| padding: 8px 15px; | |
| text-decoration: none; | |
| border-radius: 4px; | |
| font-size: 12px; | |
| display: inline-block; | |
| transition: background-color 0.2s; | |
| } | |
| .product-url a:hover { | |
| background-color: #5F9EA0; | |
| } | |
| .bulk-discount-badge { | |
| background-color: #FF9800; | |
| color: white; | |
| padding: 4px 8px; | |
| border-radius: 4px; | |
| font-size: 10px; | |
| margin-bottom: 5px; | |
| display: inline-block; | |
| } | |
| .no-image { | |
| width: 100%; | |
| height: 200px; | |
| background-color: #f5f5f5; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| color: #999; | |
| border-radius: 4px; | |
| margin-bottom: 10px; | |
| } | |
| </style> | |
| <div class="product-grid"> | |
| """ | |
| 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'<img src="{image_url}" alt="{item["name"]}" class="product-image" onerror="this.style.display=\'none\'; this.nextElementSibling.style.display=\'flex\';">' | |
| image_html += '<div class="no-image" style="display:none;">No Image Available</div>' | |
| else: | |
| image_html = '<div class="no-image">No Image Available</div>' | |
| else: | |
| image_html = '<div class="no-image">No Image Available</div>' | |
| # 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 = '<span style="color: #999; font-size: 12px;">URL not available</span>' | |
| else: | |
| url_html = f'<div class="product-url"><a href="{product_url}" target="_blank">View Product</a></div>' | |
| # Check for bulk discount | |
| bulk_discount_badge = "" | |
| if item.get('has_bulk_discount', False): | |
| bulk_discount_badge = '<div class="bulk-discount-badge">π° BULK DISCOUNT AVAILABLE</div>' | |
| # Create product card | |
| html_content += f""" | |
| <div class="product-card"> | |
| {bulk_discount_badge} | |
| {image_html} | |
| <div class="product-title">{item['name']}</div> | |
| <div class="product-price">{price_display}</div> | |
| {url_html} | |
| </div> | |
| """ | |
| html_content += "</div>" | |
| return html_content | |
| """ | |
| Create HTML grid displaying all products with images, titles, and URLs | |
| """ | |
| if not items: | |
| return "<p>No products found matching your criteria.</p>" | |
| html_content = """ | |
| <style> | |
| .product-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); | |
| gap: 20px; | |
| padding: 20px 0; | |
| } | |
| .product-card { | |
| border: 1px solid #ddd; | |
| border-radius: 8px; | |
| padding: 15px; | |
| text-align: center; | |
| background: white; | |
| box-shadow: 0 2px 4px rgba(0,0,0,0.1); | |
| transition: transform 0.2s, box-shadow 0.2s; | |
| } | |
| .product-card:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 4px 8px rgba(0,0,0,0.15); | |
| } | |
| .product-image { | |
| width: 100%; | |
| height: 200px; | |
| object-fit: contain; | |
| margin-bottom: 10px; | |
| border-radius: 4px; | |
| } | |
| .product-title { | |
| font-weight: bold; | |
| margin: 10px 0; | |
| color: #333; | |
| font-size: 14px; | |
| line-height: 1.3; | |
| } | |
| .product-price { | |
| color: #87CEEB; | |
| font-weight: bold; | |
| font-size: 16px; | |
| margin: 8px 0; | |
| } | |
| .product-url { | |
| margin-top: 10px; | |
| } | |
| .product-url a { | |
| background-color: #87CEEB; | |
| color: white; | |
| padding: 8px 15px; | |
| text-decoration: none; | |
| border-radius: 4px; | |
| font-size: 12px; | |
| display: inline-block; | |
| transition: background-color 0.2s; | |
| } | |
| .product-url a:hover { | |
| background-color: #5F9EA0; | |
| } | |
| .bulk-discount-badge { | |
| background-color: #FF9800; | |
| color: white; | |
| padding: 4px 8px; | |
| border-radius: 4px; | |
| font-size: 10px; | |
| margin-bottom: 5px; | |
| display: inline-block; | |
| } | |
| .no-image { | |
| width: 100%; | |
| height: 200px; | |
| background-color: #f5f5f5; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| color: #999; | |
| border-radius: 4px; | |
| margin-bottom: 10px; | |
| } | |
| </style> | |
| <div class="product-grid"> | |
| """ | |
| 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'<img src="{image_url}" alt="{item["name"]}" class="product-image" onerror="this.style.display=\'none\'; this.nextElementSibling.style.display=\'flex\';">' | |
| image_html += '<div class="no-image" style="display:none;">No Image Available</div>' | |
| else: | |
| image_html = '<div class="no-image">No Image Available</div>' | |
| else: | |
| image_html = '<div class="no-image">No Image Available</div>' | |
| # 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 = '<span style="color: #999; font-size: 12px;">URL not available</span>' | |
| else: | |
| url_html = f'<div class="product-url"><a href="{product_url}" target="_blank">View Product</a></div>' | |
| # Check for bulk discount | |
| bulk_discount_badge = "" | |
| if item.get('has_bulk_discount', False): | |
| bulk_discount_badge = '<div class="bulk-discount-badge">π° BULK DISCOUNT</div>' | |
| # Create product card | |
| html_content += f""" | |
| <div class="product-card"> | |
| {bulk_discount_badge} | |
| {image_html} | |
| <div class="product-title">{item['name']}</div> | |
| <div class="product-price">{price_display}</div> | |
| {url_html} | |
| </div> | |
| """ | |
| html_content += "</div>" | |
| 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""" | |
| <div style="{header_styles['container']}"> | |
| <div style="{header_styles['logo_section']}"> | |
| <img src="data:image/png;base64,{logo_base64}" | |
| alt="PrintNGift Logo" | |
| style="{header_styles['logo']}"> | |
| <div> | |
| <h1 style="{header_styles['title']}"> | |
| Your Gift Finder | |
| </h1> | |
| </div> | |
| </div> | |
| <nav style="{header_styles['nav']}"> | |
| <div style="{header_styles['nav_item']}"> | |
| <span style="font-weight: bold; margin-right: 10px;">Shop</span> | |
| <span style="font-size: 0.8rem;">βΌ</span> | |
| </div> | |
| <div style="{header_styles['nav_item']}"> | |
| <span style="font-weight: bold;">My Enquiry (0)</span> | |
| </div> | |
| </nav> | |
| </div> | |
| """) | |
| # Search bar | |
| gr.HTML(""" | |
| <div class="section-header"> | |
| <span class="section-number">1</span> Describe what you're looking for (optional) | |
| </div> | |
| """) | |
| 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(""" | |
| <div class="section-header"> | |
| <span class="section-number">2</span> Budget per item (S$) + Quantity needed* | |
| </div> | |
| """) | |
| 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(""" | |
| <div class="section-header"> | |
| <span class="section-number">3</span> Gift Occasion (optional) | |
| </div> | |
| """) | |
| 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(""" | |
| <div class="footer"> | |
| <p>Β© 2025 Gift Market. All rights reserved.</p> | |
| </div> | |
| """) | |
| # Launch the app | |
| if __name__ == "__main__": | |
| # Launch Gradio interface | |
| demo.launch() |