"""Gradio UI for Tuxedo Link cat adoption application.""" import os import gradio as gr import pandas as pd from dotenv import load_dotenv from typing import List, Optional, Tuple import logging import re from datetime import datetime # Import models - these are lightweight from models.cats import CatProfile, CatMatch, AdoptionAlert from utils.config import is_production # Load environment load_dotenv() # Initialize framework based on mode framework = None profile_agent = None if not is_production(): # LOCAL MODE: Import and initialize heavy components from cat_adoption_framework import TuxedoLinkFramework from agents.profile_agent import ProfileAgent framework = TuxedoLinkFramework() profile_agent = ProfileAgent() print("โœ“ Running in LOCAL mode - using local components") else: # PRODUCTION MODE: Don't import heavy components - use Modal API print("โœ“ Running in PRODUCTION mode - using Modal API") # Global state for current search results current_matches: List[CatMatch] = [] current_profile: Optional[CatProfile] = None # Configure logging to suppress verbose output logging.getLogger().setLevel(logging.WARNING) def extract_profile_from_text(user_input: str, use_cache: bool = False) -> tuple: """ Extract structured profile from user's natural language input. Args: user_input: User's description of desired cat use_cache: Whether to use cached data for search Returns: Tuple of (chat_history, results_html, profile_json) """ global current_matches, current_profile try: # Handle empty input - use placeholder text if not user_input or user_input.strip() == "": user_input = "I'm looking for a friendly, playful kitten in NYC that's good with children" # Extract profile using LLM # Using messages format for Gradio chatbot chat_history = [ {"role": "user", "content": user_input}, {"role": "assistant", "content": "๐Ÿ” Analyzing your preferences..."} ] # Extract profile (Modal or local) if is_production(): # PRODUCTION: Call Modal API import modal # Look up deployed function - correct API! extract_profile_func = modal.Function.from_name("tuxedo-link-api", "extract_profile") print("[INFO] Calling Modal API to extract profile...") profile_result = extract_profile_func.remote(user_input) if not profile_result["success"]: return chat_history, "

โŒ Error extracting profile

", "{}" profile = CatProfile(**profile_result["profile"]) else: # LOCAL: Use local agent conversation = [{"role": "user", "content": user_input}] profile = profile_agent.extract_profile(conversation) current_profile = profile # Perform search response_msg = f"โœ… Got it! Searching for:\n\n" + \ f"๐Ÿ“ Location: {profile.user_location or 'Not specified'}\n" + \ f"๐Ÿ“ Distance: {profile.max_distance or 100} miles\n" + \ f"๐ŸŽจ Colors: {', '.join(profile.color_preferences) if profile.color_preferences else 'Any'}\n" + \ f"๐ŸŽญ Personality: {profile.personality_description or 'Any'}\n" + \ f"๐ŸŽ‚ Age: {', '.join(profile.age_range) if profile.age_range else 'Any'}\n" + \ f"๐Ÿ‘ถ Good with children: {'Yes' if profile.good_with_children else 'Not required'}\n" + \ f"๐Ÿ• Good with dogs: {'Yes' if profile.good_with_dogs else 'Not required'}\n" + \ f"๐Ÿฑ Good with cats: {'Yes' if profile.good_with_cats else 'Not required'}\n\n" + \ f"Searching..." chat_history[1]["content"] = response_msg # Run search (Modal or local) if is_production(): # PRODUCTION: Call Modal API import modal # Look up deployed function search_cats_func = modal.Function.from_name("tuxedo-link-api", "search_cats") print("[INFO] Calling Modal API to search cats...") search_result = search_cats_func.remote(profile.model_dump(), use_cache=use_cache) if not search_result["success"]: error_msg = search_result.get('error', 'Unknown error') chat_history.append({"role": "assistant", "content": f"โŒ Search error: {error_msg}"}) return chat_history, "

๐Ÿ˜ฟ Search failed. Please try again.

