Files
mtgonline/backend/scripts/interaction_pipeline.py
T

492 lines
17 KiB
Python

"""
MTG Card Interaction Pipeline
Orchestrates the full interaction determination and recommendation pipeline.
Handles initial loads and rolling updates.
"""
import json
import logging
from datetime import datetime
from typing import List, Dict, Optional, Tuple
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
from card_profile_extractor import CardProfileExtractor, CardProfile
from interaction_determinator import InteractionDeterminator, InteractionResult
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
class MTGInteractionPipeline:
"""
Main pipeline for processing card interactions.
Handles:
1. Loading cards from database
2. Extracting card profiles
3. Determining interactions (synergies, counters, evolutions)
4. Storing interactions in database
5. Updating interaction statistics
"""
def __init__(
self,
db_url: str,
min_confidence: float = 0.5,
):
"""
Initialize the pipeline.
Args:
db_url: PostgreSQL database URL
min_confidence: Minimum confidence to auto-store interactions
"""
self.db_url = db_url
self.min_confidence = min_confidence
self.engine = create_engine(db_url)
self.SessionLocal = sessionmaker(bind=self.engine)
self.profile_extractor = CardProfileExtractor()
self.determinator = InteractionDeterminator()
# Statistics
self.stats = {
'cards_processed': 0,
'interactions_determined': 0,
'interactions_stored': 0,
'interactions_review_queue': 0,
'errors': 0,
}
def load_cards_from_db(self, set_code: Optional[str] = None) -> List[Dict]:
"""
Load cards from the database.
Args:
set_code: Optional set code to filter by
Returns:
List of card dictionaries
"""
db = self.SessionLocal()
try:
if set_code:
query = text("""
SELECT c.*, s.code as set_code, s.name as set_name
FROM mtg_cards c
JOIN mtg_sets s ON c.set_id = s.id
WHERE s.code = :set_code
""")
cards = [dict(row._mapping) for row in
db.execute(query, {"set_code": set_code}).fetchall()]
else:
query = text("""
SELECT c.*, s.code as set_code, s.name as set_name
FROM mtg_cards c
JOIN mtg_sets s ON c.set_id = s.id
""")
cards = [dict(row._mapping) for row in db.execute(query).fetchall()]
logger.info(f"Loaded {len(cards)} cards from database")
return cards
finally:
db.close()
def extract_profiles(self, cards: List[Dict]) -> List[CardProfile]:
"""
Extract card profiles from card data.
Args:
cards: List of card dictionaries
Returns:
List of CardProfile objects
"""
return self.profile_extractor.extract_profiles_batch(cards)
def determine_interactions(self, profiles: List[CardProfile]) -> dict:
"""
Determine interactions for a batch of card profiles.
Args:
profiles: List of CardProfile objects
Returns:
Dictionary with synergies, counters, and evolutions
"""
return self.determinator.determine_all_interactions(profiles)
def _determine_synergy_type(self, interaction: InteractionResult) -> str:
"""
Determine synergy type from interaction metadata.
Args:
interaction: Interaction result
Returns:
Synergy type string
"""
metadata = interaction.metadata or {}
if 'common_archetypes' in metadata:
return 'archetype'
elif 'mechanics' in metadata:
return 'mechanic'
elif 'colors' in metadata:
return 'mana'
elif 'card_a_targets' in metadata:
return 'combo'
else:
return 'support'
def _determine_counter_type(self, interaction: InteractionResult) -> str:
"""
Determine counter type from interaction metadata.
Args:
interaction: Interaction result
Returns:
Counter type string
"""
metadata = interaction.metadata or {}
if 'colors_a' in metadata:
return 'color'
elif 'power_a' in metadata:
return 'stats'
else:
return 'keyword'
def _determine_evolution_type(self, interaction: InteractionResult) -> str:
"""
Determine evolution type from interaction metadata.
Args:
interaction: Interaction result
Returns:
Evolution type string
"""
metadata = interaction.metadata or {}
if 'card_name' in metadata:
return 'reprint'
else:
return 'evolution'
def store_interactions(self, interactions: dict) -> Tuple[int, int]:
"""
Store interactions in the database.
Args:
interactions: Dictionary with synergies, counters, evolutions
Returns:
Tuple of (stored_count, review_queue_count)
"""
db = self.SessionLocal()
stored = 0
review_queue = 0
try:
# Store synergies
for interaction in interactions['synergies']:
if interaction.confidence >= self.min_confidence:
synergy_type = self._determine_synergy_type(interaction)
db.execute(text("""
INSERT INTO mtg_card_synergies (
card_a_id, card_b_id, synergy_type, strength, notes, confidence
) VALUES (
:card_a, :card_b, :synergy_type, :strength, :notes, :confidence
) ON CONFLICT DO NOTHING
"""), {
"card_a": interaction.card_a_id,
"card_b": interaction.card_b_id,
"synergy_type": synergy_type,
"strength": interaction.strength,
"notes": interaction.notes,
"confidence": interaction.confidence,
})
stored += 1
else:
review_queue += 1
# Store counters
for interaction in interactions['counters']:
if interaction.confidence >= self.min_confidence:
counter_type = self._determine_counter_type(interaction)
db.execute(text("""
INSERT INTO mtg_card_counters (
card_a_id, card_b_id, counter_type, strength, notes, confidence
) VALUES (
:card_a, :card_b, :counter_type, :strength, :notes, :confidence
) ON CONFLICT DO NOTHING
"""), {
"card_a": interaction.card_a_id,
"card_b": interaction.card_b_id,
"counter_type": counter_type,
"strength": interaction.strength,
"notes": interaction.notes,
"confidence": interaction.confidence,
})
stored += 1
else:
review_queue += 1
# Store evolutions
for interaction in interactions['evolutions']:
if interaction.confidence >= self.min_confidence:
evolution_type = self._determine_evolution_type(interaction)
db.execute(text("""
INSERT INTO mtg_card_evolution (
card_id, evolved_card_id, evolution_type, strength, notes, confidence
) VALUES (
:card_id, :evolved_card_id, :evolution_type, :strength, :notes, :confidence
) ON CONFLICT DO NOTHING
"""), {
"card_id": interaction.card_a_id,
"evolved_card_id": interaction.card_b_id,
"evolution_type": evolution_type,
"strength": interaction.strength,
"notes": interaction.notes,
"confidence": interaction.confidence,
})
stored += 1
else:
review_queue += 1
db.commit()
logger.info(f"Stored {stored} interactions, {review_queue} sent to review queue")
except Exception as e:
db.rollback()
logger.error(f"Error storing interactions: {e}")
self.stats['errors'] += 1
finally:
db.close()
return stored, review_queue
def update_interaction_stats(self):
"""Update interaction statistics for all cards."""
db = self.SessionLocal()
try:
# Delete existing stats
db.execute(text("DELETE FROM mtg_card_interaction_stats"))
# Recalculate stats
db.execute(text("""
INSERT INTO mtg_card_interaction_stats (
card_id, total_synergies, total_counters, total_evolutions,
total_synergy_strength, avg_synergy_strength
)
SELECT
c.id,
COALESCE(synergies.synergy_count, 0),
COALESCE(counters.counter_count, 0),
COALESCE(evolution.evolution_count, 0),
COALESCE(synergies.total_strength, 0),
COALESCE(synergies.avg_strength, 0)
FROM mtg_cards c
LEFT JOIN (
SELECT card_a_id as card_id, COUNT(*) as synergy_count,
SUM(strength) as total_strength,
AVG(strength) as avg_strength
FROM mtg_card_synergies
GROUP BY card_a_id
) synergies ON c.id = synergies.card_id
LEFT JOIN (
SELECT card_a_id as card_id, COUNT(*) as counter_count
FROM mtg_card_counters
GROUP BY card_a_id
) counters ON c.id = counters.card_id
LEFT JOIN (
SELECT card_id as card_id, COUNT(*) as evolution_count
FROM mtg_card_evolution
GROUP BY card_id
) evolution ON c.id = evolution.card_id
"""))
db.commit()
logger.info("Updated interaction statistics")
except Exception as e:
db.rollback()
logger.error(f"Error updating interaction stats: {e}")
self.stats['errors'] += 1
finally:
db.close()
def run_initial_load(self, set_code: Optional[str] = None):
"""
Run initial load for all cards or a specific set.
This is used for the first time data is loaded into the database.
Args:
set_code: Optional set code to process
"""
logger.info("=" * 60)
logger.info("Starting Initial Load")
logger.info("=" * 60)
# Load all cards
all_cards = self.load_cards_from_db(set_code)
if not all_cards:
logger.warning("No cards found in database")
return
# Extract profiles
logger.info(f"Extracting profiles for {len(all_cards)} cards...")
profiles = self.extract_profiles(all_cards)
# Determine interactions
logger.info(f"Determining interactions for {len(profiles)} cards...")
interactions = self.determine_interactions(profiles)
logger.info(
f"Determined {len(interactions['synergies'])} synergies, "
f"{len(interactions['counters'])} counters, "
f"{len(interactions['evolutions'])} evolutions"
)
# Store interactions
stored, review_queue = self.store_interactions(interactions)
# Update statistics
self.update_interaction_stats()
# Update stats
self.stats['cards_processed'] = len(all_cards)
self.stats['interactions_determined'] = len(interactions['synergies']) + len(interactions['counters']) + len(interactions['evolutions'])
self.stats['interactions_stored'] = stored
self.stats['interactions_review_queue'] = review_queue
logger.info("=" * 60)
logger.info(f"Initial Load Complete")
logger.info(f" Cards processed: {self.stats['cards_processed']}")
logger.info(f" Interactions determined: {self.stats['interactions_determined']}")
logger.info(f" Interactions stored: {self.stats['interactions_stored']}")
logger.info(f" Interactions in review queue: {self.stats['interactions_review_queue']}")
logger.info("=" * 60)
def run_rolling_update(self, new_cards: List[Dict], set_code: Optional[str] = None):
"""
Run rolling update for new cards.
This is used when new cards are added via MTGJSON updates.
Args:
new_cards: List of new card dictionaries
set_code: Optional set code
"""
logger.info("=" * 60)
logger.info("Starting Rolling Update")
logger.info(f"New cards: {len(new_cards)}")
logger.info("=" * 60)
# Load existing cards
existing_cards = self.load_cards_from_db(set_code)
# Combine existing and new cards
all_cards = existing_cards + new_cards
# Extract profiles
logger.info(f"Extracting profiles for {len(all_cards)} cards...")
profiles = self.extract_profiles(all_cards)
# Determine interactions
logger.info(f"Determining interactions for {len(profiles)} cards...")
interactions = self.determine_interactions(profiles)
logger.info(
f"Determined {len(interactions['synergies'])} synergies, "
f"{len(interactions['counters'])} counters, "
f"{len(interactions['evolutions'])} evolutions"
)
# Store interactions
stored, review_queue = self.store_interactions(interactions)
# Update statistics
self.update_interaction_stats()
# Update stats
self.stats['cards_processed'] = len(new_cards)
self.stats['interactions_determined'] = len(interactions['synergies']) + len(interactions['counters']) + len(interactions['evolutions'])
self.stats['interactions_stored'] = stored
self.stats['interactions_review_queue'] = review_queue
logger.info("=" * 60)
logger.info(f"Rolling Update Complete")
logger.info(f" New cards processed: {self.stats['cards_processed']}")
logger.info(f" Interactions determined: {self.stats['interactions_determined']}")
logger.info(f" Interactions stored: {self.stats['interactions_stored']}")
logger.info(f" Interactions in review queue: {self.stats['interactions_review_queue']}")
logger.info("=" * 60)
def get_pipeline_stats(self) -> Dict:
"""Get pipeline statistics."""
return {
**self.stats,
'timestamp': datetime.now().isoformat(),
}
def close(self):
"""Close database connection."""
self.engine.dispose()
def main():
"""Main entry point for pipeline execution."""
import sys
# Database URL from environment or default
db_url = "postgresql+psycopg2://mtgonline:mtgonline_pass@172.18.0.2:5432/mtgdata"
# Get command line arguments
if len(sys.argv) < 2:
print("Usage: python pipeline.py [initial|rolling] [set_code]")
print(" initial: Run initial load for all cards or a specific set")
print(" rolling: Run rolling update for new cards (requires JSON input)")
sys.exit(1)
command = sys.argv[1]
set_code = sys.argv[2] if len(sys.argv) > 2 else None
# Initialize pipeline
pipeline = MTGInteractionPipeline(db_url)
try:
if command == "initial":
pipeline.run_initial_load(set_code)
elif command == "rolling":
# Read new cards from stdin (JSON)
new_cards = json.loads(sys.stdin.read())
pipeline.run_rolling_update(new_cards, set_code)
else:
print(f"Unknown command: {command}")
sys.exit(1)
# Print statistics
stats = pipeline.get_pipeline_stats()
print("\nPipeline Statistics:")
for key, value in stats.items():
print(f" {key}: {value}")
finally:
pipeline.close()
if __name__ == "__main__":
main()