first commit
This commit is contained in:
431
app/services/config_management.py
Normal file
431
app/services/config_management.py
Normal file
@@ -0,0 +1,431 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user