", profile.json() # Reconstruct matches from Modal response from models.cats import Cat current_matches = [ CatMatch( cat=Cat(**m["cat"]), match_score=m["match_score"], vector_similarity=m["vector_similarity"], attribute_match_score=m["attribute_match_score"], explanation=m["explanation"], matching_attributes=m.get("matching_attributes", []), missing_attributes=m.get("missing_attributes", []) ) for m in search_result["matches"] ] else: # LOCAL: Use local framework result = framework.search(profile, use_cache=use_cache) current_matches = result.matches # Build results HTML if current_matches: chat_history[1]["content"] += f"\n\nโœจ Found {len(current_matches)} great matches!" results_html = build_results_grid(current_matches) else: chat_history[1]["content"] += "\n\n๐Ÿ˜ฟ No matches found. Try broadening your search criteria." results_html = "

No matches found

" # Profile JSON for display profile_json = profile.model_dump_json(indent=2) return chat_history, results_html, profile_json except Exception as e: error_msg = f"โŒ Error: {str(e)}" print(f"[ERROR] Search failed: {e}") import traceback traceback.print_exc() return [ {"role": "user", "content": user_input}, {"role": "assistant", "content": error_msg} ], "

Error occurred

", "{}" def build_results_grid(matches: List[CatMatch]) -> str: """Build HTML grid of cat results.""" html = "
" for match in matches: cat = match.cat photo = cat.primary_photo or "https://via.placeholder.com/240x180?text=No+Photo" html += f"""

{cat.name}

{match.match_score:.0%} Match {cat.age}

{cat.breed}
{cat.city}, {cat.state}
{cat.gender.capitalize()} โ€ข {cat.size.capitalize() if cat.size else 'Unknown size'}

{match.explanation}

