dataclean-env / inference.py
Dhruv Goyal
fix
1dd9581
Raw
History Blame Contribute Delete
16 kB
"""
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()