prediqai / RULE /utils.py
ganesh-vilje's picture
Deploy to Hugging Face Main
f8f02c0
Raw
History Blame Contribute Delete
56.6 kB
# utils.py
import ast
import csv
import datetime
import io
import json
import math
import os
import re
import tempfile
import time
import uuid
from contextlib import redirect_stderr, redirect_stdout
from typing import Any, Dict, List, Optional, Tuple
import numpy as np
import pandas as pd
import requests
# Mongo
from pymongo import ASCENDING, DESCENDING, MongoClient
# Core rule-engine helpers (from your pandas_rule.py)
from RULE.pandas_rule import ( # NL operation helpers (used in Part 2); LLM-based routing functions
classify_query_intent, generate_aggregate_code, generate_transform_code,
load_file, nl_to_condition, parse_column_ops, parse_concat_tables,
parse_create_column, parse_lambda_apply, parse_nl_operation,
parse_transform_column, safe_apply)
# ---------------- Constants and Globals ----------------
APP_VERSION = "build-2025-09-25-hf-storage-safe-v1"
# In-memory datasets (used temporarily for workflow execution, then cleared)
STORED_DFS: Dict[str, pd.DataFrame] = {}
STORED_META: Dict[str, Dict[str, Any]] = {} # in-memory file metadata
# ===== Mongo Configuration =====
MONGODB_URI = os.environ.get("MONGODB_URI")
# Support both MONGODB_DB and MONGODB_DATABASE for compatibility
MONGODB_DB = os.environ.get("MONGODB_DB", "rulzAI")
# Collections (as requested)
RULES_COLL_NAME = os.environ.get("RULES_COLL_NAME", "Rules")
DATASET_COLL_NAME = os.environ.get("DATASET_COLL_NAME", "dataset")
FILTERED_COLL_NAME = os.environ.get("FILTERED_COLL_NAME", "filtered_dataset")
WORKFLOW_COLL_NAME = os.environ.get("WORKFLOW_COLL_NAME", "RulzAI workflow")
WORKFLOW_EXECUTE_COLL_NAME = os.environ.get("WORKFLOW_EXECUTE_COLL_NAME", "workflow_execute")
MONGO_CLIENT = None
MONGO_DB = None
MONGO_RULES = None
MONGO_DATASET = None
MONGO_FILTERED = None
MONGO_WORKFLOWS = None
MONGO_WORKFLOW_EXECUTE = None
try:
if not MONGODB_URI:
raise ValueError(
"MONGODB_URI environment variable is not set. "
"Please configure MongoDB connection in your .env file. "
"For cloud: MONGODB_URI=mongodb+srv://user:pass@host/?appName=app "
"For local: MONGODB_URI=mongodb://localhost:27017/"
)
MONGO_CLIENT = MongoClient(
MONGODB_URI,
serverSelectionTimeoutMS=6000,
connectTimeoutMS=6000,
retryWrites=True,
w="majority",
)
MONGO_CLIENT.admin.command("ping")
MONGO_DB = MONGO_CLIENT[MONGODB_DB]
MONGO_RULES = MONGO_DB[RULES_COLL_NAME]
MONGO_DATASET = MONGO_DB[DATASET_COLL_NAME]
MONGO_FILTERED = MONGO_DB[FILTERED_COLL_NAME]
MONGO_WORKFLOWS = MONGO_DB[WORKFLOW_COLL_NAME]
MONGO_WORKFLOW_EXECUTE = MONGO_DB[WORKFLOW_EXECUTE_COLL_NAME]
# Indexes
# Rules: unique name per type=rule
MONGO_RULES.create_index(
[("type", ASCENDING), ("name", ASCENDING)],
unique=True,
name="uniq_rule_name",
partialFilterExpression={"type": "rule", "name": {"$type": "string"}}
)
# Timestamps and run listing
MONGO_RULES.create_index([("type", ASCENDING), ("created_at", DESCENDING)], name="rules_created_at")
MONGO_RULES.create_index([("type", ASCENDING), ("last_run_at", DESCENDING)], name="rules_last_run_at")
MONGO_RULES.create_index([("type", ASCENDING), ("rule_id", ASCENDING), ("ts", DESCENDING)], name="runs_rule_ts")
MONGO_RULES.create_index([("type", ASCENDING), ("status", ASCENDING), ("ts", DESCENDING)], name="runs_status_ts")
# Dataset: unique name for meta docs
MONGO_DATASET.create_index(
[("type", ASCENDING), ("name", ASCENDING)],
unique=True,
name="uniq_dataset_name",
partialFilterExpression={"type": "dataset_meta", "name": {"$type": "string"}}
)
MONGO_DATASET.create_index([("type", ASCENDING), ("created_at", DESCENDING)], name="dataset_created_at")
MONGO_DATASET.create_index(
[("type", ASCENDING), ("dataset_id", ASCENDING), ("chunk_index", ASCENDING)],
name="dataset_chunks_order",
partialFilterExpression={"type": "dataset_chunk"}
)
# Filtered: unique name for meta docs
MONGO_FILTERED.create_index(
[("type", ASCENDING), ("name", ASCENDING)],
unique=True,
name="uniq_filtered_name",
partialFilterExpression={"type": "filtered_meta", "name": {"$type": "string"}}
)
MONGO_FILTERED.create_index([("type", ASCENDING), ("created_at", DESCENDING)], name="filtered_created_at")
MONGO_FILTERED.create_index(
[("type", ASCENDING), ("filtered_id", ASCENDING), ("chunk_index", ASCENDING)],
name="filtered_chunks_order",
partialFilterExpression={"type": "filtered_chunk"}
)
# Workflow Execute indexes
MONGO_WORKFLOW_EXECUTE.create_index(
[("workflow_id", ASCENDING), ("created_at", DESCENDING)],
name="workflow_exec_wf_created"
)
MONGO_WORKFLOW_EXECUTE.create_index(
[("process_id", ASCENDING)],
name="workflow_exec_pid"
)
MONGO_WORKFLOW_EXECUTE.create_index(
[("status", ASCENDING), ("created_at", DESCENDING)],
name="workflow_exec_status_created"
)
MONGO_WORKFLOW_EXECUTE.create_index(
[("type", ASCENDING), ("execution_id", ASCENDING), ("result_type", ASCENDING), ("chunk_index", ASCENDING)],
name="workflow_result_lookup"
)
print(f"MongoDB connected successfully to database '{MONGODB_DB}'.")
except ValueError as ve:
print(f"MongoDB configuration error: {ve}")
MONGO_DB = None
except Exception as e:
print(f"MongoDB connection failed: {e}")
MONGO_DB = None
# ===== Process timers (for durations) =====
PROCESS_TIMERS: Dict[str, float] = {}
# ---------------- Utilities ----------------
def now_utc_iso() -> str:
return datetime.datetime.now(datetime.timezone.utc).isoformat()
def gen_id(prefix: str) -> str:
return f"{prefix}_{uuid.uuid4().hex}"
def slugify(s: str, max_len: int = 64) -> str:
s = re.sub(r'[^A-Za-z0-9._-]+', '_', str(s).strip())
s = re.sub(r'_{2,}', '_', s).strip('_')
return s[:max_len] if len(s) > max_len else s
def ensure_outcome_labels(df: pd.DataFrame) -> pd.DataFrame:
if "Outcome_Labels" not in df.columns:
df["Outcome_Labels"] = [[] for _ in range(len(df))]
return df
def to_list(x):
if isinstance(x, list):
return x
if pd.isna(x):
return []
try:
val = ast.literal_eval(str(x))
if isinstance(val, list):
return val
except Exception:
pass
return [x]
df["Outcome_Labels"] = df["Outcome_Labels"].apply(to_list)
return df
def df_preview(df: pd.DataFrame, max_rows: int = 20):
sub = df.head(max_rows).copy()
sub = sub.replace([np.inf, -np.inf], np.nan)
sub = sub.where(pd.notnull(sub), None)
return sub.to_dict(orient="records")
def to_json_safe(obj: Any) -> Any:
if obj is None:
return None
if isinstance(obj, (str, bool, int)):
return obj
if isinstance(obj, float):
return None if (math.isnan(obj) or math.isinf(obj)) else obj
if isinstance(obj, (np.integer,)):
return int(obj)
if isinstance(obj, (np.floating,)):
val = float(obj)
return None if (math.isnan(val) or math.isinf(val)) else val
if isinstance(obj, dict):
return {to_json_safe(k): to_json_safe(v) for k, v in obj.items()}
if isinstance(obj, (list, tuple, set)):
return [to_json_safe(x) for x in obj]
if isinstance(obj, pd.DataFrame):
return df_preview(obj)
if isinstance(obj, pd.Series):
return to_json_safe(obj.to_dict())
return str(obj)
def capture_output(fn, *args, **kwargs):
buf = io.StringIO()
with redirect_stdout(buf), redirect_stderr(buf):
result = fn(*args, **kwargs)
return result, buf.getvalue()
def normalize_dataframe(df: pd.DataFrame) -> pd.DataFrame:
df.columns = (
df.columns.astype(str)
.str.strip()
.str.replace(r"[^0-9a-zA-Z]+", "_", regex=True)
.str.strip("_")
)
return df
def coerce_numeric(df: pd.DataFrame) -> pd.DataFrame:
for col in df.columns:
if df[col].dtype == "object":
conv = pd.to_numeric(df[col], errors="coerce")
if len(conv) == 0:
continue
non_na_ratio = conv.notna().mean()
if non_na_ratio == 1.0 or (non_na_ratio >= 0.8 and conv.notna().sum() > 0):
df[col] = conv
return df
def load_uploaded_to_df(upload: Any, max_mb: int = int(os.environ.get("MAX_UPLOAD_MB", "512"))) -> pd.DataFrame:
"""
Storage-safe upload: stream to temp file with size guard, load, and delete temp immediately.
Prevents /tmp growth and limits oversized uploads.
'upload' can be FastAPI's UploadFile (recommended) or any object with file-like .file and .filename.
"""
suffix = os.path.splitext(getattr(upload, "filename", "upload.csv"))[1].lower() or ".csv"
fd, tmp_path = tempfile.mkstemp(suffix=suffix, prefix="upload_")
max_bytes = max_mb * 1024 * 1024
written = 0
try:
with os.fdopen(fd, "wb") as f:
while True:
chunk = upload.file.read(1024 * 1024) # 1 MB
if not chunk:
break
written += len(chunk)
if written > max_bytes:
raise ValueError(f"Upload too large: {written/1e6:.1f} MB > {max_mb} MB limit")
f.write(chunk)
try:
upload.file.seek(0)
except Exception:
pass
df = load_file(tmp_path)
finally:
try:
os.remove(tmp_path)
except Exception:
pass
return df
# ---------------- Process logging (stored in Rules collection) ----------------
def begin_process(endpoint: str, params: Optional[Dict[str, Any]] = None) -> str:
pid = gen_id("proc")
PROCESS_TIMERS[pid] = time.time()
if MONGO_DB is not None:
doc = {
"_id": pid,
"type": "process",
"endpoint": endpoint,
"params": params or {},
"started_at": now_utc_iso(),
"status": "running",
"version": APP_VERSION,
}
MONGO_RULES.insert_one(doc)
return pid
def end_process(process_id: str, status: str = "success", extra: Optional[Dict[str, Any]] = None):
ended_at = now_utc_iso()
started_ts = PROCESS_TIMERS.pop(process_id, None)
duration_ms = int((time.time() - started_ts) * 1000) if started_ts else None
if MONGO_DB is not None:
update = {"$set": {"status": status, "ended_at": ended_at}}
if duration_ms is not None:
update["$set"]["duration_ms"] = duration_ms
if extra:
update["$set"]["result"] = extra
MONGO_RULES.update_one({"_id": process_id, "type": "process"}, update)
# ---------------- Mongo helpers: dataset and filtered_dataset ----------------
DEFAULT_CHUNK_SIZE = int(os.environ.get("MONGO_CHUNK_SIZE", "5000"))
def _enforce_unique_name(col, type_value: str, name: str, overwrite: bool):
if name is None:
return
existing = col.find_one({"type": type_value, "name": name}, {"_id": 1})
if existing and not overwrite:
raise ValueError(f"name '{name}' already exists in collection '{col.name}'. Use overwrite=true to replace.")
if existing and overwrite:
# Delete existing meta + chunks
if type_value == "dataset_meta":
ds_id = existing["_id"]
col.delete_many({"dataset_id": ds_id})
elif type_value == "filtered_meta":
fd_id = existing["_id"]
col.delete_many({"filtered_id": fd_id})
col.delete_one({"_id": existing["_id"], "type": type_value})
def _df_to_chunks(df: pd.DataFrame, chunk_size: int) -> List[pd.DataFrame]:
if chunk_size <= 0:
chunk_size = DEFAULT_CHUNK_SIZE
n = len(df)
if n == 0:
return [df]
return [df.iloc[i:i + chunk_size].copy() for i in range(0, n, chunk_size)]
def save_dataset_to_mongo(
df: pd.DataFrame,
name: str,
*,
overwrite: bool = False,
chunk_size: int = DEFAULT_CHUNK_SIZE
) -> Dict[str, Any]:
if MONGO_DB is None:
raise RuntimeError("MongoDB not connected")
_enforce_unique_name(MONGO_DATASET, "dataset_meta", name, overwrite)
ds_id = gen_id("ds")
created_at = now_utc_iso()
df = df.copy()
df = coerce_numeric(ensure_outcome_labels(df))
chunks = _df_to_chunks(df, chunk_size)
for idx, c in enumerate(chunks):
MONGO_DATASET.insert_one({
"_id": f"{ds_id}:{idx}",
"type": "dataset_chunk",
"dataset_id": ds_id,
"chunk_index": idx,
"rows": int(len(c)),
"data": c.replace([np.inf, -np.inf], np.nan).where(pd.notnull(c), None).to_dict(orient="records"),
"created_at": created_at,
})
meta = {
"_id": ds_id,
"type": "dataset_meta",
"name": name,
"rows": int(len(df)),
"columns": list(map(str, df.columns)),
"chunks": len(chunks),
"created_at": created_at,
}
MONGO_DATASET.insert_one(meta)
return meta
def load_dataset_df_from_mongo(*, id: Optional[str] = None, name: Optional[str] = None) -> Tuple[pd.DataFrame, Dict[str, Any]]:
if MONGO_DB is None:
raise RuntimeError("MongoDB not connected")
if not id and not name:
raise ValueError("Provide id or name")
meta = MONGO_DATASET.find_one(
{"type": "dataset_meta", **({"_id": id} if id else {}), **({"name": name} if name else {})}
)
if not meta:
raise ValueError("Dataset not found")
chunks = list(MONGO_DATASET.find({"type": "dataset_chunk", "dataset_id": meta["_id"]}).sort([("chunk_index", ASCENDING)]))
rows = []
for ch in chunks:
rows.extend(ch.get("data", []))
df = pd.DataFrame(rows) if rows else pd.DataFrame(columns=meta.get("columns", []))
df = ensure_outcome_labels(df)
return df, meta
def preview_dataset_from_mongo(*, id: Optional[str] = None, name: Optional[str] = None, max_rows: int = 20) -> Dict[str, Any]:
df, meta = load_dataset_df_from_mongo(id=id, name=name)
return {
"dataset": {"_id": meta["_id"], "name": meta["name"]},
"rows": meta["rows"],
"preview": df_preview(df, max_rows=max_rows)
}
def delete_dataset_from_mongo(*, id: Optional[str] = None, name: Optional[str] = None) -> Dict[str, Any]:
if MONGO_DB is None:
raise RuntimeError("MongoDB not connected")
if not id and not name:
raise ValueError("Provide id or name")
meta = MONGO_DATASET.find_one({"type": "dataset_meta", **({"_id": id} if id else {}), **({"name": name} if name else {})})
if not meta:
raise ValueError("Dataset not found")
ds_id = meta["_id"]
ch_res = MONGO_DATASET.delete_many({"type": "dataset_chunk", "dataset_id": ds_id})
meta_res = MONGO_DATASET.delete_one({"_id": ds_id, "type": "dataset_meta"})
return {"deleted_docs": int(ch_res.deleted_count + meta_res.deleted_count), "dataset_id": ds_id}
def save_filtered_to_mongo(
df: pd.DataFrame,
name: str,
*,
outcome: str,
matched_rows: int,
overwrite: bool = False,
chunk_size: int = DEFAULT_CHUNK_SIZE
) -> Dict[str, Any]:
if MONGO_DB is None:
raise RuntimeError("MongoDB not connected")
_enforce_unique_name(MONGO_FILTERED, "filtered_meta", name, overwrite)
fd_id = gen_id("fd")
created_at = now_utc_iso()
df = df.copy()
df = coerce_numeric(ensure_outcome_labels(df))
chunks = _df_to_chunks(df, chunk_size)
for idx, c in enumerate(chunks):
MONGO_FILTERED.insert_one({
"_id": f"{fd_id}:{idx}",
"type": "filtered_chunk",
"filtered_id": fd_id,
"chunk_index": idx,
"rows": int(len(c)),
"data": c.replace([np.inf, -np.inf], np.nan).where(pd.notnull(c), None).to_dict(orient="records"),
"created_at": created_at,
})
meta = {
"_id": fd_id,
"type": "filtered_meta",
"name": name,
"outcome": outcome,
"rows": int(len(df)),
"matched_rows": int(matched_rows),
"columns": list(map(str, df.columns)),
"chunks": len(chunks),
"created_at": created_at,
}
MONGO_FILTERED.insert_one(meta)
return meta
def load_filtered_df_from_mongo(*, id: Optional[str] = None, name: Optional[str] = None) -> Tuple[pd.DataFrame, Dict[str, Any]]:
if MONGO_DB is None:
raise RuntimeError("MongoDB not connected")
if not id and not name:
raise ValueError("Provide id or name")
meta = MONGO_FILTERED.find_one({"type": "filtered_meta", **({"_id": id} if id else {}), **({"name": name} if name else {})})
if not meta:
raise ValueError("Filtered dataset not found")
chunks = list(MONGO_FILTERED.find({"type": "filtered_chunk", "filtered_id": meta["_id"]}).sort([("chunk_index", ASCENDING)]))
rows = []
for ch in chunks:
rows.extend(ch.get("data", []))
df = pd.DataFrame(rows) if rows else pd.DataFrame(columns=meta.get("columns", []))
df = ensure_outcome_labels(df)
return df, meta
def persist_df_to_mongo(
df: pd.DataFrame,
*,
name: Optional[str],
persist_to: str, # "dataset" | "filtered" (pipeline will always use "filtered")
overwrite: bool = False,
outcome: Optional[str] = None,
matched_rows: Optional[int] = None,
chunk_size: int = DEFAULT_CHUNK_SIZE
) -> Dict[str, Any]:
"""
Persists df to Mongo. For pipeline outputs, we always use persist_to='filtered'.
'dataset' path remains available for raw uploads or other flows outside the pipeline.
"""
if MONGO_DB is None:
raise RuntimeError("MongoDB not connected")
name = name or f"{persist_to}_{uuid.uuid4().hex[:8]}"
name = slugify(name)
if persist_to == "dataset":
meta = save_dataset_to_mongo(df, name, overwrite=overwrite, chunk_size=chunk_size)
result = {
"type": "dataset",
"id": meta["_id"],
"name": meta["name"],
"rows": meta["rows"],
"chunks": meta["chunks"],
"created_at": meta["created_at"]
}
elif persist_to == "filtered":
if outcome is None or matched_rows is None:
raise ValueError("For persist_to='filtered', provide outcome and matched_rows.")
meta = save_filtered_to_mongo(
df, name,
outcome=outcome,
matched_rows=matched_rows,
overwrite=overwrite,
chunk_size=chunk_size
)
result = {
"type": "filtered",
"id": meta["_id"],
"name": meta["name"],
"outcome": meta["outcome"],
"matched_rows": meta["matched_rows"],
"rows": meta["rows"],
"chunks": meta["chunks"],
"created_at": meta["created_at"]
}
else:
raise ValueError("persist_to must be 'dataset' or 'filtered'")
# ── Emit db.write event ───────────────────────────────────
try:
from triggers.event_bus import EventBus
from triggers.trigger_models import EventType, PlatformEvent
EventBus.get().emit_sync(PlatformEvent(
event_type=EventType.DB_WRITE,
source="persist_df_to_mongo",
payload={
"persist_to": persist_to,
"name": name,
"rows": result.get("rows"),
"id": result.get("id"),
},
))
except Exception:
pass # trigger failures must never block DB writes
return result
# ---------------- Rules helpers ----------------
def save_temp_rule_doc(
*,
nl_query: str,
outcome: str,
description: Optional[str],
condition: Optional[str] = None
) -> Dict[str, Any]:
if MONGO_DB is None:
raise RuntimeError("MongoDB not connected")
tmp_id = gen_id("tmp_rule")
now = now_utc_iso()
doc = {
"_id": tmp_id,
"type": "temp_rule",
"outcome": outcome,
"nl_query": nl_query,
"condition": condition, # may be None if compile skipped
"description": description,
"created_at": now,
"committed": False
}
MONGO_RULES.insert_one(doc)
return doc
def fetch_temp_rule(temp_rule_id: str) -> Optional[Dict[str, Any]]:
if MONGO_DB is None:
raise RuntimeError("MongoDB not connected")
return MONGO_RULES.find_one({"_id": temp_rule_id, "type": "temp_rule"})
def save_rule_doc(
*,
name: str,
outcome: str,
condition: Optional[str],
nl_query: Optional[str],
description: Optional[str],
status: str = "active",
overwrite: bool = False,
extra: Optional[Dict[str, Any]] = None # allow bundle fields
) -> Dict[str, Any]:
if MONGO_DB is None:
raise RuntimeError("MongoDB not connected")
existing = MONGO_RULES.find_one({"type": "rule", "name": name}, {"_id": 1})
if existing and not overwrite:
raise ValueError(f"Rule name '{name}' already exists. Use overwrite=true to replace.")
if existing and overwrite:
MONGO_RULES.delete_one({"_id": existing["_id"], "type": "rule"})
rule_id = gen_id("rule")
now = now_utc_iso()
doc = {
"_id": rule_id,
"type": "rule",
"name": name,
"outcome": outcome,
"condition": condition,
"nl_query": nl_query,
"description": description,
"status": status,
"counters": {
"run_count": 0,
"success_count": 0,
"zero_match_count": 0,
"failure_count": 0
},
"created_at": now,
"last_run_at": None
}
if extra:
doc.update(extra)
MONGO_RULES.insert_one(doc)
return doc
def fetch_rule(id: Optional[str] = None, name: Optional[str] = None) -> Optional[Dict[str, Any]]:
if MONGO_DB is None:
raise RuntimeError("MongoDB not connected")
if not id and not name:
raise ValueError("Provide id or name")
return MONGO_RULES.find_one(
{"type": "rule", **({"_id": id} if id else {}), **({"name": name} if name else {})}
)
# ---- Temp bundle + validation helpers ----
def save_temp_bundle_doc(
*,
name: Optional[str],
description: Optional[str],
rules: List[Dict[str, Any]],
file_info: Optional[Dict[str, Any]] = None,
validation: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
if MONGO_DB is None:
raise RuntimeError("MongoDB not connected")
tmp_id = gen_id("tmp_bundle")
now = now_utc_iso()
doc = {
"_id": tmp_id,
"type": "temp_bundle",
"name": name,
"description": description,
"rules": rules,
"bundle_count": int(len(rules)),
"file": file_info or {},
"validation": validation or {},
"created_at": now,
"committed": False
}
MONGO_RULES.insert_one(doc)
return doc
def fetch_temp_bundle(temp_bundle_id: str) -> Optional[Dict[str, Any]]:
if MONGO_DB is None:
raise RuntimeError("MongoDB not connected")
return MONGO_RULES.find_one({"_id": temp_bundle_id, "type": "temp_bundle"})
def compute_validation_for_rules(rules: List[Dict[str, Any]]) -> Dict[str, Any]:
"""
Static validation of rules without needing a dataset.
Criteria:
- outcome present
- has at least one of condition or nl_query
- duplicate names flagged
- nl_query compilation attempt (with empty df) tracked
- mild penalties for poor names (too long / unsafe)
Scoring (heuristic, 0-100):
- Start 100
- -10 for each missing outcome
- -15 for each missing both condition and nl_query
- -5 for each duplicate name occurrence (beyond first)
- -5 for each compile error (when nl_query provided)
- -2 if name > 64 chars
- -1 if name contains unsafe chars (differs from slugified)
"""
issues: List[str] = []
per_rule: List[Dict[str, Any]] = []
score = 100
# Duplicate name detection
names = [str(r.get("name") or "").strip() for r in rules]
name_counts: Dict[str, int] = {}
for n in names:
if n:
name_counts[n] = name_counts.get(n, 0) + 1
dup_names = {n: c for n, c in name_counts.items() if c > 1}
for n, c in dup_names.items():
occ_penalty = (c - 1) * 5
score -= occ_penalty
issues.append(f"Duplicate rule name '{n}' occurs {c} times (-{occ_penalty})")
empty_df = pd.DataFrame()
for idx, r in enumerate(rules):
nm = str(r.get("name") or f"rule_{idx+1}")
out = r.get("outcome")
cond = r.get("condition")
nlq = r.get("nl_query") or r.get("query")
has_outcome = bool(out and str(out).strip())
has_logic = bool((cond and str(cond).strip()) or (nlq and str(nlq).strip()))
pr = {
"index": idx,
"name": nm,
"has_outcome": has_outcome,
"has_condition": bool(cond and str(cond).strip()),
"has_nl_query": bool(nlq and str(nlq).strip()),
"compile_ok": None,
"compile_error": None
}
if not has_outcome:
score -= 10
pr["error"] = (pr.get("error") or "") + "missing outcome; "
issues.append(f"Rule[{idx}] '{nm}': missing outcome (-10)")
if not has_logic:
score -= 15
pr["error"] = (pr.get("error") or "") + "missing condition and nl_query; "
issues.append(f"Rule[{idx}] '{nm}': no condition or nl_query (-15)")
# Minor name checks
if len(nm) > 64:
score -= 2
issues.append(f"Rule[{idx}] '{nm[:24]}...': very long name (-2)")
safe_nm = slugify(nm)
if safe_nm != nm:
score -= 1
issues.append(f"Rule[{idx}] '{nm}': unsafe chars in name (-1); suggested '{safe_nm}'")
# Try compiling nl_query if present (non-fatal)
if nlq and str(nlq).strip():
try:
_cond, cap = capture_output(nl_to_condition, nlq, empty_df)
pr["compile_ok"] = True
except Exception as ce:
score -= 5
pr["compile_ok"] = False
pr["compile_error"] = str(ce)
issues.append(f"Rule[{idx}] '{nm}': nl_query compile error (-5): {ce}")
per_rule.append(pr)
score = max(0, min(100, score))
summary = {
"total_rules": len(rules),
"score": int(score),
"issues": issues,
"per_rule": per_rule,
"duplicates": dup_names
}
return summary
def inc_rule_stats(rule_id: str, status: str):
if MONGO_DB is None:
return
inc = {"counters.run_count": 1}
if status == "success":
inc["counters.success_count"] = 1
elif status == "zero_match":
inc["counters.zero_match_count"] = 1
elif status == "failure":
inc["counters.failure_count"] = 1
MONGO_RULES.update_one(
{"_id": rule_id, "type": "rule"},
{"$inc": inc, "$set": {"last_run_at": now_utc_iso()}}
)
def record_rule_run(
*,
rule_id: str,
rule_name: str,
status: str,
matched_rows: int,
total_rows: int,
data_source: str,
dataset_ref: Optional[Dict[str, Any]],
process_id: str,
extra_fields: Optional[Dict[str, Any]] = None
) -> str:
if MONGO_DB is None:
return ""
run_id = gen_id("run")
doc = {
"_id": run_id,
"type": "rule_run",
"rule_id": rule_id,
"rule_name": rule_name,
"status": status, # success | zero_match | failure
"matched_rows": int(matched_rows),
"total_rows": int(total_rows),
"data_source": data_source,
"dataset_ref": dataset_ref or {},
"ts": now_utc_iso(),
"process_id": process_id
}
if extra_fields:
doc.update(extra_fields)
MONGO_RULES.insert_one(doc)
# ── Emit rule.executed event ───────────────────────────────────────
try:
from triggers.event_bus import EventBus
from triggers.trigger_models import EventType, PlatformEvent
EventBus.get().emit_sync(PlatformEvent(
event_type=EventType.RULE_EXECUTED,
source="record_rule_run",
correlation_id=process_id,
payload={
"rule_id": rule_id,
"rule_name": rule_name,
"status": status,
"matched_rows": matched_rows,
"total_rows": total_rows,
"data_source": data_source,
"run_id": run_id,
},
))
except Exception:
pass # trigger failures must never block rule execution
return run_id
# ---------------- Data source loader for execution ----------------
_DB_CONNECTOR_BASE = "https://api.prediqai.com/db-connector"
def _fetch_from_db_connector(instance_id: str, collection_name: str) -> List[Dict[str, Any]]:
"""
Fetch ALL records from a DB Connector collection (unlimited=true).
Returns a list of row dicts ready to be loaded into a DataFrame.
Raises RuntimeError on any HTTP / parsing failure.
"""
url = f"{_DB_CONNECTOR_BASE}/instances/{instance_id}/resources/{collection_name}/data"
payload = {"unlimited": True}
try:
resp = requests.post(url, json=payload, timeout=120)
resp.raise_for_status()
except requests.exceptions.HTTPError as exc:
raise RuntimeError(
f"DB Connector returned HTTP {exc.response.status_code} for "
f"{instance_id}/{collection_name}: {exc.response.text[:300]}"
) from exc
except Exception as exc:
raise RuntimeError(
f"DB Connector request failed for {instance_id}/{collection_name}: {exc}"
) from exc
body = resp.json()
if not body.get("success"):
raise RuntimeError(
f"DB Connector error for {instance_id}/{collection_name}: {body}"
)
return body.get("data") or []
def get_df_from_source(
*,
data_source: str,
data_name: Optional[str],
data_file: Optional[Any],
normalize_cols: bool = False
) -> Tuple[pd.DataFrame, str, Optional[Dict[str, Any]]]:
"""
Returns (df, source_descriptor, dataset_ref_dict)
dataset_ref_dict can be {"dataset_id": "...", "dataset_name": "..."} or {"filtered_id": "...", "filtered_name": "..."}
"""
src = data_source.lower().strip()
if src == "upload":
if data_file is None:
raise ValueError("data file required for data_source=upload")
df = load_uploaded_to_df(data_file)
if normalize_cols:
df = normalize_dataframe(df)
df = coerce_numeric(ensure_outcome_labels(df))
return df, f"upload:{getattr(data_file, 'filename', 'upload')}", None
if src == "dataset":
if not data_name:
raise ValueError("data_name (dataset id or name) required for data_source=dataset")
df, meta = load_dataset_df_from_mongo(id=data_name if data_name.startswith("ds_") else None,
name=None if data_name.startswith("ds_") else data_name)
if normalize_cols:
df = normalize_dataframe(df)
df = coerce_numeric(ensure_outcome_labels(df))
return df, f"dataset:{meta['name']}", {"dataset_id": meta["_id"], "dataset_name": meta["name"]}
if src == "filtered":
if not data_name:
raise ValueError("data_name (filtered id or name) required for data_source=filtered")
df, meta = load_filtered_df_from_mongo(id=data_name if data_name.startswith("fd_") else None,
name=None if data_name.startswith("fd_") else data_name)
if normalize_cols:
df = normalize_dataframe(df)
df = coerce_numeric(ensure_outcome_labels(df))
return df, f"filtered:{meta['name']}", {"filtered_id": meta["_id"], "filtered_name": meta["name"]}
if src == "db_connector":
if not data_name:
raise ValueError(
"data_name must be '<instance_id>/<collection_name>' "
"for data_source=db_connector"
)
parts = data_name.strip().split("/", 1)
if len(parts) != 2 or not parts[0].strip() or not parts[1].strip():
raise ValueError(
"data_name must be '<instance_id>/<collection_name>' "
f"(e.g. '904342c0-6c7c-48f3-a6f2-b86fe5dcd6b3/movies'), got: {data_name!r}"
)
instance_id, collection_name = parts[0].strip(), parts[1].strip()
records = _fetch_from_db_connector(instance_id, collection_name)
df = pd.DataFrame(records) if records else pd.DataFrame()
if normalize_cols:
df = normalize_dataframe(df)
df = coerce_numeric(ensure_outcome_labels(df))
return (
df,
f"db_connector:{instance_id}/{collection_name}",
{"instance_id": instance_id, "collection": collection_name},
)
# Memory source intentionally disabled (avoid RAM usage)
raise ValueError("data_source must be one of: upload|dataset|filtered|db_connector")
# ============ Workflow Execution Helpers ============
def record_in_memory_file(stored_name: str, df: pd.DataFrame, *, process_id: Optional[str], action: str):
"""
Store DataFrame in memory temporarily with metadata.
Used ONLY for intermediate workflow results.
Memory is cleared after workflow completes and saves to MongoDB.
"""
STORED_DFS[stored_name] = df.copy()
STORED_META[stored_name] = {
"name": stored_name,
"rows": len(df),
"columns": list(df.columns),
"process_id": process_id,
"action": action,
"stored_at": now_utc_iso(),
"storage": "memory_temporary"
}
def save_workflow_execution_to_mongo(
*,
workflow_id: str,
workflow_name: str,
process_id: str,
source: str,
dataset_ref: Optional[Dict[str, Any]],
sequence: List[Dict[str, Any]],
execution_results: List[Dict[str, Any]],
initial_rows: int,
final_rows: int,
final_df: pd.DataFrame,
intermediate_results: Dict[str, Dict[str, Any]],
stored_final_as: Optional[str] = None,
status: str = "success",
chunk_size: int = DEFAULT_CHUNK_SIZE
) -> Dict[str, Any]:
"""
Save complete workflow execution to MongoDB workflow_execute collection.
Automatically clears in-memory data after saving.
Returns metadata about saved execution.
"""
if MONGO_DB is None:
raise RuntimeError("MongoDB not connected")
exec_id = gen_id("wf_exec")
created_at = now_utc_iso()
# Save final result chunks if available
final_chunks_saved = 0
if final_df is not None and len(final_df) > 0:
final_df = coerce_numeric(ensure_outcome_labels(final_df))
chunks = _df_to_chunks(final_df, chunk_size)
for idx, chunk_df in enumerate(chunks):
MONGO_WORKFLOW_EXECUTE.insert_one({
"_id": f"{exec_id}:final:{idx}",
"type": "workflow_result_chunk",
"execution_id": exec_id,
"workflow_id": workflow_id,
"result_type": "final",
"chunk_index": idx,
"rows": len(chunk_df),
"data": chunk_df.replace([np.inf, -np.inf], np.nan).where(pd.notnull(chunk_df), None).to_dict(orient="records"),
"created_at": created_at
})
final_chunks_saved += 1
# Save intermediate results from memory
intermediate_chunks_saved = {}
for store_key, meta in intermediate_results.items():
if store_key in STORED_DFS:
inter_df = STORED_DFS[store_key]
inter_df = coerce_numeric(ensure_outcome_labels(inter_df))
chunks = _df_to_chunks(inter_df, chunk_size)
chunks_count = 0
for idx, chunk_df in enumerate(chunks):
MONGO_WORKFLOW_EXECUTE.insert_one({
"_id": f"{exec_id}:inter:{slugify(store_key)}:{idx}",
"type": "workflow_result_chunk",
"execution_id": exec_id,
"workflow_id": workflow_id,
"result_type": "intermediate",
"result_name": store_key,
"step": meta.get("step"),
"operation_type": meta.get("type"),
"operation_name": meta.get("name"),
"chunk_index": idx,
"rows": len(chunk_df),
"data": chunk_df.replace([np.inf, -np.inf], np.nan).where(pd.notnull(chunk_df), None).to_dict(orient="records"),
"created_at": created_at
})
chunks_count += 1
intermediate_chunks_saved[store_key] = {
"chunks": chunks_count,
"rows": len(inter_df),
"columns": list(inter_df.columns),
**meta
}
# Create execution metadata document
exec_meta = {
"_id": exec_id,
"type": "workflow_execution",
"workflow_id": workflow_id,
"workflow_name": workflow_name,
"process_id": process_id,
"status": status,
"source": source,
"dataset_ref": dataset_ref or {},
"sequence": sequence,
"execution_results": execution_results,
"initial_rows": initial_rows,
"final_rows": final_rows,
"final_chunks": final_chunks_saved,
"final_columns": list(final_df.columns) if final_df is not None else [],
"stored_final_as": stored_final_as,
"intermediate_results": intermediate_chunks_saved,
"successful_steps": len([r for r in execution_results if r.get("status") in ["success", "skipped"]]),
"failed_steps": len([r for r in execution_results if r.get("status") == "error"]),
"total_steps": len(sequence),
"created_at": created_at
}
MONGO_WORKFLOW_EXECUTE.insert_one(exec_meta)
# IMPORTANT: Clear in-memory data after saving to MongoDB to prevent RAM buildup
for store_key in list(intermediate_results.keys()):
STORED_DFS.pop(store_key, None)
STORED_META.pop(store_key, None)
result = {
"execution_id": exec_id,
"final_chunks": final_chunks_saved,
"intermediate_results_saved": len(intermediate_chunks_saved),
"created_at": created_at,
"memory_cleared": True
}
# ── Emit workflow.completed event ──────────────────────────────────
try:
from triggers.event_bus import EventBus
from triggers.trigger_models import EventType, PlatformEvent
EventBus.get().emit_sync(PlatformEvent(
event_type=EventType.WORKFLOW_COMPLETED,
source="save_workflow_execution_to_mongo",
correlation_id=process_id,
payload={
"workflow_id": workflow_id,
"workflow_name": workflow_name,
"status": status,
"initial_rows": initial_rows,
"final_rows": final_rows,
"execution_id": exec_id,
},
))
except Exception:
pass # trigger failures must never block workflow execution
return result
def load_workflow_execution_from_mongo(execution_id: str) -> Dict[str, Any]:
"""
Load workflow execution metadata and data from MongoDB.
"""
if MONGO_DB is None:
raise RuntimeError("MongoDB not connected")
meta = MONGO_WORKFLOW_EXECUTE.find_one({"_id": execution_id, "type": "workflow_execution"})
if not meta:
raise ValueError(f"Workflow execution '{execution_id}' not found")
# Load final result
final_chunks = list(MONGO_WORKFLOW_EXECUTE.find(
{"type": "workflow_result_chunk", "execution_id": execution_id, "result_type": "final"}
).sort([("chunk_index", ASCENDING)]))
final_rows = []
for chunk in final_chunks:
final_rows.extend(chunk.get("data", []))
final_df = None
if final_rows:
final_df = pd.DataFrame(final_rows)
final_df = ensure_outcome_labels(final_df)
# Load intermediate results
intermediate_dfs = {}
for result_name, result_meta in meta.get("intermediate_results", {}).items():
inter_chunks = list(MONGO_WORKFLOW_EXECUTE.find(
{"type": "workflow_result_chunk", "execution_id": execution_id, "result_type": "intermediate", "result_name": result_name}
).sort([("chunk_index", ASCENDING)]))
inter_rows = []
for chunk in inter_chunks:
inter_rows.extend(chunk.get("data", []))
if inter_rows:
inter_df = pd.DataFrame(inter_rows)
inter_df = ensure_outcome_labels(inter_df)
intermediate_dfs[result_name] = inter_df
return {
"metadata": meta,
"final_df": final_df,
"intermediate_dfs": intermediate_dfs
}
def list_workflow_executions(
workflow_id: Optional[str] = None,
workflow_name: Optional[str] = None,
status: Optional[str] = None,
limit: int = 50,
skip: int = 0
) -> List[Dict[str, Any]]:
"""
List workflow executions with optional filters.
"""
if MONGO_DB is None:
return []
query = {"type": "workflow_execution"}
if workflow_id:
query["workflow_id"] = workflow_id
if workflow_name:
query["workflow_name"] = workflow_name
if status:
query["status"] = status
cursor = MONGO_WORKFLOW_EXECUTE.find(query).sort([("created_at", DESCENDING)]).skip(skip).limit(limit)
return list(cursor)
# =========================
# Dev utilities, NL pipeline (memoryless and filtered-only persistence)
# =========================
def get_df_from_input(
data: Optional[Any],
source_name: Optional[str],
*,
normalize_cols: bool = False,
ensure_labels: bool = True,
coerce_nums: bool = True
) -> Tuple[pd.DataFrame, str]:
"""
Memoryless helper for dev: only accepts an uploaded file.
Returns a DataFrame and a source descriptor ("upload:<name>").
"""
if source_name:
# Disable in-memory source usage
raise ValueError("In-memory sources are disabled. Use get_df_from_source with data_source=dataset|filtered.")
if data is None:
raise ValueError("Provide a 'data' file upload.")
df = load_uploaded_to_df(data)
src = f"upload:{getattr(data, 'filename', 'upload')}"
if normalize_cols:
df = normalize_dataframe(df)
if coerce_nums:
df = coerce_numeric(df)
if ensure_labels:
df = ensure_outcome_labels(df)
return df, src
def resolve_tables_spec(
spec: Optional[Dict[str, Any]],
current_df: pd.DataFrame
) -> Dict[str, pd.DataFrame]:
"""
Build a dict of named DataFrames for NL ops like concat/join without using in-memory tables.
Pass values as:
- a DataFrame (already loaded), or
- "dataset:<nameOrId>", or
- "filtered:<nameOrId>"
"""
tbls: Dict[str, pd.DataFrame] = {"current": current_df}
if not spec:
return tbls
for name, val in spec.items():
if isinstance(val, pd.DataFrame):
tbls[name] = ensure_outcome_labels(val.copy())
continue
if isinstance(val, str):
v = val.strip()
if v.startswith("dataset:"):
key = v.split(":", 1)[1].strip()
df, _ = load_dataset_df_from_mongo(
id=key if key.startswith("ds_") else None,
name=None if key.startswith("ds_") else key
)
tbls[name] = ensure_outcome_labels(df)
continue
if v.startswith("filtered:"):
key = v.split(":", 1)[1].strip()
df, _ = load_filtered_df_from_mongo(
id=key if key.startswith("fd_") else None,
name=None if key.startswith("fd_") else key
)
tbls[name] = ensure_outcome_labels(df)
continue
raise ValueError(f"Unsupported table spec for '{name}': {val!r}. Use 'dataset:<nameOrId>' or 'filtered:<nameOrId>' or pass a DataFrame.")
return tbls
def run_nl_pipeline(
df: pd.DataFrame,
nl_query: str,
*,
outcome: Optional[str] = None,
filter_matched: bool = True, # store matched subset by default for filter
save_name: Optional[str] = None, # replaces legacy store_as
store_as: Optional[str] = None, # kept for backward-compat name fallback
overwrite: bool = False,
chunk_size: int = DEFAULT_CHUNK_SIZE,
tables: Optional[Dict[str, Any]] = None, # may include "dataset:<nameOrId>" or "filtered:<nameOrId>"
process_id: Optional[str] = None,
drop_outcome_labels: bool = False # if True, remove Outcome_Labels column from saved data and preview
) -> Dict[str, Any]:
"""
Memoryless NL pipeline that ALWAYS persists DataFrame outputs to the 'filtered' collection.
- Non-filter ops: outcome = provided outcome OR 'Derived:<action>', matched_rows = len(result_df)
- Filter ops: matched_rows computed from the mask; outcome uses provided value or 'Derived:filter'
- Aggregations (non-DataFrame) are returned but not persisted automatically.
"""
logs: List[str] = []
result_name = save_name or store_as # prefer save_name if provided
# Ensure df shape
df = coerce_numeric(ensure_outcome_labels(df))
# Resolve any external tables by loading from Mongo
tbls = resolve_tables_spec(tables, df)
def save_filtered_result(out_df: pd.DataFrame, action: str) -> Dict[str, Any]:
# Use caller-provided outcome if any; otherwise tag with the action
out_label = outcome or f"Derived:{action}"
matched = len(out_df)
# Drop Outcome_Labels column if requested (for /nl/universal API)
df_to_save = out_df.copy()
if drop_outcome_labels and "Outcome_Labels" in df_to_save.columns:
df_to_save = df_to_save.drop(columns=["Outcome_Labels"])
return persist_df_to_mongo(
df_to_save,
name=result_name or f"{action}_{uuid.uuid4().hex[:6]}",
persist_to="filtered",
overwrite=overwrite,
outcome=out_label,
matched_rows=matched,
chunk_size=chunk_size
)
# 1) Lambda application
res, cap = capture_output(parse_lambda_apply, nl_query, df)
if res is not None:
out_df = ensure_outcome_labels(res)
logs.append("lambda apply:\n" + cap.strip())
action = "lambda_apply"
saved = save_filtered_result(out_df, action)
# Drop Outcome_Labels from preview if requested
preview_df = out_df.drop(columns=["Outcome_Labels"]) if drop_outcome_labels and "Outcome_Labels" in out_df.columns else out_df
return {
"action": action,
"preview": df_preview(preview_df),
"rows": len(out_df),
"logs": "\n".join([l for l in logs if l]),
"saved": saved
}
# 2) Create new column
res, cap = capture_output(parse_create_column, nl_query, df)
if res is not None:
out_df = ensure_outcome_labels(res)
logs.append("create column:\n" + cap.strip())
action = "create_column"
saved = save_filtered_result(out_df, action)
# Drop Outcome_Labels from preview if requested
preview_df = out_df.drop(columns=["Outcome_Labels"]) if drop_outcome_labels and "Outcome_Labels" in out_df.columns else out_df
return {
"action": action,
"preview": df_preview(preview_df),
"rows": len(out_df),
"logs": "\n".join([l for l in logs if l]),
"saved": saved
}
# 3) Concat/stack any two tables
res, cap = capture_output(parse_concat_tables, nl_query, tbls)
if res is not None:
out_df = ensure_outcome_labels(res)
logs.append("concat/stack:\n" + cap.strip())
action = "concat_tables"
saved = save_filtered_result(out_df, action)
# Drop Outcome_Labels from preview if requested
preview_df = out_df.drop(columns=["Outcome_Labels"]) if drop_outcome_labels and "Outcome_Labels" in out_df.columns else out_df
return {
"action": action,
"preview": df_preview(preview_df),
"rows": len(out_df),
"logs": "\n".join([l for l in logs if l]),
"saved": saved
}
# 4) Rename/delete column
res, cap = capture_output(parse_column_ops, nl_query, df)
if res is not None:
out_df = ensure_outcome_labels(res)
logs.append("column ops:\n" + cap.strip())
action = "column_ops"
saved = save_filtered_result(out_df, action)
# Drop Outcome_Labels from preview if requested
preview_df = out_df.drop(columns=["Outcome_Labels"]) if drop_outcome_labels and "Outcome_Labels" in out_df.columns else out_df
return {
"action": action,
"preview": df_preview(preview_df),
"rows": len(out_df),
"logs": "\n".join([l for l in logs if l]),
"saved": saved
}
# 5) Transform column values
res, cap = capture_output(parse_transform_column, nl_query, df)
if res is not None:
out_df = ensure_outcome_labels(res)
logs.append("transform column:\n" + cap.strip())
action = "transform_column"
saved = save_filtered_result(out_df, action)
# Drop Outcome_Labels from preview if requested
preview_df = out_df.drop(columns=["Outcome_Labels"]) if drop_outcome_labels and "Outcome_Labels" in out_df.columns else out_df
return {
"action": action,
"preview": df_preview(preview_df),
"rows": len(out_df),
"logs": "\n".join([l for l in logs if l]),
"saved": saved
}
# 6) LLM-Based Query Classification and Routing
classification, cap = capture_output(classify_query_intent, nl_query, df)
logs.append(f"Query Classification:\n{cap.strip()}")
logs.append(f" Intent: {classification.get('intent')}")
logs.append(f" Confidence: {classification.get('confidence', 0):.2f}")
logs.append(f" Reasoning: {classification.get('reasoning', 'N/A')}")
intent = classification.get("intent", "filter")
# Route based on LLM classification
if intent == "aggregate":
# Generate and execute aggregation code
agg_code, cap = capture_output(generate_aggregate_code, nl_query, df)
logs.append(f"Aggregation code generated:\n{agg_code}\n{cap.strip()}")
try:
result = eval(agg_code)
logs.append(f"Aggregation result:\n{result}")
return {
"action": "aggregate",
"code": agg_code,
"result": to_json_safe(result),
"logs": "\n".join([l for l in logs if l]),
"classification": classification
}
except Exception as e:
logs.append(f"❌ Aggregation execution failed: {e}")
return {
"action": "aggregate",
"code": agg_code,
"error": str(e),
"logs": "\n".join([l for l in logs if l]),
"classification": classification
}
elif intent == "transform":
# Generate and execute transformation code
transform_code, cap = capture_output(generate_transform_code, nl_query, df)
logs.append(f"Transform code generated:\n{transform_code}\n{cap.strip()}")
try:
out_df = eval(transform_code)
out_df = ensure_outcome_labels(out_df)
action = "transform"
saved = save_filtered_result(out_df, action)
# Drop Outcome_Labels from preview if requested
preview_df = out_df.drop(columns=["Outcome_Labels"]) if drop_outcome_labels and "Outcome_Labels" in out_df.columns else out_df
return {
"action": action,
"code": transform_code,
"preview": df_preview(preview_df),
"rows": len(out_df),
"logs": "\n".join([l for l in logs if l]),
"saved": saved,
"classification": classification
}
except Exception as e:
logs.append(f"❌ Transform execution failed: {e}")
return {
"action": "transform",
"code": transform_code,
"error": str(e),
"logs": "\n".join([l for l in logs if l]),
"classification": classification
}
elif intent == "join":
# Use existing join logic
op_result, cap = capture_output(parse_nl_operation, nl_query, df, tbls)
logs.append(cap.strip())
if isinstance(op_result, pd.DataFrame):
out_df = ensure_outcome_labels(op_result)
action = "join"
saved = save_filtered_result(out_df, action)
# Drop Outcome_Labels from preview if requested
preview_df = out_df.drop(columns=["Outcome_Labels"]) if drop_outcome_labels and "Outcome_Labels" in out_df.columns else out_df
return {
"action": action,
"preview": df_preview(preview_df),
"rows": len(out_df),
"logs": "\n".join([l for l in logs if l]),
"saved": saved,
"classification": classification
}
# 7) Default: treat as filter (intent == "filter" or "create_column" or fallback)
if not outcome:
# If the user didn't provide an outcome, tag it generically as "Derived:filter"
derived_outcome = "Derived:filter"
else:
derived_outcome = outcome
condition, cap = capture_output(nl_to_condition, nl_query, df)
logs.append("nl_to_condition:\n" + cap.strip())
out_df, cap = capture_output(safe_apply, df, condition, derived_outcome)
logs.append(cap.strip())
# Determine matched subset
mask = out_df["Outcome_Labels"].apply(lambda x: derived_outcome in x)
matched_rows = int(mask.sum())
df_to_save = out_df[mask].reset_index(drop=True) if filter_matched else out_df
# Drop Outcome_Labels column if requested (for /nl/universal API)
if drop_outcome_labels and "Outcome_Labels" in df_to_save.columns:
df_to_save = df_to_save.drop(columns=["Outcome_Labels"])
action = "filter"
saved = persist_df_to_mongo(
df_to_save,
name=result_name or f"filtered_{slugify(derived_outcome)}_{uuid.uuid4().hex[:6]}",
persist_to="filtered",
overwrite=overwrite,
outcome=derived_outcome,
matched_rows=matched_rows,
chunk_size=chunk_size
)
return {
"action": action,
"condition": condition,
"preview": df_preview(df_to_save),
"rows": len(df_to_save),
"matched_rows": matched_rows,
"logs": "\n".join([l for l in logs if l]),
"saved": saved,
"classification": classification # Include LLM classification info
}
# --------- General helpers used by APIs ---------
def _parse_rules_text(text: str) -> List[Dict[str, Any]]:
try:
payload = json.loads(text)
if isinstance(payload, dict) and isinstance(payload.get("rules"), list):
return payload["rules"]
if isinstance(payload, list):
return payload
except Exception:
pass
try:
rows = list(csv.DictReader(io.StringIO(text)))
if rows:
return rows
except Exception:
pass
return []