View Details
""" html += "
" return html def search_with_examples(example_text: str, use_cache: bool = False) -> tuple: """Handle example button clicks.""" return extract_profile_from_text(example_text, use_cache) # ===== ALERT MANAGEMENT FUNCTIONS ===== def validate_email(email: str) -> bool: """Validate email address format.""" pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' return bool(re.match(pattern, email)) def send_immediate_notification_local(alert_id: int) -> None: """ Send immediate notification locally (not via Modal). Args: alert_id: ID of the alert to process """ from agents.email_agent import EmailAgent from agents.email_providers.factory import get_email_provider print(f"[DEBUG] Sending immediate notification for alert {alert_id}") # Get alert from database alert = framework.db_manager.get_alert_by_id(alert_id) if not alert: print(f"[ERROR] Alert {alert_id} not found") raise ValueError(f"Alert {alert_id} not found") print(f"[DEBUG] Alert found: email={alert.user_email}, profile exists={alert.profile is not None}") # Run search with the alert's profile result = framework.search(alert.profile, use_cache=False) print(f"[DEBUG] Search complete: {len(result.matches)} matches found") if result.matches: # Send email notification try: email_provider = get_email_provider() email_agent = EmailAgent(email_provider) print(f"[DEBUG] Sending email to {alert.user_email}...") email_agent.send_match_notification( alert=alert, matches=result.matches ) print(f"[DEBUG] โœ“ Email sent successfully!") except Exception as e: print(f"[ERROR] Failed to send email: {e}") import traceback traceback.print_exc() raise else: print(f"[DEBUG] No matches found, no email sent") def save_alert(email: str, frequency: str, profile_json: str) -> Tuple[str, pd.DataFrame]: """ Save an adoption alert to the database. Args: email: User's email address frequency: Notification frequency (Immediately, Daily, Weekly) profile_json: JSON string of current search profile Returns: Tuple of (status_message, updated_alerts_dataframe) """ global current_profile try: # Validate email if not email or not validate_email(email): return "โŒ Please enter a valid email address", load_alerts() # Check if we have a current profile if not current_profile: return "โŒ Please perform a search first to create a profile", load_alerts() # Normalize frequency frequency = frequency.lower() # Create alert alert = AdoptionAlert( user_email=email, profile=current_profile, frequency=frequency, active=True ) # Save alert based on mode if is_production(): # PRODUCTION MODE: Use Modal function try: import modal print(f"[INFO] Production mode: Calling Modal function to create alert...") # Look up deployed function - correct API! create_alert_func = modal.Function.from_name("tuxedo-link-api", "create_alert_and_notify") # Send alert data to Modal result = create_alert_func.remote(alert.dict()) if result["success"]: status = f"โœ… {result['message']}" else: status = f"โš ๏ธ {result['message']}" return status, load_alerts() except Exception as e: import traceback error_detail = traceback.format_exc() print(f"[ERROR] Modal function failed: {error_detail}") return f"โŒ Error calling Modal service: {str(e)}\n\nCheck Modal logs for details.", load_alerts() else: # LOCAL MODE: Save and process locally alert_id = framework.db_manager.create_alert(alert) if frequency == "immediately": try: send_immediate_notification_local(alert_id) status = f"โœ… Alert saved and notification sent locally! (ID: {alert_id})\n\nCheck your email at {email}" except Exception as e: import traceback error_detail = traceback.format_exc() print(f"[ERROR] Local notification failed: {error_detail}") status = f"โœ… Alert saved (ID: {alert_id}), but notification failed: {str(e)}" else: status = f"โœ… Alert saved successfully! (ID: {alert_id})\n\nYou'll receive {frequency} notifications at {email}" return status, load_alerts() except Exception as e: return f"โŒ Error saving alert: {str(e)}", load_alerts() def load_alerts(email_filter: str = "") -> pd.DataFrame: """ Load all alerts from the database. Args: email_filter: Optional email to filter by Returns: DataFrame of alerts """ try: # Get alerts from database (Modal or local) if is_production(): # PRODUCTION: Call Modal API import modal # Look up deployed function get_alerts_func = modal.Function.from_name("tuxedo-link-api", "get_alerts") alert_dicts = get_alerts_func.remote(email=email_filter if email_filter and validate_email(email_filter) else None) alerts = [AdoptionAlert(**a) for a in alert_dicts] else: # LOCAL: Use local database if email_filter and validate_email(email_filter): alerts = framework.db_manager.get_alerts_by_email(email_filter) else: alerts = framework.db_manager.get_all_alerts() if not alerts: # Return empty DataFrame with correct columns return pd.DataFrame(columns=["ID", "Email", "Frequency", "Location", "Preferences", "Last Sent", "Status"]) # Convert to display format data = [] for alert in alerts: location = alert.profile.user_location or "Any" prefs = [] if alert.profile.age_range: prefs.append(f"Age: {', '.join(alert.profile.age_range)}") if alert.profile.good_with_children: prefs.append("Child-friendly") if alert.profile.good_with_dogs: prefs.append("Dog-friendly") if alert.profile.good_with_cats: prefs.append("Cat-friendly") prefs_str = ", ".join(prefs) if prefs else "Any" last_sent = alert.last_sent.strftime("%Y-%m-%d %H:%M") if alert.last_sent else "Never" status = "๐ŸŸข Active" if alert.active else "๐Ÿ”ด Inactive" data.append({ "ID": alert.id, "Email": alert.user_email, "Frequency": alert.frequency.capitalize(), "Location": location, "Preferences": prefs_str, "Last Sent": last_sent, "Status": status }) return pd.DataFrame(data) except Exception as e: logging.error(f"Error loading alerts: {e}") return pd.DataFrame(columns=["ID", "Email", "Frequency", "Location", "Preferences", "Last Sent", "Status"]) def delete_alert(alert_id: str, email_filter: str = "") -> Tuple[str, pd.DataFrame]: """ Delete an alert by ID. Args: alert_id: Alert ID to delete email_filter: Optional email filter for refresh Returns: Tuple of (status_message, updated_alerts_dataframe) """ try: if not alert_id: return "โŒ Please enter an Alert ID", load_alerts(email_filter) # Convert to int try: alert_id_int = int(alert_id) except ValueError: return f"โŒ Invalid Alert ID: {alert_id}", load_alerts(email_filter) # Delete from database (Modal or local) if is_production(): # PRODUCTION: Call Modal API import modal # Look up deployed function delete_alert_func = modal.Function.from_name("tuxedo-link-api", "delete_alert") success = delete_alert_func.remote(alert_id_int) if not success: return f"โŒ Failed to delete alert {alert_id}", load_alerts(email_filter) else: # LOCAL: Use local database framework.db_manager.delete_alert(alert_id_int) return f"โœ… Alert {alert_id} deleted successfully", load_alerts(email_filter) except Exception as e: return f"โŒ Error deleting alert: {str(e)}", load_alerts(email_filter) def toggle_alert_status(alert_id: str, email_filter: str = "") -> Tuple[str, pd.DataFrame]: """ Toggle alert active/inactive status. Args: alert_id: Alert ID to toggle email_filter: Optional email filter for refresh Returns: Tuple of (status_message, updated_alerts_dataframe) """ try: if not alert_id: return "โŒ Please enter an Alert ID", load_alerts(email_filter) # Convert to int try: alert_id_int = int(alert_id) except ValueError: return f"โŒ Invalid Alert ID: {alert_id}", load_alerts(email_filter) # Get current alert and toggle (Modal or local) if is_production(): # PRODUCTION: Call Modal API import modal # Look up deployed functions get_alerts_func = modal.Function.from_name("tuxedo-link-api", "get_alerts") update_alert_func = modal.Function.from_name("tuxedo-link-api", "update_alert") # Get all alerts and find this one alert_dicts = get_alerts_func.remote() alert_dict = next((a for a in alert_dicts if a["id"] == alert_id_int), None) if not alert_dict: return f"โŒ Alert {alert_id} not found", load_alerts(email_filter) alert = AdoptionAlert(**alert_dict) new_status = not alert.active success = update_alert_func.remote(alert_id_int, active=new_status) if not success: return f"โŒ Failed to update alert {alert_id}", load_alerts(email_filter) else: # LOCAL: Use local database alert = framework.db_manager.get_alert(alert_id_int) if not alert: return f"โŒ Alert {alert_id} not found", load_alerts(email_filter) new_status = not alert.active framework.db_manager.update_alert(alert_id_int, active=new_status) status_text = "activated" if new_status else "deactivated" return f"โœ… Alert {alert_id} {status_text}", load_alerts(email_filter) except Exception as e: return f"โŒ Error toggling alert: {str(e)}", load_alerts(email_filter) def build_search_tab() -> None: """Build the search tab interface with chat and results display.""" with gr.Column(): gr.Markdown("# ๐Ÿฑ Find Your Perfect Cat") gr.Markdown("Tell me what kind of cat you're looking for, and I'll help you find the perfect match!") with gr.Row(): # In production mode, default to False since Modal cache starts empty # In local mode, can default to True after first run default_cache = False if is_production() else True use_cache_checkbox = gr.Checkbox( label="Use Cache (Fast Mode)", value=default_cache, info="Use cached cat data for faster searches (uncheck for fresh data from APIs)" ) # Chat interface for natural language input chatbot = gr.Chatbot(label="Chat", height=200, type="messages") user_input = gr.Textbox( label="Describe your ideal cat", placeholder="I'm looking for a friendly, playful kitten in NYC that's good with children...", lines=3 ) with gr.Row(): submit_btn = gr.Button("๐Ÿ” Search", variant="primary") clear_btn = gr.Button("๐Ÿ”„ Clear") # Example queries gr.Markdown("### ๐Ÿ’ก Try these examples:") with gr.Row(): example_btns = [ gr.Button("๐Ÿ  Family cat", size="sm"), gr.Button("๐ŸŽฎ Playful kitten", size="sm"), gr.Button("๐Ÿ˜ด Calm adult", size="sm"), gr.Button("๐Ÿ‘ถ Good with kids", size="sm") ] # Results display gr.Markdown("---") gr.Markdown("## ๐ŸŽฏ Search Results") results_html = gr.HTML(value="

Enter your preferences above to start searching

") # Profile display (collapsible) with gr.Accordion("๐Ÿ“‹ Extracted Profile (for debugging)", open=False): profile_display = gr.JSON(label="Profile Data") # Wire up events submit_btn.click( fn=extract_profile_from_text, inputs=[user_input, use_cache_checkbox], outputs=[chatbot, results_html, profile_display] ) user_input.submit( fn=extract_profile_from_text, inputs=[user_input, use_cache_checkbox], outputs=[chatbot, results_html, profile_display] ) clear_btn.click( fn=lambda: ([], "

Enter your preferences above to start searching

", ""), outputs=[chatbot, results_html, profile_display] ) # Example buttons examples = [ "I want a friendly family cat in zip code 10001, good with children and dogs", "Looking for a playful young kitten near New York City", "I need a calm, affectionate adult cat that likes to cuddle", "Show me cats good with children in the NYC area" ] for btn, example in zip(example_btns, examples): btn.click( fn=search_with_examples, inputs=[gr.State(example), use_cache_checkbox], outputs=[chatbot, results_html, profile_display] ) def build_alerts_tab() -> None: """Build the alerts management tab for scheduling email notifications.""" with gr.Column(): gr.Markdown("# ๐Ÿ”” Manage Alerts") gr.Markdown("Save your search and get notified when new matching cats are available!") # Instructions gr.Markdown(""" ### How it works: 1. **Search** for cats using your preferred criteria in the Search tab 2. **Enter your email** below and choose notification frequency 3. **Save Alert** to start receiving notifications You'll be notified when new cats matching your preferences become available! """) # Save Alert Section gr.Markdown("### ๐Ÿ’พ Save Current Search as Alert") with gr.Row(): with gr.Column(scale=2): email_input = gr.Textbox( label="Email Address", placeholder="your@email.com", info="Where should we send notifications?" ) with gr.Column(scale=1): frequency_dropdown = gr.Dropdown( label="Notification Frequency", choices=["Immediately", "Daily", "Weekly"], value="Daily", info="How often to check for new matches" ) with gr.Row(): save_btn = gr.Button("๐Ÿ’พ Save Alert", variant="primary", scale=2) profile_display = gr.JSON( label="Current Search Profile", value={}, visible=False, scale=1 ) save_status = gr.Markdown("") gr.Markdown("---") # Manage Alerts Section gr.Markdown("### ๐Ÿ“‹ Your Saved Alerts") with gr.Row(): with gr.Column(scale=2): email_filter_input = gr.Textbox( label="Filter by Email (optional)", placeholder="your@email.com" ) with gr.Column(scale=1): refresh_btn = gr.Button("๐Ÿ”„ Refresh", size="sm") alerts_table = gr.Dataframe( value=[], # Start empty - load on demand to avoid blocking UI startup headers=["ID", "Email", "Frequency", "Location", "Preferences", "Last Sent", "Status"], datatype=["number", "str", "str", "str", "str", "str", "str"], interactive=False, wrap=True ) # Alert Actions gr.Markdown("### โš™๏ธ Manage Alert") with gr.Row(): alert_id_input = gr.Textbox( label="Alert ID", placeholder="Enter Alert ID from table above", scale=2 ) with gr.Column(scale=3): with gr.Row(): toggle_btn = gr.Button("๐Ÿ”„ Toggle Active/Inactive", size="sm") delete_btn = gr.Button("๐Ÿ—‘๏ธ Delete Alert", variant="stop", size="sm") action_status = gr.Markdown("") # Wire up events save_btn.click( fn=save_alert, inputs=[email_input, frequency_dropdown, profile_display], outputs=[save_status, alerts_table] ) refresh_btn.click( fn=load_alerts, inputs=[email_filter_input], outputs=[alerts_table] ) email_filter_input.submit( fn=load_alerts, inputs=[email_filter_input], outputs=[alerts_table] ) toggle_btn.click( fn=toggle_alert_status, inputs=[alert_id_input, email_filter_input], outputs=[action_status, alerts_table] ) delete_btn.click( fn=delete_alert, inputs=[alert_id_input, email_filter_input], outputs=[action_status, alerts_table] ) def build_about_tab() -> None: """Build the about tab with Kyra's story and application info.""" with gr.Column(): gr.Markdown("# ๐ŸŽฉ About Tuxedo Link") gr.Markdown(""" ## In Loving Memory of Kyra ๐Ÿฑ This application is dedicated to **Kyra**, a beloved companion who brought joy, comfort, and unconditional love to our lives. Kyra was more than just a catโ€” he was family, a friend, and a constant source of happiness. ### The Inspiration Kyra Link was created to help others find their perfect feline companion, just as Kyra found his way into our hearts. Every cat deserves a loving home, and every person deserves the companionship of a wonderful cat like Kyra. ### The Technology This application uses AI and machine learning to match prospective adopters with their ideal cat: - **Natural Language Processing**: Understand your preferences in plain English - **Semantic Search**: Find cats based on personality, not just keywords - **Multi-Source Aggregation**: Search across multiple adoption platforms - **Smart Deduplication**: Remove duplicate listings using AI - **Image Recognition**: Match cats visually using computer vision - **Hybrid Matching**: Combine semantic understanding with structured filters ### Features โœ… **Multi-Platform Search**: Petfinder, RescueGroups โœ… **AI-Powered Matching**: Semantic search with vector embeddings โœ… **Smart Deduplication**: Name, description, and image similarity โœ… **Personality Matching**: Find cats that match your lifestyle โœ… **Location-Based**: Search near you with customizable radius ### Technical Stack - **Frontend**: Gradio - **Backend**: Python with Modal serverless - **LLMs**: OpenAI GPT-4 for profile extraction - **Vector DB**: ChromaDB with SentenceTransformers - **Image AI**: CLIP for visual similarity - **APIs**: Petfinder, RescueGroups, SendGrid - **Database**: SQLite for caching and user management ### Open Source Tuxedo Link is open source and built as part of the Andela LLM Engineering bootcamp. Contributions and improvements are welcome! ### Acknowledgments - **Petfinder**: For their comprehensive pet adoption API - **RescueGroups**: For connecting rescues with adopters - **Andela**: For the LLM Engineering bootcamp - **Kyra**: For inspiring this project and bringing so much joy ๐Ÿ’™ --- *"In memory of Kyra, who taught us that home is wherever your cat is."* ๐Ÿพ **May every cat find their perfect home** ๐Ÿพ """) # Add Kyra's picture with gr.Row(): with gr.Column(): gr.Image( value="assets/Kyra.png", label="Kyra - Forever in our hearts ๐Ÿ’™", show_label=True, container=True, width=400, height=400, show_download_button=False, show_share_button=False, interactive=False ) def create_app() -> gr.Blocks: """ Create and configure the Gradio application. Returns: Configured Gradio Blocks application """ with gr.Blocks( title="Tuxedo Link - Find Your Perfect Cat", theme=gr.themes.Soft() ) as app: gr.Markdown("""

๐ŸŽฉ Tuxedo Link

AI-Powered Cat Adoption Search

""") with gr.Tabs(): with gr.Tab("๐Ÿ” Search"): build_search_tab() with gr.Tab("๐Ÿ”” Alerts"): build_alerts_tab() with gr.Tab("โ„น๏ธ About"): build_about_tab() gr.Markdown("""
Made with โค๏ธ in memory of Kyra | GitHub | Powered by AI & Open Source
""") return app if __name__ == "__main__": app = create_app() app.launch( server_name="0.0.0.0", server_port=7860, share=False, show_error=True )