"""Fuzzy card matching service.""" from typing import List, Tuple, Optional from thefuzz import fuzz class FuzzyCardMatcher: """Fuzzy matching service for card names.""" # Thresholds EXACT_MATCH_THRESHOLD = 100 AUTO_ACCEPT_THRESHOLD = 85 # Auto-accept matches above this MANUAL_REVIEW_THRESHOLD = 70 # Flag for manual review below this MIN_MATCH_THRESHOLD = 60 # Minimum similarity to consider a match @staticmethod def normalize_card_name(name: str) -> str: """ Normalize a card name for matching. Args: name: Raw card name Returns: Normalized card name """ # Remove extra whitespace normalized = ' '.join(name.split()) # Convert to lowercase for matching return normalized.lower() @staticmethod def exact_match(name: str, card_name: str) -> bool: """Check if two card names match exactly.""" return FuzzyCardMatcher.normalize_card_name(name) == FuzzyCardMatcher.normalize_card_name(card_name) @staticmethod def fuzzy_match(name: str, card_name: str) -> float: """ Calculate fuzzy match score between two card names. Args: name: First card name card_name: Second card name Returns: Similarity score between 0.0 and 100.0 """ normalized_name = FuzzyCardMatcher.normalize_card_name(name) normalized_card = FuzzyCardMatcher.normalize_card_name(card_name) return fuzz.token_sort_ratio(normalized_name, normalized_card) @staticmethod def find_best_match( card_name: str, candidate_names: List[str], threshold: float = MANUAL_REVIEW_THRESHOLD ) -> Tuple[Optional[str], float, str]: """ Find the best matching card name from candidates. Args: card_name: Name to match candidate_names: List of candidate card names threshold: Minimum similarity threshold Returns: Tuple of (matched_name, confidence, match_type) - matched_name: Best matching card name or None - confidence: Match confidence (0.0 to 1.0) - match_type: 'exact', 'high_confidence', 'low_confidence', or 'no_match' """ if not candidate_names: return None, 0.0, 'no_match' # Check for exact match first for candidate in candidate_names: if FuzzyCardMatcher.exact_match(card_name, candidate): return candidate, 1.0, 'exact' # Use fuzzy matching normalized_name = FuzzyCardMatcher.normalize_card_name(card_name) # Find best match using token sort ratio best_match = None best_score = 0.0 for candidate in candidate_names: score = fuzz.token_sort_ratio(normalized_name, FuzzyCardMatcher.normalize_card_name(candidate)) if score > best_score: best_score = score best_match = candidate if best_match and best_score >= threshold: confidence = best_score / 100.0 if best_score >= FuzzyCardMatcher.AUTO_ACCEPT_THRESHOLD: match_type = 'high_confidence' else: match_type = 'low_confidence' return best_match, confidence, match_type return None, 0.0, 'no_match' @staticmethod def batch_match( card_names: List[str], candidate_names: List[str], threshold: float = MANUAL_REVIEW_THRESHOLD ) -> List[Tuple[str, Optional[str], float, str]]: """ Perform batch fuzzy matching. Args: card_names: List of card names to match candidate_names: List of candidate card names threshold: Minimum similarity threshold Returns: List of tuples: (original_name, matched_name, confidence, match_type) """ results = [] for card_name in card_names: matched_name, confidence, match_type = FuzzyCardMatcher.find_best_match( card_name, candidate_names, threshold ) results.append((card_name, matched_name, confidence, match_type)) return results @staticmethod def batch_match_with_database( card_names: List[str], db_session, mtgonline_card_model, threshold: float = MANUAL_REVIEW_THRESHOLD ) -> List[Tuple[str, Optional[int], Optional[str], float, str]]: """ Perform batch fuzzy matching against database cards. Args: card_names: List of card names to match db_session: Database session mtgonline_card_model: MtgonlineCard ORM model threshold: Minimum similarity threshold Returns: List of tuples: (original_name, card_id, matched_name, confidence, match_type) """ from sqlalchemy import select # Fetch all cards from database stmt = select(mtgonline_card_model) result = db_session.execute(stmt) db_cards = result.scalars().all() # Build candidate list and lookup candidate_names = [card.name for card in db_cards if card.name] card_lookup = {card.name.lower(): card for card in db_cards if card.name} results = [] for card_name in card_names: matched_name, confidence, match_type = FuzzyCardMatcher.find_best_match( card_name, candidate_names, threshold ) card_id = None if matched_name and matched_name.lower() in card_lookup: card_id = card_lookup[matched_name.lower()].id results.append((card_name, card_id, matched_name, confidence, match_type)) return results