Complete Phase 2: Card import, deck building, and full API
- 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
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user