NiketKakkar's picture
sync all project files
97e14f1
Raw
History Blame Contribute Delete
24.4 kB
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 = """
<section id="f1-hero" aria-label="F1 Paddock Oracle">
<div class="driver-bg verstappen" aria-hidden="true"></div>
<div class="driver-bg hamilton" aria-hidden="true"></div>
<div class="driver-bg schumacher" aria-hidden="true"></div>
<div class="driver-bg senna" aria-hidden="true"></div>
<div class="driver-bg norris" aria-hidden="true"></div>
<div class="hero-content">
<div class="hero-kicker">Race Intelligence</div>
<h1 class="hero-title">F1 Paddock <span>Oracle</span></h1>
<div class="hero-meta">
<span class="hero-chip">Verstappen</span>
<span class="hero-chip">Hamilton</span>
<span class="hero-chip">Schumacher</span>
<span class="hero-chip">Senna</span>
<span class="hero-chip">Norris</span>
</div>
</div>
<div class="hero-scanline" aria-hidden="true"></div>
</section>
"""
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()