manzz05/kitchenflow-v2
1
1#!/usr/bin/env python32"""3inference.py — KitchenFlow-v1 Ghost Kitchen Dispatcher Baseline4===============================================================5Runs an LLM agent against all 3 tasks. Each step = 1 simulation minute.6 7Required environment variables:8 API_BASE_URL LLM API endpoint (default: https://router.huggingface.co/v1)9 MODEL_NAME Model identifier10 HF_TOKEN HuggingFace / API key11 12Usage:13 python inference.py14 python inference.py --url http://localhost:786015 python inference.py --task T1_single_order_dispatch16 17Structured output format (required by validator):18 [START] task=TASK_ID19 [STEP] step=N reward=R score=S done=true/false20 [END] task=TASK_ID score=S steps=N21 Scores are strictly in (0, 1) — never 0.0 or 1.0.22"""23 24import argparse25import json26import os27import sys28import textwrap29import time30import urllib.request31import urllib.error32from typing import Optional33 34from openai import OpenAI35 36# ── Config ────────────────────────────────────────────────────────────────────37API_BASE_URL = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1")38API_KEY = os.getenv("HF_TOKEN") or os.getenv("API_KEY", "")39MODEL_NAME = os.getenv("MODEL_NAME", "meta-llama/Llama-3.3-70B-Instruct")40ENV_URL = os.getenv("ENV_URL", "http://localhost:7860")41 42TEMPERATURE = 0.143MAX_TOKENS = 25644 45SYSTEM_PROMPT = textwrap.dedent("""46 You are the AI dispatcher for a ghost kitchen delivery hub.47 Every minute you receive a snapshot of active orders and must decide48 whether to summon a driver for each order.49 50 Physics:51 - Driver speed = 30 km/h / traffic_index (in km per minute: 0.5 / traffic_index)52 - Driver ETA (minutes) = driver_dist_km / (0.5 / traffic_index)53 - Food cools at 1.8 degrees C/minute while waiting; perfect temp = 75 degrees C54 - Once summoned, a driver cannot be un-summoned55 56 Strategy:57 - Summon driver when: food_ready_min - current_time approximately equals driver_eta_min58 - i.e. dispatch so driver arrives just as food is bagged59 - food_ready_min approximately equals current_time + (1 - food_prep_progress) x prep_time_min60 - (prep_time is NOT shown -- infer it from food_prep_progress vs time)61 62 Reply with ONLY a valid JSON object:63 {"dispatch_decisions": {"ORD001": 0, "ORD002": 1}}64 Values: 0 = wait, 1 = summon driver now (ignored if already summoned)65 Include every active order ID in the response.66""").strip()67 68 69def build_prompt(obs: dict) -> str:70 lines = [71 f"MINUTE: {obs['time_min']} / {obs['max_time_min']}",72 f"TRAFFIC INDEX: {obs['traffic_index']} "73 f"(driver speed = {0.5 / obs['traffic_index']:.3f} km/min)",74 "",75 "ORDERS:",76 ]77 for o in obs["orders"]:78 if o["delivered"] or o["failed"]:79 lines.append(f" {o['order_id']} [{o['status'].upper()}]")80 continue81 82 eta_str = f"ETA={o['driver_eta_min']}min" if o["driver_summoned"] else "not summoned"83 lines.append(84 f" {o['order_id']} | {o['item_name']}"85 f" | prep={o['food_prep_progress']*100:.0f}%"86 f" | dist={o['driver_dist_km']:.2f}km ({eta_str})"87 f" | temp={o['food_temp_c']:.1f}C"88 f" | food_ready={'YES' if o['food_ready'] else 'no'}"89 f" | driver_arrived={'YES' if o['driver_arrived'] else 'no'}"90 )91 if o["driver_arrived"] and not o["food_ready"]:92 lines.append(f" WARNING: DRIVER WAITING {o['minutes_driver_waited']}min")93 if o["food_ready"] and not o["driver_summoned"]:94 lines.append(f" WARNING: FOOD READY but NO driver summoned")95 96 if obs.get("last_action_feedback") and obs.get("attempts", 0) > 1:97 lines += ["", f"LAST EVENT: {obs['last_action_feedback'][-200:]}"]98 99 lines += ["", "Your JSON decision (include all active order IDs):"]100 return "\n".join(lines)101 102 103def call_llm(client: OpenAI, prompt: str) -> dict:104 completion = client.chat.completions.create(105 model=MODEL_NAME,106 messages=[107 {"role": "system", "content": SYSTEM_PROMPT},108 {"role": "user", "content": prompt},109 ],110 temperature=TEMPERATURE,111 max_tokens=MAX_TOKENS,112 )113 text = (completion.choices[0].message.content or "").strip()114 if text.startswith("```"):115 lines = text.splitlines()116 text = "\n".join(l for l in lines if not l.strip().startswith("```")).strip()117 try:118 return json.loads(text)119 except json.JSONDecodeError:120 return {"dispatch_decisions": {}}121 122 123# ── Score clamper — validator requires strictly (0, 1), never 0.0 or 1.0 ──────124 125def _clamp(score: float) -> float:126 """Clamp score to strictly open interval (0, 1): never 0.0, never 1.0."""127 return max(0.001, min(0.999, float(score)))128 129 130# ── HTTP Client ───────────────────────────────────────────────────────────────131 132class EnvClient:133 def __init__(self, base_url: str):134 self._url = base_url.rstrip("/")135 self._episode_id: Optional[str] = None136 137 def _post(self, path: str, body: dict) -> dict:138 data = json.dumps(body).encode()139 req = urllib.request.Request(140 f"{self._url}{path}", data=data,141 headers={"Content-Type": "application/json"},142 )143 try:144 with urllib.request.urlopen(req, timeout=30) as r:145 return json.loads(r.read())146 except urllib.error.HTTPError as e:147 raise RuntimeError(f"HTTP {e.code}: {e.read().decode()[:200]}") from e148 149 def _get(self, path: str) -> dict:150 with urllib.request.urlopen(f"{self._url}{path}", timeout=10) as r:151 return json.loads(r.read())152 153 def reset(self, task_id: Optional[str] = None) -> dict:154 body: dict = {}155 if task_id:156 body["task_id"] = task_id157 obs = self._post("/reset", body)158 self._episode_id = obs.get("episode_id")159 return obs160 161 def step(self, action: dict) -> dict:162 body: dict = {"action": action}163 if self._episode_id:164 body["episode_id"] = self._episode_id165 return self._post("/step", body)166 167 def tasks(self) -> list:168 try:169 return [t["task_id"] for t in self._get("/tasks").get("tasks", [])]170 except Exception:171 return [172 "T1_single_order_dispatch",173 "T2_multi_order_coordination",174 "T3_peak_hour_rush",175 ]176 177 178# ── Task runner ───────────────────────────────────────────────────────────────179 180def run_task(client: OpenAI, env: EnvClient, task_id: str) -> float:181 obs = env.reset(task_id=task_id)182 max_mins = obs.get("max_time_min", 30)183 184 # Required structured output: START185 print(f"[START] task={task_id}", flush=True)186 187 best_score = 0.0188 step_num = 0189 190 for minute in range(1, max_mins + 1):191 if obs.get("done", False):192 break193 194 step_num += 1195 prompt = build_prompt(obs)196 197 try:198 action_data = call_llm(client, prompt)199 except Exception:200 action_data = {"dispatch_decisions": {}}201 202 try:203 obs = env.step(action_data)204 except RuntimeError:205 score_out = _clamp(best_score)206 print(f"[STEP] step={step_num} reward=0.0010 score={score_out:.4f} done=true", flush=True)207 break208 209 reward = obs.get("reward", 0.0)210 score = _clamp(obs.get("score", 0.0))211 done = obs.get("done", False)212 best_score = max(best_score, score)213 214 # Required structured output: STEP215 print(216 f"[STEP] step={step_num} reward={reward:.4f} score={score:.4f} "217 f"done={'true' if done else 'false'}",218 flush=True,219 )220 221 if done:222 best_score = max(best_score, score)223 break224 225 final_score = _clamp(best_score)226 227 # Required structured output: END228 print(f"[END] task={task_id} score={final_score:.4f} steps={step_num}", flush=True)229 230 return final_score231 232 233# ── Main ──────────────────────────────────────────────────────────────────────234 235def main():236 parser = argparse.ArgumentParser(description="KitchenFlow-v1 baseline agent")237 parser.add_argument("--url", default=ENV_URL)238 parser.add_argument("--task", default=None)239 args = parser.parse_args()240 241 if not API_KEY:242 print("ERROR: Set HF_TOKEN environment variable", file=sys.stderr, flush=True)243 sys.exit(1)244 245 llm = OpenAI(base_url=API_BASE_URL, api_key=API_KEY)246 env = EnvClient(args.url)247 248 task_ids = [args.task] if args.task else env.tasks()249 scores = {}250 start = time.time()251 252 for tid in task_ids:253 try:254 scores[tid] = run_task(llm, env, tid)255 except Exception as exc:256 # Still emit valid structured blocks even on error257 fallback = _clamp(0.001)258 print(f"[START] task={tid}", flush=True)259 print(f"[STEP] step=1 reward=0.0010 score={fallback:.4f} done=true", flush=True)260 print(f"[END] task={tid} score={fallback:.4f} steps=1", flush=True)261 scores[tid] = fallback262 263 elapsed = time.time() - start264 avg = sum(scores.values()) / len(scores) if scores else 0.0265 266 print(f"\nAverage score: {avg:.4f} | Time: {elapsed:.1f}s", flush=True)267 sys.exit(0 if avg > 0 else 1)268 269 270if __name__ == "__main__":271 main()