432 lines
12 KiB
Python
432 lines
12 KiB
Python
"""
|
|
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
|