Week8 dkisselev-zz update

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

View File

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

View File

@@ -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

View File

@@ -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']
}

View File

@@ -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

View File

@@ -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",
]

View File

@@ -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

View File

@@ -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"
)

View File

@@ -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"

View File

@@ -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)"

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View File

@@ -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

View File

@@ -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 []