Dockerfile / core /intent_parser.py
kkthakur's picture
Deploy Local Hybrid Engine
b336134
Raw
History Blame Contribute Delete
13.3 kB
"""
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