NiketKakkar commited on
Commit
ccecc26
·
1 Parent(s): ec11b5b
modal_backend/commentary.py ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import modal
2
+
3
+ MODEL_ID = "openbmb/MiniCPM-o-4_5"
4
+ MODEL_DIR = "/model-weights/minicpm-o-4_5"
5
+
6
+ app = modal.App("f1-paddock-oracle")
7
+
8
+ volume = modal.Volume.from_name("f1-model-weights", create_if_missing=True)
9
+
10
+ image = (
11
+ modal.Image.debian_slim(python_version="3.11")
12
+ .pip_install(
13
+ "torch==2.4.0",
14
+ "torchvision==0.19.0",
15
+ "torchaudio==2.4.0",
16
+ extra_index_url="https://download.pytorch.org/whl/cu121",
17
+ )
18
+ .pip_install(
19
+ "transformers==4.51.0",
20
+ "tokenizers==0.21.0",
21
+ "accelerate>=0.30.0",
22
+ "minicpmo-utils>=1.0.5",
23
+ "sentencepiece",
24
+ "soundfile",
25
+ "scipy",
26
+ "huggingface_hub",
27
+ "kokoro>=0.9.4",
28
+ )
29
+ .pip_install(
30
+ "click",
31
+ "spacy",
32
+ )
33
+ )
34
+
35
+
36
+ def _load_model():
37
+ import os
38
+ import shutil
39
+ import torch
40
+ from transformers import AutoModel, AutoTokenizer
41
+
42
+ hf_token = os.environ["HF_TOKEN"]
43
+
44
+ modules_cache = "/root/.cache/huggingface/modules"
45
+ if os.path.exists(modules_cache):
46
+ shutil.rmtree(modules_cache)
47
+
48
+ from huggingface_hub import snapshot_download
49
+
50
+ sentinel = os.path.join(MODEL_DIR, ".download_complete")
51
+ if not os.path.exists(sentinel):
52
+ if os.path.exists(MODEL_DIR):
53
+ shutil.rmtree(MODEL_DIR)
54
+ snapshot_download(repo_id=MODEL_ID, local_dir=MODEL_DIR, token=hf_token)
55
+ open(sentinel, "w").close()
56
+ volume.commit()
57
+
58
+ tokenizer = AutoTokenizer.from_pretrained(MODEL_DIR, trust_remote_code=True)
59
+ model = AutoModel.from_pretrained(
60
+ MODEL_DIR,
61
+ trust_remote_code=True,
62
+ torch_dtype=torch.bfloat16,
63
+ device_map="auto",
64
+ )
65
+ model.eval()
66
+ return model, tokenizer
67
+
68
+
69
+ def _tts(text: str) -> bytes:
70
+ import io
71
+ import numpy as np
72
+ import scipy.io.wavfile as wav_writer
73
+ from kokoro import KPipeline
74
+
75
+ pipeline = KPipeline(lang_code="b") # "b" = British English
76
+ samples = []
77
+ for _, _, audio in pipeline(text, voice="bm_daniel", speed=1.1):
78
+ samples.append(audio)
79
+
80
+ if not samples:
81
+ return b""
82
+
83
+ audio_np = np.concatenate(samples)
84
+ if audio_np.dtype != np.int16:
85
+ audio_np = (audio_np * 32767).clip(-32768, 32767).astype(np.int16)
86
+
87
+ buf = io.BytesIO()
88
+ wav_writer.write(buf, 24000, audio_np)
89
+ return buf.getvalue()
90
+
91
+
92
+ @app.function(
93
+ image=image,
94
+ gpu="A100",
95
+ timeout=600,
96
+ secrets=[modal.Secret.from_name("hf-token")],
97
+ volumes={"/model-weights": volume},
98
+ scaledown_window=300,
99
+ )
100
+ def generate_commentary(prompt: str, warmup: bool = False) -> dict:
101
+ model, tokenizer = _load_model()
102
+
103
+ if warmup:
104
+ return {}
105
+
106
+ sys_msg = model.get_sys_prompt(mode="omni", language="en")
107
+ msgs = [sys_msg, {"role": "user", "content": [prompt]}]
108
+
109
+ text = model.chat(
110
+ msgs=msgs,
111
+ tokenizer=tokenizer,
112
+ max_new_tokens=512,
113
+ use_tts_template=False,
114
+ generate_audio=False,
115
+ do_sample=True,
116
+ temperature=0.7,
117
+ )
118
+ text = str(text)
119
+
120
+ audio_bytes = _tts(text)
121
+
122
+ return {"text": text, "audio_wav": audio_bytes}
123
+
124
+
125
+ @app.function(
126
+ image=image,
127
+ gpu="A100",
128
+ timeout=600,
129
+ secrets=[modal.Secret.from_name("hf-token")],
130
+ volumes={"/model-weights": volume},
131
+ scaledown_window=300,
132
+ )
133
+ def persona_chat(system_prompt: str, user_message: str, warmup: bool = False) -> dict:
134
+ model, tokenizer = _load_model()
135
+
136
+ if warmup:
137
+ return {}
138
+
139
+ msgs = [
140
+ {"role": "system", "content": system_prompt},
141
+ {"role": "user", "content": user_message},
142
+ ]
143
+
144
+ text = model.chat(
145
+ msgs=msgs,
146
+ tokenizer=tokenizer,
147
+ max_new_tokens=512,
148
+ use_tts_template=False,
149
+ generate_audio=False,
150
+ do_sample=True,
151
+ temperature=0.8,
152
+ )
153
+ text = str(text)
154
+
155
+ audio_bytes = _tts(text)
156
+
157
+ return {"text": text, "audio_wav": audio_bytes}
158
+
159
+
160
+ @app.local_entrypoint()
161
+ def smoke_test():
162
+ prompt = (
163
+ "LAP 47 of 57 at Monaco. Verstappen leads Hamilton by 6.2 seconds. "
164
+ "Hamilton is on worn mediums, tyre age 28 laps. "
165
+ "Generate a 2-sentence broadcast commentary update."
166
+ )
167
+ result = generate_commentary.remote(prompt=prompt, warmup=False)
168
+ assert result["text"], "Smoke test failed: empty text"
169
+ assert result["audio_wav"], "Smoke test failed: empty audio"
170
+ print(f"OK — text ({len(result['text'])} chars), audio ({len(result['audio_wav'])} bytes)")
modal_backend/deploy.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ """Single deploy entry point — registers both functions under one app.
2
+
3
+ Deploy with:
4
+ modal deploy modal_backend/deploy.py
5
+ """
6
+
7
+ from modal_backend.commentary import app, generate_commentary, persona_chat # noqa: F401
8
+ from modal_backend.strategy import reason_strategy # noqa: F401
modal_backend/keepwarm.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Keep-warm scheduler — pings both containers every 5 minutes with warmup=True.
2
+
3
+ Deploy manually with:
4
+ modal deploy modal_backend/keepwarm.py
5
+
6
+ Stop manually with:
7
+ modal app stop f1-paddock-oracle-keepwarm
8
+
9
+ Do NOT include this in the main app deploy.
10
+ """
11
+
12
+ import modal
13
+
14
+ from modal_backend.commentary import generate_commentary
15
+ from modal_backend.strategy import reason_strategy
16
+
17
+ app = modal.App("f1-paddock-oracle-keepwarm")
18
+
19
+
20
+ @app.function(schedule=modal.Period(minutes=5))
21
+ def ping_containers():
22
+ generate_commentary.remote(prompt="", warmup=True)
23
+ reason_strategy.remote(prompt="", warmup=True)
modal_backend/strategy.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import modal
2
+
3
+ from modal_backend.commentary import app
4
+
5
+ model_volume = modal.Volume.from_name("f1-model-cache", create_if_missing=True)
6
+
7
+ image = (
8
+ modal.Image.debian_slim(python_version="3.11")
9
+ .pip_install(
10
+ "torch==2.4.0",
11
+ extra_index_url="https://download.pytorch.org/whl/cu121",
12
+ )
13
+ .pip_install(
14
+ "transformers==4.51.0",
15
+ "accelerate>=0.30.0",
16
+ "huggingface-hub>=0.22.0",
17
+ "sentencepiece",
18
+ )
19
+ .env({"HF_HOME": "/model-cache", "TRANSFORMERS_CACHE": "/model-cache"})
20
+ )
21
+
22
+ MODEL_ID = "nvidia/Llama-3.1-Nemotron-Nano-8B-v1"
23
+
24
+ SYSTEM_PROMPT = """You are a senior F1 strategist with deep knowledge of historical races.
25
+ The user will describe a real race and change one variable.
26
+ Your task:
27
+ 1. Briefly acknowledge the actual race outcome (1 sentence)
28
+ 2. Reason through how the changed variable affects pit windows, undercut/overcut risk, tire deg, and track position (3–5 sentences)
29
+ 3. Narrate the alternate outcome with specific lap numbers and position changes
30
+ 4. Produce a plausible alternate final top-5
31
+
32
+ Be specific. Reference real team strategies and driver tendencies."""
33
+
34
+
35
+ def _load_model():
36
+ from transformers import AutoModelForCausalLM, AutoTokenizer
37
+ import torch
38
+
39
+ tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, trust_remote_code=True)
40
+ model = AutoModelForCausalLM.from_pretrained(
41
+ MODEL_ID,
42
+ torch_dtype=torch.bfloat16,
43
+ device_map="auto",
44
+ trust_remote_code=True,
45
+ )
46
+ model.eval()
47
+ return model, tokenizer
48
+
49
+
50
+ @app.function(
51
+ image=image,
52
+ gpu="A100",
53
+ timeout=300,
54
+ volumes={"/model-cache": model_volume},
55
+ secrets=[modal.Secret.from_name("hf-token")],
56
+ )
57
+ def reason_strategy(prompt: str, warmup: bool = False) -> dict:
58
+ import os
59
+ import torch
60
+
61
+ hf_token = os.environ.get("HF_TOKEN", "")
62
+ if hf_token:
63
+ from huggingface_hub import login
64
+ login(token=hf_token)
65
+
66
+ model, tokenizer = _load_model()
67
+
68
+ if warmup:
69
+ return {"status": "warm"}
70
+
71
+ messages = [
72
+ {"role": "system", "content": SYSTEM_PROMPT},
73
+ {"role": "user", "content": prompt},
74
+ ]
75
+
76
+ input_text = tokenizer.apply_chat_template(
77
+ messages, tokenize=False, add_generation_prompt=True
78
+ )
79
+ inputs = tokenizer(input_text, return_tensors="pt").to("cuda")
80
+
81
+ with torch.no_grad():
82
+ output_ids = model.generate(
83
+ **inputs,
84
+ max_new_tokens=512,
85
+ do_sample=True,
86
+ temperature=0.7,
87
+ top_p=0.9,
88
+ pad_token_id=tokenizer.eos_token_id,
89
+ )
90
+
91
+ new_tokens = output_ids[0][inputs["input_ids"].shape[-1]:]
92
+ reasoning_chain = tokenizer.decode(new_tokens, skip_special_tokens=True).strip()
93
+
94
+ return {"reasoning_chain": reasoning_chain}
95
+
96
+
97
+ @app.local_entrypoint()
98
+ def main():
99
+ sample_prompt = (
100
+ "Race: 2023 British Grand Prix\n"
101
+ "Original outcome: Verstappen won from pole, Hamilton finished P4 after a late pit.\n"
102
+ "User change: What if Hamilton had pitted 5 laps earlier on lap 30 for fresh mediums?"
103
+ )
104
+
105
+ print("Running warmup...")
106
+ result = reason_strategy.remote(prompt="", warmup=True)
107
+ print(f"Warmup result: {result}")
108
+
109
+ print("\nRunning strategy inference...")
110
+ result = reason_strategy.remote(prompt=sample_prompt, warmup=False)
111
+ reasoning = result.get("reasoning_chain", "")
112
+ assert reasoning, "reasoning_chain is empty — inference failed"
113
+ print(f"\nReasoning chain:\n{reasoning}")