Initial commit
This commit is contained in:
166
backend/app/services/apiKeyService.py
Normal file
166
backend/app/services/apiKeyService.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""
|
||||
API Key Service.
|
||||
|
||||
This module handles API key generation, validation, and management.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
import hashlib
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.api_key import ApiKey
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class ApiKeyService:
|
||||
"""Service for managing API keys."""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def generate_api_key(self, user_id: int, rate_limit: int = 100) -> ApiKey:
|
||||
"""
|
||||
Generate a new API key for a user.
|
||||
|
||||
Args:
|
||||
user_id: ID of the user
|
||||
rate_limit: Rate limit per minute (default: 100)
|
||||
|
||||
Returns:
|
||||
Created ApiKey object
|
||||
|
||||
Raises:
|
||||
ValueError: If user doesn't exist
|
||||
"""
|
||||
# Verify user exists
|
||||
user = self.db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise ValueError(f"User with id {user_id} not found")
|
||||
|
||||
# Generate API key (32 bytes = 256 bits)
|
||||
api_key = secrets.token_urlsafe(32)
|
||||
|
||||
# Hash the API key for storage (SHA-256)
|
||||
key_hash = hashlib.sha256(api_key.encode()).hexdigest()
|
||||
|
||||
# Store prefix for identification (first 8 characters)
|
||||
key_prefix = api_key[:8]
|
||||
|
||||
# Create API key record
|
||||
api_key_record = ApiKey(
|
||||
user_id=user_id,
|
||||
key_hash=key_hash,
|
||||
key_prefix=key_prefix,
|
||||
rate_limit=rate_limit,
|
||||
is_active=True,
|
||||
created_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
self.db.add(api_key_record)
|
||||
self.db.commit()
|
||||
self.db.refresh(api_key_record)
|
||||
|
||||
# Return the plain API key (only time it's shown)
|
||||
# We'll add it to the dict temporarily for the response
|
||||
api_key_dict = api_key_record.to_dict()
|
||||
api_key_dict['api_key'] = api_key
|
||||
|
||||
return api_key_dict
|
||||
|
||||
def validate_api_key(self, api_key: str) -> Optional[ApiKey]:
|
||||
"""
|
||||
Validate an API key.
|
||||
|
||||
Args:
|
||||
api_key: The API key to validate
|
||||
|
||||
Returns:
|
||||
ApiKey object if valid, None otherwise
|
||||
"""
|
||||
# Hash the provided API key
|
||||
key_hash = hashlib.sha256(api_key.encode()).hexdigest()
|
||||
|
||||
# Query for matching hash
|
||||
api_key_record = self.db.query(ApiKey).filter(
|
||||
ApiKey.key_hash == key_hash,
|
||||
ApiKey.is_active == True
|
||||
).first()
|
||||
|
||||
if api_key_record:
|
||||
# Update last used timestamp
|
||||
api_key_record.last_used_at = datetime.utcnow()
|
||||
self.db.commit()
|
||||
self.db.refresh(api_key_record)
|
||||
return api_key_record
|
||||
|
||||
return None
|
||||
|
||||
def get_user_api_keys(self, user_id: int) -> list[dict]:
|
||||
"""
|
||||
Get all API keys for a user.
|
||||
|
||||
Args:
|
||||
user_id: ID of the user
|
||||
|
||||
Returns:
|
||||
List of API key dictionaries (without actual keys)
|
||||
"""
|
||||
api_keys = self.db.query(ApiKey).filter(
|
||||
ApiKey.user_id == user_id
|
||||
).all()
|
||||
|
||||
return [api_key.to_dict() for api_key in api_keys]
|
||||
|
||||
def revoke_api_key(self, api_key_id: int, user_id: int) -> bool:
|
||||
"""
|
||||
Revoke (deactivate) an API key.
|
||||
|
||||
Args:
|
||||
api_key_id: ID of the API key to revoke
|
||||
user_id: ID of the user (for authorization)
|
||||
|
||||
Returns:
|
||||
True if revoked, False otherwise
|
||||
"""
|
||||
api_key = self.db.query(ApiKey).filter(
|
||||
ApiKey.id == api_key_id,
|
||||
ApiKey.user_id == user_id
|
||||
).first()
|
||||
|
||||
if api_key:
|
||||
api_key.is_active = False
|
||||
self.db.commit()
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def regenerate_api_key(self, api_key_id: int, user_id: int) -> Optional[dict]:
|
||||
"""
|
||||
Regenerate an API key (create new, deactivate old).
|
||||
|
||||
Args:
|
||||
api_key_id: ID of the API key to regenerate
|
||||
user_id: ID of the user (for authorization)
|
||||
|
||||
Returns:
|
||||
New API key dict with plain key, or None if not found
|
||||
"""
|
||||
old_api_key = self.db.query(ApiKey).filter(
|
||||
ApiKey.id == api_key_id,
|
||||
ApiKey.user_id == user_id
|
||||
).first()
|
||||
|
||||
if not old_api_key:
|
||||
return None
|
||||
|
||||
# Get rate limit from old key
|
||||
rate_limit = old_api_key.rate_limit
|
||||
|
||||
# Deactivate old key
|
||||
old_api_key.is_active = False
|
||||
self.db.commit()
|
||||
|
||||
# Generate new key
|
||||
return self.generate_api_key(user_id, rate_limit)
|
||||
200
backend/app/services/backtesting_service.py
Normal file
200
backend/app/services/backtesting_service.py
Normal file
@@ -0,0 +1,200 @@
|
||||
"""
|
||||
Backtesting Service.
|
||||
|
||||
This module provides the service layer for backtesting operations,
|
||||
integrating with the database to run backtesting on historical matches.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Any, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.match import Match
|
||||
from app.models.energy_score import EnergyScore
|
||||
from app.ml.backtesting import (
|
||||
run_backtesting_batch,
|
||||
export_to_json,
|
||||
export_to_csv,
|
||||
export_to_html,
|
||||
filter_matches_by_league,
|
||||
filter_matches_by_period
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BacktestingService:
|
||||
"""Service for running backtesting on historical match data."""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
"""
|
||||
Initialize backtesting service.
|
||||
|
||||
Args:
|
||||
db: SQLAlchemy database session
|
||||
"""
|
||||
self.db = db
|
||||
|
||||
def get_historical_matches(
|
||||
self,
|
||||
leagues: Optional[List[str]] = None,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Retrieve historical matches with energy scores and actual results.
|
||||
|
||||
Args:
|
||||
leagues: Optional list of leagues to filter by
|
||||
start_date: Optional start date for filtering
|
||||
end_date: Optional end date for filtering
|
||||
|
||||
Returns:
|
||||
List of match dictionaries with energy scores and actual winners
|
||||
|
||||
Raises:
|
||||
ValueError: If no historical matches found
|
||||
"""
|
||||
logger.info("Fetching historical matches from database")
|
||||
|
||||
# Query matches that have actual_winner set (completed matches)
|
||||
query = self.db.query(Match).filter(
|
||||
Match.actual_winner.isnot(None)
|
||||
)
|
||||
|
||||
# Apply filters
|
||||
if leagues:
|
||||
query = query.filter(Match.league.in_(leagues))
|
||||
|
||||
if start_date:
|
||||
query = query.filter(Match.date >= start_date)
|
||||
|
||||
if end_date:
|
||||
query = query.filter(Match.date <= end_date)
|
||||
|
||||
matches = query.all()
|
||||
|
||||
if not matches:
|
||||
raise ValueError(
|
||||
"No historical matches found. Please populate database with "
|
||||
"historical match data and actual winners before running backtesting."
|
||||
)
|
||||
|
||||
logger.info(f"Found {len(matches)} historical matches")
|
||||
|
||||
# Convert to list of dictionaries with energy scores
|
||||
match_data = []
|
||||
for match in matches:
|
||||
# Get energy scores for this match
|
||||
home_energy_score = self.db.query(EnergyScore).filter(
|
||||
EnergyScore.match_id == match.id
|
||||
).first()
|
||||
|
||||
if not home_energy_score:
|
||||
logger.warning(f"No energy score found for match {match.id}, skipping")
|
||||
continue
|
||||
|
||||
match_dict = {
|
||||
'match_id': match.id,
|
||||
'home_team': match.home_team,
|
||||
'away_team': match.away_team,
|
||||
'date': match.date,
|
||||
'league': match.league,
|
||||
'home_energy': home_energy_score.home_energy,
|
||||
'away_energy': home_energy_score.away_energy,
|
||||
'actual_winner': match.actual_winner
|
||||
}
|
||||
|
||||
match_data.append(match_dict)
|
||||
|
||||
logger.info(f"Processed {len(match_data)} matches with energy scores")
|
||||
|
||||
return match_data
|
||||
|
||||
def run_backtesting(
|
||||
self,
|
||||
leagues: Optional[List[str]] = None,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Run backtesting on historical matches with optional filters.
|
||||
|
||||
Args:
|
||||
leagues: Optional list of leagues to filter by
|
||||
start_date: Optional start date for filtering
|
||||
end_date: Optional end date for filtering
|
||||
|
||||
Returns:
|
||||
Dictionary containing backtesting results
|
||||
|
||||
Raises:
|
||||
ValueError: If no matches found or matches lack required data
|
||||
"""
|
||||
logger.info("Starting backtesting process")
|
||||
|
||||
# Get historical matches
|
||||
matches = self.get_historical_matches(
|
||||
leagues=leagues,
|
||||
start_date=start_date,
|
||||
end_date=end_date
|
||||
)
|
||||
|
||||
# Apply filters
|
||||
if leagues:
|
||||
matches = filter_matches_by_league(matches, leagues)
|
||||
|
||||
if start_date or end_date:
|
||||
matches = filter_matches_by_period(matches, start_date, end_date)
|
||||
|
||||
if not matches:
|
||||
raise ValueError("No matches match the specified filters")
|
||||
|
||||
logger.info(f"Running backtesting on {len(matches)} matches")
|
||||
|
||||
# Run backtesting
|
||||
result = run_backtesting_batch(matches)
|
||||
|
||||
# Log results
|
||||
logger.info(
|
||||
f"Backtesting complete: {result['total_matches']} matches, "
|
||||
f"{result['correct_predictions']} correct, "
|
||||
f"{result['accuracy']:.2f}% accuracy, "
|
||||
f"status: {result['status']}"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
def export_results(
|
||||
self,
|
||||
backtesting_result: Dict[str, Any],
|
||||
format: str = 'json'
|
||||
) -> str:
|
||||
"""
|
||||
Export backtesting results in specified format.
|
||||
|
||||
Args:
|
||||
backtesting_result: Result from run_backtesting
|
||||
format: Export format ('json', 'csv', or 'html')
|
||||
|
||||
Returns:
|
||||
Formatted string in specified format
|
||||
|
||||
Raises:
|
||||
ValueError: If format is not supported
|
||||
"""
|
||||
logger.info(f"Exporting backtesting results as {format}")
|
||||
|
||||
if format == 'json':
|
||||
return export_to_json(backtesting_result)
|
||||
elif format == 'csv':
|
||||
return export_to_csv(backtesting_result)
|
||||
elif format == 'html':
|
||||
return export_to_html(backtesting_result)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unsupported export format: {format}. "
|
||||
"Supported formats are: json, csv, html"
|
||||
)
|
||||
190
backend/app/services/badge_service.py
Normal file
190
backend/app/services/badge_service.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""
|
||||
Service de gestion des badges
|
||||
"""
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
from app.models.badge import Badge, UserBadge
|
||||
from app.models.user import User
|
||||
from app.models.user_prediction import UserPrediction
|
||||
from app.lib.badges import (
|
||||
BADGES,
|
||||
isBadgeUnlocked,
|
||||
getBadgeById,
|
||||
)
|
||||
|
||||
|
||||
class BadgeService:
|
||||
"""Service pour la gestion des badges"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def get_user_criteria(self, user_id: int) -> Dict[str, int]:
|
||||
"""
|
||||
Récupère les critères actuels de l'utilisateur
|
||||
"""
|
||||
# Nombre de prédictions consultées
|
||||
predictions_count = self.db.query(UserPrediction).filter(
|
||||
UserPrediction.user_id == user_id
|
||||
).count()
|
||||
|
||||
# Nombre de prédictions correctes (pour l'instant on utilise une valeur par défaut)
|
||||
# TODO: Implémenter la logique de calcul des prédictions correctes
|
||||
correct_predictions = 0
|
||||
|
||||
# Nombre de jours consécutifs (streak)
|
||||
# TODO: Implémenter la logique de calcul de streak
|
||||
streak_days = 0
|
||||
|
||||
# Nombre de partages
|
||||
# TODO: Implémenter la logique de suivi des partages
|
||||
share_count = 0
|
||||
|
||||
# Nombre de parrainages
|
||||
# TODO: Implémenter la logique de suivi des parrainages
|
||||
referral_count = 0
|
||||
|
||||
return {
|
||||
"predictions_count": predictions_count,
|
||||
"correct_predictions": correct_predictions,
|
||||
"streak_days": streak_days,
|
||||
"share_count": share_count,
|
||||
"referral_count": referral_count,
|
||||
}
|
||||
|
||||
def check_and_unlock_badges(self, user_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Vérifie et débloque les nouveaux badges pour un utilisateur
|
||||
"""
|
||||
# Récupérer les critères actuels de l'utilisateur
|
||||
user_criteria = self.get_user_criteria(user_id)
|
||||
|
||||
# Récupérer les badges déjà débloqués par l'utilisateur
|
||||
unlocked_badges = self.db.query(UserBadge).filter(
|
||||
UserBadge.user_id == user_id
|
||||
).all()
|
||||
|
||||
unlocked_badge_ids = set()
|
||||
for ub in unlocked_badges:
|
||||
badge = self.db.query(Badge).filter(Badge.id == ub.badge_id).first()
|
||||
if badge:
|
||||
unlocked_badge_ids.add(badge.badge_id)
|
||||
|
||||
# Vérifier tous les badges potentiels
|
||||
newly_unlocked_badges = []
|
||||
|
||||
for badge_def in BADGES:
|
||||
if badge_def["id"] not in unlocked_badge_ids:
|
||||
# Vérifier si le badge peut être débloqué
|
||||
criteria_type = badge_def["criteria"]["type"]
|
||||
criteria_value = badge_def["criteria"]["value"]
|
||||
|
||||
if user_criteria[criteria_type] >= criteria_value:
|
||||
# Débloquer le badge
|
||||
new_badge = self._unlock_badge(user_id, badge_def)
|
||||
if new_badge:
|
||||
newly_unlocked_badges.append(new_badge)
|
||||
|
||||
total_badges = len(unlocked_badges) + len(newly_unlocked_badges)
|
||||
|
||||
# Générer un message de notification
|
||||
message = ""
|
||||
if len(newly_unlocked_badges) > 0:
|
||||
if len(newly_unlocked_badges) == 1:
|
||||
message = f'🎉 Félicitations ! Vous avez débloqué le badge "{newly_unlocked_badges[0]["name"]}" !'
|
||||
else:
|
||||
badge_names = ', '.join(b["name"] for b in newly_unlocked_badges)
|
||||
message = f'🎉 Félicitations ! Vous avez débloqué {len(newly_unlocked_badges)} nouveaux badges : {badge_names} !'
|
||||
|
||||
return {
|
||||
"new_badges": newly_unlocked_badges,
|
||||
"total_badges": total_badges,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
def _unlock_badge(self, user_id: int, badge_def: Dict[str, Any]) -> Dict[str, Any] | None:
|
||||
"""
|
||||
Débloque un badge pour un utilisateur
|
||||
"""
|
||||
try:
|
||||
# Récupérer ou créer le badge dans la base de données
|
||||
db_badge = self.db.query(Badge).filter(
|
||||
Badge.badge_id == badge_def["id"]
|
||||
).first()
|
||||
|
||||
if not db_badge:
|
||||
# Créer le badge dans la base de données
|
||||
db_badge = Badge(
|
||||
badge_id=badge_def["id"],
|
||||
name=badge_def["name"],
|
||||
description=badge_def["description"],
|
||||
icon=badge_def["icon"],
|
||||
category=badge_def["category"],
|
||||
criteria_type=badge_def["criteria"]["type"],
|
||||
criteria_value=badge_def["criteria"]["value"],
|
||||
criteria_description=badge_def["criteria"]["description"],
|
||||
rarity=badge_def["rarity"],
|
||||
points=badge_def["points"],
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
self.db.add(db_badge)
|
||||
self.db.flush()
|
||||
|
||||
# Créer le badge utilisateur
|
||||
user_badge = UserBadge(
|
||||
user_id=user_id,
|
||||
badge_id=db_badge.id,
|
||||
unlocked_at=datetime.utcnow(),
|
||||
)
|
||||
self.db.add(user_badge)
|
||||
self.db.commit()
|
||||
|
||||
return {
|
||||
"id": db_badge.id,
|
||||
"badgeId": db_badge.badge_id,
|
||||
"name": db_badge.name,
|
||||
"description": db_badge.description,
|
||||
"icon": db_badge.icon,
|
||||
"category": db_badge.category,
|
||||
"rarity": db_badge.rarity,
|
||||
"points": db_badge.points,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
print(f"Erreur lors du déblocage du badge {badge_def['id']}: {e}")
|
||||
return None
|
||||
|
||||
def get_user_badges(self, user_id: int) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Récupère tous les badges débloqués par un utilisateur
|
||||
"""
|
||||
user_badges = self.db.query(UserBadge).filter(
|
||||
UserBadge.user_id == user_id
|
||||
).all()
|
||||
|
||||
result = []
|
||||
for ub in user_badges:
|
||||
badge = self.db.query(Badge).filter(Badge.id == ub.badge_id).first()
|
||||
if badge:
|
||||
result.append({
|
||||
"id": ub.id,
|
||||
"userId": ub.user_id,
|
||||
"badgeId": ub.badge_id,
|
||||
"unlockedAt": ub.unlocked_at.isoformat(),
|
||||
"badge": {
|
||||
"id": badge.id,
|
||||
"badgeId": badge.badge_id,
|
||||
"name": badge.name,
|
||||
"description": badge.description,
|
||||
"icon": badge.icon,
|
||||
"category": badge.category,
|
||||
"rarity": badge.rarity,
|
||||
"points": badge.points,
|
||||
"createdAt": badge.created_at.isoformat(),
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
278
backend/app/services/energy_service.py
Normal file
278
backend/app/services/energy_service.py
Normal file
@@ -0,0 +1,278 @@
|
||||
"""
|
||||
Energy Score Service.
|
||||
|
||||
This module provides business logic for energy score calculation and storage.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_
|
||||
|
||||
from app.ml.energy_calculator import (
|
||||
calculate_energy_score,
|
||||
adjust_weights_for_degraded_mode,
|
||||
get_source_weights
|
||||
)
|
||||
from app.models.energy_score import EnergyScore
|
||||
from app.schemas.energy_score import (
|
||||
EnergyScoreCreate,
|
||||
EnergyScoreUpdate,
|
||||
EnergyScoreCalculationRequest,
|
||||
EnergyScoreCalculationResponse
|
||||
)
|
||||
from app.database import get_db
|
||||
|
||||
|
||||
def calculate_and_store_energy_score(
|
||||
db: Session,
|
||||
request: EnergyScoreCalculationRequest
|
||||
) -> EnergyScore:
|
||||
"""
|
||||
Calculate energy score and store it in the database.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
request: Energy score calculation request
|
||||
|
||||
Returns:
|
||||
Created EnergyScore object
|
||||
"""
|
||||
# Calculate energy score using the ML module
|
||||
result = calculate_energy_score(
|
||||
match_id=request.match_id,
|
||||
team_id=request.team_id,
|
||||
twitter_sentiments=request.twitter_sentiments or [],
|
||||
reddit_sentiments=request.reddit_sentiments or [],
|
||||
rss_sentiments=request.rss_sentiments or [],
|
||||
tweets_with_timestamps=request.tweets_with_timestamps or []
|
||||
)
|
||||
|
||||
# Get adjusted weights for degraded mode tracking
|
||||
available_sources = result['sources_used']
|
||||
original_weights = get_source_weights()
|
||||
adjusted_weights = adjust_weights_for_degraded_mode(
|
||||
original_weights=original_weights,
|
||||
available_sources=available_sources
|
||||
)
|
||||
|
||||
# Create energy score record
|
||||
energy_score = EnergyScore(
|
||||
match_id=request.match_id,
|
||||
team_id=request.team_id,
|
||||
score=result['score'],
|
||||
confidence=result['confidence'],
|
||||
sources_used=result['sources_used'],
|
||||
twitter_score=_calculate_component_score(request.twitter_sentiments),
|
||||
reddit_score=_calculate_component_score(request.reddit_sentiments),
|
||||
rss_score=_calculate_component_score(request.rss_sentiments),
|
||||
temporal_factor=result.get('temporal_factor'),
|
||||
twitter_weight=adjusted_weights.get('twitter'),
|
||||
reddit_weight=adjusted_weights.get('reddit'),
|
||||
rss_weight=adjusted_weights.get('rss'),
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
# Save to database
|
||||
db.add(energy_score)
|
||||
db.commit()
|
||||
db.refresh(energy_score)
|
||||
|
||||
return energy_score
|
||||
|
||||
|
||||
def _calculate_component_score(sentiments: Optional[List[Dict]]) -> Optional[float]:
|
||||
"""
|
||||
Calculate component score for a single source.
|
||||
|
||||
Args:
|
||||
sentiments: List of sentiment scores
|
||||
|
||||
Returns:
|
||||
Component score or None if no sentiments
|
||||
"""
|
||||
if not sentiments:
|
||||
return None
|
||||
|
||||
# Simple average of compound scores
|
||||
total = sum(s.get('compound', 0) for s in sentiments)
|
||||
return total / len(sentiments) if sentiments else None
|
||||
|
||||
|
||||
def get_energy_score(
|
||||
db: Session,
|
||||
energy_score_id: int
|
||||
) -> Optional[EnergyScore]:
|
||||
"""
|
||||
Get an energy score by ID.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
energy_score_id: ID of the energy score
|
||||
|
||||
Returns:
|
||||
EnergyScore object or None
|
||||
"""
|
||||
return db.query(EnergyScore).filter(EnergyScore.id == energy_score_id).first()
|
||||
|
||||
|
||||
def get_energy_scores_by_match(
|
||||
db: Session,
|
||||
match_id: int
|
||||
) -> List[EnergyScore]:
|
||||
"""
|
||||
Get all energy scores for a specific match.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
match_id: ID of the match
|
||||
|
||||
Returns:
|
||||
List of EnergyScore objects
|
||||
"""
|
||||
return db.query(EnergyScore).filter(EnergyScore.match_id == match_id).all()
|
||||
|
||||
|
||||
def get_energy_scores_by_team(
|
||||
db: Session,
|
||||
team_id: int
|
||||
) -> List[EnergyScore]:
|
||||
"""
|
||||
Get all energy scores for a specific team.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
team_id: ID of the team
|
||||
|
||||
Returns:
|
||||
List of EnergyScore objects
|
||||
"""
|
||||
return db.query(EnergyScore).filter(EnergyScore.team_id == team_id).all()
|
||||
|
||||
|
||||
def get_energy_score_by_match_and_team(
|
||||
db: Session,
|
||||
match_id: int,
|
||||
team_id: int
|
||||
) -> Optional[EnergyScore]:
|
||||
"""
|
||||
Get the most recent energy score for a specific match and team.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
match_id: ID of the match
|
||||
team_id: ID of the team
|
||||
|
||||
Returns:
|
||||
EnergyScore object or None
|
||||
"""
|
||||
return (db.query(EnergyScore)
|
||||
.filter(and_(EnergyScore.match_id == match_id, EnergyScore.team_id == team_id))
|
||||
.order_by(EnergyScore.created_at.desc())
|
||||
.first())
|
||||
|
||||
|
||||
def update_energy_score(
|
||||
db: Session,
|
||||
energy_score_id: int,
|
||||
update: EnergyScoreUpdate
|
||||
) -> Optional[EnergyScore]:
|
||||
"""
|
||||
Update an existing energy score.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
energy_score_id: ID of the energy score
|
||||
update: Updated energy score data
|
||||
|
||||
Returns:
|
||||
Updated EnergyScore object or None
|
||||
"""
|
||||
energy_score = get_energy_score(db, energy_score_id)
|
||||
if not energy_score:
|
||||
return None
|
||||
|
||||
# Update fields
|
||||
update_data = update.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(energy_score, key, value)
|
||||
|
||||
energy_score.updated_at = datetime.utcnow()
|
||||
|
||||
# Save to database
|
||||
db.commit()
|
||||
db.refresh(energy_score)
|
||||
|
||||
return energy_score
|
||||
|
||||
|
||||
def delete_energy_score(
|
||||
db: Session,
|
||||
energy_score_id: int
|
||||
) -> bool:
|
||||
"""
|
||||
Delete an energy score.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
energy_score_id: ID of the energy score
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
"""
|
||||
energy_score = get_energy_score(db, energy_score_id)
|
||||
if not energy_score:
|
||||
return False
|
||||
|
||||
db.delete(energy_score)
|
||||
db.commit()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def list_energy_scores(
|
||||
db: Session,
|
||||
match_id: Optional[int] = None,
|
||||
team_id: Optional[int] = None,
|
||||
min_score: Optional[float] = None,
|
||||
max_score: Optional[float] = None,
|
||||
min_confidence: Optional[float] = None,
|
||||
limit: int = 10,
|
||||
offset: int = 0
|
||||
) -> List[EnergyScore]:
|
||||
"""
|
||||
List energy scores with optional filters.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
match_id: Optional filter by match ID
|
||||
team_id: Optional filter by team ID
|
||||
min_score: Optional filter by minimum score
|
||||
max_score: Optional filter by maximum score
|
||||
min_confidence: Optional filter by minimum confidence
|
||||
limit: Maximum number of results
|
||||
offset: Offset for pagination
|
||||
|
||||
Returns:
|
||||
List of EnergyScore objects
|
||||
"""
|
||||
query = db.query(EnergyScore)
|
||||
|
||||
# Apply filters
|
||||
if match_id is not None:
|
||||
query = query.filter(EnergyScore.match_id == match_id)
|
||||
if team_id is not None:
|
||||
query = query.filter(EnergyScore.team_id == team_id)
|
||||
if min_score is not None:
|
||||
query = query.filter(EnergyScore.score >= min_score)
|
||||
if max_score is not None:
|
||||
query = query.filter(EnergyScore.score <= max_score)
|
||||
if min_confidence is not None:
|
||||
query = query.filter(EnergyScore.confidence >= min_confidence)
|
||||
|
||||
# Apply pagination and ordering
|
||||
query = query.order_by(EnergyScore.created_at.desc())
|
||||
query = query.offset(offset).limit(limit)
|
||||
|
||||
return query.all()
|
||||
161
backend/app/services/leaderboard_service.py
Normal file
161
backend/app/services/leaderboard_service.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""
|
||||
Leaderboard Service.
|
||||
|
||||
This module handles the calculation and retrieval of user rankings
|
||||
based on prediction accuracy.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, case, desc
|
||||
|
||||
from app.models.user import User
|
||||
from app.models.user_prediction import UserPrediction
|
||||
|
||||
|
||||
class LeaderboardService:
|
||||
"""Service for managing leaderboard calculations and queries."""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
"""Initialize leaderboard service with database session."""
|
||||
self.db = db
|
||||
|
||||
def get_leaderboard(self, limit: int = 100) -> list[dict]:
|
||||
"""
|
||||
Get leaderboard sorted by accuracy (descending) with predictions count as tie-breaker.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of users to return (default: 100)
|
||||
|
||||
Returns:
|
||||
List of dictionaries containing user_id, username, accuracy, predictions_count
|
||||
"""
|
||||
# Calculate accuracy for each user who has viewed predictions
|
||||
# Accuracy = (number of correct predictions / total predictions where was_correct is not NULL) * 100
|
||||
query = (
|
||||
self.db.query(
|
||||
User.id.label('user_id'),
|
||||
User.name.label('username'),
|
||||
func.count(UserPrediction.id).label('predictions_count'),
|
||||
func.sum(
|
||||
case(
|
||||
(UserPrediction.was_correct == True, 1),
|
||||
else_=0
|
||||
)
|
||||
).label('correct_count'),
|
||||
func.sum(
|
||||
case(
|
||||
(UserPrediction.was_correct.isnot(None), 1),
|
||||
else_=0
|
||||
)
|
||||
).label('completed_predictions_count')
|
||||
)
|
||||
.join(UserPrediction, User.id == UserPrediction.user_id)
|
||||
.filter(
|
||||
UserPrediction.was_correct.isnot(None) # Only include predictions where match is completed
|
||||
)
|
||||
.group_by(User.id, User.name)
|
||||
.order_by(
|
||||
desc(
|
||||
# Calculate accuracy: correct / completed * 100
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case(
|
||||
(UserPrediction.was_correct == True, 1),
|
||||
else_=0
|
||||
)
|
||||
) * 100.0 /
|
||||
func.sum(
|
||||
case(
|
||||
(UserPrediction.was_correct.isnot(None), 1),
|
||||
else_=0
|
||||
)
|
||||
),
|
||||
0.0
|
||||
)
|
||||
),
|
||||
desc('predictions_count') # Tie-breaker: more predictions = higher rank
|
||||
)
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
results = query.all()
|
||||
|
||||
# Format results
|
||||
leaderboard = []
|
||||
for row in results:
|
||||
completed_count = row.completed_predictions_count or 0
|
||||
correct_count = row.correct_count or 0
|
||||
accuracy = (correct_count / completed_count * 100) if completed_count > 0 else 0.0
|
||||
|
||||
leaderboard.append({
|
||||
'user_id': row.user_id,
|
||||
'username': row.username,
|
||||
'accuracy': round(accuracy, 1),
|
||||
'predictions_count': row.predictions_count
|
||||
})
|
||||
|
||||
return leaderboard
|
||||
|
||||
def get_personal_rank(self, user_id: int) -> Optional[dict]:
|
||||
"""
|
||||
Get personal rank data for a specific user.
|
||||
|
||||
Args:
|
||||
user_id: ID of the user
|
||||
|
||||
Returns:
|
||||
Dictionary containing rank, accuracy, predictions_count, or None if user not found
|
||||
"""
|
||||
# Get user's stats
|
||||
user_stats = (
|
||||
self.db.query(
|
||||
User.id,
|
||||
func.count(UserPrediction.id).label('predictions_count'),
|
||||
func.sum(
|
||||
case(
|
||||
(UserPrediction.was_correct == True, 1),
|
||||
else_=0
|
||||
)
|
||||
).label('correct_count'),
|
||||
func.sum(
|
||||
case(
|
||||
(UserPrediction.was_correct.isnot(None), 1),
|
||||
else_=0
|
||||
)
|
||||
).label('completed_predictions_count')
|
||||
)
|
||||
.join(UserPrediction, User.id == UserPrediction.user_id)
|
||||
.filter(User.id == user_id)
|
||||
.filter(UserPrediction.was_correct.isnot(None))
|
||||
.group_by(User.id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not user_stats:
|
||||
return None
|
||||
|
||||
completed_count = user_stats.completed_predictions_count or 0
|
||||
correct_count = user_stats.correct_count or 0
|
||||
accuracy = (correct_count / completed_count * 100) if completed_count > 0 else 0.0
|
||||
predictions_count = user_stats.predictions_count or 0
|
||||
|
||||
# Get full leaderboard to calculate rank
|
||||
full_leaderboard = self.get_leaderboard(limit=1000) # Get more to find rank accurately
|
||||
|
||||
# Find user's rank
|
||||
rank = None
|
||||
for idx, entry in enumerate(full_leaderboard, start=1):
|
||||
if entry['user_id'] == user_id:
|
||||
rank = idx
|
||||
break
|
||||
|
||||
if rank is None:
|
||||
return None
|
||||
|
||||
return {
|
||||
'rank': rank,
|
||||
'accuracy': round(accuracy, 1),
|
||||
'predictions_count': predictions_count
|
||||
}
|
||||
304
backend/app/services/prediction_service.py
Normal file
304
backend/app/services/prediction_service.py
Normal file
@@ -0,0 +1,304 @@
|
||||
"""
|
||||
Prediction Service Module.
|
||||
|
||||
This module provides business logic for creating and managing match predictions.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.ml.prediction_calculator import calculate_prediction, validate_prediction_result
|
||||
from app.models.match import Match
|
||||
from app.models.prediction import Prediction
|
||||
from app.schemas.prediction import PredictionCreate, MatchInfo
|
||||
|
||||
|
||||
class PredictionService:
|
||||
"""Service for handling prediction business logic."""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
"""
|
||||
Initialize the prediction service.
|
||||
|
||||
Args:
|
||||
db: SQLAlchemy database session
|
||||
"""
|
||||
self.db = db
|
||||
|
||||
def create_prediction_for_match(
|
||||
self,
|
||||
match_id: int,
|
||||
home_energy: float,
|
||||
away_energy: float,
|
||||
energy_score_label: Optional[str] = None
|
||||
) -> Prediction:
|
||||
"""
|
||||
Create a prediction for a specific match based on energy scores.
|
||||
|
||||
This method:
|
||||
1. Validates that the match exists
|
||||
2. Calculates the prediction using energy scores
|
||||
3. Stores the prediction in the database
|
||||
4. Returns the created prediction
|
||||
|
||||
Args:
|
||||
match_id: ID of the match to predict
|
||||
home_energy: Energy score of the home team
|
||||
away_energy: Energy score of the away team
|
||||
energy_score_label: Optional label for energy score (e.g., "high", "medium", "low")
|
||||
|
||||
Returns:
|
||||
Created Prediction object
|
||||
|
||||
Raises:
|
||||
ValueError: If match doesn't exist or energy scores are invalid
|
||||
"""
|
||||
# Validate match exists
|
||||
match = self.db.query(Match).filter(Match.id == match_id).first()
|
||||
if not match:
|
||||
raise ValueError(f"Match with id {match_id} not found")
|
||||
|
||||
# Validate energy scores
|
||||
if not isinstance(home_energy, (int, float)) or not isinstance(away_energy, (int, float)):
|
||||
raise ValueError("Energy scores must be numeric values")
|
||||
|
||||
if home_energy < 0 or away_energy < 0:
|
||||
raise ValueError("Energy scores cannot be negative")
|
||||
|
||||
# Calculate prediction
|
||||
prediction_result = calculate_prediction(home_energy, away_energy)
|
||||
|
||||
# Validate prediction result
|
||||
if not validate_prediction_result(prediction_result):
|
||||
raise ValueError("Invalid prediction calculation result")
|
||||
|
||||
# Determine energy score label if not provided
|
||||
if energy_score_label is None:
|
||||
avg_energy = (home_energy + away_energy) / 2
|
||||
if avg_energy >= 70:
|
||||
energy_score_label = "very_high"
|
||||
elif avg_energy >= 50:
|
||||
energy_score_label = "high"
|
||||
elif avg_energy >= 30:
|
||||
energy_score_label = "medium"
|
||||
else:
|
||||
energy_score_label = "low"
|
||||
|
||||
# Determine predicted winner team name
|
||||
if prediction_result['predicted_winner'] == 'home':
|
||||
predicted_winner_name = match.home_team
|
||||
elif prediction_result['predicted_winner'] == 'away':
|
||||
predicted_winner_name = match.away_team
|
||||
else:
|
||||
predicted_winner_name = "Draw"
|
||||
|
||||
# Create prediction object
|
||||
prediction = Prediction(
|
||||
match_id=match_id,
|
||||
energy_score=energy_score_label,
|
||||
confidence=f"{prediction_result['confidence']:.1f}%",
|
||||
predicted_winner=predicted_winner_name,
|
||||
created_at=datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
# Save to database
|
||||
self.db.add(prediction)
|
||||
self.db.commit()
|
||||
self.db.refresh(prediction)
|
||||
|
||||
return prediction
|
||||
|
||||
def get_prediction_by_id(self, prediction_id: int) -> Optional[Prediction]:
|
||||
"""
|
||||
Get a prediction by its ID.
|
||||
|
||||
Args:
|
||||
prediction_id: ID of the prediction to retrieve
|
||||
|
||||
Returns:
|
||||
Prediction object or None if not found
|
||||
"""
|
||||
return self.db.query(Prediction).filter(Prediction.id == prediction_id).first()
|
||||
|
||||
def get_predictions_for_match(self, match_id: int) -> list[Prediction]:
|
||||
"""
|
||||
Get all predictions for a specific match.
|
||||
|
||||
Args:
|
||||
match_id: ID of the match
|
||||
|
||||
Returns:
|
||||
List of Prediction objects
|
||||
"""
|
||||
return self.db.query(Prediction).filter(Prediction.match_id == match_id).all()
|
||||
|
||||
def get_latest_prediction_for_match(self, match_id: int) -> Optional[Prediction]:
|
||||
"""
|
||||
Get the most recent prediction for a match.
|
||||
|
||||
Args:
|
||||
match_id: ID of the match
|
||||
|
||||
Returns:
|
||||
Latest Prediction object or None if no predictions exist
|
||||
"""
|
||||
return (
|
||||
self.db.query(Prediction)
|
||||
.filter(Prediction.match_id == match_id)
|
||||
.order_by(Prediction.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
|
||||
def delete_prediction(self, prediction_id: int) -> bool:
|
||||
"""
|
||||
Delete a prediction by its ID.
|
||||
|
||||
Args:
|
||||
prediction_id: ID of the prediction to delete
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
"""
|
||||
prediction = self.db.query(Prediction).filter(Prediction.id == prediction_id).first()
|
||||
if prediction:
|
||||
self.db.delete(prediction)
|
||||
self.db.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_predictions_with_pagination(
|
||||
self,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
team_id: Optional[int] = None,
|
||||
league: Optional[str] = None,
|
||||
date_min: Optional[datetime] = None,
|
||||
date_max: Optional[datetime] = None
|
||||
) -> tuple[list[Prediction], int]:
|
||||
"""
|
||||
Get predictions with pagination and filters.
|
||||
|
||||
This method retrieves predictions joined with match data, applies filters,
|
||||
and returns paginated results.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of predictions to return (max 100)
|
||||
offset: Number of predictions to skip
|
||||
team_id: Optional filter by team ID (home or away)
|
||||
league: Optional filter by league name
|
||||
date_min: Optional filter for matches after this date
|
||||
date_max: Optional filter for matches before this date
|
||||
|
||||
Returns:
|
||||
Tuple of (list of predictions, total count)
|
||||
"""
|
||||
# Start with a query that includes match data
|
||||
query = (
|
||||
self.db.query(Prediction)
|
||||
.join(Match)
|
||||
)
|
||||
|
||||
# Apply filters
|
||||
if team_id:
|
||||
# Get the match for the team_id to get team names
|
||||
team_match = self.db.query(Match).filter(Match.id == team_id).first()
|
||||
if team_match:
|
||||
query = query.filter(
|
||||
(Match.home_team == team_match.home_team) |
|
||||
(Match.away_team == team_match.away_team)
|
||||
)
|
||||
|
||||
if league:
|
||||
query = query.filter(Match.league.ilike(f"%{league}%"))
|
||||
|
||||
if date_min:
|
||||
query = query.filter(Match.date >= date_min)
|
||||
|
||||
if date_max:
|
||||
query = query.filter(Match.date <= date_max)
|
||||
|
||||
# Get total count before pagination
|
||||
total = query.count()
|
||||
|
||||
# Apply pagination and ordering by match date (upcoming matches first)
|
||||
predictions = (
|
||||
query
|
||||
.order_by(Match.date.asc())
|
||||
.limit(min(limit, 100))
|
||||
.offset(offset)
|
||||
.all()
|
||||
)
|
||||
|
||||
return predictions, total
|
||||
|
||||
def get_prediction_with_details(self, match_id: int) -> Optional[dict]:
|
||||
"""
|
||||
Get a prediction for a specific match with full details.
|
||||
|
||||
This method retrieves latest prediction for a match and includes
|
||||
match details, energy score information, and historical data.
|
||||
|
||||
Args:
|
||||
match_id: ID of match
|
||||
|
||||
Returns:
|
||||
Dictionary with prediction details or None if not found
|
||||
"""
|
||||
prediction = self.get_latest_prediction_for_match(match_id)
|
||||
|
||||
if not prediction:
|
||||
return None
|
||||
|
||||
# Get match details
|
||||
match = prediction.match
|
||||
|
||||
# Build response with all details
|
||||
result = {
|
||||
"id": prediction.id,
|
||||
"match_id": prediction.match_id,
|
||||
"match": {
|
||||
"id": match.id,
|
||||
"home_team": match.home_team,
|
||||
"away_team": match.away_team,
|
||||
"date": match.date.isoformat() if match.date else None,
|
||||
"league": match.league,
|
||||
"status": match.status,
|
||||
"actual_winner": match.actual_winner
|
||||
},
|
||||
"energy_score": prediction.energy_score,
|
||||
"confidence": prediction.confidence,
|
||||
"predicted_winner": prediction.predicted_winner,
|
||||
"created_at": prediction.created_at.isoformat() if prediction.created_at else None,
|
||||
"history": self._get_prediction_history(match_id)
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
def _get_prediction_history(self, match_id: int) -> list[dict]:
|
||||
"""
|
||||
Get historical predictions for a match.
|
||||
|
||||
Args:
|
||||
match_id: ID of match
|
||||
|
||||
Returns:
|
||||
List of historical predictions (all predictions for match)
|
||||
"""
|
||||
predictions = (
|
||||
self.db.query(Prediction)
|
||||
.filter(Prediction.match_id == match_id)
|
||||
.order_by(Prediction.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
"id": pred.id,
|
||||
"energy_score": pred.energy_score,
|
||||
"confidence": pred.confidence,
|
||||
"predicted_winner": pred.predicted_winner,
|
||||
"created_at": pred.created_at.isoformat() if pred.created_at else None
|
||||
}
|
||||
for pred in predictions
|
||||
]
|
||||
326
backend/app/services/sentiment_service.py
Normal file
326
backend/app/services/sentiment_service.py
Normal file
@@ -0,0 +1,326 @@
|
||||
"""
|
||||
Sentiment Analysis Service
|
||||
|
||||
This module provides services for batch processing of tweets and posts,
|
||||
storing sentiment scores in the database, and calculating aggregated metrics.
|
||||
"""
|
||||
|
||||
from typing import List, Dict, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.ml.sentiment_analyzer import (
|
||||
analyze_sentiment,
|
||||
analyze_sentiment_batch,
|
||||
calculate_aggregated_metrics
|
||||
)
|
||||
from app.models.sentiment_score import SentimentScore
|
||||
from app.models.tweet import Tweet
|
||||
from app.models.reddit_post import RedditPost
|
||||
|
||||
|
||||
def process_tweet_sentiment(
|
||||
db: Session,
|
||||
tweet_id: str,
|
||||
text: str
|
||||
) -> SentimentScore:
|
||||
"""
|
||||
Analyze sentiment for a single tweet and store in database.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
tweet_id: Tweet identifier
|
||||
text: Tweet text to analyze
|
||||
|
||||
Returns:
|
||||
Created SentimentScore record
|
||||
"""
|
||||
# Analyze sentiment
|
||||
sentiment_result = analyze_sentiment(text)
|
||||
|
||||
# Create database record
|
||||
sentiment_score = SentimentScore(
|
||||
entity_id=tweet_id,
|
||||
entity_type='tweet',
|
||||
score=sentiment_result['compound'],
|
||||
sentiment_type=sentiment_result['sentiment'],
|
||||
positive=sentiment_result['positive'],
|
||||
negative=sentiment_result['negative'],
|
||||
neutral=sentiment_result['neutral']
|
||||
)
|
||||
|
||||
db.add(sentiment_score)
|
||||
db.commit()
|
||||
db.refresh(sentiment_score)
|
||||
|
||||
return sentiment_score
|
||||
|
||||
|
||||
def process_tweet_batch(
|
||||
db: Session,
|
||||
tweets: List[Tweet]
|
||||
) -> List[SentimentScore]:
|
||||
"""
|
||||
Analyze sentiment for a batch of tweets and store in database.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
tweets: List of Tweet models to analyze
|
||||
|
||||
Returns:
|
||||
List of created SentimentScore records
|
||||
"""
|
||||
if not tweets:
|
||||
return []
|
||||
|
||||
# Extract texts
|
||||
texts = [tweet.text for tweet in tweets]
|
||||
tweet_ids = [tweet.tweet_id for tweet in tweets]
|
||||
|
||||
# Analyze in batch
|
||||
sentiment_results = analyze_sentiment_batch(texts)
|
||||
|
||||
# Create database records
|
||||
sentiment_scores = []
|
||||
for tweet_id, result in zip(tweet_ids, sentiment_results):
|
||||
sentiment_score = SentimentScore(
|
||||
entity_id=tweet_id,
|
||||
entity_type='tweet',
|
||||
score=result['compound'],
|
||||
sentiment_type=result['sentiment'],
|
||||
positive=result['positive'],
|
||||
negative=result['negative'],
|
||||
neutral=result['neutral']
|
||||
)
|
||||
sentiment_scores.append(sentiment_score)
|
||||
|
||||
# Batch insert
|
||||
db.add_all(sentiment_scores)
|
||||
db.commit()
|
||||
|
||||
# Refresh to get IDs
|
||||
for score in sentiment_scores:
|
||||
db.refresh(score)
|
||||
|
||||
return sentiment_scores
|
||||
|
||||
|
||||
def process_reddit_post_sentiment(
|
||||
db: Session,
|
||||
post_id: str,
|
||||
text: str
|
||||
) -> SentimentScore:
|
||||
"""
|
||||
Analyze sentiment for a single Reddit post and store in database.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
post_id: Reddit post identifier
|
||||
text: Post text to analyze
|
||||
|
||||
Returns:
|
||||
Created SentimentScore record
|
||||
"""
|
||||
# Analyze sentiment
|
||||
sentiment_result = analyze_sentiment(text)
|
||||
|
||||
# Create database record
|
||||
sentiment_score = SentimentScore(
|
||||
entity_id=post_id,
|
||||
entity_type='reddit_post',
|
||||
score=sentiment_result['compound'],
|
||||
sentiment_type=sentiment_result['sentiment'],
|
||||
positive=sentiment_result['positive'],
|
||||
negative=sentiment_result['negative'],
|
||||
neutral=sentiment_result['neutral']
|
||||
)
|
||||
|
||||
db.add(sentiment_score)
|
||||
db.commit()
|
||||
db.refresh(sentiment_score)
|
||||
|
||||
return sentiment_score
|
||||
|
||||
|
||||
def process_reddit_post_batch(
|
||||
db: Session,
|
||||
posts: List[RedditPost]
|
||||
) -> List[SentimentScore]:
|
||||
"""
|
||||
Analyze sentiment for a batch of Reddit posts and store in database.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
posts: List of RedditPost models to analyze
|
||||
|
||||
Returns:
|
||||
List of created SentimentScore records
|
||||
"""
|
||||
if not posts:
|
||||
return []
|
||||
|
||||
# Extract texts (combine title and text if available)
|
||||
texts = []
|
||||
post_ids = []
|
||||
for post in posts:
|
||||
text = post.text if post.text else ""
|
||||
full_text = f"{post.title} {text}"
|
||||
texts.append(full_text)
|
||||
post_ids.append(post.post_id)
|
||||
|
||||
# Analyze in batch
|
||||
sentiment_results = analyze_sentiment_batch(texts)
|
||||
|
||||
# Create database records
|
||||
sentiment_scores = []
|
||||
for post_id, result in zip(post_ids, sentiment_results):
|
||||
sentiment_score = SentimentScore(
|
||||
entity_id=post_id,
|
||||
entity_type='reddit_post',
|
||||
score=result['compound'],
|
||||
sentiment_type=result['sentiment'],
|
||||
positive=result['positive'],
|
||||
negative=result['negative'],
|
||||
neutral=result['neutral']
|
||||
)
|
||||
sentiment_scores.append(sentiment_score)
|
||||
|
||||
# Batch insert
|
||||
db.add_all(sentiment_scores)
|
||||
db.commit()
|
||||
|
||||
# Refresh to get IDs
|
||||
for score in sentiment_scores:
|
||||
db.refresh(score)
|
||||
|
||||
return sentiment_scores
|
||||
|
||||
|
||||
def get_sentiment_by_entity(
|
||||
db: Session,
|
||||
entity_id: str,
|
||||
entity_type: str
|
||||
) -> Optional[SentimentScore]:
|
||||
"""
|
||||
Retrieve sentiment score for a specific entity.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
entity_id: Entity identifier
|
||||
entity_type: Entity type ('tweet' or 'reddit_post')
|
||||
|
||||
Returns:
|
||||
SentimentScore if found, None otherwise
|
||||
"""
|
||||
return db.query(SentimentScore).filter(
|
||||
SentimentScore.entity_id == entity_id,
|
||||
SentimentScore.entity_type == entity_type
|
||||
).first()
|
||||
|
||||
|
||||
def get_sentiments_by_match(
|
||||
db: Session,
|
||||
match_id: int
|
||||
) -> List[SentimentScore]:
|
||||
"""
|
||||
Retrieve all sentiment scores for a specific match.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
match_id: Match identifier
|
||||
|
||||
Returns:
|
||||
List of SentimentScore records for the match
|
||||
"""
|
||||
# Join with tweets table to filter by match_id
|
||||
return db.query(SentimentScore).join(
|
||||
Tweet, Tweet.tweet_id == SentimentScore.entity_id
|
||||
).filter(
|
||||
Tweet.match_id == match_id,
|
||||
SentimentScore.entity_type == 'tweet'
|
||||
).all()
|
||||
|
||||
|
||||
def calculate_match_sentiment_metrics(
|
||||
db: Session,
|
||||
match_id: int
|
||||
) -> Dict:
|
||||
"""
|
||||
Calculate aggregated sentiment metrics for a match.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
match_id: Match identifier
|
||||
|
||||
Returns:
|
||||
Dictionary with aggregated metrics
|
||||
"""
|
||||
# Get all sentiments for the match
|
||||
sentiments = get_sentiments_by_match(db, match_id)
|
||||
|
||||
if not sentiments:
|
||||
return {
|
||||
'match_id': match_id,
|
||||
'total_count': 0,
|
||||
'positive_count': 0,
|
||||
'negative_count': 0,
|
||||
'neutral_count': 0,
|
||||
'positive_ratio': 0.0,
|
||||
'negative_ratio': 0.0,
|
||||
'neutral_ratio': 0.0,
|
||||
'average_compound': 0.0
|
||||
}
|
||||
|
||||
# Convert to list of dicts for calculate_aggregated_metrics
|
||||
sentiment_dicts = [
|
||||
{
|
||||
'compound': s.score,
|
||||
'sentiment': s.sentiment_type
|
||||
}
|
||||
for s in sentiments
|
||||
]
|
||||
|
||||
# Calculate metrics
|
||||
metrics = calculate_aggregated_metrics(sentiment_dicts)
|
||||
metrics['match_id'] = match_id
|
||||
|
||||
return metrics
|
||||
|
||||
|
||||
def get_global_sentiment_metrics(
|
||||
db: Session
|
||||
) -> Dict:
|
||||
"""
|
||||
Calculate global sentiment metrics across all entities.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Dictionary with global aggregated metrics
|
||||
"""
|
||||
# Get all sentiment scores
|
||||
all_sentiments = db.query(SentimentScore).all()
|
||||
|
||||
if not all_sentiments:
|
||||
return {
|
||||
'total_count': 0,
|
||||
'positive_count': 0,
|
||||
'negative_count': 0,
|
||||
'neutral_count': 0,
|
||||
'positive_ratio': 0.0,
|
||||
'negative_ratio': 0.0,
|
||||
'neutral_ratio': 0.0,
|
||||
'average_compound': 0.0
|
||||
}
|
||||
|
||||
# Convert to list of dicts
|
||||
sentiment_dicts = [
|
||||
{
|
||||
'compound': s.score,
|
||||
'sentiment': s.sentiment_type
|
||||
}
|
||||
for s in all_sentiments
|
||||
]
|
||||
|
||||
# Calculate metrics
|
||||
return calculate_aggregated_metrics(sentiment_dicts)
|
||||
216
backend/app/services/user_prediction_service.py
Normal file
216
backend/app/services/user_prediction_service.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""
|
||||
User Prediction Service.
|
||||
|
||||
This module provides business logic for tracking user predictions,
|
||||
calculating ROI and accuracy rates.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Tuple, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import User, UserPrediction, Prediction, Match
|
||||
|
||||
|
||||
class UserPredictionService:
|
||||
"""
|
||||
Service for managing user predictions and statistics.
|
||||
|
||||
This service handles:
|
||||
- Tracking which predictions users have viewed
|
||||
- Updating prediction results when matches complete
|
||||
- Calculating user accuracy rates
|
||||
- Calculating user ROI (Return on Investment)
|
||||
"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
"""Initialize service with database session."""
|
||||
self.db = db
|
||||
|
||||
def record_prediction_view(self, user_id: int, prediction_id: int) -> UserPrediction:
|
||||
"""
|
||||
Record that a user viewed a prediction.
|
||||
|
||||
Args:
|
||||
user_id: ID of the user
|
||||
prediction_id: ID of the prediction viewed
|
||||
|
||||
Returns:
|
||||
Created or existing UserPrediction record
|
||||
|
||||
Raises:
|
||||
ValueError: If user or prediction doesn't exist
|
||||
"""
|
||||
# Check if user exists
|
||||
user = self.db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise ValueError(f"User with id {user_id} not found")
|
||||
|
||||
# Check if prediction exists
|
||||
prediction = self.db.query(Prediction).filter(Prediction.id == prediction_id).first()
|
||||
if not prediction:
|
||||
raise ValueError(f"Prediction with id {prediction_id} not found")
|
||||
|
||||
# Check if already viewed (unique constraint)
|
||||
existing = self.db.query(UserPrediction).filter(
|
||||
UserPrediction.user_id == user_id,
|
||||
UserPrediction.prediction_id == prediction_id
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
# Create new record
|
||||
user_prediction = UserPrediction(
|
||||
user_id=user_id,
|
||||
prediction_id=prediction_id,
|
||||
viewed_at=datetime.utcnow(),
|
||||
was_correct=None # Will be set when match completes
|
||||
)
|
||||
|
||||
self.db.add(user_prediction)
|
||||
self.db.commit()
|
||||
self.db.refresh(user_prediction)
|
||||
|
||||
return user_prediction
|
||||
|
||||
def get_user_prediction_history(
|
||||
self,
|
||||
user_id: int,
|
||||
limit: int = 50,
|
||||
offset: int = 0
|
||||
) -> Tuple[List[dict], int]:
|
||||
"""
|
||||
Get a user's prediction viewing history.
|
||||
|
||||
Args:
|
||||
user_id: ID of the user
|
||||
limit: Maximum number of records to return
|
||||
offset: Number of records to skip
|
||||
|
||||
Returns:
|
||||
Tuple of (list of user predictions with details, total count)
|
||||
"""
|
||||
query = self.db.query(UserPrediction).filter(
|
||||
UserPrediction.user_id == user_id
|
||||
).order_by(UserPrediction.viewed_at.desc())
|
||||
|
||||
total = query.count()
|
||||
user_predictions = query.limit(limit).offset(offset).all()
|
||||
|
||||
# Build response with full details
|
||||
result = []
|
||||
for up in user_predictions:
|
||||
pred = up.prediction
|
||||
match = pred.match if pred else None
|
||||
|
||||
result.append({
|
||||
'id': up.id,
|
||||
'user_id': up.user_id,
|
||||
'prediction_id': up.prediction_id,
|
||||
'viewed_at': up.viewed_at.isoformat() if up.viewed_at else None,
|
||||
'was_correct': up.was_correct,
|
||||
'prediction': {
|
||||
'id': pred.id if pred else None,
|
||||
'match_id': pred.match_id if pred else None,
|
||||
'energy_score': pred.energy_score if pred else None,
|
||||
'confidence': pred.confidence if pred else None,
|
||||
'predicted_winner': pred.predicted_winner if pred else None,
|
||||
'created_at': pred.created_at.isoformat() if pred and pred.created_at else None
|
||||
} if pred else None,
|
||||
'match': {
|
||||
'id': match.id if match else None,
|
||||
'home_team': match.home_team if match else None,
|
||||
'away_team': match.away_team if match else None,
|
||||
'date': match.date.isoformat() if match and match.date else None,
|
||||
'league': match.league if match else None,
|
||||
'status': match.status if match else None,
|
||||
'actual_winner': match.actual_winner if match else None
|
||||
} if match else None
|
||||
})
|
||||
|
||||
return result, total
|
||||
|
||||
def update_prediction_result(self, prediction_id: int, actual_winner: Optional[str]) -> None:
|
||||
"""
|
||||
Update all user predictions for a given prediction with match result.
|
||||
|
||||
This is called when a match completes to mark which predictions were correct.
|
||||
|
||||
Args:
|
||||
prediction_id: ID of the prediction
|
||||
actual_winner: Actual winner of the match ("home", "away", "draw", or None)
|
||||
"""
|
||||
# Get prediction
|
||||
prediction = self.db.query(Prediction).filter(Prediction.id == prediction_id).first()
|
||||
if not prediction:
|
||||
return
|
||||
|
||||
# Update match result
|
||||
match = self.db.query(Match).filter(Match.id == prediction.match_id).first()
|
||||
if match:
|
||||
match.actual_winner = actual_winner
|
||||
|
||||
# Determine if prediction was correct
|
||||
was_correct = False
|
||||
if actual_winner:
|
||||
# Normalize winner comparison
|
||||
predicted = prediction.predicted_winner.lower()
|
||||
actual = actual_winner.lower()
|
||||
|
||||
if predicted == actual:
|
||||
was_correct = True
|
||||
elif (predicted == 'home' and actual == 'home_team') or \
|
||||
(predicted == 'away' and actual == 'away_team'):
|
||||
was_correct = True
|
||||
|
||||
# Update all user predictions for this prediction
|
||||
user_predictions = self.db.query(UserPrediction).filter(
|
||||
UserPrediction.prediction_id == prediction_id
|
||||
).all()
|
||||
|
||||
for up in user_predictions:
|
||||
up.was_correct = was_correct
|
||||
|
||||
self.db.commit()
|
||||
|
||||
def get_user_stats(self, user_id: int) -> dict:
|
||||
"""
|
||||
Calculate user statistics including accuracy and ROI.
|
||||
|
||||
Args:
|
||||
user_id: ID of the user
|
||||
|
||||
Returns:
|
||||
Dictionary with statistics:
|
||||
- total_predictions_viewed: Total predictions viewed
|
||||
- correct_predictions: Number of correct predictions
|
||||
- incorrect_predictions: Number of incorrect predictions
|
||||
- accuracy_rate: Accuracy as percentage
|
||||
- roi: Return on Investment in EUR
|
||||
"""
|
||||
user_predictions = self.db.query(UserPrediction).filter(
|
||||
UserPrediction.user_id == user_id
|
||||
).all()
|
||||
|
||||
total = len(user_predictions)
|
||||
correct = sum(1 for up in user_predictions if up.was_correct is True)
|
||||
incorrect = sum(1 for up in user_predictions if up.was_correct is False)
|
||||
|
||||
# Calculate accuracy
|
||||
accuracy_rate = 0.0
|
||||
if correct + incorrect > 0:
|
||||
accuracy_rate = (correct / (correct + incorrect)) * 100
|
||||
|
||||
# Calculate ROI
|
||||
# Assumptions: Each correct prediction = +100€, Each incorrect = -50€
|
||||
# This is a simplified model - can be adjusted based on actual betting rules
|
||||
roi = (correct * 100) - (incorrect * 50)
|
||||
|
||||
return {
|
||||
'total_predictions_viewed': total,
|
||||
'correct_predictions': correct,
|
||||
'incorrect_predictions': incorrect,
|
||||
'accuracy_rate': round(accuracy_rate, 1),
|
||||
'roi': roi
|
||||
}
|
||||
Reference in New Issue
Block a user