Files
LLM_Engineering_OLD/week8/community_contributions/dkisselev-zz/tuxedo_link/app.py
2025-10-29 02:10:53 -07:00

835 lines
32 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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
)