Spaces:
Sleeping
Sleeping
| """ | |
| Intent parser — regex + keyword + fuzzy column resolution. | |
| No LLM anywhere. Covers Student + Business tier operations: | |
| increase, decrease, filter, sort_asc, sort_desc, | |
| sum, average, count, min, max, | |
| find_replace, delete_column, rename_column, add_column, | |
| remove_duplicates, cast_type | |
| """ | |
| from __future__ import annotations | |
| import re | |
| from typing import Optional | |
| from rapidfuzz import process as rf_process, fuzz as rf_fuzz | |
| from core.column_registry import column_registry | |
| # ── Operation keyword table ───────────────────────────────────────── | |
| # Longer, more specific keywords score higher so "badhao" beats "bada" | |
| # when both appear in a command. | |
| OPERATION_KEYWORDS: dict[str, list[str]] = { | |
| "increase": [ | |
| "badhao", "badha do", "increase", "barha do", "barhao", | |
| "zyada karo", "bada karo", "grow", "raise", "badha dijiye", | |
| ], | |
| "decrease": [ | |
| "ghatao", "ghata do", "decrease", "kam karo", "kam kar do", | |
| "chhota karo", "reduce", "cut", "minus karo", "ghata dijiye", | |
| ], | |
| "filter": [ | |
| "sirf", "only", "filter", "dikhao", "show only", "show me", | |
| "bas", "wale dikhao", "where", "jitne", "laao", | |
| ], | |
| "sort_asc": [ | |
| "chota se bada", "ascending", "a to z", "low to high", | |
| "smallest first", "ascending order", "a-z", "asc", | |
| ], | |
| "sort_desc": [ | |
| "bada se chota", "descending", "z to a", "high to low", | |
| "largest first", "descending order", "z-a", "desc", | |
| "bade se chhote", | |
| ], | |
| "sum": [ | |
| "sum", "total", "jod", "yog", "add up", "total batao", | |
| "kul", "jama", | |
| ], | |
| "average": [ | |
| "average", "avg", "mean", "samanya", "average nikalo", | |
| ], | |
| "count": [ | |
| "count", "ginti", "kitne", "kitni rows", "count karo", | |
| "kitni", "rows kitne", "total rows", | |
| ], | |
| "min": [ | |
| "minimum", "min", "sabse chhota", "lowest", "kam se kam", | |
| ], | |
| "max": [ | |
| "maximum", "max", "sabse bada", "highest", "zyada se zyada", | |
| ], | |
| "find_replace": [ | |
| "replace", "badlo", "change", "find", "dhundho", | |
| "substitute", "replace karo", "change karo", "naye se badlo", | |
| ], | |
| "delete_column": [ | |
| "delete column", "column hatao", "column delete karo", | |
| "remove column", "column remove karo", "column hatado", | |
| "column drop karo", | |
| ], | |
| "rename_column": [ | |
| "rename column", "column ka naam badlo", "column rename karo", | |
| "name change karo", "naam badlo", "rename karo", | |
| ], | |
| "add_column": [ | |
| "add column", "naya column banao", "column add karo", | |
| "new column", "column create karo", | |
| ], | |
| "remove_duplicates": [ | |
| "duplicate hatao", "duplicates remove karo", "unique rakho", | |
| "duplicate remove", "repeat hatao", | |
| ], | |
| "cast_type": [ | |
| "type badlo", "data type change", "convert type", | |
| "type convert karo", "numeric banao", "string banao", | |
| ], | |
| } | |
| # ── Compiled regexes ─────────────────────────────────────────────── | |
| PERCENT_RE = re.compile(r"(\d+\.?\d*)\s*%", re.IGNORECASE) | |
| NUMBER_RE = re.compile(r"(\d+\.?\d*)") | |
| # Comparison operators (symbols) | |
| _CMP_SYMBOLS = re.compile(r"([><=!]+)\s*([\d.]+|[^\s]+)") | |
| HINDI_CMP_MAP: dict[str, str] = { | |
| "se zyada": ">", | |
| "se kam": "<", | |
| "ke barabar": "==", | |
| "se zyada ya barabar": ">=", | |
| "se kam ya barabar": "<=", | |
| "se bada": ">", | |
| "se chhota": "<", | |
| "ke equal": "==", | |
| "ke hi": "==", | |
| } | |
| # Words to strip when extracting filter values | |
| _STOP_WORDS = [ | |
| "sirf", "only", "filter", "dikhao", "show", "show only", | |
| "bas", "wale", "laao", "bhai", "ko", "ka", "ke", "ki", | |
| "mein", "hai", "hain", "karo", "karein", "sort", | |
| "bada", "chhota", "se", "nikalo", "batao", | |
| ] | |
| # ══════════════════════════════════════════════════════════════════════ | |
| # Public API | |
| # ══════════════════════════════════════════════════════════════════════ | |
| def parse_intent(session_id: str, command: str) -> Optional[dict]: | |
| """Parse a natural-language command into a structured intent dict. | |
| Returns ``None`` when nothing can be resolved (the caller should | |
| return an *unresolved* response with column suggestions). | |
| """ | |
| columns = column_registry.get_columns(session_id) | |
| if not columns: | |
| return None | |
| operation = _match_operation(command) | |
| if operation is None: | |
| return None | |
| # ── Operations that need special parsing ────────────────────── | |
| if operation == "remove_duplicates": | |
| col = _best_column(session_id, command, columns) | |
| return {"operation": "remove_duplicates", "column": col} | |
| if operation == "delete_column": | |
| col = _best_column(session_id, command, columns) | |
| if col is None: | |
| return None | |
| return {"operation": "delete_column", "column": col} | |
| if operation == "rename_column": | |
| return _parse_rename(session_id, command, columns) | |
| if operation == "find_replace": | |
| return _parse_find_replace(session_id, command, columns) | |
| if operation == "filter": | |
| return _parse_filter(session_id, command, columns) | |
| if operation == "cast_type": | |
| return _parse_cast(session_id, command, columns) | |
| # ── Standard: operation + column + optional value ──────────── | |
| col = _best_column(session_id, command, columns) | |
| if col is None: | |
| return None | |
| value = _parse_value(command) | |
| return {"operation": operation, "column": col, "value": value} | |
| # ══════════════════════════════════════════════════════════════════════ | |
| # Internal helpers | |
| # ══════════════════════════════════════════════════════════════════════ | |
| def _match_operation(command: str) -> Optional[str]: | |
| """Pick the operation with the highest keyword-match score.""" | |
| cmd = command.lower() | |
| scores: dict[str, int] = {} | |
| for op, keywords in OPERATION_KEYWORDS.items(): | |
| for kw in keywords: | |
| if kw in cmd: | |
| # Weight by keyword length so specific phrases beat short ones | |
| scores[op] = scores.get(op, 0) + len(kw) | |
| if not scores: | |
| return None | |
| return max(scores, key=scores.get) # type: ignore[arg-type] | |
| # Flat set of all operation keywords — used to skip them during column resolution | |
| _ALL_OP_KEYWORDS: set[str] = set() | |
| for _kws in OPERATION_KEYWORDS.values(): | |
| _ALL_OP_KEYWORDS.update(_kws) | |
| _ALL_OP_KEYWORDS.update(_STOP_WORDS) | |
| _ALL_OP_KEYWORDS.update(["ko", "ka", "ke", "ki", "karo", "nikalo", "batao", | |
| "hai", "hain", "mein", "se", "do", "dijiye"]) | |
| def _best_column(session_id: str, command: str, columns: list[str]) -> Optional[str]: | |
| """Fuzzy-resolve the best column from the command text. | |
| 1. Try column_registry (O(1) alias + cached fuzzy). | |
| 2. Fall back to direct rapidfuzz scan. | |
| Skips tokens that are known operation keywords (e.g. "average" won't | |
| false-match to column "Age"). | |
| """ | |
| tokens = command.split() | |
| # Filter out keyword tokens and pure-number tokens | |
| clean_tokens = [ | |
| t for t in tokens | |
| if not re.fullmatch(r"[\d.]+%?", t) | |
| and t.lower() not in _ALL_OP_KEYWORDS | |
| ] | |
| candidates = clean_tokens + [ | |
| " ".join(clean_tokens[i : i + 2]) for i in range(len(clean_tokens) - 1) | |
| ] | |
| # 1. Registry first (O(1) alias + cached fuzzy) | |
| for token in candidates: | |
| resolved = column_registry.resolve(session_id, token) | |
| if resolved: | |
| return resolved | |
| # 2. Direct rapidfuzz scan as fallback | |
| best_match, best_score = None, 0 | |
| for cand in candidates: | |
| hit = rf_process.extractOne(cand, columns, scorer=rf_fuzz.WRatio) | |
| if hit and hit[1] > best_score: | |
| best_match, best_score = hit[0], hit[1] | |
| return best_match if best_score >= 78 else None | |
| def _parse_value(command: str) -> Optional[float]: | |
| """Extract a numeric value. Percentage wins over absolute.""" | |
| m = PERCENT_RE.search(command) | |
| if m: | |
| return float(m.group(1)) | |
| m = NUMBER_RE.search(command) | |
| if m: | |
| return float(m.group(1)) | |
| return None | |
| def _parse_filter(session_id: str, command: str, columns: list[str]) -> Optional[dict]: | |
| """Resolve a filter command into {column, condition, filter_value}.""" | |
| col = _best_column(session_id, command, columns) | |
| if col is None: | |
| return None | |
| cmd_lower = command.lower() | |
| # 1. Symbol comparison: salary > 50000 | |
| sym = _CMP_SYMBOLS.search(cmd_lower) | |
| if sym and col.lower() in cmd_lower: | |
| op_str, val_str = sym.group(1), sym.group(2) | |
| try: | |
| fval: str | float = float(val_str) | |
| except ValueError: | |
| fval = val_str.strip("'\"") | |
| return { | |
| "operation": "filter", | |
| "column": col, | |
| "condition": op_str, | |
| "filter_value": fval, | |
| } | |
| # 2. Hindi comparison: salary 50000 se zyada | |
| for hindi_op, symbol in HINDI_CMP_MAP.items(): | |
| if hindi_op in cmd_lower: | |
| num_match = re.search( | |
| r"(\d+\.?\d*)\s+" + re.escape(hindi_op), cmd_lower | |
| ) | |
| if num_match: | |
| return { | |
| "operation": "filter", | |
| "column": col, | |
| "condition": symbol, | |
| "filter_value": float(num_match.group(1)), | |
| } | |
| # 3. Equality by presence: "city Mumbai dikhao" → city == Mumbai | |
| stripped = cmd_lower | |
| for kw in _STOP_WORDS: | |
| stripped = stripped.replace(kw, "") | |
| stripped = stripped.replace(col.lower(), "", 1).strip() | |
| if stripped: | |
| stripped = re.sub(r"^[><=!]+\s*", "", stripped).strip() | |
| return { | |
| "operation": "filter", | |
| "column": col, | |
| "condition": "==", | |
| "filter_value": stripped, | |
| } | |
| return {"operation": "filter", "column": col, "condition": None, "filter_value": None} | |
| def _parse_find_replace(session_id: str, command: str, columns: list[str]) -> Optional[dict]: | |
| """Extract old_value and new_value for find & replace.""" | |
| col = _best_column(session_id, command, columns) | |
| if col is None: | |
| return None | |
| # Try quoted values first | |
| quoted = re.findall(r"""['"]([^'"]+)['"]""", command) | |
| if len(quoted) >= 2: | |
| return { | |
| "operation": "find_replace", | |
| "column": col, | |
| "old_value": quoted[0], | |
| "new_value": quoted[1], | |
| } | |
| # Try "X ko Y se badlo" / "replace X with Y" / "X ko Y replace karo" | |
| m = re.search( | |
| r"(\S+)\s+ko\s+(\S+)\s+(?:se\s+)?badlo" | |
| r"|replace\s+(\S+)\s+with\s+(\S+)" | |
| r"|(\S+)\s+ko\s+(\S+)\s+replace", | |
| command, | |
| re.IGNORECASE, | |
| ) | |
| if m: | |
| groups = [g for g in m.groups() if g is not None] | |
| if len(groups) >= 2: | |
| return { | |
| "operation": "find_replace", | |
| "column": col, | |
| "old_value": groups[0], | |
| "new_value": groups[1], | |
| } | |
| return None | |
| def _parse_rename(session_id: str, command: str, columns: list[str]) -> Optional[dict]: | |
| """Extract current column and desired new name.""" | |
| col = _best_column(session_id, command, columns) | |
| if col is None: | |
| return None | |
| m = re.search( | |
| r"(?:naam|name)\s+(?:ko\s+)?(\S+)\s+(?:se\s+)?badlo" | |
| r"|rename\s+\S+\s+to\s+(\S+)", | |
| command, | |
| re.IGNORECASE, | |
| ) | |
| if m: | |
| new_name = m.group(1) or m.group(2) | |
| if new_name: | |
| return { | |
| "operation": "rename_column", | |
| "column": col, | |
| "new_name": new_name.strip("'\" "), | |
| } | |
| return None | |
| def _parse_cast(session_id: str, command: str, columns: list[str]) -> Optional[dict]: | |
| """Extract column and target type for type casting.""" | |
| col = _best_column(session_id, command, columns) | |
| if col is None: | |
| return None | |
| cmd_lower = command.lower() | |
| target_dtype: str | None = None | |
| if "int" in cmd_lower or "numeric" in cmd_lower or "number" in cmd_lower: | |
| target_dtype = "Int64" | |
| elif "float" in cmd_lower or "decimal" in cmd_lower: | |
| target_dtype = "Float64" | |
| elif "str" in cmd_lower or "string" in cmd_lower or "text" in cmd_lower: | |
| target_dtype = "String" | |
| elif "bool" in cmd_lower: | |
| target_dtype = "Boolean" | |
| elif "date" in cmd_lower or "datetime" in cmd_lower: | |
| target_dtype = "Date" | |
| if target_dtype: | |
| return {"operation": "cast_type", "column": col, "target_dtype": target_dtype} | |
| return None |