Files
yellow-bank-soal/app/services/config_management.py
Dwindi Ramadhana cf193d7ea0 first commit
2026-03-21 23:32:59 +07:00

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