ProfessionalMario commited on
Commit
9eecab5
·
1 Parent(s): b2fb95a

Fresh deployment with LFS tracking

Browse files
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()