""" inference.py — Baseline inference script for the Data Cleaning OpenEnv environment. Mandatory configuration (set as environment variables): API_BASE_URL The LLM API endpoint (default: HuggingFace Inference Router) MODEL_NAME The model identifier (default: Qwen/Qwen2.5-72B-Instruct) HF_TOKEN Your HuggingFace API key Optional: TASK_NAME Task to run (default: runs all three tasks sequentially) MAX_STEPS Maximum steps per episode (overrides task default) STDOUT FORMAT (mandatory — do not alter field names or order): [START] task= env=data-cleaning-env model= [STEP] step= action= reward=<0.00> done= error= [END] success= steps= score= rewards= """ from __future__ import annotations import json import os import sys import textwrap from typing import Any, Dict, List, Optional from openai import OpenAI # --------------------------------------------------------------------------- # Import environment directly (no Docker client needed for local/HF execution) # --------------------------------------------------------------------------- from environment.env import DataCleaningEnv, TASK_CONFIG from environment.models import DataCleaningAction, DataCleaningObservation # ============================================================================ # Configuration # ============================================================================ API_BASE_URL: str = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1") MODEL_NAME: str = os.getenv("MODEL_NAME", "Qwen/Qwen2.5-72B-Instruct") HF_TOKEN = os.getenv("HF_TOKEN") BENCHMARK: str = "data-cleaning-env" # Which tasks to run (comma-separated list, or 'all') _TASK_ENV = os.getenv("TASK_NAME", "all") TASKS_TO_RUN: List[str] = ( ["csv-doctor", "schema-enforcer", "pipeline-debugger"] if _TASK_ENV == "all" else [t.strip() for t in _TASK_ENV.split(",")] ) SEED: int = int(os.getenv("SEED", "42")) MAX_STEPS: int = int(os.getenv("MAX_STEPS", "0")) # 0 → use task default TEMPERATURE: float = float(os.getenv("TEMPERATURE", "0.2")) MAX_TOKENS: int = int(os.getenv("MAX_TOKENS", "512")) SUCCESS_THRESHOLD: float = 0.65 # score in [0, 1] that counts as "success" # ============================================================================ # Logging helpers (mandatory format — do not change) # ============================================================================ def log_start(task: str, env: str, model: str) -> None: print(f"[START] task={task} env={env} model={model}", flush=True) def log_step( step: int, action: str, reward: float, done: bool, error: Optional[str], ) -> None: error_val = error if error else "null" done_val = str(done).lower() # Sanitise action string — no newlines allowed on a single [STEP] line action_clean = action.replace("\n", " ").replace("\r", "")[:200] print( f"[STEP] step={step} action={action_clean} " f"reward={reward:.2f} done={done_val} error={error_val}", flush=True, ) def log_end( success: bool, steps: int, score: float, rewards: List[float], ) -> None: rewards_str = ",".join(f"{r:.2f}" for r in rewards) print( f"[END] success={str(success).lower()} steps={steps} " f"score={score:.3f} rewards={rewards_str}", flush=True, ) # ============================================================================ # Prompt builders # ============================================================================ SYSTEM_PROMPT = textwrap.dedent(""" You are an expert data scientist operating an AI data cleaning environment. Your goal is to fix data quality issues in the provided dataset by issuing data-cleaning actions one at a time. AVAILABLE ACTIONS (issue one per turn as valid JSON): fill_missing — fill null values in a column drop_duplicates — remove duplicate rows cast_column — change a column's data type normalize_format — standardise phone/email/date/zip_code/text_case/strip_currency apply_regex — regex substitution on a column drop_column — remove a column entirely drop_rows_by_condition — drop rows matching a condition clip_outliers — clip statistical outliers (iqr/zscore) standardize_text — apply strip/lower/upper/title/remove_extra_spaces fix_referential_integrity — fix foreign-key violations merge_tables — merge an auxiliary table RESPONSE FORMAT (respond with ONLY this JSON — no markdown, no explanation): { "action_type": "", "parameters": { ... } } Examples: {"action_type": "fill_missing", "parameters": {"column": "age", "strategy": "median"}} {"action_type": "drop_duplicates", "parameters": {}} {"action_type": "normalize_format", "parameters": {"column": "phone", "format_type": "phone"}} {"action_type": "cast_column", "parameters": {"column": "salary", "dtype": "float"}} {"action_type": "clip_outliers", "parameters": {"column": "price", "method": "iqr", "threshold": 1.5}} PRIORITIES: 1. Fix the highest-severity issues first. 2. For 'csv-doctor': fix salary currency strings → cast salary, fix age dtype, fill nulls, drop dupes, title case names & strip dept whitespace. 3. For 'schema-enforcer': use normalize_format for phone/email/date/zip_code, then fix country case and name casing. 4. For 'pipeline-debugger': fix FK violations → drop dupes → clip outliers → merge_tables to add segment. 5. Never drop more than 30% of rows in a single action (incurs penalty). 6. Stop issuing redundant actions once an issue is fixed. """).strip() def _obs_summary(obs: DataCleaningObservation) -> str: """Build a concise observation string for the LLM.""" issues_text = "\n".join( f" - [{i.severity.upper()}] {i.issue_type}: {i.description}" + (f" (column: {i.column})" if i.column else "") for i in obs.issues[:10] ) or " None detected" columns_text = "\n".join( f" {c.name} ({c.dtype}): {c.null_count} nulls, {c.unique_count} unique" + (f" | issues: {'; '.join(c.detected_issues)}" if c.detected_issues else "") for c in obs.columns[:12] ) history_text = ( "\n".join(f" {h}" for h in obs.actions_history[-5:]) if obs.actions_history else " None yet" ) schema_text = "" if obs.target_schema: schema_text = "\nTARGET SCHEMA:\n" + json.dumps(obs.target_schema, indent=2)[:800] aux_text = "" if obs.auxiliary_datasets: aux_text = "\nAUXILIARY TABLES (preview):\n" + json.dumps( obs.auxiliary_datasets, default=str )[:400] return textwrap.dedent(f""" TASK: {obs.task_name} OBJECTIVE: {obs.task_description[:300]} DATASET STATS: Rows: {obs.stats.total_rows} | Cols: {obs.stats.total_cols} Missing cells: {obs.stats.missing_cells} ({obs.stats.missing_pct:.1f}%) Duplicate rows: {obs.stats.duplicate_rows} Dtype issues: {obs.stats.dtype_issues} CURRENT SCORE: {obs.current_score:.3f} (step {obs.step_count}/{obs.max_steps}) COLUMNS: {columns_text} DETECTED ISSUES: {issues_text}{schema_text}{aux_text} RECENT ACTIONS: {history_text} Issue the next cleaning action as JSON: """).strip() def _parse_action(text: str) -> Optional[DataCleaningAction]: """Extract a DataCleaningAction from the model's response.""" text = text.strip() # Try to extract JSON block try: # Sometimes the model wraps it in ```json ... ``` if "```" in text: import re match = re.search(r"```(?:json)?\s*([\s\S]*?)```", text) if match: text = match.group(1).strip() data = json.loads(text) return DataCleaningAction( action_type=data.get("action_type", ""), parameters=data.get("parameters", {}), ) except (json.JSONDecodeError, KeyError, ValueError): return None def _get_llm_action( client: OpenAI, obs: DataCleaningObservation, step: int, ) -> tuple[Optional[DataCleaningAction], str]: """Call the LLM and return (action, raw_text).""" user_prompt = _obs_summary(obs) try: completion = client.chat.completions.create( model=MODEL_NAME, messages=[ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_prompt}, ], temperature=TEMPERATURE, max_tokens=MAX_TOKENS, ) raw = (completion.choices[0].message.content or "").strip() except Exception as exc: print(f"[DEBUG] LLM call failed at step {step}: {exc}", flush=True) raw = "" action = _parse_action(raw) return action, raw # ============================================================================ # Fallback rule-based agent (for when LLM fails or is unavailable) # ============================================================================ def _rule_based_action(obs: DataCleaningObservation, step: int) -> DataCleaningAction: """ Deterministic rule-based agent used as fallback. Follows a fixed priority queue per task, ensuring reproducible baseline scores. """ task = obs.task_name issues = obs.issues columns = {c.name: c for c in obs.columns} if task == "csv-doctor": # Priority order if obs.stats.duplicate_rows > 0: return DataCleaningAction(action_type="drop_duplicates", parameters={}) # Fix salary currency string if "salary" in columns and any("currency" in i for i in columns["salary"].detected_issues): return DataCleaningAction( action_type="normalize_format", parameters={"column": "salary", "format_type": "strip_currency"}, ) # Fill missing age if "age" in columns and columns["age"].null_count > 0: return DataCleaningAction( action_type="fill_missing", parameters={"column": "age", "strategy": "median"}, ) # Cast age to int if "age" in columns and columns["age"].dtype in ("float64", "float32"): return DataCleaningAction( action_type="cast_column", parameters={"column": "age", "dtype": "int"}, ) # Fill missing salary if "salary" in columns and columns["salary"].null_count > 0: return DataCleaningAction( action_type="fill_missing", parameters={"column": "salary", "strategy": "median"}, ) # Fill missing email if "email" in columns and columns["email"].null_count > 0: return DataCleaningAction( action_type="fill_missing", parameters={"column": "email", "strategy": "constant", "fill_value": "unknown@example.com"}, ) # Title-case names if "name" in columns: return DataCleaningAction( action_type="standardize_text", parameters={"column": "name", "operations": ["title"]}, ) # Strip department whitespace if "department" in columns: return DataCleaningAction( action_type="standardize_text", parameters={"column": "department", "operations": ["strip"]}, ) elif task == "schema-enforcer": order = [ ("phone", "normalize_format", {"column": "phone", "format_type": "phone"}), ("birth_date", "normalize_format", {"column": "birth_date", "format_type": "date"}), ("email", "normalize_format", {"column": "email", "format_type": "email"}), ("zip_code", "normalize_format", {"column": "zip_code", "format_type": "zip_code"}), ("country", "normalize_format", {"column": "country", "format_type": "text_case", "output_format": "upper"}), ("first_name", "standardize_text", {"column": "first_name", "operations": ["title"]}), ("last_name", "standardize_text", {"column": "last_name", "operations": ["title"]}), ] idx = min(step - 1, len(order) - 1) col, act, params = order[idx] return DataCleaningAction(action_type=act, parameters=params) elif task == "pipeline-debugger": order = [ ("fix_referential_integrity", {"child_column": "customer_id", "parent_table": "customers", "parent_column": "customer_id", "action": "drop"}), ("drop_duplicates", {"subset": ["customer_id", "product", "price", "quantity", "order_date"]}), ("clip_outliers", {"column": "price", "method": "iqr", "threshold": 1.5}), ("clip_outliers", {"column": "quantity", "method": "iqr", "threshold": 1.5}), ("merge_tables", {"right_table": "customers", "left_on": "customer_id", "right_on": "customer_id", "how": "left", "columns": ["segment"]}), ] idx = min(step - 1, len(order) - 1) act, params = order[idx] return DataCleaningAction(action_type=act, parameters=params) # Fallback no-op: drop duplicates (safe) return DataCleaningAction(action_type="drop_duplicates", parameters={}) # ============================================================================ # Single-task episode runner # ============================================================================ def run_task( env: DataCleaningEnv, client: Optional[OpenAI], task_name: str, ) -> Dict[str, Any]: """Run one full episode and return result metrics.""" rewards: List[float] = [] steps_taken = 0 score = 0.0 success = False error_msg: Optional[str] = None log_start(task=task_name, env=BENCHMARK, model=MODEL_NAME) max_steps = MAX_STEPS if MAX_STEPS > 0 else TASK_CONFIG[task_name]["max_steps"] try: reset_result = env.reset(task_name=task_name, seed=SEED) obs = reset_result.observation for step in range(1, max_steps + 1): if obs.step_count > 0 and step > obs.max_steps: break # Try LLM agent first; fall back to rule-based on failure action: Optional[DataCleaningAction] = None action_str = "" if client is not None: action, raw_text = _get_llm_action(client, obs, step) action_str = raw_text[:150] if raw_text else "parse_error" if action is None: action = _rule_based_action(obs, step) action_str = f"{action.action_type}({action.parameters})" step_result = env.step(action) obs = step_result.observation reward = step_result.reward done = step_result.done err = step_result.info.get("action_message") if not step_result.info.get("action_success", True) else None rewards.append(reward) steps_taken = step log_step(step=step, action=action_str, reward=reward, done=done, error=err) if done: break state = env.state() score = float(state.current_score) success = score >= SUCCESS_THRESHOLD except Exception as exc: error_msg = str(exc) print(f"[DEBUG] Episode error: {exc}", flush=True) score = float(env.state().current_score) if steps_taken > 0 else 0.0 finally: log_end(success=success, steps=steps_taken, score=score, rewards=rewards) return { "task": task_name, "score": round(score, 4), "success": success, "steps": steps_taken, "rewards": rewards, "error": error_msg, } # ============================================================================ # Main entry point # ============================================================================ def main() -> None: # Build OpenAI client (pointing to HF Inference Router or custom endpoint) try: client: Optional[OpenAI] = OpenAI(base_url=API_BASE_URL, api_key=HF_TOKEN) # Quick connectivity test client.models.list() except Exception as exc: print(f"[DEBUG] LLM client unavailable ({exc}), using rule-based fallback.", flush=True) client = None env = DataCleaningEnv() all_results: List[Dict[str, Any]] = [] for task in TASKS_TO_RUN: result = run_task(env, client, task) all_results.append(result) print("", flush=True) # blank line between tasks # Summary avg_score = sum(r["score"] for r in all_results) / len(all_results) if all_results else 0.0 n_success = sum(1 for r in all_results if r["success"]) print( f"[SUMMARY] tasks={len(all_results)} success={n_success}/{len(all_results)} " f"avg_score={avg_score:.3f}", flush=True, ) if __name__ == "__main__": main()