import io import tempfile from pathlib import Path import gradio as gr import yaml from data.race_data import get_lap_window, get_race_window, _CACHE_DIR, _race_key from modal_backend.client import ( call_generate_commentary, call_persona_chat, call_reason_strategy, get_commentary_loading_message, get_persona_loading_message, get_strategy_loading_message, transcribe_audio, ) from prompts.builder import build_commentary_prompt, build_persona_prompt RACES_PATH = Path(__file__).parent / "data" / "curated_races.yaml" _HISTORICAL_DRIVERS = {"senna", "schumacher"} _ACTIVE_DRIVERS = {"verstappen", "hamilton", "norris"} PERSONA_DRIVERS = ["Verstappen", "Hamilton", "Norris", "Senna", "Schumacher"] F1_CSS = """ @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&display=swap'); :root { --f1-bg: #0f0f0f; --f1-panel: #171717; --f1-panel-soft: #202020; --f1-text: #f4f4f4; --f1-muted: #a8a8a8; --f1-accent: #e8002d; --f1-line: rgba(255, 255, 255, 0.13); } .gradio-container { background: radial-gradient(circle at 20% 0%, rgba(232, 0, 45, 0.18), transparent 32rem), linear-gradient(135deg, #060606 0%, var(--f1-bg) 48%, #181818 100%) !important; color: var(--f1-text) !important; min-height: 100vh; } #f1-shell { max-width: 1220px; margin: 0 auto; padding: 20px 18px 42px; } #f1-hero { position: relative; min-height: 270px; overflow: hidden; border: 1px solid var(--f1-line); border-radius: 18px; background: #090909; box-shadow: 0 24px 80px rgba(0, 0, 0, 0.45); isolation: isolate; } #f1-hero::before { content: ""; position: absolute; inset: 0; z-index: 1; background: linear-gradient(90deg, rgba(15, 15, 15, 0.92) 0%, rgba(15, 15, 15, 0.76) 42%, rgba(15, 15, 15, 0.3) 100%), linear-gradient(180deg, rgba(15, 15, 15, 0.1), rgba(15, 15, 15, 0.82)); } #f1-hero::after { content: ""; position: absolute; inset: 0; z-index: 2; background: repeating-linear-gradient(120deg, rgba(255, 255, 255, 0.08) 0 1px, transparent 1px 22px); opacity: 0.18; pointer-events: none; } .driver-bg { position: absolute; inset: 0; background-position: right center; background-repeat: no-repeat; background-size: min(46vw, 520px) auto; opacity: 0; transform: scale(1.04); animation: driverFade 35s infinite; filter: saturate(0.95) contrast(1.05); } .driver-bg.verstappen { background-image: url("https://upload.wikimedia.org/wikipedia/commons/thumb/5/52/2024-08-25_Motorsport%2C_Formel_1%2C_Gro%C3%9Fer_Preis_der_Niederlande_2024_STP_3973_by_Stepro_%28medium_crop%29.jpg/900px-2024-08-25_Motorsport%2C_Formel_1%2C_Gro%C3%9Fer_Preis_der_Niederlande_2024_STP_3973_by_Stepro_%28medium_crop%29.jpg"); } .driver-bg.hamilton { background-image: url("https://upload.wikimedia.org/wikipedia/commons/thumb/d/d3/Prime_Minister_Keir_Starmer_meets_Sir_Lewis_Hamilton_%2854566928382%29_%28cropped%29.jpg/900px-Prime_Minister_Keir_Starmer_meets_Sir_Lewis_Hamilton_%2854566928382%29_%28cropped%29.jpg"); animation-delay: 7s; } .driver-bg.schumacher { background-image: url("https://upload.wikimedia.org/wikipedia/commons/thumb/3/32/A%C3%A9cio_Neves%2C_Michael_Schumacher_e_Didi_%28Cropped%29.jpg/900px-A%C3%A9cio_Neves%2C_Michael_Schumacher_e_Didi_%28Cropped%29.jpg"); animation-delay: 14s; } .driver-bg.senna { background-image: url("https://upload.wikimedia.org/wikipedia/commons/thumb/6/65/Ayrton_Senna_9_%28cropped%29.jpg/900px-Ayrton_Senna_9_%28cropped%29.jpg"); animation-delay: 21s; } .driver-bg.norris { background-image: url("https://upload.wikimedia.org/wikipedia/commons/thumb/9/90/2024-08-25_Motorsport%2C_Formel_1%2C_Gro%C3%9Fer_Preis_der_Niederlande_2024_STP_3968_by_Stepro_%28cropped2%29.jpg/900px-2024-08-25_Motorsport%2C_Formel_1%2C_Gro%C3%9Fer_Preis_der_Niederlande_2024_STP_3968_by_Stepro_%28cropped2%29.jpg"); animation-delay: 28s; } @keyframes driverFade { 0%, 100% { opacity: 0; transform: scale(1.04); } 4%, 18% { opacity: 0.58; transform: scale(1); } 23% { opacity: 0; transform: scale(1.015); } } .hero-content { position: relative; z-index: 3; max-width: 680px; padding: 34px; } .hero-kicker { display: inline-flex; align-items: center; gap: 10px; margin-bottom: 18px; color: #f6f6f6; font-family: "JetBrains Mono", monospace; font-size: 0.78rem; font-weight: 700; letter-spacing: 0; text-transform: uppercase; } .hero-kicker::before { content: ""; display: inline-block; width: 36px; height: 3px; background: var(--f1-accent); border-radius: 999px; } .hero-title { margin: 0; color: #fff; font-size: clamp(2.4rem, 6vw, 4.9rem); line-height: 0.94; font-weight: 800; letter-spacing: 0; } .hero-title span { color: var(--f1-accent); } .hero-meta { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 22px; } .hero-chip { border: 1px solid rgba(255, 255, 255, 0.18); border-radius: 999px; padding: 7px 11px; background: rgba(15, 15, 15, 0.58); color: #f5f5f5; font-family: "JetBrains Mono", monospace; font-size: 0.76rem; } .hero-scanline { position: absolute; left: 0; right: 0; bottom: 0; z-index: 4; height: 4px; background: linear-gradient(90deg, var(--f1-accent), #ffffff, #2dd4bf, var(--f1-accent)); background-size: 220% 100%; animation: scanline 8s linear infinite; } @keyframes scanline { to { background-position: 220% 0; } } #race-topbar { margin-top: 14px; background: rgba(23, 23, 23, 0.95); border: 1px solid var(--f1-line); border-left: 4px solid var(--f1-accent); border-radius: 12px; padding: 16px; } #race-topbar label, #race-topbar span, #race-topbar input, #race-topbar button, #race-topbar select { font-family: "JetBrains Mono", monospace !important; } .gradio-container .form, .gradio-container .block, .gradio-container .panel, .gradio-container .tabs, .gradio-container .tabitem { border-color: var(--f1-line) !important; } .gradio-container input, .gradio-container textarea, .gradio-container select { background: #101010 !important; color: var(--f1-text) !important; } .gradio-container button { border-radius: 8px !important; } .gradio-container button.primary { background: var(--f1-accent) !important; color: white !important; border-color: var(--f1-accent) !important; box-shadow: 0 10px 28px rgba(232, 0, 45, 0.26); } .timing-data, .stub-panel textarea, .stub-panel input { font-family: "JetBrains Mono", monospace !important; } button.primary, .selected, [aria-selected="true"] { border-color: var(--f1-accent) !important; } .tabs { margin-top: 14px; background: rgba(15, 15, 15, 0.78) !important; border-radius: 14px; } .stub-panel { background: var(--f1-panel-soft); border: 1px solid #2b2b2b; padding: 16px; } #historical-notice { background: #1a1a1a; border: 1px dashed #444; border-radius: 4px; padding: 8px 12px; color: var(--f1-muted); font-family: "JetBrains Mono", monospace; font-size: 0.8rem; } .persona-chat-output { background: var(--f1-panel-soft); border: 1px solid #2b2b2b; border-radius: 4px; font-family: "JetBrains Mono", monospace; min-height: 120px; } @media (max-width: 760px) { #f1-shell { padding: 12px 8px 32px; } #f1-hero { min-height: 360px; } .driver-bg { background-size: 88vw auto; background-position: center bottom; } #f1-hero::before { background: linear-gradient(180deg, rgba(15, 15, 15, 0.82) 0%, rgba(15, 15, 15, 0.58) 54%, rgba(15, 15, 15, 0.9) 100%); } .hero-content { padding: 24px; } } #race-topbar { overflow: visible !important; } #f1-shell { overflow: visible !important; } """ HERO_HTML = """
Race Intelligence

F1 Paddock Oracle

Verstappen Hamilton Schumacher Senna Norris
""" def load_curated_races() -> list[dict]: with open(RACES_PATH, encoding="utf-8") as race_file: return yaml.safe_load(race_file)["races"] def race_label(race: dict) -> str: return f"{race['season']} {race['name']} - {race['circuit']}" def race_choice_value(race: dict) -> str: return f"{race['season']}:{race['round']}" def selected_race_from_value(selected_value: str, races: list[dict]) -> dict: for race in races: if race_choice_value(race) == selected_value: return race return races[0] def _top_two_drivers(season: int, round_num: int, pivot_lap: int) -> tuple[str, str, str]: import pandas as pd key = _race_key(season, round_num) parquet_path = _CACHE_DIR / f"{key}_laps.parquet" laps = pd.read_parquet(parquet_path) at_pivot = laps[laps["lap_number"] == pivot_lap].sort_values("position") if len(at_pivot) < 2: last_lap = int(laps["lap_number"].max()) at_pivot = laps[laps["lap_number"] == last_lap].sort_values("position") driver_a = at_pivot.iloc[0]["driver_code"] driver_b = at_pivot.iloc[1]["driver_code"] team_name = at_pivot.iloc[0]["team"] return driver_a, driver_b, team_name def _generate_commentary( race: dict, pivot_lap: int, style: str, ) -> tuple[str | None, str]: season = race["season"] round_num = race["round"] driver_a, driver_b, team_name = _top_two_drivers(season, round_num, int(pivot_lap)) lap_df = get_lap_window(season, round_num, int(pivot_lap), driver_a, driver_b) mode = "broadcast" if style == "Broadcast" else "radio" prompt = build_commentary_prompt(lap_df, team_name, mode) result = call_generate_commentary(prompt, style=mode) text = result.get("text", "") audio_bytes: bytes = result.get("audio_wav", b"") audio_path = None if audio_bytes: tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) tmp.write(audio_bytes) tmp.flush() audio_path = tmp.name return audio_path, text def build_commentary_tab(race_state: gr.State) -> None: with gr.Tab("Commentary (TV)"): style_toggle = gr.Radio( choices=["Broadcast", "Radio"], value="Broadcast", label="Commentary style", interactive=True, ) lap_slider = gr.Slider( minimum=1, maximum=80, value=20, step=1, label="Pivot lap", interactive=True, ) generate_btn = gr.Button("Generate commentary", variant="primary") loading_status = gr.Textbox( label="Status", value="", interactive=False, visible=False, ) commentary_audio = gr.Audio( label="Commentary audio", type="filepath", interactive=False, ) commentary_text = gr.Textbox( label="Commentary text", interactive=False, lines=4, elem_classes="timing-data", ) def on_generate(race, pivot_lap, style): loading = get_commentary_loading_message() yield gr.update(value=loading, visible=True), None, "" audio_path, text = _generate_commentary(race, pivot_lap, style) yield gr.update(value="Done.", visible=True), audio_path, text generate_btn.click( fn=on_generate, inputs=[race_state, lap_slider, style_toggle], outputs=[loading_status, commentary_audio, commentary_text], ) _PIVOT_LAP_MIN = 15 _PIVOT_LAP_MAX = 45 def _build_timing_table(race_window_df) -> str: if race_window_df.empty: return "No data available." uniq_laps = sorted(race_window_df["lap_number"].unique()) mid_lap = uniq_laps[len(uniq_laps) // 2] snap = race_window_df[race_window_df["lap_number"] == mid_lap].copy().sort_values("position") lines = [f"{'LAP':<5} {'DRV':<6} {'POS':<5} {'GAP(s)':>8} {'CMPD':<10} {'AGE':>5}"] lines.append("-" * 44) for _, row in snap.iterrows(): import math gap_val = row["gap_to_leader_s"] gap = "LEADER" if (math.isnan(gap_val) or gap_val == 0) else f"+{gap_val:.3f}" lines.append( f"{int(mid_lap):<5} {row['driver_code']:<6} {int(row['position']):<5} {gap:>8} {str(row['compound']):<10} {int(row['tyre_life']):>5}" ) return "\n".join(lines) def _build_strategy_prompt(race: dict, pivot_lap: int, scenario: str, timing_table: str) -> str: return ( f"Race: {race['name']} ({race['season']}) at {race['circuit']}\n" f"Pivot lap: {pivot_lap}\n\n" f"### Race Snapshot\n\n{timing_table}\n\n" f"### What-If Scenario\n\n{scenario}\n\n" f"### Instructions\n\n" f"Reason through how this change affects pit windows, undercut/overcut risk, " f"tyre degradation, and track position. Narrate the alternate outcome with " f"specific lap numbers and position changes. Produce a plausible alternate final top-5." ) def _run_what_if(race: dict, pivot_lap: int, what_if_text: str): if not what_if_text or not what_if_text.strip(): yield "Enter a what-if scenario first.", "" return if pivot_lap < _PIVOT_LAP_MIN or pivot_lap > _PIVOT_LAP_MAX: yield ( f"Pivot lap {int(pivot_lap)} is outside the recommended range ({_PIVOT_LAP_MIN}–{_PIVOT_LAP_MAX}). " f"Adjust the lap slider and try again.", "", ) return try: race_window = get_race_window(race["season"], race["round"], int(pivot_lap)) except FileNotFoundError as exc: yield f"[Data not found: {exc}]", "" return timing_str = _build_timing_table(race_window) prompt = _build_strategy_prompt(race, pivot_lap, what_if_text.strip(), timing_str) yield "Connecting to the pit wall…", timing_str result = call_reason_strategy(prompt) reasoning = result.get("reasoning_chain", "") yield timing_str, reasoning def build_what_if_tab(race_state: gr.State) -> None: with gr.Tab("What-If"): lap_slider = gr.Slider( minimum=1, maximum=80, value=30, step=1, label="Pivot lap (works best for strategy changes in laps 15–45)", interactive=True, ) lap_warning = gr.Markdown(value="", visible=False) whatif_input = gr.Textbox( label="Change one variable (e.g. 'Hamilton pits 5 laps earlier on fresh mediums')", placeholder="Describe your what-if scenario...", lines=2, interactive=True, ) generate_btn = gr.Button("Generate", variant="primary") loading_status = gr.Textbox( label="Status", value="", interactive=False, visible=False, ) with gr.Row(): timing_table = gr.Textbox( label="Actual race snapshot", interactive=False, lines=15, elem_classes="timing-data", scale=1, ) reasoning_output = gr.Textbox( label="Nemotron reasoning", interactive=False, lines=15, elem_classes="timing-data", scale=1, ) def on_lap_change(pivot_lap): if pivot_lap < 15 or pivot_lap > 45: return gr.update( value=f"> Warning: lap {int(pivot_lap)} is outside the recommended 15–45 window. Strategy reasoning may be less reliable.", visible=True, ) return gr.update(value="", visible=False) lap_slider.change(fn=on_lap_change, inputs=[lap_slider], outputs=[lap_warning]) def on_generate(race, pivot_lap, what_if_text): loading = get_strategy_loading_message() first = True for left, right in _run_what_if(race, pivot_lap, what_if_text): status_val = loading if first else "Done." yield gr.update(value=status_val, visible=True), left, right first = False generate_btn.click( fn=on_generate, inputs=[race_state, lap_slider, whatif_input], outputs=[loading_status, timing_table, reasoning_output], ) def _race_context_string(race: dict) -> str: return ( f"{race['season']} {race['name']} at {race['circuit']}. " f"Round {race['round']} of the season." ) def build_persona_chat_tab(race_state: gr.State) -> None: with gr.Tab("Persona Chat"): driver_selector = gr.Radio( choices=PERSONA_DRIVERS, value="Verstappen", label="Select driver", elem_id="driver-selector", ) historical_notice = gr.Markdown( value="", elem_id="historical-notice", visible=False, ) race_context_display = gr.Textbox( label="Race context (seeded into prompt for active drivers)", interactive=False, elem_id="persona-race-context", lines=1, ) mic_input = gr.Audio( sources=["microphone"], type="numpy", label="Record your question", elem_id="persona-mic", ) transcription_box = gr.Textbox( label="Transcription - edit before sending", placeholder="Record audio above or type directly...", lines=3, interactive=True, elem_id="persona-transcription", ) with gr.Row(): send_btn = gr.Button("Send", variant="primary", scale=1) clear_btn = gr.Button("Clear", variant="secondary", scale=1) chat_output = gr.Textbox( label="Driver reply", interactive=False, lines=5, elem_classes="persona-chat-output", ) tts_output = gr.Audio( label="Voiced reply", type="numpy", interactive=False, autoplay=True, elem_id="persona-tts-output", ) def on_driver_selected(driver, race): key = driver.lower() is_historical = key in _HISTORICAL_DRIVERS if is_historical: notice = ( "> **Historical drivers don't use race telemetry** - " "Senna and Schumacher prompts are not seeded with current race data." ) ctx_display = "" else: notice = "" ctx_display = _race_context_string(race) if race else "" return gr.update(value=notice, visible=is_historical), ctx_display driver_selector.change( fn=on_driver_selected, inputs=[driver_selector, race_state], outputs=[historical_notice, race_context_display], ) def on_race_changed(race, driver): if driver.lower() in _HISTORICAL_DRIVERS: return "" return _race_context_string(race) if race else "" race_state.change( fn=on_race_changed, inputs=[race_state, driver_selector], outputs=[race_context_display], ) def on_audio_recorded(audio_data): if audio_data is None: return "" import numpy as np import scipy.io.wavfile as wav_writer sample_rate, audio_array = audio_data if audio_array.dtype != np.int16: audio_array = (audio_array * 32767).clip(-32768, 32767).astype(np.int16) buf = io.BytesIO() wav_writer.write(buf, sample_rate, audio_array) try: return transcribe_audio(buf.getvalue()) except Exception as exc: return f"[Transcription failed: {exc}]" mic_input.stop_recording( fn=on_audio_recorded, inputs=[mic_input], outputs=[transcription_box], ) def on_send(driver, user_text, race): if not user_text or not user_text.strip(): yield "Please record or type a question first.", None return key = driver.lower() ctx = _race_context_string(race) if key in _ACTIVE_DRIVERS and race else None try: system_prompt = build_persona_prompt(key, race_context=ctx) except FileNotFoundError as exc: yield f"[Persona error: {exc}]", None return yield get_persona_loading_message(), None try: result = call_persona_chat( system_prompt=system_prompt, user_message=user_text.strip(), ) except Exception as exc: yield f"[Modal call failed: {exc}]", None return reply_text = result.get("text", "") audio_bytes = result.get("audio_wav", b"") audio_numpy = None if audio_bytes: import numpy as np import scipy.io.wavfile as wav_reader buf = io.BytesIO(audio_bytes) sample_rate, audio_array = wav_reader.read(buf) audio_numpy = (sample_rate, audio_array) yield reply_text, audio_numpy send_event = send_btn.click( fn=on_send, inputs=[driver_selector, transcription_box, race_state], outputs=[chat_output, tts_output], ) clear_btn.click( fn=lambda: ("", None, ""), outputs=[transcription_box, tts_output, chat_output], cancels=[send_event], ) def build_app() -> gr.Blocks: races = load_curated_races() choices = [(race_label(race), race_choice_value(race)) for race in races] initial_value = race_choice_value(races[0]) with gr.Blocks(css=F1_CSS, title="F1 Paddock Oracle") as app: with gr.Column(elem_id="f1-shell"): race_state = gr.State(races[0]) gr.HTML(HERO_HTML) with gr.Row(elem_id="race-topbar"): race_dropdown = gr.Dropdown( label="15 hand-picked races", choices=choices, value=initial_value, interactive=True, allow_custom_value=False, filterable=False, scale=3, ) lap_range = gr.Slider( minimum=1, maximum=80, value=1, step=1, label="Lap range", interactive=True, scale=2, ) race_dropdown.change( fn=lambda selected: selected_race_from_value(selected, races), inputs=race_dropdown, outputs=race_state, ) with gr.Tabs(): build_commentary_tab(race_state) build_what_if_tab(race_state) build_persona_chat_tab(race_state) return app demo = build_app() if __name__ == "__main__": demo.launch()