File size: 13,317 Bytes
b336134
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
"""
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