Week8 dkisselev-zz update
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
"""Agent implementations for Tuxedo Link."""
|
||||
|
||||
from .agent import Agent
|
||||
from .petfinder_agent import PetfinderAgent
|
||||
from .rescuegroups_agent import RescueGroupsAgent
|
||||
from .profile_agent import ProfileAgent
|
||||
from .matching_agent import MatchingAgent
|
||||
from .deduplication_agent import DeduplicationAgent
|
||||
from .planning_agent import PlanningAgent
|
||||
from .email_agent import EmailAgent
|
||||
|
||||
__all__ = [
|
||||
"Agent",
|
||||
"PetfinderAgent",
|
||||
"RescueGroupsAgent",
|
||||
"ProfileAgent",
|
||||
"MatchingAgent",
|
||||
"DeduplicationAgent",
|
||||
"PlanningAgent",
|
||||
"EmailAgent",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Base Agent class for Tuxedo Link agents."""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from functools import wraps
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
class Agent:
|
||||
"""
|
||||
An abstract superclass for Agents.
|
||||
Used to log messages in a way that can identify each Agent.
|
||||
"""
|
||||
|
||||
# Foreground colors
|
||||
RED = '\033[31m'
|
||||
GREEN = '\033[32m'
|
||||
YELLOW = '\033[33m'
|
||||
BLUE = '\033[34m'
|
||||
MAGENTA = '\033[35m'
|
||||
CYAN = '\033[36m'
|
||||
WHITE = '\033[37m'
|
||||
|
||||
# Background color
|
||||
BG_BLACK = '\033[40m'
|
||||
|
||||
# Reset code to return to default color
|
||||
RESET = '\033[0m'
|
||||
|
||||
name: str = ""
|
||||
color: str = '\033[37m'
|
||||
|
||||
def log(self, message: str) -> None:
|
||||
"""
|
||||
Log this as an info message, identifying the agent.
|
||||
|
||||
Args:
|
||||
message: Message to log
|
||||
"""
|
||||
color_code = self.BG_BLACK + self.color
|
||||
message = f"[{self.name}] {message}"
|
||||
logging.info(color_code + message + self.RESET)
|
||||
|
||||
def log_error(self, message: str) -> None:
|
||||
"""
|
||||
Log an error message.
|
||||
|
||||
Args:
|
||||
message: Error message to log
|
||||
"""
|
||||
color_code = self.BG_BLACK + self.RED
|
||||
message = f"[{self.name}] ERROR: {message}"
|
||||
logging.error(color_code + message + self.RESET)
|
||||
|
||||
def log_warning(self, message: str) -> None:
|
||||
"""
|
||||
Log a warning message.
|
||||
|
||||
Args:
|
||||
message: Warning message to log
|
||||
"""
|
||||
color_code = self.BG_BLACK + self.YELLOW
|
||||
message = f"[{self.name}] WARNING: {message}"
|
||||
logging.warning(color_code + message + self.RESET)
|
||||
|
||||
|
||||
def timed(func: Callable[..., Any]) -> Callable[..., Any]:
|
||||
"""
|
||||
Decorator to log execution time of agent methods.
|
||||
|
||||
Args:
|
||||
func: Function to time
|
||||
|
||||
Returns:
|
||||
Wrapped function
|
||||
"""
|
||||
@wraps(func)
|
||||
def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Wrapper function that times and logs method execution."""
|
||||
start_time = time.time()
|
||||
result = func(self, *args, **kwargs)
|
||||
elapsed = time.time() - start_time
|
||||
self.log(f"{func.__name__} completed in {elapsed:.2f} seconds")
|
||||
return result
|
||||
return wrapper
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
"""Deduplication agent for identifying and managing duplicate cat listings."""
|
||||
|
||||
import os
|
||||
from typing import List, Tuple, Optional
|
||||
from dotenv import load_dotenv
|
||||
import numpy as np
|
||||
|
||||
from models.cats import Cat
|
||||
from database.manager import DatabaseManager
|
||||
from utils.deduplication import (
|
||||
create_fingerprint,
|
||||
calculate_text_similarity,
|
||||
calculate_composite_score
|
||||
)
|
||||
from utils.image_utils import generate_image_embedding, calculate_image_similarity
|
||||
from .agent import Agent, timed
|
||||
|
||||
|
||||
class DeduplicationAgent(Agent):
|
||||
"""Agent for deduplicating cat listings across multiple sources."""
|
||||
|
||||
name = "Deduplication Agent"
|
||||
color = Agent.YELLOW
|
||||
|
||||
def __init__(self, db_manager: DatabaseManager):
|
||||
"""
|
||||
Initialize the deduplication agent.
|
||||
|
||||
Args:
|
||||
db_manager: Database manager instance
|
||||
"""
|
||||
load_dotenv()
|
||||
|
||||
self.db_manager = db_manager
|
||||
|
||||
# Load thresholds from environment
|
||||
self.name_threshold = float(os.getenv('DEDUP_NAME_SIMILARITY_THRESHOLD', '0.8'))
|
||||
self.desc_threshold = float(os.getenv('DEDUP_DESCRIPTION_SIMILARITY_THRESHOLD', '0.7'))
|
||||
self.image_threshold = float(os.getenv('DEDUP_IMAGE_SIMILARITY_THRESHOLD', '0.9'))
|
||||
self.composite_threshold = float(os.getenv('DEDUP_COMPOSITE_THRESHOLD', '0.85'))
|
||||
|
||||
self.log("Deduplication Agent initialized")
|
||||
self.log(f"Thresholds - Name: {self.name_threshold}, Desc: {self.desc_threshold}, "
|
||||
f"Image: {self.image_threshold}, Composite: {self.composite_threshold}")
|
||||
|
||||
def _get_image_embedding(self, cat: Cat) -> Optional[np.ndarray]:
|
||||
"""
|
||||
Get or generate image embedding for a cat.
|
||||
|
||||
Args:
|
||||
cat: Cat object
|
||||
|
||||
Returns:
|
||||
Image embedding or None if unavailable
|
||||
"""
|
||||
if not cat.primary_photo:
|
||||
return None
|
||||
|
||||
try:
|
||||
embedding = generate_image_embedding(cat.primary_photo)
|
||||
return embedding
|
||||
except Exception as e:
|
||||
self.log_warning(f"Failed to generate image embedding for {cat.name}: {e}")
|
||||
return None
|
||||
|
||||
def _compare_cats(self, cat1: Cat, cat2: Cat,
|
||||
emb1: Optional[np.ndarray],
|
||||
emb2: Optional[np.ndarray]) -> Tuple[float, dict]:
|
||||
"""
|
||||
Compare two cats and return composite similarity score with details.
|
||||
|
||||
Args:
|
||||
cat1: First cat
|
||||
cat2: Second cat
|
||||
emb1: Image embedding for cat1
|
||||
emb2: Image embedding for cat2
|
||||
|
||||
Returns:
|
||||
Tuple of (composite_score, details_dict)
|
||||
"""
|
||||
# Text similarity
|
||||
name_sim, desc_sim = calculate_text_similarity(cat1, cat2)
|
||||
|
||||
# Image similarity
|
||||
image_sim = 0.0
|
||||
if emb1 is not None and emb2 is not None:
|
||||
image_sim = calculate_image_similarity(emb1, emb2)
|
||||
|
||||
# Composite score
|
||||
composite = calculate_composite_score(
|
||||
name_similarity=name_sim,
|
||||
description_similarity=desc_sim,
|
||||
image_similarity=image_sim,
|
||||
name_weight=0.4,
|
||||
description_weight=0.3,
|
||||
image_weight=0.3
|
||||
)
|
||||
|
||||
details = {
|
||||
'name_similarity': name_sim,
|
||||
'description_similarity': desc_sim,
|
||||
'image_similarity': image_sim,
|
||||
'composite_score': composite
|
||||
}
|
||||
|
||||
return composite, details
|
||||
|
||||
@timed
|
||||
def process_cat(self, cat: Cat) -> Tuple[Cat, bool]:
|
||||
"""
|
||||
Process a single cat for deduplication.
|
||||
|
||||
Checks if the cat is a duplicate of an existing cat in the database.
|
||||
If it's a duplicate, marks it as such and returns the canonical cat.
|
||||
If it's unique, caches it in the database.
|
||||
|
||||
Args:
|
||||
cat: Cat to process
|
||||
|
||||
Returns:
|
||||
Tuple of (canonical_cat, is_duplicate)
|
||||
"""
|
||||
# Generate fingerprint
|
||||
cat.fingerprint = create_fingerprint(cat)
|
||||
|
||||
# Check database for cats with same fingerprint
|
||||
candidates = self.db_manager.get_cats_by_fingerprint(cat.fingerprint)
|
||||
|
||||
if not candidates:
|
||||
# No candidates, this is unique
|
||||
# Generate and cache image embedding
|
||||
embedding = self._get_image_embedding(cat)
|
||||
self.db_manager.cache_cat(cat, embedding)
|
||||
return cat, False
|
||||
|
||||
self.log(f"Found {len(candidates)} potential duplicates for {cat.name}")
|
||||
|
||||
# Get embedding for new cat
|
||||
new_embedding = self._get_image_embedding(cat)
|
||||
|
||||
# Compare with each candidate
|
||||
best_match = None
|
||||
best_score = 0.0
|
||||
best_details = None
|
||||
|
||||
for candidate_cat, candidate_embedding in candidates:
|
||||
score, details = self._compare_cats(cat, candidate_cat, new_embedding, candidate_embedding)
|
||||
|
||||
self.log(f"Comparing with {candidate_cat.name} (ID: {candidate_cat.id}): "
|
||||
f"name={details['name_similarity']:.2f}, "
|
||||
f"desc={details['description_similarity']:.2f}, "
|
||||
f"image={details['image_similarity']:.2f}, "
|
||||
f"composite={score:.2f}")
|
||||
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_match = candidate_cat
|
||||
best_details = details
|
||||
|
||||
# Check if best match exceeds threshold
|
||||
if best_match and best_score >= self.composite_threshold:
|
||||
self.log(f"DUPLICATE DETECTED: {cat.name} is duplicate of {best_match.name} "
|
||||
f"(score: {best_score:.2f})")
|
||||
|
||||
# Mark as duplicate in database
|
||||
self.db_manager.mark_as_duplicate(cat.id, best_match.id)
|
||||
|
||||
return best_match, True
|
||||
|
||||
# Not a duplicate, cache it
|
||||
self.log(f"UNIQUE: {cat.name} is not a duplicate (best score: {best_score:.2f})")
|
||||
self.db_manager.cache_cat(cat, new_embedding)
|
||||
|
||||
return cat, False
|
||||
|
||||
@timed
|
||||
def deduplicate_batch(self, cats: List[Cat]) -> List[Cat]:
|
||||
"""
|
||||
Process a batch of cats for deduplication.
|
||||
|
||||
Args:
|
||||
cats: List of cats to process
|
||||
|
||||
Returns:
|
||||
List of unique cats (duplicates removed)
|
||||
"""
|
||||
self.log(f"Deduplicating batch of {len(cats)} cats")
|
||||
|
||||
unique_cats = []
|
||||
duplicate_count = 0
|
||||
|
||||
for cat in cats:
|
||||
try:
|
||||
canonical_cat, is_duplicate = self.process_cat(cat)
|
||||
|
||||
if not is_duplicate:
|
||||
unique_cats.append(canonical_cat)
|
||||
else:
|
||||
duplicate_count += 1
|
||||
# Optionally include canonical if not already in list
|
||||
if canonical_cat not in unique_cats:
|
||||
unique_cats.append(canonical_cat)
|
||||
|
||||
except Exception as e:
|
||||
self.log_error(f"Error processing cat {cat.name}: {e}")
|
||||
# Include it anyway to avoid losing data
|
||||
unique_cats.append(cat)
|
||||
|
||||
self.log(f"Deduplication complete: {len(unique_cats)} unique, {duplicate_count} duplicates")
|
||||
|
||||
return unique_cats
|
||||
|
||||
def get_duplicate_report(self) -> dict:
|
||||
"""
|
||||
Generate a report of duplicate statistics.
|
||||
|
||||
Returns:
|
||||
Dictionary with duplicate statistics
|
||||
"""
|
||||
stats = self.db_manager.get_cache_stats()
|
||||
|
||||
return {
|
||||
'total_unique': stats['total_unique'],
|
||||
'total_duplicates': stats['total_duplicates'],
|
||||
'deduplication_rate': stats['total_duplicates'] / (stats['total_unique'] + stats['total_duplicates'])
|
||||
if (stats['total_unique'] + stats['total_duplicates']) > 0 else 0,
|
||||
'by_source': stats['by_source']
|
||||
}
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
"""Email agent for sending match notifications."""
|
||||
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from agents.agent import Agent
|
||||
from agents.email_providers import get_email_provider, EmailProvider
|
||||
from models.cats import CatMatch, AdoptionAlert
|
||||
from utils.timing import timed
|
||||
from utils.config import get_email_config
|
||||
|
||||
|
||||
class EmailAgent(Agent):
|
||||
"""Agent for sending email notifications about cat matches."""
|
||||
|
||||
name = "Email Agent"
|
||||
color = '\033[35m' # Magenta
|
||||
|
||||
def __init__(self, provider: Optional[EmailProvider] = None):
|
||||
"""
|
||||
Initialize the email agent.
|
||||
|
||||
Args:
|
||||
provider: Optional email provider instance. If None, creates from config.
|
||||
"""
|
||||
super().__init__()
|
||||
|
||||
try:
|
||||
self.provider = provider or get_email_provider()
|
||||
self.enabled = True
|
||||
self.log(f"Email Agent initialized with provider: {self.provider.get_provider_name()}")
|
||||
except Exception as e:
|
||||
self.log_error(f"Failed to initialize email provider: {e}")
|
||||
self.log_warning("Email notifications disabled")
|
||||
self.enabled = False
|
||||
self.provider = None
|
||||
|
||||
def _build_match_html(self, matches: List[CatMatch], alert: AdoptionAlert) -> str:
|
||||
"""
|
||||
Build HTML email content for matches.
|
||||
|
||||
Args:
|
||||
matches: List of cat matches
|
||||
alert: Adoption alert with user preferences
|
||||
|
||||
Returns:
|
||||
HTML email content
|
||||
"""
|
||||
# Header
|
||||
html = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body {{
|
||||
font-family: Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}}
|
||||
.header {{
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 30px;
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}}
|
||||
.header h1 {{
|
||||
margin: 0;
|
||||
font-size: 2.5em;
|
||||
}}
|
||||
.cat-card {{
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}}
|
||||
.cat-photo {{
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
object-fit: cover;
|
||||
}}
|
||||
.cat-details {{
|
||||
padding: 20px;
|
||||
}}
|
||||
.cat-name {{
|
||||
font-size: 1.8em;
|
||||
color: #333;
|
||||
margin: 0 0 10px 0;
|
||||
}}
|
||||
.match-score {{
|
||||
background: #4CAF50;
|
||||
color: white;
|
||||
padding: 5px 15px;
|
||||
border-radius: 20px;
|
||||
display: inline-block;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
}}
|
||||
.cat-info {{
|
||||
color: #666;
|
||||
margin: 10px 0;
|
||||
}}
|
||||
.cat-description {{
|
||||
color: #888;
|
||||
line-height: 1.8;
|
||||
margin: 15px 0;
|
||||
}}
|
||||
.view-button {{
|
||||
display: inline-block;
|
||||
background: #2196F3;
|
||||
color: white;
|
||||
padding: 12px 30px;
|
||||
border-radius: 5px;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
margin-top: 10px;
|
||||
}}
|
||||
.footer {{
|
||||
text-align: center;
|
||||
color: #999;
|
||||
padding: 30px 0;
|
||||
border-top: 1px solid #eee;
|
||||
margin-top: 30px;
|
||||
}}
|
||||
.unsubscribe {{
|
||||
color: #999;
|
||||
text-decoration: none;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>🎩 Tuxedo Link</h1>
|
||||
<p>We found {len(matches)} new cat{'s' if len(matches) != 1 else ''} matching your preferences!</p>
|
||||
</div>
|
||||
"""
|
||||
|
||||
# Cat cards
|
||||
for match in matches[:10]: # Limit to top 10 for email
|
||||
cat = match.cat
|
||||
photo = cat.primary_photo or "https://via.placeholder.com/800x300?text=No+Photo"
|
||||
|
||||
html += f"""
|
||||
<div class="cat-card">
|
||||
<img src="{photo}" alt="{cat.name}" class="cat-photo">
|
||||
<div class="cat-details">
|
||||
<h2 class="cat-name">{cat.name}</h2>
|
||||
<div class="match-score">{match.match_score:.0%} Match</div>
|
||||
<div class="cat-info">
|
||||
<strong>{cat.breed}</strong><br/>
|
||||
📍 {cat.city}, {cat.state}<br/>
|
||||
🎂 {cat.age} • {cat.gender.capitalize()} • {cat.size.capitalize() if cat.size else 'Size not specified'}<br/>
|
||||
"""
|
||||
|
||||
# Add special attributes
|
||||
attrs = []
|
||||
if cat.good_with_children:
|
||||
attrs.append("👶 Good with children")
|
||||
if cat.good_with_dogs:
|
||||
attrs.append("🐕 Good with dogs")
|
||||
if cat.good_with_cats:
|
||||
attrs.append("🐱 Good with cats")
|
||||
|
||||
if attrs:
|
||||
html += "<br/>" + " • ".join(attrs)
|
||||
|
||||
html += f"""
|
||||
</div>
|
||||
<div class="cat-description">
|
||||
<strong>Why this is a great match:</strong><br/>
|
||||
{match.explanation}
|
||||
</div>
|
||||
"""
|
||||
|
||||
# Add description if available
|
||||
if cat.description:
|
||||
desc = cat.description[:300] + "..." if len(cat.description) > 300 else cat.description
|
||||
html += f"""
|
||||
<div class="cat-description">
|
||||
<strong>About {cat.name}:</strong><br/>
|
||||
{desc}
|
||||
</div>
|
||||
"""
|
||||
|
||||
html += f"""
|
||||
<a href="{cat.url}" class="view-button">View {cat.name}'s Profile →</a>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
# Footer
|
||||
html += f"""
|
||||
<div class="footer">
|
||||
<p>This email was sent because you saved a search on Tuxedo Link.</p>
|
||||
<p>
|
||||
<a href="http://localhost:7860" class="unsubscribe">Manage Alerts</a> |
|
||||
<a href="http://localhost:7860" class="unsubscribe">Unsubscribe</a>
|
||||
</p>
|
||||
<p>Made with ❤️ in memory of Tuxedo</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
return html
|
||||
|
||||
def _build_match_text(self, matches: List[CatMatch]) -> str:
|
||||
"""
|
||||
Build plain text email content for matches.
|
||||
|
||||
Args:
|
||||
matches: List of cat matches
|
||||
|
||||
Returns:
|
||||
Plain text email content
|
||||
"""
|
||||
text = f"TUXEDO LINK - New Matches Found!\n\n"
|
||||
text += f"We found {len(matches)} cat{'s' if len(matches) != 1 else ''} matching your preferences!\n\n"
|
||||
text += "="*60 + "\n\n"
|
||||
|
||||
for i, match in enumerate(matches[:10], 1):
|
||||
cat = match.cat
|
||||
text += f"{i}. {cat.name} - {match.match_score:.0%} Match\n"
|
||||
text += f" {cat.breed}\n"
|
||||
text += f" {cat.city}, {cat.state}\n"
|
||||
text += f" {cat.age} • {cat.gender} • {cat.size or 'Size not specified'}\n"
|
||||
text += f" Match: {match.explanation}\n"
|
||||
text += f" View: {cat.url}\n\n"
|
||||
|
||||
text += "="*60 + "\n"
|
||||
text += "Manage your alerts: http://localhost:7860\n"
|
||||
text += "Made with love in memory of Tuxedo\n"
|
||||
|
||||
return text
|
||||
|
||||
@timed
|
||||
def send_match_notification(
|
||||
self,
|
||||
alert: AdoptionAlert,
|
||||
matches: List[CatMatch]
|
||||
) -> bool:
|
||||
"""
|
||||
Send email notification about new matches.
|
||||
|
||||
Args:
|
||||
alert: Adoption alert with user email and preferences
|
||||
matches: List of cat matches to notify about
|
||||
|
||||
Returns:
|
||||
True if email sent successfully, False otherwise
|
||||
"""
|
||||
if not self.enabled:
|
||||
self.log_warning("Email agent disabled - skipping notification")
|
||||
return False
|
||||
|
||||
if not matches:
|
||||
self.log("No matches to send")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Build email content
|
||||
subject = f"🐱 {len(matches)} New Cat Match{'es' if len(matches) != 1 else ''} on Tuxedo Link!"
|
||||
html_content = self._build_match_html(matches, alert)
|
||||
text_content = self._build_match_text(matches)
|
||||
|
||||
# Send via provider
|
||||
self.log(f"Sending notification to {alert.user_email} for {len(matches)} matches")
|
||||
success = self.provider.send_email(
|
||||
to=alert.user_email,
|
||||
subject=subject,
|
||||
html=html_content,
|
||||
text=text_content
|
||||
)
|
||||
|
||||
if success:
|
||||
self.log(f"✅ Email sent successfully")
|
||||
return True
|
||||
else:
|
||||
self.log_error(f"Failed to send email")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.log_error(f"Error sending email: {e}")
|
||||
return False
|
||||
|
||||
@timed
|
||||
def send_welcome_email(self, user_email: str, user_name: str = None) -> bool:
|
||||
"""
|
||||
Send welcome email when user creates an alert.
|
||||
|
||||
Args:
|
||||
user_email: User's email address
|
||||
user_name: User's name (optional)
|
||||
|
||||
Returns:
|
||||
True if sent successfully, False otherwise
|
||||
"""
|
||||
if not self.enabled:
|
||||
return False
|
||||
|
||||
try:
|
||||
greeting = f"Hi {user_name}" if user_name else "Hello"
|
||||
|
||||
subject = "Welcome to Tuxedo Link! 🐱"
|
||||
|
||||
html_content = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body {{
|
||||
font-family: Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}}
|
||||
.header {{
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 40px;
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
}}
|
||||
.content {{
|
||||
padding: 30px 0;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>🎩 Welcome to Tuxedo Link!</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>{greeting}!</p>
|
||||
<p>Thank you for signing up for cat adoption alerts. We're excited to help you find your perfect feline companion!</p>
|
||||
<p>We'll notify you when new cats matching your preferences become available for adoption.</p>
|
||||
<p><strong>What happens next?</strong></p>
|
||||
<ul>
|
||||
<li>We'll search across multiple adoption platforms</li>
|
||||
<li>You'll receive email notifications based on your preferences</li>
|
||||
<li>You can manage your alerts anytime at <a href="http://localhost:7860">Tuxedo Link</a></li>
|
||||
</ul>
|
||||
<p>Happy cat hunting! 🐾</p>
|
||||
<p style="color: #999; font-style: italic;">In loving memory of Kyra</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
text_content = f"""
|
||||
{greeting}!
|
||||
|
||||
Thank you for signing up for Tuxedo Link cat adoption alerts!
|
||||
|
||||
We'll notify you when new cats matching your preferences become available.
|
||||
|
||||
What happens next?
|
||||
- We'll search across multiple adoption platforms
|
||||
- You'll receive email notifications based on your preferences
|
||||
- Manage your alerts at: http://localhost:7860
|
||||
|
||||
Happy cat hunting!
|
||||
|
||||
In loving memory of Kyra
|
||||
"""
|
||||
|
||||
success = self.provider.send_email(
|
||||
to=user_email,
|
||||
subject=subject,
|
||||
html=html_content,
|
||||
text=text_content
|
||||
)
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
self.log_error(f"Error sending welcome email: {e}")
|
||||
return False
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Email provider implementations."""
|
||||
|
||||
from .base import EmailProvider
|
||||
from .mailgun_provider import MailgunProvider
|
||||
from .sendgrid_provider import SendGridProvider
|
||||
from .factory import get_email_provider
|
||||
|
||||
__all__ = [
|
||||
"EmailProvider",
|
||||
"MailgunProvider",
|
||||
"SendGridProvider",
|
||||
"get_email_provider",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Base email provider interface."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, Optional
|
||||
|
||||
|
||||
class EmailProvider(ABC):
|
||||
"""Abstract base class for email providers."""
|
||||
|
||||
@abstractmethod
|
||||
def send_email(
|
||||
self,
|
||||
to: str,
|
||||
subject: str,
|
||||
html: str,
|
||||
text: str,
|
||||
from_email: Optional[str] = None,
|
||||
from_name: Optional[str] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Send an email.
|
||||
|
||||
Args:
|
||||
to: Recipient email address
|
||||
subject: Email subject
|
||||
html: HTML body
|
||||
text: Plain text body
|
||||
from_email: Sender email (optional, uses config default)
|
||||
from_name: Sender name (optional, uses config default)
|
||||
|
||||
Returns:
|
||||
bool: True if email was sent successfully, False otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_provider_name(self) -> str:
|
||||
"""
|
||||
Get the name of this provider.
|
||||
|
||||
Returns:
|
||||
str: Provider name
|
||||
"""
|
||||
pass
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Email provider factory."""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from typing import Optional
|
||||
from .base import EmailProvider
|
||||
from .mailgun_provider import MailgunProvider
|
||||
from .sendgrid_provider import SendGridProvider
|
||||
from utils.config import get_email_provider as get_configured_provider
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_email_provider(provider_name: Optional[str] = None) -> EmailProvider:
|
||||
"""
|
||||
Get an email provider instance.
|
||||
|
||||
Args:
|
||||
provider_name: Provider name (mailgun or sendgrid).
|
||||
If None, uses configuration from config.yaml
|
||||
|
||||
Returns:
|
||||
EmailProvider: Configured email provider instance
|
||||
|
||||
Raises:
|
||||
ValueError: If provider name is unknown
|
||||
"""
|
||||
if not provider_name:
|
||||
provider_name = get_configured_provider()
|
||||
|
||||
provider_name = provider_name.lower()
|
||||
|
||||
logger.info(f"Initializing email provider: {provider_name}")
|
||||
|
||||
if provider_name == 'mailgun':
|
||||
return MailgunProvider()
|
||||
elif provider_name == 'sendgrid':
|
||||
return SendGridProvider()
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unknown email provider: {provider_name}. "
|
||||
"Valid options are: mailgun, sendgrid"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Mailgun email provider implementation."""
|
||||
|
||||
import os
|
||||
import requests
|
||||
import logging
|
||||
from typing import Optional
|
||||
from .base import EmailProvider
|
||||
from utils.config import get_mailgun_config, get_email_config
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MailgunProvider(EmailProvider):
|
||||
"""Mailgun email provider."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize Mailgun provider."""
|
||||
self.api_key = os.getenv('MAILGUN_API_KEY')
|
||||
if not self.api_key:
|
||||
raise ValueError("MAILGUN_API_KEY environment variable not set")
|
||||
|
||||
mailgun_config = get_mailgun_config()
|
||||
self.domain = mailgun_config['domain']
|
||||
self.base_url = f"https://api.mailgun.net/v3/{self.domain}/messages"
|
||||
|
||||
email_config = get_email_config()
|
||||
self.default_from_name = email_config['from_name']
|
||||
self.default_from_email = email_config['from_email']
|
||||
|
||||
logger.info(f"Mailgun provider initialized with domain: {self.domain}")
|
||||
|
||||
def send_email(
|
||||
self,
|
||||
to: str,
|
||||
subject: str,
|
||||
html: str,
|
||||
text: str,
|
||||
from_email: Optional[str] = None,
|
||||
from_name: Optional[str] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Send an email via Mailgun.
|
||||
|
||||
Args:
|
||||
to: Recipient email address
|
||||
subject: Email subject
|
||||
html: HTML body
|
||||
text: Plain text body
|
||||
from_email: Sender email (optional, uses config default)
|
||||
from_name: Sender name (optional, uses config default)
|
||||
|
||||
Returns:
|
||||
bool: True if email was sent successfully, False otherwise
|
||||
"""
|
||||
from_email = from_email or self.default_from_email
|
||||
from_name = from_name or self.default_from_name
|
||||
from_header = f"{from_name} <{from_email}>"
|
||||
|
||||
data = {
|
||||
"from": from_header,
|
||||
"to": to,
|
||||
"subject": subject,
|
||||
"text": text,
|
||||
"html": html
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
self.base_url,
|
||||
auth=("api", self.api_key),
|
||||
data=data,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info(f"Email sent successfully to {to} via Mailgun")
|
||||
return True
|
||||
else:
|
||||
logger.error(
|
||||
f"Failed to send email via Mailgun: {response.status_code} - {response.text}"
|
||||
)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Exception sending email via Mailgun: {e}")
|
||||
return False
|
||||
|
||||
def get_provider_name(self) -> str:
|
||||
"""
|
||||
Get the name of this provider.
|
||||
|
||||
Returns:
|
||||
str: Provider name
|
||||
"""
|
||||
return "mailgun"
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""SendGrid email provider implementation (stub)."""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from typing import Optional
|
||||
from .base import EmailProvider
|
||||
from utils.config import get_email_config
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SendGridProvider(EmailProvider):
|
||||
"""SendGrid email provider (stub implementation)."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize SendGrid provider."""
|
||||
self.api_key = os.getenv('SENDGRID_API_KEY')
|
||||
|
||||
email_config = get_email_config()
|
||||
self.default_from_name = email_config['from_name']
|
||||
self.default_from_email = email_config['from_email']
|
||||
|
||||
logger.info("SendGrid provider initialized (stub mode)")
|
||||
if not self.api_key:
|
||||
logger.warning("SENDGRID_API_KEY not set - stub will only log, not send")
|
||||
|
||||
def send_email(
|
||||
self,
|
||||
to: str,
|
||||
subject: str,
|
||||
html: str,
|
||||
text: str,
|
||||
from_email: Optional[str] = None,
|
||||
from_name: Optional[str] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Send an email via SendGrid (stub - only logs, doesn't actually send).
|
||||
|
||||
Args:
|
||||
to: Recipient email address
|
||||
subject: Email subject
|
||||
html: HTML body
|
||||
text: Plain text body
|
||||
from_email: Sender email (optional, uses config default)
|
||||
from_name: Sender name (optional, uses config default)
|
||||
|
||||
Returns:
|
||||
bool: True (always succeeds in stub mode)
|
||||
"""
|
||||
from_email = from_email or self.default_from_email
|
||||
from_name = from_name or self.default_from_name
|
||||
|
||||
logger.info(f"[STUB] Would send email via SendGrid:")
|
||||
logger.info(f" From: {from_name} <{from_email}>")
|
||||
logger.info(f" To: {to}")
|
||||
logger.info(f" Subject: {subject}")
|
||||
logger.info(f" Text length: {len(text)} chars")
|
||||
logger.info(f" HTML length: {len(html)} chars")
|
||||
|
||||
# Simulate success
|
||||
return True
|
||||
|
||||
def get_provider_name(self) -> str:
|
||||
"""
|
||||
Get the name of this provider.
|
||||
|
||||
Returns:
|
||||
str: Provider name
|
||||
"""
|
||||
return "sendgrid (stub)"
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
"""Matching agent for hybrid search (vector + metadata filtering)."""
|
||||
|
||||
import os
|
||||
from typing import List
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from models.cats import Cat, CatProfile, CatMatch
|
||||
from setup_vectordb import VectorDBManager
|
||||
from utils.geocoding import calculate_distance
|
||||
from .agent import Agent, timed
|
||||
|
||||
|
||||
class MatchingAgent(Agent):
|
||||
"""Agent for matching cats to user preferences using hybrid search."""
|
||||
|
||||
name = "Matching Agent"
|
||||
color = Agent.BLUE
|
||||
|
||||
def __init__(self, vector_db: VectorDBManager):
|
||||
"""
|
||||
Initialize the matching agent.
|
||||
|
||||
Args:
|
||||
vector_db: Vector database manager
|
||||
"""
|
||||
load_dotenv()
|
||||
|
||||
self.vector_db = vector_db
|
||||
|
||||
# Load configuration
|
||||
self.vector_top_n = int(os.getenv('VECTOR_TOP_N', '50'))
|
||||
self.final_limit = int(os.getenv('FINAL_RESULTS_LIMIT', '20'))
|
||||
self.semantic_weight = float(os.getenv('SEMANTIC_WEIGHT', '0.6'))
|
||||
self.attribute_weight = float(os.getenv('ATTRIBUTE_WEIGHT', '0.4'))
|
||||
|
||||
self.log("Matching Agent initialized")
|
||||
self.log(f"Config - Vector Top N: {self.vector_top_n}, Final Limit: {self.final_limit}")
|
||||
self.log(f"Weights - Semantic: {self.semantic_weight}, Attribute: {self.attribute_weight}")
|
||||
|
||||
def _apply_metadata_filters(self, profile: CatProfile) -> dict:
|
||||
"""
|
||||
Build ChromaDB where clause from profile hard constraints.
|
||||
|
||||
Args:
|
||||
profile: User's cat profile
|
||||
|
||||
Returns:
|
||||
Dictionary of metadata filters
|
||||
"""
|
||||
filters = []
|
||||
|
||||
# Age filter
|
||||
if profile.age_range:
|
||||
age_conditions = [{"age": age} for age in profile.age_range]
|
||||
if len(age_conditions) > 1:
|
||||
filters.append({"$or": age_conditions})
|
||||
else:
|
||||
filters.extend(age_conditions)
|
||||
|
||||
# Size filter
|
||||
if profile.size:
|
||||
size_conditions = [{"size": size} for size in profile.size]
|
||||
if len(size_conditions) > 1:
|
||||
filters.append({"$or": size_conditions})
|
||||
else:
|
||||
filters.extend(size_conditions)
|
||||
|
||||
# Gender filter
|
||||
if profile.gender_preference:
|
||||
filters.append({"gender": profile.gender_preference})
|
||||
|
||||
# Behavioral filters
|
||||
if profile.good_with_children is not None:
|
||||
# Filter for cats that are explicitly good with children or unknown
|
||||
if profile.good_with_children:
|
||||
filters.append({
|
||||
"$or": [
|
||||
{"good_with_children": "True"},
|
||||
{"good_with_children": "unknown"}
|
||||
]
|
||||
})
|
||||
|
||||
if profile.good_with_dogs is not None:
|
||||
if profile.good_with_dogs:
|
||||
filters.append({
|
||||
"$or": [
|
||||
{"good_with_dogs": "True"},
|
||||
{"good_with_dogs": "unknown"}
|
||||
]
|
||||
})
|
||||
|
||||
if profile.good_with_cats is not None:
|
||||
if profile.good_with_cats:
|
||||
filters.append({
|
||||
"$or": [
|
||||
{"good_with_cats": "True"},
|
||||
{"good_with_cats": "unknown"}
|
||||
]
|
||||
})
|
||||
|
||||
# Special needs filter
|
||||
if not profile.special_needs_ok:
|
||||
filters.append({"special_needs": "False"})
|
||||
|
||||
# Combine filters with AND logic
|
||||
if len(filters) == 0:
|
||||
return None
|
||||
elif len(filters) == 1:
|
||||
return filters[0]
|
||||
else:
|
||||
return {"$and": filters}
|
||||
|
||||
def _calculate_attribute_match_score(self, cat: Cat, profile: CatProfile) -> tuple[float, List[str], List[str]]:
|
||||
"""
|
||||
Calculate how well cat's attributes match profile preferences.
|
||||
|
||||
Args:
|
||||
cat: Cat to evaluate
|
||||
profile: User profile
|
||||
|
||||
Returns:
|
||||
Tuple of (score, matching_attributes, missing_attributes)
|
||||
"""
|
||||
matching_attrs = []
|
||||
missing_attrs = []
|
||||
total_checks = 0
|
||||
matches = 0
|
||||
|
||||
# Age preference
|
||||
if profile.age_range:
|
||||
total_checks += 1
|
||||
if cat.age in profile.age_range:
|
||||
matches += 1
|
||||
matching_attrs.append(f"Age: {cat.age}")
|
||||
else:
|
||||
missing_attrs.append(f"Preferred age: {', '.join(profile.age_range)}")
|
||||
|
||||
# Size preference
|
||||
if profile.size:
|
||||
total_checks += 1
|
||||
if cat.size in profile.size:
|
||||
matches += 1
|
||||
matching_attrs.append(f"Size: {cat.size}")
|
||||
else:
|
||||
missing_attrs.append(f"Preferred size: {', '.join(profile.size)}")
|
||||
|
||||
# Gender preference
|
||||
if profile.gender_preference:
|
||||
total_checks += 1
|
||||
if cat.gender == profile.gender_preference:
|
||||
matches += 1
|
||||
matching_attrs.append(f"Gender: {cat.gender}")
|
||||
else:
|
||||
missing_attrs.append(f"Preferred gender: {profile.gender_preference}")
|
||||
|
||||
# Good with children
|
||||
if profile.good_with_children:
|
||||
total_checks += 1
|
||||
if cat.good_with_children:
|
||||
matches += 1
|
||||
matching_attrs.append("Good with children")
|
||||
elif cat.good_with_children is False:
|
||||
missing_attrs.append("Not good with children")
|
||||
|
||||
# Good with dogs
|
||||
if profile.good_with_dogs:
|
||||
total_checks += 1
|
||||
if cat.good_with_dogs:
|
||||
matches += 1
|
||||
matching_attrs.append("Good with dogs")
|
||||
elif cat.good_with_dogs is False:
|
||||
missing_attrs.append("Not good with dogs")
|
||||
|
||||
# Good with cats
|
||||
if profile.good_with_cats:
|
||||
total_checks += 1
|
||||
if cat.good_with_cats:
|
||||
matches += 1
|
||||
matching_attrs.append("Good with other cats")
|
||||
elif cat.good_with_cats is False:
|
||||
missing_attrs.append("Not good with other cats")
|
||||
|
||||
# Special needs
|
||||
if not profile.special_needs_ok and cat.special_needs:
|
||||
total_checks += 1
|
||||
missing_attrs.append("Has special needs")
|
||||
|
||||
# Breed preference
|
||||
if profile.preferred_breeds:
|
||||
total_checks += 1
|
||||
if cat.breed.lower() in [b.lower() for b in profile.preferred_breeds]:
|
||||
matches += 1
|
||||
matching_attrs.append(f"Breed: {cat.breed}")
|
||||
else:
|
||||
missing_attrs.append(f"Preferred breeds: {', '.join(profile.preferred_breeds)}")
|
||||
|
||||
# Calculate score
|
||||
if total_checks == 0:
|
||||
return 0.5, matching_attrs, missing_attrs # Neutral if no preferences
|
||||
|
||||
score = matches / total_checks
|
||||
return score, matching_attrs, missing_attrs
|
||||
|
||||
def _filter_by_distance(self, cats_data: dict, profile: CatProfile) -> List[tuple[Cat, float, dict]]:
|
||||
"""
|
||||
Filter cats by distance and prepare for ranking.
|
||||
|
||||
Args:
|
||||
cats_data: Results from vector search
|
||||
profile: User profile
|
||||
|
||||
Returns:
|
||||
List of (cat, vector_similarity, metadata) tuples
|
||||
"""
|
||||
results = []
|
||||
|
||||
ids = cats_data['ids'][0]
|
||||
distances = cats_data['distances'][0]
|
||||
metadatas = cats_data['metadatas'][0]
|
||||
|
||||
for i, cat_id in enumerate(ids):
|
||||
metadata = metadatas[i]
|
||||
|
||||
# Convert distance to similarity (ChromaDB returns L2 distance)
|
||||
# Lower distance = higher similarity
|
||||
vector_similarity = 1.0 / (1.0 + distances[i])
|
||||
|
||||
# Check distance constraint
|
||||
if profile.user_latitude and profile.user_longitude:
|
||||
cat_lat = metadata.get('latitude')
|
||||
cat_lon = metadata.get('longitude')
|
||||
|
||||
if cat_lat and cat_lon and cat_lat != '' and cat_lon != '':
|
||||
try:
|
||||
cat_lat = float(cat_lat)
|
||||
cat_lon = float(cat_lon)
|
||||
distance = calculate_distance(
|
||||
profile.user_latitude,
|
||||
profile.user_longitude,
|
||||
cat_lat,
|
||||
cat_lon
|
||||
)
|
||||
|
||||
max_dist = profile.max_distance or 100
|
||||
if distance > max_dist:
|
||||
self.log(f"DEBUG: Filtered out {metadata['name']} - {distance:.1f} miles away (max: {max_dist})")
|
||||
continue # Skip this cat, too far away
|
||||
except (ValueError, TypeError):
|
||||
pass # Keep cat if coordinates invalid
|
||||
|
||||
# Reconstruct Cat from metadata
|
||||
cat = Cat(
|
||||
id=metadata['id'],
|
||||
name=metadata['name'],
|
||||
age=metadata['age'],
|
||||
size=metadata['size'],
|
||||
gender=metadata['gender'],
|
||||
breed=metadata['breed'],
|
||||
city=metadata.get('city', ''),
|
||||
state=metadata.get('state', ''),
|
||||
zip_code=metadata.get('zip_code', ''),
|
||||
latitude=float(metadata['latitude']) if metadata.get('latitude') and metadata['latitude'] != '' else None,
|
||||
longitude=float(metadata['longitude']) if metadata.get('longitude') and metadata['longitude'] != '' else None,
|
||||
organization_name=metadata['organization'],
|
||||
source=metadata['source'],
|
||||
url=metadata['url'],
|
||||
primary_photo=metadata.get('primary_photo', ''),
|
||||
description='', # Not stored in metadata
|
||||
good_with_children=metadata.get('good_with_children') == 'True' if metadata.get('good_with_children') != 'unknown' else None,
|
||||
good_with_dogs=metadata.get('good_with_dogs') == 'True' if metadata.get('good_with_dogs') != 'unknown' else None,
|
||||
good_with_cats=metadata.get('good_with_cats') == 'True' if metadata.get('good_with_cats') != 'unknown' else None,
|
||||
special_needs=metadata.get('special_needs') == 'True',
|
||||
)
|
||||
|
||||
results.append((cat, vector_similarity, metadata))
|
||||
|
||||
return results
|
||||
|
||||
def _create_explanation(self, cat: Cat, match_score: float, vector_sim: float, attr_score: float, matching_attrs: List[str]) -> str:
|
||||
"""
|
||||
Create human-readable explanation of match.
|
||||
|
||||
Args:
|
||||
cat: Matched cat
|
||||
match_score: Overall match score
|
||||
vector_sim: Vector similarity score
|
||||
attr_score: Attribute match score
|
||||
matching_attrs: List of matching attributes
|
||||
|
||||
Returns:
|
||||
Explanation string
|
||||
"""
|
||||
explanation_parts = []
|
||||
|
||||
# Overall match quality
|
||||
if match_score >= 0.8:
|
||||
explanation_parts.append(f"{cat.name} is an excellent match!")
|
||||
elif match_score >= 0.6:
|
||||
explanation_parts.append(f"{cat.name} is a good match.")
|
||||
else:
|
||||
explanation_parts.append(f"{cat.name} might be a match.")
|
||||
|
||||
# Personality match
|
||||
if vector_sim >= 0.7:
|
||||
explanation_parts.append("Personality description strongly matches your preferences.")
|
||||
elif vector_sim >= 0.5:
|
||||
explanation_parts.append("Personality description aligns with your preferences.")
|
||||
|
||||
# Matching attributes
|
||||
if matching_attrs:
|
||||
top_matches = matching_attrs[:3] # Show top 3
|
||||
explanation_parts.append("Matches: " + ", ".join(top_matches))
|
||||
|
||||
return " ".join(explanation_parts)
|
||||
|
||||
@timed
|
||||
def match(self, profile: CatProfile) -> List[CatMatch]:
|
||||
"""
|
||||
Find cats that match the user's profile using hybrid search.
|
||||
|
||||
Strategy:
|
||||
1. Vector search for semantic similarity (top N)
|
||||
2. Filter by hard constraints (metadata)
|
||||
3. Rank by weighted combination of semantic + attribute scores
|
||||
4. Return top matches with explanations
|
||||
|
||||
Args:
|
||||
profile: User's cat profile
|
||||
|
||||
Returns:
|
||||
List of CatMatch objects, sorted by match score
|
||||
"""
|
||||
self.log(f"Starting hybrid search with profile: {profile.personality_description[:50]}...")
|
||||
|
||||
# Step 1: Vector search
|
||||
query = profile.personality_description or "friendly, loving cat"
|
||||
where_clause = self._apply_metadata_filters(profile)
|
||||
|
||||
self.log(f"Vector search for top {self.vector_top_n} semantic matches")
|
||||
if where_clause:
|
||||
self.log(f"Applying metadata filters: {where_clause}")
|
||||
|
||||
results = self.vector_db.search(
|
||||
query=query,
|
||||
n_results=self.vector_top_n,
|
||||
where=where_clause
|
||||
)
|
||||
|
||||
if not results['ids'][0]:
|
||||
self.log("No results found matching criteria")
|
||||
return []
|
||||
|
||||
self.log(f"Vector search returned {len(results['ids'][0])} candidates")
|
||||
|
||||
# Step 2: Filter by distance (if applicable)
|
||||
candidates = self._filter_by_distance(results, profile)
|
||||
|
||||
# Step 3: Calculate attribute scores and rank
|
||||
self.log("Calculating attribute match scores and ranking")
|
||||
matches = []
|
||||
|
||||
for cat, vector_similarity, metadata in candidates:
|
||||
# Calculate attribute match score
|
||||
attr_score, matching_attrs, missing_attrs = self._calculate_attribute_match_score(cat, profile)
|
||||
|
||||
# Calculate weighted final score
|
||||
final_score = (
|
||||
self.semantic_weight * vector_similarity +
|
||||
self.attribute_weight * attr_score
|
||||
)
|
||||
|
||||
# Create explanation
|
||||
explanation = self._create_explanation(cat, final_score, vector_similarity, attr_score, matching_attrs)
|
||||
|
||||
# Create match object
|
||||
match = CatMatch(
|
||||
cat=cat,
|
||||
match_score=final_score,
|
||||
vector_similarity=vector_similarity,
|
||||
attribute_match_score=attr_score,
|
||||
explanation=explanation,
|
||||
matching_attributes=matching_attrs,
|
||||
missing_attributes=missing_attrs
|
||||
)
|
||||
|
||||
matches.append(match)
|
||||
|
||||
# Sort by match score
|
||||
matches.sort(key=lambda m: m.match_score, reverse=True)
|
||||
|
||||
# Return top matches
|
||||
top_matches = matches[:self.final_limit]
|
||||
|
||||
self.log(f"Returning top {len(top_matches)} matches")
|
||||
if top_matches:
|
||||
self.log(f"Best match: {top_matches[0].cat.name} (score: {top_matches[0].match_score:.2f})")
|
||||
|
||||
return top_matches
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
"""Petfinder API agent for fetching cat adoption listings."""
|
||||
|
||||
import os
|
||||
import time
|
||||
import requests
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional, Dict, Any
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from models.cats import Cat
|
||||
from .agent import Agent, timed
|
||||
|
||||
|
||||
class PetfinderAgent(Agent):
|
||||
"""Agent for interacting with Petfinder API v2."""
|
||||
|
||||
name = "Petfinder Agent"
|
||||
color = Agent.CYAN
|
||||
|
||||
BASE_URL = "https://api.petfinder.com/v2"
|
||||
TOKEN_URL = f"{BASE_URL}/oauth2/token"
|
||||
ANIMALS_URL = f"{BASE_URL}/animals"
|
||||
TYPES_URL = f"{BASE_URL}/types"
|
||||
|
||||
# Rate limiting
|
||||
MAX_REQUESTS_PER_SECOND = 1
|
||||
MAX_RESULTS_PER_PAGE = 100
|
||||
MAX_TOTAL_RESULTS = 1000
|
||||
|
||||
# Cache for valid colors and breeds (populated on first use)
|
||||
_valid_colors_cache: Optional[List[str]] = None
|
||||
_valid_breeds_cache: Optional[List[str]] = None
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the Petfinder agent with API credentials."""
|
||||
load_dotenv()
|
||||
|
||||
self.api_key = os.getenv('PETFINDER_API_KEY')
|
||||
self.api_secret = os.getenv('PETFINDER_SECRET')
|
||||
|
||||
if not self.api_key or not self.api_secret:
|
||||
raise ValueError("PETFINDER_API_KEY and PETFINDER_SECRET must be set in environment")
|
||||
|
||||
self.access_token: Optional[str] = None
|
||||
self.token_expires_at: Optional[datetime] = None
|
||||
self.last_request_time: float = 0
|
||||
|
||||
self.log("Petfinder Agent initialized")
|
||||
|
||||
def get_valid_colors(self) -> List[str]:
|
||||
"""
|
||||
Fetch valid colors for cats from Petfinder API.
|
||||
|
||||
Returns:
|
||||
List of valid color strings accepted by the API
|
||||
"""
|
||||
# Use class-level cache
|
||||
if PetfinderAgent._valid_colors_cache is not None:
|
||||
return PetfinderAgent._valid_colors_cache
|
||||
|
||||
try:
|
||||
self.log("Fetching valid cat colors from Petfinder API...")
|
||||
url = f"{self.TYPES_URL}/cat"
|
||||
token = self._get_access_token()
|
||||
headers = {'Authorization': f'Bearer {token}'}
|
||||
|
||||
response = requests.get(url, headers=headers, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
colors = data.get('type', {}).get('colors', [])
|
||||
|
||||
# Cache the results
|
||||
PetfinderAgent._valid_colors_cache = colors
|
||||
|
||||
self.log(f"✓ Fetched {len(colors)} valid colors from Petfinder")
|
||||
self.log(f"Valid colors: {', '.join(colors[:10])}...")
|
||||
|
||||
return colors
|
||||
except Exception as e:
|
||||
self.log_error(f"Failed to fetch valid colors: {e}")
|
||||
# Return common colors as fallback
|
||||
fallback = ["Black", "White", "Orange", "Gray", "Brown", "Cream", "Tabby"]
|
||||
self.log(f"Using fallback colors: {fallback}")
|
||||
return fallback
|
||||
|
||||
def get_valid_breeds(self) -> List[str]:
|
||||
"""
|
||||
Fetch valid cat breeds from Petfinder API.
|
||||
|
||||
Returns:
|
||||
List of valid breed strings accepted by the API
|
||||
"""
|
||||
# Use class-level cache
|
||||
if PetfinderAgent._valid_breeds_cache is not None:
|
||||
return PetfinderAgent._valid_breeds_cache
|
||||
|
||||
try:
|
||||
self.log("Fetching valid cat breeds from Petfinder API...")
|
||||
url = f"{self.TYPES_URL}/cat/breeds"
|
||||
token = self._get_access_token()
|
||||
headers = {'Authorization': f'Bearer {token}'}
|
||||
|
||||
response = requests.get(url, headers=headers, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
breeds = [breed['name'] for breed in data.get('breeds', [])]
|
||||
|
||||
# Cache the results
|
||||
PetfinderAgent._valid_breeds_cache = breeds
|
||||
|
||||
self.log(f"✓ Fetched {len(breeds)} valid breeds from Petfinder")
|
||||
|
||||
return breeds
|
||||
except Exception as e:
|
||||
self.log_error(f"Failed to fetch valid breeds: {e}")
|
||||
# Return common breeds as fallback
|
||||
fallback = ["Domestic Short Hair", "Domestic Medium Hair", "Domestic Long Hair", "Siamese", "Persian", "Maine Coon"]
|
||||
self.log(f"Using fallback breeds: {fallback}")
|
||||
return fallback
|
||||
|
||||
def _rate_limit(self) -> None:
|
||||
"""Implement rate limiting to respect API limits."""
|
||||
elapsed = time.time() - self.last_request_time
|
||||
min_interval = 1.0 / self.MAX_REQUESTS_PER_SECOND
|
||||
|
||||
if elapsed < min_interval:
|
||||
time.sleep(min_interval - elapsed)
|
||||
|
||||
self.last_request_time = time.time()
|
||||
|
||||
def _get_access_token(self) -> str:
|
||||
"""
|
||||
Get or refresh the OAuth access token.
|
||||
|
||||
Returns:
|
||||
Access token string
|
||||
"""
|
||||
# Check if we have a valid token
|
||||
if self.access_token and self.token_expires_at:
|
||||
if datetime.now() < self.token_expires_at:
|
||||
return self.access_token
|
||||
|
||||
# Request new token
|
||||
self.log("Requesting new access token from Petfinder")
|
||||
|
||||
data = {
|
||||
'grant_type': 'client_credentials',
|
||||
'client_id': self.api_key,
|
||||
'client_secret': self.api_secret
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(self.TOKEN_URL, data=data, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
token_data = response.json()
|
||||
self.access_token = token_data['access_token']
|
||||
|
||||
# Set expiration (subtract 60 seconds for safety)
|
||||
expires_in = token_data.get('expires_in', 3600)
|
||||
self.token_expires_at = datetime.now() + timedelta(seconds=expires_in - 60)
|
||||
|
||||
self.log(f"Access token obtained, expires at {self.token_expires_at}")
|
||||
return self.access_token
|
||||
|
||||
except Exception as e:
|
||||
self.log_error(f"Failed to get access token: {e}")
|
||||
raise
|
||||
|
||||
def _make_request(self, url: str, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Make an authenticated request to Petfinder API with rate limiting.
|
||||
|
||||
Args:
|
||||
url: API endpoint URL
|
||||
params: Query parameters
|
||||
|
||||
Returns:
|
||||
JSON response data
|
||||
"""
|
||||
self._rate_limit()
|
||||
|
||||
token = self._get_access_token()
|
||||
headers = {
|
||||
'Authorization': f'Bearer {token}'
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.get(url, headers=headers, params=params, timeout=10)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
except requests.exceptions.HTTPError as e:
|
||||
if e.response.status_code == 401:
|
||||
# Token might be invalid, clear it and retry once
|
||||
self.log_warning("Token invalid, refreshing and retrying")
|
||||
self.access_token = None
|
||||
token = self._get_access_token()
|
||||
headers['Authorization'] = f'Bearer {token}'
|
||||
|
||||
response = requests.get(url, headers=headers, params=params, timeout=10)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
else:
|
||||
raise
|
||||
|
||||
def _parse_cat(self, animal_data: Dict[str, Any]) -> Cat:
|
||||
"""
|
||||
Parse Petfinder API animal data into Cat model.
|
||||
|
||||
Args:
|
||||
animal_data: Animal data from Petfinder API
|
||||
|
||||
Returns:
|
||||
Cat object
|
||||
"""
|
||||
# Basic info
|
||||
cat_id = f"petfinder_{animal_data['id']}"
|
||||
name = animal_data.get('name', 'Unknown')
|
||||
|
||||
# Breed info
|
||||
breeds = animal_data.get('breeds', {})
|
||||
primary_breed = breeds.get('primary', 'Unknown')
|
||||
secondary_breed = breeds.get('secondary')
|
||||
secondary_breeds = [secondary_breed] if secondary_breed else []
|
||||
|
||||
# Age mapping
|
||||
age_map = {
|
||||
'Baby': 'kitten',
|
||||
'Young': 'young',
|
||||
'Adult': 'adult',
|
||||
'Senior': 'senior'
|
||||
}
|
||||
age = age_map.get(animal_data.get('age', 'Unknown'), 'unknown')
|
||||
|
||||
# Size mapping
|
||||
size_map = {
|
||||
'Small': 'small',
|
||||
'Medium': 'medium',
|
||||
'Large': 'large'
|
||||
}
|
||||
size = size_map.get(animal_data.get('size', 'Unknown'), 'unknown')
|
||||
|
||||
# Gender mapping
|
||||
gender_map = {
|
||||
'Male': 'male',
|
||||
'Female': 'female',
|
||||
'Unknown': 'unknown'
|
||||
}
|
||||
gender = gender_map.get(animal_data.get('gender', 'Unknown'), 'unknown')
|
||||
|
||||
# Description
|
||||
description = animal_data.get('description', '')
|
||||
if not description:
|
||||
description = f"{name} is a {age} {primary_breed} looking for a home."
|
||||
|
||||
# Location info
|
||||
contact = animal_data.get('contact', {})
|
||||
address = contact.get('address', {})
|
||||
|
||||
organization_id = animal_data.get('organization_id')
|
||||
city = address.get('city')
|
||||
state = address.get('state')
|
||||
zip_code = address.get('postcode')
|
||||
|
||||
# Attributes
|
||||
attributes = animal_data.get('attributes', {})
|
||||
environment = animal_data.get('environment', {})
|
||||
|
||||
# Photos
|
||||
photos_data = animal_data.get('photos', [])
|
||||
photos = [p['large'] or p['medium'] or p['small'] for p in photos_data if p]
|
||||
primary_photo = photos[0] if photos else None
|
||||
|
||||
# Videos
|
||||
videos_data = animal_data.get('videos', [])
|
||||
videos = [v.get('embed') for v in videos_data if v.get('embed')]
|
||||
|
||||
# Contact info
|
||||
contact_email = contact.get('email')
|
||||
contact_phone = contact.get('phone')
|
||||
|
||||
# Colors
|
||||
colors_data = animal_data.get('colors', {})
|
||||
colors = [c for c in [colors_data.get('primary'), colors_data.get('secondary'), colors_data.get('tertiary')] if c]
|
||||
|
||||
# Coat length
|
||||
coat = animal_data.get('coat')
|
||||
coat_map = {
|
||||
'Short': 'short',
|
||||
'Medium': 'medium',
|
||||
'Long': 'long'
|
||||
}
|
||||
coat_length = coat_map.get(coat) if coat else None
|
||||
|
||||
# URL
|
||||
url = animal_data.get('url', f"https://www.petfinder.com/cat/{animal_data['id']}")
|
||||
|
||||
return Cat(
|
||||
id=cat_id,
|
||||
name=name,
|
||||
breed=primary_breed,
|
||||
breeds_secondary=secondary_breeds,
|
||||
age=age,
|
||||
size=size,
|
||||
gender=gender,
|
||||
description=description,
|
||||
organization_name=animal_data.get('organization_id', 'Unknown Organization'),
|
||||
organization_id=organization_id,
|
||||
city=city,
|
||||
state=state,
|
||||
zip_code=zip_code,
|
||||
country='US',
|
||||
distance=animal_data.get('distance'),
|
||||
good_with_children=environment.get('children'),
|
||||
good_with_dogs=environment.get('dogs'),
|
||||
good_with_cats=environment.get('cats'),
|
||||
special_needs=attributes.get('special_needs', False),
|
||||
photos=photos,
|
||||
primary_photo=primary_photo,
|
||||
videos=videos,
|
||||
source='petfinder',
|
||||
url=url,
|
||||
contact_email=contact_email,
|
||||
contact_phone=contact_phone,
|
||||
declawed=attributes.get('declawed'),
|
||||
spayed_neutered=attributes.get('spayed_neutered'),
|
||||
house_trained=attributes.get('house_trained'),
|
||||
coat_length=coat_length,
|
||||
colors=colors,
|
||||
fetched_at=datetime.now()
|
||||
)
|
||||
|
||||
@timed
|
||||
def search_cats(
|
||||
self,
|
||||
location: Optional[str] = None,
|
||||
distance: int = 100,
|
||||
age: Optional[List[str]] = None,
|
||||
size: Optional[List[str]] = None,
|
||||
gender: Optional[str] = None,
|
||||
color: Optional[List[str]] = None,
|
||||
breed: Optional[List[str]] = None,
|
||||
good_with_children: Optional[bool] = None,
|
||||
good_with_dogs: Optional[bool] = None,
|
||||
good_with_cats: Optional[bool] = None,
|
||||
limit: int = 100
|
||||
) -> List[Cat]:
|
||||
"""
|
||||
Search for cats on Petfinder.
|
||||
|
||||
Args:
|
||||
location: ZIP code or "city, state" (e.g., "10001" or "New York, NY")
|
||||
distance: Search radius in miles (default: 100)
|
||||
age: List of age categories: baby, young, adult, senior
|
||||
size: List of sizes: small, medium, large
|
||||
gender: Gender filter: male, female
|
||||
color: List of colors (e.g., ["black", "white", "tuxedo"])
|
||||
breed: List of breed names (e.g., ["Siamese", "Maine Coon"])
|
||||
good_with_children: Filter for cats good with children
|
||||
good_with_dogs: Filter for cats good with dogs
|
||||
good_with_cats: Filter for cats good with other cats
|
||||
limit: Maximum number of results (default: 100, max: 1000)
|
||||
|
||||
Returns:
|
||||
List of Cat objects
|
||||
"""
|
||||
color_str = f" with colors {color}" if color else ""
|
||||
self.log(f"Searching for cats near {location} within {distance} miles{color_str}")
|
||||
|
||||
# Build query parameters
|
||||
params: Dict[str, Any] = {
|
||||
'type': 'cat',
|
||||
'limit': min(self.MAX_RESULTS_PER_PAGE, limit),
|
||||
'sort': 'recent'
|
||||
}
|
||||
|
||||
self.log(f"DEBUG: Initial params: {params}")
|
||||
|
||||
if location:
|
||||
params['location'] = location
|
||||
params['distance'] = distance
|
||||
|
||||
if age:
|
||||
# Map our age categories to Petfinder's
|
||||
age_map = {
|
||||
'kitten': 'baby',
|
||||
'young': 'young',
|
||||
'adult': 'adult',
|
||||
'senior': 'senior'
|
||||
}
|
||||
petfinder_ages = [age_map.get(a, a) for a in age]
|
||||
params['age'] = ','.join(petfinder_ages)
|
||||
|
||||
if size:
|
||||
params['size'] = ','.join(size)
|
||||
|
||||
if gender:
|
||||
params['gender'] = gender
|
||||
|
||||
if color:
|
||||
params['color'] = ','.join(color)
|
||||
|
||||
if breed:
|
||||
params['breed'] = ','.join(breed)
|
||||
|
||||
if good_with_children is not None:
|
||||
params['good_with_children'] = str(good_with_children).lower()
|
||||
|
||||
if good_with_dogs is not None:
|
||||
params['good_with_dogs'] = str(good_with_dogs).lower()
|
||||
|
||||
if good_with_cats is not None:
|
||||
params['good_with_cats'] = str(good_with_cats).lower()
|
||||
|
||||
self.log(f"DEBUG: ====== PETFINDER API CALL ======")
|
||||
self.log(f"DEBUG: Final API params: {params}")
|
||||
self.log(f"DEBUG: ================================")
|
||||
|
||||
# Fetch results with pagination
|
||||
cats = []
|
||||
page = 1
|
||||
total_pages = 1
|
||||
|
||||
while page <= total_pages and len(cats) < min(limit, self.MAX_TOTAL_RESULTS):
|
||||
params['page'] = page
|
||||
|
||||
try:
|
||||
data = self._make_request(self.ANIMALS_URL, params)
|
||||
|
||||
self.log(f"DEBUG: API Response - Total results: {data.get('pagination', {}).get('total_count', 'unknown')}")
|
||||
self.log(f"DEBUG: API Response - Animals in this page: {len(data.get('animals', []))}")
|
||||
|
||||
# Parse animals
|
||||
animals = data.get('animals', [])
|
||||
for animal_data in animals:
|
||||
try:
|
||||
cat = self._parse_cat(animal_data)
|
||||
cats.append(cat)
|
||||
except Exception as e:
|
||||
self.log_warning(f"Failed to parse cat {animal_data.get('id')}: {e}")
|
||||
|
||||
# Check pagination
|
||||
pagination = data.get('pagination', {})
|
||||
total_pages = pagination.get('total_pages', 1)
|
||||
|
||||
self.log(f"Fetched page {page}/{total_pages}, {len(animals)} cats")
|
||||
|
||||
page += 1
|
||||
|
||||
except Exception as e:
|
||||
self.log_error(f"Failed to fetch page {page}: {e}")
|
||||
break
|
||||
|
||||
self.log(f"Search complete: found {len(cats)} cats")
|
||||
return cats[:limit] # Ensure we don't exceed limit
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
"""Planning agent for orchestrating the cat adoption search pipeline."""
|
||||
|
||||
import threading
|
||||
from typing import List
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
from models.cats import Cat, CatProfile, CatMatch, SearchResult
|
||||
from database.manager import DatabaseManager
|
||||
from setup_vectordb import VectorDBManager
|
||||
from setup_metadata_vectordb import MetadataVectorDB
|
||||
from .agent import Agent, timed
|
||||
from .petfinder_agent import PetfinderAgent
|
||||
from .rescuegroups_agent import RescueGroupsAgent
|
||||
from .deduplication_agent import DeduplicationAgent
|
||||
from .matching_agent import MatchingAgent
|
||||
|
||||
|
||||
class PlanningAgent(Agent):
|
||||
"""Agent for orchestrating the complete cat adoption search pipeline."""
|
||||
|
||||
name = "Planning Agent"
|
||||
color = Agent.WHITE
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db_manager: DatabaseManager,
|
||||
vector_db: VectorDBManager,
|
||||
metadata_vectordb: MetadataVectorDB = None
|
||||
):
|
||||
"""
|
||||
Initialize the planning agent.
|
||||
|
||||
Args:
|
||||
db_manager: Database manager instance
|
||||
vector_db: Vector database manager instance
|
||||
metadata_vectordb: Optional metadata vector DB for color/breed fuzzy matching
|
||||
"""
|
||||
self.log("Planning Agent initializing...")
|
||||
|
||||
# Initialize all agents
|
||||
self.petfinder = PetfinderAgent()
|
||||
self.rescuegroups = RescueGroupsAgent()
|
||||
self.deduplication = DeduplicationAgent(db_manager)
|
||||
self.matching = MatchingAgent(vector_db)
|
||||
|
||||
self.db_manager = db_manager
|
||||
self.vector_db = vector_db
|
||||
self.metadata_vectordb = metadata_vectordb
|
||||
|
||||
self.log("Planning Agent ready")
|
||||
|
||||
def _search_petfinder(self, profile: CatProfile) -> List[Cat]:
|
||||
"""
|
||||
Search Petfinder with the given profile.
|
||||
|
||||
Args:
|
||||
profile: User's cat profile
|
||||
|
||||
Returns:
|
||||
List of cats from Petfinder
|
||||
"""
|
||||
try:
|
||||
# Normalize colors to valid Petfinder API values (3-tier: dict + vector + fallback)
|
||||
api_colors = None
|
||||
if profile.color_preferences:
|
||||
from utils.color_mapping import normalize_user_colors
|
||||
valid_colors = self.petfinder.get_valid_colors()
|
||||
api_colors = normalize_user_colors(
|
||||
profile.color_preferences,
|
||||
valid_colors,
|
||||
vectordb=self.metadata_vectordb,
|
||||
source="petfinder"
|
||||
)
|
||||
|
||||
if api_colors:
|
||||
self.log(f"✓ Colors: {profile.color_preferences} → {api_colors}")
|
||||
else:
|
||||
self.log(f"⚠️ Could not map colors {profile.color_preferences}")
|
||||
|
||||
# Normalize breeds to valid Petfinder API values (3-tier: dict + vector + fallback)
|
||||
api_breeds = None
|
||||
if profile.preferred_breeds:
|
||||
from utils.breed_mapping import normalize_user_breeds
|
||||
valid_breeds = self.petfinder.get_valid_breeds()
|
||||
api_breeds = normalize_user_breeds(
|
||||
profile.preferred_breeds,
|
||||
valid_breeds,
|
||||
vectordb=self.metadata_vectordb,
|
||||
source="petfinder"
|
||||
)
|
||||
|
||||
if api_breeds:
|
||||
self.log(f"✓ Breeds: {profile.preferred_breeds} → {api_breeds}")
|
||||
else:
|
||||
self.log(f"⚠️ Could not map breeds {profile.preferred_breeds}")
|
||||
|
||||
return self.petfinder.search_cats(
|
||||
location=profile.user_location,
|
||||
distance=profile.max_distance or 100,
|
||||
age=profile.age_range,
|
||||
size=profile.size,
|
||||
gender=profile.gender_preference,
|
||||
color=api_colors,
|
||||
breed=api_breeds,
|
||||
good_with_children=profile.good_with_children,
|
||||
good_with_dogs=profile.good_with_dogs,
|
||||
good_with_cats=profile.good_with_cats,
|
||||
limit=100
|
||||
)
|
||||
except Exception as e:
|
||||
self.log_error(f"Petfinder search failed: {e}")
|
||||
return []
|
||||
|
||||
def _search_rescuegroups(self, profile: CatProfile) -> List[Cat]:
|
||||
"""
|
||||
Search RescueGroups with the given profile.
|
||||
|
||||
Args:
|
||||
profile: User's cat profile
|
||||
|
||||
Returns:
|
||||
List of cats from RescueGroups
|
||||
"""
|
||||
try:
|
||||
# Normalize colors to valid RescueGroups API values (3-tier: dict + vector + fallback)
|
||||
api_colors = None
|
||||
if profile.color_preferences:
|
||||
from utils.color_mapping import normalize_user_colors
|
||||
valid_colors = self.rescuegroups.get_valid_colors()
|
||||
api_colors = normalize_user_colors(
|
||||
profile.color_preferences,
|
||||
valid_colors,
|
||||
vectordb=self.metadata_vectordb,
|
||||
source="rescuegroups"
|
||||
)
|
||||
|
||||
if api_colors:
|
||||
self.log(f"✓ Colors: {profile.color_preferences} → {api_colors}")
|
||||
else:
|
||||
self.log(f"⚠️ Could not map colors {profile.color_preferences}")
|
||||
|
||||
# Normalize breeds to valid RescueGroups API values (3-tier: dict + vector + fallback)
|
||||
api_breeds = None
|
||||
if profile.preferred_breeds:
|
||||
from utils.breed_mapping import normalize_user_breeds
|
||||
valid_breeds = self.rescuegroups.get_valid_breeds()
|
||||
api_breeds = normalize_user_breeds(
|
||||
profile.preferred_breeds,
|
||||
valid_breeds,
|
||||
vectordb=self.metadata_vectordb,
|
||||
source="rescuegroups"
|
||||
)
|
||||
|
||||
if api_breeds:
|
||||
self.log(f"✓ Breeds: {profile.preferred_breeds} → {api_breeds}")
|
||||
else:
|
||||
self.log(f"⚠️ Could not map breeds {profile.preferred_breeds}")
|
||||
|
||||
return self.rescuegroups.search_cats(
|
||||
location=profile.user_location,
|
||||
distance=profile.max_distance or 100,
|
||||
age=profile.age_range,
|
||||
size=profile.size,
|
||||
gender=profile.gender_preference,
|
||||
color=api_colors,
|
||||
breed=api_breeds,
|
||||
good_with_children=profile.good_with_children,
|
||||
good_with_dogs=profile.good_with_dogs,
|
||||
good_with_cats=profile.good_with_cats,
|
||||
limit=100
|
||||
)
|
||||
except Exception as e:
|
||||
self.log_error(f"RescueGroups search failed: {e}")
|
||||
return []
|
||||
|
||||
@timed
|
||||
def fetch_cats(self, profile: CatProfile) -> List[Cat]:
|
||||
"""
|
||||
Fetch cats from all sources in parallel.
|
||||
|
||||
Args:
|
||||
profile: User's cat profile
|
||||
|
||||
Returns:
|
||||
Combined list of cats from all sources
|
||||
"""
|
||||
self.log("Fetching cats from all sources in parallel...")
|
||||
self.log(f"DEBUG: Profile location={profile.user_location}, distance={profile.max_distance}, colors={profile.color_preferences}, age={profile.age_range}")
|
||||
|
||||
all_cats = []
|
||||
sources_queried = []
|
||||
|
||||
# Execute searches in parallel
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
futures = {
|
||||
executor.submit(self._search_petfinder, profile): 'petfinder',
|
||||
executor.submit(self._search_rescuegroups, profile): 'rescuegroups'
|
||||
}
|
||||
|
||||
for future in as_completed(futures):
|
||||
source = futures[future]
|
||||
try:
|
||||
cats = future.result()
|
||||
all_cats.extend(cats)
|
||||
sources_queried.append(source)
|
||||
self.log(f"DEBUG: ✓ Received {len(cats)} cats from {source}")
|
||||
except Exception as e:
|
||||
self.log_error(f"Failed to fetch from {source}: {e}")
|
||||
|
||||
self.log(f"DEBUG: Total cats fetched: {len(all_cats)} from {len(sources_queried)} sources")
|
||||
return all_cats, sources_queried
|
||||
|
||||
@timed
|
||||
def deduplicate_and_cache(self, cats: List[Cat]) -> List[Cat]:
|
||||
"""
|
||||
Deduplicate cats and cache them in the database.
|
||||
|
||||
Args:
|
||||
cats: List of cats to process
|
||||
|
||||
Returns:
|
||||
List of unique cats
|
||||
"""
|
||||
self.log(f"Deduplicating {len(cats)} cats...")
|
||||
|
||||
unique_cats = self.deduplication.deduplicate_batch(cats)
|
||||
|
||||
self.log(f"Deduplication complete: {len(unique_cats)} unique cats")
|
||||
return unique_cats
|
||||
|
||||
@timed
|
||||
def update_vector_db(self, cats: List[Cat]) -> None:
|
||||
"""
|
||||
Update vector database with new cats.
|
||||
|
||||
Args:
|
||||
cats: List of cats to add/update
|
||||
"""
|
||||
self.log(f"Updating vector database with {len(cats)} cats...")
|
||||
|
||||
try:
|
||||
self.vector_db.add_cats_batch(cats)
|
||||
self.log("Vector database updated successfully")
|
||||
except Exception as e:
|
||||
self.log_error(f"Failed to update vector database: {e}")
|
||||
|
||||
@timed
|
||||
def search(self, profile: CatProfile, use_cache: bool = False) -> SearchResult:
|
||||
"""
|
||||
Execute the complete search pipeline.
|
||||
|
||||
Pipeline:
|
||||
1. Fetch cats from Petfinder and RescueGroups in parallel (or use cache)
|
||||
2. Deduplicate across sources and cache in database
|
||||
3. Update vector database with new/updated cats
|
||||
4. Use matching agent to find best matches
|
||||
5. Return search results
|
||||
|
||||
Args:
|
||||
profile: User's cat profile
|
||||
use_cache: If True, use cached cats instead of fetching from APIs
|
||||
|
||||
Returns:
|
||||
SearchResult with matches and metadata
|
||||
"""
|
||||
import time
|
||||
start_time = time.time()
|
||||
|
||||
self.log("=" * 50)
|
||||
self.log("STARTING CAT ADOPTION SEARCH PIPELINE")
|
||||
if use_cache:
|
||||
self.log("🔄 CACHE MODE: Using existing cached data")
|
||||
self.log("=" * 50)
|
||||
|
||||
# Step 1: Fetch from sources or use cache
|
||||
if use_cache:
|
||||
self.log("Loading cats from cache...")
|
||||
all_cats = self.db_manager.get_all_cached_cats(exclude_duplicates=True)
|
||||
sources_queried = ['cache']
|
||||
total_found = len(all_cats)
|
||||
unique_cats = all_cats
|
||||
duplicates_removed = 0
|
||||
|
||||
if not all_cats:
|
||||
self.log("No cached cats found. Run without use_cache=True first.")
|
||||
return SearchResult(
|
||||
matches=[],
|
||||
total_found=0,
|
||||
search_profile=profile,
|
||||
search_time=time.time() - start_time,
|
||||
sources_queried=['cache'],
|
||||
duplicates_removed=0
|
||||
)
|
||||
|
||||
self.log(f"Loaded {len(all_cats)} cats from cache")
|
||||
else:
|
||||
all_cats, sources_queried = self.fetch_cats(profile)
|
||||
total_found = len(all_cats)
|
||||
|
||||
if not all_cats:
|
||||
self.log("No cats found matching criteria")
|
||||
return SearchResult(
|
||||
matches=[],
|
||||
total_found=0,
|
||||
search_profile=profile,
|
||||
search_time=time.time() - start_time,
|
||||
sources_queried=sources_queried,
|
||||
duplicates_removed=0
|
||||
)
|
||||
|
||||
# Step 2: Deduplicate and cache
|
||||
unique_cats = self.deduplicate_and_cache(all_cats)
|
||||
duplicates_removed = total_found - len(unique_cats)
|
||||
|
||||
# Step 3: Update vector database
|
||||
self.update_vector_db(unique_cats)
|
||||
|
||||
# Step 4: Find matches using hybrid search
|
||||
self.log("Finding best matches using hybrid search...")
|
||||
matches = self.matching.match(profile)
|
||||
|
||||
# Calculate search time
|
||||
search_time = time.time() - start_time
|
||||
|
||||
# Create result
|
||||
result = SearchResult(
|
||||
matches=matches,
|
||||
total_found=total_found,
|
||||
search_profile=profile,
|
||||
search_time=search_time,
|
||||
sources_queried=sources_queried,
|
||||
duplicates_removed=duplicates_removed
|
||||
)
|
||||
|
||||
self.log("=" * 50)
|
||||
self.log(f"SEARCH COMPLETE - Found {len(matches)} matches in {search_time:.2f}s")
|
||||
self.log("=" * 50)
|
||||
|
||||
return result
|
||||
|
||||
def cleanup_old_data(self, days: int = 30) -> dict:
|
||||
"""
|
||||
Clean up old cached data.
|
||||
|
||||
Args:
|
||||
days: Number of days to keep
|
||||
|
||||
Returns:
|
||||
Dictionary with cleanup stats
|
||||
"""
|
||||
self.log(f"Cleaning up cats older than {days} days...")
|
||||
|
||||
# Clean SQLite cache
|
||||
removed = self.db_manager.cleanup_old_cats(days)
|
||||
|
||||
# Note: ChromaDB cleanup would require tracking IDs separately
|
||||
# For now, we rely on the database as source of truth
|
||||
|
||||
self.log(f"Cleanup complete: removed {removed} old cats")
|
||||
|
||||
return {
|
||||
'cats_removed': removed,
|
||||
'days_threshold': days
|
||||
}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Profile agent for extracting user preferences using LLM."""
|
||||
|
||||
import os
|
||||
from typing import List, Optional
|
||||
from openai import OpenAI
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from models.cats import CatProfile
|
||||
from utils.geocoding import parse_location_input
|
||||
from .agent import Agent
|
||||
|
||||
|
||||
class ProfileAgent(Agent):
|
||||
"""Agent for extracting cat adoption preferences from user conversation."""
|
||||
|
||||
name = "Profile Agent"
|
||||
color = Agent.GREEN
|
||||
|
||||
MODEL = "gpt-4o-mini"
|
||||
|
||||
SYSTEM_PROMPT = """You are a helpful assistant helping users find their perfect cat for adoption.
|
||||
|
||||
Your job is to extract their preferences through natural conversation and return them in structured format.
|
||||
|
||||
Ask about:
|
||||
- Color and coat patterns (e.g., tuxedo/black&white, tabby, orange, calico, tortoiseshell, gray, etc.)
|
||||
- Personality traits they're looking for (playful, calm, cuddly, independent, etc.)
|
||||
- Age preference (kitten, young adult, adult, senior)
|
||||
- Size preference (small, medium, large)
|
||||
- Living situation (children, dogs, other cats)
|
||||
- Special needs acceptance
|
||||
- Location and max distance willing to travel
|
||||
- Gender preference (if any)
|
||||
- Breed preferences (if any)
|
||||
|
||||
IMPORTANT: When users mention colors or patterns (like "tuxedo", "black and white", "orange tabby", etc.),
|
||||
extract these into the color_preferences field exactly as the user states them. Examples:
|
||||
- "tuxedo" → ["tuxedo"]
|
||||
- "black and white" → ["black and white"]
|
||||
- "orange tabby" → ["orange", "tabby"]
|
||||
- "calico" → ["calico"]
|
||||
- "gray" or "grey" → ["gray"]
|
||||
|
||||
Extract colors/patterns naturally without trying to map to specific API values.
|
||||
|
||||
Be conversational and warm. Ask follow-up questions if preferences are unclear.
|
||||
When you have enough information, extract it into the CatProfile format."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the profile agent."""
|
||||
load_dotenv()
|
||||
|
||||
self.api_key = os.getenv('OPENAI_API_KEY')
|
||||
if not self.api_key:
|
||||
raise ValueError("OPENAI_API_KEY must be set in environment")
|
||||
|
||||
self.client = OpenAI(api_key=self.api_key)
|
||||
|
||||
self.log("Profile Agent initialized")
|
||||
|
||||
def extract_profile(self, conversation: List[dict]) -> Optional[CatProfile]:
|
||||
"""
|
||||
Extract CatProfile from conversation history.
|
||||
|
||||
Args:
|
||||
conversation: List of message dicts with 'role' and 'content'
|
||||
|
||||
Returns:
|
||||
CatProfile object or None if extraction fails
|
||||
"""
|
||||
self.log("Extracting profile from conversation")
|
||||
|
||||
# Add system message
|
||||
messages = [{"role": "system", "content": self.SYSTEM_PROMPT}]
|
||||
messages.extend(conversation)
|
||||
|
||||
# Add extraction prompt
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": "Please extract my preferences into a structured profile now."
|
||||
})
|
||||
|
||||
try:
|
||||
response = self.client.beta.chat.completions.parse(
|
||||
model=self.MODEL,
|
||||
messages=messages,
|
||||
response_format=CatProfile
|
||||
)
|
||||
|
||||
profile = response.choices[0].message.parsed
|
||||
|
||||
# Parse location if provided
|
||||
if profile.user_location:
|
||||
coords = parse_location_input(profile.user_location)
|
||||
if coords:
|
||||
profile.user_latitude, profile.user_longitude = coords
|
||||
self.log(f"Parsed location: {profile.user_location} -> {coords}")
|
||||
else:
|
||||
self.log_warning(f"Could not parse location: {profile.user_location}")
|
||||
|
||||
self.log("Profile extracted successfully")
|
||||
return profile
|
||||
|
||||
except Exception as e:
|
||||
self.log_error(f"Failed to extract profile: {e}")
|
||||
return None
|
||||
|
||||
def chat(self, user_message: str, conversation_history: List[dict]) -> str:
|
||||
"""
|
||||
Continue conversation to gather preferences.
|
||||
|
||||
Args:
|
||||
user_message: Latest user message
|
||||
conversation_history: Previous conversation
|
||||
|
||||
Returns:
|
||||
Assistant's response
|
||||
"""
|
||||
self.log(f"Processing user message: {user_message[:50]}...")
|
||||
|
||||
# Build messages
|
||||
messages = [{"role": "system", "content": self.SYSTEM_PROMPT}]
|
||||
messages.extend(conversation_history)
|
||||
messages.append({"role": "user", "content": user_message})
|
||||
|
||||
try:
|
||||
response = self.client.chat.completions.create(
|
||||
model=self.MODEL,
|
||||
messages=messages
|
||||
)
|
||||
|
||||
assistant_message = response.choices[0].message.content
|
||||
self.log("Generated response")
|
||||
|
||||
return assistant_message
|
||||
|
||||
except Exception as e:
|
||||
self.log_error(f"Chat failed: {e}")
|
||||
return "I'm sorry, I'm having trouble right now. Could you try again?"
|
||||
|
||||
def create_profile_from_direct_input(
|
||||
self,
|
||||
location: str,
|
||||
distance: int = 100,
|
||||
personality_description: str = "",
|
||||
age_range: Optional[List[str]] = None,
|
||||
size: Optional[List[str]] = None,
|
||||
good_with_children: Optional[bool] = None,
|
||||
good_with_dogs: Optional[bool] = None,
|
||||
good_with_cats: Optional[bool] = None
|
||||
) -> CatProfile:
|
||||
"""
|
||||
Create profile directly from form inputs (bypass conversation).
|
||||
|
||||
Args:
|
||||
location: User location
|
||||
distance: Search radius in miles
|
||||
personality_description: Free text personality description
|
||||
age_range: Age preferences
|
||||
size: Size preferences
|
||||
good_with_children: Must be good with children
|
||||
good_with_dogs: Must be good with dogs
|
||||
good_with_cats: Must be good with cats
|
||||
|
||||
Returns:
|
||||
CatProfile object
|
||||
"""
|
||||
self.log("Creating profile from direct input")
|
||||
|
||||
# Parse location
|
||||
user_lat, user_lon = None, None
|
||||
coords = parse_location_input(location)
|
||||
if coords:
|
||||
user_lat, user_lon = coords
|
||||
|
||||
profile = CatProfile(
|
||||
user_location=location,
|
||||
user_latitude=user_lat,
|
||||
user_longitude=user_lon,
|
||||
max_distance=distance,
|
||||
personality_description=personality_description,
|
||||
age_range=age_range,
|
||||
size=size,
|
||||
good_with_children=good_with_children,
|
||||
good_with_dogs=good_with_dogs,
|
||||
good_with_cats=good_with_cats
|
||||
)
|
||||
|
||||
self.log("Profile created from direct input")
|
||||
return profile
|
||||
|
||||
@@ -0,0 +1,474 @@
|
||||
"""RescueGroups.org API agent for fetching cat adoption listings."""
|
||||
|
||||
import os
|
||||
import time
|
||||
import requests
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Dict, Any
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from models.cats import Cat
|
||||
from .agent import Agent, timed
|
||||
|
||||
|
||||
class RescueGroupsAgent(Agent):
|
||||
"""Agent for interacting with RescueGroups.org API."""
|
||||
|
||||
name = "RescueGroups Agent"
|
||||
color = Agent.MAGENTA
|
||||
|
||||
BASE_URL = "https://api.rescuegroups.org/v5"
|
||||
|
||||
# Rate limiting
|
||||
MAX_REQUESTS_PER_SECOND = 0.5 # Be conservative
|
||||
MAX_RESULTS_PER_PAGE = 100
|
||||
|
||||
# Cache for valid colors and breeds
|
||||
_valid_colors_cache: Optional[List[str]] = None
|
||||
_valid_breeds_cache: Optional[List[str]] = None
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the RescueGroups agent with API credentials."""
|
||||
load_dotenv()
|
||||
|
||||
self.api_key = os.getenv('RESCUEGROUPS_API_KEY')
|
||||
|
||||
if not self.api_key:
|
||||
self.log_warning("RESCUEGROUPS_API_KEY not set - agent will not function")
|
||||
self.api_key = None
|
||||
|
||||
self.last_request_time: float = 0
|
||||
|
||||
self.log("RescueGroups Agent initialized")
|
||||
|
||||
def get_valid_colors(self) -> List[str]:
|
||||
"""
|
||||
Fetch valid colors from RescueGroups API.
|
||||
|
||||
Returns:
|
||||
List of valid color strings
|
||||
"""
|
||||
if not self.api_key:
|
||||
return []
|
||||
|
||||
# Use class-level cache
|
||||
if RescueGroupsAgent._valid_colors_cache is not None:
|
||||
return RescueGroupsAgent._valid_colors_cache
|
||||
|
||||
try:
|
||||
self.log("Fetching valid cat colors from RescueGroups API...")
|
||||
|
||||
# Correct endpoint for colors
|
||||
url = f"{self.BASE_URL}/public/animals/colors"
|
||||
headers = {
|
||||
'Authorization': self.api_key,
|
||||
'Content-Type': 'application/vnd.api+json'
|
||||
}
|
||||
|
||||
# Add limit parameter to get all colors (no max limit for static data per docs)
|
||||
params = {'limit': 1000}
|
||||
|
||||
self._rate_limit()
|
||||
response = requests.get(url, headers=headers, params=params, timeout=15)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
colors = [item['attributes']['name'] for item in data.get('data', [])]
|
||||
|
||||
# Cache the results
|
||||
RescueGroupsAgent._valid_colors_cache = colors
|
||||
|
||||
self.log(f"✓ Fetched {len(colors)} valid colors from RescueGroups")
|
||||
return colors
|
||||
|
||||
except Exception as e:
|
||||
self.log_error(f"Failed to fetch valid colors: {e}")
|
||||
# Return empty list - planning agent will handle gracefully
|
||||
return []
|
||||
|
||||
def get_valid_breeds(self) -> List[str]:
|
||||
"""
|
||||
Fetch valid cat breeds from RescueGroups API.
|
||||
|
||||
Returns:
|
||||
List of valid breed strings
|
||||
"""
|
||||
if not self.api_key:
|
||||
return []
|
||||
|
||||
# Use class-level cache
|
||||
if RescueGroupsAgent._valid_breeds_cache is not None:
|
||||
return RescueGroupsAgent._valid_breeds_cache
|
||||
|
||||
try:
|
||||
self.log("Fetching valid cat breeds from RescueGroups API...")
|
||||
|
||||
# Correct endpoint for breeds
|
||||
url = f"{self.BASE_URL}/public/animals/breeds"
|
||||
headers = {
|
||||
'Authorization': self.api_key,
|
||||
'Content-Type': 'application/vnd.api+json'
|
||||
}
|
||||
|
||||
# Add limit parameter to get all breeds (no max limit for static data per docs)
|
||||
params = {'limit': 1000}
|
||||
|
||||
self._rate_limit()
|
||||
response = requests.get(url, headers=headers, params=params, timeout=15)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
breeds = [item['attributes']['name'] for item in data.get('data', [])]
|
||||
|
||||
# Cache the results
|
||||
RescueGroupsAgent._valid_breeds_cache = breeds
|
||||
|
||||
self.log(f"✓ Fetched {len(breeds)} valid breeds from RescueGroups")
|
||||
return breeds
|
||||
|
||||
except Exception as e:
|
||||
self.log_error(f"Failed to fetch valid breeds: {e}")
|
||||
# Return empty list - planning agent will handle gracefully
|
||||
return []
|
||||
|
||||
def _rate_limit(self) -> None:
|
||||
"""Implement rate limiting to respect API limits."""
|
||||
elapsed = time.time() - self.last_request_time
|
||||
min_interval = 1.0 / self.MAX_REQUESTS_PER_SECOND
|
||||
|
||||
if elapsed < min_interval:
|
||||
time.sleep(min_interval - elapsed)
|
||||
|
||||
self.last_request_time = time.time()
|
||||
|
||||
def _make_request(self, endpoint: str, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Make an authenticated POST request to RescueGroups API.
|
||||
|
||||
Args:
|
||||
endpoint: API endpoint (e.g., "/animals/search")
|
||||
data: Request payload
|
||||
|
||||
Returns:
|
||||
JSON response data
|
||||
"""
|
||||
if not self.api_key:
|
||||
raise ValueError("RescueGroups API key not configured")
|
||||
|
||||
self._rate_limit()
|
||||
|
||||
url = f"{self.BASE_URL}{endpoint}"
|
||||
headers = {
|
||||
'Authorization': self.api_key,
|
||||
'Content-Type': 'application/vnd.api+json'
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(url, json=data, headers=headers, timeout=15)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
self.log_error(f"API request failed: {e}")
|
||||
if hasattr(e, 'response') and e.response is not None:
|
||||
self.log_error(f"Response: {e.response.text[:500]}")
|
||||
raise
|
||||
|
||||
def _parse_cat(self, animal_data: Dict[str, Any]) -> Cat:
|
||||
"""
|
||||
Parse RescueGroups API animal data into Cat model.
|
||||
|
||||
Args:
|
||||
animal_data: Animal data from RescueGroups API
|
||||
|
||||
Returns:
|
||||
Cat object
|
||||
"""
|
||||
attributes = animal_data.get('attributes', {})
|
||||
|
||||
# Basic info
|
||||
cat_id = f"rescuegroups_{animal_data['id']}"
|
||||
name = attributes.get('name', 'Unknown')
|
||||
|
||||
# Breed info
|
||||
primary_breed = attributes.get('breedPrimary', 'Unknown')
|
||||
secondary_breed = attributes.get('breedSecondary')
|
||||
secondary_breeds = [secondary_breed] if secondary_breed else []
|
||||
|
||||
# Age mapping
|
||||
age_str = attributes.get('ageGroup', '').lower()
|
||||
age_map = {
|
||||
'baby': 'kitten',
|
||||
'young': 'young',
|
||||
'adult': 'adult',
|
||||
'senior': 'senior'
|
||||
}
|
||||
age = age_map.get(age_str, 'unknown')
|
||||
|
||||
# Size mapping
|
||||
size_str = attributes.get('sizeGroup', '').lower()
|
||||
size_map = {
|
||||
'small': 'small',
|
||||
'medium': 'medium',
|
||||
'large': 'large'
|
||||
}
|
||||
size = size_map.get(size_str, 'unknown')
|
||||
|
||||
# Gender mapping
|
||||
gender_str = attributes.get('sex', '').lower()
|
||||
gender_map = {
|
||||
'male': 'male',
|
||||
'female': 'female'
|
||||
}
|
||||
gender = gender_map.get(gender_str, 'unknown')
|
||||
|
||||
# Description
|
||||
description = attributes.get('descriptionText', '')
|
||||
if not description:
|
||||
description = f"{name} is a {age} {primary_breed} looking for a home."
|
||||
|
||||
# Location info
|
||||
location = attributes.get('location', {}) or {}
|
||||
city = location.get('citytown')
|
||||
state = location.get('stateProvince')
|
||||
zip_code = location.get('postalcode')
|
||||
|
||||
# Organization
|
||||
org_name = attributes.get('orgName', 'Unknown Organization')
|
||||
org_id = attributes.get('orgID')
|
||||
|
||||
# Attributes - map RescueGroups boolean fields
|
||||
good_with_children = attributes.get('isKidsGood')
|
||||
good_with_dogs = attributes.get('isDogsGood')
|
||||
good_with_cats = attributes.get('isCatsGood')
|
||||
special_needs = attributes.get('isSpecialNeeds', False)
|
||||
|
||||
# Photos
|
||||
pictures = attributes.get('pictureThumbnailUrl', [])
|
||||
if isinstance(pictures, str):
|
||||
pictures = [pictures] if pictures else []
|
||||
elif not pictures:
|
||||
pictures = []
|
||||
|
||||
photos = [pic for pic in pictures if pic]
|
||||
primary_photo = photos[0] if photos else None
|
||||
|
||||
# Contact info
|
||||
contact_email = attributes.get('emailAddress')
|
||||
contact_phone = attributes.get('phoneNumber')
|
||||
|
||||
# Colors
|
||||
color_str = attributes.get('colorDetails', '')
|
||||
colors = [c.strip() for c in color_str.split(',') if c.strip()] if color_str else []
|
||||
|
||||
# Coat
|
||||
coat_str = attributes.get('coatLength', '').lower()
|
||||
coat_map = {
|
||||
'short': 'short',
|
||||
'medium': 'medium',
|
||||
'long': 'long'
|
||||
}
|
||||
coat_length = coat_map.get(coat_str)
|
||||
|
||||
# URL
|
||||
url = attributes.get('url', f"https://rescuegroups.org/animal/{animal_data['id']}")
|
||||
|
||||
# Additional attributes
|
||||
declawed = attributes.get('isDeclawed')
|
||||
spayed_neutered = attributes.get('isAltered')
|
||||
house_trained = attributes.get('isHousetrained')
|
||||
|
||||
return Cat(
|
||||
id=cat_id,
|
||||
name=name,
|
||||
breed=primary_breed,
|
||||
breeds_secondary=secondary_breeds,
|
||||
age=age,
|
||||
size=size,
|
||||
gender=gender,
|
||||
description=description,
|
||||
organization_name=org_name,
|
||||
organization_id=org_id,
|
||||
city=city,
|
||||
state=state,
|
||||
zip_code=zip_code,
|
||||
country='US',
|
||||
good_with_children=good_with_children,
|
||||
good_with_dogs=good_with_dogs,
|
||||
good_with_cats=good_with_cats,
|
||||
special_needs=special_needs,
|
||||
photos=photos,
|
||||
primary_photo=primary_photo,
|
||||
source='rescuegroups',
|
||||
url=url,
|
||||
contact_email=contact_email,
|
||||
contact_phone=contact_phone,
|
||||
declawed=declawed,
|
||||
spayed_neutered=spayed_neutered,
|
||||
house_trained=house_trained,
|
||||
coat_length=coat_length,
|
||||
colors=colors,
|
||||
fetched_at=datetime.now()
|
||||
)
|
||||
|
||||
@timed
|
||||
def search_cats(
|
||||
self,
|
||||
location: Optional[str] = None,
|
||||
distance: int = 100,
|
||||
age: Optional[List[str]] = None,
|
||||
size: Optional[List[str]] = None,
|
||||
gender: Optional[str] = None,
|
||||
color: Optional[List[str]] = None,
|
||||
breed: Optional[List[str]] = None,
|
||||
good_with_children: Optional[bool] = None,
|
||||
good_with_dogs: Optional[bool] = None,
|
||||
good_with_cats: Optional[bool] = None,
|
||||
limit: int = 100
|
||||
) -> List[Cat]:
|
||||
"""
|
||||
Search for cats on RescueGroups.
|
||||
|
||||
Args:
|
||||
location: ZIP code or city/state
|
||||
distance: Search radius in miles (default: 100)
|
||||
age: List of age categories: kitten, young, adult, senior
|
||||
size: List of sizes: small, medium, large
|
||||
gender: Gender filter: male, female
|
||||
color: List of colors (e.g., ["black", "white", "tuxedo"])
|
||||
breed: List of breed names (e.g., ["Siamese", "Maine Coon"])
|
||||
good_with_children: Filter for cats good with children
|
||||
good_with_dogs: Filter for cats good with dogs
|
||||
good_with_cats: Filter for cats good with other cats
|
||||
limit: Maximum number of results (default: 100)
|
||||
|
||||
Returns:
|
||||
List of Cat objects
|
||||
"""
|
||||
if not self.api_key:
|
||||
self.log_warning("RescueGroups API key not configured, returning empty results")
|
||||
return []
|
||||
|
||||
color_str = f" with colors {color}" if color else ""
|
||||
breed_str = f" breeds {breed}" if breed else ""
|
||||
self.log(f"Searching RescueGroups for cats near {location}{color_str}{breed_str}")
|
||||
|
||||
self.log(f"DEBUG: RescueGroups search params - location: {location}, distance: {distance}, age: {age}, size: {size}, gender: {gender}, color: {color}, breed: {breed}")
|
||||
|
||||
# Build filter criteria
|
||||
filters = [
|
||||
{
|
||||
"fieldName": "species.singular",
|
||||
"operation": "equals",
|
||||
"criteria": "cat"
|
||||
},
|
||||
{
|
||||
"fieldName": "statuses.name",
|
||||
"operation": "equals",
|
||||
"criteria": "Available"
|
||||
}
|
||||
]
|
||||
|
||||
# Location filter - DISABLED: RescueGroups v5 API doesn't support location filtering
|
||||
# Their API returns animals from all locations, filtering must be done client-side
|
||||
if location:
|
||||
self.log(f"NOTE: RescueGroups doesn't support location filters. Will return all results.")
|
||||
|
||||
# Age filter
|
||||
if age:
|
||||
age_map = {
|
||||
'kitten': 'Baby',
|
||||
'young': 'Young',
|
||||
'adult': 'Adult',
|
||||
'senior': 'Senior'
|
||||
}
|
||||
rg_ages = [age_map.get(a, a.capitalize()) for a in age]
|
||||
for rg_age in rg_ages:
|
||||
filters.append({
|
||||
"fieldName": "animals.ageGroup",
|
||||
"operation": "equals",
|
||||
"criteria": rg_age
|
||||
})
|
||||
|
||||
# Size filter
|
||||
if size:
|
||||
size_map = {
|
||||
'small': 'Small',
|
||||
'medium': 'Medium',
|
||||
'large': 'Large'
|
||||
}
|
||||
for s in size:
|
||||
rg_size = size_map.get(s, s.capitalize())
|
||||
filters.append({
|
||||
"fieldName": "animals.sizeGroup",
|
||||
"operation": "equals",
|
||||
"criteria": rg_size
|
||||
})
|
||||
|
||||
# Gender filter
|
||||
if gender:
|
||||
filters.append({
|
||||
"fieldName": "animals.sex",
|
||||
"operation": "equals",
|
||||
"criteria": gender.capitalize()
|
||||
})
|
||||
|
||||
# Color filter - DISABLED: RescueGroups v5 API field name for color is unclear
|
||||
# Filtering by color will be done client-side with returned data
|
||||
if color:
|
||||
self.log(f"NOTE: Color filtering for RescueGroups will be done client-side: {color}")
|
||||
|
||||
# Breed filter - DISABLED: RescueGroups v5 API breed filtering is not reliable
|
||||
# Filtering by breed will be done client-side with returned data
|
||||
if breed:
|
||||
self.log(f"NOTE: Breed filtering for RescueGroups will be done client-side: {breed}")
|
||||
|
||||
# Behavioral filters - DISABLED: RescueGroups v5 API doesn't support behavioral filters
|
||||
# These fields exist in response data but cannot be used as filter criteria
|
||||
# Client-side filtering will be applied to returned results
|
||||
if good_with_children:
|
||||
self.log(f"NOTE: good_with_children filtering will be done client-side")
|
||||
|
||||
if good_with_dogs:
|
||||
self.log(f"NOTE: good_with_dogs filtering will be done client-side")
|
||||
|
||||
if good_with_cats:
|
||||
self.log(f"NOTE: good_with_cats filtering will be done client-side")
|
||||
|
||||
# Build request payload
|
||||
payload = {
|
||||
"data": {
|
||||
"filters": filters,
|
||||
"filterProcessing": "1" # AND logic
|
||||
}
|
||||
}
|
||||
|
||||
# Add pagination
|
||||
if limit:
|
||||
payload["data"]["limit"] = min(limit, self.MAX_RESULTS_PER_PAGE)
|
||||
|
||||
self.log(f"DEBUG: RescueGroups filters: {len(filters)} filters applied")
|
||||
|
||||
try:
|
||||
response = self._make_request("/public/animals/search/available/cats", payload)
|
||||
|
||||
self.log(f"DEBUG: RescueGroups API Response - Found {len(response.get('data', []))} animals")
|
||||
|
||||
# Parse response
|
||||
data = response.get('data', [])
|
||||
cats = []
|
||||
|
||||
for animal_data in data:
|
||||
try:
|
||||
cat = self._parse_cat(animal_data)
|
||||
cats.append(cat)
|
||||
except Exception as e:
|
||||
self.log_warning(f"Failed to parse cat {animal_data.get('id')}: {e}")
|
||||
|
||||
self.log(f"Search complete: found {len(cats)} cats")
|
||||
return cats
|
||||
|
||||
except Exception as e:
|
||||
self.log_error(f"Search failed: {e}")
|
||||
return []
|
||||
|
||||
Reference in New Issue
Block a user