- Add card import feature with fuzzy matching - Implement deck CRUD and management endpoints - Add user data APIs for groups, networks, preferences, activity, replays - Create comprehensive API documentation (API_DOCUMENTATION.md) - Add ENDPOINT_AUDIT.md for endpoint verification - Update documentation (README, ROADMAP, state.json) - Update architecture blueprint and Cockatrice analysis - All Phase 2 deliverables complete and documented
171 lines
5.9 KiB
Python
171 lines
5.9 KiB
Python
"""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
|