f1commentator / reachy_f1_commentator /src /placeholder_resolver.py
d10g's picture
Initial Import
c95ad37
"""
Placeholder Resolver for Enhanced Commentary System.
This module provides placeholder resolution for commentary templates,
converting template placeholders into formatted values based on context data.
Validates: Requirements 10.2
"""
import logging
from typing import Optional
from reachy_f1_commentator.src.enhanced_models import ContextData
from reachy_f1_commentator.src.openf1_data_cache import OpenF1DataCache
logger = logging.getLogger(__name__)
class PlaceholderResolver:
"""
Resolves template placeholders to formatted values.
Handles all placeholder types including driver names, positions, times,
gaps, tire data, weather, speeds, and narrative references.
Validates: Requirements 10.2
"""
def __init__(self, data_cache: OpenF1DataCache):
"""
Initialize placeholder resolver.
Args:
data_cache: OpenF1 data cache for driver info and other static data
"""
self.data_cache = data_cache
logger.debug("PlaceholderResolver initialized")
def resolve(self, placeholder: str, context: ContextData) -> Optional[str]:
"""
Resolve a single placeholder to its value.
Args:
placeholder: Placeholder name (e.g., "driver1", "gap", "tire_compound")
context: Context data containing all available information
Returns:
Formatted string value if placeholder can be resolved, None otherwise
"""
# Remove curly braces if present
placeholder = placeholder.strip('{}')
try:
# Driver placeholders
if placeholder in ["driver1", "driver"]:
return self._resolve_driver_name(context.event.driver, context)
elif placeholder == "driver2":
# For overtake events, get the overtaken driver
if hasattr(context.event, 'overtaken_driver'):
return self._resolve_driver_name(context.event.overtaken_driver, context)
return None
# Pronoun placeholders
elif placeholder in ["pronoun", "pronoun1"]:
return self._resolve_pronoun(context.event.driver)
elif placeholder == "pronoun2":
if hasattr(context.event, 'overtaken_driver'):
return self._resolve_pronoun(context.event.overtaken_driver)
return None
# Team placeholders
elif placeholder in ["team1", "team"]:
return self._resolve_team_name(context.event.driver)
elif placeholder == "team2":
if hasattr(context.event, 'overtaken_driver'):
return self._resolve_team_name(context.event.overtaken_driver)
return None
# Position placeholders
elif placeholder == "position":
if context.position_after is not None:
return self._resolve_position(context.position_after)
return None
elif placeholder == "position_before":
if context.position_before is not None:
return self._resolve_position(context.position_before)
return None
elif placeholder == "positions_gained":
if context.positions_gained is not None:
return str(context.positions_gained)
return None
# Gap placeholders
elif placeholder == "gap":
if context.gap_to_leader is not None:
return self._resolve_gap(context.gap_to_leader)
elif context.gap_to_ahead is not None:
return self._resolve_gap(context.gap_to_ahead)
return None
elif placeholder == "gap_to_leader":
if context.gap_to_leader is not None:
return self._resolve_gap(context.gap_to_leader)
return None
elif placeholder == "gap_to_ahead":
if context.gap_to_ahead is not None:
return self._resolve_gap(context.gap_to_ahead)
return None
elif placeholder == "gap_trend":
return context.gap_trend
# Time placeholders
elif placeholder == "lap_time":
if hasattr(context.event, 'lap_time') and context.event.lap_time:
return self._resolve_lap_time(context.event.lap_time)
return None
elif placeholder == "sector_1_time":
if context.sector_1_time is not None:
return self._resolve_sector_time(context.sector_1_time)
return None
elif placeholder == "sector_2_time":
if context.sector_2_time is not None:
return self._resolve_sector_time(context.sector_2_time)
return None
elif placeholder == "sector_3_time":
if context.sector_3_time is not None:
return self._resolve_sector_time(context.sector_3_time)
return None
# Sector status placeholders
elif placeholder == "sector_status":
# Return the best sector status available
if context.sector_1_status == "purple":
return "purple sector in sector 1"
elif context.sector_2_status == "purple":
return "purple sector in sector 2"
elif context.sector_3_status == "purple":
return "purple sector in sector 3"
return None
# Tire placeholders
elif placeholder == "tire_compound":
if context.current_tire_compound:
return self._resolve_tire_compound(context.current_tire_compound)
return None
elif placeholder == "tire_age":
if context.current_tire_age is not None:
return f"{context.current_tire_age} laps old"
return None
elif placeholder == "tire_age_diff":
if context.tire_age_differential is not None:
return str(abs(context.tire_age_differential))
return None
elif placeholder == "new_tire_compound":
if context.current_tire_compound:
return self._resolve_tire_compound(context.current_tire_compound)
return None
elif placeholder == "old_tire_compound":
if context.previous_tire_compound:
return self._resolve_tire_compound(context.previous_tire_compound)
return None
elif placeholder == "old_tire_age":
if context.previous_tire_age is not None:
return f"{context.previous_tire_age} laps"
return None
# Speed placeholders
elif placeholder == "speed":
if context.speed is not None:
return self._resolve_speed(context.speed)
return None
elif placeholder == "speed_trap":
if context.speed_trap is not None:
return self._resolve_speed(context.speed_trap)
return None
# DRS placeholder
elif placeholder == "drs_status":
if context.drs_active:
return "with DRS"
return ""
# Weather placeholders
elif placeholder == "track_temp":
if context.track_temp is not None:
return f"{context.track_temp:.1f}°C"
return None
elif placeholder == "air_temp":
if context.air_temp is not None:
return f"{context.air_temp:.1f}°C"
return None
elif placeholder == "weather_condition":
return self._resolve_weather_condition(context)
# Pit stop placeholders
elif placeholder == "pit_duration":
if context.pit_duration is not None:
return f"{context.pit_duration:.1f} seconds"
return None
elif placeholder == "pit_count":
return str(context.pit_count)
# Narrative placeholders
elif placeholder == "narrative_reference":
return self._resolve_narrative_reference(context)
elif placeholder == "battle_laps":
# Extract from narrative context if available
for narrative_id in context.active_narratives:
if "battle" in narrative_id.lower():
# Try to extract lap count from narrative
# This would need to be enhanced with actual narrative data
return "several"
return None
elif placeholder == "positions_gained_total":
if context.positions_gained is not None:
return str(context.positions_gained)
return None
# Championship placeholders
elif placeholder == "championship_position":
if context.driver_championship_position is not None:
return self._resolve_championship_position(context.driver_championship_position)
return None
elif placeholder == "championship_gap":
if context.championship_gap_to_leader is not None:
return f"{context.championship_gap_to_leader} points"
return None
elif placeholder == "championship_context":
return self._resolve_championship_context(context)
# Unknown placeholder
else:
logger.warning(f"Unknown placeholder: {placeholder}")
return None
except Exception as e:
logger.error(f"Error resolving placeholder '{placeholder}': {e}")
return None
def _resolve_driver_name(self, driver_identifier: str, context: ContextData) -> str:
"""
Resolve driver name to last name only for brevity.
Args:
driver_identifier: Driver identifier (name, number, or acronym)
context: Context data
Returns:
Driver's last name, or identifier if not found
"""
# Try to get driver info from cache
driver_info = self.data_cache.get_driver_info(driver_identifier)
if driver_info and driver_info.last_name:
return driver_info.last_name
# Fallback: return the identifier as-is
return str(driver_identifier)
def _resolve_pronoun(self, driver_identifier: str) -> str:
"""
Resolve pronoun (he/she) for driver.
Note: Currently defaults to "he" as gender information is not
available in OpenF1 API. This could be enhanced with a manual
mapping if needed.
Args:
driver_identifier: Driver identifier
Returns:
Pronoun string ("he" or "she")
"""
# TODO: Add gender mapping if needed
# For now, default to "he" as most F1 drivers are male
# This could be enhanced with a configuration mapping
return "he"
def _resolve_team_name(self, driver_identifier: str) -> Optional[str]:
"""
Resolve team name for driver.
Args:
driver_identifier: Driver identifier
Returns:
Team name if found, None otherwise
"""
driver_info = self.data_cache.get_driver_info(driver_identifier)
if driver_info and driver_info.team_name:
return driver_info.team_name
return None
def _resolve_gap(self, gap_seconds: float) -> str:
"""
Format gap appropriately based on size.
Rules:
- Under 1s: "0.8 seconds" (one decimal)
- 1-10s: "2.3 seconds" (one decimal)
- Over 10s: "15 seconds" (nearest second)
Args:
gap_seconds: Gap in seconds
Returns:
Formatted gap string
"""
if gap_seconds < 1.0:
return f"{gap_seconds:.1f} seconds"
elif gap_seconds < 10.0:
return f"{gap_seconds:.1f} seconds"
else:
return f"{int(round(gap_seconds))} seconds"
def _resolve_tire_compound(self, compound: str) -> str:
"""
Format tire compound name.
Ensures lowercase and correct terminology.
Args:
compound: Tire compound (SOFT, MEDIUM, HARD, INTERMEDIATE, WET)
Returns:
Formatted compound name (lowercase)
"""
compound_lower = compound.lower()
# Map common variations to standard names
compound_map = {
"soft": "soft",
"medium": "medium",
"hard": "hard",
"intermediate": "intermediate",
"inter": "intermediate",
"wet": "wet",
"wets": "wet"
}
return compound_map.get(compound_lower, compound_lower)
def _resolve_position(self, position: int) -> str:
"""
Format position as P1, P2, etc.
Args:
position: Position number
Returns:
Formatted position string
"""
return f"P{position}"
def _resolve_sector_time(self, sector_time: float) -> str:
"""
Format sector time.
Args:
sector_time: Sector time in seconds
Returns:
Formatted sector time (e.g., "23.456")
"""
return f"{sector_time:.3f}"
def _resolve_lap_time(self, lap_time: float) -> str:
"""
Format lap time.
Args:
lap_time: Lap time in seconds
Returns:
Formatted lap time (e.g., "1:23.456")
"""
minutes = int(lap_time // 60)
seconds = lap_time % 60
return f"{minutes}:{seconds:06.3f}"
def _resolve_speed(self, speed_kmh: float) -> str:
"""
Format speed in km/h.
Args:
speed_kmh: Speed in kilometers per hour
Returns:
Formatted speed string (e.g., "315 kilometers per hour")
"""
return f"{int(round(speed_kmh))} kilometers per hour"
def _resolve_weather_condition(self, context: ContextData) -> Optional[str]:
"""
Generate weather condition phrase.
Creates appropriate phrases based on weather data:
- "in these conditions" (general)
- "as the track heats up" (rising temperature)
- "with the wind picking up" (high wind)
- "in the wet conditions" (rain)
Args:
context: Context data with weather information
Returns:
Weather phrase if conditions are notable, None otherwise
"""
phrases = []
# Check for rain
if context.rainfall is not None and context.rainfall > 0:
return "in the wet conditions"
# Check for high wind
if context.wind_speed is not None and context.wind_speed > 20:
phrases.append("with the wind picking up")
# Check for high track temperature
if context.track_temp is not None and context.track_temp > 45:
phrases.append("as the track heats up")
# Check for high humidity
if context.humidity is not None and context.humidity > 70:
phrases.append("in these challenging conditions")
# Return first phrase if any, otherwise generic phrase
if phrases:
return phrases[0]
# If weather data exists but nothing notable, return generic phrase
if context.track_temp is not None or context.air_temp is not None:
return "in these conditions"
return None
def _resolve_narrative_reference(self, context: ContextData) -> Optional[str]:
"""
Generate narrative reference phrase.
Creates phrases based on active narratives:
- "continuing their battle"
- "on his comeback drive"
- "with the different tire strategies"
Args:
context: Context data with active narratives
Returns:
Narrative phrase if narratives are active, None otherwise
"""
if not context.active_narratives:
return None
# Get the first active narrative
narrative_id = context.active_narratives[0]
# Generate phrase based on narrative type
if "battle" in narrative_id.lower():
return "continuing their battle"
elif "comeback" in narrative_id.lower():
return "on his comeback drive"
elif "strategy" in narrative_id.lower():
return "with the different tire strategies"
elif "undercut" in narrative_id.lower():
return "attempting the undercut"
elif "overcut" in narrative_id.lower():
return "going for the overcut"
elif "championship" in narrative_id.lower():
return "in the championship fight"
# Generic fallback
return "as the story unfolds"
def _resolve_championship_context(self, context: ContextData) -> Optional[str]:
"""
Generate championship context phrase.
Creates phrases based on championship position:
- "the championship leader"
- "second in the standings"
- "fighting for third in the championship"
Args:
context: Context data with championship information
Returns:
Championship phrase if position is known, None otherwise
"""
if context.driver_championship_position is None:
return None
position = context.driver_championship_position
if position == 1:
return "the championship leader"
elif position == 2:
return "second in the standings"
elif position == 3:
return "third in the championship"
elif position <= 5:
return f"{self._ordinal(position)} in the championship"
elif position <= 10:
return f"fighting for {self._ordinal(position)} in the championship"
else:
return None
def _resolve_championship_position(self, position: int) -> str:
"""
Format championship position.
Args:
position: Championship position
Returns:
Formatted position (e.g., "1st", "2nd", "3rd")
"""
return self._ordinal(position)
def _ordinal(self, n: int) -> str:
"""
Convert number to ordinal string.
Args:
n: Number
Returns:
Ordinal string (e.g., "1st", "2nd", "3rd", "4th")
"""
if 10 <= n % 100 <= 20:
suffix = "th"
else:
suffix = {1: "st", 2: "nd", 3: "rd"}.get(n % 10, "th")
return f"{n}{suffix}"