835 lines
32 KiB
Python
835 lines
32 KiB
Python
"""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, "<p>❌ Error extracting profile</p>", "{}"
|
||
|
||
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, "<p>😿 Search failed. Please try again.</p>", 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 = "<p style='text-align:center; color: #666; padding: 40px;'>No matches found</p>"
|
||
|
||
# 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}
|
||
], "<p>Error occurred</p>", "{}"
|
||
|
||
|
||
def build_results_grid(matches: List[CatMatch]) -> str:
|
||
"""Build HTML grid of cat results."""
|
||
html = "<div style='display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 20px; padding: 20px;'>"
|
||
|
||
for match in matches:
|
||
cat = match.cat
|
||
photo = cat.primary_photo or "https://via.placeholder.com/240x180?text=No+Photo"
|
||
|
||
html += f"""
|
||
<div style='border: 1px solid #ddd; border-radius: 10px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1);'>
|
||
<img src='{photo}' style='width: 100%; height: 180px; object-fit: cover;' onerror="this.src='https://via.placeholder.com/240x180?text=No+Photo'">
|
||
<div style='padding: 15px;'>
|
||
<h3 style='margin: 0 0 10px 0; color: #333;'>{cat.name}</h3>
|
||
<div style='display: flex; justify-content: space-between; margin-bottom: 8px;'>
|
||
<span style='background: #4CAF50; color: white; padding: 4px 12px; border-radius: 12px; font-size: 12px;'>
|
||
{match.match_score:.0%} Match
|
||
</span>
|
||
<span style='color: #666; font-size: 14px;'>{cat.age}</span>
|
||
</div>
|
||
<p style='color: #666; font-size: 14px; margin: 8px 0;'>
|
||
<strong>{cat.breed}</strong><br/>
|
||
{cat.city}, {cat.state}<br/>
|
||
{cat.gender.capitalize()} • {cat.size.capitalize() if cat.size else 'Unknown size'}
|
||
</p>
|
||
<p style='color: #888; font-size: 13px; margin: 10px 0; line-height: 1.4;'>
|
||
{match.explanation}
|
||
</p>
|
||
<a href='{cat.url}' target='_blank' style='display: block; text-align: center; background: #2196F3; color: white; padding: 10px; border-radius: 5px; text-decoration: none; margin-top: 10px;'>
|
||
View Details
|
||
</a>
|
||
</div>
|
||
</div>
|
||
"""
|
||
|
||
html += "</div>"
|
||
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="<p style='text-align:center; color: #999; padding: 40px;'>Enter your preferences above to start searching</p>")
|
||
|
||
# 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: ([], "<p style='text-align:center; color: #999; padding: 40px;'>Enter your preferences above to start searching</p>", ""),
|
||
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("""
|
||
<div style='text-align: center; padding: 20px;'>
|
||
<h1 style='font-size: 3em; margin: 0;'>🎩 Tuxedo Link</h1>
|
||
<p style='font-size: 1.2em; color: #666; margin: 10px 0;'>
|
||
AI-Powered Cat Adoption Search
|
||
</p>
|
||
</div>
|
||
""")
|
||
|
||
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("""
|
||
<div style='text-align: center; padding: 20px; color: #999; font-size: 0.9em;'>
|
||
Made with ❤️ in memory of Kyra |
|
||
<a href='https://github.com/yourusername/tuxedo-link' style='color: #2196F3;'>GitHub</a> |
|
||
Powered by AI & Open Source
|
||
</div>
|
||
""")
|
||
|
||
return app
|
||
|
||
|
||
if __name__ == "__main__":
|
||
app = create_app()
|
||
app.launch(
|
||
server_name="0.0.0.0",
|
||
server_port=7860,
|
||
share=False,
|
||
show_error=True
|
||
)
|
||
|