CSV_DC_ENV / server /csv_cleaning_environment.py
printf-sourav's picture
final
2f52c9b
Raw
History Blame Contribute Delete
18.5 kB
"""
CSV Cleaning Environment Implementation.
A real-world data cleaning environment where an AI agent must clean messy CSV
datasets using structured commands. Exposes cleaning tools through MCP.
Supported tools:
- rename_column(old_name, new_name)
- cast_column(column, dtype)
- fill_missing(column, strategy, value?)
- drop_missing(column?)
- drop_duplicates(columns?)
- filter_rows(column, operator, value)
- strip_whitespace(column)
- replace_values(column, old_value, new_value)
"""
import json
import os
from typing import Any, Dict, List, Optional
from uuid import uuid4
import pandas as pd
try:
from openenv.core.env_server.mcp_environment import MCPEnvironment
from openenv.core.env_server.types import Action, Observation, State
except ImportError:
from openenv.core.env_server.mcp_environment import MCPEnvironment
from openenv.core.env_server.types import Action, Observation, State
from fastmcp import FastMCP
from .tasks import TASKS, TaskDefinition
class CsvCleaningEnvironment(MCPEnvironment):
"""
A data cleaning environment where agents fix messy CSV data.
The environment generates a messy dataset for the selected task.
Each step, the agent issues a cleaning command via MCP tools.
The environment applies the command, updates the dataset, and
returns reward based on progress toward the target clean dataset.
"""
def __init__(self):
"""Initialize with MCP server and cleaning tools."""
mcp = FastMCP("csv_cleaner_env")
self._df: Optional[pd.DataFrame] = None
self._target: Optional[pd.DataFrame] = None
self._task: Optional[TaskDefinition] = None
self._last_result: str = ""
self._prev_score: float = 0.0
self._state = State(episode_id=str(uuid4()), step_count=0)
self._done = False
self._env_ref = self # capture for closures
# ---- MCP Tools ----
@mcp.tool
def rename_column(old_name: str, new_name: str) -> str:
"""Rename a column in the dataset."""
return self._exec_rename_column(old_name, new_name)
@mcp.tool
def cast_column(column: str, dtype: str) -> str:
"""Cast a column to a new type. dtype: int, float, str, datetime."""
return self._exec_cast_column(column, dtype)
@mcp.tool
def fill_missing(column: str, strategy: str, value: str = "") -> str:
"""Fill missing values. strategy: mean, median, mode, constant. value used if strategy=constant."""
return self._exec_fill_missing(column, strategy, value)
@mcp.tool
def drop_missing(column: str = "") -> str:
"""Drop rows with missing values. If column empty, drops rows with any null."""
return self._exec_drop_missing(column)
@mcp.tool
def drop_duplicates(columns: str = "") -> str:
"""Remove duplicate rows. columns: comma-separated list or empty for all."""
return self._exec_drop_duplicates(columns)
@mcp.tool
def filter_rows(column: str, operator: str, value: str) -> str:
"""Filter rows. operator: ==, !=, >, <, >=, <=, contains."""
return self._exec_filter_rows(column, operator, value)
@mcp.tool
def strip_whitespace(column: str) -> str:
"""Strip leading/trailing whitespace from a string column."""
return self._exec_strip_whitespace(column)
@mcp.tool
def replace_values(column: str, old_value: str, new_value: str) -> str:
"""Replace occurrences of old_value with new_value in a column."""
return self._exec_replace_values(column, old_value, new_value)
@mcp.tool
def get_dataset_info() -> str:
"""Get current dataset info: columns, types, null counts, sample rows."""
return self._exec_get_info()
super().__init__(mcp)
# ------------------------------------------------------------------
# Tool implementations
# ------------------------------------------------------------------
def _exec_rename_column(self, old_name: str, new_name: str) -> str:
if self._df is None:
return "Error: No dataset loaded. Call reset() first."
if old_name not in self._df.columns:
self._last_result = f"Error: Column '{old_name}' not found. Available: {list(self._df.columns)}"
return self._last_result
self._df = self._df.rename(columns={old_name: new_name})
self._last_result = f"Renamed '{old_name}' to '{new_name}'"
return self._last_result
def _exec_cast_column(self, column: str, dtype: str) -> str:
if self._df is None:
return "Error: No dataset loaded."
if column not in self._df.columns:
self._last_result = f"Error: Column '{column}' not found."
return self._last_result
try:
if dtype == "int":
self._df[column] = pd.to_numeric(self._df[column], errors="coerce").astype("Int64")
elif dtype == "float":
self._df[column] = pd.to_numeric(self._df[column].astype(str).str.replace("$", "", regex=False), errors="coerce")
elif dtype == "str":
self._df[column] = self._df[column].astype(str)
elif dtype in ("datetime", "date"):
self._df[column] = pd.to_datetime(self._df[column], errors="coerce")
else:
self._last_result = f"Error: Unknown dtype '{dtype}'. Use: int, float, str, datetime."
return self._last_result
self._last_result = f"Cast '{column}' to {dtype}"
except Exception as e:
self._last_result = f"Error casting '{column}' to {dtype}: {e}"
return self._last_result
def _exec_fill_missing(self, column: str, strategy: str, value: str = "") -> str:
if self._df is None:
return "Error: No dataset loaded."
if column not in self._df.columns:
self._last_result = f"Error: Column '{column}' not found."
return self._last_result
try:
null_before = int(self._df[column].isnull().sum())
if strategy == "mean":
fill_val = self._df[column].mean()
self._df[column] = self._df[column].fillna(fill_val)
elif strategy == "median":
fill_val = self._df[column].median()
self._df[column] = self._df[column].fillna(fill_val)
elif strategy == "mode":
mode_vals = self._df[column].mode()
fill_val = mode_vals[0] if len(mode_vals) > 0 else ""
self._df[column] = self._df[column].fillna(fill_val)
elif strategy == "constant":
self._df[column] = self._df[column].fillna(value)
elif strategy == "zero":
self._df[column] = self._df[column].fillna(0)
else:
self._last_result = f"Error: Unknown strategy '{strategy}'. Use: mean, median, mode, constant, zero."
return self._last_result
null_after = int(self._df[column].isnull().sum())
self._last_result = f"Filled {null_before - null_after} nulls in '{column}' using {strategy}"
except Exception as e:
self._last_result = f"Error filling missing in '{column}': {e}"
return self._last_result
def _exec_drop_missing(self, column: str = "") -> str:
if self._df is None:
return "Error: No dataset loaded."
before = len(self._df)
try:
if column and column in self._df.columns:
self._df = self._df.dropna(subset=[column]).reset_index(drop=True)
else:
self._df = self._df.dropna().reset_index(drop=True)
after = len(self._df)
self._last_result = f"Dropped {before - after} rows with missing values"
except Exception as e:
self._last_result = f"Error dropping missing: {e}"
return self._last_result
def _exec_drop_duplicates(self, columns: str = "") -> str:
if self._df is None:
return "Error: No dataset loaded."
before = len(self._df)
try:
if columns:
col_list = [c.strip() for c in columns.split(",")]
valid_cols = [c for c in col_list if c in self._df.columns]
if valid_cols:
self._df = self._df.drop_duplicates(subset=valid_cols).reset_index(drop=True)
else:
self._last_result = f"Error: None of {col_list} found in columns."
return self._last_result
else:
self._df = self._df.drop_duplicates().reset_index(drop=True)
after = len(self._df)
self._last_result = f"Removed {before - after} duplicate rows"
except Exception as e:
self._last_result = f"Error removing duplicates: {e}"
return self._last_result
def _exec_filter_rows(self, column: str, operator: str, value: str) -> str:
if self._df is None:
return "Error: No dataset loaded."
if column not in self._df.columns:
self._last_result = f"Error: Column '{column}' not found."
return self._last_result
before = len(self._df)
try:
col_data = self._df[column]
if operator == "==":
mask = col_data.astype(str) == value
elif operator == "!=":
mask = col_data.astype(str) != value
elif operator == ">":
mask = pd.to_numeric(col_data, errors="coerce") > float(value)
elif operator == "<":
mask = pd.to_numeric(col_data, errors="coerce") < float(value)
elif operator == ">=":
mask = pd.to_numeric(col_data, errors="coerce") >= float(value)
elif operator == "<=":
mask = pd.to_numeric(col_data, errors="coerce") <= float(value)
elif operator == "contains":
mask = col_data.astype(str).str.contains(value, na=False)
else:
self._last_result = f"Error: Unknown operator '{operator}'."
return self._last_result
self._df = self._df[mask].reset_index(drop=True)
after = len(self._df)
self._last_result = f"Filtered: kept {after} rows ({before - after} removed)"
except Exception as e:
self._last_result = f"Error filtering: {e}"
return self._last_result
def _exec_strip_whitespace(self, column: str) -> str:
if self._df is None:
return "Error: No dataset loaded."
if column not in self._df.columns:
self._last_result = f"Error: Column '{column}' not found."
return self._last_result
try:
self._df[column] = self._df[column].astype(str).str.strip()
self._last_result = f"Stripped whitespace from '{column}'"
except Exception as e:
self._last_result = f"Error stripping whitespace: {e}"
return self._last_result
def _exec_replace_values(self, column: str, old_value: str, new_value: str) -> str:
if self._df is None:
return "Error: No dataset loaded."
if column not in self._df.columns:
self._last_result = f"Error: Column '{column}' not found."
return self._last_result
try:
count = int((self._df[column].astype(str) == old_value).sum())
self._df[column] = self._df[column].astype(str).str.replace(old_value, new_value, regex=False)
self._last_result = f"Replaced {count} occurrences of '{old_value}' with '{new_value}' in '{column}'"
except Exception as e:
self._last_result = f"Error replacing values: {e}"
return self._last_result
def _exec_get_info(self) -> str:
if self._df is None:
return "Error: No dataset loaded."
obs_data = self._get_observation_dict()
info = {
"row_count": obs_data["row_count"],
"duplicate_count": obs_data["duplicate_count"],
"columns": obs_data["columns"],
"task_description": obs_data["task_description"],
"last_action_result": obs_data["last_action_result"],
"progress": obs_data["progress"],
}
return json.dumps(info, indent=2)
# ------------------------------------------------------------------
# Environment API
# ------------------------------------------------------------------
def _get_observation_dict(self) -> Dict[str, Any]:
"""Build observation data from current state."""
if self._df is None:
return {
"columns": [],
"row_count": 0,
"duplicate_count": 0,
"task_description": "",
"last_action_result": self._last_result,
"progress": 0.0,
}
columns_info = []
for col in self._df.columns:
columns_info.append({
"name": col,
"dtype": str(self._df[col].dtype),
"null_count": int(self._df[col].isnull().sum()),
"unique_count": int(self._df[col].nunique()),
"sample_values": [str(v) for v in self._df[col].dropna().head(3).tolist()],
})
progress = 0.0
if self._task and self._target is not None:
progress = self._task.grade(self._df, self._target)
return {
"columns": columns_info,
"row_count": len(self._df),
"duplicate_count": int(self._df.duplicated().sum()),
"task_description": self._task.description if self._task else "",
"last_action_result": self._last_result,
"progress": round(min(max(progress, 0.0), 1.0), 4),
}
def reset(
self,
seed: Optional[int] = None,
episode_id: Optional[str] = None,
**kwargs: Any,
) -> Observation:
"""Reset environment with a messy dataset for the configured task."""
task_name = kwargs.get("task", os.getenv("CSV_CLEANER_TASK", "fix_column_types"))
actual_seed = seed if seed is not None else 42
if task_name not in TASKS:
available = list(TASKS.keys())
return Observation(
done=True,
reward=0.0,
metadata={"error": f"Unknown task '{task_name}'. Available: {available}"},
)
self._task = TASKS[task_name]
self._df = self._task.generate_messy(actual_seed)
self._target = self._task.generate_target(actual_seed)
self._done = False
self._last_result = "Environment ready. Use get_dataset_info to see the current state."
self._prev_score = self._task.grade(self._df, self._target)
self._state = State(
episode_id=episode_id or str(uuid4()),
step_count=0,
)
obs_data = self._get_observation_dict()
return Observation(
done=False,
reward=0.0,
metadata={
"status": "ready",
"task": task_name,
"difficulty": self._task.difficulty,
"max_steps": self._task.max_steps,
"checklist": self._task.checklist,
**obs_data,
},
)
def _step_impl(
self,
action: Action,
timeout_s: Optional[float] = None,
**kwargs: Any,
) -> Observation:
"""Handle non-MCP actions (returns error — use MCP tools instead)."""
return Observation(
done=False,
reward=0.0,
metadata={
"error": f"Unknown action type: {type(action).__name__}. "
"Use MCP tools (get_dataset_info, cast_column, fill_missing, etc.)",
},
)
def step(
self,
action: Action,
timeout_s: Optional[float] = None,
**kwargs: Any,
) -> Observation:
"""Execute a step. Increments step count, computes reward."""
self._state.step_count += 1
# Let MCPEnvironment handle tool dispatch
obs = super().step(action, timeout_s=timeout_s, **kwargs)
# Compute reward based on progress delta
reward = 0.0
done = False
if self._task and self._target is not None and self._df is not None:
current_score = self._task.grade(self._df, self._target)
reward = max(0.0, current_score - self._prev_score)
self._prev_score = current_score
# Check if done (target reached or max steps exceeded)
if current_score >= 0.95:
done = True
reward += 0.1 # bonus for completing
elif self._state.step_count >= self._task.max_steps:
done = True
self._done = done
# Inject our reward/done into the observation
obs.reward = round(reward, 4)
obs.done = done
if obs.metadata is None:
obs.metadata = {}
obs.metadata.update(self._get_observation_dict())
return obs
async def step_async(
self,
action: Action,
timeout_s: Optional[float] = None,
**kwargs: Any,
) -> Observation:
"""Async step used by the WebSocket handler."""
self._state.step_count += 1
obs = await super().step_async(action, timeout_s=timeout_s, **kwargs)
reward = 0.0
done = False
if self._task and self._target is not None and self._df is not None:
current_score = self._task.grade(self._df, self._target)
reward = max(0.0, current_score - self._prev_score)
self._prev_score = current_score
if current_score >= 0.95:
done = True
reward += 0.1
elif self._state.step_count >= self._task.max_steps:
done = True
self._done = done
obs.reward = round(reward, 4)
obs.done = done
if obs.metadata is None:
obs.metadata = {}
obs.metadata.update(self._get_observation_dict())
return obs
@property
def state(self) -> State:
"""Get current environment state."""
return self._state