Week8 dkisselev-zz update

This commit is contained in:
Dmitry Kisselev
2025-10-29 02:07:03 -07:00
parent ba929c7ed4
commit d28039e255
81 changed files with 21291 additions and 0 deletions

View File

@@ -0,0 +1,834 @@
"""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
)