iialonso's picture
Publish DataQualityProfilerTool with tool.py, requirements, and documentation
972fc05 verified
Raw History Blame Contribute Delete
10.8 kB
from typing import Any, Optional
from smolagents.tools import Tool
import os
import json
import io
import pandas
class DataQualityProfilerTool(Tool):
name = "data_quality_profiler"
description = "Profiles tabular datasets (CSV, TSV, JSON lines, Markdown tables, or file paths) according to enterprise Data Governance (CDMP\u00ae / DAMA-DMBOK) standards. Computes overall data quality scores, completeness, uniqueness, primary key candidates, statistical distributions, outlier flags, and actionable anomalies for downstream agent tasks."
inputs = {'data': {'type': 'string', 'description': 'Raw tabular string content (CSV, TSV, JSON, Markdown table) or an existing file path to inspect.'}, 'file_type': {'type': 'string', 'description': "Optional format hint: 'csv', 'tsv', 'json', 'markdown', or 'auto' (default: 'auto').", 'nullable': True}, 'max_sample_rows': {'type': 'integer', 'description': 'Maximum number of rows to sample for profiling if dataset is huge (default: 10000).', 'nullable': True}}
output_type = "string"
def _parse_input(self, data_str: str, file_type: Optional[str] = None):
import io
import json
import os
import pandas as pd
clean_str = data_str.strip()
fmt = (file_type or "auto").lower()
# Check if input is a valid existing file path
if os.path.exists(clean_str) and os.path.isfile(clean_str):
ext = os.path.splitext(clean_str)[1].lower()
if ext in [".csv", ".txt"] or fmt == "csv":
return pd.read_csv(clean_str)
elif ext in [".tsv", ".tab"] or fmt == "tsv":
return pd.read_csv(clean_str, sep="\t")
elif ext == ".json" or fmt == "json":
try:
return pd.read_json(clean_str)
except Exception:
return pd.read_json(clean_str, lines=True)
elif ext == ".parquet":
return pd.read_parquet(clean_str)
# Handle Markdown table format
if fmt == "markdown" or ("|" in clean_str and "\n|-" in clean_str):
lines = [line.strip() for line in clean_str.splitlines() if line.strip().startswith("|")]
if len(lines) >= 3:
header = [c.strip() for c in lines[0].split("|")[1:-1]]
data_rows = []
for row_line in lines[2:]:
cols = [c.strip() for c in row_line.split("|")[1:-1]]
if len(cols) == len(header):
data_rows.append(cols)
if data_rows:
return pd.DataFrame(data_rows, columns=header)
# Handle JSON format (array or jsonl)
if fmt == "json" or clean_str.startswith("[") or clean_str.startswith("{"):
try:
data_obj = json.loads(clean_str)
if isinstance(data_obj, list):
return pd.DataFrame(data_obj)
elif isinstance(data_obj, dict):
return pd.DataFrame([data_obj])
except Exception:
try:
return pd.read_json(io.StringIO(clean_str), lines=True)
except Exception:
pass
# Handle Delimited (CSV / TSV)
if fmt == "tsv" or ("\t" in clean_str and "," not in clean_str):
return pd.read_csv(io.StringIO(clean_str), sep="\t")
else:
try:
return pd.read_csv(io.StringIO(clean_str))
except Exception:
return pd.read_csv(io.StringIO(clean_str), sep=None, engine="python")
def forward(
self,
data: str,
file_type: Optional[str] = "auto",
max_sample_rows: Optional[int] = 10000,
) -> str:
"""Profiles the provided dataset and returns a comprehensive Markdown report."""
import pandas as pd
if not data or not data.strip():
return "Error: Empty dataset provided. Please provide non-empty tabular data."
try:
df = self._parse_input(data, file_type)
except Exception as err:
return f"Error parsing input data: {str(err)}. Ensure valid CSV, TSV, JSON, or Markdown table format."
if df.empty:
return "Error: Parsed dataframe contains zero rows or columns."
total_rows = len(df)
total_cols = len(df.columns)
is_sampled = False
if max_sample_rows and total_rows > max_sample_rows:
df = df.sample(n=max_sample_rows, random_state=42)
is_sampled = True
analyzed_rows = len(df)
# 1. Uniqueness & Row Duplication
duplicate_rows_count = int(df.duplicated().sum())
duplicate_rate = (duplicate_rows_count / analyzed_rows) if analyzed_rows > 0 else 0.0
# 2. Completeness Dimension
total_cells = analyzed_rows * total_cols
missing_cells = int(df.isna().sum().sum())
completeness_rate = 1.0 - (missing_cells / total_cells) if total_cells > 0 else 1.0
# Primary Key Candidates
pk_candidates = [
str(col) for col in df.columns
if df[col].isna().sum() == 0 and df[col].nunique() == analyzed_rows and analyzed_rows > 1
]
# 3. Column Profiling & Anomaly Detection
anomalies = []
col_profiles = []
if duplicate_rows_count > 0:
anomalies.append(
f"**High Row Duplication**: Found {duplicate_rows_count} exact duplicate rows ({duplicate_rate:.1%})."
)
for col in df.columns:
series = df[col]
col_name = str(col)
null_count = int(series.isna().sum())
null_pct = (null_count / analyzed_rows) * 100.0
n_unique = int(series.nunique(dropna=True))
if null_pct > 50.0:
anomalies.append(f"**Critical Missing Values**: Column `{col_name}` has {null_pct:.1f}% nulls.")
elif null_pct > 20.0:
anomalies.append(f"**Elevated Missing Values**: Column `{col_name}` has {null_pct:.1f}% nulls.")
if n_unique == 1 and analyzed_rows > 1:
anomalies.append(f"**Constant Column**: Column `{col_name}` has only 1 distinct value (zero variance).")
# Logical Type Inference
non_null_series = series.dropna()
is_numeric = pd.api.types.is_numeric_dtype(series)
if is_numeric and len(non_null_series) > 0:
min_val = float(non_null_series.min())
max_val = float(non_null_series.max())
mean_val = float(non_null_series.mean())
q25 = float(non_null_series.quantile(0.25))
q75 = float(non_null_series.quantile(0.75))
iqr = q75 - q25
outliers_count = 0
if iqr > 0:
lower_bound = q25 - 1.5 * iqr
upper_bound = q75 + 1.5 * iqr
outliers_count = int(((non_null_series < lower_bound) | (non_null_series > upper_bound)).sum())
if outliers_count > 0:
outlier_pct = (outliers_count / len(non_null_series)) * 100.0
if outlier_pct > 5.0:
anomalies.append(
f"**Significant Outliers**: Column `{col_name}` has {outliers_count} statistical outliers ({outlier_pct:.1f}%)."
)
col_profiles.append({
"name": col_name,
"type": "Numeric",
"null_pct": f"{null_pct:.1f}%",
"distinct": n_unique,
"stats": f"min: {min_val:.2f}, max: {max_val:.2f}, mean: {mean_val:.2f}, outliers: {outliers_count}",
})
else:
top_vals = non_null_series.value_counts().head(2).to_dict()
top_str = ", ".join([f"'{k}': {v}" for k, v in top_vals.items()]) if top_vals else "N/A"
col_profiles.append({
"name": col_name,
"type": "Categorical/Text",
"null_pct": f"{null_pct:.1f}%",
"distinct": n_unique,
"stats": f"top values: [{top_str}]",
})
# Calculate Overall Health Score
completeness_score = completeness_rate * 50.0
uniqueness_score = (1.0 - duplicate_rate) * 30.0
anomaly_penalty = min(20.0, len(anomalies) * 4.0)
validity_score = 20.0 - anomaly_penalty
overall_score = round(completeness_score + uniqueness_score + validity_score, 1)
if overall_score >= 90.0:
verdict = "EXCELLENT (Production Ready)"
elif overall_score >= 75.0:
verdict = "GOOD (Minor Cleaning Needed)"
elif overall_score >= 50.0:
verdict = "NEEDS ATTENTION (Notable Quality Issues)"
else:
verdict = "CRITICAL (Severe Defects Detected)"
# Build Markdown Report
lines = [
"# Data Quality & Governance Profile Report",
f"**Overall Quality Score:** `{overall_score} / 100` — **{verdict}**",
"",
"## 1. Executive Summary",
f"- **Total Rows:** {total_rows:,}" + (f" *(Profiled sample: {analyzed_rows:,})*" if is_sampled else ""),
f"- **Total Columns:** {total_cols}",
f"- **Completeness Rate:** `{completeness_rate:.1%}` ({missing_cells:,} missing cells)",
f"- **Row Duplication Rate:** `{duplicate_rate:.1%}` ({duplicate_rows_count:,} duplicate rows)",
f"- **Primary Key Candidates:** {', '.join([f'`{k}`' for k in pk_candidates]) if pk_candidates else 'None detected (No single unique non-null column)'}",
"",
"## 2. Key Actionable Anomalies",
]
if anomalies:
for anomaly in anomalies:
lines.append(f"- {anomaly}")
else:
lines.append("- No critical quality anomalies detected! Data conforms to expected bounds.")
lines.extend([
"",
"## 3. Column-by-Column Health Breakdown",
"| Column Name | Inferred Type | Missing % | Unique Count | Distribution / Top Values |",
"| :--- | :--- | :---: | :---: | :--- |",
])
for cp in col_profiles:
lines.append(f"| `{cp['name']}` | {cp['type']} | {cp['null_pct']} | {cp['distinct']:,} | {cp['stats']} |")
lines.extend([
"",
"---",
"*Report generated deterministically according to DAMA-DMBOK / CDMP® standards via `smolagents` Data Quality Profiler.*",
])
return "\n".join(lines)
def __init__(self, *args, **kwargs):
self.is_initialized = False