""" Configuration Management Service. Provides functions to retrieve and update tryout configurations. Handles configuration changes for scoring, selection, and normalization modes. """ import logging from typing import Any, Dict, Literal, Optional from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.models.tryout import Tryout from app.models.tryout_stats import TryoutStats logger = logging.getLogger(__name__) async def get_config( db: AsyncSession, website_id: int, tryout_id: str, ) -> Tryout: """ Fetch tryout configuration for a specific tryout. Returns all configuration fields including scoring_mode, selection_mode, normalization_mode, and other settings. Args: db: Async database session website_id: Website identifier tryout_id: Tryout identifier Returns: Tryout model with all configuration fields Raises: ValueError: If tryout not found """ result = await db.execute( select(Tryout).where( Tryout.website_id == website_id, Tryout.tryout_id == tryout_id, ) ) tryout = result.scalar_one_or_none() if tryout is None: raise ValueError( f"Tryout {tryout_id} not found for website {website_id}" ) return tryout async def update_config( db: AsyncSession, website_id: int, tryout_id: str, config_updates: Dict[str, Any], ) -> Tryout: """ Update tryout configuration with provided fields. Accepts a dictionary of configuration updates and applies them to the tryout configuration. Only provided fields are updated. Args: db: Async database session website_id: Website identifier tryout_id: Tryout identifier config_updates: Dictionary of configuration fields to update Returns: Updated Tryout model Raises: ValueError: If tryout not found or invalid field provided """ # Fetch tryout result = await db.execute( select(Tryout).where( Tryout.website_id == website_id, Tryout.tryout_id == tryout_id, ) ) tryout = result.scalar_one_or_none() if tryout is None: raise ValueError( f"Tryout {tryout_id} not found for website {website_id}" ) # Valid configuration fields valid_fields = { "name", "description", "scoring_mode", "selection_mode", "normalization_mode", "min_sample_for_dynamic", "static_rataan", "static_sb", "ai_generation_enabled", "hybrid_transition_slot", "min_calibration_sample", "theta_estimation_method", "fallback_to_ctt_on_error", } # Update only valid fields updated_fields = [] for field, value in config_updates.items(): if field not in valid_fields: logger.warning(f"Skipping invalid config field: {field}") continue setattr(tryout, field, value) updated_fields.append(field) if not updated_fields: logger.warning(f"No valid config fields to update for tryout {tryout_id}") await db.flush() logger.info( f"Updated config for tryout {tryout_id}, website {website_id}: " f"{', '.join(updated_fields)}" ) return tryout async def toggle_normalization_mode( db: AsyncSession, website_id: int, tryout_id: str, new_mode: Literal["static", "dynamic", "hybrid"], ) -> Tryout: """ Toggle normalization mode for a tryout. Updates the normalization_mode field. If switching to "auto" (dynamic mode), checks if threshold is met and logs appropriate warnings. Args: db: Async database session website_id: Website identifier tryout_id: Tryout identifier new_mode: New normalization mode ("static", "dynamic", "hybrid") Returns: Updated Tryout model Raises: ValueError: If tryout not found or invalid mode provided """ if new_mode not in ["static", "dynamic", "hybrid"]: raise ValueError( f"Invalid normalization_mode: {new_mode}. " "Must be 'static', 'dynamic', or 'hybrid'" ) # Fetch tryout with stats result = await db.execute( select(Tryout).where( Tryout.website_id == website_id, Tryout.tryout_id == tryout_id, ) ) tryout = result.scalar_one_or_none() if tryout is None: raise ValueError( f"Tryout {tryout_id} not found for website {website_id}" ) old_mode = tryout.normalization_mode tryout.normalization_mode = new_mode # Fetch stats for participant count stats_result = await db.execute( select(TryoutStats).where( TryoutStats.website_id == website_id, TryoutStats.tryout_id == tryout_id, ) ) stats = stats_result.scalar_one_or_none() participant_count = stats.participant_count if stats else 0 min_sample = tryout.min_sample_for_dynamic # Log warnings and suggestions based on mode change if new_mode == "dynamic": if participant_count < min_sample: logger.warning( f"Switching to dynamic normalization with only {participant_count} " f"participants (threshold: {min_sample}). " "Dynamic normalization may produce unreliable results." ) else: logger.info( f"Switching to dynamic normalization with {participant_count} " f"participants (threshold: {min_sample}). " "Ready for dynamic normalization." ) elif new_mode == "hybrid": if participant_count >= min_sample: logger.info( f"Switching to hybrid normalization with {participant_count} " f"participants (threshold: {min_sample}). " "Will use dynamic normalization immediately." ) else: logger.info( f"Switching to hybrid normalization with {participant_count} " f"participants (threshold: {min_sample}). " f"Will use static normalization until {min_sample} participants reached." ) await db.flush() logger.info( f"Toggled normalization mode for tryout {tryout_id}, " f"website {website_id}: {old_mode} -> {new_mode}" ) return tryout async def get_normalization_config( db: AsyncSession, website_id: int, tryout_id: str, ) -> Dict[str, Any]: """ Get normalization configuration summary. Returns current normalization mode, static values, dynamic values, participant count, and threshold status. Args: db: Async database session website_id: Website identifier tryout_id: Tryout identifier Returns: Dictionary with normalization configuration summary Raises: ValueError: If tryout not found """ # Fetch tryout config tryout = await get_config(db, website_id, tryout_id) # Fetch stats stats_result = await db.execute( select(TryoutStats).where( TryoutStats.website_id == website_id, TryoutStats.tryout_id == tryout_id, ) ) stats = stats_result.scalar_one_or_none() # Determine threshold status participant_count = stats.participant_count if stats else 0 min_sample = tryout.min_sample_for_dynamic threshold_ready = participant_count >= min_sample participants_needed = max(0, min_sample - participant_count) # Determine current effective mode current_mode = tryout.normalization_mode if current_mode == "hybrid": effective_mode = "dynamic" if threshold_ready else "static" else: effective_mode = current_mode return { "tryout_id": tryout_id, "normalization_mode": current_mode, "effective_mode": effective_mode, "static_rataan": tryout.static_rataan, "static_sb": tryout.static_sb, "dynamic_rataan": stats.rataan if stats else None, "dynamic_sb": stats.sb if stats else None, "participant_count": participant_count, "min_sample_for_dynamic": min_sample, "threshold_ready": threshold_ready, "participants_needed": participants_needed, } async def reset_normalization_stats( db: AsyncSession, website_id: int, tryout_id: str, ) -> TryoutStats: """ Reset TryoutStats to initial values. Resets participant_count to 0 and clears running sums. Switches normalization_mode to "static" temporarily. Args: db: Async database session website_id: Website identifier tryout_id: Tryout identifier Returns: Reset TryoutStats record Raises: ValueError: If tryout not found """ # Fetch tryout tryout_result = await db.execute( select(Tryout).where( Tryout.website_id == website_id, Tryout.tryout_id == tryout_id, ) ) tryout = tryout_result.scalar_one_or_none() if tryout is None: raise ValueError( f"Tryout {tryout_id} not found for website {website_id}" ) # Switch to static mode temporarily tryout.normalization_mode = "static" # Fetch or create stats stats_result = await db.execute( select(TryoutStats).where( TryoutStats.website_id == website_id, TryoutStats.tryout_id == tryout_id, ) ) stats = stats_result.scalar_one_or_none() if stats is None: # Create new empty stats record stats = TryoutStats( website_id=website_id, tryout_id=tryout_id, participant_count=0, total_nm_sum=0.0, total_nm_sq_sum=0.0, rataan=None, sb=None, min_nm=None, max_nm=None, ) db.add(stats) else: # Reset existing stats stats.participant_count = 0 stats.total_nm_sum = 0.0 stats.total_nm_sq_sum = 0.0 stats.rataan = None stats.sb = None stats.min_nm = None stats.max_nm = None await db.flush() logger.info( f"Reset normalization stats for tryout {tryout_id}, " f"website {website_id}. Normalization mode switched to static." ) return stats async def get_full_config( db: AsyncSession, website_id: int, tryout_id: str, ) -> Dict[str, Any]: """ Get full tryout configuration including stats. Returns all configuration fields plus current statistics. Args: db: Async database session website_id: Website identifier tryout_id: Tryout identifier Returns: Dictionary with full configuration and stats Raises: ValueError: If tryout not found """ # Fetch tryout config tryout = await get_config(db, website_id, tryout_id) # Fetch stats stats_result = await db.execute( select(TryoutStats).where( TryoutStats.website_id == website_id, TryoutStats.tryout_id == tryout_id, ) ) stats = stats_result.scalar_one_or_none() # Build config dictionary config = { "tryout_id": tryout.tryout_id, "name": tryout.name, "description": tryout.description, "scoring_mode": tryout.scoring_mode, "selection_mode": tryout.selection_mode, "normalization_mode": tryout.normalization_mode, "min_sample_for_dynamic": tryout.min_sample_for_dynamic, "static_rataan": tryout.static_rataan, "static_sb": tryout.static_sb, "ai_generation_enabled": tryout.ai_generation_enabled, "hybrid_transition_slot": tryout.hybrid_transition_slot, "min_calibration_sample": tryout.min_calibration_sample, "theta_estimation_method": tryout.theta_estimation_method, "fallback_to_ctt_on_error": tryout.fallback_to_ctt_on_error, "stats": { "participant_count": stats.participant_count if stats else 0, "rataan": stats.rataan if stats else None, "sb": stats.sb if stats else None, "min_nm": stats.min_nm if stats else None, "max_nm": stats.max_nm if stats else None, "last_calculated": stats.last_calculated if stats else None, }, "created_at": tryout.created_at, "updated_at": tryout.updated_at, } return config