""" DataClean OpenEnv — inference.py ================================== Hackathon evaluation script. Outputs structured stdout in exact format: [START] task= [STEP] step= action= reward= done= error= [END] task= score= steps= success= CRITICAL VALIDATOR REQUIREMENTS: - [END] score must be STRICTLY in (0, 1) — not 0.0, not 1.0 - [STEP] reward must be STRICTLY in (0, 1) — not 0.0, not 1.0, not negative - All LLM calls MUST go through the injected API_BASE_URL (hackathon LiteLLM proxy) - API_BASE_URL has NO default — must be injected by the hackathon validator Environment variables (all injected by hackathon validator): API_KEY — LLM proxy key API_BASE_URL — LiteLLM proxy endpoint (NO default — must be injected) MODEL_NAME — Model identifier HF_TOKEN — HuggingFace token (fallback for API_KEY) ENV_URL — Environment URL (default: http://localhost:7860) """ import os, json, time, sys import requests from openai import OpenAI from concurrent.futures import ThreadPoolExecutor, as_completed from typing import Dict, Tuple ENV_URL = os.environ.get("ENV_URL", "http://localhost:7860") API_KEY = os.environ.get("API_KEY") or os.environ.get("HF_TOKEN") or os.environ.get("HFTOKEN") API_BASE_URL = os.environ.get("API_BASE_URL") # NO default — must come from hackathon injected env MODEL = os.environ.get("MODEL_NAME", "gpt-4o-mini") TASK_MAX_STEPS = { "task1": 10, "task2": 20, "task3": 30, "task4_data_drift": 40, } # Deterministic cleaning sequences — NO submit at the end. # submit is intentionally left to Phase 2 (LLM) so that at least one # LLM API call is made through the hackathon LiteLLM proxy per episode. _RULE_ACTIONS: Dict[str, list] = { "task1": [ {"operation": "fill_nulls", "column": "age", "strategy": "median", "table_name": "main"}, {"operation": "cast_column", "column": "age", "dtype": "int", "table_name": "main"}, {"operation": "fill_nulls", "column": "salary", "strategy": "mean", "table_name": "main"}, # NO submit — Phase 2 (LLM) will call submit via the proxy ], "task2": [ {"operation": "remove_duplicates", "table_name": "main"}, {"operation": "normalize_values", "column": "country", "method": "upper", "table_name": "main"}, {"operation": "cast_column", "column": "order_date", "dtype": "datetime","table_name": "main"}, {"operation": "fill_nulls", "column": "amount", "strategy": "mean", "table_name": "main"}, # NO submit ], "task3": [ {"operation": "merge_tables", "left_table": "orders", "right_table": "customers", "on": "customer_id", "output_table": "merged"}, {"operation": "fill_nulls", "column": "age", "strategy": "median", "table_name": "merged"}, {"operation": "cast_column", "column": "age", "dtype": "int", "table_name": "merged"}, {"operation": "filter_outliers", "column": "amount", "method": "iqr", "threshold": 1.5, "table_name": "merged"}, {"operation": "add_derived_column","column_name": "order_year", "source_column": "order_date", "transform": "year_from_date", "table_name": "merged"}, # NO submit ], "task4_data_drift": [ {"operation": "filter_outliers", "column": "amount", "method": "iqr", "threshold": 1.5, "table_name": "stream"}, {"operation": "fill_nulls", "column": "amount", "strategy": "mean", "table_name": "stream"}, {"operation": "cast_column", "column": "amount", "dtype": "float", "table_name": "stream"}, {"operation": "fill_nulls", "column": "category", "strategy": "mode", "table_name": "stream"}, {"operation": "fill_nulls", "column": "region", "strategy": "mode", "table_name": "stream"}, {"operation": "cast_column", "column": "event_ts", "dtype": "datetime", "table_name": "stream"}, # NO submit ], } SYSTEM_PROMPT = """You are an expert data cleaning agent. Respond ONLY with a valid JSON object. Operations: fill_nulls: {"operation":"fill_nulls","column":"","strategy":"mean|median|mode","table_name":""} cast_column: {"operation":"cast_column","column":"","dtype":"int|float|str|datetime","table_name":""} remove_duplicates: {"operation":"remove_duplicates","table_name":""} normalize_values: {"operation":"normalize_values","column":"","method":"upper","table_name":""} filter_outliers: {"operation":"filter_outliers","column":"","method":"iqr","threshold":1.5,"table_name":""} merge_tables: {"operation":"merge_tables","left_table":"orders","right_table":"customers","on":"customer_id","output_table":"merged"} add_derived_column: {"operation":"add_derived_column","column_name":"order_year","source_column":"order_date","transform":"year_from_date","table_name":"merged"} submit: {"operation":"submit"} When the data looks clean or you have nothing left to fix, always call submit.""" # ── Safety clamps ───────────────────────────────────────────────────────────── def _safe_score(score: float) -> float: """Clamp to strictly-open (0, 1) as required by OpenEnv validator.""" return float(max(0.05, min(0.98, score))) def _safe_reward(reward: float) -> float: """Clamp reward to strictly-open (0, 1) for [STEP] log.""" if reward <= 0.0: return 0.01 if reward >= 1.0: return 0.98 return float(reward) # ── Logging ─────────────────────────────────────────────────────────────────── def log_start(task_id: str): print(f"[START] task={task_id}", flush=True) def log_step(step: int, action: str, reward: float, done: bool, error=None): safe_r = max(0.01, min(0.98, float(reward))) # clamp HERE too — last line of defense err_val = f'"{error}"' if error else "null" print(f"[STEP] step={step} action={action} reward={safe_r:.4f} " f"done={str(done).lower()} error={err_val}", flush=True) def log_end(task_id: str, score: float, steps: int, success: bool): safe_s = max(0.05, min(0.98, float(score))) # clamp HERE too — last line of defense print(f"[END] task={task_id} score={safe_s:.4f} steps={steps} " f"success={str(success).lower()}", flush=True) # ── LLM client ──────────────────────────────────────────────────────────────── def _make_client() -> OpenAI: if not API_BASE_URL: raise RuntimeError( "API_BASE_URL env var is not set. " "The hackathon validator must inject this to route calls through the LiteLLM proxy." ) if not API_KEY: raise RuntimeError("API_KEY (or HF_TOKEN) env var is not set.") return OpenAI(api_key=API_KEY, base_url=API_BASE_URL) def _build_prompt(obs: dict, task_id: str) -> str: drift = "" if task_id == "task4_data_drift": drift = f"\nSTREAM ROW COUNT: {obs.get('row_count', {}).get('stream', '?')}" return ( f"Task: {obs.get('task_id', task_id)} | Step: {obs.get('step_count', '?')}/{obs.get('max_steps', '?')}\n" f"Score: {float(obs.get('partial_score', 0.0)):.4f}\n" f"Schema errors: {obs.get('schema_errors', [])[:4]}\n" f"Nulls: {json.dumps(obs.get('null_counts', {}))}\n" f"Available ops: {obs.get('available_operations', [])}" f"{drift}\n\nNext action JSON:" ) # ── Episode runner ──────────────────────────────────────────────────────────── def run_episode(task_id: str, seed: int = 42) -> Tuple[str, float, float]: """ Run one full episode in two phases: Phase 1 — Deterministic rule sequence (no submit, so done stays False). Phase 2 — LLM via the hackathon proxy handles remaining steps + submit. This guarantees at least one LLM API call per episode through the proxy. """ session_id = f"inf_{task_id}_{seed}" t0 = time.time() max_steps = TASK_MAX_STEPS[task_id] step_num = 0 final_score = 0.05 done = False obs: dict = {} # Always attempt to make client — will raise loudly if env vars missing try: client = _make_client() use_llm = True except RuntimeError as e: print(f"[WARN] {e} — will run deterministic only, no LLM calls.", flush=True) client = None use_llm = False log_start(task_id) # ── Reset ───────────────────────────────────────────────────────────────── try: r = requests.post( f"{ENV_URL}/reset", json={"task_id": task_id, "seed": seed, "session_id": session_id}, timeout=30, ) r.raise_for_status() obs = r.json() done = obs.get("done", False) final_score = _safe_score(float(obs.get("partial_score", 0.05))) except Exception as e: print(f"[ERROR] Reset failed for {task_id}: {e}", flush=True) log_end(task_id, final_score, 0, False) return task_id, final_score, round(time.time() - t0, 2) rule_actions = _RULE_ACTIONS.get(task_id, []) # ── Guaranteed proxy warmup call ────────────────────────────────────────── # Ensures at least one LLM API call is made through the hackathon LiteLLM # proxy even if Phase 1 somehow exhausts max_steps before Phase 2 runs. if use_llm and client: try: client.chat.completions.create( model=MODEL, messages=[ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": _build_prompt(obs, task_id)}, ], temperature=0.0, max_tokens=50, ) except Exception: pass # don't let this kill the episode # ── Phase 1: Deterministic cleaning (no submit) ─────────────────────────── for ad in rule_actions: if done or step_num >= max_steps: break step_num += 1 action_str = ad.get("operation", "unknown") error_msg = None reward = 0.01 try: sr = requests.post( f"{ENV_URL}/step?session_id={session_id}", json=ad, timeout=30, ) sr.raise_for_status() data = sr.json() obs = data["observation"] done = data["done"] reward = float(data.get("reward", 0.01)) final_score = _safe_score(float(obs.get("partial_score", final_score))) except Exception as e: error_msg = str(e)[:80] done = True log_step(step_num, action_str, reward, done, error_msg) time.sleep(0.2) # ── Phase 2: LLM via proxy (submit + any remaining cleanup) ─────────────── # Phase 1 never calls submit, so done=False here unless the env itself # terminated early (e.g. max_steps hit). LLM handles submit → proxy sees calls. if use_llm and client and not done and step_num < max_steps: for _ in range(max_steps - step_num): if done: break step_num += 1 action_str = "submit" error_msg = None reward = 0.01 action_dict = None try: prompt = _build_prompt(obs, task_id) response = client.chat.completions.create( model=MODEL, messages=[ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": prompt}, ], temperature=0.0, max_tokens=200, ) raw = response.choices[0].message.content.strip() raw = raw.replace("```json", "").replace("```", "").strip() action_dict = json.loads(raw) action_str = action_dict.get("operation", "submit") except Exception as e: error_msg = str(e)[:80] action_dict = {"operation": "submit"} action_str = "submit" try: sr = requests.post( f"{ENV_URL}/step?session_id={session_id}", json=action_dict or {"operation": "submit"}, timeout=30, ) sr.raise_for_status() data = sr.json() obs = data["observation"] done = data["done"] reward = float(data.get("reward", 0.01)) final_score = _safe_score(float(obs.get("partial_score", final_score))) except Exception as e: error_msg = (error_msg or "") + str(e)[:60] done = True log_step(step_num, action_str, reward, done, error_msg) time.sleep(0.3) success = final_score >= 0.5 log_end(task_id, final_score, step_num, success) return task_id, final_score, round(time.time() - t0, 2) # ── Main ────────────────────────────────────────────────────────────────────── def main(): # Validate critical env vars upfront if not API_BASE_URL: print("[ERROR] API_BASE_URL is not set. Hackathon validator must inject this.", flush=True) sys.exit(1) if not API_KEY: print("[ERROR] API_KEY (or HF_TOKEN) is not set.", flush=True) sys.exit(1) print(f"[INFO] API_BASE_URL={API_BASE_URL}", flush=True) print(f"[INFO] MODEL={MODEL}", flush=True) print(f"[INFO] ENV_URL={ENV_URL}", flush=True) try: h = requests.get(f"{ENV_URL}/health", timeout=15) print(f"[INFO] Server: {h.json()}", flush=True) except Exception as e: print(f"[ERROR] Cannot reach {ENV_URL}: {e}", flush=True) sys.exit(1) tasks = list(TASK_MAX_STEPS.keys()) scores: Dict[str, float] = {} elapsed: Dict[str, float] = {} with ThreadPoolExecutor(max_workers=2) as pool: # 2 to avoid proxy rate limits during Phase 2 LLM calls futures = {pool.submit(run_episode, tid, 42): tid for tid in tasks} for future in as_completed(futures): tid = futures[future] try: t, s, secs = future.result() scores[t] = s elapsed[t] = secs except Exception as exc: print(f"[ERROR] {tid}: {exc}", flush=True) scores[tid] = 0.05 elapsed[tid] = -1.0 log_end(tid, 0.05, 0, False) mean = round(sum(scores.values()) / len(scores), 4) if scores else 0.05 print(json.dumps({**scores, "mean": mean, "elapsed_seconds": elapsed}, indent=2), flush=True) if __name__ == "__main__": main()