Spaces:
Sleeping
Sleeping
| """ | |
| DataClean OpenEnv β inference.py | |
| ================================== | |
| Hackathon evaluation script. Outputs structured stdout in exact format: | |
| [START] task=<task_id> | |
| [STEP] step=<n> action=<op> reward=<float> done=<bool> error=<null|"msg"> | |
| [END] task=<task_id> score=<float> steps=<n> success=<bool> | |
| 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":"<col>","strategy":"mean|median|mode","table_name":"<tbl>"} | |
| cast_column: {"operation":"cast_column","column":"<col>","dtype":"int|float|str|datetime","table_name":"<tbl>"} | |
| remove_duplicates: {"operation":"remove_duplicates","table_name":"<tbl>"} | |
| normalize_values: {"operation":"normalize_values","column":"<col>","method":"upper","table_name":"<tbl>"} | |
| filter_outliers: {"operation":"filter_outliers","column":"<col>","method":"iqr","threshold":1.5,"table_name":"<tbl>"} | |
| 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() |