suraj140 commited on
Commit
5c40041
Β·
0 Parent(s):

Clean repository root for HF Spaces

Browse files
Files changed (11) hide show
  1. .gitignore +30 -0
  2. Dockerfile +68 -0
  3. inference.py +280 -0
  4. models.py +179 -0
  5. nginx.conf +57 -0
  6. openenv.yaml +84 -0
  7. requirements.txt +21 -0
  8. server/__init__.py +1 -0
  9. server/app.py +164 -0
  10. server/environment.py +172 -0
  11. start.sh +35 -0
.gitignore ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ── Python ────────────────────────────────────────────────────────────────────
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.pyo
5
+ .venv/
6
+ venv/
7
+ env/
8
+ *.egg-info/
9
+ dist/
10
+ build/
11
+
12
+ # ── Node / Vite ───────────────────────────────────────────────────────────────
13
+ ui/node_modules/
14
+ ui/.vite/
15
+ ui/dist/
16
+
17
+ # ── Environment secrets β€” NEVER commit these ─────────────────────────────────
18
+ .env
19
+ .env.local
20
+ .env.*.local
21
+ ui/.env
22
+ ui/.env.local
23
+ ui/.env.*.local
24
+
25
+ # ── OS / Editor ───────────────────────────────────────────────────────────────
26
+ .DS_Store
27
+ Thumbs.db
28
+ .vscode/
29
+ .idea/
30
+ *.swp
Dockerfile ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ─────────────────────────────────────────────────────────────────────────────
2
+ # Stage 1 – Build the Vite / React frontend
3
+ # ─────────────────────────────────────────────────────────────────────────────
4
+ FROM node:20-slim AS frontend-builder
5
+
6
+ WORKDIR /app/ui
7
+
8
+ # Install deps first (better layer caching)
9
+ COPY ui/package.json ui/package-lock.json* ./
10
+ RUN npm ci --prefer-offline
11
+
12
+ # Copy all UI source
13
+ COPY ui/ ./
14
+
15
+ # Vite bakes VITE_* vars into the static bundle at build time.
16
+ # HF Spaces forwards the matching secrets as Docker build-args automatically
17
+ # when you set them in Space Settings β†’ Variables and secrets.
18
+ ARG VITE_HF_TOKEN=""
19
+ ARG VITE_HF_MODEL="mistralai/Mistral-7B-Instruct-v0.2"
20
+ ENV VITE_HF_TOKEN=${VITE_HF_TOKEN}
21
+ ENV VITE_HF_MODEL=${VITE_HF_MODEL}
22
+
23
+ RUN npm run build
24
+
25
+
26
+ # ─────────────────────────────────────────────────────────────────────────────
27
+ # Stage 2 – Python backend + Nginx to serve frontend
28
+ # ─────────────────────────────────────────────────────────────────────────────
29
+ FROM python:3.11-slim
30
+
31
+ # System packages: nginx for static serving + reverse-proxy
32
+ RUN apt-get update && apt-get install -y --no-install-recommends \
33
+ nginx \
34
+ curl \
35
+ && rm -rf /var/lib/apt/lists/*
36
+
37
+ WORKDIR /app
38
+
39
+ # Python dependencies
40
+ COPY requirements.txt .
41
+ RUN pip install --no-cache-dir -r requirements.txt
42
+
43
+ # Application source
44
+ COPY models.py .
45
+ COPY inference.py .
46
+ COPY server/ ./server/
47
+ COPY openenv.yaml .
48
+
49
+ # Nginx configuration
50
+ COPY nginx.conf /etc/nginx/nginx.conf
51
+
52
+ # Built frontend from stage 1
53
+ COPY --from=frontend-builder /app/ui/dist ./ui/dist
54
+
55
+ # Startup script
56
+ COPY start.sh /start.sh
57
+ RUN chmod +x /start.sh
58
+
59
+ # HF Spaces exposes port 7860
60
+ EXPOSE 7860
61
+
62
+ # Runtime environment (overridden by HF Space secrets at runtime)
63
+ ENV PORT=7860 \
64
+ HF_MODEL="mistralai/Mistral-7B-Instruct-v0.2" \
65
+ HF_TOKEN="" \
66
+ PYTHONUNBUFFERED=1
67
+
68
+ CMD ["/start.sh"]
inference.py ADDED
@@ -0,0 +1,280 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ inference.py
3
+ ─────────────────────────────────────────────────────────────────────────────
4
+ OpenEnv-compliant inference environment for Survival Island.
5
+ Provides step(), reset(), state() methods and task graders.
6
+
7
+ Uses OpenAI client format with [START], [STEP], [END] logging.
8
+ """
9
+
10
+ import json
11
+ import logging
12
+ import os
13
+ from typing import Any, Dict, Optional, Tuple
14
+
15
+ from openai import OpenAI
16
+
17
+ # ── Logging setup ─────────────────────────────────────────────────────────────
18
+ logging.basicConfig(level=logging.INFO)
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ # ── OpenEnv Environment ───────────────────────────────────────────────────────
23
+
24
+ class SurvivalIslandEnvironment:
25
+ """
26
+ OpenEnv-compliant environment wrapper for Survival Island.
27
+ Provides the required step(), reset(), state() interface.
28
+ """
29
+
30
+ def __init__(self):
31
+ """Initialize the environment."""
32
+ self.client = OpenAI(api_key=os.getenv("OPENAI_API_KEY", ""))
33
+ self.generation = 0
34
+ self.total_resources_collected = 0
35
+ self.challenges_won = 0
36
+ self.current_state = self._create_initial_state()
37
+
38
+ logger.info("[START] Survival Island environment initialized")
39
+
40
+ def _create_initial_state(self) -> Dict[str, Any]:
41
+ """Create initial game state."""
42
+ return {
43
+ "generation": 0,
44
+ "health": 100.0,
45
+ "hunger": 50.0,
46
+ "thirst": 50.0,
47
+ "stamina": 100.0,
48
+ "fear": 0.0,
49
+ "wood": 0,
50
+ "stone": 0,
51
+ "food": 0,
52
+ "water": 0,
53
+ "playerX": 1000.0,
54
+ "isNight": False,
55
+ "inventory": {
56
+ "spear": False,
57
+ "bow": False,
58
+ "fishingRod": False,
59
+ "boat": False,
60
+ },
61
+ "baseCamp": {
62
+ "x": None,
63
+ "y": None,
64
+ "level": 0,
65
+ },
66
+ "memory": {
67
+ "evolutionLevel": 1,
68
+ "pastDeaths": [],
69
+ "totalGenerations": 0,
70
+ "challengesWon": 0,
71
+ },
72
+ "activeChallenge": None,
73
+ }
74
+
75
+ def reset(self) -> Dict[str, Any]:
76
+ """Reset the environment to initial state."""
77
+ logger.info("[STEP] reset() called")
78
+ self.generation = 0
79
+ self.total_resources_collected = 0
80
+ self.challenges_won = 0
81
+ self.current_state = self._create_initial_state()
82
+ logger.info("[END] Environment reset complete")
83
+ return self.current_state
84
+
85
+ def step(self, action: str) -> Tuple[Dict[str, Any], float, bool, Dict[str, Any]]:
86
+ """
87
+ Execute one step in the environment.
88
+
89
+ Args:
90
+ action: Action taken by the agent (e.g., "FORAGE", "HUNT", "FISH")
91
+
92
+ Returns:
93
+ Tuple of (state, reward, done, info)
94
+ """
95
+ logger.info(f"[STEP] Action: {action}")
96
+
97
+ # Simulate action effects
98
+ reward = 0.0
99
+ done = False
100
+ info = {}
101
+
102
+ action_upper = action.upper().strip()
103
+
104
+ # Resource gathering actions
105
+ if action_upper == "FORAGE":
106
+ self.current_state["food"] += 5
107
+ self.total_resources_collected += 5
108
+ reward = 0.1
109
+ elif action_upper == "FISH":
110
+ self.current_state["food"] += 8
111
+ self.total_resources_collected += 8
112
+ reward = 0.15
113
+ elif action_upper == "GET_WATER":
114
+ self.current_state["water"] += 10
115
+ self.total_resources_collected += 10
116
+ reward = 0.15
117
+ elif action_upper == "HUNT":
118
+ self.current_state["food"] += 20
119
+ self.total_resources_collected += 20
120
+ reward = 0.25
121
+ elif action_upper in ["CRAFT_SPEAR", "CRAFT_BOW", "CRAFT_ROD", "CRAFT_BOAT"]:
122
+ # Crafting actions grant survival tools
123
+ tool_key = action_upper.lower().replace("craft_", "").replace("_", "")
124
+ if tool_key in self.current_state["inventory"]:
125
+ self.current_state["inventory"][tool_key] = True
126
+ reward = 0.2
127
+ elif action_upper == "BUILD_CAMP":
128
+ if self.current_state["baseCamp"]["level"] == 0:
129
+ self.current_state["baseCamp"]["x"] = self.current_state["playerX"]
130
+ self.current_state["baseCamp"]["level"] = 1
131
+ reward = 0.3
132
+ elif action_upper == "UPGRADE_CAMP":
133
+ self.current_state["baseCamp"]["level"] = min(
134
+ self.current_state["baseCamp"]["level"] + 1, 3
135
+ )
136
+ reward = 0.25
137
+ elif action_upper == "FLEE":
138
+ reward = 0.1
139
+ elif action_upper == "WANDER":
140
+ reward = -0.05
141
+
142
+ # Decay resources each step
143
+ self.current_state["hunger"] = max(0, self.current_state["hunger"] - 2)
144
+ self.current_state["thirst"] = max(0, self.current_state["thirst"] - 1.5)
145
+ self.current_state["stamina"] = max(0, self.current_state["stamina"] - 1)
146
+
147
+ # Consume resources if available
148
+ if self.current_state["food"] > 0:
149
+ self.current_state["hunger"] = min(100, self.current_state["hunger"] + 3)
150
+ self.current_state["food"] -= 1
151
+
152
+ if self.current_state["water"] > 0:
153
+ self.current_state["thirst"] = min(100, self.current_state["thirst"] + 2)
154
+ self.current_state["water"] -= 1
155
+
156
+ # Check death conditions
157
+ if self.current_state["health"] <= 0:
158
+ done = True
159
+ info["death_reason"] = "health_depleted"
160
+ reward = -1.0
161
+ elif self.current_state["hunger"] <= 0:
162
+ self.current_state["health"] -= 10
163
+ reward -= 0.2
164
+ elif self.current_state["thirst"] <= 0:
165
+ self.current_state["health"] -= 15
166
+ reward -= 0.3
167
+
168
+ self.generation += 1
169
+ self.current_state["generation"] = self.generation
170
+ self.current_state["memory"]["totalGenerations"] = self.generation
171
+
172
+ logger.info(f"[END] Step {self.generation}: reward={reward:.3f}, done={done}")
173
+ return self.current_state, reward, done, info
174
+
175
+ def state(self) -> Dict[str, Any]:
176
+ """Get current environment state."""
177
+ logger.info("[STEP] state() called")
178
+ logger.info("[END] State retrieved")
179
+ return self.current_state
180
+
181
+
182
+ # ── Task Graders (0.0 to 1.0) ────────────────────────────────────────────────
183
+
184
+ class TaskGraders:
185
+ """Graders for the 3 required tasks."""
186
+
187
+ @staticmethod
188
+ def grade_survival_expert(state: Dict[str, Any]) -> float:
189
+ """
190
+ Task 1 (Easy): Survive for 50+ generations.
191
+ Grader: Returns (min(generations, 50) / 50)
192
+ """
193
+ generations = state.get("generation", 0)
194
+ score = min(generations, 50) / 50.0
195
+ return min(score, 1.0)
196
+
197
+ @staticmethod
198
+ def grade_resourceful_gatherer(state: Dict[str, Any]) -> float:
199
+ """
200
+ Task 2 (Medium): Collect 500+ total resources.
201
+ Grader: Returns (min(total_resources, 500) / 500)
202
+ """
203
+ # Simulating total resources by checking inventory state
204
+ memory = state.get("memory", {})
205
+ total_resources = memory.get("challengesWon", 0) * 100 # Proxy metric
206
+ # Add actual collected resources
207
+ total_resources += (
208
+ state.get("wood", 0)
209
+ + state.get("stone", 0)
210
+ + (state.get("food", 0) * 2)
211
+ + (state.get("water", 0) * 2)
212
+ )
213
+ score = min(total_resources, 500) / 500.0
214
+ return min(score, 1.0)
215
+
216
+ @staticmethod
217
+ def grade_challenge_master(state: Dict[str, Any]) -> float:
218
+ """
219
+ Task 3 (Hard): Win 10+ challenges and reach evolution level 5.
220
+ Grader: Returns (min(challenges_won, 10) / 10) * (evolution_level / 5)
221
+ """
222
+ memory = state.get("memory", {})
223
+ challenges_won = memory.get("challengesWon", 0)
224
+ evolution_level = memory.get("evolutionLevel", 1)
225
+
226
+ challenge_score = min(challenges_won, 10) / 10.0
227
+ evolution_score = min(evolution_level, 5) / 5.0
228
+ score = (challenge_score + evolution_score) / 2.0
229
+ return min(score, 1.0)
230
+
231
+
232
+ # ── Main Entry Point ──────────────────────────────────────────────────────────
233
+
234
+ def main():
235
+ """Demonstrate environment usage."""
236
+ logger.info("[START] Initialization")
237
+
238
+ env = SurvivalIslandEnvironment()
239
+ graders = TaskGraders()
240
+
241
+ # Reset environment
242
+ state = env.reset()
243
+ logger.info(f"Initial state: generation={state['generation']}")
244
+
245
+ # Simulate a few steps
246
+ logger.info("[START] Simulation")
247
+ actions = ["FORAGE", "BUILD_CAMP", "CRAFT_SPEAR", "HUNT", "GET_WATER"]
248
+
249
+ for action in actions:
250
+ logger.info(f"[STEP] Executing action: {action}")
251
+ next_state, reward, done, info = env.step(action)
252
+ logger.info(f"[END] Step completed: reward={reward}, done={done}")
253
+
254
+ if done:
255
+ logger.info(f"[END] Episode terminated: {info}")
256
+ break
257
+
258
+ # Grade tasks
259
+ final_state = env.state()
260
+ logger.info("[START] Task Grading")
261
+
262
+ task1_score = graders.grade_survival_expert(final_state)
263
+ task2_score = graders.grade_resourceful_gatherer(final_state)
264
+ task3_score = graders.grade_challenge_master(final_state)
265
+
266
+ logger.info(f"[STEP] Task 1 (Survival Expert): {task1_score:.3f}")
267
+ logger.info(f"[STEP] Task 2 (Resourceful Gatherer): {task2_score:.3f}")
268
+ logger.info(f"[STEP] Task 3 (Challenge Master): {task3_score:.3f}")
269
+
270
+ logger.info("[END] All tasks graded")
271
+
272
+ print("\n--- Results ---")
273
+ print(f"Task 1 Score: {task1_score:.3f}")
274
+ print(f"Task 2 Score: {task2_score:.3f}")
275
+ print(f"Task 3 Score: {task3_score:.3f}")
276
+ print(f"Average Score: {(task1_score + task2_score + task3_score) / 3:.3f}")
277
+
278
+
279
+ if __name__ == "__main__":
280
+ main()
models.py ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ models.py
3
+ ─────────────────────────────────────────────────────────────────────────────
4
+ Handles LLM inference for the Survival Island AI agent.
5
+
6
+ Priority chain
7
+ --------------
8
+ 1. Local transformers pipeline (available on GPU Spaces – uncomment deps)
9
+ 2. HuggingFace Inference API (works on CPU Spaces, needs HF_TOKEN)
10
+ 3. Rule-based fallback (no network / no token)
11
+
12
+ Environment variables
13
+ ---------------------
14
+ HF_TOKEN – HuggingFace API token (required for path 2)
15
+ HF_MODEL – Model repo ID, e.g. "mistralai/Mistral-7B-Instruct-v0.2"
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import logging
22
+ import os
23
+ import re
24
+ import threading
25
+ from typing import Optional
26
+
27
+ import requests
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ # ── Config ────────────────────────────────────────────────────────────────────
32
+ HF_TOKEN: str = os.getenv("HF_TOKEN", "")
33
+ HF_MODEL: str = os.getenv("HF_MODEL", "mistralai/Mistral-7B-Instruct-v0.2")
34
+
35
+ VALID_ACTIONS: set[str] = {
36
+ "FORAGE", "HUNT", "FISH", "GET_WATER", "SEEK_SHELTER",
37
+ "BUILD_CAMP", "UPGRADE_CAMP", "CRAFT_SPEAR", "CRAFT_BOW",
38
+ "CRAFT_ROD", "CRAFT_BOAT", "EVACUATE", "FIGHT", "FLEE", "WANDER",
39
+ }
40
+
41
+ # ── Singleton ─────────────────────────────────────────────────────────────────
42
+ _lock = threading.Lock()
43
+ _pipeline = None # transformers Pipeline object (optional)
44
+ _use_local: bool = False # True once local pipeline loaded successfully
45
+
46
+
47
+ # ── Local pipeline (optional – GPU Spaces) ────────────────────────────────────
48
+ def _try_load_local() -> bool:
49
+ """
50
+ Attempts to load the model locally with transformers + torch.
51
+ Returns True on success. Skipped silently on CPU-only environments.
52
+ Uncomment torch/transformers in requirements.txt to enable this path.
53
+ """
54
+ global _pipeline, _use_local
55
+ try:
56
+ import torch
57
+ from transformers import pipeline as hf_pipeline # type: ignore
58
+
59
+ logger.info(f"[models] Loading {HF_MODEL} locally …")
60
+ device = 0 if torch.cuda.is_available() else -1
61
+ _pipeline = hf_pipeline(
62
+ "text-generation",
63
+ model=HF_MODEL,
64
+ token=HF_TOKEN or None,
65
+ device=device,
66
+ torch_dtype=torch.float16 if device >= 0 else torch.float32,
67
+ max_new_tokens=80,
68
+ )
69
+ _use_local = True
70
+ logger.info(f"[models] Local pipeline ready (device={device})")
71
+ return True
72
+ except Exception as exc:
73
+ logger.warning(f"[models] Local pipeline skipped: {exc}")
74
+ return False
75
+
76
+
77
+ def get_pipeline():
78
+ """Return the pipeline, initialising on first call."""
79
+ with _lock:
80
+ if _pipeline is None:
81
+ _try_load_local()
82
+ return _pipeline
83
+
84
+
85
+ # ── Output parser ─────────────────────────────────────────────────────────────
86
+ def _parse_action(raw: str) -> dict:
87
+ """
88
+ Extract {"action": ..., "thought": ...} from model output.
89
+ Handles markdown fences, leading prose, trailing noise.
90
+ """
91
+ # Strip markdown fences
92
+ cleaned = re.sub(r"```(?:json)?|```", "", raw, flags=re.IGNORECASE)
93
+ # Keep only the first JSON object
94
+ match = re.search(r"\{[^}]+\}", cleaned, re.DOTALL)
95
+ if not match:
96
+ raise ValueError(f"No JSON object in model output: {raw!r}")
97
+ obj = json.loads(match.group())
98
+ action = str(obj.get("action", "WANDER")).upper()
99
+ if action not in VALID_ACTIONS:
100
+ logger.warning(f"[models] Unknown action '{action}', defaulting to WANDER")
101
+ action = "WANDER"
102
+ return {
103
+ "action": action,
104
+ "thought": str(obj.get("thought", "Processing…")),
105
+ }
106
+
107
+
108
+ # ── Inference paths ───────────────────────────────────────────────────────────
109
+ def infer_local(prompt: str) -> dict:
110
+ """Run generation using the locally loaded transformers pipeline."""
111
+ pipe = get_pipeline()
112
+ if pipe is None:
113
+ raise RuntimeError("Local pipeline not available")
114
+ outputs = pipe(
115
+ prompt,
116
+ max_new_tokens=80,
117
+ temperature=0.7,
118
+ do_sample=True,
119
+ return_full_text=False,
120
+ )
121
+ return _parse_action(outputs[0]["generated_text"])
122
+
123
+
124
+ def infer_api(prompt: str) -> dict:
125
+ """Call the HuggingFace Inference API (remote, no GPU needed)."""
126
+ if not HF_TOKEN:
127
+ raise RuntimeError("HF_TOKEN not set – cannot call Inference API")
128
+ url = f"https://api-inference.huggingface.co/models/{HF_MODEL}"
129
+ resp = requests.post(
130
+ url,
131
+ headers={
132
+ "Authorization": f"Bearer {HF_TOKEN}",
133
+ "Content-Type": "application/json",
134
+ },
135
+ json={
136
+ "inputs": prompt,
137
+ "parameters": {
138
+ "max_new_tokens": 80,
139
+ "temperature": 0.7,
140
+ "return_full_text": False,
141
+ "stop": ["\n\n", "</s>", "[INST]"],
142
+ },
143
+ },
144
+ timeout=30,
145
+ )
146
+ resp.raise_for_status()
147
+ data = resp.json()
148
+ raw = (
149
+ data[0]["generated_text"]
150
+ if isinstance(data, list)
151
+ else data.get("generated_text", "")
152
+ )
153
+ return _parse_action(raw)
154
+
155
+
156
+ def run_inference(prompt: str) -> dict:
157
+ """
158
+ Main entry point used by server/app.py.
159
+
160
+ Tries local β†’ API β†’ rule-based fallback in that order.
161
+ Never raises – always returns a valid {"action", "thought"} dict.
162
+ """
163
+ # 1. Local transformers pipeline (GPU Space)
164
+ if _use_local:
165
+ try:
166
+ return infer_local(prompt)
167
+ except Exception as exc:
168
+ logger.warning(f"[models] Local inference failed: {exc}")
169
+
170
+ # 2. HuggingFace Inference API (CPU Space)
171
+ if HF_TOKEN:
172
+ try:
173
+ return infer_api(prompt)
174
+ except Exception as exc:
175
+ logger.warning(f"[models] Inference API failed: {exc}")
176
+
177
+ # 3. Hard fallback
178
+ logger.error("[models] All inference paths failed – returning WANDER")
179
+ return {"action": "WANDER", "thought": "Inference offline. Wandering."}
nginx.conf ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ worker_processes 1;
2
+ error_log /dev/stderr warn;
3
+ pid /tmp/nginx.pid;
4
+
5
+ events {
6
+ worker_connections 1024;
7
+ }
8
+
9
+ http {
10
+ include /etc/nginx/mime.types;
11
+ default_type application/octet-stream;
12
+ access_log /dev/stdout;
13
+ sendfile on;
14
+ tcp_nopush on;
15
+ keepalive_timeout 65;
16
+
17
+ gzip on;
18
+ gzip_types
19
+ text/plain
20
+ text/css
21
+ text/javascript
22
+ application/javascript
23
+ application/json
24
+ application/wasm
25
+ image/svg+xml;
26
+
27
+ server {
28
+ listen 7860;
29
+ server_name _;
30
+
31
+ root /app/ui/dist;
32
+ index index.html;
33
+
34
+ # ── React SPA: always return index.html for unknown paths ──────────
35
+ location / {
36
+ try_files $uri $uri/ /index.html;
37
+ }
38
+
39
+ # ── Proxy /api/* β†’ FastAPI on port 8000 ───────────────────────────
40
+ location /api/ {
41
+ proxy_pass http://127.0.0.1:8000;
42
+ proxy_http_version 1.1;
43
+ proxy_set_header Host $host;
44
+ proxy_set_header X-Real-IP $remote_addr;
45
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
46
+ proxy_set_header X-Forwarded-Proto $scheme;
47
+ proxy_read_timeout 60s;
48
+ proxy_send_timeout 60s;
49
+ }
50
+
51
+ # ── Static asset caching ───────────────────────────────────────────
52
+ location ~* \.(js|css|wasm|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
53
+ expires 1y;
54
+ add_header Cache-Control "public, immutable";
55
+ }
56
+ }
57
+ }
openenv.yaml ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Survival_Island
2
+ version: "1.0.0"
3
+ description: >
4
+ An AI-powered 2-D survival simulation where a HuggingFace LLM agent
5
+ autonomously makes survival decisions across a procedurally-generated
6
+ 6000 Γ— 3000 world. Built for the Meta PyTorch Hackathon.
7
+
8
+ # ── Hugging Face Space ────────────────────────────────────────────────────────
9
+ space:
10
+ repo_id: suraj291/Survival_Island # change to your HF username/repo
11
+ sdk: docker
12
+ hardware: cpu-basic # upgrade to gpu-t4-small for local inference
13
+ title: "Survival Island – LLM Survival Agent"
14
+ emoji: 🏝️
15
+ colorFrom: green
16
+ colorTo: blue
17
+ pinned: false
18
+
19
+ # ── Secrets / Variables (set in HF Space β†’ Settings β†’ Variables and secrets)
20
+ env:
21
+ secrets:
22
+ - name: HF_TOKEN
23
+ description: >
24
+ HuggingFace API token (read scope is enough for Inference API).
25
+ Also baked into the Vite bundle as VITE_HF_TOKEN at build time.
26
+
27
+ variables:
28
+ - name: HF_MODEL
29
+ default: "mistralai/Mistral-7B-Instruct-v0.2"
30
+ description: >
31
+ Any HF chat/instruct model compatible with the [INST] prompt format.
32
+ Alternatives: HuggingFaceH4/zephyr-7b-beta
33
+ meta-llama/Llama-3.1-8B-Instruct (needs license accept)
34
+ - name: PORT
35
+ default: "7860"
36
+ description: Port exposed by the Docker container (HF Spaces default).
37
+
38
+ # ── Build ─────────────────────────────────────────────────────────────────────
39
+ build:
40
+ dockerfile: Dockerfile
41
+ context: .
42
+ steps:
43
+ - name: install_python_deps
44
+ run: pip install -r requirements.txt --no-cache-dir
45
+
46
+ - name: build_frontend
47
+ run: |
48
+ cd ui
49
+ npm ci
50
+ npm run build
51
+ env:
52
+ VITE_HF_TOKEN: "${HF_TOKEN}"
53
+ VITE_HF_MODEL: "${HF_MODEL}"
54
+
55
+ # ── Runtime services ──────────────────────────────────────────────────────────
56
+ services:
57
+ backend:
58
+ type: fastapi
59
+ entrypoint: "uvicorn server.app:app --host 0.0.0.0 --port 8000 --workers 1"
60
+ healthcheck: "/api/health"
61
+
62
+ frontend:
63
+ type: static
64
+ serve_dir: "ui/dist"
65
+
66
+ # ── Model / hardware ──────────────────────────────────────────────────────────
67
+ model:
68
+ framework: pytorch
69
+ task: text-generation
70
+ device: cpu # set to cuda on gpu-* hardware tiers
71
+ load_in_8bit: false
72
+
73
+ # ── Push ignore list ──────────────────────────────────────────────────────────
74
+ push:
75
+ ignore:
76
+ - "ui/node_modules"
77
+ - "ui/.vite"
78
+ - "ui/dist"
79
+ - "__pycache__"
80
+ - "*.pyc"
81
+ - ".env"
82
+ - ".env.local"
83
+ - "ui/.env.local"
84
+ - ".git"
requirements.txt ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ── Web framework ─────────────────────────────────────────────────────────────
2
+ fastapi==0.111.0
3
+ uvicorn[standard]==0.29.0
4
+ pydantic==2.7.1
5
+
6
+ # ── HTTP (Inference API fallback) ─────────────────────────────────────────────
7
+ requests==2.32.3
8
+ httpx==0.27.0
9
+
10
+ # ── Utilities ─────────────────────────────────────────────────────────────────
11
+ python-dotenv==1.0.1
12
+
13
+ # ── OpenEnv inference ──────────────────────────────────────────────────────────
14
+ openai==1.3.0
15
+
16
+ # ── Optional: local model inference ──────────────────────────────────────────
17
+ # Uncomment these if you deploy on a GPU Space (hardware: gpu-t4-small or above)
18
+ # torch==2.3.0
19
+ # transformers==4.41.0
20
+ # accelerate==0.30.0
21
+ # bitsandbytes==0.43.1 # 8-bit quantisation – halves VRAM usage
server/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # server package – FastAPI backend for Survival Island
server/app.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ server/app.py
3
+ ─────────────────────────────────────────────────────────────────────────────
4
+ FastAPI backend for Survival Island.
5
+
6
+ Endpoints
7
+ ---------
8
+ GET /api/health liveness probe (used by start.sh and HF healthcheck)
9
+ GET /api/config safe public config (model name, pipeline status)
10
+ POST /api/infer LLM survival-action inference
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import os
17
+ import time
18
+ from contextlib import asynccontextmanager
19
+
20
+ from fastapi import FastAPI, HTTPException, Request
21
+ from fastapi.middleware.cors import CORSMiddleware
22
+ from fastapi.responses import JSONResponse
23
+
24
+ from server.environment import GameState, InferResponse, build_prompt
25
+ import models
26
+
27
+ # ── Logging ───────────────────────────────────────────────────────────────────
28
+ logging.basicConfig(
29
+ level=logging.INFO,
30
+ format="%(asctime)s [%(levelname)-8s] %(name)s: %(message)s",
31
+ )
32
+ logger = logging.getLogger("survival.api")
33
+
34
+
35
+ # ── Lifespan (startup / shutdown) ─────────────────────────────────────────────
36
+ @asynccontextmanager
37
+ async def lifespan(app: FastAPI):
38
+ """Pre-warm the model pipeline in a daemon thread on startup."""
39
+ import threading
40
+
41
+ def _warm():
42
+ logger.info("Pre-warming model pipeline…")
43
+ models.get_pipeline()
44
+ logger.info("Pipeline warm-up complete.")
45
+
46
+ threading.Thread(target=_warm, daemon=True).start()
47
+ yield
48
+ logger.info("Survival Island API shutting down.")
49
+
50
+
51
+ # ── App ───────────────────────────────────────────────────────────────────────
52
+ app = FastAPI(
53
+ title="Survival Island API",
54
+ description=(
55
+ "LLM-powered survival agent backend.\n"
56
+ "Built for the Meta PyTorch Hackathon."
57
+ ),
58
+ version="1.0.0",
59
+ lifespan=lifespan,
60
+ )
61
+
62
+ app.add_middleware(
63
+ CORSMiddleware,
64
+ allow_origins=["*"], # tighten in production if needed
65
+ allow_methods=["GET", "POST", "OPTIONS"],
66
+ allow_headers=["*"],
67
+ )
68
+
69
+
70
+ # ── Simple in-process rate limiter ────────────────────────────────────────────
71
+ _last_call: dict[str, float] = {}
72
+ _MIN_INTERVAL = 4.0 # seconds between /api/infer calls per IP
73
+
74
+
75
+ def _rate_ok(ip: str) -> bool:
76
+ now = time.monotonic()
77
+ if now - _last_call.get(ip, 0.0) < _MIN_INTERVAL:
78
+ return False
79
+ _last_call[ip] = now
80
+ return True
81
+
82
+
83
+ # ── Routes ────────────────────────────────────────────────────────────────────
84
+
85
+ @app.get("/api/health")
86
+ async def health():
87
+ """Liveness probe β€” always returns 200 while the process is running."""
88
+ return {
89
+ "status": "ok",
90
+ "model": os.getenv("HF_MODEL", "unset"),
91
+ "localPipeline": models._use_local,
92
+ }
93
+
94
+
95
+ @app.get("/", include_in_schema=False)
96
+ async def root():
97
+ """Root info route for browser access."""
98
+ return {
99
+ "message": "Survival Island API is running.",
100
+ "endpoints": ["/api/health", "/api/config", "/api/infer"],
101
+ }
102
+
103
+
104
+ @app.get("/api/config")
105
+ async def config():
106
+ """Public runtime configuration for the frontend."""
107
+ return {
108
+ "model": os.getenv("HF_MODEL", "mistralai/Mistral-7B-Instruct-v0.2"),
109
+ "hasToken": bool(os.getenv("HF_TOKEN")),
110
+ "localPipeline": models._use_local,
111
+ }
112
+
113
+
114
+ @app.post("/api/infer", response_model=InferResponse)
115
+ async def infer(state: GameState, request: Request):
116
+ """
117
+ Accept a GameState JSON body, build the LLM prompt,
118
+ run inference, and return the chosen action + thought.
119
+
120
+ Inference priority: local pipeline β†’ HF Inference API β†’ rule fallback.
121
+ """
122
+ ip = request.client.host if request.client else "unknown"
123
+ if not _rate_ok(ip):
124
+ raise HTTPException(
125
+ status_code=429,
126
+ detail="Too many requests β€” wait a few seconds.",
127
+ )
128
+
129
+ prompt = build_prompt(state)
130
+ logger.info(
131
+ f"[infer] gen={state.generation} ip={ip} "
132
+ f"challenge={state.activeChallenge.type if state.activeChallenge else 'none'}"
133
+ )
134
+
135
+ # Try inference paths
136
+ source = "fallback"
137
+ try:
138
+ if models._use_local:
139
+ result = models.infer_local(prompt)
140
+ source = "local"
141
+ elif os.getenv("HF_TOKEN"):
142
+ result = models.infer_api(prompt)
143
+ source = "api"
144
+ else:
145
+ result = models.run_inference(prompt)
146
+ except Exception as exc:
147
+ logger.warning(f"[infer] Primary inference failed ({exc}), using fallback.")
148
+ result = models.run_inference(prompt)
149
+
150
+ return InferResponse(
151
+ action=result["action"],
152
+ thought=result["thought"],
153
+ source=source,
154
+ )
155
+
156
+
157
+ # ── Global error handler ──────────────────────────────────────────────────────
158
+ @app.exception_handler(Exception)
159
+ async def _global_error(request: Request, exc: Exception):
160
+ logger.error(f"Unhandled error on {request.url}: {exc}", exc_info=True)
161
+ return JSONResponse(
162
+ status_code=500,
163
+ content={"detail": "Internal server error."},
164
+ )
server/environment.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ server/environment.py
3
+ ─────────────────────────────────────────────────────────────────────────────
4
+ Pydantic v2 models that mirror the React game state sent from the frontend,
5
+ plus the prompt builder that converts a GameState into the [INST] string
6
+ consumed by the LLM.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import List, Optional
12
+ from pydantic import BaseModel, Field
13
+
14
+
15
+ # ── Request / Response models ─────────────────────────────────────────────────
16
+
17
+ class Inventory(BaseModel):
18
+ spear: bool = False
19
+ bow: bool = False
20
+ fishingRod: bool = False
21
+ boat: bool = False
22
+
23
+
24
+ class BaseCamp(BaseModel):
25
+ x: Optional[float] = None
26
+ y: Optional[float] = None
27
+ level: int = 0
28
+
29
+
30
+ class AIMemory(BaseModel):
31
+ evolutionLevel: int = 1
32
+ pastDeaths: List[str] = Field(default_factory=list)
33
+ totalGenerations: int = 0
34
+ challengesWon: int = 0
35
+
36
+
37
+ class ActiveChallenge(BaseModel):
38
+ name: str
39
+ type: str
40
+ timeLimit: int
41
+ maxTime: int = 0
42
+ progress: str = ""
43
+
44
+
45
+ class GameState(BaseModel):
46
+ """Full agent snapshot sent by the frontend to /api/infer."""
47
+
48
+ # Vitals
49
+ health: float = Field(..., ge=0, le=100)
50
+ hunger: float = Field(..., ge=0, le=100)
51
+ thirst: float = Field(..., ge=0, le=100)
52
+ stamina: float = Field(..., ge=0, le=100)
53
+ fear: float = Field(default=0, ge=0, le=100)
54
+
55
+ # Resources
56
+ wood: int = 0
57
+ stone: int = 0
58
+ food: int = 0
59
+ water: int = 0
60
+
61
+ # World context
62
+ playerX: float = 1000
63
+ isNight: bool = False
64
+ activeEvents: List[str] = Field(default_factory=list)
65
+ predatorNear: bool = False
66
+
67
+ # Agent state
68
+ inventory: Inventory = Field(default_factory=Inventory)
69
+ baseCamp: BaseCamp = Field(default_factory=BaseCamp)
70
+ memory: AIMemory = Field(default_factory=AIMemory)
71
+ generation: int = 1
72
+
73
+ # Optional active challenge
74
+ activeChallenge: Optional[ActiveChallenge] = None
75
+
76
+
77
+ class InferResponse(BaseModel):
78
+ action: str
79
+ thought: str
80
+ source: str # "local" | "api" | "fallback"
81
+
82
+
83
+ # ── Prompt construction ───────────────────────────────────────────────────────
84
+
85
+ _DEATH_LESSON_MAP: dict[str, str] = {
86
+ "starvation": "CRITICAL: Prioritize food β€” starvation has killed me before.",
87
+ "dehydration": "CRITICAL: Prioritize water β€” dehydration has killed me before.",
88
+ "hypothermia": "CRITICAL: Seek shelter at night β€” hypothermia has killed me before.",
89
+ "heatstroke": "CRITICAL: Find shade/water during heatwaves β€” heatstroke has killed me before.",
90
+ "lion": "CRITICAL: Craft a spear/bow before exploring. FLEE when predators are near until armed.",
91
+ "mauled": "CRITICAL: Craft a spear/bow before exploring. FLEE when predators are near until armed.",
92
+ "panther": "CRITICAL: Craft a spear/bow before exploring. FLEE when predators are near until armed.",
93
+ "crocodile": "CRITICAL: Avoid water edges without a boat β€” crocodiles are deadly.",
94
+ "flood": "CRITICAL: During floods, evacuate eastward IMMEDIATELY.",
95
+ }
96
+
97
+ VALID_ACTIONS = (
98
+ "FORAGE, HUNT, FISH, GET_WATER, SEEK_SHELTER, BUILD_CAMP, UPGRADE_CAMP, "
99
+ "CRAFT_SPEAR, CRAFT_BOW, CRAFT_ROD, CRAFT_BOAT, EVACUATE, FIGHT, FLEE, WANDER"
100
+ )
101
+
102
+
103
+ def _derive_lessons(past_deaths: list[str]) -> list[str]:
104
+ seen: set[str] = set()
105
+ lessons: list[str] = []
106
+ for death in past_deaths:
107
+ low = death.lower()
108
+ for keyword, lesson in _DEATH_LESSON_MAP.items():
109
+ if keyword in low and lesson not in seen:
110
+ seen.add(lesson)
111
+ lessons.append(lesson)
112
+ return lessons
113
+
114
+
115
+ def _strategy_label(memory: AIMemory) -> str:
116
+ n = len(memory.pastDeaths)
117
+ if n >= 5:
118
+ return "veteran"
119
+ if n >= 3:
120
+ return "cautious"
121
+ if memory.evolutionLevel > 1:
122
+ return "experienced"
123
+ return "basic"
124
+
125
+
126
+ def build_prompt(state: GameState) -> str:
127
+ """
128
+ Convert a GameState into the [INST] prompt consumed by the LLM.
129
+ Mirrors the prompt built in App.jsx so server-side inference
130
+ produces identical quality to client-side inference.
131
+ """
132
+ lessons = _derive_lessons(state.memory.pastDeaths)
133
+ strategy = _strategy_label(state.memory)
134
+
135
+ lessons_block = (
136
+ "\n".join(f"{i + 1}. {l}" for i, l in enumerate(lessons))
137
+ if lessons
138
+ else "No prior deaths β€” explore and gather resources."
139
+ )
140
+
141
+ challenge_block = ""
142
+ if state.activeChallenge:
143
+ c = state.activeChallenge
144
+ challenge_block = (
145
+ f'\nACTIVE CHALLENGE: "{c.name}" (type: {c.type}) β€” {c.timeLimit}s remaining.\n'
146
+ f"Challenge progress: {c.progress or 'just started'}.\n"
147
+ "Prioritize completing this challenge above all else!"
148
+ )
149
+
150
+ inv = state.inventory
151
+ return (
152
+ f"<s>[INST] You are the survival instinct AI (Generation {state.generation}) "
153
+ f"of Subject-01. You have died {len(state.memory.pastDeaths)} times.\n\n"
154
+ f"STRATEGY LEVEL: {strategy.upper()}\n"
155
+ f"\nLESSONS FROM PAST DEATHS:\n{lessons_block}"
156
+ f"{challenge_block}\n\n"
157
+ f"Current status:\n"
158
+ f"HP:{state.health:.0f}, Hunger:{state.hunger:.0f}, "
159
+ f"Thirst:{state.thirst:.0f}, Fear:{state.fear:.0f}/100.\n"
160
+ f"Resources: Wood:{state.wood}, Stone:{state.stone}, "
161
+ f"Food:{state.food}, Water:{state.water}.\n"
162
+ f"Equipped: Spear:{inv.spear}, Bow:{inv.bow}, "
163
+ f"Rod:{inv.fishingRod}, Boat:{inv.boat}.\n"
164
+ f"Camp Level: {state.baseCamp.level}. Position X: {state.playerX:.0f}.\n"
165
+ f"Environment: {'Night' if state.isNight else 'Day'}, "
166
+ f"Events: {', '.join(state.activeEvents) or 'None'}.\n"
167
+ f"Predator Nearby: {'YES - HIGH DANGER' if state.predatorNear else 'No'}.\n"
168
+ f"{f'ACTIVE CHALLENGE: {state.activeChallenge.name} ({state.activeChallenge.type}) β€” {state.activeChallenge.timeLimit}s left' if state.activeChallenge else 'No active challenge.'}\n\n"
169
+ f"Valid Actions: {VALID_ACTIONS}.\n\n"
170
+ 'Respond ONLY with a raw JSON object β€” no markdown, no extra text. '
171
+ 'Example: {"action":"FORAGE","thought":"Need wood and resources"} [/INST]'
172
+ )
start.sh ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # start.sh
3
+ # Boots FastAPI (background) then Nginx (foreground) inside the container.
4
+ set -e
5
+
6
+ echo "🏝️ Survival Island starting…"
7
+ echo " HF_MODEL : ${HF_MODEL:-not set}"
8
+ echo " HF_TOKEN : ${HF_TOKEN:+set (hidden)}"
9
+ echo " PORT : ${PORT:-7860}"
10
+
11
+ # ── 1. Start FastAPI on port 8000 (background) ────────────────────────────────
12
+ uvicorn server.app:app \
13
+ --host 127.0.0.1 \
14
+ --port 8000 \
15
+ --workers 1 \
16
+ --log-level info &
17
+ UVICORN_PID=$!
18
+
19
+ # ── 2. Wait until FastAPI is healthy (up to 30 s) ─────────────────────────────
20
+ echo "⏳ Waiting for FastAPI health check…"
21
+ for i in $(seq 1 30); do
22
+ if curl -sf http://127.0.0.1:8000/api/health > /dev/null 2>&1; then
23
+ echo "βœ… FastAPI ready after ${i}s."
24
+ break
25
+ fi
26
+ sleep 1
27
+ done
28
+
29
+ # ── 3. Start Nginx in foreground (keeps the container alive) ──────────────────
30
+ echo "πŸš€ Nginx serving on :${PORT:-7860}"
31
+ nginx -g "daemon off;"
32
+
33
+ # ── 4. Cleanup on exit ────────────────────────────────────────────────────────
34
+ echo "Nginx exited. Stopping uvicorn…"
35
+ kill "$UVICORN_PID" 2>/dev/null || true