Spaces:
Sleeping
Sleeping
Commit ·
9eecab5
1
Parent(s): b2fb95a
Fresh deployment with LFS tracking
Browse files- agents/analysis_agent.py +329 -0
- agents/dataframe_agent.py +141 -0
- agents/metadata_agent.py +73 -0
- agents/transformer_agent.py +518 -0
- agents/visualization_agent.py +145 -0
- attached_assets/Pasted-list-me-all-the-datasets-i-have-Datasets-Name--17735902_1773590289755.txt +51 -0
- cli_app/command_handler.py +324 -0
- core/llm_planner.py +305 -0
- core/query_router.py +96 -0
- data/dataframe_store.py +58 -0
- data/loader.py +34 -0
- data/metadata/Customer_Churn.json +97 -0
- data/metadata/Loan_default.json +85 -0
- data/metadata/creditcard.json +136 -0
- data/metadata/titanic.json +61 -0
- data/registry.py +164 -0
- data/schema_extractor.py +29 -0
- experiments/experimentation.py +0 -0
- instructions/analyze.txt +82 -0
- testing/test_agent_routing.py +211 -0
- testing/test_transformer.py +369 -0
- testing/test_visualization.py +134 -0
- utils/logger.py +15 -0
- vector_store/analyze_embeddings.pkl +3 -0
- vector_store/instruction_embedder.py +140 -0
agents/analysis_agent.py
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from utils.logger import logger
|
| 2 |
+
import numpy as np
|
| 3 |
+
from sklearn.ensemble import RandomForestClassifier
|
| 4 |
+
|
| 5 |
+
class AnalysisAgent:
|
| 6 |
+
|
| 7 |
+
def __init__(self, registry):
|
| 8 |
+
|
| 9 |
+
self.registry = registry
|
| 10 |
+
|
| 11 |
+
# ---------------------------
|
| 12 |
+
# Proper dataset extraction
|
| 13 |
+
# ---------------------------
|
| 14 |
+
def _extract_dataset(self, text):
|
| 15 |
+
print("🔍 Running dataset analysis...")
|
| 16 |
+
datasets = self.registry.list_datasets()
|
| 17 |
+
words = str(text).lower().split()
|
| 18 |
+
|
| 19 |
+
for word in words:
|
| 20 |
+
for d in datasets:
|
| 21 |
+
if word == d.lower():
|
| 22 |
+
return d
|
| 23 |
+
return None
|
| 24 |
+
|
| 25 |
+
# ---------------------------
|
| 26 |
+
# Remove ID-like columns
|
| 27 |
+
# ---------------------------
|
| 28 |
+
def _remove_id_like_columns(self, df):
|
| 29 |
+
cols_to_drop = []
|
| 30 |
+
|
| 31 |
+
for col in df.columns:
|
| 32 |
+
unique_ratio = df[col].nunique() / len(df)
|
| 33 |
+
|
| 34 |
+
if unique_ratio > 0.9:
|
| 35 |
+
cols_to_drop.append(col)
|
| 36 |
+
|
| 37 |
+
df_clean = df.drop(columns=cols_to_drop)
|
| 38 |
+
|
| 39 |
+
return df_clean, cols_to_drop
|
| 40 |
+
|
| 41 |
+
# ---------------------------
|
| 42 |
+
# Select target column
|
| 43 |
+
# ---------------------------
|
| 44 |
+
def _select_target(self, df):
|
| 45 |
+
candidates = []
|
| 46 |
+
|
| 47 |
+
for col in df.columns:
|
| 48 |
+
unique_count = df[col].nunique()
|
| 49 |
+
unique_ratio = unique_count / len(df)
|
| 50 |
+
|
| 51 |
+
# Skip obvious bad columns
|
| 52 |
+
if any(k in col.lower() for k in ["id", "name", "email", "phone"]):
|
| 53 |
+
continue
|
| 54 |
+
|
| 55 |
+
# Skip high-cardinality
|
| 56 |
+
if unique_ratio > 0.5:
|
| 57 |
+
continue
|
| 58 |
+
|
| 59 |
+
# Prefer categorical / classification targets
|
| 60 |
+
if unique_count <= 20:
|
| 61 |
+
candidates.append((col, unique_count))
|
| 62 |
+
|
| 63 |
+
# Pick best candidate (lowest unique count but >1)
|
| 64 |
+
if candidates:
|
| 65 |
+
candidates = sorted(candidates, key=lambda x: x[1])
|
| 66 |
+
return candidates[0][0]
|
| 67 |
+
|
| 68 |
+
return None
|
| 69 |
+
|
| 70 |
+
# ---------------------------
|
| 71 |
+
# Feature importance
|
| 72 |
+
# ---------------------------
|
| 73 |
+
def _compute_feature_importance(self, df):
|
| 74 |
+
|
| 75 |
+
df_clean, dropped_cols = self._remove_id_like_columns(df)
|
| 76 |
+
if len(df.columns) <= 2:
|
| 77 |
+
return None, dropped_cols, "Dataset too small for feature importance."
|
| 78 |
+
target = self._select_target(df_clean)
|
| 79 |
+
if not target:
|
| 80 |
+
return None, dropped_cols, "No suitable target column found."
|
| 81 |
+
|
| 82 |
+
y = df_clean[target]
|
| 83 |
+
|
| 84 |
+
# Fix NaN issue
|
| 85 |
+
if y.isnull().sum() > 0:
|
| 86 |
+
return None, dropped_cols, "Target contains missing values. Cannot compute feature importance."
|
| 87 |
+
if not target:
|
| 88 |
+
return None, dropped_cols, "No suitable target column found."
|
| 89 |
+
|
| 90 |
+
# Prevent sklearn warning
|
| 91 |
+
if df_clean[target].nunique() > 0.5 * len(df_clean):
|
| 92 |
+
return None, dropped_cols, "Target not suitable for classification."
|
| 93 |
+
|
| 94 |
+
X = df_clean.drop(columns=[target])
|
| 95 |
+
y = df_clean[target]
|
| 96 |
+
|
| 97 |
+
# Encode categoricals
|
| 98 |
+
X = X.apply(lambda col: col.astype('category').cat.codes)
|
| 99 |
+
|
| 100 |
+
try:
|
| 101 |
+
model = RandomForestClassifier(n_estimators=50)
|
| 102 |
+
model.fit(X, y)
|
| 103 |
+
|
| 104 |
+
importances = dict(zip(X.columns, model.feature_importances_))
|
| 105 |
+
sorted_imp = sorted(importances.items(), key=lambda x: x[1], reverse=True)
|
| 106 |
+
|
| 107 |
+
return sorted_imp[:5], dropped_cols, None
|
| 108 |
+
|
| 109 |
+
except Exception as e:
|
| 110 |
+
return None, dropped_cols, str(e)
|
| 111 |
+
|
| 112 |
+
# ---------------------------
|
| 113 |
+
# Optional explanation layer
|
| 114 |
+
# ---------------------------
|
| 115 |
+
def _explain_feature(self, col):
|
| 116 |
+
return f"{col} shows strong predictive signal based on dataset patterns."
|
| 117 |
+
|
| 118 |
+
#----------------------------
|
| 119 |
+
# Outlier Detection
|
| 120 |
+
#----------------------------
|
| 121 |
+
def _detect_outliers(self, df):
|
| 122 |
+
try:
|
| 123 |
+
numeric_df = df.select_dtypes(include="number")
|
| 124 |
+
|
| 125 |
+
outlier_summary = {}
|
| 126 |
+
|
| 127 |
+
for col in numeric_df.columns:
|
| 128 |
+
q1 = numeric_df[col].quantile(0.25)
|
| 129 |
+
q3 = numeric_df[col].quantile(0.75)
|
| 130 |
+
iqr = q3 - q1
|
| 131 |
+
|
| 132 |
+
lower = q1 - 1.5 * iqr
|
| 133 |
+
upper = q3 + 1.5 * iqr
|
| 134 |
+
|
| 135 |
+
outliers = numeric_df[(numeric_df[col] < lower) | (numeric_df[col] > upper)]
|
| 136 |
+
|
| 137 |
+
if len(outliers) > 0:
|
| 138 |
+
outlier_summary[col] = len(outliers)
|
| 139 |
+
|
| 140 |
+
return outlier_summary, None
|
| 141 |
+
|
| 142 |
+
except Exception as e:
|
| 143 |
+
logger.error(f"Outlier detection failed | {e}")
|
| 144 |
+
return None, str(e)
|
| 145 |
+
|
| 146 |
+
#---------------------------
|
| 147 |
+
# Correlation analysis
|
| 148 |
+
#---------------------------
|
| 149 |
+
def _compute_correlation(self, df):
|
| 150 |
+
try:
|
| 151 |
+
numeric_df = df.select_dtypes(include="number")
|
| 152 |
+
|
| 153 |
+
if numeric_df.shape[1] < 2:
|
| 154 |
+
return None, "Not enough numeric columns for correlation."
|
| 155 |
+
|
| 156 |
+
# corr = numeric_df.corr()
|
| 157 |
+
|
| 158 |
+
# Get top correlations (excluding self)
|
| 159 |
+
corr_matrix = numeric_df.corr().abs()
|
| 160 |
+
|
| 161 |
+
upper = corr_matrix.where(
|
| 162 |
+
np.triu(np.ones(corr_matrix.shape), k=1).astype(bool)
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
top_pairs = (
|
| 166 |
+
upper.unstack()
|
| 167 |
+
.dropna()
|
| 168 |
+
.sort_values(ascending=False)
|
| 169 |
+
.head(5)
|
| 170 |
+
)
|
| 171 |
+
return top_pairs.to_dict(), None
|
| 172 |
+
|
| 173 |
+
except Exception as e:
|
| 174 |
+
logger.error(f"Correlation failed | {e}")
|
| 175 |
+
return None, str(e)
|
| 176 |
+
|
| 177 |
+
#-------------------------
|
| 178 |
+
#Saving report
|
| 179 |
+
#-------------------------
|
| 180 |
+
def _export_report(self, dataset, content):
|
| 181 |
+
try:
|
| 182 |
+
path = f"output/report_{dataset}.txt"
|
| 183 |
+
|
| 184 |
+
with open(path, "w", encoding="utf-8") as f:
|
| 185 |
+
f.write(content)
|
| 186 |
+
|
| 187 |
+
logger.info(f"Report exported: {path}")
|
| 188 |
+
|
| 189 |
+
return path
|
| 190 |
+
|
| 191 |
+
except Exception as e:
|
| 192 |
+
logger.error(f"Report export failed | {e}")
|
| 193 |
+
return None
|
| 194 |
+
|
| 195 |
+
# ---------------------------
|
| 196 |
+
# MAIN HANDLER
|
| 197 |
+
# ---------------------------
|
| 198 |
+
def handle(self, dataset=None):
|
| 199 |
+
|
| 200 |
+
try:
|
| 201 |
+
# ---- HANDLE "analyze people" CASE ----
|
| 202 |
+
if isinstance(dataset, str):
|
| 203 |
+
extracted = self._extract_dataset(dataset)
|
| 204 |
+
if extracted:
|
| 205 |
+
dataset = extracted
|
| 206 |
+
|
| 207 |
+
# ---- STRICT DATASET CHECK ----
|
| 208 |
+
if not dataset:
|
| 209 |
+
return "Please specify a dataset (e.g., 'analyze people')"
|
| 210 |
+
|
| 211 |
+
df = self.registry.load_dataframe(dataset)
|
| 212 |
+
|
| 213 |
+
except Exception as e:
|
| 214 |
+
logger.error(f"Failed loading dataset | {e}")
|
| 215 |
+
return f"Failed to load dataset: {dataset}"
|
| 216 |
+
|
| 217 |
+
try:
|
| 218 |
+
# ---------- OUTPUT ----------
|
| 219 |
+
output = []
|
| 220 |
+
rows, cols = df.shape
|
| 221 |
+
print("🧹 Checking duplicates...")
|
| 222 |
+
# ---------- DATA QUALITY ----------
|
| 223 |
+
total_missing = df.isnull().sum().sum()
|
| 224 |
+
duplicates = df.duplicated().sum()
|
| 225 |
+
|
| 226 |
+
missing_by_column = df.isnull().sum()
|
| 227 |
+
missing_by_column = missing_by_column[missing_by_column > 0]
|
| 228 |
+
|
| 229 |
+
# ---------- COLUMN TYPES ----------
|
| 230 |
+
numeric_cols = df.select_dtypes(include="number").columns.tolist()
|
| 231 |
+
categorical_cols = df.select_dtypes(exclude="number").columns.tolist()
|
| 232 |
+
|
| 233 |
+
# ---------- WARNINGS ----------
|
| 234 |
+
print("⚠️ Generating warnings...")
|
| 235 |
+
warnings = []
|
| 236 |
+
|
| 237 |
+
for col in df.columns:
|
| 238 |
+
if len(df) == 0:
|
| 239 |
+
continue
|
| 240 |
+
|
| 241 |
+
unique_ratio = df[col].nunique() / len(df)
|
| 242 |
+
|
| 243 |
+
if unique_ratio > 0.95 and "id" in col.lower():
|
| 244 |
+
warnings.append(f"{col} looks like an ID column")
|
| 245 |
+
|
| 246 |
+
missing_ratio = df[col].isnull().sum() / len(df)
|
| 247 |
+
if missing_ratio > 0.5:
|
| 248 |
+
warnings.append(f"{col} has {missing_ratio:.2%} missing values")
|
| 249 |
+
|
| 250 |
+
if df[col].nunique() == 1:
|
| 251 |
+
warnings.append(f"{col} is constant (no variance)")
|
| 252 |
+
|
| 253 |
+
# ---------- FEATURE IMPORTANCE (NEW CLEAN VERSION) ----------
|
| 254 |
+
print("📈 Looking for potential feature importance...")
|
| 255 |
+
fi, dropped_cols, error = self._compute_feature_importance(df)
|
| 256 |
+
|
| 257 |
+
# ---------- CORRELATION ANALYSIS ----------
|
| 258 |
+
print("📊 Computing correlation...")
|
| 259 |
+
corr_pairs, corr_error = self._compute_correlation(df)
|
| 260 |
+
|
| 261 |
+
output= []
|
| 262 |
+
|
| 263 |
+
output.append(f"\nDataset Analysis: {dataset}")
|
| 264 |
+
output.append("=" * 40)
|
| 265 |
+
|
| 266 |
+
output.append(f"Rows: {rows}")
|
| 267 |
+
output.append(f"Columns: {cols}")
|
| 268 |
+
|
| 269 |
+
output.append("\nData Quality")
|
| 270 |
+
output.append("-" * 20)
|
| 271 |
+
output.append(f"Total Missing Values : {total_missing}")
|
| 272 |
+
output.append(f"Duplicate Rows : {duplicates}")
|
| 273 |
+
# ---------- CORRELATION OUTPUT ----------
|
| 274 |
+
output.append("\nTop Correlations")
|
| 275 |
+
output.append("-" * 20)
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
if corr_error:
|
| 279 |
+
output.append(corr_error)
|
| 280 |
+
elif corr_pairs is not None:
|
| 281 |
+
for (col1, col2), val in corr_pairs.items():
|
| 282 |
+
output.append(f"{col1} ↔ {col2}: {val:.3f}")
|
| 283 |
+
else:
|
| 284 |
+
output.append("No correlation data available.")
|
| 285 |
+
if not missing_by_column.empty:
|
| 286 |
+
output.append("\nMissing by Column")
|
| 287 |
+
output.append("-" * 20)
|
| 288 |
+
for col, val in missing_by_column.items():
|
| 289 |
+
output.append(f"{col}: {val}")
|
| 290 |
+
|
| 291 |
+
output.append("\nColumn Types")
|
| 292 |
+
output.append("-" * 20)
|
| 293 |
+
output.append(f"Numeric : {', '.join(numeric_cols) if numeric_cols else 'None'}")
|
| 294 |
+
output.append(f"Categorical : {', '.join(categorical_cols) if categorical_cols else 'None'}")
|
| 295 |
+
|
| 296 |
+
if warnings:
|
| 297 |
+
output.append("\n⚠️ Data Warnings")
|
| 298 |
+
output.append("-" * 20)
|
| 299 |
+
for w in warnings[:5]:
|
| 300 |
+
output.append(f"- {w}")
|
| 301 |
+
|
| 302 |
+
# ---------- FEATURE IMPORTANCE OUTPUT ----------
|
| 303 |
+
output.append("\nPotential Feature Importance")
|
| 304 |
+
output.append("-" * 20)
|
| 305 |
+
|
| 306 |
+
if error:
|
| 307 |
+
output.append(error)
|
| 308 |
+
else:
|
| 309 |
+
for col, score in fi:
|
| 310 |
+
explanation = self._explain_feature(col)
|
| 311 |
+
output.append(f"{col}: {score:.4f} → {explanation}")
|
| 312 |
+
|
| 313 |
+
# ---------- DROPPED COLUMNS ----------
|
| 314 |
+
if dropped_cols:
|
| 315 |
+
output.append("\n⚠️ Ignored high-cardinality columns:")
|
| 316 |
+
for col in dropped_cols:
|
| 317 |
+
output.append(f"- {col}")
|
| 318 |
+
|
| 319 |
+
# ---------- EXPORT (ONLY ONCE) ----------
|
| 320 |
+
report_path = self._export_report(dataset, "\n".join(output))
|
| 321 |
+
|
| 322 |
+
if report_path:
|
| 323 |
+
output.append(f"\n📁 Report saved to: {report_path}")
|
| 324 |
+
|
| 325 |
+
return "\n".join(output)
|
| 326 |
+
|
| 327 |
+
except Exception as e:
|
| 328 |
+
logger.error(f"Analysis failed | {e}")
|
| 329 |
+
return "Analysis agent error."
|
agents/dataframe_agent.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from utils.logger import logger
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class DataFrameAgent:
|
| 5 |
+
|
| 6 |
+
def __init__(self, registry):
|
| 7 |
+
self.registry = registry
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def _detect_dataset(self, query, datasets):
|
| 11 |
+
"""
|
| 12 |
+
Detect dataset name from query.
|
| 13 |
+
Falls back to first dataset if none mentioned.
|
| 14 |
+
"""
|
| 15 |
+
q = query.lower()
|
| 16 |
+
|
| 17 |
+
for d in datasets:
|
| 18 |
+
if d.lower() in q:
|
| 19 |
+
return d
|
| 20 |
+
|
| 21 |
+
logger.info("Dataset not specified, using default dataset.")
|
| 22 |
+
return datasets[0]
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _detect_column(self, query, columns):
|
| 26 |
+
"""
|
| 27 |
+
Detect column name from query.
|
| 28 |
+
"""
|
| 29 |
+
q = query.lower()
|
| 30 |
+
|
| 31 |
+
for col in columns:
|
| 32 |
+
if col.lower() in q:
|
| 33 |
+
return col
|
| 34 |
+
|
| 35 |
+
return None
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _detect_number(self, query, default=5):
|
| 39 |
+
"""
|
| 40 |
+
Extract number from query (used for top N rows).
|
| 41 |
+
"""
|
| 42 |
+
words = query.split()
|
| 43 |
+
|
| 44 |
+
for w in words:
|
| 45 |
+
if w.isdigit():
|
| 46 |
+
return int(w)
|
| 47 |
+
|
| 48 |
+
return default
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def handle(self, query):
|
| 52 |
+
|
| 53 |
+
q = query.lower()
|
| 54 |
+
|
| 55 |
+
try:
|
| 56 |
+
|
| 57 |
+
datasets = self.registry.list_datasets()
|
| 58 |
+
|
| 59 |
+
if not datasets:
|
| 60 |
+
logger.warning("DataFrameAgent called with no datasets loaded.")
|
| 61 |
+
return "No datasets available."
|
| 62 |
+
|
| 63 |
+
dataset = self._detect_dataset(q, datasets)
|
| 64 |
+
|
| 65 |
+
df = self.registry.load_dataframe(dataset)
|
| 66 |
+
|
| 67 |
+
columns = df.columns.tolist()
|
| 68 |
+
|
| 69 |
+
except Exception as e:
|
| 70 |
+
logger.error(f"Failed loading dataset in DataFrameAgent | {e}")
|
| 71 |
+
return "Failed to load dataset."
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
try:
|
| 75 |
+
|
| 76 |
+
# -------- SHOW ROWS --------
|
| 77 |
+
if "top" in q or "first" in q:
|
| 78 |
+
|
| 79 |
+
n = self._detect_number(q, default=5)
|
| 80 |
+
|
| 81 |
+
logger.info(f"Showing first {n} rows from {dataset}")
|
| 82 |
+
|
| 83 |
+
return df.head(n)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
# -------- ROW COUNT --------
|
| 87 |
+
if "how many rows" in q or "row count" in q or "count rows" in q:
|
| 88 |
+
|
| 89 |
+
logger.info(f"Row count requested for {dataset}")
|
| 90 |
+
|
| 91 |
+
return f"{dataset} has {len(df)} rows."
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
# -------- COLUMN DETECTION --------
|
| 95 |
+
column = self._detect_column(q, columns)
|
| 96 |
+
|
| 97 |
+
if column is None and any(
|
| 98 |
+
word in q for word in ["average", "mean", "max", "min", "highest", "lowest"]
|
| 99 |
+
):
|
| 100 |
+
logger.warning("Column not detected for dataframe operation.")
|
| 101 |
+
return "Column not found in dataset."
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
# -------- MEAN / AVERAGE --------
|
| 105 |
+
if "average" in q or "mean" in q:
|
| 106 |
+
|
| 107 |
+
result = df[column].mean()
|
| 108 |
+
|
| 109 |
+
logger.info(f"Mean computed for {column} in {dataset}")
|
| 110 |
+
|
| 111 |
+
return f"Average {column} in {dataset}: {round(result, 2)}"
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
# -------- MAX --------
|
| 115 |
+
if "max" in q or "highest" in q:
|
| 116 |
+
|
| 117 |
+
result = df[column].max()
|
| 118 |
+
|
| 119 |
+
logger.info(f"Max computed for {column} in {dataset}")
|
| 120 |
+
|
| 121 |
+
return f"Max {column} in {dataset}: {result}"
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
# -------- MIN --------
|
| 125 |
+
if "min" in q or "lowest" in q:
|
| 126 |
+
|
| 127 |
+
result = df[column].min()
|
| 128 |
+
|
| 129 |
+
logger.info(f"Min computed for {column} in {dataset}")
|
| 130 |
+
|
| 131 |
+
return f"Min {column} in {dataset}: {result}"
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
return "DataFrame query not understood."
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
except Exception as e:
|
| 138 |
+
|
| 139 |
+
logger.error(f"DataFrame operation failed | Query: {query} | Error: {e}")
|
| 140 |
+
|
| 141 |
+
return "DataFrame agent error."
|
agents/metadata_agent.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from utils.logger import logger
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class MetadataAgent:
|
| 5 |
+
|
| 6 |
+
def __init__(self, registry):
|
| 7 |
+
self.registry = registry
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def _detect_dataset(self, query, datasets):
|
| 11 |
+
|
| 12 |
+
q = query.lower()
|
| 13 |
+
|
| 14 |
+
for d in datasets:
|
| 15 |
+
if d.lower() in q:
|
| 16 |
+
return d
|
| 17 |
+
|
| 18 |
+
# fallback to first dataset
|
| 19 |
+
return datasets[0]
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def handle(self, query):
|
| 23 |
+
|
| 24 |
+
q = query.lower()
|
| 25 |
+
|
| 26 |
+
try:
|
| 27 |
+
|
| 28 |
+
datasets = self.registry.list_datasets()
|
| 29 |
+
|
| 30 |
+
if not datasets:
|
| 31 |
+
return "No datasets available."
|
| 32 |
+
|
| 33 |
+
dataset = self._detect_dataset(q, datasets)
|
| 34 |
+
|
| 35 |
+
meta = self.registry.get_info(dataset)
|
| 36 |
+
|
| 37 |
+
cols = meta.get("columns", [])
|
| 38 |
+
nums = meta.get("numeric_columns", [])
|
| 39 |
+
cats = meta.get("categorical_columns", [])
|
| 40 |
+
miss = meta.get("missing_values", {})
|
| 41 |
+
|
| 42 |
+
# ---- INTENT DETECTION ----
|
| 43 |
+
|
| 44 |
+
if "how many column" in q or "number of column" in q:
|
| 45 |
+
return f"{dataset} has {len(cols)} columns."
|
| 46 |
+
|
| 47 |
+
if "numeric" in q:
|
| 48 |
+
if not nums:
|
| 49 |
+
return f"No numeric columns found in {dataset}."
|
| 50 |
+
return f"Numeric columns in {dataset}: {', '.join(nums)}"
|
| 51 |
+
|
| 52 |
+
if "categorical" in q:
|
| 53 |
+
if not cats:
|
| 54 |
+
return f"No categorical columns found in {dataset}."
|
| 55 |
+
return f"Categorical columns in {dataset}: {', '.join(cats)}"
|
| 56 |
+
|
| 57 |
+
if "missing" in q:
|
| 58 |
+
if not miss:
|
| 59 |
+
return f"No missing value info for {dataset}."
|
| 60 |
+
return f"Missing values in {dataset}: {miss}"
|
| 61 |
+
|
| 62 |
+
if "column" in q:
|
| 63 |
+
if not cols:
|
| 64 |
+
return f"No columns found for {dataset}."
|
| 65 |
+
return f"Columns in {dataset}: {', '.join(cols)}"
|
| 66 |
+
logger.info(f"MetadataAgent | dataset={dataset} | query={query}")
|
| 67 |
+
return "Metadata query not understood."
|
| 68 |
+
|
| 69 |
+
except Exception as e:
|
| 70 |
+
|
| 71 |
+
logger.error(f"Metadata agent failed | {e}")
|
| 72 |
+
|
| 73 |
+
return "Metadata agent error."
|
agents/transformer_agent.py
ADDED
|
@@ -0,0 +1,518 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pandas as pd
|
| 2 |
+
from data.schema_extractor import extract_schema
|
| 3 |
+
from utils.logger import logger
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
NUMERIC_TYPES = ("int64", "float64", "int32", "float32")
|
| 7 |
+
CATEGORICAL_TYPES = ("object", "category", "str", "string")
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class TransformerAgent:
|
| 11 |
+
"""
|
| 12 |
+
All operations target a '<dataset>_clean' copy of the original.
|
| 13 |
+
The source dataset is never modified.
|
| 14 |
+
|
| 15 |
+
Supports plan-based dispatch (from LLM) and keyword-based fallback.
|
| 16 |
+
|
| 17 |
+
Cleaning:
|
| 18 |
+
drop_duplicates, drop_column, drop_constant_columns,
|
| 19 |
+
strip_whitespace, drop_missing_rows, drop_missing_cols
|
| 20 |
+
|
| 21 |
+
Filling:
|
| 22 |
+
fill_nulls — smart auto (mean/median for numeric, mode for categorical)
|
| 23 |
+
fill_mean — explicit mean
|
| 24 |
+
fill_median — explicit median
|
| 25 |
+
fill_mode — explicit mode
|
| 26 |
+
fill_zero — fill with 0 / empty string
|
| 27 |
+
|
| 28 |
+
Scaling / Encoding:
|
| 29 |
+
normalize — min-max [0, 1]
|
| 30 |
+
standardize — z-score (subtract mean, divide by std)
|
| 31 |
+
encode — label encoding (int codes)
|
| 32 |
+
onehot — one-hot encoding (pd.get_dummies, ≤20 unique values)
|
| 33 |
+
|
| 34 |
+
Other:
|
| 35 |
+
rename — rename <old> to <new> in <dataset>
|
| 36 |
+
"""
|
| 37 |
+
|
| 38 |
+
def __init__(self, registry):
|
| 39 |
+
self.registry = registry
|
| 40 |
+
|
| 41 |
+
# ── helpers ────────────────────────────────────────────────────────────
|
| 42 |
+
|
| 43 |
+
def _detect_source(self, query, datasets):
|
| 44 |
+
q = query.lower()
|
| 45 |
+
base = [d for d in datasets if not d.endswith("_clean")]
|
| 46 |
+
for d in base:
|
| 47 |
+
if d.lower() in q:
|
| 48 |
+
return d
|
| 49 |
+
return base[0] if base else datasets[0]
|
| 50 |
+
|
| 51 |
+
def _detect_column(self, query, columns):
|
| 52 |
+
q = query.lower()
|
| 53 |
+
for col in columns:
|
| 54 |
+
if col.lower() in q:
|
| 55 |
+
return col
|
| 56 |
+
return None
|
| 57 |
+
|
| 58 |
+
def _resolve_column(self, plan_col, query, columns):
|
| 59 |
+
"""
|
| 60 |
+
Return the real column name, checking the plan first then falling
|
| 61 |
+
back to keyword scan. Returns None if nothing found.
|
| 62 |
+
"""
|
| 63 |
+
if plan_col:
|
| 64 |
+
for col in columns:
|
| 65 |
+
if col.lower() == plan_col.lower():
|
| 66 |
+
return col
|
| 67 |
+
return self._detect_column(query, columns)
|
| 68 |
+
|
| 69 |
+
def _get_working_dataset(self, source_name):
|
| 70 |
+
clean_name = f"{source_name}_clean"
|
| 71 |
+
datasets = self.registry.list_datasets()
|
| 72 |
+
if clean_name not in datasets:
|
| 73 |
+
source_df = self.registry.load_dataframe(source_name)
|
| 74 |
+
schema = extract_schema(source_df)
|
| 75 |
+
self.registry.register_dataset(clean_name, source_df.copy(), schema)
|
| 76 |
+
logger.info(f"Created clean copy: {clean_name}")
|
| 77 |
+
df = self.registry.load_dataframe(clean_name)
|
| 78 |
+
return clean_name, df
|
| 79 |
+
|
| 80 |
+
def _save(self, name, df):
|
| 81 |
+
self.registry.update_dataset(name, df, extract_schema(df))
|
| 82 |
+
|
| 83 |
+
def _smart_fill_column(self, series):
|
| 84 |
+
"""Auto pick mean/median for numeric, mode for categorical."""
|
| 85 |
+
if series.dtype.name in NUMERIC_TYPES:
|
| 86 |
+
skewness = abs(series.skew())
|
| 87 |
+
if skewness < 1:
|
| 88 |
+
val = series.mean()
|
| 89 |
+
return series.fillna(val), f"mean ({round(val, 4)})"
|
| 90 |
+
val = series.median()
|
| 91 |
+
return series.fillna(val), f"median ({round(val, 4)})"
|
| 92 |
+
mode_val = series.mode()
|
| 93 |
+
if mode_val.empty:
|
| 94 |
+
return series, "no mode found"
|
| 95 |
+
val = mode_val[0]
|
| 96 |
+
return series.fillna(val), f"mode ('{val}')"
|
| 97 |
+
|
| 98 |
+
# ── individual operation methods ───────────────────────────────────────
|
| 99 |
+
|
| 100 |
+
def _op_drop_duplicates(self, clean_name, df):
|
| 101 |
+
before = len(df)
|
| 102 |
+
str_cols = [c for c in df.columns if df[c].dtype.name in ("str", "string", "object")]
|
| 103 |
+
df[str_cols] = df[str_cols].astype(object)
|
| 104 |
+
df = df.drop_duplicates()
|
| 105 |
+
removed = before - len(df)
|
| 106 |
+
self._save(clean_name, df)
|
| 107 |
+
logger.info(f"Dropped {removed} duplicates from {clean_name}")
|
| 108 |
+
return (
|
| 109 |
+
f"Dropped {removed} duplicate row(s) from '{clean_name}'. "
|
| 110 |
+
f"Rows remaining: {len(df)}."
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
def _op_drop_constant_columns(self, clean_name, df):
|
| 114 |
+
const_cols = [c for c in df.columns if df[c].nunique() <= 1]
|
| 115 |
+
if not const_cols:
|
| 116 |
+
return f"No constant columns found in '{clean_name}'."
|
| 117 |
+
df = df.drop(columns=const_cols)
|
| 118 |
+
self._save(clean_name, df)
|
| 119 |
+
logger.info(f"Dropped constant columns {const_cols} from {clean_name}")
|
| 120 |
+
return (
|
| 121 |
+
f"Dropped {len(const_cols)} constant column(s) from '{clean_name}': "
|
| 122 |
+
f"{', '.join(const_cols)}."
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
def _op_strip_whitespace(self, clean_name, df):
|
| 126 |
+
str_cols = [c for c in df.columns if df[c].dtype.name in ("object", "str", "string")]
|
| 127 |
+
if not str_cols:
|
| 128 |
+
return f"No string columns to strip in '{clean_name}'."
|
| 129 |
+
for col in str_cols:
|
| 130 |
+
df[col] = df[col].str.strip()
|
| 131 |
+
self._save(clean_name, df)
|
| 132 |
+
logger.info(f"Stripped whitespace in {len(str_cols)} columns in {clean_name}")
|
| 133 |
+
return (
|
| 134 |
+
f"Stripped leading/trailing whitespace from {len(str_cols)} "
|
| 135 |
+
f"string column(s) in '{clean_name}'."
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
def _op_drop_missing_rows(self, clean_name, df):
|
| 139 |
+
before = len(df)
|
| 140 |
+
df = df.dropna()
|
| 141 |
+
removed = before - len(df)
|
| 142 |
+
self._save(clean_name, df)
|
| 143 |
+
logger.info(f"Dropped {removed} rows with nulls from {clean_name}")
|
| 144 |
+
return (
|
| 145 |
+
f"Dropped {removed} row(s) containing missing values from '{clean_name}'. "
|
| 146 |
+
f"Rows remaining: {len(df)}."
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
def _op_drop_missing_cols(self, clean_name, df, threshold=0.5):
|
| 150 |
+
missing_pct = df.isnull().mean()
|
| 151 |
+
drop_cols = missing_pct[missing_pct > threshold].index.tolist()
|
| 152 |
+
if not drop_cols:
|
| 153 |
+
return f"No columns exceed {int(threshold * 100)}% missing threshold in '{clean_name}'."
|
| 154 |
+
df = df.drop(columns=drop_cols)
|
| 155 |
+
self._save(clean_name, df)
|
| 156 |
+
logger.info(f"Dropped high-null columns {drop_cols} from {clean_name}")
|
| 157 |
+
return (
|
| 158 |
+
f"Dropped {len(drop_cols)} column(s) with >{int(threshold * 100)}% missing "
|
| 159 |
+
f"from '{clean_name}': {', '.join(drop_cols)}."
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
def _op_drop_column(self, clean_name, df, column):
|
| 163 |
+
if column is None:
|
| 164 |
+
return "Column not found in dataset."
|
| 165 |
+
df = df.drop(columns=[column])
|
| 166 |
+
self._save(clean_name, df)
|
| 167 |
+
logger.info(f"Dropped column '{column}' from {clean_name}")
|
| 168 |
+
return f"Column '{column}' dropped from '{clean_name}'."
|
| 169 |
+
|
| 170 |
+
def _op_fill_smart(self, clean_name, df, column):
|
| 171 |
+
"""Smart fill — auto selects mean/median/mode."""
|
| 172 |
+
if column is not None:
|
| 173 |
+
nulls = df[column].isnull().sum()
|
| 174 |
+
if nulls == 0:
|
| 175 |
+
return f"Column '{column}' in '{clean_name}' has no missing values."
|
| 176 |
+
df[column], label = self._smart_fill_column(df[column])
|
| 177 |
+
self._save(clean_name, df)
|
| 178 |
+
logger.info(f"Smart-filled {nulls} nulls in '{column}' in {clean_name}")
|
| 179 |
+
return f"Filled {nulls} missing value(s) in '{column}' using {label}."
|
| 180 |
+
|
| 181 |
+
report = []
|
| 182 |
+
for col in df.columns:
|
| 183 |
+
nulls = df[col].isnull().sum()
|
| 184 |
+
if nulls > 0:
|
| 185 |
+
df[col], label = self._smart_fill_column(df[col])
|
| 186 |
+
report.append(f" '{col}': {nulls} filled using {label}")
|
| 187 |
+
if not report:
|
| 188 |
+
return f"No missing values found in '{clean_name}'."
|
| 189 |
+
self._save(clean_name, df)
|
| 190 |
+
logger.info(f"Smart-filled all nulls in {clean_name}")
|
| 191 |
+
return "Filled missing values:\n" + "\n".join(report)
|
| 192 |
+
|
| 193 |
+
def _op_fill_with(self, clean_name, df, column, strategy):
|
| 194 |
+
"""
|
| 195 |
+
Fill with an explicit strategy: mean | median | mode | zero.
|
| 196 |
+
If column is None, applies to all eligible columns.
|
| 197 |
+
"""
|
| 198 |
+
if strategy == "zero":
|
| 199 |
+
targets = [column] if column else df.columns.tolist()
|
| 200 |
+
report = []
|
| 201 |
+
for col in targets:
|
| 202 |
+
nulls = df[col].isnull().sum()
|
| 203 |
+
if nulls == 0:
|
| 204 |
+
continue
|
| 205 |
+
fill_val = 0 if df[col].dtype.name in NUMERIC_TYPES else ""
|
| 206 |
+
df[col] = df[col].fillna(fill_val)
|
| 207 |
+
report.append(f" '{col}': {nulls} filled with {fill_val!r}")
|
| 208 |
+
if not report:
|
| 209 |
+
return f"No missing values found in '{clean_name}'."
|
| 210 |
+
self._save(clean_name, df)
|
| 211 |
+
return f"Filled with zero:\n" + "\n".join(report)
|
| 212 |
+
|
| 213 |
+
if strategy in ("mean", "median"):
|
| 214 |
+
targets = (
|
| 215 |
+
[column] if column
|
| 216 |
+
else [c for c in df.columns if df[c].dtype.name in NUMERIC_TYPES]
|
| 217 |
+
)
|
| 218 |
+
if not targets:
|
| 219 |
+
return "No numeric columns available for this operation."
|
| 220 |
+
report = []
|
| 221 |
+
for col in targets:
|
| 222 |
+
nulls = df[col].isnull().sum()
|
| 223 |
+
if nulls == 0:
|
| 224 |
+
continue
|
| 225 |
+
if df[col].dtype.name not in NUMERIC_TYPES:
|
| 226 |
+
report.append(f" '{col}': skipped (not numeric)")
|
| 227 |
+
continue
|
| 228 |
+
val = df[col].mean() if strategy == "mean" else df[col].median()
|
| 229 |
+
df[col] = df[col].fillna(val)
|
| 230 |
+
report.append(f" '{col}': {nulls} filled with {strategy} ({round(val, 4)})")
|
| 231 |
+
if not report:
|
| 232 |
+
return f"No missing values in numeric columns of '{clean_name}'."
|
| 233 |
+
self._save(clean_name, df)
|
| 234 |
+
return f"Filled with {strategy}:\n" + "\n".join(report)
|
| 235 |
+
|
| 236 |
+
if strategy == "mode":
|
| 237 |
+
targets = [column] if column else df.columns.tolist()
|
| 238 |
+
report = []
|
| 239 |
+
for col in targets:
|
| 240 |
+
nulls = df[col].isnull().sum()
|
| 241 |
+
if nulls == 0:
|
| 242 |
+
continue
|
| 243 |
+
mode_val = df[col].mode()
|
| 244 |
+
if mode_val.empty:
|
| 245 |
+
report.append(f" '{col}': skipped (no mode)")
|
| 246 |
+
continue
|
| 247 |
+
df[col] = df[col].fillna(mode_val[0])
|
| 248 |
+
report.append(f" '{col}': {nulls} filled with mode ('{mode_val[0]}')")
|
| 249 |
+
if not report:
|
| 250 |
+
return f"No missing values found in '{clean_name}'."
|
| 251 |
+
self._save(clean_name, df)
|
| 252 |
+
return f"Filled with mode:\n" + "\n".join(report)
|
| 253 |
+
|
| 254 |
+
return f"Unknown fill strategy: {strategy!r}"
|
| 255 |
+
|
| 256 |
+
def _op_normalize(self, clean_name, df, column, columns):
|
| 257 |
+
"""Min-max normalization to [0, 1]."""
|
| 258 |
+
targets = (
|
| 259 |
+
[column] if column
|
| 260 |
+
else [c for c in columns if df[c].dtype.name in NUMERIC_TYPES]
|
| 261 |
+
)
|
| 262 |
+
if not targets:
|
| 263 |
+
return "No numeric columns to normalize."
|
| 264 |
+
report = []
|
| 265 |
+
for col in targets:
|
| 266 |
+
if df[col].dtype.name not in NUMERIC_TYPES:
|
| 267 |
+
report.append(f" '{col}': skipped (not numeric)")
|
| 268 |
+
continue
|
| 269 |
+
col_min, col_max = df[col].min(), df[col].max()
|
| 270 |
+
if col_max == col_min:
|
| 271 |
+
report.append(f" '{col}': skipped (constant value)")
|
| 272 |
+
continue
|
| 273 |
+
df[col] = (df[col] - col_min) / (col_max - col_min)
|
| 274 |
+
report.append(f" '{col}': normalized to [0, 1]")
|
| 275 |
+
if not report:
|
| 276 |
+
return f"No columns were normalized in '{clean_name}'."
|
| 277 |
+
self._save(clean_name, df)
|
| 278 |
+
logger.info(f"Normalized columns in {clean_name}")
|
| 279 |
+
return f"Min-max normalization applied in '{clean_name}':\n" + "\n".join(report)
|
| 280 |
+
|
| 281 |
+
def _op_standardize(self, clean_name, df, column, columns):
|
| 282 |
+
"""Z-score standardization: (x - mean) / std."""
|
| 283 |
+
targets = (
|
| 284 |
+
[column] if column
|
| 285 |
+
else [c for c in columns if df[c].dtype.name in NUMERIC_TYPES]
|
| 286 |
+
)
|
| 287 |
+
if not targets:
|
| 288 |
+
return "No numeric columns to standardize."
|
| 289 |
+
report = []
|
| 290 |
+
for col in targets:
|
| 291 |
+
if df[col].dtype.name not in NUMERIC_TYPES:
|
| 292 |
+
report.append(f" '{col}': skipped (not numeric)")
|
| 293 |
+
continue
|
| 294 |
+
std = df[col].std()
|
| 295 |
+
if std == 0:
|
| 296 |
+
report.append(f" '{col}': skipped (zero variance)")
|
| 297 |
+
continue
|
| 298 |
+
mean = df[col].mean()
|
| 299 |
+
df[col] = (df[col] - mean) / std
|
| 300 |
+
report.append(
|
| 301 |
+
f" '{col}': standardized (mean={round(mean, 4)}, std={round(std, 4)})"
|
| 302 |
+
)
|
| 303 |
+
if not report:
|
| 304 |
+
return f"No columns were standardized in '{clean_name}'."
|
| 305 |
+
self._save(clean_name, df)
|
| 306 |
+
logger.info(f"Standardized columns in {clean_name}")
|
| 307 |
+
return f"Z-score standardization applied in '{clean_name}':\n" + "\n".join(report)
|
| 308 |
+
|
| 309 |
+
def _op_encode(self, clean_name, df, column):
|
| 310 |
+
"""Label encoding — categorical → integer codes."""
|
| 311 |
+
if column is None:
|
| 312 |
+
return "Specify a column to encode."
|
| 313 |
+
if df[column].dtype.name not in CATEGORICAL_TYPES:
|
| 314 |
+
return f"Column '{column}' is not categorical. Cannot label-encode."
|
| 315 |
+
categories = df[column].astype("category").cat.categories.tolist()
|
| 316 |
+
df[column] = df[column].astype("category").cat.codes
|
| 317 |
+
self._save(clean_name, df)
|
| 318 |
+
logger.info(f"Label-encoded '{column}' in {clean_name}")
|
| 319 |
+
return (
|
| 320 |
+
f"Column '{column}' in '{clean_name}' label-encoded. "
|
| 321 |
+
f"Categories: {categories[:10]}"
|
| 322 |
+
f"{'...' if len(categories) > 10 else ''}"
|
| 323 |
+
)
|
| 324 |
+
|
| 325 |
+
def _op_onehot(self, clean_name, df, column, columns, max_unique=20):
|
| 326 |
+
"""One-hot encoding via pd.get_dummies (≤max_unique unique values)."""
|
| 327 |
+
targets = (
|
| 328 |
+
[column] if column
|
| 329 |
+
else [c for c in columns if df[c].dtype.name in CATEGORICAL_TYPES]
|
| 330 |
+
)
|
| 331 |
+
if not targets:
|
| 332 |
+
return "No categorical columns for one-hot encoding."
|
| 333 |
+
report = []
|
| 334 |
+
for col in targets:
|
| 335 |
+
if df[col].dtype.name not in CATEGORICAL_TYPES:
|
| 336 |
+
report.append(f" '{col}': skipped (not categorical)")
|
| 337 |
+
continue
|
| 338 |
+
unique_n = df[col].nunique()
|
| 339 |
+
if unique_n > max_unique:
|
| 340 |
+
report.append(
|
| 341 |
+
f" '{col}': skipped ({unique_n} unique values exceeds limit of {max_unique})"
|
| 342 |
+
)
|
| 343 |
+
continue
|
| 344 |
+
dummies = pd.get_dummies(df[col], prefix=col, drop_first=False)
|
| 345 |
+
df = df.drop(columns=[col])
|
| 346 |
+
df = pd.concat([df, dummies], axis=1)
|
| 347 |
+
report.append(f" '{col}': expanded into {len(dummies.columns)} columns")
|
| 348 |
+
if not report:
|
| 349 |
+
return f"No columns were one-hot encoded in '{clean_name}'."
|
| 350 |
+
self._save(clean_name, df)
|
| 351 |
+
logger.info(f"One-hot encoded columns in {clean_name}")
|
| 352 |
+
return f"One-hot encoding applied in '{clean_name}':\n" + "\n".join(report)
|
| 353 |
+
|
| 354 |
+
def _op_rename(self, clean_name, df, columns, query):
|
| 355 |
+
q = query.lower()
|
| 356 |
+
try:
|
| 357 |
+
after = q.split("rename", 1)[1]
|
| 358 |
+
parts = after.split(" to ", 1)
|
| 359 |
+
old_raw = parts[0].strip()
|
| 360 |
+
new_raw = parts[1].strip().split()[0]
|
| 361 |
+
old_name = next((c for c in columns if c.lower() == old_raw), None)
|
| 362 |
+
if old_name is None:
|
| 363 |
+
return f"Column '{old_raw}' not found in dataset."
|
| 364 |
+
df = df.rename(columns={old_name: new_raw})
|
| 365 |
+
self._save(clean_name, df)
|
| 366 |
+
logger.info(f"Renamed '{old_name}' → '{new_raw}' in {clean_name}")
|
| 367 |
+
return f"Column '{old_name}' renamed to '{new_raw}' in '{clean_name}'."
|
| 368 |
+
except Exception:
|
| 369 |
+
return "Could not parse rename. Use: rename <old> to <new> in <dataset>"
|
| 370 |
+
|
| 371 |
+
# ── plan-based dispatch ────────────────────────────────────────────────
|
| 372 |
+
|
| 373 |
+
def _dispatch_plan(self, plan, clean_name, df, columns):
|
| 374 |
+
"""
|
| 375 |
+
Directly execute the operation named in the LLM plan.
|
| 376 |
+
Bypasses keyword matching for precise execution.
|
| 377 |
+
"""
|
| 378 |
+
op = plan.get("operation")
|
| 379 |
+
p_col = plan.get("column")
|
| 380 |
+
column = self._resolve_column(p_col, "", columns)
|
| 381 |
+
|
| 382 |
+
dispatch = {
|
| 383 |
+
"drop_duplicates": lambda: self._op_drop_duplicates(clean_name, df),
|
| 384 |
+
"drop_constant_columns":lambda: self._op_drop_constant_columns(clean_name, df),
|
| 385 |
+
"strip_whitespace": lambda: self._op_strip_whitespace(clean_name, df),
|
| 386 |
+
"drop_missing_rows": lambda: self._op_drop_missing_rows(clean_name, df),
|
| 387 |
+
"drop_missing_cols": lambda: self._op_drop_missing_cols(clean_name, df),
|
| 388 |
+
"drop_column": lambda: self._op_drop_column(clean_name, df, column),
|
| 389 |
+
"fill_nulls": lambda: self._op_fill_smart(clean_name, df, column),
|
| 390 |
+
"fill_mean": lambda: self._op_fill_with(clean_name, df, column, "mean"),
|
| 391 |
+
"fill_median": lambda: self._op_fill_with(clean_name, df, column, "median"),
|
| 392 |
+
"fill_mode": lambda: self._op_fill_with(clean_name, df, column, "mode"),
|
| 393 |
+
"fill_zero": lambda: self._op_fill_with(clean_name, df, column, "zero"),
|
| 394 |
+
"normalize": lambda: self._op_normalize(clean_name, df, column, columns),
|
| 395 |
+
"standardize": lambda: self._op_standardize(clean_name, df, column, columns),
|
| 396 |
+
"encode": lambda: self._op_encode(clean_name, df, column),
|
| 397 |
+
"onehot": lambda: self._op_onehot(clean_name, df, column, columns),
|
| 398 |
+
"rename": lambda: self._op_rename(clean_name, df, columns, ""),
|
| 399 |
+
}
|
| 400 |
+
|
| 401 |
+
fn = dispatch.get(op)
|
| 402 |
+
if fn:
|
| 403 |
+
logger.info(f"Plan dispatch | op={op} | col={column} | dataset={clean_name}")
|
| 404 |
+
return fn()
|
| 405 |
+
return (
|
| 406 |
+
f"Operation '{op}' is not implemented in the transformer agent."
|
| 407 |
+
)
|
| 408 |
+
|
| 409 |
+
# ── keyword-based fallback ─────────────────────────────────────────────
|
| 410 |
+
|
| 411 |
+
def _dispatch_keywords(self, q, query, clean_name, df, columns):
|
| 412 |
+
"""Keyword-based routing used when no LLM plan is available."""
|
| 413 |
+
|
| 414 |
+
# ── CLEANING ────────────────────────────────────────────────────
|
| 415 |
+
|
| 416 |
+
if "duplicate" in q:
|
| 417 |
+
return self._op_drop_duplicates(clean_name, df)
|
| 418 |
+
|
| 419 |
+
if "constant" in q:
|
| 420 |
+
return self._op_drop_constant_columns(clean_name, df)
|
| 421 |
+
|
| 422 |
+
if "strip" in q or "whitespace" in q:
|
| 423 |
+
return self._op_strip_whitespace(clean_name, df)
|
| 424 |
+
|
| 425 |
+
if "drop missing row" in q or "drop na" in q or "dropna" in q:
|
| 426 |
+
return self._op_drop_missing_rows(clean_name, df)
|
| 427 |
+
|
| 428 |
+
if "drop missing col" in q:
|
| 429 |
+
return self._op_drop_missing_cols(clean_name, df)
|
| 430 |
+
|
| 431 |
+
# ── FILLING ─────────────────────────────────────────────────────
|
| 432 |
+
|
| 433 |
+
if "fill" in q or "impute" in q:
|
| 434 |
+
column = self._detect_column(q, columns)
|
| 435 |
+
|
| 436 |
+
# Explicit strategy keywords take priority over smart fill
|
| 437 |
+
if "mean" in q:
|
| 438 |
+
return self._op_fill_with(clean_name, df, column, "mean")
|
| 439 |
+
if "median" in q:
|
| 440 |
+
return self._op_fill_with(clean_name, df, column, "median")
|
| 441 |
+
if "mode" in q:
|
| 442 |
+
return self._op_fill_with(clean_name, df, column, "mode")
|
| 443 |
+
if "zero" in q or " 0 " in q:
|
| 444 |
+
return self._op_fill_with(clean_name, df, column, "zero")
|
| 445 |
+
|
| 446 |
+
# Default: smart auto-fill
|
| 447 |
+
return self._op_fill_smart(clean_name, df, column)
|
| 448 |
+
|
| 449 |
+
# ── DROP COLUMN ─────────────────────────────────────────────────
|
| 450 |
+
|
| 451 |
+
if "drop" in q:
|
| 452 |
+
column = self._detect_column(q, columns)
|
| 453 |
+
return self._op_drop_column(clean_name, df, column)
|
| 454 |
+
|
| 455 |
+
# ── TRANSFORMS ──────────────────────────────────────────────────
|
| 456 |
+
|
| 457 |
+
if "standardize" in q or "zscore" in q or "z-score" in q:
|
| 458 |
+
column = self._detect_column(q, columns)
|
| 459 |
+
return self._op_standardize(clean_name, df, column, columns)
|
| 460 |
+
|
| 461 |
+
if "normalize" in q or "scale" in q:
|
| 462 |
+
column = self._detect_column(q, columns)
|
| 463 |
+
return self._op_normalize(clean_name, df, column, columns)
|
| 464 |
+
|
| 465 |
+
if "one hot" in q or "onehot" in q or "one-hot" in q or "dummies" in q:
|
| 466 |
+
column = self._detect_column(q, columns)
|
| 467 |
+
return self._op_onehot(clean_name, df, column, columns)
|
| 468 |
+
|
| 469 |
+
if "encode" in q:
|
| 470 |
+
column = self._detect_column(q, columns)
|
| 471 |
+
return self._op_encode(clean_name, df, column)
|
| 472 |
+
|
| 473 |
+
if "rename" in q and " to " in q:
|
| 474 |
+
return self._op_rename(clean_name, df, columns, query)
|
| 475 |
+
|
| 476 |
+
return (
|
| 477 |
+
"Operation not understood. Supported — "
|
| 478 |
+
"cleaning: drop duplicates, drop column, drop constant columns, "
|
| 479 |
+
"strip whitespace, drop missing rows, drop missing cols; "
|
| 480 |
+
"filling: fill nulls / fill with mean / median / mode / zero; "
|
| 481 |
+
"scaling: normalize, standardize; "
|
| 482 |
+
"encoding: encode (label), onehot; "
|
| 483 |
+
"other: rename."
|
| 484 |
+
)
|
| 485 |
+
|
| 486 |
+
# ── public entry point ─────────────────────────────────────────────────
|
| 487 |
+
|
| 488 |
+
def handle(self, query, plan=None):
|
| 489 |
+
q = query.lower()
|
| 490 |
+
|
| 491 |
+
try:
|
| 492 |
+
all_datasets = self.registry.list_datasets()
|
| 493 |
+
if not all_datasets:
|
| 494 |
+
logger.warning("TransformerAgent called with no datasets loaded.")
|
| 495 |
+
return "No datasets available."
|
| 496 |
+
|
| 497 |
+
# Dataset resolution: prefer plan's dataset, fall back to keyword scan
|
| 498 |
+
if plan and plan.get("dataset"):
|
| 499 |
+
raw_ds = plan["dataset"].replace("_clean", "")
|
| 500 |
+
source = raw_ds if raw_ds in all_datasets else self._detect_source(q, all_datasets)
|
| 501 |
+
else:
|
| 502 |
+
source = self._detect_source(q, all_datasets)
|
| 503 |
+
|
| 504 |
+
clean_name, df = self._get_working_dataset(source)
|
| 505 |
+
columns = df.columns.tolist()
|
| 506 |
+
|
| 507 |
+
except Exception as e:
|
| 508 |
+
logger.error(f"TransformerAgent failed to load dataset | {e}")
|
| 509 |
+
return "Failed to load dataset."
|
| 510 |
+
|
| 511 |
+
try:
|
| 512 |
+
if plan and plan.get("operation"):
|
| 513 |
+
return self._dispatch_plan(plan, clean_name, df, columns)
|
| 514 |
+
return self._dispatch_keywords(q, query, clean_name, df, columns)
|
| 515 |
+
|
| 516 |
+
except Exception as e:
|
| 517 |
+
logger.error(f"TransformerAgent error | Query: {query} | {e}")
|
| 518 |
+
return "Transformer agent error."
|
agents/visualization_agent.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from utils.logger import logger
|
| 2 |
+
import matplotlib.pyplot as plt
|
| 3 |
+
import plotext as plt_terminal
|
| 4 |
+
import os
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class VisualizationAgent:
|
| 8 |
+
|
| 9 |
+
def __init__(self, registry):
|
| 10 |
+
self.registry = registry
|
| 11 |
+
os.makedirs("output", exist_ok=True)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _detect_dataset(self, query, datasets):
|
| 15 |
+
|
| 16 |
+
q = query.lower()
|
| 17 |
+
|
| 18 |
+
for d in datasets:
|
| 19 |
+
if d.lower() in q:
|
| 20 |
+
return d
|
| 21 |
+
|
| 22 |
+
logger.info("Dataset not specified, using default dataset.")
|
| 23 |
+
return datasets[0]
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _detect_column(self, query, columns):
|
| 27 |
+
|
| 28 |
+
q = query.lower()
|
| 29 |
+
|
| 30 |
+
for col in columns:
|
| 31 |
+
if col.lower() in q:
|
| 32 |
+
return col
|
| 33 |
+
|
| 34 |
+
return None
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def handle(self, query):
|
| 38 |
+
|
| 39 |
+
q = query.lower()
|
| 40 |
+
|
| 41 |
+
try:
|
| 42 |
+
|
| 43 |
+
datasets = self.registry.list_datasets()
|
| 44 |
+
|
| 45 |
+
if not datasets:
|
| 46 |
+
logger.warning("VisualizationAgent called with no datasets loaded.")
|
| 47 |
+
return "No datasets available."
|
| 48 |
+
|
| 49 |
+
dataset = self._detect_dataset(q, datasets)
|
| 50 |
+
|
| 51 |
+
df = self.registry.load_dataframe(dataset)
|
| 52 |
+
|
| 53 |
+
columns = df.columns.tolist()
|
| 54 |
+
|
| 55 |
+
except Exception as e:
|
| 56 |
+
|
| 57 |
+
logger.error(f"Failed loading dataset in VisualizationAgent | {e}")
|
| 58 |
+
return "Failed to load dataset."
|
| 59 |
+
|
| 60 |
+
try:
|
| 61 |
+
|
| 62 |
+
column = self._detect_column(q, columns)
|
| 63 |
+
|
| 64 |
+
if column is None:
|
| 65 |
+
logger.warning("Column not detected for visualization.")
|
| 66 |
+
return "Column not found in dataset."
|
| 67 |
+
|
| 68 |
+
# ---------- HISTOGRAM ----------
|
| 69 |
+
if "hist" in q or "histogram" in q:
|
| 70 |
+
|
| 71 |
+
logger.info(f"Generating histogram for {column} in {dataset}")
|
| 72 |
+
|
| 73 |
+
values = df[column].dropna().values
|
| 74 |
+
|
| 75 |
+
# Terminal plot
|
| 76 |
+
plt_terminal.clear_figure()
|
| 77 |
+
plt_terminal.hist(values, bins=20)
|
| 78 |
+
plt_terminal.title(f"Histogram of {column}")
|
| 79 |
+
plt_terminal.xlabel(column)
|
| 80 |
+
plt_terminal.ylabel("Frequency")
|
| 81 |
+
plt_terminal.show()
|
| 82 |
+
|
| 83 |
+
# Save PNG
|
| 84 |
+
filepath = f"output/{dataset}_{column}_hist.png"
|
| 85 |
+
|
| 86 |
+
plt.figure()
|
| 87 |
+
df[column].dropna().hist()
|
| 88 |
+
plt.title(f"Histogram of {column}")
|
| 89 |
+
plt.xlabel(column)
|
| 90 |
+
plt.ylabel("Frequency")
|
| 91 |
+
plt.savefig(filepath)
|
| 92 |
+
plt.close()
|
| 93 |
+
|
| 94 |
+
logger.info(f"Histogram saved → {filepath}")
|
| 95 |
+
|
| 96 |
+
return f"Histogram generated in terminal. PNG saved to {filepath}"
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
# ---------- BAR CHART ----------
|
| 100 |
+
if "bar" in q or "bar chart" in q:
|
| 101 |
+
|
| 102 |
+
unique_values = df[column].nunique()
|
| 103 |
+
|
| 104 |
+
if unique_values > 50:
|
| 105 |
+
logger.warning(
|
| 106 |
+
f"Column '{column}' has {unique_values} unique values. Skipping bar chart."
|
| 107 |
+
)
|
| 108 |
+
return f"Column '{column}' has {unique_values} unique values. Too many to visualize meaningfully."
|
| 109 |
+
|
| 110 |
+
logger.info(f"Generating bar chart for {column} in {dataset}")
|
| 111 |
+
|
| 112 |
+
counts = df[column].value_counts()
|
| 113 |
+
|
| 114 |
+
# Terminal plot
|
| 115 |
+
plt_terminal.clear_figure()
|
| 116 |
+
plt_terminal.bar(
|
| 117 |
+
counts.index.astype(str).tolist(),
|
| 118 |
+
counts.values.tolist()
|
| 119 |
+
)
|
| 120 |
+
plt_terminal.title(f"Bar Chart of {column}")
|
| 121 |
+
plt_terminal.xlabel(column)
|
| 122 |
+
plt_terminal.ylabel("Count")
|
| 123 |
+
plt_terminal.show()
|
| 124 |
+
|
| 125 |
+
# Save PNG
|
| 126 |
+
filepath = f"output/{dataset}_{column}_bar.png"
|
| 127 |
+
|
| 128 |
+
plt.figure()
|
| 129 |
+
counts.plot(kind="bar")
|
| 130 |
+
plt.title(f"Bar Chart of {column}")
|
| 131 |
+
plt.xlabel(column)
|
| 132 |
+
plt.ylabel("Count")
|
| 133 |
+
plt.savefig(filepath)
|
| 134 |
+
plt.close()
|
| 135 |
+
|
| 136 |
+
logger.info(f"Bar chart saved → {filepath}")
|
| 137 |
+
|
| 138 |
+
return f"Bar chart generated in terminal. PNG saved to {filepath}"
|
| 139 |
+
|
| 140 |
+
return "Visualization query not understood."
|
| 141 |
+
|
| 142 |
+
except Exception as e:
|
| 143 |
+
|
| 144 |
+
logger.error(f"Visualization failed | Query: {query} | Error: {e}")
|
| 145 |
+
return "Visualization agent error."
|
attached_assets/Pasted-list-me-all-the-datasets-i-have-Datasets-Name--17735902_1773590289755.txt
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
list me all the datasets i have
|
| 2 |
+
Datasets
|
| 3 |
+
┏━━━━━━━━━━━━━━━┓
|
| 4 |
+
┃ Name ┃
|
| 5 |
+
┡━━━━━━━━━━━━━━━┩
|
| 6 |
+
│ leads │
|
| 7 |
+
│ organizations │
|
| 8 |
+
│ people │
|
| 9 |
+
└───────────────┘
|
| 10 |
+
> tell me the no of datasets i have
|
| 11 |
+
Unknown command. Type 'help' to see available commands.
|
| 12 |
+
> how many datasets do i have
|
| 13 |
+
Unknown command. Type 'help' to see available commands.
|
| 14 |
+
> clean
|
| 15 |
+
Unknown command. Type 'help' to see available commands.
|
| 16 |
+
> how many columsn are present in leads dataset
|
| 17 |
+
Unknown command. Type 'help' to see available commands.
|
| 18 |
+
> columns in leads
|
| 19 |
+
Error: Dataset not found
|
| 20 |
+
> visualize people dataset with historgram
|
| 21 |
+
Column not found in dataset.
|
| 22 |
+
> people info
|
| 23 |
+
Unknown command. Type 'help' to see available commands.
|
| 24 |
+
> info people
|
| 25 |
+
Dataset Info: people
|
| 26 |
+
┏━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
| 27 |
+
┃ Property ┃ Value ┃
|
| 28 |
+
┡━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
|
| 29 |
+
│ Rows │ 10000 │
|
| 30 |
+
│ Columns │ 9 │
|
| 31 |
+
│ Numeric Columns │ Index │
|
| 32 |
+
│ Categorical Columns │ User Id, First Name, Last Name, Sex, Email, Phone, Date of birth, Job │
|
| 33 |
+
│ │ Title │
|
| 34 |
+
│ Column Types │ Index:int64, User Id:str, First Name:str, Last Name:str, Sex:str, │
|
| 35 |
+
│ │ Email:str, Phone:str, Date of birth:str, Job Title:str │
|
| 36 |
+
└─────────────────────┴────────────────────────────────────────────────────────────────────────┘
|
| 37 |
+
> describe people
|
| 38 |
+
Index
|
| 39 |
+
count 10000.00
|
| 40 |
+
mean 5000.50
|
| 41 |
+
std 2886.90
|
| 42 |
+
min 1.00
|
| 43 |
+
25% 2500.75
|
| 44 |
+
50% 5000.50
|
| 45 |
+
75% 7500.25
|
| 46 |
+
max 10000.00
|
| 47 |
+
> fill the skewed columns in people dataset
|
| 48 |
+
No missing values found in 'people_clean'.
|
| 49 |
+
> when was the leads dataset created and how many records exists
|
| 50 |
+
Unknown command. Type 'help' to see available commands.
|
| 51 |
+
> ^C
|
cli_app/command_handler.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from rich.table import Table
|
| 2 |
+
from rich.console import Console
|
| 3 |
+
from data.loader import load_dataset
|
| 4 |
+
from data.schema_extractor import extract_schema
|
| 5 |
+
from data.registry import DatasetRegistry
|
| 6 |
+
from utils.logger import logger
|
| 7 |
+
from core.query_router import QueryRouter
|
| 8 |
+
from agents.metadata_agent import MetadataAgent
|
| 9 |
+
from agents.dataframe_agent import DataFrameAgent
|
| 10 |
+
from agents.visualization_agent import VisualizationAgent
|
| 11 |
+
from agents.transformer_agent import TransformerAgent
|
| 12 |
+
from core.llm_planner import LLMPlanner
|
| 13 |
+
from agents.analysis_agent import AnalysisAgent
|
| 14 |
+
from data.registry import DatasetRegistry
|
| 15 |
+
router = QueryRouter()
|
| 16 |
+
llm_planner = LLMPlanner()
|
| 17 |
+
console = Console()
|
| 18 |
+
registry = DatasetRegistry()
|
| 19 |
+
metadata_agent = MetadataAgent(registry)
|
| 20 |
+
dataframe_agent = DataFrameAgent(registry)
|
| 21 |
+
visualization_agent = VisualizationAgent(registry)
|
| 22 |
+
transformer_agent = TransformerAgent(registry)
|
| 23 |
+
analysis_agent = AnalysisAgent(registry)
|
| 24 |
+
|
| 25 |
+
METADATA_CONTEXT_WORDS = [
|
| 26 |
+
"column", "columns", "numeric", "categorical", "missing", "fields", "field"
|
| 27 |
+
]
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _validate_plan_column(plan):
|
| 31 |
+
"""
|
| 32 |
+
If the LLM plan specifies a column, verify it actually exists in the dataset.
|
| 33 |
+
Returns (ok, error_message). ok=True means safe to proceed.
|
| 34 |
+
"""
|
| 35 |
+
column = plan.get("column")
|
| 36 |
+
dataset = plan.get("dataset")
|
| 37 |
+
|
| 38 |
+
if not column or not dataset:
|
| 39 |
+
return True, None
|
| 40 |
+
|
| 41 |
+
try:
|
| 42 |
+
info = registry.get_info(dataset)
|
| 43 |
+
columns = [c.lower() for c in info.get("columns", [])]
|
| 44 |
+
if column.lower() not in columns:
|
| 45 |
+
msg = (
|
| 46 |
+
f"Column '{column}' does not exist in dataset '{dataset}'. "
|
| 47 |
+
f"Available columns: {', '.join(info.get('columns', []))}"
|
| 48 |
+
)
|
| 49 |
+
logger.warning(f"Column validation failed | {msg}")
|
| 50 |
+
return False, msg
|
| 51 |
+
except Exception as e:
|
| 52 |
+
logger.error(f"Column validation error | {e}")
|
| 53 |
+
return False, f"Could not validate column '{column}' in dataset '{dataset}'."
|
| 54 |
+
|
| 55 |
+
return True, None
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _is_list_with_context(command):
|
| 59 |
+
"""
|
| 60 |
+
Returns True if 'list' is used in a dataset-specific context
|
| 61 |
+
(e.g. 'list all columns in leads') rather than a bare 'list datasets' call.
|
| 62 |
+
"""
|
| 63 |
+
q = command.lower()
|
| 64 |
+
return any(word in q for word in METADATA_CONTEXT_WORDS)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def extract_dataset(command, registry):
|
| 68 |
+
datasets = registry.list_datasets()
|
| 69 |
+
words = command.lower().split()
|
| 70 |
+
|
| 71 |
+
for word in words:
|
| 72 |
+
for d in datasets:
|
| 73 |
+
if word == d.lower():
|
| 74 |
+
return d
|
| 75 |
+
return None
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def handle_command(command):
|
| 79 |
+
|
| 80 |
+
try:
|
| 81 |
+
|
| 82 |
+
parts = command.strip().split()
|
| 83 |
+
|
| 84 |
+
if not parts:
|
| 85 |
+
return ""
|
| 86 |
+
|
| 87 |
+
action = parts[0].lower()
|
| 88 |
+
|
| 89 |
+
# ── LOAD ──────────────────────────────────────────────────────────────
|
| 90 |
+
if action == "load":
|
| 91 |
+
if len(parts) < 2:
|
| 92 |
+
return "Please provide a dataset path."
|
| 93 |
+
|
| 94 |
+
path = parts[1]
|
| 95 |
+
name, df = load_dataset(path)
|
| 96 |
+
schema = extract_schema(df)
|
| 97 |
+
registry.register_dataset(name, df, schema)
|
| 98 |
+
return f"Dataset '{name}' loaded."
|
| 99 |
+
|
| 100 |
+
# ── LIST ──────────────────────────────────────────────────────────────
|
| 101 |
+
# If the user says "list columns in X" or "list numeric in X" etc.,
|
| 102 |
+
# route to metadata_agent instead of showing all datasets.
|
| 103 |
+
if action == "list":
|
| 104 |
+
if _is_list_with_context(command):
|
| 105 |
+
result = metadata_agent.handle(command)
|
| 106 |
+
console.print(result)
|
| 107 |
+
console.print(registry.list_datasets())
|
| 108 |
+
return ""
|
| 109 |
+
|
| 110 |
+
datasets = registry.list_datasets()
|
| 111 |
+
|
| 112 |
+
if not datasets:
|
| 113 |
+
return "No datasets loaded."
|
| 114 |
+
|
| 115 |
+
table = Table(title="Datasets")
|
| 116 |
+
table.add_column("Name")
|
| 117 |
+
|
| 118 |
+
for d in datasets:
|
| 119 |
+
table.add_row(d)
|
| 120 |
+
|
| 121 |
+
console.print(table)
|
| 122 |
+
return ""
|
| 123 |
+
|
| 124 |
+
#── DELETE ──────────────────────────────────────────────────────────────
|
| 125 |
+
if "delete" in command:
|
| 126 |
+
dataset = extract_dataset(command, registry)
|
| 127 |
+
|
| 128 |
+
if not dataset:
|
| 129 |
+
return "Please specify dataset to delete (e.g., 'delete leads')"
|
| 130 |
+
|
| 131 |
+
return registry.delete_dataset(dataset)
|
| 132 |
+
# ── INFO ──────────────────────────────────────────────────────────────
|
| 133 |
+
if action == "info":
|
| 134 |
+
if len(parts) < 2:
|
| 135 |
+
return "Provide dataset name."
|
| 136 |
+
|
| 137 |
+
name = parts[1]
|
| 138 |
+
meta = registry.get_info(name)
|
| 139 |
+
|
| 140 |
+
rows = meta.get("rows", "unknown")
|
| 141 |
+
cols = meta.get("columns", [])
|
| 142 |
+
numeric = meta.get("numeric_columns", [])
|
| 143 |
+
categorical = meta.get("categorical_columns", [])
|
| 144 |
+
column_types = meta.get("column_types", {})
|
| 145 |
+
|
| 146 |
+
table = Table(title=f"Dataset Info: {name}")
|
| 147 |
+
table.add_column("Property")
|
| 148 |
+
table.add_column("Value")
|
| 149 |
+
|
| 150 |
+
table.add_row("Rows", str(rows))
|
| 151 |
+
table.add_row("Columns", str(len(cols)))
|
| 152 |
+
table.add_row("Numeric Columns", ", ".join(numeric) if numeric else "None")
|
| 153 |
+
table.add_row("Categorical Columns", ", ".join(categorical) if categorical else "None")
|
| 154 |
+
table.add_row(
|
| 155 |
+
"Column Types",
|
| 156 |
+
", ".join([f"{k}:{v}" for k, v in column_types.items()])
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
console.print(table)
|
| 160 |
+
return ""
|
| 161 |
+
|
| 162 |
+
# ── DESCRIBE ──────────────────────────────────────────────────────────
|
| 163 |
+
if action == "describe":
|
| 164 |
+
if len(parts) < 2:
|
| 165 |
+
return "Provide dataset name."
|
| 166 |
+
|
| 167 |
+
name = parts[1]
|
| 168 |
+
df = registry.load_dataframe(name)
|
| 169 |
+
console.print(df.describe().round(2))
|
| 170 |
+
return ""
|
| 171 |
+
|
| 172 |
+
# ── EXIT ──────────────────────────────────────────────────────────────
|
| 173 |
+
if action == "exit":
|
| 174 |
+
return "exit"
|
| 175 |
+
|
| 176 |
+
# ── Analyze ──────────────────────────────────────────────────────────────
|
| 177 |
+
if action in {"analyze", "analyse"}:
|
| 178 |
+
return analysis_agent.handle(command)
|
| 179 |
+
|
| 180 |
+
# ── HELP ──────────────────────────────────────────────────────────────
|
| 181 |
+
if action == "help":
|
| 182 |
+
table = Table(title="EDA Explorer Commands")
|
| 183 |
+
|
| 184 |
+
table.add_column("Command")
|
| 185 |
+
table.add_column("Description")
|
| 186 |
+
|
| 187 |
+
# ---------- DATASET ----------
|
| 188 |
+
table.add_row("load <file_path>", "Load dataset (auto converts to parquet)")
|
| 189 |
+
table.add_row("delete <dataset>", "Delete dataset (parquet + metadata)")
|
| 190 |
+
table.add_row("delete all", "Delete ALL datasets")
|
| 191 |
+
table.add_row("list", "List available datasets")
|
| 192 |
+
|
| 193 |
+
# ---------- METADATA ----------
|
| 194 |
+
table.add_row("info <dataset>", "Show dataset metadata")
|
| 195 |
+
table.add_row("columns <dataset>", "Show column names")
|
| 196 |
+
table.add_row("shape <dataset>", "Show dataset size")
|
| 197 |
+
table.add_row("list columns in <dataset>", "List columns (metadata agent)")
|
| 198 |
+
|
| 199 |
+
# ---------- DATA PREVIEW ----------
|
| 200 |
+
table.add_row("head <dataset> [n]", "Preview first rows")
|
| 201 |
+
table.add_row("describe <dataset>", "Statistical summary")
|
| 202 |
+
|
| 203 |
+
# ---------- ANALYSIS ----------
|
| 204 |
+
table.add_row("analyze <dataset>", "Full EDA analysis (quality + warnings)")
|
| 205 |
+
table.add_row("missing <dataset>", "Show missing values")
|
| 206 |
+
table.add_row("duplicates <dataset>", "Show duplicate rows")
|
| 207 |
+
table.add_row("correlation <dataset>", "Correlation matrix")
|
| 208 |
+
|
| 209 |
+
# ---------- NATURAL LANGUAGE ----------
|
| 210 |
+
table.add_row("NL: show top 10 rows in <dataset>", "Row preview")
|
| 211 |
+
table.add_row("NL: how many rows in <dataset>", "Row count")
|
| 212 |
+
table.add_row("NL: average <column> in <dataset>", "Column mean")
|
| 213 |
+
table.add_row("NL: histogram <column> in <dataset>", "Histogram")
|
| 214 |
+
table.add_row("NL: bar chart <column> in <dataset>", "Bar chart")
|
| 215 |
+
|
| 216 |
+
# ---------- SYSTEM ----------
|
| 217 |
+
table.add_row("exit", "Quit program")
|
| 218 |
+
|
| 219 |
+
console.print(table)
|
| 220 |
+
|
| 221 |
+
# ── COLUMNS ───────────────────────────────────────────────────────────
|
| 222 |
+
if action == "columns":
|
| 223 |
+
if len(parts) < 2:
|
| 224 |
+
return "Provide dataset name."
|
| 225 |
+
|
| 226 |
+
name = parts[1]
|
| 227 |
+
meta = registry.get_info(name)
|
| 228 |
+
cols = meta.get("columns", [])
|
| 229 |
+
|
| 230 |
+
table = Table(title=f"Columns: {name}")
|
| 231 |
+
table.add_column("Column Name")
|
| 232 |
+
|
| 233 |
+
for col in cols:
|
| 234 |
+
table.add_row(col)
|
| 235 |
+
|
| 236 |
+
console.print(table)
|
| 237 |
+
return ""
|
| 238 |
+
|
| 239 |
+
# ── SHAPE ─────────────────────────────────────────────────────────────
|
| 240 |
+
if action == "shape":
|
| 241 |
+
if len(parts) < 2:
|
| 242 |
+
return "Provide dataset name."
|
| 243 |
+
|
| 244 |
+
name = parts[1]
|
| 245 |
+
meta = registry.get_info(name)
|
| 246 |
+
rows = meta.get("rows", "unknown")
|
| 247 |
+
cols = len(meta.get("columns", []))
|
| 248 |
+
|
| 249 |
+
console.print(f"\nRows: {rows}")
|
| 250 |
+
console.print(f"Columns: {cols}\n")
|
| 251 |
+
return ""
|
| 252 |
+
|
| 253 |
+
# ── HEAD ──────────────────────────────────────────────────────────────
|
| 254 |
+
if action == "head":
|
| 255 |
+
if len(parts) < 2:
|
| 256 |
+
return "Provide dataset name."
|
| 257 |
+
|
| 258 |
+
name = parts[1]
|
| 259 |
+
n = 5
|
| 260 |
+
|
| 261 |
+
if len(parts) == 3:
|
| 262 |
+
try:
|
| 263 |
+
n = int(parts[2])
|
| 264 |
+
except Exception:
|
| 265 |
+
pass
|
| 266 |
+
|
| 267 |
+
df = registry.load_dataframe(name)
|
| 268 |
+
console.print(df.head(n))
|
| 269 |
+
return ""
|
| 270 |
+
|
| 271 |
+
# ── AGENT ROUTING ─────────────────────────────────────────────────────
|
| 272 |
+
# LLM planner is tried first; falls back to rule-based router if the
|
| 273 |
+
# key is missing or the LLM call fails.
|
| 274 |
+
|
| 275 |
+
plan = llm_planner.plan(command)
|
| 276 |
+
agent_name = plan["agent"] if plan else router.route(command)
|
| 277 |
+
|
| 278 |
+
# Column validation: if the LLM suggested a column, confirm it exists
|
| 279 |
+
if plan and plan.get("column"):
|
| 280 |
+
ok, err = _validate_plan_column(plan)
|
| 281 |
+
if not ok:
|
| 282 |
+
return err
|
| 283 |
+
|
| 284 |
+
agent_map = {
|
| 285 |
+
"metadata_agent": metadata_agent,
|
| 286 |
+
"dataframe_agent": dataframe_agent,
|
| 287 |
+
"visualization_agent": visualization_agent,
|
| 288 |
+
"transformer_agent": transformer_agent,
|
| 289 |
+
"analysis_agent": analysis_agent,
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
if agent_name in agent_map:
|
| 293 |
+
agent = agent_map[agent_name]
|
| 294 |
+
|
| 295 |
+
# ---- SPECIAL HANDLING ----
|
| 296 |
+
|
| 297 |
+
# Transformer agent uses full plan
|
| 298 |
+
if agent_name == "transformer_agent" and plan:
|
| 299 |
+
result = agent.handle(command, plan=plan)
|
| 300 |
+
|
| 301 |
+
# Analysis agent gets dataset directly
|
| 302 |
+
elif agent_name == "analysis_agent":
|
| 303 |
+
dataset = plan.get("dataset") if plan else None
|
| 304 |
+
|
| 305 |
+
# fallback if dataset missing
|
| 306 |
+
if not dataset:
|
| 307 |
+
datasets = registry.list_datasets()
|
| 308 |
+
if not datasets:
|
| 309 |
+
return "No datasets available."
|
| 310 |
+
dataset = datasets[0]
|
| 311 |
+
|
| 312 |
+
result = agent.handle(dataset)
|
| 313 |
+
|
| 314 |
+
# Default agents
|
| 315 |
+
else:
|
| 316 |
+
result = agent.handle(command)
|
| 317 |
+
|
| 318 |
+
console.print(result)
|
| 319 |
+
return ""
|
| 320 |
+
|
| 321 |
+
return "Unknown command. Type 'help' to see available commands."
|
| 322 |
+
except Exception as e:
|
| 323 |
+
logger.error(f"Command failed: {command} | {e}")
|
| 324 |
+
return f"Error: {e}"
|
core/llm_planner.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import os
|
| 3 |
+
import requests
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from utils.logger import logger
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
# OLLAMA_BASE_URL = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434")
|
| 9 |
+
# OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "hf.co/bartowski/gemma-2-2b-it-GGUF:Q5_K_M")
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class LLMPlanner:
|
| 13 |
+
"""
|
| 14 |
+
Schema-aware query planner using Ollama (default: gemma3).
|
| 15 |
+
|
| 16 |
+
Before calling the LLM, the planner reads the dataset's JSON metadata
|
| 17 |
+
(fast — no parquet touch) and injects column names and types into the
|
| 18 |
+
prompt so the model can resolve natural-language column references to
|
| 19 |
+
their exact names and pick the right preprocessing operation.
|
| 20 |
+
|
| 21 |
+
Falls back gracefully if Ollama is unavailable.
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
VALID_AGENTS = {
|
| 25 |
+
"metadata_agent",
|
| 26 |
+
"dataframe_agent",
|
| 27 |
+
"visualization_agent",
|
| 28 |
+
"transformer_agent",
|
| 29 |
+
"analysis_agent",
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
VALID_OPERATIONS = {
|
| 33 |
+
# metadata
|
| 34 |
+
"columns", "numeric_columns", "categorical_columns",
|
| 35 |
+
"missing_values", "column_count",
|
| 36 |
+
# dataframe
|
| 37 |
+
"head", "row_count", "mean", "max", "min",
|
| 38 |
+
# visualization
|
| 39 |
+
"histogram", "bar_chart",
|
| 40 |
+
# transformer — cleaning
|
| 41 |
+
"drop_duplicates", "fill_nulls", "drop_column",
|
| 42 |
+
"drop_constant_columns", "strip_whitespace",
|
| 43 |
+
"drop_missing_rows", "drop_missing_cols",
|
| 44 |
+
# transformer — explicit fill strategies
|
| 45 |
+
"fill_mean", "fill_median", "fill_mode", "fill_zero",
|
| 46 |
+
# transformer — transforms
|
| 47 |
+
"normalize", "standardize", "encode", "onehot", "rename",
|
| 48 |
+
# analysis
|
| 49 |
+
"analyze","analyse"
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
SYSTEM_PROMPT = """\
|
| 53 |
+
You are a planner for a data analysis CLI system.
|
| 54 |
+
|
| 55 |
+
Convert the user query into a JSON execution plan.
|
| 56 |
+
|
| 57 |
+
Return ONLY valid JSON with exactly this structure:
|
| 58 |
+
{
|
| 59 |
+
"agent": "<agent_name>",
|
| 60 |
+
"operation": "<operation>",
|
| 61 |
+
"dataset": "<exact dataset name or null>",
|
| 62 |
+
"column": "<exact column name from schema or null>"
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
Valid agents:
|
| 66 |
+
metadata_agent — schema / structure queries
|
| 67 |
+
dataframe_agent — statistics, row previews
|
| 68 |
+
visualization_agent — charts
|
| 69 |
+
transformer_agent — cleaning, filling, encoding, scaling
|
| 70 |
+
|
| 71 |
+
metadata_agent operations:
|
| 72 |
+
columns, numeric_columns, categorical_columns, missing_values, column_count
|
| 73 |
+
|
| 74 |
+
dataframe_agent operations:
|
| 75 |
+
head, row_count, mean, max, min
|
| 76 |
+
|
| 77 |
+
visualization_agent operations:
|
| 78 |
+
histogram, bar_chart
|
| 79 |
+
|
| 80 |
+
transformer_agent operations:
|
| 81 |
+
Cleaning : drop_duplicates, drop_column, drop_constant_columns,
|
| 82 |
+
strip_whitespace, drop_missing_rows, drop_missing_cols
|
| 83 |
+
Filling : fill_nulls (smart — auto picks mean/median/mode),
|
| 84 |
+
fill_mean, fill_median, fill_mode, fill_zero
|
| 85 |
+
Scaling : normalize (min-max [0,1]), standardize (z-score)
|
| 86 |
+
Encoding : encode (label), onehot (one-hot / get_dummies)
|
| 87 |
+
Other : rename
|
| 88 |
+
|
| 89 |
+
Rules:
|
| 90 |
+
- Output ONLY JSON — no explanation, no markdown, no extra keys.
|
| 91 |
+
- Use the EXACT column name from the schema context provided.
|
| 92 |
+
- If the query covers all columns (e.g. "fill all nulls"), set column to null.
|
| 93 |
+
- For queries about listing/showing structure → metadata_agent.
|
| 94 |
+
- For queries about previewing data or computing statistics → dataframe_agent.
|
| 95 |
+
- For fill operations: choose fill_mean/fill_median/fill_mode/fill_zero when the
|
| 96 |
+
user explicitly names a strategy; use fill_nulls when they don't.
|
| 97 |
+
"""
|
| 98 |
+
|
| 99 |
+
def __init__(self):
|
| 100 |
+
self.enabled = True
|
| 101 |
+
# logger.info(f"LLMPlanner ready | model={OLLAMA_MODEL} | base={OLLAMA_BASE_URL}")
|
| 102 |
+
|
| 103 |
+
# ── schema context ─────────────────────────────────────────────────────
|
| 104 |
+
|
| 105 |
+
def _load_schema_context(self, query):
|
| 106 |
+
"""
|
| 107 |
+
Scan the query for a known dataset name and load its JSON metadata.
|
| 108 |
+
Returns a compact schema string for injection into the LLM prompt.
|
| 109 |
+
Reads only the tiny JSON file — parquet is never touched.
|
| 110 |
+
"""
|
| 111 |
+
meta_dir = Path("data/metadata")
|
| 112 |
+
if not meta_dir.exists():
|
| 113 |
+
return None, ""
|
| 114 |
+
|
| 115 |
+
q = query.lower()
|
| 116 |
+
for meta_file in sorted(meta_dir.glob("*.json")):
|
| 117 |
+
name = meta_file.stem
|
| 118 |
+
if name.endswith("_clean"):
|
| 119 |
+
continue
|
| 120 |
+
if name.lower() not in q:
|
| 121 |
+
continue
|
| 122 |
+
try:
|
| 123 |
+
with open(meta_file) as f:
|
| 124 |
+
schema = json.load(f)
|
| 125 |
+
|
| 126 |
+
cols = schema.get("columns", [])
|
| 127 |
+
numeric = schema.get("numeric_columns", [])
|
| 128 |
+
cats = schema.get("categorical_columns", [])
|
| 129 |
+
col_types = schema.get("column_types", {})
|
| 130 |
+
rows = schema.get("rows", "?")
|
| 131 |
+
|
| 132 |
+
lines = [
|
| 133 |
+
f"Dataset '{name}' ({rows} rows, {len(cols)} columns):",
|
| 134 |
+
f" All columns : {', '.join(cols)}",
|
| 135 |
+
f" Numeric : {', '.join(numeric) if numeric else 'none'}",
|
| 136 |
+
f" Categorical : {', '.join(cats) if cats else 'none'}",
|
| 137 |
+
f" Column types : {', '.join(f'{k}:{v}' for k, v in col_types.items())}",
|
| 138 |
+
]
|
| 139 |
+
return name, "\n".join(lines)
|
| 140 |
+
except Exception as e:
|
| 141 |
+
logger.warning(f"Schema load failed for '{name}' | {e}")
|
| 142 |
+
|
| 143 |
+
return None, ""
|
| 144 |
+
|
| 145 |
+
# ── ollama call ────────────────────────────────────────────────────────
|
| 146 |
+
|
| 147 |
+
# def _call_ollama(self, user_query, schema_context=""):
|
| 148 |
+
# """POST to local Ollama API and return the raw response string."""
|
| 149 |
+
# try:
|
| 150 |
+
# schema_block = (
|
| 151 |
+
# f"\n\nSchema context (use exact column names from here):\n{schema_context}"
|
| 152 |
+
# if schema_context else ""
|
| 153 |
+
# )
|
| 154 |
+
# prompt = f"{self.SYSTEM_PROMPT}{schema_block}\n\nUser Query: {user_query}\n\nJSON:"
|
| 155 |
+
|
| 156 |
+
# response = requests.post(
|
| 157 |
+
# f"{OLLAMA_BASE_URL}/api/generate",
|
| 158 |
+
# json={
|
| 159 |
+
# "model": OLLAMA_MODEL,
|
| 160 |
+
# "prompt": prompt,
|
| 161 |
+
# "format": "json",
|
| 162 |
+
# "stream": False,
|
| 163 |
+
# "options": {
|
| 164 |
+
# "temperature": 0,
|
| 165 |
+
# "top_p": 0.9,
|
| 166 |
+
# "num_predict": 100,
|
| 167 |
+
# "stop": ["\n\n"],
|
| 168 |
+
# },
|
| 169 |
+
# },
|
| 170 |
+
# timeout=30,
|
| 171 |
+
# )
|
| 172 |
+
# response.raise_for_status()
|
| 173 |
+
# return response.json().get("response", "").strip()
|
| 174 |
+
|
| 175 |
+
# except Exception as e:
|
| 176 |
+
# logger.error(f"Ollama call failed | {e}")
|
| 177 |
+
# return None
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
# ── ollama call with huggingface fallback ────────────────────────────────
|
| 181 |
+
|
| 182 |
+
def _call_ollama(self, user_query, schema_context=""):
|
| 183 |
+
"""
|
| 184 |
+
POST to local Ollama API. Falls back to Hugging Face Serverless API
|
| 185 |
+
if local service is unavailable, missing, or times out.
|
| 186 |
+
"""
|
| 187 |
+
# Add this environment check at the top of your function
|
| 188 |
+
if os.environ.get("ENVIRONMENT") == "production":
|
| 189 |
+
logger.info("Production mode: Skipping local Ollama check. Routing directly to Hugging Face...")
|
| 190 |
+
# Jump straight to your Hugging Face API request logic here!
|
| 191 |
+
schema_block = (
|
| 192 |
+
f"\n\nSchema context (use exact column names from here):\n{schema_context}"
|
| 193 |
+
if schema_context else ""
|
| 194 |
+
)
|
| 195 |
+
prompt = f"{self.SYSTEM_PROMPT}{schema_block}\n\nUser Query: {user_query}\n\nJSON:"
|
| 196 |
+
|
| 197 |
+
# 1. Try Local Ollama First
|
| 198 |
+
try:
|
| 199 |
+
logger.info("Attempting local Ollama generation...")
|
| 200 |
+
# response = requests.post(
|
| 201 |
+
# f"{OLLAMA_BASE_URL}/api/generate",
|
| 202 |
+
# json={
|
| 203 |
+
# "model": OLLAMA_MODEL,
|
| 204 |
+
# "prompt": prompt,
|
| 205 |
+
# "format": "json",
|
| 206 |
+
# "stream": False,
|
| 207 |
+
# "options": {
|
| 208 |
+
# "temperature": 0,
|
| 209 |
+
# "top_p": 0.9,
|
| 210 |
+
# "num_predict": 100,
|
| 211 |
+
# "stop": ["\n\n"],
|
| 212 |
+
# },
|
| 213 |
+
# },
|
| 214 |
+
# timeout=5, # Reduced timeout so fallback triggers rapidly if offline
|
| 215 |
+
# )
|
| 216 |
+
# response.raise_for_status()
|
| 217 |
+
# return response.json().get("response", "").strip()
|
| 218 |
+
|
| 219 |
+
except Exception as local_err:
|
| 220 |
+
logger.warning(f"Local Ollama unavailable ({local_err}). Routing fallback to Hugging Face...")
|
| 221 |
+
|
| 222 |
+
# 2. Hugging Face Serverless Fallback
|
| 223 |
+
hf_token = os.environ.get("HF_TOKEN")
|
| 224 |
+
if not hf_token:
|
| 225 |
+
logger.error("Hugging Face fallback skipped: HF_TOKEN environment variable not set.")
|
| 226 |
+
return None
|
| 227 |
+
|
| 228 |
+
try:
|
| 229 |
+
# We use HF's serverless OpenAI-compatible Router endpoint
|
| 230 |
+
hf_url = "https://router.huggingface.co/v1/chat/completions"
|
| 231 |
+
|
| 232 |
+
headers = {
|
| 233 |
+
"Authorization": f"Bearer {hf_token}",
|
| 234 |
+
"Content-Type": "application/json"
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
# Format to structure for standard OpenAI/HF chat endpoint specs
|
| 238 |
+
hf_payload = {
|
| 239 |
+
"model": "google/gemma-3-12b-it", # Fallback leverages beefier cloud model
|
| 240 |
+
"messages": [
|
| 241 |
+
{"role": "user", "content": prompt}
|
| 242 |
+
],
|
| 243 |
+
"temperature": 0,
|
| 244 |
+
"max_tokens": 100,
|
| 245 |
+
"response_format": {"type": "json_object"} # Forces strict JSON format out of HF
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
hf_response = requests.post(hf_url, headers=headers, json=hf_payload, timeout=15)
|
| 249 |
+
hf_response.raise_for_status()
|
| 250 |
+
|
| 251 |
+
# Extract text out of OpenAI spec completion block
|
| 252 |
+
result_json = hf_response.json()
|
| 253 |
+
content = result_json['choices'][0]['message']['content'].strip()
|
| 254 |
+
|
| 255 |
+
logger.info("Successfully fetched plan from Hugging Face.")
|
| 256 |
+
return content
|
| 257 |
+
|
| 258 |
+
except Exception as hf_err:
|
| 259 |
+
logger.error(f"Hugging Face fallback also failed | {hf_err}")
|
| 260 |
+
return None
|
| 261 |
+
|
| 262 |
+
# ── public API ─────────────────────────────────────────────────────────
|
| 263 |
+
|
| 264 |
+
def plan(self, query):
|
| 265 |
+
"""
|
| 266 |
+
Return a validated execution plan dict, or None if unavailable.
|
| 267 |
+
The plan always contains: agent, operation, dataset, column.
|
| 268 |
+
"""
|
| 269 |
+
_, schema_ctx = self._load_schema_context(query)
|
| 270 |
+
content = self._call_ollama(query, schema_ctx)
|
| 271 |
+
|
| 272 |
+
if not content:
|
| 273 |
+
return None
|
| 274 |
+
|
| 275 |
+
try:
|
| 276 |
+
if content.startswith("```"):
|
| 277 |
+
content = content.split("```")[1]
|
| 278 |
+
if content.startswith("json"):
|
| 279 |
+
content = content[4:]
|
| 280 |
+
|
| 281 |
+
plan = json.loads(content)
|
| 282 |
+
|
| 283 |
+
agent = plan.get("agent")
|
| 284 |
+
operation = plan.get("operation")
|
| 285 |
+
|
| 286 |
+
if agent not in self.VALID_AGENTS:
|
| 287 |
+
logger.error(f"LLM returned invalid agent: {agent!r}")
|
| 288 |
+
return None
|
| 289 |
+
|
| 290 |
+
if operation not in self.VALID_OPERATIONS:
|
| 291 |
+
logger.error(f"LLM returned invalid operation: {operation!r}")
|
| 292 |
+
return None
|
| 293 |
+
|
| 294 |
+
plan.setdefault("dataset", None)
|
| 295 |
+
plan.setdefault("column", None)
|
| 296 |
+
|
| 297 |
+
logger.info(f"LLMPlanner plan → {plan}")
|
| 298 |
+
return plan
|
| 299 |
+
|
| 300 |
+
except json.JSONDecodeError as e:
|
| 301 |
+
logger.error(f"LLM response not valid JSON | {e} | raw: {content!r}")
|
| 302 |
+
return None
|
| 303 |
+
except Exception as e:
|
| 304 |
+
logger.error(f"LLMPlanner error | {e}")
|
| 305 |
+
return None
|
core/query_router.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from utils.logger import logger
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class QueryRouter:
|
| 5 |
+
"""
|
| 6 |
+
Rule-based fallback router.
|
| 7 |
+
Order matters: transformer action words are checked first so that queries
|
| 8 |
+
like 'drop column X' or 'impute missing' don't get swallowed by the
|
| 9 |
+
metadata keyword list.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
# Transformer keywords take top priority — they are explicit action verbs.
|
| 13 |
+
TRANSFORMER_KEYWORDS = [
|
| 14 |
+
"normalize",
|
| 15 |
+
"standardize",
|
| 16 |
+
"zscore",
|
| 17 |
+
"z-score",
|
| 18 |
+
"scale",
|
| 19 |
+
"encode",
|
| 20 |
+
"onehot",
|
| 21 |
+
"one-hot",
|
| 22 |
+
"one hot",
|
| 23 |
+
"dummies",
|
| 24 |
+
"drop",
|
| 25 |
+
"fill",
|
| 26 |
+
"impute",
|
| 27 |
+
"rename",
|
| 28 |
+
"strip",
|
| 29 |
+
"duplicate",
|
| 30 |
+
"constant",
|
| 31 |
+
"whitespace",
|
| 32 |
+
"dropna",
|
| 33 |
+
]
|
| 34 |
+
|
| 35 |
+
# Metadata keywords — structural / schema queries.
|
| 36 |
+
METADATA_KEYWORDS = [
|
| 37 |
+
"column",
|
| 38 |
+
"numeric",
|
| 39 |
+
"categorical",
|
| 40 |
+
"missing",
|
| 41 |
+
"schema",
|
| 42 |
+
"fields",
|
| 43 |
+
"field",
|
| 44 |
+
]
|
| 45 |
+
|
| 46 |
+
# DataFrame / statistics keywords.
|
| 47 |
+
DATAFRAME_KEYWORDS = [
|
| 48 |
+
"average",
|
| 49 |
+
"mean",
|
| 50 |
+
"median",
|
| 51 |
+
"max",
|
| 52 |
+
"min",
|
| 53 |
+
"top",
|
| 54 |
+
"count",
|
| 55 |
+
"rows",
|
| 56 |
+
"sum",
|
| 57 |
+
"highest",
|
| 58 |
+
"lowest",
|
| 59 |
+
]
|
| 60 |
+
|
| 61 |
+
# Visualisation keywords.
|
| 62 |
+
VISUAL_KEYWORDS = [
|
| 63 |
+
"plot",
|
| 64 |
+
"graph",
|
| 65 |
+
"scatter",
|
| 66 |
+
"hist",
|
| 67 |
+
"bar",
|
| 68 |
+
"chart",
|
| 69 |
+
"histogram",
|
| 70 |
+
"distribution",
|
| 71 |
+
]
|
| 72 |
+
|
| 73 |
+
def route(self, query):
|
| 74 |
+
q = query.lower()
|
| 75 |
+
|
| 76 |
+
if any(word in q for word in self.TRANSFORMER_KEYWORDS):
|
| 77 |
+
logger.info("Routing → transformer_agent")
|
| 78 |
+
return "transformer_agent"
|
| 79 |
+
|
| 80 |
+
if any(word in q for word in self.METADATA_KEYWORDS):
|
| 81 |
+
logger.info("Routing → metadata_agent")
|
| 82 |
+
return "metadata_agent"
|
| 83 |
+
|
| 84 |
+
if any(word in q for word in self.DATAFRAME_KEYWORDS):
|
| 85 |
+
logger.info("Routing → dataframe_agent")
|
| 86 |
+
return "dataframe_agent"
|
| 87 |
+
|
| 88 |
+
if any(word in q for word in self.VISUAL_KEYWORDS):
|
| 89 |
+
logger.info("Routing → visualization_agent")
|
| 90 |
+
return "visualization_agent"
|
| 91 |
+
|
| 92 |
+
if "analyze" in query or "analysis" in query or "analyse" in query:
|
| 93 |
+
return "analysis_agent"
|
| 94 |
+
|
| 95 |
+
logger.warning(f"No route matched for query: {query}")
|
| 96 |
+
return "unknown command"
|
data/dataframe_store.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
class DataFrameStore:
|
| 2 |
+
"""
|
| 3 |
+
A simple in-memory manager for storing and accessing multiple datasets.
|
| 4 |
+
|
| 5 |
+
Each dataset is stored with:
|
| 6 |
+
- original: the untouched DataFrame
|
| 7 |
+
- working: a copy used for transformations
|
| 8 |
+
- schema: metadata describing the dataset structure
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
def __init__(self):
|
| 12 |
+
"""Initialize an empty dataset store."""
|
| 13 |
+
self.datasets = {}
|
| 14 |
+
|
| 15 |
+
def add_dataset(self, name, df, schema):
|
| 16 |
+
"""
|
| 17 |
+
Add a dataset to the store.
|
| 18 |
+
|
| 19 |
+
Parameters
|
| 20 |
+
----------
|
| 21 |
+
name : str
|
| 22 |
+
Unique name used to identify the dataset.
|
| 23 |
+
df : pandas.DataFrame
|
| 24 |
+
The DataFrame to store.
|
| 25 |
+
schema : dict
|
| 26 |
+
Metadata describing column types or structure.
|
| 27 |
+
"""
|
| 28 |
+
if name in self.datasets:
|
| 29 |
+
raise ValueError(f"Dataset '{name}' already loaded")
|
| 30 |
+
|
| 31 |
+
self.datasets[name] = {
|
| 32 |
+
"original": df,
|
| 33 |
+
"working": df.copy(),
|
| 34 |
+
"schema": schema
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
def list_datasets(self):
|
| 38 |
+
"""
|
| 39 |
+
Return a list of all dataset names currently stored.
|
| 40 |
+
"""
|
| 41 |
+
return list(self.datasets.keys())
|
| 42 |
+
|
| 43 |
+
def get_dataset(self, name):
|
| 44 |
+
"""
|
| 45 |
+
Retrieve the dataset dictionary for a given dataset name.
|
| 46 |
+
|
| 47 |
+
Returns
|
| 48 |
+
-------
|
| 49 |
+
dict
|
| 50 |
+
Contains 'original', 'working', and 'schema'.
|
| 51 |
+
"""
|
| 52 |
+
return self.datasets.get(name)
|
| 53 |
+
|
| 54 |
+
def get_schema(self, name):
|
| 55 |
+
"""
|
| 56 |
+
Get the schema metadata for a specific dataset.
|
| 57 |
+
"""
|
| 58 |
+
return self.datasets[name]["schema"]
|
data/loader.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pandas as pd
|
| 2 |
+
import os
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
def load_dataset(path):
|
| 6 |
+
#strips path with quotes.
|
| 7 |
+
path = path.strip().strip('"').strip("'")
|
| 8 |
+
if not os.path.exists(path):
|
| 9 |
+
raise FileNotFoundError("Dataset file not found")
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
name = Path(path).stem
|
| 13 |
+
|
| 14 |
+
if path.endswith(".csv"):
|
| 15 |
+
|
| 16 |
+
encodings = ["utf-8", "latin1", "cp1252"]
|
| 17 |
+
|
| 18 |
+
for enc in encodings:
|
| 19 |
+
try:
|
| 20 |
+
df = pd.read_csv(path, encoding=enc)
|
| 21 |
+
print(f"Loaded CSV using encoding: {enc}")
|
| 22 |
+
return name, df
|
| 23 |
+
except UnicodeDecodeError:
|
| 24 |
+
continue
|
| 25 |
+
|
| 26 |
+
raise ValueError("Could not decode CSV file with common encodings.")
|
| 27 |
+
|
| 28 |
+
elif path.endswith(".xlsx"):
|
| 29 |
+
|
| 30 |
+
df = pd.read_excel(path)
|
| 31 |
+
return name, df
|
| 32 |
+
|
| 33 |
+
else:
|
| 34 |
+
raise ValueError("Only CSV and XLSX supported")
|
data/metadata/Customer_Churn.json
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"rows": 7043,
|
| 3 |
+
"columns": [
|
| 4 |
+
"customerID",
|
| 5 |
+
"gender",
|
| 6 |
+
"SeniorCitizen",
|
| 7 |
+
"Partner",
|
| 8 |
+
"Dependents",
|
| 9 |
+
"tenure",
|
| 10 |
+
"PhoneService",
|
| 11 |
+
"MultipleLines",
|
| 12 |
+
"InternetService",
|
| 13 |
+
"OnlineSecurity",
|
| 14 |
+
"OnlineBackup",
|
| 15 |
+
"DeviceProtection",
|
| 16 |
+
"TechSupport",
|
| 17 |
+
"StreamingTV",
|
| 18 |
+
"StreamingMovies",
|
| 19 |
+
"Contract",
|
| 20 |
+
"PaperlessBilling",
|
| 21 |
+
"PaymentMethod",
|
| 22 |
+
"MonthlyCharges",
|
| 23 |
+
"TotalCharges",
|
| 24 |
+
"Churn"
|
| 25 |
+
],
|
| 26 |
+
"numeric_columns": [
|
| 27 |
+
"SeniorCitizen",
|
| 28 |
+
"tenure",
|
| 29 |
+
"MonthlyCharges"
|
| 30 |
+
],
|
| 31 |
+
"categorical_columns": [
|
| 32 |
+
"customerID",
|
| 33 |
+
"gender",
|
| 34 |
+
"Partner",
|
| 35 |
+
"Dependents",
|
| 36 |
+
"PhoneService",
|
| 37 |
+
"MultipleLines",
|
| 38 |
+
"InternetService",
|
| 39 |
+
"OnlineSecurity",
|
| 40 |
+
"OnlineBackup",
|
| 41 |
+
"DeviceProtection",
|
| 42 |
+
"TechSupport",
|
| 43 |
+
"StreamingTV",
|
| 44 |
+
"StreamingMovies",
|
| 45 |
+
"Contract",
|
| 46 |
+
"PaperlessBilling",
|
| 47 |
+
"PaymentMethod",
|
| 48 |
+
"TotalCharges",
|
| 49 |
+
"Churn"
|
| 50 |
+
],
|
| 51 |
+
"missing_values": {
|
| 52 |
+
"customerID": 0.0,
|
| 53 |
+
"gender": 0.0,
|
| 54 |
+
"SeniorCitizen": 0.0,
|
| 55 |
+
"Partner": 0.0,
|
| 56 |
+
"Dependents": 0.0,
|
| 57 |
+
"tenure": 0.0,
|
| 58 |
+
"PhoneService": 0.0,
|
| 59 |
+
"MultipleLines": 0.0,
|
| 60 |
+
"InternetService": 0.0,
|
| 61 |
+
"OnlineSecurity": 0.0,
|
| 62 |
+
"OnlineBackup": 0.0,
|
| 63 |
+
"DeviceProtection": 0.0,
|
| 64 |
+
"TechSupport": 0.0,
|
| 65 |
+
"StreamingTV": 0.0,
|
| 66 |
+
"StreamingMovies": 0.0,
|
| 67 |
+
"Contract": 0.0,
|
| 68 |
+
"PaperlessBilling": 0.0,
|
| 69 |
+
"PaymentMethod": 0.0,
|
| 70 |
+
"MonthlyCharges": 0.0,
|
| 71 |
+
"TotalCharges": 0.0,
|
| 72 |
+
"Churn": 0.0
|
| 73 |
+
},
|
| 74 |
+
"column_types": {
|
| 75 |
+
"customerID": "str",
|
| 76 |
+
"gender": "str",
|
| 77 |
+
"SeniorCitizen": "int64",
|
| 78 |
+
"Partner": "str",
|
| 79 |
+
"Dependents": "str",
|
| 80 |
+
"tenure": "int64",
|
| 81 |
+
"PhoneService": "str",
|
| 82 |
+
"MultipleLines": "str",
|
| 83 |
+
"InternetService": "str",
|
| 84 |
+
"OnlineSecurity": "str",
|
| 85 |
+
"OnlineBackup": "str",
|
| 86 |
+
"DeviceProtection": "str",
|
| 87 |
+
"TechSupport": "str",
|
| 88 |
+
"StreamingTV": "str",
|
| 89 |
+
"StreamingMovies": "str",
|
| 90 |
+
"Contract": "str",
|
| 91 |
+
"PaperlessBilling": "str",
|
| 92 |
+
"PaymentMethod": "str",
|
| 93 |
+
"MonthlyCharges": "float64",
|
| 94 |
+
"TotalCharges": "str",
|
| 95 |
+
"Churn": "str"
|
| 96 |
+
}
|
| 97 |
+
}
|
data/metadata/Loan_default.json
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"rows": 255347,
|
| 3 |
+
"columns": [
|
| 4 |
+
"LoanID",
|
| 5 |
+
"Age",
|
| 6 |
+
"Income",
|
| 7 |
+
"LoanAmount",
|
| 8 |
+
"CreditScore",
|
| 9 |
+
"MonthsEmployed",
|
| 10 |
+
"NumCreditLines",
|
| 11 |
+
"InterestRate",
|
| 12 |
+
"LoanTerm",
|
| 13 |
+
"DTIRatio",
|
| 14 |
+
"Education",
|
| 15 |
+
"EmploymentType",
|
| 16 |
+
"MaritalStatus",
|
| 17 |
+
"HasMortgage",
|
| 18 |
+
"HasDependents",
|
| 19 |
+
"LoanPurpose",
|
| 20 |
+
"HasCoSigner",
|
| 21 |
+
"Default"
|
| 22 |
+
],
|
| 23 |
+
"numeric_columns": [
|
| 24 |
+
"Age",
|
| 25 |
+
"Income",
|
| 26 |
+
"LoanAmount",
|
| 27 |
+
"CreditScore",
|
| 28 |
+
"MonthsEmployed",
|
| 29 |
+
"NumCreditLines",
|
| 30 |
+
"InterestRate",
|
| 31 |
+
"LoanTerm",
|
| 32 |
+
"DTIRatio",
|
| 33 |
+
"Default"
|
| 34 |
+
],
|
| 35 |
+
"categorical_columns": [
|
| 36 |
+
"LoanID",
|
| 37 |
+
"Education",
|
| 38 |
+
"EmploymentType",
|
| 39 |
+
"MaritalStatus",
|
| 40 |
+
"HasMortgage",
|
| 41 |
+
"HasDependents",
|
| 42 |
+
"LoanPurpose",
|
| 43 |
+
"HasCoSigner"
|
| 44 |
+
],
|
| 45 |
+
"missing_values": {
|
| 46 |
+
"LoanID": 0.0,
|
| 47 |
+
"Age": 0.0,
|
| 48 |
+
"Income": 0.0,
|
| 49 |
+
"LoanAmount": 0.0,
|
| 50 |
+
"CreditScore": 0.0,
|
| 51 |
+
"MonthsEmployed": 0.0,
|
| 52 |
+
"NumCreditLines": 0.0,
|
| 53 |
+
"InterestRate": 0.0,
|
| 54 |
+
"LoanTerm": 0.0,
|
| 55 |
+
"DTIRatio": 0.0,
|
| 56 |
+
"Education": 0.0,
|
| 57 |
+
"EmploymentType": 0.0,
|
| 58 |
+
"MaritalStatus": 0.0,
|
| 59 |
+
"HasMortgage": 0.0,
|
| 60 |
+
"HasDependents": 0.0,
|
| 61 |
+
"LoanPurpose": 0.0,
|
| 62 |
+
"HasCoSigner": 0.0,
|
| 63 |
+
"Default": 0.0
|
| 64 |
+
},
|
| 65 |
+
"column_types": {
|
| 66 |
+
"LoanID": "str",
|
| 67 |
+
"Age": "int64",
|
| 68 |
+
"Income": "int64",
|
| 69 |
+
"LoanAmount": "int64",
|
| 70 |
+
"CreditScore": "int64",
|
| 71 |
+
"MonthsEmployed": "int64",
|
| 72 |
+
"NumCreditLines": "int64",
|
| 73 |
+
"InterestRate": "float64",
|
| 74 |
+
"LoanTerm": "int64",
|
| 75 |
+
"DTIRatio": "float64",
|
| 76 |
+
"Education": "str",
|
| 77 |
+
"EmploymentType": "str",
|
| 78 |
+
"MaritalStatus": "str",
|
| 79 |
+
"HasMortgage": "str",
|
| 80 |
+
"HasDependents": "str",
|
| 81 |
+
"LoanPurpose": "str",
|
| 82 |
+
"HasCoSigner": "str",
|
| 83 |
+
"Default": "int64"
|
| 84 |
+
}
|
| 85 |
+
}
|
data/metadata/creditcard.json
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"rows": 284807,
|
| 3 |
+
"columns": [
|
| 4 |
+
"Time",
|
| 5 |
+
"V1",
|
| 6 |
+
"V2",
|
| 7 |
+
"V3",
|
| 8 |
+
"V4",
|
| 9 |
+
"V5",
|
| 10 |
+
"V6",
|
| 11 |
+
"V7",
|
| 12 |
+
"V8",
|
| 13 |
+
"V9",
|
| 14 |
+
"V10",
|
| 15 |
+
"V11",
|
| 16 |
+
"V12",
|
| 17 |
+
"V13",
|
| 18 |
+
"V14",
|
| 19 |
+
"V15",
|
| 20 |
+
"V16",
|
| 21 |
+
"V17",
|
| 22 |
+
"V18",
|
| 23 |
+
"V19",
|
| 24 |
+
"V20",
|
| 25 |
+
"V21",
|
| 26 |
+
"V22",
|
| 27 |
+
"V23",
|
| 28 |
+
"V24",
|
| 29 |
+
"V25",
|
| 30 |
+
"V26",
|
| 31 |
+
"V27",
|
| 32 |
+
"V28",
|
| 33 |
+
"Amount",
|
| 34 |
+
"Class"
|
| 35 |
+
],
|
| 36 |
+
"numeric_columns": [
|
| 37 |
+
"Time",
|
| 38 |
+
"V1",
|
| 39 |
+
"V2",
|
| 40 |
+
"V3",
|
| 41 |
+
"V4",
|
| 42 |
+
"V5",
|
| 43 |
+
"V6",
|
| 44 |
+
"V7",
|
| 45 |
+
"V8",
|
| 46 |
+
"V9",
|
| 47 |
+
"V10",
|
| 48 |
+
"V11",
|
| 49 |
+
"V12",
|
| 50 |
+
"V13",
|
| 51 |
+
"V14",
|
| 52 |
+
"V15",
|
| 53 |
+
"V16",
|
| 54 |
+
"V17",
|
| 55 |
+
"V18",
|
| 56 |
+
"V19",
|
| 57 |
+
"V20",
|
| 58 |
+
"V21",
|
| 59 |
+
"V22",
|
| 60 |
+
"V23",
|
| 61 |
+
"V24",
|
| 62 |
+
"V25",
|
| 63 |
+
"V26",
|
| 64 |
+
"V27",
|
| 65 |
+
"V28",
|
| 66 |
+
"Amount",
|
| 67 |
+
"Class"
|
| 68 |
+
],
|
| 69 |
+
"categorical_columns": [],
|
| 70 |
+
"missing_values": {
|
| 71 |
+
"Time": 0.0,
|
| 72 |
+
"V1": 0.0,
|
| 73 |
+
"V2": 0.0,
|
| 74 |
+
"V3": 0.0,
|
| 75 |
+
"V4": 0.0,
|
| 76 |
+
"V5": 0.0,
|
| 77 |
+
"V6": 0.0,
|
| 78 |
+
"V7": 0.0,
|
| 79 |
+
"V8": 0.0,
|
| 80 |
+
"V9": 0.0,
|
| 81 |
+
"V10": 0.0,
|
| 82 |
+
"V11": 0.0,
|
| 83 |
+
"V12": 0.0,
|
| 84 |
+
"V13": 0.0,
|
| 85 |
+
"V14": 0.0,
|
| 86 |
+
"V15": 0.0,
|
| 87 |
+
"V16": 0.0,
|
| 88 |
+
"V17": 0.0,
|
| 89 |
+
"V18": 0.0,
|
| 90 |
+
"V19": 0.0,
|
| 91 |
+
"V20": 0.0,
|
| 92 |
+
"V21": 0.0,
|
| 93 |
+
"V22": 0.0,
|
| 94 |
+
"V23": 0.0,
|
| 95 |
+
"V24": 0.0,
|
| 96 |
+
"V25": 0.0,
|
| 97 |
+
"V26": 0.0,
|
| 98 |
+
"V27": 0.0,
|
| 99 |
+
"V28": 0.0,
|
| 100 |
+
"Amount": 0.0,
|
| 101 |
+
"Class": 0.0
|
| 102 |
+
},
|
| 103 |
+
"column_types": {
|
| 104 |
+
"Time": "float64",
|
| 105 |
+
"V1": "float64",
|
| 106 |
+
"V2": "float64",
|
| 107 |
+
"V3": "float64",
|
| 108 |
+
"V4": "float64",
|
| 109 |
+
"V5": "float64",
|
| 110 |
+
"V6": "float64",
|
| 111 |
+
"V7": "float64",
|
| 112 |
+
"V8": "float64",
|
| 113 |
+
"V9": "float64",
|
| 114 |
+
"V10": "float64",
|
| 115 |
+
"V11": "float64",
|
| 116 |
+
"V12": "float64",
|
| 117 |
+
"V13": "float64",
|
| 118 |
+
"V14": "float64",
|
| 119 |
+
"V15": "float64",
|
| 120 |
+
"V16": "float64",
|
| 121 |
+
"V17": "float64",
|
| 122 |
+
"V18": "float64",
|
| 123 |
+
"V19": "float64",
|
| 124 |
+
"V20": "float64",
|
| 125 |
+
"V21": "float64",
|
| 126 |
+
"V22": "float64",
|
| 127 |
+
"V23": "float64",
|
| 128 |
+
"V24": "float64",
|
| 129 |
+
"V25": "float64",
|
| 130 |
+
"V26": "float64",
|
| 131 |
+
"V27": "float64",
|
| 132 |
+
"V28": "float64",
|
| 133 |
+
"Amount": "float64",
|
| 134 |
+
"Class": "int64"
|
| 135 |
+
}
|
| 136 |
+
}
|
data/metadata/titanic.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"rows": 891,
|
| 3 |
+
"columns": [
|
| 4 |
+
"PassengerId",
|
| 5 |
+
"Survived",
|
| 6 |
+
"Pclass",
|
| 7 |
+
"Name",
|
| 8 |
+
"Sex",
|
| 9 |
+
"Age",
|
| 10 |
+
"SibSp",
|
| 11 |
+
"Parch",
|
| 12 |
+
"Ticket",
|
| 13 |
+
"Fare",
|
| 14 |
+
"Cabin",
|
| 15 |
+
"Embarked"
|
| 16 |
+
],
|
| 17 |
+
"numeric_columns": [
|
| 18 |
+
"PassengerId",
|
| 19 |
+
"Survived",
|
| 20 |
+
"Pclass",
|
| 21 |
+
"Age",
|
| 22 |
+
"SibSp",
|
| 23 |
+
"Parch",
|
| 24 |
+
"Fare"
|
| 25 |
+
],
|
| 26 |
+
"categorical_columns": [
|
| 27 |
+
"Name",
|
| 28 |
+
"Sex",
|
| 29 |
+
"Ticket",
|
| 30 |
+
"Cabin",
|
| 31 |
+
"Embarked"
|
| 32 |
+
],
|
| 33 |
+
"missing_values": {
|
| 34 |
+
"PassengerId": 0.0,
|
| 35 |
+
"Survived": 0.0,
|
| 36 |
+
"Pclass": 0.0,
|
| 37 |
+
"Name": 0.0,
|
| 38 |
+
"Sex": 0.0,
|
| 39 |
+
"Age": 0.1987,
|
| 40 |
+
"SibSp": 0.0,
|
| 41 |
+
"Parch": 0.0,
|
| 42 |
+
"Ticket": 0.0,
|
| 43 |
+
"Fare": 0.0,
|
| 44 |
+
"Cabin": 0.771,
|
| 45 |
+
"Embarked": 0.0022
|
| 46 |
+
},
|
| 47 |
+
"column_types": {
|
| 48 |
+
"PassengerId": "int64",
|
| 49 |
+
"Survived": "int64",
|
| 50 |
+
"Pclass": "int64",
|
| 51 |
+
"Name": "str",
|
| 52 |
+
"Sex": "str",
|
| 53 |
+
"Age": "float64",
|
| 54 |
+
"SibSp": "int64",
|
| 55 |
+
"Parch": "int64",
|
| 56 |
+
"Ticket": "str",
|
| 57 |
+
"Fare": "float64",
|
| 58 |
+
"Cabin": "str",
|
| 59 |
+
"Embarked": "str"
|
| 60 |
+
}
|
| 61 |
+
}
|
data/registry.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
import pandas as pd
|
| 4 |
+
from utils.logger import logger
|
| 5 |
+
import os
|
| 6 |
+
DATASET_DIR = Path("data/datasets")
|
| 7 |
+
METADATA_DIR = Path("data/metadata")
|
| 8 |
+
|
| 9 |
+
DATASET_DIR.mkdir(parents=True, exist_ok=True)
|
| 10 |
+
METADATA_DIR.mkdir(parents=True, exist_ok=True)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class DatasetRegistry:
|
| 14 |
+
|
| 15 |
+
def __init__(self):
|
| 16 |
+
|
| 17 |
+
self.datasets = {}
|
| 18 |
+
|
| 19 |
+
self._load_existing()
|
| 20 |
+
|
| 21 |
+
def _load_existing(self):
|
| 22 |
+
|
| 23 |
+
try:
|
| 24 |
+
|
| 25 |
+
for meta_file in METADATA_DIR.glob("*.json"):
|
| 26 |
+
|
| 27 |
+
name = meta_file.stem
|
| 28 |
+
|
| 29 |
+
with open(meta_file, "r") as f:
|
| 30 |
+
metadata = json.load(f)
|
| 31 |
+
|
| 32 |
+
self.datasets[name] = metadata
|
| 33 |
+
logger.info(f"Loaded {len(self.datasets)} datasets into registry")
|
| 34 |
+
|
| 35 |
+
except Exception as e:
|
| 36 |
+
logger.error(f"Registry loading failed | {e}")
|
| 37 |
+
|
| 38 |
+
def delete_dataset(self, name):
|
| 39 |
+
try:
|
| 40 |
+
deleted_files = []
|
| 41 |
+
|
| 42 |
+
dataset_dir = "data/datasets"
|
| 43 |
+
metadata_dir = "data/metadata"
|
| 44 |
+
|
| 45 |
+
# Possible variations
|
| 46 |
+
variants = [name, f"{name}_clean"]
|
| 47 |
+
|
| 48 |
+
for variant in variants:
|
| 49 |
+
parquet_path = os.path.join(dataset_dir, f"{variant}.parquet")
|
| 50 |
+
metadata_path = os.path.join(metadata_dir, f"{variant}.json")
|
| 51 |
+
|
| 52 |
+
if os.path.exists(parquet_path):
|
| 53 |
+
os.remove(parquet_path)
|
| 54 |
+
deleted_files.append(parquet_path)
|
| 55 |
+
|
| 56 |
+
if os.path.exists(metadata_path):
|
| 57 |
+
os.remove(metadata_path)
|
| 58 |
+
deleted_files.append(metadata_path)
|
| 59 |
+
|
| 60 |
+
if not deleted_files:
|
| 61 |
+
return f"No dataset found for '{name}'"
|
| 62 |
+
|
| 63 |
+
logger.info(f"Deleted dataset {name} | Files: {deleted_files}")
|
| 64 |
+
|
| 65 |
+
return f"Deleted dataset '{name}' successfully."
|
| 66 |
+
|
| 67 |
+
except Exception as e:
|
| 68 |
+
logger.error(f"Delete failed | {e}")
|
| 69 |
+
return f"Failed to delete dataset '{name}'"
|
| 70 |
+
|
| 71 |
+
def register_dataset(self, name, df, schema):
|
| 72 |
+
|
| 73 |
+
try:
|
| 74 |
+
|
| 75 |
+
if name in self.datasets:
|
| 76 |
+
raise ValueError(f"Dataset '{name}' already exists")
|
| 77 |
+
|
| 78 |
+
parquet_path = DATASET_DIR / f"{name}.parquet"
|
| 79 |
+
meta_path = METADATA_DIR / f"{name}.json"
|
| 80 |
+
|
| 81 |
+
df.to_parquet(parquet_path)
|
| 82 |
+
|
| 83 |
+
with open(meta_path, "w") as f:
|
| 84 |
+
json.dump(schema, f, indent=2)
|
| 85 |
+
|
| 86 |
+
self.datasets[name] = schema
|
| 87 |
+
|
| 88 |
+
logger.info(f"Dataset registered | {name}")
|
| 89 |
+
|
| 90 |
+
except Exception as e:
|
| 91 |
+
logger.error(f"Dataset registration failed | {e}")
|
| 92 |
+
raise
|
| 93 |
+
|
| 94 |
+
def dataset_exists(self, name):
|
| 95 |
+
|
| 96 |
+
return name in self.datasets
|
| 97 |
+
|
| 98 |
+
def list_datasets(self):
|
| 99 |
+
|
| 100 |
+
return list(self.datasets.keys())
|
| 101 |
+
|
| 102 |
+
def get_info(self, name):
|
| 103 |
+
|
| 104 |
+
if name not in self.datasets:
|
| 105 |
+
raise ValueError("Dataset not found")
|
| 106 |
+
|
| 107 |
+
return self.datasets[name]
|
| 108 |
+
|
| 109 |
+
def update_dataset(self, name, df, schema):
|
| 110 |
+
|
| 111 |
+
try:
|
| 112 |
+
|
| 113 |
+
parquet_path = DATASET_DIR / f"{name}.parquet"
|
| 114 |
+
meta_path = METADATA_DIR / f"{name}.json"
|
| 115 |
+
|
| 116 |
+
df.to_parquet(parquet_path)
|
| 117 |
+
|
| 118 |
+
with open(meta_path, "w") as f:
|
| 119 |
+
json.dump(schema, f, indent=2)
|
| 120 |
+
|
| 121 |
+
self.datasets[name] = schema
|
| 122 |
+
|
| 123 |
+
logger.info(f"Dataset updated | {name}")
|
| 124 |
+
|
| 125 |
+
except Exception as e:
|
| 126 |
+
logger.error(f"Dataset update failed | {e}")
|
| 127 |
+
raise
|
| 128 |
+
|
| 129 |
+
def load_dataframe(self, name, sample=True, sample_size=50000):
|
| 130 |
+
try:
|
| 131 |
+
# ---------- VALIDATION ----------
|
| 132 |
+
if name not in self.datasets:
|
| 133 |
+
logger.error(f"Dataset '{name}' not found in registry")
|
| 134 |
+
raise ValueError(f"Dataset '{name}' not found")
|
| 135 |
+
|
| 136 |
+
path = DATASET_DIR / f"{name}.parquet"
|
| 137 |
+
|
| 138 |
+
if not path.exists():
|
| 139 |
+
logger.error(f"Parquet file missing: {path}")
|
| 140 |
+
raise FileNotFoundError(f"{path} not found")
|
| 141 |
+
|
| 142 |
+
logger.info(f"Loading dataset: {name}")
|
| 143 |
+
|
| 144 |
+
# ---------- LOAD ----------
|
| 145 |
+
df = pd.read_parquet(path)
|
| 146 |
+
|
| 147 |
+
logger.info(f"Loaded dataset '{name}' | shape={df.shape}")
|
| 148 |
+
|
| 149 |
+
# ---------- SMART SAMPLING ----------
|
| 150 |
+
if sample and len(df) > sample_size:
|
| 151 |
+
logger.info(
|
| 152 |
+
f"Dataset '{name}' is large ({len(df)} rows). "
|
| 153 |
+
f"Sampling {sample_size} rows for analysis."
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
df = df.sample(sample_size, random_state=42)
|
| 157 |
+
|
| 158 |
+
logger.info(f"Sampled dataset '{name}' | new_shape={df.shape}")
|
| 159 |
+
|
| 160 |
+
return df
|
| 161 |
+
|
| 162 |
+
except Exception as e:
|
| 163 |
+
logger.error(f"Failed to load dataset '{name}' | {e}")
|
| 164 |
+
raise
|
data/schema_extractor.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pandas as pd
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def extract_schema(df: pd.DataFrame):
|
| 5 |
+
|
| 6 |
+
schema = {}
|
| 7 |
+
|
| 8 |
+
schema["rows"] = len(df)
|
| 9 |
+
|
| 10 |
+
schema["columns"] = list(df.columns)
|
| 11 |
+
|
| 12 |
+
schema["numeric_columns"] = list(
|
| 13 |
+
df.select_dtypes(include=["number"]).columns
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
schema["categorical_columns"] = list(
|
| 17 |
+
df.select_dtypes(include=["object", "category"]).columns
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
schema["missing_values"] = (
|
| 21 |
+
df.isnull().mean().round(4).to_dict()
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
# NEW FIELD
|
| 25 |
+
schema["column_types"] = {
|
| 26 |
+
col: str(dtype) for col, dtype in df.dtypes.items()
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
return schema
|
experiments/experimentation.py
ADDED
|
File without changes
|
instructions/analyze.txt
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Instruction Set for Generic Dataset Analysis (Read-Only)
|
| 2 |
+
|
| 3 |
+
1. "Loading dataset '{dataset_name}'..."
|
| 4 |
+
- Load dataset into memory
|
| 5 |
+
- Record total rows and columns
|
| 6 |
+
- Log: "Dataset loaded: {num_rows} rows, {num_columns} columns"
|
| 7 |
+
|
| 8 |
+
2. "Computing data quality metrics..."
|
| 9 |
+
- Count total missing values per column and total in dataset
|
| 10 |
+
- Count total duplicate rows
|
| 11 |
+
- Log: "Total missing values: {total_missing}, Duplicate rows: {num_duplicates}"
|
| 12 |
+
|
| 13 |
+
3. "Profiling feature types..."
|
| 14 |
+
- Identify numeric columns
|
| 15 |
+
- Identify categorical columns
|
| 16 |
+
- Compute unique values per categorical column
|
| 17 |
+
- Log: "Numeric columns: {numeric_columns}, Categorical columns: {categorical_columns}"
|
| 18 |
+
|
| 19 |
+
4. "Checking basic statistics for numeric features..."
|
| 20 |
+
- Compute min, max, mean, std for each numeric column
|
| 21 |
+
- Log: "Numeric summary computed for {column}"
|
| 22 |
+
|
| 23 |
+
5. "Checking categorical value distributions..."
|
| 24 |
+
- Compute top 5 categories and their counts for each categorical column
|
| 25 |
+
- Log: "Categorical summary computed for {column}"
|
| 26 |
+
|
| 27 |
+
6. "Computing correlation matrix (numeric features only)..."
|
| 28 |
+
- Compute Pearson correlations between numeric columns
|
| 29 |
+
- Log: "Correlation matrix computed"
|
| 30 |
+
|
| 31 |
+
7. "Preparing visualizations in terminal..."
|
| 32 |
+
- Generate histograms for numeric columns using plotext
|
| 33 |
+
- Log: "Histogram ready for {column}"
|
| 34 |
+
- Generate bar charts for categorical columns with cardinality <= 20
|
| 35 |
+
- Log: "Bar chart ready for {column}"
|
| 36 |
+
- High-cardinality columns are noted but skipped from plotting
|
| 37 |
+
|
| 38 |
+
8. "Generating dataset summary (LLM)..."
|
| 39 |
+
- Summarize:
|
| 40 |
+
- Rows and columns
|
| 41 |
+
- Missing value overview
|
| 42 |
+
- Duplicates overview
|
| 43 |
+
- Key numeric/categorical insights
|
| 44 |
+
- Correlation highlights
|
| 45 |
+
- Log: "Summary generated"
|
| 46 |
+
|
| 47 |
+
9. "Finalizing terminal dashboard..."
|
| 48 |
+
- Print structured dashboard like:
|
| 49 |
+
|
| 50 |
+
Dataset Analysis: {dataset_name}
|
| 51 |
+
--------------------------------
|
| 52 |
+
Rows: {num_rows}
|
| 53 |
+
Columns: {num_columns}
|
| 54 |
+
|
| 55 |
+
Data Quality
|
| 56 |
+
------------
|
| 57 |
+
Total Missing Values : {total_missing}
|
| 58 |
+
Duplicate Rows : {num_duplicates}
|
| 59 |
+
|
| 60 |
+
Missing by Column
|
| 61 |
+
-----------------
|
| 62 |
+
{column1} : {missing_pct1}%
|
| 63 |
+
{column2} : {missing_pct2}%
|
| 64 |
+
|
| 65 |
+
Feature Summary
|
| 66 |
+
---------------
|
| 67 |
+
Numeric Columns : {numeric_columns}
|
| 68 |
+
Categorical Columns : {categorical_columns}
|
| 69 |
+
|
| 70 |
+
Correlations (Top pairs)
|
| 71 |
+
------------------------
|
| 72 |
+
{col1} & {col2} : r={value}
|
| 73 |
+
{col3} & {col4} : r={value}
|
| 74 |
+
|
| 75 |
+
Visualizations Ready
|
| 76 |
+
-------------------
|
| 77 |
+
[Histograms: {numeric_columns}]
|
| 78 |
+
[Bar Charts: {categorical_columns <= 20}]
|
| 79 |
+
|
| 80 |
+
LLM Summary
|
| 81 |
+
-----------
|
| 82 |
+
{text_summary}
|
testing/test_agent_routing.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Agent Routing Test Suite
|
| 3 |
+
========================
|
| 4 |
+
Tests that every query type routes to the correct agent (rule-based router),
|
| 5 |
+
that the 'list' command is correctly disambiguated, and that the LLM plan
|
| 6 |
+
column-validation guard works.
|
| 7 |
+
|
| 8 |
+
No Ollama required — all tests use the rule-based fallback router directly.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import sys
|
| 12 |
+
import os
|
| 13 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 14 |
+
|
| 15 |
+
from unittest.mock import patch
|
| 16 |
+
from core.query_router import QueryRouter
|
| 17 |
+
from data.registry import DatasetRegistry
|
| 18 |
+
from cli_app.command_handler import _validate_plan_column, _is_list_with_context
|
| 19 |
+
|
| 20 |
+
router = QueryRouter()
|
| 21 |
+
|
| 22 |
+
passed = 0
|
| 23 |
+
failed = 0
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def run_test(label, got, expected):
|
| 27 |
+
global passed, failed
|
| 28 |
+
ok = got == expected
|
| 29 |
+
tag = "[PASS]" if ok else "[FAIL]"
|
| 30 |
+
print(f"{tag} {label}")
|
| 31 |
+
print(f" Expected : {expected}")
|
| 32 |
+
print(f" Got : {got}\n")
|
| 33 |
+
if ok:
|
| 34 |
+
passed += 1
|
| 35 |
+
else:
|
| 36 |
+
failed += 1
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def run_bool_test(label, got, expected=True):
|
| 40 |
+
global passed, failed
|
| 41 |
+
ok = bool(got) == expected
|
| 42 |
+
tag = "[PASS]" if ok else "[FAIL]"
|
| 43 |
+
print(f"{tag} {label}")
|
| 44 |
+
print(f" Expected : {expected}")
|
| 45 |
+
print(f" Got : {got}\n")
|
| 46 |
+
if ok:
|
| 47 |
+
passed += 1
|
| 48 |
+
else:
|
| 49 |
+
failed += 1
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
print("=" * 60)
|
| 53 |
+
print(" Agent Routing Test Suite")
|
| 54 |
+
print("=" * 60)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
# ── METADATA AGENT ────────────────────────────────────────────
|
| 58 |
+
print("\n--- Metadata Agent Routing ---\n")
|
| 59 |
+
|
| 60 |
+
run_test("Columns query",
|
| 61 |
+
router.route("show all columns in leads"), "metadata_agent")
|
| 62 |
+
run_test("Numeric columns query",
|
| 63 |
+
router.route("what are the numeric columns in leads"), "metadata_agent")
|
| 64 |
+
run_test("Categorical columns query",
|
| 65 |
+
router.route("list categorical columns in organizations"), "metadata_agent")
|
| 66 |
+
run_test("Missing values query",
|
| 67 |
+
router.route("how many missing values in people"), "metadata_agent")
|
| 68 |
+
run_test("Schema query",
|
| 69 |
+
router.route("show schema for organizations"), "metadata_agent")
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
# ── DATAFRAME AGENT ───────────────────────────────────────────
|
| 73 |
+
print("--- DataFrame Agent Routing ---\n")
|
| 74 |
+
|
| 75 |
+
run_test("Average query",
|
| 76 |
+
router.route("average annual_revenue in leads"), "dataframe_agent")
|
| 77 |
+
run_test("Mean query",
|
| 78 |
+
router.route("mean of employees in organizations"), "dataframe_agent")
|
| 79 |
+
run_test("Max query",
|
| 80 |
+
router.route("max annual_revenue in leads"), "dataframe_agent")
|
| 81 |
+
run_test("Min query",
|
| 82 |
+
router.route("min employees in organizations"), "dataframe_agent")
|
| 83 |
+
run_test("Top rows query",
|
| 84 |
+
router.route("show top 10 rows in leads"), "dataframe_agent")
|
| 85 |
+
run_test("Row count query",
|
| 86 |
+
router.route("how many rows in leads"), "dataframe_agent")
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
# ── VISUALIZATION AGENT ───────────────────────────────────────
|
| 90 |
+
print("--- Visualization Agent Routing ---\n")
|
| 91 |
+
|
| 92 |
+
run_test("Histogram query",
|
| 93 |
+
router.route("histogram of annual_revenue in leads"), "visualization_agent")
|
| 94 |
+
run_test("Bar chart query",
|
| 95 |
+
router.route("bar chart of industry in leads"), "visualization_agent")
|
| 96 |
+
run_test("Plot query",
|
| 97 |
+
router.route("plot distribution in organizations"), "visualization_agent")
|
| 98 |
+
run_test("Graph query",
|
| 99 |
+
router.route("graph of employees"), "visualization_agent")
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
# ── TRANSFORMER AGENT — existing ops ─────────────────────────
|
| 103 |
+
print("--- Transformer Agent Routing (existing ops) ---\n")
|
| 104 |
+
|
| 105 |
+
run_test("Drop duplicates",
|
| 106 |
+
router.route("drop duplicates in leads"), "transformer_agent")
|
| 107 |
+
run_test("Fill nulls",
|
| 108 |
+
router.route("fill nulls in organizations"), "transformer_agent")
|
| 109 |
+
run_test("Normalize",
|
| 110 |
+
router.route("normalize annual_revenue in leads"), "transformer_agent")
|
| 111 |
+
run_test("Encode",
|
| 112 |
+
router.route("encode industry in leads"), "transformer_agent")
|
| 113 |
+
run_test("Rename",
|
| 114 |
+
router.route("rename industry to sector in leads"), "transformer_agent")
|
| 115 |
+
run_test("Drop column (no metadata collision)",
|
| 116 |
+
router.route("drop column description in leads"), "transformer_agent")
|
| 117 |
+
run_test("Impute (no metadata collision)",
|
| 118 |
+
router.route("impute missing in organizations"), "transformer_agent")
|
| 119 |
+
run_test("Strip whitespace",
|
| 120 |
+
router.route("strip whitespace in people"), "transformer_agent")
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
# ── TRANSFORMER AGENT — new preprocessing ops ─────────────────
|
| 124 |
+
print("--- Transformer Agent Routing (new preprocessing ops) ---\n")
|
| 125 |
+
|
| 126 |
+
run_test("Standardize",
|
| 127 |
+
router.route("standardize number of employees in organizations"), "transformer_agent")
|
| 128 |
+
run_test("Z-score keyword",
|
| 129 |
+
router.route("z-score normalize founded in organizations"), "transformer_agent")
|
| 130 |
+
run_test("Zscore keyword",
|
| 131 |
+
router.route("zscore the index column in leads"), "transformer_agent")
|
| 132 |
+
run_test("One-hot encoding",
|
| 133 |
+
router.route("one hot encode industry in organizations"), "transformer_agent")
|
| 134 |
+
run_test("Onehot keyword",
|
| 135 |
+
router.route("onehot encode sex in people"), "transformer_agent")
|
| 136 |
+
run_test("Dummies keyword",
|
| 137 |
+
router.route("get dummies for industry in organizations"), "transformer_agent")
|
| 138 |
+
run_test("Fill with mean",
|
| 139 |
+
router.route("fill with mean in organizations"), "transformer_agent")
|
| 140 |
+
run_test("Fill with median",
|
| 141 |
+
router.route("fill nulls with median in leads"), "transformer_agent")
|
| 142 |
+
run_test("Fill with mode",
|
| 143 |
+
router.route("fill missing using mode in people"), "transformer_agent")
|
| 144 |
+
run_test("Fill zero",
|
| 145 |
+
router.route("fill with zero in leads"), "transformer_agent")
|
| 146 |
+
run_test("Drop missing rows",
|
| 147 |
+
router.route("drop missing rows in organizations"), "transformer_agent")
|
| 148 |
+
run_test("Drop missing cols",
|
| 149 |
+
router.route("drop missing columns in leads"), "transformer_agent")
|
| 150 |
+
run_test("Dropna keyword",
|
| 151 |
+
router.route("dropna in organizations"), "transformer_agent")
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
# ── LIST DISAMBIGUATION ───────────────────────────────────────
|
| 155 |
+
print("--- List Ambiguity Detection ---\n")
|
| 156 |
+
|
| 157 |
+
run_bool_test("'list columns in leads' → metadata context",
|
| 158 |
+
_is_list_with_context("list columns in leads"), expected=True)
|
| 159 |
+
run_bool_test("'list all numeric columns in people' → metadata context",
|
| 160 |
+
_is_list_with_context("list all numeric columns in people"), expected=True)
|
| 161 |
+
run_bool_test("'list' alone → no context (dataset list)",
|
| 162 |
+
_is_list_with_context("list"), expected=False)
|
| 163 |
+
run_bool_test("'list datasets' → no context (dataset list)",
|
| 164 |
+
_is_list_with_context("list datasets"), expected=False)
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
# ── COLUMN VALIDATION ─────────────────────────────────────────
|
| 168 |
+
print("--- Column Validation (LLM plan guard) ---\n")
|
| 169 |
+
|
| 170 |
+
registry = DatasetRegistry()
|
| 171 |
+
datasets = registry.list_datasets()
|
| 172 |
+
|
| 173 |
+
if datasets:
|
| 174 |
+
sample_dataset = [d for d in datasets if not d.endswith("_clean")][0]
|
| 175 |
+
info = registry.get_info(sample_dataset)
|
| 176 |
+
real_columns = info.get("columns", [])
|
| 177 |
+
|
| 178 |
+
if real_columns:
|
| 179 |
+
real_col = real_columns[0]
|
| 180 |
+
|
| 181 |
+
with patch("cli_app.command_handler.registry", registry):
|
| 182 |
+
ok, _ = _validate_plan_column({
|
| 183 |
+
"agent": "transformer_agent", "operation": "fill_mean",
|
| 184 |
+
"dataset": sample_dataset, "column": real_col
|
| 185 |
+
})
|
| 186 |
+
run_bool_test(f"Valid column '{real_col}' in '{sample_dataset}' → passes",
|
| 187 |
+
ok, expected=True)
|
| 188 |
+
|
| 189 |
+
ok, _ = _validate_plan_column({
|
| 190 |
+
"agent": "transformer_agent", "operation": "standardize",
|
| 191 |
+
"dataset": sample_dataset, "column": "ghost_col_xyz"
|
| 192 |
+
})
|
| 193 |
+
run_bool_test("Non-existent column 'ghost_col_xyz' → fails validation",
|
| 194 |
+
not ok, expected=True)
|
| 195 |
+
|
| 196 |
+
ok, _ = _validate_plan_column({
|
| 197 |
+
"agent": "transformer_agent", "operation": "drop_missing_rows",
|
| 198 |
+
"dataset": sample_dataset, "column": None
|
| 199 |
+
})
|
| 200 |
+
run_bool_test("Plan with column=None → always passes",
|
| 201 |
+
ok, expected=True)
|
| 202 |
+
else:
|
| 203 |
+
print("[SKIP] No datasets loaded — skipping column validation tests\n")
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
# ── SUMMARY ───────────────────────────────────────────────────
|
| 207 |
+
print("=" * 60)
|
| 208 |
+
print(f"Results: {passed} passed, {failed} failed")
|
| 209 |
+
if failed == 0:
|
| 210 |
+
print("All tests passed.")
|
| 211 |
+
print("=" * 60)
|
testing/test_transformer.py
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import shutil
|
| 2 |
+
import os
|
| 3 |
+
import pandas as pd
|
| 4 |
+
from data.registry import DatasetRegistry
|
| 5 |
+
from data.schema_extractor import extract_schema
|
| 6 |
+
from agents.transformer_agent import TransformerAgent
|
| 7 |
+
|
| 8 |
+
DATASETS_DIR = "data/datasets"
|
| 9 |
+
METADATA_DIR = "data/metadata"
|
| 10 |
+
DATASETS_BACKUP = "data/datasets_backup"
|
| 11 |
+
METADATA_BACKUP = "data/metadata_backup"
|
| 12 |
+
|
| 13 |
+
passed = 0
|
| 14 |
+
failed = 0
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def backup():
|
| 18 |
+
shutil.copytree(DATASETS_DIR, DATASETS_BACKUP, dirs_exist_ok=True)
|
| 19 |
+
shutil.copytree(METADATA_DIR, METADATA_BACKUP, dirs_exist_ok=True)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def restore():
|
| 23 |
+
shutil.rmtree(DATASETS_DIR)
|
| 24 |
+
shutil.rmtree(METADATA_DIR)
|
| 25 |
+
shutil.copytree(DATASETS_BACKUP, DATASETS_DIR)
|
| 26 |
+
shutil.copytree(METADATA_BACKUP, METADATA_DIR)
|
| 27 |
+
shutil.rmtree(DATASETS_BACKUP, ignore_errors=True)
|
| 28 |
+
shutil.rmtree(METADATA_BACKUP, ignore_errors=True)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def fresh():
|
| 32 |
+
return DatasetRegistry(), None
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def fresh_agent():
|
| 36 |
+
registry = DatasetRegistry()
|
| 37 |
+
return registry, TransformerAgent(registry)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def run_test(label, query, check_fn, agent):
|
| 41 |
+
global passed, failed
|
| 42 |
+
result = agent.handle(query)
|
| 43 |
+
try:
|
| 44 |
+
ok = check_fn(result, agent)
|
| 45 |
+
except Exception as e:
|
| 46 |
+
ok = False
|
| 47 |
+
print(f" [check error] {e}")
|
| 48 |
+
tag = "[PASS]" if ok else "[FAIL]"
|
| 49 |
+
print(f"{tag} {label}")
|
| 50 |
+
print(f" Query : {query}")
|
| 51 |
+
print(f" Result : {result}\n")
|
| 52 |
+
if ok:
|
| 53 |
+
passed += 1
|
| 54 |
+
else:
|
| 55 |
+
failed += 1
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
print("=" * 60)
|
| 59 |
+
print(" Transformer Agent Test Suite")
|
| 60 |
+
print("=" * 60)
|
| 61 |
+
|
| 62 |
+
backup()
|
| 63 |
+
|
| 64 |
+
try:
|
| 65 |
+
|
| 66 |
+
# ── SAFETY: ORIGINAL IS NEVER MODIFIED ─────────────────
|
| 67 |
+
print("--- Safety: original dataset is never modified ---\n")
|
| 68 |
+
|
| 69 |
+
registry, agent = fresh_agent()
|
| 70 |
+
original_shape = registry.load_dataframe("products").shape
|
| 71 |
+
agent.handle("drop duplicates in products")
|
| 72 |
+
original_after = registry.load_dataframe("products").shape
|
| 73 |
+
clean_exists = "products_clean" in registry.list_datasets()
|
| 74 |
+
|
| 75 |
+
ok = (original_after == original_shape) and clean_exists
|
| 76 |
+
print(f"{'[PASS]' if ok else '[FAIL]'} Original unchanged; products_clean created")
|
| 77 |
+
print(f" Original shape before : {original_shape}")
|
| 78 |
+
print(f" Original shape after : {original_after}")
|
| 79 |
+
print(f" products_clean exists : {clean_exists}\n")
|
| 80 |
+
passed += ok
|
| 81 |
+
failed += (not ok)
|
| 82 |
+
|
| 83 |
+
restore(); backup()
|
| 84 |
+
|
| 85 |
+
# ── CLEANING: DROP DUPLICATES ───────────────────────────
|
| 86 |
+
print("--- Cleaning: Drop Duplicates ---\n")
|
| 87 |
+
|
| 88 |
+
registry, agent = fresh_agent()
|
| 89 |
+
df = registry.load_dataframe("products")
|
| 90 |
+
df_with_dups = pd.concat([df, df.head(10)], ignore_index=True)
|
| 91 |
+
registry.update_dataset("products", df_with_dups, extract_schema(df_with_dups))
|
| 92 |
+
|
| 93 |
+
run_test(
|
| 94 |
+
label="Drop 10 injected duplicate rows",
|
| 95 |
+
query="drop duplicates in products",
|
| 96 |
+
check_fn=lambda result, ag: (
|
| 97 |
+
"dropped 10" in result.lower() and
|
| 98 |
+
ag.registry.load_dataframe("products_clean").duplicated().sum() == 0
|
| 99 |
+
),
|
| 100 |
+
agent=agent,
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
restore(); backup()
|
| 104 |
+
|
| 105 |
+
registry, agent = fresh_agent()
|
| 106 |
+
|
| 107 |
+
run_test(
|
| 108 |
+
label="No duplicates present → reports 0 dropped",
|
| 109 |
+
query="drop duplicates in products",
|
| 110 |
+
check_fn=lambda result, ag: "dropped 0" in result.lower(),
|
| 111 |
+
agent=agent,
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
restore(); backup()
|
| 115 |
+
|
| 116 |
+
# ── CLEANING: FILL NULLS ────────────────────────────────
|
| 117 |
+
print("--- Cleaning: Fill Nulls ---\n")
|
| 118 |
+
|
| 119 |
+
# symmetric numeric (|skew| < 1) → mean
|
| 120 |
+
registry, agent = fresh_agent()
|
| 121 |
+
df = registry.load_dataframe("products")
|
| 122 |
+
df.loc[0:9, "Price"] = None
|
| 123 |
+
registry.update_dataset("products", df, extract_schema(df))
|
| 124 |
+
|
| 125 |
+
run_test(
|
| 126 |
+
label="Fill symmetric Price column → uses mean",
|
| 127 |
+
query="fill price in products",
|
| 128 |
+
check_fn=lambda result, ag: (
|
| 129 |
+
"mean" in result.lower() and
|
| 130 |
+
ag.registry.load_dataframe("products_clean")["Price"].isnull().sum() == 0
|
| 131 |
+
),
|
| 132 |
+
agent=agent,
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
restore(); backup()
|
| 136 |
+
|
| 137 |
+
# skewed numeric (|skew| >= 1) → median
|
| 138 |
+
registry, agent = fresh_agent()
|
| 139 |
+
df = registry.load_dataframe("products")
|
| 140 |
+
df["Price"] = df["Price"].astype(float)
|
| 141 |
+
df.loc[0:9, "Price"] = None
|
| 142 |
+
df.loc[10:, "Price"] = df.loc[10:, "Price"] ** 3
|
| 143 |
+
registry.update_dataset("products", df, extract_schema(df))
|
| 144 |
+
|
| 145 |
+
run_test(
|
| 146 |
+
label="Fill skewed Price column → uses median",
|
| 147 |
+
query="fill price in products",
|
| 148 |
+
check_fn=lambda result, ag: (
|
| 149 |
+
"median" in result.lower() and
|
| 150 |
+
ag.registry.load_dataframe("products_clean")["Price"].isnull().sum() == 0
|
| 151 |
+
),
|
| 152 |
+
agent=agent,
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
restore(); backup()
|
| 156 |
+
|
| 157 |
+
# categorical → mode
|
| 158 |
+
registry, agent = fresh_agent()
|
| 159 |
+
df = registry.load_dataframe("products")
|
| 160 |
+
df.loc[0:9, "Category"] = None
|
| 161 |
+
registry.update_dataset("products", df, extract_schema(df))
|
| 162 |
+
|
| 163 |
+
run_test(
|
| 164 |
+
label="Fill categorical Category column → uses mode",
|
| 165 |
+
query="fill category in products",
|
| 166 |
+
check_fn=lambda result, ag: (
|
| 167 |
+
"mode" in result.lower() and
|
| 168 |
+
ag.registry.load_dataframe("products_clean")["Category"].isnull().sum() == 0
|
| 169 |
+
),
|
| 170 |
+
agent=agent,
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
restore(); backup()
|
| 174 |
+
|
| 175 |
+
# fill all columns at once
|
| 176 |
+
registry, agent = fresh_agent()
|
| 177 |
+
df = registry.load_dataframe("products")
|
| 178 |
+
df.loc[0:9, "Price"] = None
|
| 179 |
+
df.loc[0:4, "Category"] = None
|
| 180 |
+
registry.update_dataset("products", df, extract_schema(df))
|
| 181 |
+
|
| 182 |
+
run_test(
|
| 183 |
+
label="Fill all nulls across every column in one call",
|
| 184 |
+
query="fill nulls in products",
|
| 185 |
+
check_fn=lambda result, ag: (
|
| 186 |
+
"filled" in result.lower() and
|
| 187 |
+
ag.registry.load_dataframe("products_clean").isnull().sum().sum() == 0
|
| 188 |
+
),
|
| 189 |
+
agent=agent,
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
restore(); backup()
|
| 193 |
+
|
| 194 |
+
# column with no nulls
|
| 195 |
+
registry, agent = fresh_agent()
|
| 196 |
+
|
| 197 |
+
run_test(
|
| 198 |
+
label="Fill column with no nulls → no-op message",
|
| 199 |
+
query="fill price in products",
|
| 200 |
+
check_fn=lambda result, ag: "no missing" in result.lower(),
|
| 201 |
+
agent=agent,
|
| 202 |
+
)
|
| 203 |
+
|
| 204 |
+
restore(); backup()
|
| 205 |
+
|
| 206 |
+
# ── CLEANING: DROP CONSTANT COLUMNS ────────────────────
|
| 207 |
+
print("--- Cleaning: Drop Constant Columns ---\n")
|
| 208 |
+
|
| 209 |
+
# Currency is constant (USD) in the original products data
|
| 210 |
+
registry, agent = fresh_agent()
|
| 211 |
+
|
| 212 |
+
run_test(
|
| 213 |
+
label="Drop existing constant column (Currency=USD)",
|
| 214 |
+
query="drop constant columns in products",
|
| 215 |
+
check_fn=lambda result, ag: (
|
| 216 |
+
"currency" in result.lower() and
|
| 217 |
+
"Currency" not in ag.registry.load_dataframe("products_clean").columns
|
| 218 |
+
),
|
| 219 |
+
agent=agent,
|
| 220 |
+
)
|
| 221 |
+
|
| 222 |
+
restore(); backup()
|
| 223 |
+
|
| 224 |
+
# inject an additional constant column
|
| 225 |
+
registry, agent = fresh_agent()
|
| 226 |
+
df = registry.load_dataframe("products")
|
| 227 |
+
df["TestConst"] = 0
|
| 228 |
+
registry.update_dataset("products", df, extract_schema(df))
|
| 229 |
+
|
| 230 |
+
run_test(
|
| 231 |
+
label="Drop multiple constant columns (Currency + injected TestConst)",
|
| 232 |
+
query="drop constant columns in products",
|
| 233 |
+
check_fn=lambda result, ag: (
|
| 234 |
+
"testconst" in result.lower() and
|
| 235 |
+
"TestConst" not in ag.registry.load_dataframe("products_clean").columns and
|
| 236 |
+
"Currency" not in ag.registry.load_dataframe("products_clean").columns
|
| 237 |
+
),
|
| 238 |
+
agent=agent,
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
restore(); backup()
|
| 242 |
+
|
| 243 |
+
# ── CLEANING: STRIP WHITESPACE ──────────────────────────
|
| 244 |
+
print("--- Cleaning: Strip Whitespace ---\n")
|
| 245 |
+
|
| 246 |
+
registry, agent = fresh_agent()
|
| 247 |
+
df = registry.load_dataframe("products")
|
| 248 |
+
df["Name"] = " " + df["Name"].astype(str) + " "
|
| 249 |
+
registry.update_dataset("products", df, extract_schema(df))
|
| 250 |
+
|
| 251 |
+
run_test(
|
| 252 |
+
label="Strip whitespace from string columns",
|
| 253 |
+
query="strip whitespace in products",
|
| 254 |
+
check_fn=lambda result, ag: (
|
| 255 |
+
"stripped" in result.lower() and
|
| 256 |
+
not ag.registry.load_dataframe("products_clean")["Name"]
|
| 257 |
+
.str.startswith(" ").any()
|
| 258 |
+
),
|
| 259 |
+
agent=agent,
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
+
restore(); backup()
|
| 263 |
+
|
| 264 |
+
# ── CLEANING: DROP COLUMN ───────────────────────────────
|
| 265 |
+
print("--- Cleaning: Drop Column ---\n")
|
| 266 |
+
|
| 267 |
+
registry, agent = fresh_agent()
|
| 268 |
+
|
| 269 |
+
run_test(
|
| 270 |
+
label="Drop Description column",
|
| 271 |
+
query="drop description in products",
|
| 272 |
+
check_fn=lambda result, ag: (
|
| 273 |
+
"dropped" in result.lower() and
|
| 274 |
+
"Description" not in ag.registry.load_dataframe("products_clean").columns
|
| 275 |
+
),
|
| 276 |
+
agent=agent,
|
| 277 |
+
)
|
| 278 |
+
|
| 279 |
+
run_test(
|
| 280 |
+
label="Drop non-existent column → not found",
|
| 281 |
+
query="drop ghostcol in products",
|
| 282 |
+
check_fn=lambda result, ag: "not found" in result.lower(),
|
| 283 |
+
agent=agent,
|
| 284 |
+
)
|
| 285 |
+
|
| 286 |
+
restore(); backup()
|
| 287 |
+
|
| 288 |
+
# ── TRANSFORMATIONS ─────────────────────────────────────
|
| 289 |
+
print("--- Transformations (secondary) ---\n")
|
| 290 |
+
|
| 291 |
+
registry, agent = fresh_agent()
|
| 292 |
+
|
| 293 |
+
run_test(
|
| 294 |
+
label="Normalize Price → [0, 1]",
|
| 295 |
+
query="normalize price in products",
|
| 296 |
+
check_fn=lambda result, ag: (
|
| 297 |
+
"normalized" in result.lower() and
|
| 298 |
+
ag.registry.load_dataframe("products_clean")["Price"].between(0, 1).all()
|
| 299 |
+
),
|
| 300 |
+
agent=agent,
|
| 301 |
+
)
|
| 302 |
+
|
| 303 |
+
run_test(
|
| 304 |
+
label="Normalize non-numeric column → blocked",
|
| 305 |
+
query="normalize category in products",
|
| 306 |
+
check_fn=lambda result, ag: "not numeric" in result.lower(),
|
| 307 |
+
agent=agent,
|
| 308 |
+
)
|
| 309 |
+
|
| 310 |
+
restore(); backup()
|
| 311 |
+
|
| 312 |
+
registry, agent = fresh_agent()
|
| 313 |
+
|
| 314 |
+
run_test(
|
| 315 |
+
label="Encode Category → integer codes",
|
| 316 |
+
query="encode category in products",
|
| 317 |
+
check_fn=lambda result, ag: (
|
| 318 |
+
"label-encoded" in result.lower() and
|
| 319 |
+
pd.api.types.is_integer_dtype(
|
| 320 |
+
ag.registry.load_dataframe("products_clean")["Category"]
|
| 321 |
+
)
|
| 322 |
+
),
|
| 323 |
+
agent=agent,
|
| 324 |
+
)
|
| 325 |
+
|
| 326 |
+
run_test(
|
| 327 |
+
label="Encode numeric column → blocked",
|
| 328 |
+
query="encode price in products",
|
| 329 |
+
check_fn=lambda result, ag: "not categorical" in result.lower(),
|
| 330 |
+
agent=agent,
|
| 331 |
+
)
|
| 332 |
+
|
| 333 |
+
restore(); backup()
|
| 334 |
+
|
| 335 |
+
registry, agent = fresh_agent()
|
| 336 |
+
|
| 337 |
+
run_test(
|
| 338 |
+
label="Rename Stock to inventory",
|
| 339 |
+
query="rename stock to inventory in products",
|
| 340 |
+
check_fn=lambda result, ag: (
|
| 341 |
+
"renamed" in result.lower() and
|
| 342 |
+
"inventory" in ag.registry.load_dataframe("products_clean").columns and
|
| 343 |
+
"Stock" not in ag.registry.load_dataframe("products_clean").columns
|
| 344 |
+
),
|
| 345 |
+
agent=agent,
|
| 346 |
+
)
|
| 347 |
+
|
| 348 |
+
restore(); backup()
|
| 349 |
+
|
| 350 |
+
# ── EDGE CASES ──────────────────────────────────────────
|
| 351 |
+
print("--- Edge Cases ---\n")
|
| 352 |
+
|
| 353 |
+
registry, agent = fresh_agent()
|
| 354 |
+
|
| 355 |
+
run_test(
|
| 356 |
+
label="Unknown operation → fallback message",
|
| 357 |
+
query="sort price in products",
|
| 358 |
+
check_fn=lambda result, ag: "not understood" in result.lower(),
|
| 359 |
+
agent=agent,
|
| 360 |
+
)
|
| 361 |
+
|
| 362 |
+
finally:
|
| 363 |
+
restore()
|
| 364 |
+
|
| 365 |
+
print("=" * 60)
|
| 366 |
+
print(f"Results: {passed} passed, {failed} failed")
|
| 367 |
+
if failed == 0:
|
| 368 |
+
print("All tests passed.")
|
| 369 |
+
print("=" * 60)
|
testing/test_visualization.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import matplotlib
|
| 2 |
+
matplotlib.use("Agg")
|
| 3 |
+
|
| 4 |
+
import matplotlib.pyplot as plt
|
| 5 |
+
plt.show = lambda: None
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
from data.registry import DatasetRegistry
|
| 9 |
+
from agents.visualization_agent import VisualizationAgent
|
| 10 |
+
|
| 11 |
+
OUTPUT_DIR = "test_output"
|
| 12 |
+
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
| 13 |
+
|
| 14 |
+
registry = DatasetRegistry()
|
| 15 |
+
agent = VisualizationAgent(registry)
|
| 16 |
+
|
| 17 |
+
passed = 0
|
| 18 |
+
failed = 0
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def run_test(label, query, output_file=None, expect_chart=True, expect_message=None):
|
| 22 |
+
global passed, failed
|
| 23 |
+
plt.close("all")
|
| 24 |
+
|
| 25 |
+
result = agent.handle(query)
|
| 26 |
+
figures = [plt.figure(i) for i in plt.get_fignums()]
|
| 27 |
+
|
| 28 |
+
if expect_chart:
|
| 29 |
+
if figures:
|
| 30 |
+
fig = figures[-1]
|
| 31 |
+
fig.tight_layout()
|
| 32 |
+
path = os.path.join(OUTPUT_DIR, output_file)
|
| 33 |
+
fig.savefig(path, bbox_inches="tight")
|
| 34 |
+
plt.close("all")
|
| 35 |
+
print(f"[PASS] {label}")
|
| 36 |
+
print(f" Query : {query}")
|
| 37 |
+
print(f" Result : {result}")
|
| 38 |
+
print(f" Saved to: {path}\n")
|
| 39 |
+
passed += 1
|
| 40 |
+
else:
|
| 41 |
+
print(f"[FAIL] {label}")
|
| 42 |
+
print(f" Query : {query}")
|
| 43 |
+
print(f" Result : {result}")
|
| 44 |
+
print(f" Expected a chart but none was generated.\n")
|
| 45 |
+
failed += 1
|
| 46 |
+
else:
|
| 47 |
+
plt.close("all")
|
| 48 |
+
ok = (expect_message is None) or (expect_message in result)
|
| 49 |
+
tag = "[PASS]" if ok else "[FAIL]"
|
| 50 |
+
print(f"{tag} {label}")
|
| 51 |
+
print(f" Query : {query}")
|
| 52 |
+
print(f" Result : {result}\n")
|
| 53 |
+
if ok:
|
| 54 |
+
passed += 1
|
| 55 |
+
else:
|
| 56 |
+
failed += 1
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
print("=" * 60)
|
| 60 |
+
print(" Visualization Agent Test Suite")
|
| 61 |
+
print("=" * 60)
|
| 62 |
+
print(f"Datasets loaded: {registry.list_datasets()}\n")
|
| 63 |
+
|
| 64 |
+
# ── Histograms (guardrail does NOT apply) ───────────────────
|
| 65 |
+
print("--- Histograms ---\n")
|
| 66 |
+
|
| 67 |
+
run_test(
|
| 68 |
+
label="Histogram – Price (products, 999 unique values, allowed)",
|
| 69 |
+
query="histogram price in products",
|
| 70 |
+
output_file="histogram_price_products.png",
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
run_test(
|
| 74 |
+
label="Histogram – Stock (products, 999 unique values, allowed)",
|
| 75 |
+
query="show histogram of stock in products",
|
| 76 |
+
output_file="histogram_stock_products.png",
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
# ── Bar charts – under limit (guardrail allows) ─────────────
|
| 80 |
+
print("--- Bar Charts (within limit) ---\n")
|
| 81 |
+
|
| 82 |
+
run_test(
|
| 83 |
+
label="Bar chart – Category (products, 34 unique values → allowed)",
|
| 84 |
+
query="bar chart category in products",
|
| 85 |
+
output_file="bar_category_products.png",
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
# ── Bar charts – over limit (guardrail blocks) ──────────────
|
| 89 |
+
print("--- Bar Charts (guardrail triggered) ---\n")
|
| 90 |
+
|
| 91 |
+
run_test(
|
| 92 |
+
label="Bar chart – Color (products, 140 unique values → blocked)",
|
| 93 |
+
query="bar chart color in products",
|
| 94 |
+
expect_chart=False,
|
| 95 |
+
expect_message="Too many to visualize meaningfully",
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
run_test(
|
| 99 |
+
label="Bar chart – Brand (products, 72263 unique values → blocked)",
|
| 100 |
+
query="bar chart brand in products",
|
| 101 |
+
expect_chart=False,
|
| 102 |
+
expect_message="Too many to visualize meaningfully",
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
# ── Edge cases ──────────────────────────────────────────────
|
| 106 |
+
print("--- Edge Cases ---\n")
|
| 107 |
+
|
| 108 |
+
run_test(
|
| 109 |
+
label="No column specified → helpful message",
|
| 110 |
+
query="histogram in products",
|
| 111 |
+
expect_chart=False,
|
| 112 |
+
expect_message="Column not found",
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
run_test(
|
| 116 |
+
label="Unknown dataset → error message",
|
| 117 |
+
query="histogram price in unknown_dataset",
|
| 118 |
+
expect_chart=False,
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
run_test(
|
| 122 |
+
label="Unsupported chart type → fallback message",
|
| 123 |
+
query="scatter plot price in products",
|
| 124 |
+
expect_chart=False,
|
| 125 |
+
expect_message="not understood",
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
# ── Summary ─────────────────────────────────────────────────
|
| 129 |
+
print("=" * 60)
|
| 130 |
+
print(f"Results: {passed} passed, {failed} failed")
|
| 131 |
+
if failed == 0:
|
| 132 |
+
print("All tests passed.")
|
| 133 |
+
print(f"Charts saved in '{OUTPUT_DIR}/'")
|
| 134 |
+
print("=" * 60)
|
utils/logger.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
LOG_DIR = Path("logs")
|
| 5 |
+
LOG_DIR.mkdir(exist_ok=True)
|
| 6 |
+
|
| 7 |
+
LOG_FILE = LOG_DIR / "eda_explorer.log"
|
| 8 |
+
|
| 9 |
+
logging.basicConfig(
|
| 10 |
+
filename=LOG_FILE,
|
| 11 |
+
level=logging.INFO,
|
| 12 |
+
format="%(asctime)s | %(levelname)s | %(message)s",
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger("EDA_EXPLORER")
|
vector_store/analyze_embeddings.pkl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:5d51489b7332e27e48fcd6ef7f30efd80310959669fc05597932932e2146a309
|
| 3 |
+
size 104114
|
vector_store/instruction_embedder.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# from pathlib import Path
|
| 2 |
+
# import pickle
|
| 3 |
+
# from sentence_transformers import SentenceTransformer
|
| 4 |
+
|
| 5 |
+
# BASE_DIR = Path(__file__).resolve().parent.parent
|
| 6 |
+
|
| 7 |
+
# pickle_file = BASE_DIR / "vector_store" / "analyze_embeddings.pkl"
|
| 8 |
+
# instruction_file = BASE_DIR / "instructions" / "analyze.txt"
|
| 9 |
+
|
| 10 |
+
# def embed_analyze_instructions():
|
| 11 |
+
# instruction_file = BASE_DIR / "instructions" / "analyze.txt"
|
| 12 |
+
# pickle_file = BASE_DIR / "vector_store" / "analyze_embeddings.pkl"
|
| 13 |
+
|
| 14 |
+
# # Ensure directory exists
|
| 15 |
+
# pickle_file.parent.mkdir(parents=True, exist_ok=True)
|
| 16 |
+
|
| 17 |
+
# # If embeddings already exist, load
|
| 18 |
+
# if pickle_file.exists():
|
| 19 |
+
# with open(pickle_file, "rb") as f:
|
| 20 |
+
# data = pickle.load(f)
|
| 21 |
+
# # print("Analyze embeddings already exist. Loaded from disk.")
|
| 22 |
+
# return data
|
| 23 |
+
|
| 24 |
+
# # Load instructions
|
| 25 |
+
# with open(instruction_file, "r", encoding="utf-8") as f:
|
| 26 |
+
# instructions = [line.strip() for line in f if line.strip()]
|
| 27 |
+
|
| 28 |
+
# # Embed
|
| 29 |
+
# model = SentenceTransformer('all-MiniLM-L6-v2')
|
| 30 |
+
# embeddings = model.encode(instructions)
|
| 31 |
+
# pickle_file.parent.mkdir(parents=True, exist_ok=True)
|
| 32 |
+
# # Save
|
| 33 |
+
# data = {"instructions": instructions, "embeddings": embeddings}
|
| 34 |
+
# with open(pickle_file, "wb") as f:
|
| 35 |
+
# pickle.dump(data, f)
|
| 36 |
+
|
| 37 |
+
# print(f"Instruction embeddings created and saved: {len(instructions)} instructions")
|
| 38 |
+
# return data
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
# if __name__ == "__main__":
|
| 42 |
+
# embed_analyze_instructions()
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
import os
|
| 48 |
+
import pickle
|
| 49 |
+
import requests
|
| 50 |
+
from pathlib import Path
|
| 51 |
+
from utils.logger import logger
|
| 52 |
+
|
| 53 |
+
BASE_DIR = Path(__file__).resolve().parent.parent
|
| 54 |
+
PICKLE_FILE = BASE_DIR / "vector_store" / "analyze_embeddings.pkl"
|
| 55 |
+
INSTRUCTION_FILE = BASE_DIR / "instructions" / "analyze.txt"
|
| 56 |
+
|
| 57 |
+
def get_ollama_embeddings(texts):
|
| 58 |
+
"""Try to get embeddings from local Ollama service."""
|
| 59 |
+
try:
|
| 60 |
+
# Default Ollama address
|
| 61 |
+
url = "http://localhost:11434/api/embed"
|
| 62 |
+
# Note: Some Ollama versions use /api/embeddings (plural)
|
| 63 |
+
embeddings = []
|
| 64 |
+
for text in texts:
|
| 65 |
+
response = requests.post(
|
| 66 |
+
url,
|
| 67 |
+
json={"model": "mxbai-embed-large", "input": text},
|
| 68 |
+
timeout=5
|
| 69 |
+
)
|
| 70 |
+
embeddings.append(response.json()['embeddings'][0])
|
| 71 |
+
return embeddings
|
| 72 |
+
except Exception:
|
| 73 |
+
return None
|
| 74 |
+
|
| 75 |
+
def get_hf_api_embeddings(texts):
|
| 76 |
+
"""Try to get embeddings via Hugging Face Inference API."""
|
| 77 |
+
token = os.environ.get("HF_TOKEN")
|
| 78 |
+
if not token:
|
| 79 |
+
return None
|
| 80 |
+
|
| 81 |
+
api_url = "https://api-inference.huggingface.co/pipeline/feature-extraction/sentence-transformers/all-MiniLM-L6-v2"
|
| 82 |
+
headers = {"Authorization": f"Bearer {token}"}
|
| 83 |
+
|
| 84 |
+
try:
|
| 85 |
+
response = requests.post(api_url, headers=headers, json={"inputs": texts}, timeout=10)
|
| 86 |
+
return response.json()
|
| 87 |
+
except Exception:
|
| 88 |
+
return None
|
| 89 |
+
|
| 90 |
+
def embed_analyze_instructions():
|
| 91 |
+
# 1. Ensure directory exists
|
| 92 |
+
PICKLE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
| 93 |
+
|
| 94 |
+
# 2. Check if cached embeddings exist
|
| 95 |
+
if PICKLE_FILE.exists():
|
| 96 |
+
with open(PICKLE_FILE, "rb") as f:
|
| 97 |
+
return pickle.load(f)
|
| 98 |
+
|
| 99 |
+
# 3. Load instructions from file
|
| 100 |
+
if not INSTRUCTION_FILE.exists():
|
| 101 |
+
logger.error(f"Instruction file not found at {INSTRUCTION_FILE}")
|
| 102 |
+
return None
|
| 103 |
+
|
| 104 |
+
with open(INSTRUCTION_FILE, "r", encoding="utf-8") as f:
|
| 105 |
+
instructions = [line.strip() for line in f if line.strip()]
|
| 106 |
+
|
| 107 |
+
embeddings = None
|
| 108 |
+
|
| 109 |
+
# --- FALLBACK LOGIC ---
|
| 110 |
+
|
| 111 |
+
# Try Ollama First
|
| 112 |
+
logger.info("Attempting Ollama embeddings...")
|
| 113 |
+
embeddings = get_ollama_embeddings(instructions)
|
| 114 |
+
|
| 115 |
+
# Try HF API Second
|
| 116 |
+
if embeddings is None:
|
| 117 |
+
logger.info("Ollama failed. Attempting Hugging Face API...")
|
| 118 |
+
embeddings = get_hf_api_embeddings(instructions)
|
| 119 |
+
|
| 120 |
+
# Local Heavy Fallback Third
|
| 121 |
+
if embeddings is None:
|
| 122 |
+
logger.warning("External APIs failed. Loading heavy local SentenceTransformer...")
|
| 123 |
+
# Lazy import: Only loads Torch/Transformers if absolutely necessary
|
| 124 |
+
from sentence_transformers import SentenceTransformer
|
| 125 |
+
model = SentenceTransformer('all-MiniLM-L6-v2')
|
| 126 |
+
embeddings = model.encode(instructions)
|
| 127 |
+
|
| 128 |
+
# 4. Save and Return
|
| 129 |
+
if embeddings is not None:
|
| 130 |
+
data = {"instructions": instructions, "embeddings": embeddings}
|
| 131 |
+
with open(PICKLE_FILE, "wb") as f:
|
| 132 |
+
pickle.dump(data, f)
|
| 133 |
+
logger.info(f"Embeddings saved: {len(instructions)} instructions")
|
| 134 |
+
return data
|
| 135 |
+
|
| 136 |
+
logger.error("Failed to generate embeddings via any method.")
|
| 137 |
+
return None
|
| 138 |
+
|
| 139 |
+
if __name__ == "__main__":
|
| 140 |
+
embed_analyze_instructions()
|